Compare commits
5 Commits
61d41900cc
...
b52926f7d8
| Author | SHA1 | Date | |
|---|---|---|---|
| b52926f7d8 | |||
| d338e1c35e | |||
| 9589680a81 | |||
| 27f1357fc0 | |||
| 9509a9705e |
@@ -76,6 +76,7 @@ tauri.ts / electron main
|
|||||||
- Multiple runtimes: `isTauri`, `isElectron`, `isCapacitorNative` branch logic
|
- Multiple runtimes: `isTauri`, `isElectron`, `isCapacitorNative` branch logic
|
||||||
- Pusher registration races on fast login/logout
|
- Pusher registration races on fast login/logout
|
||||||
- Android small icon: `ic_stat_paarrot` must exist in Android resources
|
- Android small icon: `ic_stat_paarrot` must exist in Android resources
|
||||||
|
- Background wake (`MatrixSyncService`) must filter by push rules / unread_notifications; a push for one room used to notify for every new message in the sync batch
|
||||||
|
|
||||||
## Future work
|
## Future work
|
||||||
|
|
||||||
@@ -91,3 +92,4 @@ tauri.ts / electron main
|
|||||||
|
|
||||||
- Constants: `PUSHER_APP_ID_BASE`, `PUSHER_STORAGE_PREFIX` in `backgroundSync.ts`
|
- Constants: `PUSHER_APP_ID_BASE`, `PUSHER_STORAGE_PREFIX` in `backgroundSync.ts`
|
||||||
- Logo assets: `paarrot.svg`, `paarrot-unread.svg`, `paarrot-highlight.svg` in `public/res/svg/`
|
- Logo assets: `paarrot.svg`, `paarrot-unread.svg`, `paarrot-highlight.svg` in `public/res/svg/`
|
||||||
|
- Android notify filter: mute / mentions / default-room behavior lives in `MatrixSyncService.resolveRoomNotifyMode`
|
||||||
|
|||||||
@@ -1,16 +1,53 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
const decodeSegment = (segment: string): string => {
|
||||||
|
let decoded = segment;
|
||||||
|
try {
|
||||||
|
let next = decodeURIComponent(decoded);
|
||||||
|
while (next !== decoded) {
|
||||||
|
decoded = next;
|
||||||
|
next = decodeURIComponent(decoded);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep partially decoded value
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper for Outlet that adds route-based animation
|
* Room routes are `:roomIdOrAlias/:eventId?/`. Jumping to an event (or clearing it)
|
||||||
* Forces remount on route change by using location as key
|
* changes the pathname but not the room — keep the outlet mounted so we don't replay
|
||||||
|
* the route enter animation or remount drawers like Shared Media.
|
||||||
|
*
|
||||||
|
* Path segments are decoded so `!room:server` and `%21room%3Aserver` share a key.
|
||||||
|
*/
|
||||||
|
const getOutletTransitionKey = (pathname: string): string => {
|
||||||
|
const segments = pathname.split('/').filter(Boolean).map(decodeSegment);
|
||||||
|
if (segments.length === 0) return pathname;
|
||||||
|
|
||||||
|
// Matrix event IDs start with `$`
|
||||||
|
if (segments[segments.length - 1].startsWith('$')) {
|
||||||
|
segments.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/${segments.join('/')}/`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for Outlet that adds route-based animation.
|
||||||
|
* Remounts (and animates) when leaving a room / switching rooms, not on same-room event hops.
|
||||||
*/
|
*/
|
||||||
export function AnimatedOutlet() {
|
export function AnimatedOutlet() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const transitionKey = useMemo(
|
||||||
|
() => getOutletTransitionKey(location.pathname),
|
||||||
|
[location.pathname]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={location.pathname}
|
key={transitionKey}
|
||||||
data-route-transition="true"
|
data-route-transition="true"
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import React, {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Box, Scroll, Text } from 'folds';
|
import { Box, Scroll, Text } from 'folds';
|
||||||
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
|
import { Descendant, Editor, createEditor, Element as SlateElement, Text as SlateText } from 'slate';
|
||||||
import {
|
import {
|
||||||
Slate,
|
Slate,
|
||||||
Editable,
|
Editable,
|
||||||
@@ -172,48 +170,6 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
[editor, onKeyDown]
|
[editor, onKeyDown]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBeforeInput = useCallback(
|
|
||||||
(event: Event) => {
|
|
||||||
const inputEvent = event as InputEvent;
|
|
||||||
|
|
||||||
// Handle autocorrect replacement that causes text duplication
|
|
||||||
if (inputEvent.inputType === 'insertReplacementText' ||
|
|
||||||
inputEvent.inputType === 'insertFromComposition') {
|
|
||||||
const { selection } = editor;
|
|
||||||
if (!selection) return;
|
|
||||||
|
|
||||||
// Get the data being inserted
|
|
||||||
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
// If there's selected text, delete it first
|
|
||||||
if (selection && !Range.isCollapsed(selection)) {
|
|
||||||
Transforms.delete(editor, { at: selection });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert the replacement text
|
|
||||||
editor.insertText(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[editor]
|
|
||||||
);
|
|
||||||
|
|
||||||
const editableRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const editableElement = editableRef.current?.querySelector('[data-slate-editor="true"]');
|
|
||||||
if (!editableElement) return;
|
|
||||||
|
|
||||||
editableElement.addEventListener('beforeinput', handleBeforeInput, { capture: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
editableElement.removeEventListener('beforeinput', handleBeforeInput, { capture: true });
|
|
||||||
};
|
|
||||||
}, [handleBeforeInput]);
|
|
||||||
|
|
||||||
const renderPlaceholder = useCallback(
|
const renderPlaceholder = useCallback(
|
||||||
({ attributes, children }: RenderPlaceholderProps) => (
|
({ attributes, children }: RenderPlaceholderProps) => (
|
||||||
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
||||||
@@ -256,10 +212,9 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
className={css.EditorTextareaScroll}
|
className={css.EditorTextareaScroll}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
style={scrollStyle}
|
style={scrollStyle}
|
||||||
size="300"
|
size="0"
|
||||||
visibility="Hover"
|
visibility="Hover"
|
||||||
hideTrack
|
hideTrack
|
||||||
ref={editableRef}
|
|
||||||
>
|
>
|
||||||
<Editable
|
<Editable
|
||||||
data-editable-name={editableName}
|
data-editable-name={editableName}
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ export const Reaction = style([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const ReactionText = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: toRem(150),
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
lineHeight: toRem(20),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
export const ReactionStack = style([
|
export const ReactionStack = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
@@ -66,7 +77,7 @@ export const ReactionStack = style([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ReactionText = style([
|
export const ReactionSticker = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
@@ -77,11 +88,6 @@ export const ReactionText = style([
|
|||||||
lineHeight: toRem(20),
|
lineHeight: toRem(20),
|
||||||
gridArea: '1 / 1',
|
gridArea: '1 / 1',
|
||||||
transformOrigin: 'center center',
|
transformOrigin: 'center center',
|
||||||
// Fan each stacked copy so count>1 reads as multiple stickers
|
|
||||||
transform: `translate(
|
|
||||||
calc(var(--sticker-i, 0) * 7px - 2px),
|
|
||||||
calc(var(--sticker-i, 0) * -5px)
|
|
||||||
) rotate(calc((var(--sticker-i, 0) - 1) * 12deg))`,
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import * as css from './Reaction.css';
|
|||||||
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
|
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
|
||||||
import { getMemberDisplayName } from '../../utils/room';
|
import { getMemberDisplayName } from '../../utils/room';
|
||||||
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
|
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
const MAX_STICKER_STACK = 4;
|
const MAX_STICKER_STACK = 4;
|
||||||
|
|
||||||
@@ -18,13 +19,48 @@ export const Reaction = as<
|
|||||||
useAuthentication?: boolean;
|
useAuthentication?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => {
|
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => {
|
||||||
const stackCount = Math.min(Math.max(count, 1), MAX_STICKER_STACK);
|
const theme = useTheme();
|
||||||
const showCount = count > 2;
|
const stationery = isStationeryTheme(theme);
|
||||||
const isCustomEmoji = reaction.startsWith('mxc://');
|
const isCustomEmoji = reaction.startsWith('mxc://');
|
||||||
const customSrc = isCustomEmoji
|
const customSrc = isCustomEmoji
|
||||||
? mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
|
? mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const emoji = isCustomEmoji ? (
|
||||||
|
<img className={css.ReactionImg} src={customSrc} alt={reaction} />
|
||||||
|
) : (
|
||||||
|
<Text as="span" size="Inherit" truncate>
|
||||||
|
{reaction}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stationery: fanned sticker stack. Everywhere else: compact emoji + count.
|
||||||
|
if (!stationery) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="button"
|
||||||
|
className={classNames(css.Reaction, className)}
|
||||||
|
alignItems="Center"
|
||||||
|
shrink="No"
|
||||||
|
gap="200"
|
||||||
|
data-reaction=""
|
||||||
|
aria-label={`${reaction}, ${count}`}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Text className={css.ReactionText} as="span" size="T400">
|
||||||
|
{emoji}
|
||||||
|
</Text>
|
||||||
|
<Text as="span" size="T300" data-reaction-count="">
|
||||||
|
{count}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stackCount = Math.min(Math.max(count, 1), MAX_STICKER_STACK);
|
||||||
|
const showCount = count > 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
as="button"
|
as="button"
|
||||||
@@ -40,12 +76,11 @@ export const Reaction = as<
|
|||||||
>
|
>
|
||||||
<span className={css.ReactionStack} data-reaction-stack-layer="">
|
<span className={css.ReactionStack} data-reaction-stack-layer="">
|
||||||
{Array.from({ length: stackCount }, (_, i) => {
|
{Array.from({ length: stackCount }, (_, i) => {
|
||||||
// Draw back-to-front so the top sticker is the last layer
|
|
||||||
const layer = stackCount - 1 - i;
|
const layer = stackCount - 1 - i;
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
key={layer}
|
key={layer}
|
||||||
className={css.ReactionText}
|
className={css.ReactionSticker}
|
||||||
as="span"
|
as="span"
|
||||||
size="T400"
|
size="T400"
|
||||||
data-reaction-sticker=""
|
data-reaction-sticker=""
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ export const MessageTextBody = recipe({
|
|||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
overflowY: 'visible',
|
overflowY: 'visible',
|
||||||
|
paddingBottom: config.space.S200,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emote: {
|
emote: {
|
||||||
|
|||||||
@@ -1,95 +1,149 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { keyframes, style } from '@vanilla-extract/css';
|
||||||
import { color, config, toRem } from 'folds';
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
export const CheckButtonContainer = style({
|
const indeterminate = keyframes({
|
||||||
|
'0%': { transform: 'translateX(-100%)' },
|
||||||
|
'100%': { transform: 'translateX(250%)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const IdleSlot = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
height: '32px',
|
height: '100%',
|
||||||
width: '32px',
|
WebkitAppRegion: 'no-drag',
|
||||||
|
flexShrink: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CheckButton = style({
|
export const GhostCheck = style({
|
||||||
all: 'unset',
|
all: 'unset',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: 0,
|
width: toRem(28),
|
||||||
|
height: toRem(28),
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
cursor: 'pointer',
|
color: color.Surface.OnContainer,
|
||||||
color: color.Secondary.Main,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
transition: 'opacity 0.2s, background-color 0.15s',
|
|
||||||
height: '32px',
|
|
||||||
width: '32px',
|
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
WebkitAppRegion: 'no-drag',
|
cursor: 'pointer',
|
||||||
flexShrink: 0,
|
transition: 'opacity 0.15s ease, background-color 0.15s ease',
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'&[data-visible="true"]': {
|
'&[data-visible="true"]': {
|
||||||
opacity: 0.7,
|
opacity: 0.65,
|
||||||
},
|
},
|
||||||
},
|
'&:hover': {
|
||||||
|
opacity: 1,
|
||||||
':hover': {
|
|
||||||
backgroundColor: color.Surface.ContainerHover,
|
backgroundColor: color.Surface.ContainerHover,
|
||||||
opacity: 1,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
':active': {
|
|
||||||
backgroundColor: color.Surface.ContainerActive,
|
|
||||||
},
|
|
||||||
|
|
||||||
':focus-visible': {
|
|
||||||
outline: `2px solid ${color.Secondary.Main}`,
|
|
||||||
outlineOffset: '2px',
|
|
||||||
opacity: 1,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateButton = style({
|
export const Bar = style({
|
||||||
all: 'unset',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
gap: config.space.S200,
|
||||||
padding: 0,
|
height: toRem(22),
|
||||||
|
minWidth: toRem(120),
|
||||||
|
maxWidth: toRem(200),
|
||||||
|
padding: `0 ${config.space.S200}`,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
cursor: 'pointer',
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
color: color.Success.Main,
|
color: color.SurfaceVariant.OnContainer,
|
||||||
backgroundColor: 'transparent',
|
|
||||||
transition: 'background-color 0.15s',
|
|
||||||
height: '32px',
|
|
||||||
width: '32px',
|
|
||||||
WebkitAppRegion: 'no-drag',
|
WebkitAppRegion: 'no-drag',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
});
|
||||||
|
|
||||||
':hover': {
|
export const BarTrack = style({
|
||||||
backgroundColor: color.Surface.ContainerHover,
|
position: 'relative',
|
||||||
|
flex: 1,
|
||||||
|
height: toRem(4),
|
||||||
|
borderRadius: toRem(2),
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: color.Surface.ContainerLine,
|
||||||
|
minWidth: toRem(64),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BarFill = style({
|
||||||
|
position: 'absolute',
|
||||||
|
inset: '0 auto 0 0',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
backgroundColor: color.Success.Main,
|
||||||
|
transition: 'width 120ms linear',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BarIndeterminate = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '40%',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
backgroundColor: color.Secondary.Main,
|
||||||
|
animation: `${indeterminate} 1s ease-in-out infinite`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BarLabel = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
opacity: 0.9,
|
||||||
|
minWidth: toRem(28),
|
||||||
|
textAlign: 'right',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Chip = style({
|
||||||
|
height: toRem(22),
|
||||||
|
maxWidth: toRem(260),
|
||||||
|
padding: `0 ${config.space.S100} 0 ${config.space.S200}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
backgroundColor: color.Success.Container,
|
||||||
|
color: color.Success.OnContainer,
|
||||||
|
WebkitAppRegion: 'no-drag',
|
||||||
|
flexShrink: 0,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ChipText = style({
|
||||||
|
maxWidth: toRem(140),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ChipAction = style({
|
||||||
|
all: 'unset',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: toRem(18),
|
||||||
|
padding: `0 ${config.space.S200}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
backgroundColor: color.Success.Main,
|
||||||
|
color: color.Success.OnMain,
|
||||||
|
fontSize: toRem(11),
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
filter: 'brightness(1.05)',
|
||||||
},
|
},
|
||||||
|
|
||||||
':active': {
|
|
||||||
backgroundColor: color.Surface.ContainerActive,
|
|
||||||
},
|
|
||||||
|
|
||||||
':focus-visible': {
|
|
||||||
outline: `2px solid ${color.Success.Main}`,
|
|
||||||
outlineOffset: '2px',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateMenu = style({
|
export const ChipDismiss = style({
|
||||||
minWidth: toRem(280),
|
all: 'unset',
|
||||||
maxWidth: toRem(320),
|
display: 'inline-flex',
|
||||||
backgroundColor: color.Surface.Container,
|
alignItems: 'center',
|
||||||
borderRadius: config.radii.R400,
|
justifyContent: 'center',
|
||||||
boxShadow: config.shadow.E400,
|
width: toRem(18),
|
||||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
height: toRem(18),
|
||||||
});
|
borderRadius: config.radii.R300,
|
||||||
|
cursor: 'pointer',
|
||||||
export const ProgressText = style({
|
opacity: 0.7,
|
||||||
color: color.Success.Main,
|
fontSize: toRem(14),
|
||||||
fontWeight: 500,
|
lineHeight: 1,
|
||||||
minWidth: toRem(35),
|
selectors: {
|
||||||
textAlign: 'center',
|
'&:hover': {
|
||||||
|
opacity: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.08)',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,138 +1,136 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { Box, Spinner, Text, Menu, PopOut, Button, config } from 'folds';
|
import { Box, Text } from 'folds';
|
||||||
import * as css from './UpdateNotification.css';
|
import * as css from './UpdateNotification.css';
|
||||||
|
|
||||||
interface UpdateInfo {
|
interface UpdateInfo {
|
||||||
version: string;
|
version: string;
|
||||||
releaseNotes?: string;
|
releaseNotes?: string;
|
||||||
releaseDate?: string;
|
releaseDate?: string;
|
||||||
|
mock?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DownloadProgress {
|
interface DownloadProgress {
|
||||||
percent: number;
|
percent: number;
|
||||||
transferred: number;
|
transferred: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
mock?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdaterPhase = 'idle' | 'checking' | 'available' | 'downloading' | 'ready';
|
||||||
|
|
||||||
export function UpdateNotification() {
|
export function UpdateNotification() {
|
||||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
const [phase, setPhase] = useState<UpdaterPhase>('idle');
|
||||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||||
const [downloading, setDownloading] = useState(false);
|
|
||||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||||
const [updateReady, setUpdateReady] = useState(false);
|
const [isMock, setIsMock] = useState(false);
|
||||||
const [checking, setChecking] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number; width: number; height: number } | undefined>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if we're in Electron environment
|
const electron = (window as any).electron;
|
||||||
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
|
if (!electron?.updater) return undefined;
|
||||||
return;
|
|
||||||
|
const { updater } = electron;
|
||||||
|
|
||||||
|
updater.isMock?.().then((result: { success?: boolean; data?: { mock?: boolean } }) => {
|
||||||
|
if (result?.data?.mock) setIsMock(true);
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
updater.onUpdateAvailable((info: UpdateInfo) => {
|
||||||
|
setUpdateInfo(info);
|
||||||
|
if (info.mock) setIsMock(true);
|
||||||
|
setPhase('available');
|
||||||
|
setDownloadProgress(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
updater.onUpdateDownloadProgress((progress: DownloadProgress) => {
|
||||||
|
setPhase('downloading');
|
||||||
|
setDownloadProgress(Math.min(100, Math.round(progress.percent)));
|
||||||
|
});
|
||||||
|
|
||||||
|
updater.onUpdateDownloaded((info: UpdateInfo) => {
|
||||||
|
setUpdateInfo(info);
|
||||||
|
setPhase('ready');
|
||||||
|
setDownloadProgress(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onNotAvailable = () => {
|
||||||
|
setPhase('idle');
|
||||||
|
setUpdateInfo(null);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
};
|
||||||
|
// Optional channel — ignore if preload doesn't expose a dedicated listener
|
||||||
|
if (electron.updater.onUpdateNotAvailable) {
|
||||||
|
electron.updater.onUpdateNotAvailable(onNotAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { updater } = (window as any).electron;
|
return undefined;
|
||||||
|
|
||||||
// Listen for update available
|
|
||||||
updater.onUpdateAvailable((info: UpdateInfo) => {
|
|
||||||
console.log('Update available:', info.version);
|
|
||||||
setUpdateAvailable(true);
|
|
||||||
setUpdateInfo(info);
|
|
||||||
setDownloading(false);
|
|
||||||
setUpdateReady(false);
|
|
||||||
setChecking(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for download progress
|
|
||||||
updater.onUpdateDownloadProgress((progress: DownloadProgress) => {
|
|
||||||
setDownloadProgress(Math.round(progress.percent));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for update downloaded
|
|
||||||
updater.onUpdateDownloaded((info: UpdateInfo) => {
|
|
||||||
console.log('Update downloaded:', info.version);
|
|
||||||
setDownloading(false);
|
|
||||||
setUpdateReady(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup - IPC listeners don't need manual cleanup in this case
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCheckForUpdates = async () => {
|
const handleCheck = useCallback(async () => {
|
||||||
setChecking(true);
|
setPhase('checking');
|
||||||
try {
|
try {
|
||||||
const result = await (window as any).electron.updater.checkForUpdates();
|
const result = await (window as any).electron.updater.checkForUpdates();
|
||||||
console.log('Update check result:', result);
|
if (!result?.success) {
|
||||||
|
setPhase('idle');
|
||||||
// Handle error response (including dev mode error)
|
|
||||||
if (!result.success) {
|
|
||||||
console.warn('Update check failed:', result.error);
|
|
||||||
setChecking(false);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
// available event will advance phase; if nothing comes, fall back
|
||||||
// If no update found, show feedback briefly
|
window.setTimeout(() => {
|
||||||
setTimeout(() => {
|
setPhase((current) => (current === 'checking' ? 'idle' : current));
|
||||||
if (!updateAvailable) {
|
}, 4000);
|
||||||
setChecking(false);
|
} catch {
|
||||||
|
setPhase('idle');
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, []);
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to check for updates:', error);
|
|
||||||
setChecking(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = useCallback(async () => {
|
||||||
setDownloading(true);
|
setPhase('downloading');
|
||||||
setDownloadProgress(0);
|
setDownloadProgress(0);
|
||||||
try {
|
try {
|
||||||
await (window as any).electron.updater.downloadUpdate();
|
await (window as any).electron.updater.downloadUpdate();
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Failed to download update:', error);
|
setPhase('available');
|
||||||
setDownloading(false);
|
|
||||||
}
|
}
|
||||||
setMenuAnchor(undefined);
|
}, []);
|
||||||
};
|
|
||||||
|
|
||||||
const handleInstall = async () => {
|
const handleInstall = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await (window as any).electron.updater.installUpdate();
|
await (window as any).electron.updater.installUpdate();
|
||||||
} catch (error) {
|
if (isMock) {
|
||||||
console.error('Failed to install update:', error);
|
setPhase('idle');
|
||||||
|
setUpdateInfo(null);
|
||||||
|
setDownloadProgress(0);
|
||||||
}
|
}
|
||||||
};
|
} catch {
|
||||||
|
// keep ready state
|
||||||
const handleMenuToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
if (menuAnchor) {
|
|
||||||
setMenuAnchor(undefined);
|
|
||||||
} else {
|
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
|
||||||
setMenuAnchor({ x: rect.x, y: rect.y, width: rect.width, height: rect.height });
|
|
||||||
}
|
}
|
||||||
};
|
}, [isMock]);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
setPhase('idle');
|
||||||
|
setUpdateInfo(null);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Don't render anything if not in Electron
|
|
||||||
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
|
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show check button if no update status
|
if (phase === 'idle') {
|
||||||
if (!updateAvailable && !updateReady && !checking) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css.CheckButtonContainer}
|
className={css.IdleSlot}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className={css.CheckButton}
|
|
||||||
data-visible={isHovered}
|
|
||||||
onClick={handleCheckForUpdates}
|
|
||||||
aria-label="Check for updates"
|
|
||||||
title="Check for updates"
|
|
||||||
type="button"
|
type="button"
|
||||||
|
className={css.GhostCheck}
|
||||||
|
data-visible={hovered || isMock ? 'true' : undefined}
|
||||||
|
onClick={handleCheck}
|
||||||
|
title={isMock ? 'Check for updates (mock)' : 'Check for updates'}
|
||||||
|
aria-label="Check for updates"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||||
<path
|
<path
|
||||||
d="M8 2V10M8 10L5 7M8 10L11 7"
|
d="M8 2V10M8 10L5 7M8 10L11 7"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -140,109 +138,75 @@ export function UpdateNotification() {
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
<path
|
<path d="M3 14H13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
d="M3 14H13"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show checking state
|
if (phase === 'checking') {
|
||||||
if (checking) {
|
|
||||||
return (
|
return (
|
||||||
<button className={css.UpdateButton} disabled type="button">
|
<div className={css.Bar} title="Checking for updates…">
|
||||||
<Spinner variant="Secondary" size="50" />
|
<div className={css.BarTrack}>
|
||||||
</button>
|
<div className={css.BarIndeterminate} />
|
||||||
|
</div>
|
||||||
|
<Text className={css.BarLabel} size="L400">
|
||||||
|
Checking…
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show update available/ready state
|
if (phase === 'downloading') {
|
||||||
if (!updateAvailable && !updateReady) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
<button
|
className={css.Bar}
|
||||||
className={css.UpdateButton}
|
title={`Downloading update${updateInfo ? ` ${updateInfo.version}` : ''}… ${downloadProgress}%`}
|
||||||
onClick={handleMenuToggle}
|
|
||||||
aria-label={updateReady ? 'Update ready' : 'Update available'}
|
|
||||||
title={updateReady ? 'Update downloaded - click to install' : 'New version available'}
|
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
{downloading ? (
|
<div className={css.BarTrack}>
|
||||||
<Spinner variant="Secondary" size="50" />
|
<div className={css.BarFill} style={{ width: `${downloadProgress}%` }} />
|
||||||
) : (
|
</div>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
<Text className={css.BarLabel} size="L400">
|
||||||
<path
|
{downloadProgress}%
|
||||||
d="M8 2V10M8 10L5 7M8 10L11 7"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M3 14H13"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<PopOut
|
|
||||||
anchor={menuAnchor}
|
|
||||||
position="Bottom"
|
|
||||||
align="End"
|
|
||||||
offset={8}
|
|
||||||
content={
|
|
||||||
<Menu className={css.UpdateMenu}>
|
|
||||||
<Box direction="Column" gap="200" style={{ padding: config.space.S300 }}>
|
|
||||||
<Box direction="Column" gap="100">
|
|
||||||
<Text size="H5" priority="400">
|
|
||||||
{updateReady ? 'Update Ready' : 'Update Available'}
|
|
||||||
</Text>
|
</Text>
|
||||||
{updateInfo && (
|
</div>
|
||||||
<Text size="T200" priority="300">
|
);
|
||||||
Version {updateInfo.version}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{updateReady ? (
|
|
||||||
<Button
|
|
||||||
variant="Primary"
|
|
||||||
size="400"
|
|
||||||
onClick={handleInstall}
|
|
||||||
fill="Solid"
|
|
||||||
>
|
|
||||||
<Text size="B400">Install and Restart</Text>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="Primary"
|
|
||||||
size="400"
|
|
||||||
onClick={handleDownload}
|
|
||||||
disabled={downloading}
|
|
||||||
fill="Solid"
|
|
||||||
>
|
|
||||||
<Text size="B400">
|
|
||||||
{downloading ? `Downloading ${downloadProgress}%` : 'Download Update'}
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Menu>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (phase === 'ready') {
|
||||||
|
return (
|
||||||
|
<Box className={css.Chip} alignItems="Center" gap="100">
|
||||||
|
<Text className={css.ChipText} size="L400" truncate>
|
||||||
|
{isMock ? 'Mock ready' : 'Update ready'}
|
||||||
|
{updateInfo ? ` · ${updateInfo.version}` : ''}
|
||||||
|
</Text>
|
||||||
|
<button type="button" className={css.ChipAction} onClick={handleInstall}>
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// available
|
||||||
|
return (
|
||||||
|
<Box className={css.Chip} alignItems="Center" gap="100">
|
||||||
|
<Text className={css.ChipText} size="L400" truncate>
|
||||||
|
{isMock ? 'Mock update' : 'Update'}
|
||||||
|
{updateInfo ? ` ${updateInfo.version}` : ''}
|
||||||
|
</Text>
|
||||||
|
<button type="button" className={css.ChipAction} onClick={handleDownload}>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.ChipDismiss}
|
||||||
|
onClick={handleDismiss}
|
||||||
|
aria-label="Dismiss update"
|
||||||
|
title="Dismiss"
|
||||||
>
|
>
|
||||||
{null}
|
×
|
||||||
</PopOut>
|
</button>
|
||||||
</>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
|||||||
import { ScrollTopContainer } from '../../components/scroll-top-container';
|
import { ScrollTopContainer } from '../../components/scroll-top-container';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
|
import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
|
||||||
import { useRooms } from '../../state/hooks/roomList';
|
import { useRooms, useDirects } from '../../state/hooks/roomList';
|
||||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
|
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
|
||||||
@@ -53,7 +53,13 @@ export function MessageSearch({
|
|||||||
}: MessageSearchProps) {
|
}: MessageSearchProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
const allRooms = useRooms(mx, allRoomsAtom, mDirects);
|
const nonDirectRooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||||
|
const directRooms = useDirects(mx, allRoomsAtom, mDirects);
|
||||||
|
// Include DMs — previously useRooms alone stripped them and broke DM search
|
||||||
|
const allRooms = useMemo(
|
||||||
|
() => [...nonDirectRooms, ...directRooms],
|
||||||
|
[nonDirectRooms, directRooms]
|
||||||
|
);
|
||||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
ISearchRequestBody,
|
ISearchRequestBody,
|
||||||
ISearchResponse,
|
ISearchResponse,
|
||||||
ISearchResult,
|
ISearchResult,
|
||||||
|
MatrixClient,
|
||||||
|
MatrixEvent,
|
||||||
|
Room,
|
||||||
SearchOrderBy,
|
SearchOrderBy,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
@@ -26,6 +29,12 @@ export type SearchResult = {
|
|||||||
groups: ResultGroup[];
|
groups: ResultGroup[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EMPTY_CONTEXT: IResultContext = {
|
||||||
|
events_before: [],
|
||||||
|
events_after: [],
|
||||||
|
profile_info: {},
|
||||||
|
};
|
||||||
|
|
||||||
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||||
const groups: ResultGroup[] = [];
|
const groups: ResultGroup[] = [];
|
||||||
|
|
||||||
@@ -54,13 +63,108 @@ const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
|||||||
const parseSearchResult = (result: ISearchResponse): SearchResult => {
|
const parseSearchResult = (result: ISearchResponse): SearchResult => {
|
||||||
const roomEvents = result.search_categories.room_events;
|
const roomEvents = result.search_categories.room_events;
|
||||||
|
|
||||||
const searchResult: SearchResult = {
|
return {
|
||||||
nextToken: roomEvents?.next_batch,
|
nextToken: roomEvents?.next_batch,
|
||||||
highlights: roomEvents?.highlights ?? [],
|
highlights: roomEvents?.highlights ?? [],
|
||||||
groups: groupSearchResult(roomEvents?.results ?? []),
|
groups: groupSearchResult(roomEvents?.results ?? []),
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return searchResult;
|
const eventToSearchEvent = (event: MatrixEvent, roomId: string): IEventWithRoomId | undefined => {
|
||||||
|
const eventId = event.getId();
|
||||||
|
if (!eventId) return undefined;
|
||||||
|
|
||||||
|
const content = event.getClearContent() ?? event.getContent();
|
||||||
|
return {
|
||||||
|
event_id: eventId,
|
||||||
|
type: event.getWireType() === 'm.room.encrypted' ? 'm.room.message' : event.getType(),
|
||||||
|
sender: event.getSender() ?? '',
|
||||||
|
origin_server_ts: event.getTs(),
|
||||||
|
content,
|
||||||
|
room_id: roomId,
|
||||||
|
unsigned: event.getUnsigned(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSearchableBody = (event: MatrixEvent): string | undefined => {
|
||||||
|
if (event.isRedacted()) return undefined;
|
||||||
|
|
||||||
|
// After decryption, clear content is available even if wire type was encrypted
|
||||||
|
const content = event.getClearContent() ?? event.getContent();
|
||||||
|
if (!content || typeof content !== 'object') return undefined;
|
||||||
|
|
||||||
|
// Skip still-encrypted payloads
|
||||||
|
if (event.isEncrypted() && !event.isDecryptionFailure() && !event.getClearContent()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgType = content.msgtype;
|
||||||
|
if (msgType && msgType !== 'm.text' && msgType !== 'm.notice' && msgType !== 'm.emote') {
|
||||||
|
// Still allow filename / body on media
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = typeof content.body === 'string' ? content.body : undefined;
|
||||||
|
const formatted =
|
||||||
|
typeof content.formatted_body === 'string' ? content.formatted_body : undefined;
|
||||||
|
return [body, formatted].filter(Boolean).join('\n') || undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const highlightsFromTerm = (term: string): string[] =>
|
||||||
|
term
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => part.length > 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search decrypted timeline events already loaded for a room.
|
||||||
|
* Server-side search cannot match E2EE message bodies.
|
||||||
|
*/
|
||||||
|
const searchLocalRoomTimeline = (room: Room, term: string): ResultItem[] => {
|
||||||
|
const needle = term.trim().toLowerCase();
|
||||||
|
if (!needle) return [];
|
||||||
|
|
||||||
|
const events = room.getLiveTimeline().getEvents();
|
||||||
|
const matches: ResultItem[] = [];
|
||||||
|
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const event = events[i];
|
||||||
|
const haystack = getSearchableBody(event);
|
||||||
|
if (!haystack || !haystack.toLowerCase().includes(needle)) continue;
|
||||||
|
|
||||||
|
const searchEvent = eventToSearchEvent(event, room.roomId);
|
||||||
|
if (!searchEvent) continue;
|
||||||
|
|
||||||
|
matches.push({
|
||||||
|
rank: 1,
|
||||||
|
event: searchEvent,
|
||||||
|
context: EMPTY_CONTEXT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEncryptedRoom = (mx: MatrixClient, roomId: string): boolean => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
return !!room?.hasEncryptionStateEvent();
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchLocalRooms = (mx: MatrixClient, roomIds: string[], term: string): SearchResult => {
|
||||||
|
const groups: ResultGroup[] = [];
|
||||||
|
|
||||||
|
roomIds.forEach((roomId) => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return;
|
||||||
|
const items = searchLocalRoomTimeline(room, term);
|
||||||
|
if (items.length > 0) {
|
||||||
|
groups.push({ roomId, items });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
highlights: highlightsFromTerm(term),
|
||||||
|
groups,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessageSearchParams = {
|
export type MessageSearchParams = {
|
||||||
@@ -69,19 +173,30 @@ export type MessageSearchParams = {
|
|||||||
rooms?: string[];
|
rooms?: string[];
|
||||||
senders?: string[];
|
senders?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useMessageSearch = (params: MessageSearchParams) => {
|
export const useMessageSearch = (params: MessageSearchParams) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const { term, order, rooms, senders } = params;
|
const { term, order, rooms, senders } = params;
|
||||||
|
|
||||||
const searchMessages = useCallback(
|
const searchMessages = useCallback(
|
||||||
async (nextBatch?: string) => {
|
async (nextBatch?: string) => {
|
||||||
if (!term)
|
if (!term) {
|
||||||
return {
|
return {
|
||||||
highlights: [],
|
highlights: [],
|
||||||
groups: [],
|
groups: [],
|
||||||
};
|
};
|
||||||
const limit = 20;
|
}
|
||||||
|
|
||||||
|
const scopedRooms = rooms?.filter(Boolean) ?? [];
|
||||||
|
const encryptedScoped =
|
||||||
|
scopedRooms.length > 0 && scopedRooms.every((roomId) => isEncryptedRoom(mx, roomId));
|
||||||
|
|
||||||
|
// Encrypted-only scope: server cannot see bodies — search loaded timeline locally
|
||||||
|
if (encryptedScoped && !nextBatch) {
|
||||||
|
return searchLocalRooms(mx, scopedRooms, term);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = 20;
|
||||||
const requestBody: ISearchRequestBody = {
|
const requestBody: ISearchRequestBody = {
|
||||||
search_categories: {
|
search_categories: {
|
||||||
room_events: {
|
room_events: {
|
||||||
@@ -102,11 +217,33 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
const r = await mx.search({
|
const r = await mx.search({
|
||||||
body: requestBody,
|
body: requestBody,
|
||||||
next_batch: nextBatch === '' ? undefined : nextBatch,
|
next_batch: nextBatch === '' ? undefined : nextBatch,
|
||||||
});
|
});
|
||||||
return parseSearchResult(r);
|
const parsed = parseSearchResult(r);
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsed.groups.length === 0 &&
|
||||||
|
!nextBatch &&
|
||||||
|
scopedRooms.length > 0 &&
|
||||||
|
scopedRooms.some((roomId) => isEncryptedRoom(mx, roomId))
|
||||||
|
) {
|
||||||
|
return searchLocalRooms(
|
||||||
|
mx,
|
||||||
|
scopedRooms.filter((id) => isEncryptedRoom(mx, id)),
|
||||||
|
term
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
if (!nextBatch && scopedRooms.length > 0) {
|
||||||
|
return searchLocalRooms(mx, scopedRooms, term);
|
||||||
|
}
|
||||||
|
throw new Error('Message search failed');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[mx, term, order, rooms, senders]
|
[mx, term, order, rooms, senders]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { Box, Line } from 'folds';
|
|||||||
import { decodeRouteParam } from '../../pages/pathUtils';
|
import { decodeRouteParam } from '../../pages/pathUtils';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useAtom, useSetAtom } from 'jotai';
|
||||||
import { RoomView } from './RoomView';
|
import { RoomView } from './RoomView';
|
||||||
import { MembersDrawer } from './MembersDrawer';
|
import { MembersDrawer } from './MembersDrawer';
|
||||||
|
import { MediaDrawer } from './room-media-menu';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
@@ -16,6 +17,7 @@ import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||||
import { activeRoomIdAtom } from '../../state/activeRoom';
|
import { activeRoomIdAtom } from '../../state/activeRoom';
|
||||||
|
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
|
||||||
import { isForum } from '../../utils/room';
|
import { isForum } from '../../utils/room';
|
||||||
import { ForumRoomView } from './ForumRoomView';
|
import { ForumRoomView } from './ForumRoomView';
|
||||||
|
|
||||||
@@ -26,13 +28,18 @@ export function Room() {
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const setActiveRoomId = useSetAtom(activeRoomIdAtom);
|
const setActiveRoomId = useSetAtom(activeRoomIdAtom);
|
||||||
|
|
||||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
const [isPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
|
const [isMediaDrawer] = useAtom(isMediaDrawerAtom);
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
const members = useRoomMembers(mx, room.roomId);
|
const members = useRoomMembers(mx, room.roomId);
|
||||||
const markAsRead = useMarkAsRead(mx);
|
const markAsRead = useMarkAsRead(mx);
|
||||||
const forumRoom = isForum(room);
|
const forumRoom = isForum(room);
|
||||||
|
const skinny = screenSize !== ScreenSize.Desktop;
|
||||||
|
const showMediaSolo = skinny && isMediaDrawer;
|
||||||
|
const showRightDrawer =
|
||||||
|
!skinny && (isPeopleDrawer || isMediaDrawer);
|
||||||
|
|
||||||
// Update titlebar with current room ID
|
// Update titlebar with current room ID
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,11 +62,21 @@ export function Room() {
|
|||||||
return (
|
return (
|
||||||
<PowerLevelsContextProvider value={powerLevels}>
|
<PowerLevelsContextProvider value={powerLevels}>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
{forumRoom ? <ForumRoomView room={room} eventId={eventId} /> : <RoomView room={room} eventId={eventId} />}
|
{!showMediaSolo &&
|
||||||
{screenSize === ScreenSize.Desktop && isDrawer && (
|
(forumRoom ? (
|
||||||
|
<ForumRoomView room={room} eventId={eventId} />
|
||||||
|
) : (
|
||||||
|
<RoomView room={room} eventId={eventId} />
|
||||||
|
))}
|
||||||
|
{showMediaSolo && <MediaDrawer room={room} solo />}
|
||||||
|
{!showMediaSolo && showRightDrawer && (
|
||||||
<>
|
<>
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
|
{isMediaDrawer ? (
|
||||||
|
<MediaDrawer room={room} />
|
||||||
|
) : (
|
||||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export const RoomInputWrap = style({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
});
|
});
|
||||||
|
|
||||||
globalStyle(`${RoomInputWrap} .${editorCss.Editor}`, {
|
/* Notebook-flat composer chrome — Stationery only (other themes keep Editor inset border) */
|
||||||
|
globalStyle(`.stationery ${RoomInputWrap} .${editorCss.Editor}`, {
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
boxShadow: 'none',
|
boxShadow: 'none',
|
||||||
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
|||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import {
|
import {
|
||||||
TUploadContent,
|
TUploadContent,
|
||||||
encryptFile,
|
|
||||||
getImageInfo,
|
getImageInfo,
|
||||||
getMxIdLocalPart,
|
getMxIdLocalPart,
|
||||||
mxcUrlToHttp,
|
mxcUrlToHttp,
|
||||||
@@ -54,6 +53,7 @@ import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
|||||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||||
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
||||||
import { useFileDropZone } from '../../hooks/useFileDrop';
|
import { useFileDropZone } from '../../hooks/useFileDrop';
|
||||||
|
import { useRoomUploadFiles } from '../../hooks/useRoomUploadFiles';
|
||||||
import {
|
import {
|
||||||
TUploadItem,
|
TUploadItem,
|
||||||
TUploadMetadata,
|
TUploadMetadata,
|
||||||
@@ -76,7 +76,6 @@ import {
|
|||||||
createUploadFamilyObserverAtom,
|
createUploadFamilyObserverAtom,
|
||||||
} from '../../state/upload';
|
} from '../../state/upload';
|
||||||
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
|
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
|
||||||
import { safeFile } from '../../utils/mimeTypes';
|
|
||||||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
@@ -101,7 +100,7 @@ import colorMXID from '../../../util/colorMXID';
|
|||||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||||
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||||
import { useComposingCheck } from '../../hooks/useComposingCheck';
|
import { useComposingCheck } from '../../hooks/useComposingCheck';
|
||||||
@@ -224,42 +223,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
});
|
});
|
||||||
}, [commands]);
|
}, [commands]);
|
||||||
|
|
||||||
|
const { handleFiles: enqueueFiles } = useRoomUploadFiles(room);
|
||||||
const handleFiles = useCallback(
|
const handleFiles = useCallback(
|
||||||
async (files: File[]) => {
|
async (files: File[]) => {
|
||||||
setUploadBoard(true);
|
setUploadBoard(true);
|
||||||
const safeFiles = files.map(safeFile);
|
await enqueueFiles(files);
|
||||||
const fileItems: TUploadItem[] = [];
|
|
||||||
|
|
||||||
if (room.hasEncryptionStateEvent()) {
|
|
||||||
const encryptFiles = fulfilledPromiseSettledResult(
|
|
||||||
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
|
|
||||||
);
|
|
||||||
encryptFiles.forEach((ef) =>
|
|
||||||
fileItems.push({
|
|
||||||
...ef,
|
|
||||||
metadata: {
|
|
||||||
markedAsSpoiler: false,
|
|
||||||
},
|
},
|
||||||
})
|
[enqueueFiles]
|
||||||
);
|
|
||||||
} else {
|
|
||||||
safeFiles.forEach((f) =>
|
|
||||||
fileItems.push({
|
|
||||||
file: f,
|
|
||||||
originalFile: f,
|
|
||||||
encInfo: undefined,
|
|
||||||
metadata: {
|
|
||||||
markedAsSpoiler: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setSelectedFiles({
|
|
||||||
type: 'PUT',
|
|
||||||
item: fileItems,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[setSelectedFiles, room]
|
|
||||||
);
|
);
|
||||||
const pickFile = useFilePicker(handleFiles, true);
|
const pickFile = useFilePicker(handleFiles, true);
|
||||||
const handlePaste = useFilePasteHandler(handleFiles);
|
const handlePaste = useFilePasteHandler(handleFiles);
|
||||||
@@ -724,7 +694,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
backgroundColor: color.SurfaceVariant.Container,
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
borderBottom: 'none',
|
borderBottom: 'none',
|
||||||
borderRadius: 0,
|
borderRadius: isStationeryTheme(theme) ? 0 : undefined,
|
||||||
|
borderTopLeftRadius: isStationeryTheme(theme) ? undefined : config.radii.R400,
|
||||||
|
borderTopRightRadius: isStationeryTheme(theme) ? undefined : config.radii.R400,
|
||||||
marginBottom: config.space.S100,
|
marginBottom: config.space.S100,
|
||||||
}}
|
}}
|
||||||
direction="Column"
|
direction="Column"
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export const TimelineFloat = recipe({
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 1,
|
// Above timeline media carousels / sticky message chrome
|
||||||
|
zIndex: 10,
|
||||||
minWidth: 'max-content',
|
minWidth: 'max-content',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ import {
|
|||||||
useIntersectionObserver,
|
useIntersectionObserver,
|
||||||
} from '../../hooks/useIntersectionObserver';
|
} from '../../hooks/useIntersectionObserver';
|
||||||
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
||||||
import { useDebounce } from '../../hooks/useDebounce';
|
|
||||||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
||||||
import * as css from './RoomTimeline.css';
|
import * as css from './RoomTimeline.css';
|
||||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||||
@@ -104,9 +103,12 @@ 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';
|
||||||
import {
|
import {
|
||||||
|
clearRoomReturnAnchor,
|
||||||
clearRoomScrollState,
|
clearRoomScrollState,
|
||||||
|
getRoomReturnAnchor,
|
||||||
getRoomScrollState,
|
getRoomScrollState,
|
||||||
saveRoomScrollState,
|
saveRoomScrollState,
|
||||||
|
setRoomReturnAnchor,
|
||||||
} from '../../state/roomScrollCache';
|
} from '../../state/roomScrollCache';
|
||||||
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
|
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
|
||||||
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
||||||
@@ -650,6 +652,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
sender: string;
|
sender: string;
|
||||||
content: string;
|
content: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [returnToEventId, setReturnToEventIdState] = useState<string | null>(
|
||||||
|
() => getRoomReturnAnchor(room.roomId) ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
const setReturnToEventId = useCallback(
|
||||||
|
(eventId: string | null) => {
|
||||||
|
if (eventId) setRoomReturnAnchor(room.roomId, eventId);
|
||||||
|
else clearRoomReturnAnchor(room.roomId);
|
||||||
|
setReturnToEventIdState(eventId);
|
||||||
|
},
|
||||||
|
[room.roomId]
|
||||||
|
);
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const timelineContentRef = useRef<HTMLDivElement>(null);
|
const timelineContentRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -661,6 +675,31 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getViewportAnchorEventId = useCallback((): string | undefined => {
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (!scrollEl) return undefined;
|
||||||
|
|
||||||
|
const scrollRect = scrollEl.getBoundingClientRect();
|
||||||
|
const centerY = scrollRect.top + scrollRect.height / 2;
|
||||||
|
const items = scrollEl.querySelectorAll<HTMLElement>('[data-message-id]');
|
||||||
|
|
||||||
|
let bestId: string | undefined;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
|
||||||
|
items.forEach((el) => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.bottom < scrollRect.top || rect.top > scrollRect.bottom) return;
|
||||||
|
const mid = (rect.top + rect.bottom) / 2;
|
||||||
|
const dist = Math.abs(mid - centerY);
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
bestId = el.getAttribute('data-message-id') ?? undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return bestId;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [focusItem, setFocusItem] = useState<
|
const [focusItem, setFocusItem] = useState<
|
||||||
| {
|
| {
|
||||||
index: number;
|
index: number;
|
||||||
@@ -709,6 +748,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const timelineRef = useRef(timeline);
|
const timelineRef = useRef(timeline);
|
||||||
timelineRef.current = timeline;
|
timelineRef.current = timeline;
|
||||||
|
|
||||||
|
const getRangeCenterEventId = useCallback((): string | undefined => {
|
||||||
|
const { linkedTimelines, range } = timelineRef.current;
|
||||||
|
if (range.end <= range.start) return undefined;
|
||||||
|
const mid = Math.floor((range.start + range.end - 1) / 2);
|
||||||
|
const [tm, baseIndex] = getTimelineAndBaseIndex(linkedTimelines, mid);
|
||||||
|
if (!tm) return undefined;
|
||||||
|
return getTimelineEvent(tm, getTimelineRelativeIndex(mid, baseIndex))?.getId();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const persistTimelineScroll = useCallback(
|
const persistTimelineScroll = useCallback(
|
||||||
(roomId: string) => {
|
(roomId: string) => {
|
||||||
if (eventId) return;
|
if (eventId) return;
|
||||||
@@ -736,6 +784,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd);
|
const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd);
|
||||||
atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
|
atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
|
||||||
|
|
||||||
|
// Historical / non-live windows are never "at bottom" of the room.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!liveTimelineLinked || !rangeAtEnd) {
|
||||||
|
setAtBottom(false);
|
||||||
|
}
|
||||||
|
}, [liveTimelineLinked, rangeAtEnd]);
|
||||||
|
|
||||||
const handleTimelinePagination = useTimelinePagination(
|
const handleTimelinePagination = useTimelinePagination(
|
||||||
mx,
|
mx,
|
||||||
timeline,
|
timeline,
|
||||||
@@ -800,6 +855,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const isOwnMessage = mEvt.getSender() === mx.getUserId();
|
const isOwnMessage = mEvt.getSender() === mx.getUserId();
|
||||||
const isBottomPinnedEvent =
|
const isBottomPinnedEvent =
|
||||||
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
|
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
|
||||||
|
const wasAwayFromBottom = !atBottomRef.current;
|
||||||
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
|
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
|
||||||
|
|
||||||
// Always update timeline with new message
|
// Always update timeline with new message
|
||||||
@@ -820,6 +876,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
|
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Sending while scrolled up jumps to latest — offer return to previous spot
|
||||||
|
if (wasAwayFromBottom && isBottomPinnedEvent) {
|
||||||
|
const anchorEventId =
|
||||||
|
eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
|
||||||
|
if (anchorEventId) {
|
||||||
|
setReturnToEventId(anchorEventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (!document.hasFocus() && !unreadInfo) {
|
} else if (!document.hasFocus() && !unreadInfo) {
|
||||||
// Show unread notification for other users' messages when unfocused
|
// Show unread notification for other users' messages when unfocused
|
||||||
setUnreadInfo(getRoomUnreadInfo(room));
|
setUnreadInfo(getRoomUnreadInfo(room));
|
||||||
@@ -846,7 +910,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx, room, unreadInfo, hideActivity, markAsRead]
|
[mx, room, unreadInfo, hideActivity, markAsRead, eventId, getViewportAnchorEventId, getRangeCenterEventId, setReturnToEventId]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -961,30 +1025,27 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}
|
}
|
||||||
}, [room, hideActivity, markAsRead]);
|
}, [room, hideActivity, markAsRead]);
|
||||||
|
|
||||||
const debounceSetAtBottom = useDebounce(
|
|
||||||
useCallback((entry: IntersectionObserverEntry) => {
|
|
||||||
if (!entry.isIntersecting) {
|
|
||||||
setAtBottom(false);
|
|
||||||
}
|
|
||||||
}, []),
|
|
||||||
{ wait: 1000 }
|
|
||||||
);
|
|
||||||
useIntersectionObserver(
|
useIntersectionObserver(
|
||||||
useCallback(
|
useCallback(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
const target = atBottomAnchorRef.current;
|
const target = atBottomAnchorRef.current;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const targetEntry = getIntersectionObserverEntry(target, entries);
|
const targetEntry = getIntersectionObserverEntry(target, entries);
|
||||||
if (targetEntry) debounceSetAtBottom(targetEntry);
|
if (!targetEntry) return;
|
||||||
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
|
|
||||||
|
// Leave the live bottom immediately so Jump to Latest stays available.
|
||||||
|
if (!targetEntry.isIntersecting || !atLiveEndRef.current) {
|
||||||
|
setAtBottom(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
setLatestUnreadMessage(null);
|
setLatestUnreadMessage(null);
|
||||||
if (document.hasFocus()) {
|
if (document.hasFocus()) {
|
||||||
tryAutoMarkAsRead();
|
tryAutoMarkAsRead();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[debounceSetAtBottom, tryAutoMarkAsRead]
|
[tryAutoMarkAsRead]
|
||||||
),
|
),
|
||||||
useCallback(
|
useCallback(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -1048,6 +1109,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}
|
}
|
||||||
}, [eventId, loadEventTimeline]);
|
}, [eventId, loadEventTimeline]);
|
||||||
|
|
||||||
|
// Restore return-to-previous when switching rooms (survives remount via cache).
|
||||||
|
useEffect(() => {
|
||||||
|
setReturnToEventIdState(getRoomReturnAnchor(room.roomId) ?? null);
|
||||||
|
}, [room.roomId]);
|
||||||
|
|
||||||
// Scroll to latest when clicking on already-selected room
|
// Scroll to latest when clicking on already-selected room
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollToLatest) {
|
if (scrollToLatest) {
|
||||||
@@ -1234,17 +1300,51 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}, [room, hour24Clock]);
|
}, [room, hour24Clock]);
|
||||||
|
|
||||||
const handleJumpToLatest = () => {
|
const handleJumpToLatest = () => {
|
||||||
|
const anchorEventId =
|
||||||
|
eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
|
||||||
|
if (anchorEventId) {
|
||||||
|
setReturnToEventId(anchorEventId);
|
||||||
|
}
|
||||||
|
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
navigateRoom(room.roomId, undefined, { replace: true });
|
navigateRoom(room.roomId, undefined, { replace: true });
|
||||||
}
|
}
|
||||||
clearRoomScrollState(room.roomId);
|
clearRoomScrollState(room.roomId);
|
||||||
setLatestUnreadMessage(null);
|
setLatestUnreadMessage(null);
|
||||||
|
|
||||||
|
// Already on the live timeline: only snap the virtual window to the end.
|
||||||
|
// A full getInitialTimeline() rebuild feels like a chat reload when far back.
|
||||||
|
if (!eventId && liveTimelineLinked) {
|
||||||
|
setTimeline((ct) => {
|
||||||
|
const evLength = getTimelinesEventsCount(ct.linkedTimelines);
|
||||||
|
return {
|
||||||
|
linkedTimelines: ct.linkedTimelines,
|
||||||
|
range: {
|
||||||
|
start: Math.max(evLength - PAGINATION_LIMIT, 0),
|
||||||
|
end: evLength,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
setTimeline(getInitialTimeline(room));
|
setTimeline(getInitialTimeline(room));
|
||||||
|
}
|
||||||
|
|
||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = false;
|
scrollToBottomRef.current.smooth = false;
|
||||||
scrollToBottomRef.current.force = true;
|
scrollToBottomRef.current.force = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReturnToPrevious = () => {
|
||||||
|
if (!returnToEventId) return;
|
||||||
|
const targetId = returnToEventId;
|
||||||
|
setReturnToEventId(null);
|
||||||
|
handleOpenEvent(targetId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearReturnToPrevious = () => {
|
||||||
|
setReturnToEventId(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleJumpToUnread = () => {
|
const handleJumpToUnread = () => {
|
||||||
if (unreadInfo?.readUptoEventId) {
|
if (unreadInfo?.readUptoEventId) {
|
||||||
setTimeline(getEmptyTimeline());
|
setTimeline(getEmptyTimeline());
|
||||||
@@ -2391,31 +2491,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
return callSummaryJSX || eventJSX;
|
return callSummaryJSX || eventJSX;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showUnreadTop =
|
||||||
|
!!unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline;
|
||||||
|
const atLiveBottom = atBottom && liveTimelineLinked && rangeAtEnd;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" style={{ position: 'relative' }}>
|
<Box grow="Yes" style={{ position: 'relative' }}>
|
||||||
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
|
|
||||||
<TimelineFloat position="Top">
|
|
||||||
<Chip
|
|
||||||
variant="Primary"
|
|
||||||
radii="Pill"
|
|
||||||
outlined
|
|
||||||
before={<Icon size="50" src={Icons.MessageUnread} />}
|
|
||||||
onClick={handleJumpToUnread}
|
|
||||||
>
|
|
||||||
<Text size="L400">Jump to Unread</Text>
|
|
||||||
</Chip>
|
|
||||||
|
|
||||||
<Chip
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
radii="Pill"
|
|
||||||
outlined
|
|
||||||
before={<Icon size="50" src={Icons.CheckTwice} />}
|
|
||||||
onClick={handleMarkAsRead}
|
|
||||||
>
|
|
||||||
<Text size="L400">Mark as Read</Text>
|
|
||||||
</Chip>
|
|
||||||
</TimelineFloat>
|
|
||||||
)}
|
|
||||||
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
|
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
|
||||||
<Box
|
<Box
|
||||||
ref={timelineContentRef}
|
ref={timelineContentRef}
|
||||||
@@ -2510,7 +2591,57 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
<span ref={atBottomAnchorRef} />
|
<span ref={atBottomAnchorRef} />
|
||||||
</Box>
|
</Box>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
{!atBottom && (
|
{(showUnreadTop || returnToEventId) && (
|
||||||
|
<TimelineFloat position="Top">
|
||||||
|
{returnToEventId && (
|
||||||
|
<>
|
||||||
|
<Chip
|
||||||
|
variant="Primary"
|
||||||
|
radii="Pill"
|
||||||
|
outlined
|
||||||
|
before={<Icon size="50" src={Icons.ArrowTop} />}
|
||||||
|
onClick={handleReturnToPrevious}
|
||||||
|
>
|
||||||
|
<Text size="L400">Return to Previous</Text>
|
||||||
|
</Chip>
|
||||||
|
<IconButton
|
||||||
|
title="Dismiss"
|
||||||
|
aria-label="Dismiss return to previous"
|
||||||
|
size="300"
|
||||||
|
radii="Pill"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
onClick={handleClearReturnToPrevious}
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showUnreadTop && (
|
||||||
|
<>
|
||||||
|
<Chip
|
||||||
|
variant="Primary"
|
||||||
|
radii="Pill"
|
||||||
|
outlined
|
||||||
|
before={<Icon size="50" src={Icons.MessageUnread} />}
|
||||||
|
onClick={handleJumpToUnread}
|
||||||
|
>
|
||||||
|
<Text size="L400">Jump to Unread</Text>
|
||||||
|
</Chip>
|
||||||
|
|
||||||
|
<Chip
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="Pill"
|
||||||
|
outlined
|
||||||
|
before={<Icon size="50" src={Icons.CheckTwice} />}
|
||||||
|
onClick={handleMarkAsRead}
|
||||||
|
>
|
||||||
|
<Text size="L400">Mark as Read</Text>
|
||||||
|
</Chip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TimelineFloat>
|
||||||
|
)}
|
||||||
|
{!atLiveBottom && (
|
||||||
<TimelineFloat position="Bottom">
|
<TimelineFloat position="Bottom">
|
||||||
<Box direction="Column" gap="200" alignItems="Center">
|
<Box direction="Column" gap="200" alignItems="Center">
|
||||||
{latestUnreadMessage && (
|
{latestUnreadMessage && (
|
||||||
|
|||||||
@@ -7,18 +7,16 @@ import { JoinRule, Room } from 'matrix-js-sdk';
|
|||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||||
|
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
|
||||||
import { PageHeader } from '../../components/page';
|
import { PageHeader } from '../../components/page';
|
||||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
|
import { getHomeSearchPath, getSpaceSearchPath, getDirectSearchPath, withSearchParam } from '../../pages/pathUtils';
|
||||||
import { getCanonicalAliasOrRoomId, guessDmRoomUserId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
|
import { getCanonicalAliasOrRoomId, guessDmRoomUserId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import { useOtherUserColor } from '../../hooks/useUserColor';
|
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||||
@@ -43,6 +41,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||||||
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
|
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
|
||||||
import { RoomPinMenu } from './room-pin-menu';
|
import { RoomPinMenu } from './room-pin-menu';
|
||||||
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
||||||
|
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
|
||||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||||
import {
|
import {
|
||||||
getRoomNotificationMode,
|
getRoomNotificationMode,
|
||||||
@@ -58,6 +57,8 @@ import { useRoomCall, useRoomCallMembers } from '../call';
|
|||||||
import { CallState, CallType } from '../call/types';
|
import { CallState, CallType } from '../call/types';
|
||||||
import { UserAvatar } from '../../components/user-avatar';
|
import { UserAvatar } from '../../components/user-avatar';
|
||||||
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
|
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
|
||||||
|
import { useDirectSelected } from '../../hooks/router/useDirectSelected';
|
||||||
|
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
type RoomMenuProps = {
|
type RoomMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -471,10 +472,11 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const onDirectPath = useDirectSelected();
|
||||||
|
const theme = useTheme();
|
||||||
|
const stationery = isStationeryTheme(theme);
|
||||||
|
|
||||||
const pinnedEvents = useRoomPinnedEvents(room);
|
const pinnedEvents = useRoomPinnedEvents(room);
|
||||||
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
|
||||||
const ecryptedRoom = !!encryptionEvent;
|
|
||||||
const isDirect = mDirects.has(room.roomId);
|
const isDirect = mDirects.has(room.roomId);
|
||||||
const avatarMxc = useRoomAvatar(room, isDirect);
|
const avatarMxc = useRoomAvatar(room, isDirect);
|
||||||
const name = useRoomName(room);
|
const name = useRoomName(room);
|
||||||
@@ -488,11 +490,13 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
(dmUserId ? room.getMember(dmUserId)?.getMxcAvatarUrl() : undefined) ||
|
(dmUserId ? room.getMember(dmUserId)?.getMxcAvatarUrl() : undefined) ||
|
||||||
(isDirect ? avatarMxc : undefined);
|
(isDirect ? avatarMxc : undefined);
|
||||||
const dmCustomColor = useOtherUserColor(dmUserId ?? '', dmAvatarMxc);
|
const dmCustomColor = useOtherUserColor(dmUserId ?? '', dmAvatarMxc);
|
||||||
const dmFolderTabColor = isDirect
|
const dmFolderTabColor =
|
||||||
|
stationery && isDirect
|
||||||
? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))`
|
? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
|
const [isMediaDrawer, setMediaDrawer] = useAtom(isMediaDrawerAtom);
|
||||||
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
|
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
|
||||||
|
|
||||||
const handleSearchClick = () => {
|
const handleSearchClick = () => {
|
||||||
@@ -501,6 +505,8 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
};
|
};
|
||||||
const path = space
|
const path = space
|
||||||
? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
|
? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
|
||||||
|
: isDirect || onDirectPath
|
||||||
|
? getDirectSearchPath()
|
||||||
: getHomeSearchPath();
|
: getHomeSearchPath();
|
||||||
navigate(withSearchParam(path, searchParams));
|
navigate(withSearchParam(path, searchParams));
|
||||||
};
|
};
|
||||||
@@ -513,6 +519,22 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTogglePeopleDrawer = () => {
|
||||||
|
setPeopleDrawer((drawer) => {
|
||||||
|
const next = !drawer;
|
||||||
|
if (next) setMediaDrawer(false);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleMediaDrawer = () => {
|
||||||
|
setMediaDrawer((open) => {
|
||||||
|
const next = !open;
|
||||||
|
if (next) setPeopleDrawer(false);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageHeader balance={showInPageHeader}>
|
<PageHeader balance={showInPageHeader}>
|
||||||
<Box grow="Yes" gap="300">
|
<Box grow="Yes" gap="300">
|
||||||
@@ -611,7 +633,6 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
<Box shrink="No" alignItems="Center" gap="100">
|
<Box shrink="No" alignItems="Center" gap="100">
|
||||||
<CallIndicator roomId={room.roomId} />
|
<CallIndicator roomId={room.roomId} />
|
||||||
<RoomCallButtons roomId={room.roomId} />
|
<RoomCallButtons roomId={room.roomId} />
|
||||||
{!ecryptedRoom && (
|
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
offset={4}
|
offset={4}
|
||||||
@@ -627,7 +648,6 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
offset={4}
|
offset={4}
|
||||||
@@ -695,8 +715,12 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
|
<IconButton
|
||||||
<Icon size="300" src={Icons.User} />
|
ref={triggerRef}
|
||||||
|
onClick={handleTogglePeopleDrawer}
|
||||||
|
aria-pressed={peopleDrawer}
|
||||||
|
>
|
||||||
|
<Icon size="300" src={Icons.User} filled={peopleDrawer} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -720,6 +744,26 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>{isMediaDrawer ? 'Hide Shared Media' : 'Show Shared Media'}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
onClick={handleToggleMediaDrawer}
|
||||||
|
aria-pressed={isMediaDrawer}
|
||||||
|
aria-label="Shared Media"
|
||||||
|
>
|
||||||
|
<Icon size="300" src={Icons.Photo} filled={isMediaDrawer} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
<PluginButtonSlot location="room-header" />
|
<PluginButtonSlot location="room-header" />
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
|
|||||||
149
src/app/features/room/room-media-menu/RoomMediaMenu.css.ts
Normal file
149
src/app/features/room/room-media-menu/RoomMediaMenu.css.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const MediaDrawer = style({
|
||||||
|
width: toRem(399),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaDrawerSolo = style({
|
||||||
|
width: '100%',
|
||||||
|
flexGrow: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaDrawerHeader = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaDrawerContentBase = style({
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaDrawerContent = style({
|
||||||
|
padding: `${config.space.S100} ${config.space.S200} ${config.space.S200}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaMonthSection = style({
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaMonthHeader = style({
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
margin: '5px 0',
|
||||||
|
padding: `0 ${config.space.S100}`,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
color: color.Background.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaDaySection = style({
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaDayHeader = style({
|
||||||
|
padding: `${config.space.S100} ${config.space.S100} ${config.space.S200}`,
|
||||||
|
color: color.Background.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaGrid = style({
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||||
|
gap: config.space.S200,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaTile = style({
|
||||||
|
position: 'relative',
|
||||||
|
aspectRatio: '1',
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaTileImg = style({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'block',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaTileBadge = style({
|
||||||
|
position: 'absolute',
|
||||||
|
right: config.space.S100,
|
||||||
|
bottom: config.space.S100,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: toRem(24),
|
||||||
|
height: toRem(24),
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
||||||
|
color: '#fff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaEmpty = style({
|
||||||
|
padding: config.space.S400,
|
||||||
|
textAlign: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaFooter = style({
|
||||||
|
padding: config.space.S200,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaUserFiltersScroll = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
flexGrow: 0,
|
||||||
|
overflowX: 'auto',
|
||||||
|
overflowY: 'hidden',
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
scrollbarColor: `${color.SurfaceVariant.ContainerLine} ${color.SurfaceVariant.Container}`,
|
||||||
|
selectors: {
|
||||||
|
'&::-webkit-scrollbar': {
|
||||||
|
height: toRem(8),
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-track': {
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
backgroundColor: color.SurfaceVariant.ContainerLine,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.Container}`,
|
||||||
|
backgroundClip: 'padding-box',
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb:hover': {
|
||||||
|
backgroundColor: color.Surface.ContainerLine,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaUserFilters = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S500,
|
||||||
|
padding: `${config.space.S200} ${config.space.S200}`,
|
||||||
|
width: 'max-content',
|
||||||
|
minWidth: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaUserChip = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaUserChipLabel = style({
|
||||||
|
maxWidth: toRem(72),
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
});
|
||||||
524
src/app/features/room/room-media-menu/RoomMediaMenu.tsx
Normal file
524
src/app/features/room/room-media-menu/RoomMediaMenu.tsx
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Header,
|
||||||
|
IconButton,
|
||||||
|
Scroll,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
as,
|
||||||
|
config,
|
||||||
|
Chip,
|
||||||
|
Avatar,
|
||||||
|
} from 'folds';
|
||||||
|
import { MsgType, Room } from 'matrix-js-sdk';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { Icon, Icons } from '../../../components/icons';
|
||||||
|
import * as css from './RoomMediaMenu.css';
|
||||||
|
import { RoomMediaItem, useRoomMediaEvents } from '../../../hooks/useRoomMediaEvents';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import {
|
||||||
|
decryptFile,
|
||||||
|
downloadEncryptedMedia,
|
||||||
|
getMxIdLocalPart,
|
||||||
|
isAuthenticatedMediaUrl,
|
||||||
|
mxcUrlToHttp,
|
||||||
|
} from '../../../utils/matrix';
|
||||||
|
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||||
|
import { Image } from '../../../components/media';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||||
|
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
||||||
|
import { VideoFrameThumbnail } from '../../../components/message/content/VideoFrameThumbnail';
|
||||||
|
import { getMemberAvatarMxc, getMemberDisplayName } from '../../../utils/room';
|
||||||
|
import { UserAvatar } from '../../../components/user-avatar';
|
||||||
|
import {
|
||||||
|
getMediaDrawerScrollTop,
|
||||||
|
isMediaDrawerAtom,
|
||||||
|
setMediaDrawerScrollTop,
|
||||||
|
} from '../../../state/mediaDrawer';
|
||||||
|
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||||
|
|
||||||
|
const fetchAuthenticatedMedia = async (
|
||||||
|
url: string,
|
||||||
|
accessToken: string | null
|
||||||
|
): Promise<string> => {
|
||||||
|
try {
|
||||||
|
let response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && response.status === 401 && accessToken) {
|
||||||
|
response = await fetch(url, { method: 'GET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) return url;
|
||||||
|
const blob = await response.blob();
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaTileThumbProps = {
|
||||||
|
item: RoomMediaItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
function MediaTileImageThumb({ item }: { item: RoomMediaItem & { thumbMxc: string } }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const accessToken = getCurrentAccessToken();
|
||||||
|
|
||||||
|
if (item.thumbEncInfo) {
|
||||||
|
const mediaUrl = mxcUrlToHttp(mx, item.thumbMxc, useAuthentication) ?? item.thumbMxc;
|
||||||
|
const fileContent = await downloadEncryptedMedia(
|
||||||
|
mediaUrl,
|
||||||
|
(encBuf) =>
|
||||||
|
decryptFile(encBuf, item.thumbMimeType ?? FALLBACK_MIMETYPE, item.thumbEncInfo!),
|
||||||
|
accessToken
|
||||||
|
);
|
||||||
|
return URL.createObjectURL(fileContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaUrl =
|
||||||
|
mxcUrlToHttp(mx, item.thumbMxc, useAuthentication, 240, 240, 'crop') ?? item.thumbMxc;
|
||||||
|
|
||||||
|
if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
|
||||||
|
return fetchAuthenticatedMedia(mediaUrl, accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaUrl;
|
||||||
|
}, [mx, item.thumbMxc, item.thumbEncInfo, item.thumbMimeType, useAuthentication])
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSrc();
|
||||||
|
}, [loadSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (srcState.status !== AsyncStatus.Success) return undefined;
|
||||||
|
const src = srcState.data;
|
||||||
|
if (!src.startsWith('blob:')) return undefined;
|
||||||
|
return () => URL.revokeObjectURL(src);
|
||||||
|
}, [srcState]);
|
||||||
|
|
||||||
|
if (srcState.status === AsyncStatus.Success) {
|
||||||
|
return <Image className={css.MediaTileImg} src={srcState.data} alt={item.body} loading="lazy" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box alignItems="Center" justifyContent="Center" style={{ width: '100%', height: '100%' }}>
|
||||||
|
{srcState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner size="200" />
|
||||||
|
) : (
|
||||||
|
<Icon src={Icons.Photo} size="400" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaTileFullImageThumb({ item }: { item: RoomMediaItem }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const accessToken = getCurrentAccessToken();
|
||||||
|
const mediaUrl = mxcUrlToHttp(mx, item.fullMxc, useAuthentication) ?? item.fullMxc;
|
||||||
|
|
||||||
|
if (item.fullEncInfo) {
|
||||||
|
const fileContent = await downloadEncryptedMedia(
|
||||||
|
mediaUrl,
|
||||||
|
(encBuf) =>
|
||||||
|
decryptFile(encBuf, item.fullMimeType ?? FALLBACK_MIMETYPE, item.fullEncInfo!),
|
||||||
|
accessToken
|
||||||
|
);
|
||||||
|
return URL.createObjectURL(fileContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
|
||||||
|
return fetchAuthenticatedMedia(mediaUrl, accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaUrl;
|
||||||
|
}, [mx, item.fullMxc, item.fullEncInfo, item.fullMimeType, useAuthentication])
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSrc();
|
||||||
|
}, [loadSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (srcState.status !== AsyncStatus.Success) return undefined;
|
||||||
|
const src = srcState.data;
|
||||||
|
if (!src.startsWith('blob:')) return undefined;
|
||||||
|
return () => URL.revokeObjectURL(src);
|
||||||
|
}, [srcState]);
|
||||||
|
|
||||||
|
if (srcState.status === AsyncStatus.Success) {
|
||||||
|
return <Image className={css.MediaTileImg} src={srcState.data} alt={item.body} loading="lazy" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box alignItems="Center" justifyContent="Center" style={{ width: '100%', height: '100%' }}>
|
||||||
|
{srcState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner size="200" />
|
||||||
|
) : (
|
||||||
|
<Icon src={Icons.Photo} size="400" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaTileThumb({ item }: MediaTileThumbProps) {
|
||||||
|
if (item.thumbMxc) {
|
||||||
|
return <MediaTileImageThumb item={{ ...item, thumbMxc: item.thumbMxc }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.msgtype === MsgType.Video) {
|
||||||
|
return (
|
||||||
|
<Box style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||||
|
<Box
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
style={{ position: 'absolute', inset: 0 }}
|
||||||
|
>
|
||||||
|
<Spinner size="200" />
|
||||||
|
</Box>
|
||||||
|
<VideoFrameThumbnail
|
||||||
|
mimeType={item.fullMimeType ?? 'video/mp4'}
|
||||||
|
url={item.fullMxc}
|
||||||
|
encInfo={item.fullEncInfo}
|
||||||
|
renderImage={(src) => (
|
||||||
|
<Image className={css.MediaTileImg} src={src} alt={item.body} loading="lazy" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MediaTileFullImageThumb item={item} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaDayGroup = {
|
||||||
|
dayKey: string;
|
||||||
|
dayLabel: string;
|
||||||
|
items: RoomMediaItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaMonthGroup = {
|
||||||
|
monthKey: string;
|
||||||
|
monthLabel: string;
|
||||||
|
days: MediaDayGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dayFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sameDayFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDayLabel = (ts: number): string => {
|
||||||
|
const date = new Date(ts);
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(today.getDate() - 1);
|
||||||
|
|
||||||
|
if (sameDayFormatter.format(date) === sameDayFormatter.format(today)) return 'Today';
|
||||||
|
if (sameDayFormatter.format(date) === sameDayFormatter.format(yesterday)) return 'Yesterday';
|
||||||
|
return dayFormatter.format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupMediaByMonthAndDay = (items: RoomMediaItem[]): MediaMonthGroup[] => {
|
||||||
|
const months: MediaMonthGroup[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const date = new Date(item.ts);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
const dayKey = `${monthKey}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
let month = months[months.length - 1];
|
||||||
|
if (!month || month.monthKey !== monthKey) {
|
||||||
|
month = {
|
||||||
|
monthKey,
|
||||||
|
monthLabel: monthFormatter.format(date),
|
||||||
|
days: [],
|
||||||
|
};
|
||||||
|
months.push(month);
|
||||||
|
}
|
||||||
|
|
||||||
|
let day = month.days[month.days.length - 1];
|
||||||
|
if (!day || day.dayKey !== dayKey) {
|
||||||
|
day = {
|
||||||
|
dayKey,
|
||||||
|
dayLabel: formatDayLabel(item.ts),
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
month.days.push(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
day.items.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return months;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaSenderChip = {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
avatarMxc?: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaDrawerProps = {
|
||||||
|
room: Room;
|
||||||
|
/** Full-page takeover when the layout is too narrow for a side rail. */
|
||||||
|
solo?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MediaDrawer = as<'div', MediaDrawerProps>(({ room, solo = false, className, ...props }, ref) => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
const [, setMediaDrawer] = useAtom(isMediaDrawerAtom);
|
||||||
|
const { items, loading, hasMore, loadMore } = useRoomMediaEvents(room);
|
||||||
|
const [excludedSenders, setExcludedSenders] = useState<Set<string>>(() => new Set());
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const restoredScrollRef = useRef(false);
|
||||||
|
|
||||||
|
const senders = useMemo<MediaSenderChip[]>(() => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const item of items) {
|
||||||
|
counts.set(item.sender, (counts.get(item.sender) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(counts.entries())
|
||||||
|
.map(([userId, count]) => ({
|
||||||
|
userId,
|
||||||
|
count,
|
||||||
|
name: getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId,
|
||||||
|
avatarMxc: getMemberAvatarMxc(room, userId),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
||||||
|
}, [items, room]);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(
|
||||||
|
() => items.filter((item) => !excludedSenders.has(item.sender)),
|
||||||
|
[items, excludedSenders]
|
||||||
|
);
|
||||||
|
const sections = useMemo(() => groupMediaByMonthAndDay(filteredItems), [filteredItems]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
restoredScrollRef.current = false;
|
||||||
|
}, [room.roomId]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (restoredScrollRef.current || items.length === 0) return;
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const saved = getMediaDrawerScrollTop(room.roomId);
|
||||||
|
if (typeof saved === 'number') {
|
||||||
|
el.scrollTop = saved;
|
||||||
|
}
|
||||||
|
restoredScrollRef.current = true;
|
||||||
|
}, [room.roomId, items.length]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setMediaDrawer(false);
|
||||||
|
}, [setMediaDrawer]);
|
||||||
|
|
||||||
|
const handleSenderClick = useCallback(
|
||||||
|
(evt: React.MouseEvent, userId: string) => {
|
||||||
|
if (evt.ctrlKey || evt.metaKey) {
|
||||||
|
evt.preventDefault();
|
||||||
|
const allIds = senders.map((sender) => sender.userId);
|
||||||
|
setExcludedSenders((prev) => {
|
||||||
|
const enabledIds = allIds.filter((id) => !prev.has(id));
|
||||||
|
const alreadySolo = enabledIds.length === 1 && enabledIds[0] === userId;
|
||||||
|
if (alreadySolo) return new Set();
|
||||||
|
return new Set(allIds.filter((id) => id !== userId));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExcludedSenders((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(userId)) next.delete(userId);
|
||||||
|
else next.add(userId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[senders]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpen = useCallback(
|
||||||
|
(eventId: string) => {
|
||||||
|
navigateRoom(room.roomId, eventId);
|
||||||
|
if (solo) setMediaDrawer(false);
|
||||||
|
},
|
||||||
|
[navigateRoom, room.roomId, solo, setMediaDrawer]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(
|
||||||
|
(evt: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = evt.currentTarget;
|
||||||
|
setMediaDrawerScrollTop(room.roomId, el.scrollTop);
|
||||||
|
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
|
||||||
|
if (nearBottom && hasMore && !loading) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hasMore, loading, loadMore, room.roomId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
grow={solo ? 'Yes' : undefined}
|
||||||
|
direction="Column"
|
||||||
|
className={classNames(
|
||||||
|
solo ? css.MediaDrawerSolo : css.MediaDrawer,
|
||||||
|
ContainerColor({ variant: 'Background' }),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Header className={css.MediaDrawerHeader} size="600" variant="Background">
|
||||||
|
<Box grow="Yes" alignItems="Center">
|
||||||
|
<Text size="H5" truncate>
|
||||||
|
Shared Media
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
{senders.length > 0 && (
|
||||||
|
<div className={css.MediaUserFiltersScroll}>
|
||||||
|
<div className={css.MediaUserFilters}>
|
||||||
|
{senders.map((sender) => {
|
||||||
|
const enabled = !excludedSenders.has(sender.userId);
|
||||||
|
const avatarUrl = sender.avatarMxc
|
||||||
|
? mxcUrlToHttp(mx, sender.avatarMxc, useAuthentication, 48, 48, 'crop') ??
|
||||||
|
undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={sender.userId}
|
||||||
|
className={css.MediaUserChip}
|
||||||
|
variant={enabled ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
outlined
|
||||||
|
radii="Pill"
|
||||||
|
aria-pressed={enabled}
|
||||||
|
onClick={(evt) => handleSenderClick(evt, sender.userId)}
|
||||||
|
before={
|
||||||
|
<Avatar size="200">
|
||||||
|
<UserAvatar
|
||||||
|
userId={sender.userId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={sender.name}
|
||||||
|
renderFallback={() => (
|
||||||
|
<Text as="span" size="L400">
|
||||||
|
{sender.name.slice(0, 1).toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text className={css.MediaUserChipLabel} size="T200" truncate>
|
||||||
|
{sender.name}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box grow="Yes" direction="Column" className={css.MediaDrawerContentBase}>
|
||||||
|
<Scroll
|
||||||
|
ref={scrollRef}
|
||||||
|
visibility="Hover"
|
||||||
|
hideTrack
|
||||||
|
variant="Background"
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
<div className={css.MediaDrawerContent}>
|
||||||
|
{items.length === 0 && !loading && (
|
||||||
|
<Text className={css.MediaEmpty} size="T300" priority="300">
|
||||||
|
No shared photos or videos yet.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length > 0 && filteredItems.length === 0 && (
|
||||||
|
<Text className={css.MediaEmpty} size="T300" priority="300">
|
||||||
|
No media from the selected people.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sections.map((month) => (
|
||||||
|
<section key={month.monthKey} className={css.MediaMonthSection}>
|
||||||
|
<Text className={css.MediaMonthHeader} size="H6" as="h3">
|
||||||
|
{month.monthLabel}
|
||||||
|
</Text>
|
||||||
|
{month.days.map((day) => (
|
||||||
|
<div key={day.dayKey} className={css.MediaDaySection}>
|
||||||
|
<Text className={css.MediaDayHeader} size="T200" priority="300" as="h4">
|
||||||
|
{day.dayLabel}
|
||||||
|
</Text>
|
||||||
|
<div className={css.MediaGrid}>
|
||||||
|
{day.items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.eventId}
|
||||||
|
type="button"
|
||||||
|
className={css.MediaTile}
|
||||||
|
onClick={() => handleOpen(item.eventId)}
|
||||||
|
title={item.body}
|
||||||
|
aria-label={item.body}
|
||||||
|
>
|
||||||
|
<MediaTileThumb item={item} />
|
||||||
|
{item.msgtype === MsgType.Video && (
|
||||||
|
<span className={css.MediaTileBadge}>
|
||||||
|
<Icon src={Icons.Play} size="100" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(loading || hasMore) && (
|
||||||
|
<div className={css.MediaFooter} style={{ minHeight: config.space.S700 }}>
|
||||||
|
{loading && <Spinner size="400" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
1
src/app/features/room/room-media-menu/index.ts
Normal file
1
src/app/features/room/room-media-menu/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './RoomMediaMenu';
|
||||||
@@ -145,11 +145,23 @@ export function Search({ requestClose }: SearchProps) {
|
|||||||
|
|
||||||
const getTargetStr: SearchItemStrGetter<string> = useCallback(
|
const getTargetStr: SearchItemStrGetter<string> = useCallback(
|
||||||
(roomId: string) => {
|
(roomId: string) => {
|
||||||
const roomName = getRoom(roomId)?.name ?? roomId;
|
const room = getRoom(roomId);
|
||||||
|
const roomName = room?.name ?? roomId;
|
||||||
if (mDirects.has(roomId)) {
|
if (mDirects.has(roomId)) {
|
||||||
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
|
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
|
||||||
const targetUsername = targetUserId && getMxIdLocalPart(targetUserId);
|
if (!targetUserId) return roomName;
|
||||||
if (targetUsername) return [roomName, targetUsername];
|
const targetUsername = getMxIdLocalPart(targetUserId);
|
||||||
|
const targetServer = getMxIdServer(targetUserId);
|
||||||
|
const member = room?.getMember(targetUserId);
|
||||||
|
const displayName = member?.name || member?.rawDisplayName;
|
||||||
|
return [
|
||||||
|
roomName,
|
||||||
|
displayName,
|
||||||
|
targetUsername,
|
||||||
|
targetUserId,
|
||||||
|
targetServer ? `@${targetUsername}:${targetServer}` : undefined,
|
||||||
|
targetServer,
|
||||||
|
].filter((s): s is string => typeof s === 'string' && s.length > 0);
|
||||||
}
|
}
|
||||||
return roomName;
|
return roomName;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMatch } from 'react-router-dom';
|
import { useMatch } from 'react-router-dom';
|
||||||
import { getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
|
import { getDirectCreatePath, getDirectPath, getDirectSearchPath } from '../../pages/pathUtils';
|
||||||
|
|
||||||
export const useDirectSelected = (): boolean => {
|
export const useDirectSelected = (): boolean => {
|
||||||
const directMatch = useMatch({
|
const directMatch = useMatch({
|
||||||
@@ -20,3 +20,13 @@ export const useDirectCreateSelected = (): boolean => {
|
|||||||
|
|
||||||
return !!match;
|
return !!match;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useDirectSearchSelected = (): boolean => {
|
||||||
|
const match = useMatch({
|
||||||
|
path: getDirectSearchPath(),
|
||||||
|
caseSensitive: true,
|
||||||
|
end: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!match;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useClientConfig } from './useClientConfig';
|
import { useClientConfig } from './useClientConfig';
|
||||||
import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '../utils/common';
|
import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '../utils/common';
|
||||||
|
import { isElectron } from '../utils/tauri';
|
||||||
|
|
||||||
|
/** Authority shown on Matrix SSO consent ("Continue to paarrot://desktop"). */
|
||||||
|
export const ELECTRON_PROTOCOL_HOST = 'desktop';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an absolute URL for the given in-app path.
|
||||||
|
*
|
||||||
|
* In Electron, SSO (and other browser handoffs) must return via paarrot://desktop/...
|
||||||
|
* — a named host so consent UI identifies the desktop app, and a real pathname
|
||||||
|
* (not a hash) because browsers strip fragments when launching custom protocols.
|
||||||
|
*/
|
||||||
export const usePathWithOrigin = (path: string): string => {
|
export const usePathWithOrigin = (path: string): string => {
|
||||||
const { hashRouter } = useClientConfig();
|
const { hashRouter } = useClientConfig();
|
||||||
const { origin } = window.location;
|
const { origin } = window.location;
|
||||||
|
|
||||||
const pathWithOrigin = useMemo(() => {
|
const pathWithOrigin = useMemo(() => {
|
||||||
|
if (isElectron()) {
|
||||||
|
const basename = trimSlash(hashRouter?.basename ?? '');
|
||||||
|
const routeParts = [basename, trimLeadingSlash(path)].filter(Boolean);
|
||||||
|
const route = `/${routeParts.join('/')}`.replace(/\/{2,}/g, '/');
|
||||||
|
return `paarrot://${ELECTRON_PROTOCOL_HOST}${route}`;
|
||||||
|
}
|
||||||
|
|
||||||
let url: string = trimSlash(origin);
|
let url: string = trimSlash(origin);
|
||||||
|
|
||||||
url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`;
|
url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`;
|
||||||
|
|||||||
256
src/app/hooks/useRoomMediaEvents.ts
Normal file
256
src/app/hooks/useRoomMediaEvents.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import to from 'await-to-js';
|
||||||
|
import {
|
||||||
|
Direction,
|
||||||
|
IRoomEvent,
|
||||||
|
MatrixEvent,
|
||||||
|
MsgType,
|
||||||
|
RelationType,
|
||||||
|
Room,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||||
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { IEncryptedFile } from '../../types/matrix/common';
|
||||||
|
|
||||||
|
export type RoomMediaItem = {
|
||||||
|
eventId: string;
|
||||||
|
msgtype: typeof MsgType.Image | typeof MsgType.Video;
|
||||||
|
/** MXC used for an image thumbnail when available. */
|
||||||
|
thumbMxc?: string;
|
||||||
|
thumbMimeType?: string;
|
||||||
|
thumbEncInfo?: EncryptedAttachmentInfo;
|
||||||
|
/** Full media MXC — used to extract a video frame when no image thumb exists. */
|
||||||
|
fullMxc: string;
|
||||||
|
fullMimeType?: string;
|
||||||
|
fullEncInfo?: EncryptedAttachmentInfo;
|
||||||
|
body: string;
|
||||||
|
ts: number;
|
||||||
|
sender: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
const TARGET_BATCH = 24;
|
||||||
|
const MAX_PAGES_PER_LOAD = 8;
|
||||||
|
|
||||||
|
type RoomMediaCache = {
|
||||||
|
items: RoomMediaItem[];
|
||||||
|
hasMore: boolean;
|
||||||
|
nextToken: string | null;
|
||||||
|
seenIds: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Survives MediaDrawer remounts within the same room/session. */
|
||||||
|
const mediaCache = new Map<string, RoomMediaCache>();
|
||||||
|
|
||||||
|
const getOrCreateCache = (roomId: string): RoomMediaCache => {
|
||||||
|
const existing = mediaCache.get(roomId);
|
||||||
|
if (existing) return existing;
|
||||||
|
const created: RoomMediaCache = {
|
||||||
|
items: [],
|
||||||
|
hasMore: true,
|
||||||
|
nextToken: null,
|
||||||
|
seenIds: new Set(),
|
||||||
|
};
|
||||||
|
mediaCache.set(roomId, created);
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toMediaItem = (mEvent: MatrixEvent): RoomMediaItem | undefined => {
|
||||||
|
if (mEvent.isRedacted()) return undefined;
|
||||||
|
|
||||||
|
const relation = mEvent.getRelation();
|
||||||
|
if (relation?.rel_type === RelationType.Replace) return undefined;
|
||||||
|
|
||||||
|
const content = mEvent.getContent();
|
||||||
|
const msgtype = content.msgtype;
|
||||||
|
if (msgtype !== MsgType.Image && msgtype !== MsgType.Video) return undefined;
|
||||||
|
|
||||||
|
const file = content.file as IEncryptedFile | undefined;
|
||||||
|
const info = content.info as
|
||||||
|
| {
|
||||||
|
mimetype?: string;
|
||||||
|
thumbnail_url?: string;
|
||||||
|
thumbnail_info?: { mimetype?: string };
|
||||||
|
thumbnail_file?: IEncryptedFile;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const fullMxc =
|
||||||
|
(typeof content.url === 'string' && content.url) ||
|
||||||
|
(typeof file?.url === 'string' && file.url) ||
|
||||||
|
undefined;
|
||||||
|
if (!fullMxc) return undefined;
|
||||||
|
|
||||||
|
const thumbFile = info?.thumbnail_file;
|
||||||
|
const thumbMxc =
|
||||||
|
(typeof info?.thumbnail_url === 'string' && info.thumbnail_url) ||
|
||||||
|
(typeof thumbFile?.url === 'string' && thumbFile.url) ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
const eventId = mEvent.getId();
|
||||||
|
const sender = mEvent.getSender();
|
||||||
|
if (!eventId || !sender) return undefined;
|
||||||
|
|
||||||
|
const usingThumbEnc = !!thumbMxc && thumbMxc === thumbFile?.url;
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventId,
|
||||||
|
msgtype,
|
||||||
|
thumbMxc,
|
||||||
|
thumbMimeType: usingThumbEnc
|
||||||
|
? info?.thumbnail_info?.mimetype
|
||||||
|
: thumbMxc
|
||||||
|
? info?.thumbnail_info?.mimetype
|
||||||
|
: undefined,
|
||||||
|
thumbEncInfo: usingThumbEnc ? thumbFile : undefined,
|
||||||
|
fullMxc,
|
||||||
|
fullMimeType: info?.mimetype,
|
||||||
|
fullEncInfo: file,
|
||||||
|
body: typeof content.body === 'string' ? content.body : msgtype,
|
||||||
|
ts: mEvent.getTs(),
|
||||||
|
sender,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginates room history for image/video messages without mutating the live timeline.
|
||||||
|
*/
|
||||||
|
export const useRoomMediaEvents = (room: Room) => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const roomId = room.roomId;
|
||||||
|
const cacheRef = useRef(getOrCreateCache(roomId));
|
||||||
|
if (cacheRef.current !== mediaCache.get(roomId)) {
|
||||||
|
cacheRef.current = getOrCreateCache(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, setItems] = useState<RoomMediaItem[]>(() => cacheRef.current.items);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(() => cacheRef.current.hasMore);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
|
const decryptEvent = useCallback(
|
||||||
|
async (raw: IRoomEvent | MatrixEvent) => {
|
||||||
|
const mEvent = raw instanceof MatrixEvent ? raw : new MatrixEvent(raw);
|
||||||
|
if (mEvent.isEncrypted() && mx.getCrypto()) {
|
||||||
|
await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
|
||||||
|
}
|
||||||
|
return mEvent;
|
||||||
|
},
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchPages = useCallback(async () => {
|
||||||
|
const cache = cacheRef.current;
|
||||||
|
const collected: RoomMediaItem[] = [];
|
||||||
|
|
||||||
|
for (let page = 0; page < MAX_PAGES_PER_LOAD; page += 1) {
|
||||||
|
const response = await mx.createMessagesRequest(
|
||||||
|
roomId,
|
||||||
|
cache.nextToken,
|
||||||
|
PAGE_SIZE,
|
||||||
|
Direction.Backward
|
||||||
|
);
|
||||||
|
|
||||||
|
cache.nextToken = response.end ?? null;
|
||||||
|
if (!response.end) {
|
||||||
|
cache.hasMore = false;
|
||||||
|
setHasMore(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const raw of response.chunk ?? []) {
|
||||||
|
const mEvent = await decryptEvent(raw);
|
||||||
|
const item = toMediaItem(mEvent);
|
||||||
|
if (!item || cache.seenIds.has(item.eventId)) continue;
|
||||||
|
cache.seenIds.add(item.eventId);
|
||||||
|
collected.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collected.length >= TARGET_BATCH || !response.end) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return collected;
|
||||||
|
}, [mx, roomId, decryptEvent]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
const cache = cacheRef.current;
|
||||||
|
if (loadingRef.current || !cache.hasMore) return;
|
||||||
|
loadingRef.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const collected = await fetchPages();
|
||||||
|
if (collected.length > 0) {
|
||||||
|
cache.items = [...cache.items, ...collected];
|
||||||
|
setItems(cache.items);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingRef.current = false;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [fetchPages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cache = getOrCreateCache(roomId);
|
||||||
|
cacheRef.current = cache;
|
||||||
|
|
||||||
|
// Already loaded for this room — restore without re-fetching.
|
||||||
|
if (cache.items.length > 0 || cache.nextToken !== null || !cache.hasMore) {
|
||||||
|
setItems(cache.items);
|
||||||
|
setHasMore(cache.hasMore);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const bootstrap = async () => {
|
||||||
|
cache.seenIds = new Set();
|
||||||
|
cache.nextToken = null;
|
||||||
|
cache.hasMore = true;
|
||||||
|
cache.items = [];
|
||||||
|
setItems([]);
|
||||||
|
setHasMore(true);
|
||||||
|
setLoading(true);
|
||||||
|
loadingRef.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localFound: RoomMediaItem[] = [];
|
||||||
|
const localEvents = room.getLiveTimeline().getEvents();
|
||||||
|
for (let i = localEvents.length - 1; i >= 0; i -= 1) {
|
||||||
|
const mEvent = await decryptEvent(localEvents[i]);
|
||||||
|
const item = toMediaItem(mEvent);
|
||||||
|
if (!item || cache.seenIds.has(item.eventId)) continue;
|
||||||
|
cache.seenIds.add(item.eventId);
|
||||||
|
localFound.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
if (localFound.length > 0) {
|
||||||
|
cache.items = localFound;
|
||||||
|
setItems(localFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
const collected = await fetchPages();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (collected.length > 0) {
|
||||||
|
cache.items = [...cache.items, ...collected];
|
||||||
|
setItems(cache.items);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
loadingRef.current = false;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// Only re-bootstrap when switching rooms — not when callback identities change.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [roomId]);
|
||||||
|
|
||||||
|
return { items, loading, hasMore, loadMore };
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { NavigateOptions, useNavigate } from 'react-router-dom';
|
import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
|
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
|
||||||
import {
|
import {
|
||||||
@@ -7,21 +7,25 @@ import {
|
|||||||
getHomeRoomPath,
|
getHomeRoomPath,
|
||||||
getSpacePath,
|
getSpacePath,
|
||||||
getSpaceRoomPath,
|
getSpaceRoomPath,
|
||||||
|
withRoomEventId,
|
||||||
} from '../pages/pathUtils';
|
} from '../pages/pathUtils';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room';
|
import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room';
|
||||||
import { roomToParentsAtom } from '../state/room/roomToParents';
|
import { roomToParentsAtom } from '../state/room/roomToParents';
|
||||||
import { mDirectAtom } from '../state/mDirectList';
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
|
import { useSelectedRoom } from './router/useSelectedRoom';
|
||||||
import { useSelectedSpace } from './router/useSelectedSpace';
|
import { useSelectedSpace } from './router/useSelectedSpace';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
|
||||||
export const useRoomNavigate = () => {
|
export const useRoomNavigate = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
const spaceSelectedId = useSelectedSpace();
|
const spaceSelectedId = useSelectedSpace();
|
||||||
|
const selectedRoomId = useSelectedRoom();
|
||||||
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
|
||||||
const navigateSpace = useCallback(
|
const navigateSpace = useCallback(
|
||||||
@@ -34,6 +38,14 @@ export const useRoomNavigate = () => {
|
|||||||
|
|
||||||
const navigateRoom = useCallback(
|
const navigateRoom = useCallback(
|
||||||
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
|
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
|
||||||
|
// Already viewing this room — keep the current home/direct/space URL and only
|
||||||
|
// change the event id. Otherwise guessPerfectParent can yank the sidebar to
|
||||||
|
// wherever the room "belongs".
|
||||||
|
if (selectedRoomId === roomId) {
|
||||||
|
navigate(withRoomEventId(location.pathname, eventId), opts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
|
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
|
||||||
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
|
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
|
||||||
@@ -68,7 +80,16 @@ export const useRoomNavigate = () => {
|
|||||||
|
|
||||||
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
|
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
|
||||||
},
|
},
|
||||||
[mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools]
|
[
|
||||||
|
mx,
|
||||||
|
navigate,
|
||||||
|
location.pathname,
|
||||||
|
selectedRoomId,
|
||||||
|
spaceSelectedId,
|
||||||
|
roomToParents,
|
||||||
|
mDirects,
|
||||||
|
developerTools,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
60
src/app/hooks/useRoomUploadFiles.ts
Normal file
60
src/app/hooks/useRoomUploadFiles.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import {
|
||||||
|
roomIdToUploadItemsAtomFamily,
|
||||||
|
TUploadItem,
|
||||||
|
} from '../state/room/roomInputDrafts';
|
||||||
|
import { encryptFile } from '../utils/matrix';
|
||||||
|
import { safeFile } from '../utils/mimeTypes';
|
||||||
|
import { fulfilledPromiseSettledResult } from '../utils/common';
|
||||||
|
import { useFilePicker } from './useFilePicker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueues files into the room composer upload board (shared by RoomInput and header media).
|
||||||
|
*/
|
||||||
|
export const useRoomUploadFiles = (room: Room) => {
|
||||||
|
const setSelectedFiles = useSetAtom(roomIdToUploadItemsAtomFamily(room.roomId));
|
||||||
|
|
||||||
|
const handleFiles = useCallback(
|
||||||
|
async (files: File[]) => {
|
||||||
|
const safeFiles = files.map(safeFile);
|
||||||
|
const fileItems: TUploadItem[] = [];
|
||||||
|
|
||||||
|
if (room.hasEncryptionStateEvent()) {
|
||||||
|
const encryptFiles = fulfilledPromiseSettledResult(
|
||||||
|
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
|
||||||
|
);
|
||||||
|
encryptFiles.forEach((ef) =>
|
||||||
|
fileItems.push({
|
||||||
|
...ef,
|
||||||
|
metadata: {
|
||||||
|
markedAsSpoiler: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
safeFiles.forEach((f) =>
|
||||||
|
fileItems.push({
|
||||||
|
file: f,
|
||||||
|
originalFile: f,
|
||||||
|
encInfo: undefined,
|
||||||
|
metadata: {
|
||||||
|
markedAsSpoiler: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFiles({
|
||||||
|
type: 'PUT',
|
||||||
|
item: fileItems,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setSelectedFiles, room]
|
||||||
|
);
|
||||||
|
|
||||||
|
const pickFiles = useFilePicker(handleFiles, true);
|
||||||
|
|
||||||
|
return { handleFiles, pickFiles };
|
||||||
|
};
|
||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
} from './pathUtils';
|
} from './pathUtils';
|
||||||
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
|
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
|
||||||
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
|
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
|
||||||
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
|
import { Direct, DirectCreate, DirectRouteRoomProvider, DirectSearch } from './client/direct';
|
||||||
import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch, SpaceIndexRedirect } from './client/space';
|
import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch, SpaceIndexRedirect } from './client/space';
|
||||||
import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout';
|
import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout';
|
||||||
import { ForumFeedPage } from '../features/forum/ForumFeedPage';
|
import { ForumFeedPage } from '../features/forum/ForumFeedPage';
|
||||||
@@ -223,6 +223,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||||||
>
|
>
|
||||||
{mobile ? null : <Route index element={<WelcomePage />} />}
|
{mobile ? null : <Route index element={<WelcomePage />} />}
|
||||||
<Route path={_CREATE_PATH} element={<DirectCreate />} />
|
<Route path={_CREATE_PATH} element={<DirectCreate />} />
|
||||||
|
<Route path={_SEARCH_PATH} element={<DirectSearch />} />
|
||||||
<Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
|
<Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
|
||||||
<Route path={_INVITES_PATH} element={<Invites />} />
|
<Route path={_INVITES_PATH} element={<Invites />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -378,6 +378,14 @@ function MessageNotifications() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Match TitleBar / room bell settings: Default and Mentions rooms only notify on highlights.
|
||||||
|
const notificationType = getNotificationType(mx, room.roomId);
|
||||||
|
const isDm = mDirects.has(room.roomId);
|
||||||
|
const mentionsOnly =
|
||||||
|
notificationType === NotificationType.MentionsAndKeywords ||
|
||||||
|
(notificationType === NotificationType.Default && !isDm);
|
||||||
|
if (mentionsOnly && unreadInfo.highlight === 0) return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
showNotifications &&
|
showNotifications &&
|
||||||
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
|
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
|
||||||
@@ -399,7 +407,6 @@ function MessageNotifications() {
|
|||||||
messageBody = typeof content.body === 'string' ? content.body : undefined;
|
messageBody = typeof content.body === 'string' ? content.body : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDm = room.getJoinedMemberCount() === 2 && !room.isSpaceRoom();
|
|
||||||
notify({
|
notify({
|
||||||
roomName: room.name ?? 'Unknown',
|
roomName: room.name ?? 'Unknown',
|
||||||
roomAvatar: avatarMxc
|
roomAvatar: avatarMxc
|
||||||
@@ -430,6 +437,7 @@ function MessageNotifications() {
|
|||||||
notify,
|
notify,
|
||||||
selectedRoomId,
|
selectedRoomId,
|
||||||
useAuthentication,
|
useAuthentication,
|
||||||
|
mDirects,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
NavItemContent,
|
NavItemContent,
|
||||||
NavLink,
|
NavLink,
|
||||||
} from '../../../components/nav';
|
} from '../../../components/nav';
|
||||||
import { getDirectCreatePath, getDirectRoomPath, getDirectNotificationsPath, getDirectInvitesPath } from '../../pathUtils';
|
import { getDirectCreatePath, getDirectRoomPath, getDirectNotificationsPath, getDirectInvitesPath, getDirectSearchPath } from '../../pathUtils';
|
||||||
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
||||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||||
import { VirtualTile } from '../../../components/virtualizer';
|
import { VirtualTile } from '../../../components/virtualizer';
|
||||||
@@ -41,10 +41,9 @@ import {
|
|||||||
getRoomNotificationMode,
|
getRoomNotificationMode,
|
||||||
useRoomsNotificationPreferencesContext,
|
useRoomsNotificationPreferencesContext,
|
||||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||||
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
|
import { useDirectCreateSelected, useDirectSearchSelected } 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;
|
||||||
};
|
};
|
||||||
@@ -228,6 +227,7 @@ export function Direct() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const createDirectSelected = useDirectCreateSelected();
|
const createDirectSelected = useDirectCreateSelected();
|
||||||
|
const searchSelected = useDirectSearchSelected();
|
||||||
|
|
||||||
const selectedRoomId = useSelectedRoom();
|
const selectedRoomId = useSelectedRoom();
|
||||||
const noRoomToDisplay = directs.length === 0;
|
const noRoomToDisplay = directs.length === 0;
|
||||||
@@ -283,6 +283,22 @@ export function Direct() {
|
|||||||
</NavButton>
|
</NavButton>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
<PluginNavSlot location="direct-messages" />
|
<PluginNavSlot location="direct-messages" />
|
||||||
|
<NavItem variant="Background" radii="400" aria-selected={searchSelected}>
|
||||||
|
<NavLink to={getDirectSearchPath()}>
|
||||||
|
<NavItemContent>
|
||||||
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Avatar size="200" radii="400">
|
||||||
|
<Icon src={Icons.Search} size="100" filled={searchSelected} />
|
||||||
|
</Avatar>
|
||||||
|
<Box as="span" grow="Yes">
|
||||||
|
<Text as="span" size="Inherit" truncate>
|
||||||
|
Message Search
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</NavItemContent>
|
||||||
|
</NavLink>
|
||||||
|
</NavItem>
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
<NavCategory>
|
<NavCategory>
|
||||||
<NotificationsNavItem />
|
<NotificationsNavItem />
|
||||||
|
|||||||
55
src/app/pages/client/direct/Search.tsx
Normal file
55
src/app/pages/client/direct/Search.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { Box, Text, Scroll, IconButton } from 'folds';
|
||||||
|
import { Icon, Icons } from '../../../components/icons';
|
||||||
|
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
|
||||||
|
import { MessageSearch } from '../../../features/message-search';
|
||||||
|
import { useDirectRooms } from './useDirectRooms';
|
||||||
|
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||||
|
import { BackRouteHandler } from '../../../components/BackRouteHandler';
|
||||||
|
|
||||||
|
export function DirectSearch() {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rooms = useDirectRooms();
|
||||||
|
const screenSize = useScreenSizeContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader balance>
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Box grow="Yes" basis="No">
|
||||||
|
{screenSize === ScreenSize.Mobile && (
|
||||||
|
<BackRouteHandler>
|
||||||
|
{(onBack) => (
|
||||||
|
<IconButton onClick={onBack}>
|
||||||
|
<Icon src={Icons.ArrowLeft} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</BackRouteHandler>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="Center" alignItems="Center" gap="200">
|
||||||
|
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Search} />}
|
||||||
|
<Text size="H3" truncate>
|
||||||
|
Message Search
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes" basis="No" />
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
<Box style={{ position: 'relative' }} grow="Yes">
|
||||||
|
<Scroll ref={scrollRef} hideTrack visibility="Hover">
|
||||||
|
<PageContent>
|
||||||
|
<PageContentCenter>
|
||||||
|
<MessageSearch
|
||||||
|
defaultRoomsFilterName="Direct Messages"
|
||||||
|
allowGlobal
|
||||||
|
rooms={rooms}
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
/>
|
||||||
|
</PageContentCenter>
|
||||||
|
</PageContent>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './Direct';
|
export * from './Direct';
|
||||||
export * from './RoomProvider';
|
export * from './RoomProvider';
|
||||||
export * from './DirectCreate';
|
export * from './DirectCreate';
|
||||||
|
export * from './Search';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
DIRECT_CREATE_PATH,
|
DIRECT_CREATE_PATH,
|
||||||
DIRECT_PATH,
|
DIRECT_PATH,
|
||||||
DIRECT_ROOM_PATH,
|
DIRECT_ROOM_PATH,
|
||||||
|
DIRECT_SEARCH_PATH,
|
||||||
DIRECT_NOTIFICATIONS_PATH,
|
DIRECT_NOTIFICATIONS_PATH,
|
||||||
DIRECT_INVITES_PATH,
|
DIRECT_INVITES_PATH,
|
||||||
EXPLORE_FEATURED_PATH,
|
EXPLORE_FEATURED_PATH,
|
||||||
@@ -121,6 +122,7 @@ export const getHomeRoomPath = (roomIdOrAlias: string, eventId?: string): string
|
|||||||
|
|
||||||
export const getDirectPath = (): string => DIRECT_PATH;
|
export const getDirectPath = (): string => DIRECT_PATH;
|
||||||
export const getDirectCreatePath = (): string => DIRECT_CREATE_PATH;
|
export const getDirectCreatePath = (): string => DIRECT_CREATE_PATH;
|
||||||
|
export const getDirectSearchPath = (): string => DIRECT_SEARCH_PATH;
|
||||||
export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): string => {
|
export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): string => {
|
||||||
const params = {
|
const params = {
|
||||||
roomIdOrAlias,
|
roomIdOrAlias,
|
||||||
@@ -180,6 +182,28 @@ export const getSpaceRoomPath = (
|
|||||||
return generatePath(SPACE_ROOM_PATH, params);
|
return generatePath(SPACE_ROOM_PATH, params);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the current room URL (home / direct / space) and only add, replace, or clear
|
||||||
|
* the optional `$eventId` segment. Used so same-room jumps (media, permalinks) don't
|
||||||
|
* re-home the room into a different sidebar section.
|
||||||
|
*/
|
||||||
|
export const withRoomEventId = (pathname: string, eventId?: string): string => {
|
||||||
|
const parts = pathname.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
if (parts.length > 0) {
|
||||||
|
const lastDecoded = decodeRouteParam(parts[parts.length - 1]);
|
||||||
|
if (lastDecoded?.startsWith('$')) {
|
||||||
|
parts.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventId) {
|
||||||
|
parts.push(encodeURIComponent(eventId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/${parts.join('/')}/`;
|
||||||
|
};
|
||||||
|
|
||||||
export const getExplorePath = (): string => EXPLORE_PATH;
|
export const getExplorePath = (): string => EXPLORE_PATH;
|
||||||
export const getExploreFeaturedPath = (): string => EXPLORE_FEATURED_PATH;
|
export const getExploreFeaturedPath = (): string => EXPLORE_FEATURED_PATH;
|
||||||
export const getExploreServerPath = (server: string): string => {
|
export const getExploreServerPath = (server: string): string => {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export type DirectCreateSearchParams = {
|
|||||||
userId?: string;
|
userId?: string;
|
||||||
};
|
};
|
||||||
export const DIRECT_CREATE_PATH = `/direct/${_CREATE_PATH}`;
|
export const DIRECT_CREATE_PATH = `/direct/${_CREATE_PATH}`;
|
||||||
|
export const DIRECT_SEARCH_PATH = `/direct/${_SEARCH_PATH}`;
|
||||||
export const DIRECT_ROOM_PATH = `/direct/${_ROOM_PATH}`;
|
export const DIRECT_ROOM_PATH = `/direct/${_ROOM_PATH}`;
|
||||||
|
|
||||||
export const SPACE_PATH = '/:spaceIdOrAlias/';
|
export const SPACE_PATH = '/:spaceIdOrAlias/';
|
||||||
|
|||||||
17
src/app/state/mediaDrawer.ts
Normal file
17
src/app/state/mediaDrawer.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
/** Session flag for the Shared Media side drawer (mutually exclusive with Members). */
|
||||||
|
export const isMediaDrawerAtom = atom(false);
|
||||||
|
|
||||||
|
const scrollTops = new Map<string, number>();
|
||||||
|
|
||||||
|
export const getMediaDrawerScrollTop = (roomId: string): number | undefined =>
|
||||||
|
scrollTops.get(roomId);
|
||||||
|
|
||||||
|
export const setMediaDrawerScrollTop = (roomId: string, top: number): void => {
|
||||||
|
scrollTops.set(roomId, top);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearMediaDrawerScrollTop = (roomId: string): void => {
|
||||||
|
scrollTops.delete(roomId);
|
||||||
|
};
|
||||||
@@ -30,3 +30,17 @@ export const saveRoomScrollState = (roomId: string, state: RoomScrollState): voi
|
|||||||
export const clearRoomScrollState = (roomId: string): void => {
|
export const clearRoomScrollState = (roomId: string): void => {
|
||||||
cache.delete(roomId);
|
cache.delete(roomId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Survives RoomTimeline remounts (e.g. clearing an event permalink URL). */
|
||||||
|
const returnAnchors = new Map<string, string>();
|
||||||
|
|
||||||
|
export const getRoomReturnAnchor = (roomId: string): string | undefined =>
|
||||||
|
returnAnchors.get(roomId);
|
||||||
|
|
||||||
|
export const setRoomReturnAnchor = (roomId: string, eventId: string): void => {
|
||||||
|
returnAnchors.set(roomId, eventId);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearRoomReturnAnchor = (roomId: string): void => {
|
||||||
|
returnAnchors.delete(roomId);
|
||||||
|
};
|
||||||
|
|||||||
@@ -259,24 +259,35 @@ body.stationery-dark-theme {
|
|||||||
transition: width 0.2s ease;
|
transition: width 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* Scrollbar styling — transparent until hover so Folds Hover/hideTrack
|
||||||
|
scroll areas (e.g. message composer) don't show a permanent side bar */
|
||||||
.twilight-theme ::-webkit-scrollbar {
|
.twilight-theme ::-webkit-scrollbar {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.twilight-theme ::-webkit-scrollbar-track {
|
.twilight-theme ::-webkit-scrollbar-track {
|
||||||
background: rgba(20, 18, 31, 0.5);
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.twilight-theme ::-webkit-scrollbar-thumb {
|
.twilight-theme ::-webkit-scrollbar-thumb {
|
||||||
background: linear-gradient(180deg, #3E3A6A 0%, #565096 100%);
|
background: transparent;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border: 2px solid rgba(20, 18, 31, 0.5);
|
border: 2px solid transparent;
|
||||||
transition: background 0.2s ease;
|
background-clip: padding-box;
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.twilight-theme ::-webkit-scrollbar-thumb:hover {
|
.twilight-theme *:hover::-webkit-scrollbar-track {
|
||||||
|
background: rgba(20, 18, 31, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.twilight-theme *:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: linear-gradient(180deg, #3E3A6A 0%, #565096 100%);
|
||||||
|
border-color: rgba(20, 18, 31, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.twilight-theme *:hover::-webkit-scrollbar-thumb:hover {
|
||||||
background: linear-gradient(180deg, #4A4580 0%, #625BAC 100%);
|
background: linear-gradient(180deg, #4A4580 0%, #625BAC 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,24 +368,34 @@ body.stationery-dark-theme {
|
|||||||
transition: width 0.2s ease;
|
transition: width 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* Scrollbar styling — transparent until hover (see twilight note above) */
|
||||||
.mocha-theme ::-webkit-scrollbar {
|
.mocha-theme ::-webkit-scrollbar {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mocha-theme ::-webkit-scrollbar-track {
|
.mocha-theme ::-webkit-scrollbar-track {
|
||||||
background: rgba(26, 22, 20, 0.5);
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mocha-theme ::-webkit-scrollbar-thumb {
|
.mocha-theme ::-webkit-scrollbar-thumb {
|
||||||
background: linear-gradient(180deg, #54493F 0%, #706151 100%);
|
background: transparent;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border: 2px solid rgba(26, 22, 20, 0.5);
|
border: 2px solid transparent;
|
||||||
transition: background 0.2s ease;
|
background-clip: padding-box;
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mocha-theme ::-webkit-scrollbar-thumb:hover {
|
.mocha-theme *:hover::-webkit-scrollbar-track {
|
||||||
|
background: rgba(26, 22, 20, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mocha-theme *:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: linear-gradient(180deg, #54493F 0%, #706151 100%);
|
||||||
|
border-color: rgba(26, 22, 20, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mocha-theme *:hover::-webkit-scrollbar-thumb:hover {
|
||||||
background: linear-gradient(180deg, #625548 0%, #7E6D5A 100%);
|
background: linear-gradient(180deg, #625548 0%, #7E6D5A 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1762,16 +1783,26 @@ body.stationery-dark-theme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stationery ::-webkit-scrollbar-track {
|
.stationery ::-webkit-scrollbar-track {
|
||||||
background: color-mix(in srgb, var(--manila) 45%, transparent);
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stationery ::-webkit-scrollbar-thumb {
|
.stationery ::-webkit-scrollbar-thumb {
|
||||||
background: linear-gradient(180deg, var(--manila-deep) 0%, var(--manila-edge) 100%);
|
background: transparent;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
border: 2px solid color-mix(in srgb, var(--manila) 45%, transparent);
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stationery ::-webkit-scrollbar-thumb:hover {
|
.stationery *:hover::-webkit-scrollbar-track {
|
||||||
|
background: color-mix(in srgb, var(--manila) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stationery *:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: linear-gradient(180deg, var(--manila-deep) 0%, var(--manila-edge) 100%);
|
||||||
|
border-color: color-mix(in srgb, var(--manila) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stationery *:hover::-webkit-scrollbar-thumb:hover {
|
||||||
background: linear-gradient(180deg, var(--manila-edge) 0%, var(--manila-dark) 100%);
|
background: linear-gradient(180deg, var(--manila-edge) 0%, var(--manila-dark) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user