Compare commits

...

17 Commits

Author SHA1 Message Date
Ajay Bura
444b2feb9e update folds 2025-11-03 15:30:52 +05:30
Ajay Bura
f35aa384e5 thread view - WIP 2025-11-03 15:29:51 +05:30
Ajay Bura
b5fd41f862 clear active thread state on logout 2025-11-03 15:28:52 +05:30
Ajay Bura
3d4c91c969 add onClick prop to thread selector 2025-11-03 15:28:42 +05:30
Ajay Bura
38cc6e6f3a add room to active thread atom 2025-11-03 15:27:02 +05:30
Ajay Bura
12bcbc2e78 load threads in My Threads menu 2025-10-22 16:22:31 +05:30
Ajay Bura
e44ca92422 remove avatar from threads selector 2025-10-22 16:21:33 +05:30
Ajay Bura
174b315278 move timeline utils functions to new file 2025-10-22 16:21:16 +05:30
Ajay Bura
d73428ee3d add option to inherit priority in time component 2025-10-22 16:20:22 +05:30
Ajay Bura
f2c5a595b9 Merge branch 'dev' into fix-257 2025-09-27 10:00:30 +05:30
Ajay Bura
a6a3ac3b24 redesign thread selector 2025-09-25 12:17:44 +05:30
Ajay Bura
67c6785bf3 inherit font weight for time component 2025-09-25 12:17:14 +05:30
Ajay Bura
d36938e1fd fix typo 2025-09-24 16:32:05 +05:30
Ajay Bura
1914606895 threads - WIP 2025-09-24 15:57:15 +05:30
Ajay Bura
19096c3543 Merge branch 'dev' into fix-257 2025-09-21 09:54:55 +05:30
Ajay Bura
737cc09fea Merge branch 'dev' into fix-257 2025-09-15 13:16:59 +05:30
Ajay Bura
154f234d0c thread menu - WIP 2025-09-15 13:16:43 +05:30
27 changed files with 1248 additions and 87 deletions

8
package-lock.json generated
View File

@@ -32,7 +32,7 @@
"emojibase-data": "15.3.2",
"file-saver": "2.0.5",
"focus-trap-react": "10.0.2",
"folds": "2.3.0",
"folds": "2.5.0",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
"i18next": "23.12.2",
@@ -7157,9 +7157,9 @@
}
},
"node_modules/folds": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/folds/-/folds-2.3.0.tgz",
"integrity": "sha512-1KoM21jrg5daxvKrmSY0V04wa946KlNT0z6h017Rsnw2fdtNC6J0f34Ce5GF46Tzi00gZ/7SvCDXMzW/7e5s0w==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/folds/-/folds-2.5.0.tgz",
"integrity": "sha512-UJhvXAQ1XnZ9w10KJwSW+frvzzWE/zcF0dH3fDVCD70RFHAxwEi0UkkVS8CaZGxZF2Wvt3qTJyTS5LW3LwwUAw==",
"license": "Apache-2.0",
"peerDependencies": {
"@vanilla-extract/css": "1.9.2",

View File

@@ -43,7 +43,7 @@
"emojibase-data": "15.3.2",
"file-saver": "2.0.5",
"focus-trap-react": "10.0.2",
"folds": "2.3.0",
"folds": "2.5.0",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
"i18next": "23.12.2",

View File

@@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
export const Time = style({
fontWeight: 'inherit',
flexShrink: 0,
});

View File

@@ -1,12 +1,15 @@
import React, { ComponentProps } from 'react';
import { Text, as } from 'folds';
import classNames from 'classnames';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
import * as css from './Time.css';
export type TimeProps = {
compact?: boolean;
ts: number;
hour24Clock: boolean;
dateFormatString: string;
inheritPriority?: boolean;
};
/**
@@ -22,7 +25,7 @@ export type TimeProps = {
* @returns {React.ReactElement} A <Text as="time"> element with the formatted date/time.
*/
export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
({ compact, hour24Clock, dateFormatString, ts, ...props }, ref) => {
({ compact, hour24Clock, dateFormatString, ts, inheritPriority, className, ...props }, ref) => {
const formattedTime = timeHourMinute(ts, hour24Clock);
let time = '';
@@ -33,11 +36,18 @@ export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
} else if (yesterday(ts)) {
time = `Yesterday ${formattedTime}`;
} else {
time = `${timeDayMonYear(ts, dateFormatString)} ${formattedTime}`;
time = `${timeDayMonYear(ts, dateFormatString)}, ${formattedTime}`;
}
return (
<Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
<Text
as="time"
className={classNames(css.Time, className)}
size="T200"
priority={inheritPriority ? undefined : '300'}
{...props}
ref={ref}
>
{time}
</Text>
);

View File

@@ -13,6 +13,8 @@ import { useKeyDown } from '../../hooks/useKeyDown';
import { markAsRead } from '../../utils/notifications';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers';
import { ThreadView } from './thread-view';
import { useActiveThread } from '../../state/hooks/roomToActiveThread';
export function Room() {
const { eventId } = useParams();
@@ -25,6 +27,8 @@ export function Room() {
const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId);
const threadId = useActiveThread(room.roomId);
useKeyDown(
window,
useCallback(
@@ -41,11 +45,19 @@ export function Room() {
<PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes">
<RoomView room={room} eventId={eventId} />
{screenSize === ScreenSize.Desktop && isDrawer && (
{threadId ? (
<>
<Line variant="Background" direction="Vertical" size="300" />
<MembersDrawer key={room.roomId} room={room} members={members} />
<Line variant="Surface" direction="Vertical" size="300" />
<ThreadView threadId={threadId} />
</>
) : (
screenSize === ScreenSize.Desktop &&
isDrawer && (
<>
<Line variant="Background" direction="Vertical" size="300" />
<MembersDrawer key={room.roomId} room={room} members={members} />
</>
)
)}
</Box>
</PowerLevelsContextProvider>

View File

@@ -77,6 +77,7 @@ import {
decryptAllTimelineEvent,
getEditedEvent,
getEventReactions,
getEventThreadDetail,
getLatestEditableEvt,
getMemberDisplayName,
getReactionContent,
@@ -126,6 +127,18 @@ import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/u
import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { ThreadSelector, ThreadSelectorContainer } from './message/thread-selector';
import {
getEventIdAbsoluteIndex,
getFirstLinkedTimeline,
getLinkedTimelines,
getTimelineAndBaseIndex,
getTimelineEvent,
getTimelineRelativeIndex,
getTimelinesEventsCount,
timelineToEventsCount,
} from './utils';
import { useThreadSelector } from '../../state/hooks/roomToActiveThread';
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => (
@@ -150,79 +163,6 @@ const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
)
);
export const getLiveTimeline = (room: Room): EventTimeline =>
room.getUnfilteredTimelineSet().getLiveTimeline();
export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => {
const timelineSet = room.getUnfilteredTimelineSet();
return timelineSet.getTimelineForEvent(eventId) ?? undefined;
};
export const getFirstLinkedTimeline = (
timeline: EventTimeline,
direction: Direction
): EventTimeline => {
const linkedTm = timeline.getNeighbouringTimeline(direction);
if (!linkedTm) return timeline;
return getFirstLinkedTimeline(linkedTm, direction);
};
export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
const timelines: EventTimeline[] = [];
for (
let nextTimeline: EventTimeline | null = firstTimeline;
nextTimeline;
nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward)
) {
timelines.push(nextTimeline);
}
return timelines;
};
export const timelineToEventsCount = (t: EventTimeline) => t.getEvents().length;
export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => {
const timelineEventCountReducer = (count: number, tm: EventTimeline) =>
count + timelineToEventsCount(tm);
return timelines.reduce(timelineEventCountReducer, 0);
};
export const getTimelineAndBaseIndex = (
timelines: EventTimeline[],
index: number
): [EventTimeline | undefined, number] => {
let uptoTimelineLen = 0;
const timeline = timelines.find((t) => {
uptoTimelineLen += t.getEvents().length;
if (index < uptoTimelineLen) return true;
return false;
});
if (!timeline) return [undefined, 0];
return [timeline, uptoTimelineLen - timeline.getEvents().length];
};
export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) =>
absoluteIndex - timelineBaseIndex;
export const getTimelineEvent = (timeline: EventTimeline, index: number): MatrixEvent | undefined =>
timeline.getEvents()[index];
export const getEventIdAbsoluteIndex = (
timelines: EventTimeline[],
eventTimeline: EventTimeline,
eventId: string
): number | undefined => {
const timelineIndex = timelines.findIndex((t) => t === eventTimeline);
if (timelineIndex === -1) return undefined;
const eventIndex = eventTimeline.getEvents().findIndex((evt) => evt.getId() === eventId);
if (eventIndex === -1) return undefined;
const baseIndex = timelines
.slice(0, timelineIndex)
.reduce((accValue, timeline) => timeline.getEvents().length + accValue, 0);
return baseIndex + eventIndex;
};
type RoomTimelineProps = {
room: Room;
eventId?: string;
@@ -232,6 +172,14 @@ type RoomTimelineProps = {
const PAGINATION_LIMIT = 80;
export const getLiveTimeline = (room: Room): EventTimeline =>
room.getUnfilteredTimelineSet().getLiveTimeline();
export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => {
const timelineSet = room.getUnfilteredTimelineSet();
return timelineSet.getTimelineForEvent(eventId) ?? undefined;
};
type Timeline = {
linkedTimelines: EventTimeline[];
range: ItemRange;
@@ -482,6 +430,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const spoilerClickHandler = useSpoilerClickHandler();
const openUserRoomProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
const handleThreadClick = useThreadSelector(room.roomId);
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
@@ -1014,6 +963,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
},
[editor]
);
const { t } = useTranslation();
const renderMatrixEvent = useMatrixEventRenderer<
@@ -1034,6 +984,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? '';
const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const threadDetail = getEventThreadDetail(mEvent);
return (
<Message
@@ -1107,6 +1058,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
outlineAttachment={messageLayout === MessageLayout.Bubble}
/>
)}
{threadDetail && (
<ThreadSelectorContainer>
<ThreadSelector
room={room}
threadId={mEventId}
threadDetail={threadDetail}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
outlined={messageLayout === MessageLayout.Bubble}
onClick={handleThreadClick}
/>
</ThreadSelectorContainer>
)}
</Message>
);
},

View File

@@ -69,6 +69,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { ThreadsMenu } from './threads-menu';
type RoomMenuProps = {
room: Room;
@@ -263,6 +264,7 @@ export function RoomViewHeader() {
const space = useSpaceOptionally();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const [threadsMenuAnchor, setThreadsMenuAnchor] = useState<RectCords>();
const mDirects = useAtomValue(mDirectAtom);
const pinnedEvents = useRoomPinnedEvents(room);
@@ -295,6 +297,10 @@ export function RoomViewHeader() {
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
const handleOpenThreadsMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setThreadsMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
return (
<PageHeader balance={screenSize === ScreenSize.Mobile}>
<Box grow="Yes" gap="300">
@@ -424,6 +430,27 @@ export function RoomViewHeader() {
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>My Threads</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
style={{ position: 'relative' }}
onClick={handleOpenThreadsMenu}
ref={triggerRef}
aria-pressed={!!threadsMenuAnchor}
>
<Icon size="400" src={Icons.Thread} filled={!!threadsMenuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={pinMenuAnchor}
position="Bottom"
@@ -443,6 +470,25 @@ export function RoomViewHeader() {
</FocusTrap>
}
/>
<PopOut
anchor={threadsMenuAnchor}
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setThreadsMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<ThreadsMenu room={room} requestClose={() => setThreadsMenuAnchor(undefined)} />
</FocusTrap>
}
/>
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"

View File

@@ -0,0 +1,83 @@
import { Box, Icon, Icons, Line, Text } from 'folds';
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import { IThreadBundledRelationship, Room } from 'matrix-js-sdk';
import * as css from './styles.css';
import { getMemberDisplayName } from '../../../../utils/room';
import { getMxIdLocalPart } from '../../../../utils/matrix';
import { Time } from '../../../../components/message';
export function ThreadSelectorContainer({ children }: { children: ReactNode }) {
return <Box className={css.ThreadSelectorContainer}>{children}</Box>;
}
type ThreadSelectorProps = {
room: Room;
threadId: string;
threadDetail: IThreadBundledRelationship;
outlined?: boolean;
hour24Clock: boolean;
dateFormatString: string;
onClick?: (threadId: string) => void;
};
export function ThreadSelector({
room,
threadId,
threadDetail,
outlined,
hour24Clock,
dateFormatString,
onClick,
}: ThreadSelectorProps) {
const latestEvent = threadDetail.latest_event;
const latestSenderId = latestEvent.sender;
const latestDisplayName =
getMemberDisplayName(room, latestSenderId) ??
getMxIdLocalPart(latestSenderId) ??
latestSenderId;
const latestEventTs = latestEvent.origin_server_ts;
return (
<Box
as="button"
type="button"
className={classNames(css.ThreadSelector, outlined && css.ThreadSectorOutlined)}
alignItems="Center"
gap="300"
onClick={() => onClick?.(threadId)}
>
<Box className={css.ThreadRepliesCount} shrink="No" alignItems="Center" gap="200">
<Icon size="100" src={Icons.Thread} filled />
<Text size="L400">
{threadDetail.count} {threadDetail.count === 1 ? 'Reply' : 'Replies'}
</Text>
</Box>
{latestSenderId && (
<>
<Line
className={css.ThreadSelectorDivider}
direction="Vertical"
variant="SurfaceVariant"
/>
<Box gap="200" alignItems="Inherit">
<Text size="T200" truncate>
<span>Last reply by </span>
<b>{latestDisplayName}</b>
<span> </span>
<Time
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
ts={latestEventTs}
inheritPriority
/>
</Text>
<Icon size="100" src={Icons.ChevronRight} />
</Box>
</>
)}
</Box>
);
}

View File

@@ -0,0 +1 @@
export * from './ThreadSelector';

View File

@@ -0,0 +1,37 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
import { ContainerColor } from '../../../../styles/ContainerColor.css';
export const ThreadSelectorContainer = style({
marginTop: config.space.S200,
});
export const ThreadSelector = style([
ContainerColor({ variant: 'SurfaceVariant' }),
{
padding: config.space.S200,
borderRadius: config.radii.R400,
cursor: 'pointer',
selectors: {
'&:hover, &:focus-visible': {
backgroundColor: color.SurfaceVariant.ContainerHover,
},
'&:active': {
backgroundColor: color.SurfaceVariant.ContainerActive,
},
},
},
]);
export const ThreadSectorOutlined = style({
borderWidth: config.borderWidth.B300,
});
export const ThreadSelectorDivider = style({
height: toRem(16),
});
export const ThreadRepliesCount = style({
color: color.Primary.Main,
});

View File

@@ -0,0 +1,79 @@
import React from 'react';
import { Box, Icon, IconButton, Icons, Scroll, Text, Tooltip, TooltipProvider } from 'folds';
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
import { Page, PageHeader } from '../../../components/page';
import * as css from './styles.css';
import { useRoom } from '../../../hooks/useRoom';
import { useThreadClose } from '../../../state/hooks/roomToActiveThread';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
type ThreadViewProps = {
threadId: string;
};
export function ThreadView({ threadId }: ThreadViewProps) {
const room = useRoom();
const screenSize = useScreenSizeContext();
const floating = screenSize !== ScreenSize.Desktop;
const closeThread = useThreadClose(room.roomId);
const thread = room.getThread(threadId);
const events = thread?.events ?? [];
return (
<FocusTrap
paused={!floating}
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: floating ? closeThread : undefined,
}}
>
<Page
className={classNames(css.ThreadView, {
[css.ThreadViewFloating]: floating,
})}
>
<PageHeader>
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes">
<Text size="H5" truncate>
Thread
</Text>
</Box>
<Box shrink="No" alignItems="Center">
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton ref={triggerRef} variant="Surface" onClick={closeThread}>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
</Box>
</PageHeader>
<Box grow="Yes" direction="Column">
<Scroll visibility="Hover" hideTrack>
<div>
{events.map((mEvent) => (
<p style={{ padding: `8px 16px` }} key={mEvent.getId()}>
{mEvent.sender?.name}: {mEvent.getContent().body}
</p>
))}
</div>
</Scroll>
</Box>
</Page>
</FocusTrap>
);
}

View File

@@ -0,0 +1 @@
export * from './ThreadView';

View File

@@ -0,0 +1,21 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
export const ThreadView = style({
width: toRem(456),
flexShrink: 0,
flexGrow: 0,
});
export const ThreadViewFloating = style({
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
zIndex: 1,
maxWidth: toRem(456),
flexShrink: 1,
width: '100vw',
boxShadow: config.shadow.E400,
});

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { Box, Icon, Icons, toRem, Text, config } from 'folds';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { BreakWord } from '../../../styles/Text.css';
export function ThreadsError({ error }: { error: Error }) {
return (
<Box
className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{
marginBottom: config.space.S200,
padding: `${config.space.S700} ${config.space.S400} ${toRem(60)}`,
borderRadius: config.radii.R300,
}}
grow="Yes"
direction="Column"
gap="400"
justifyContent="Center"
alignItems="Center"
>
<Icon src={Icons.Warning} size="600" />
<Box style={{ maxWidth: toRem(300) }} direction="Column" gap="200" alignItems="Center">
<Text size="H4" align="Center">
{error.name}
</Text>
<Text className={BreakWord} size="T400" align="Center">
{error.message}
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,23 @@
import { Box, config, Spinner } from 'folds';
import React from 'react';
import { ContainerColor } from '../../../styles/ContainerColor.css';
export function ThreadsLoading() {
return (
<Box
className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{
marginBottom: config.space.S200,
padding: config.space.S700,
borderRadius: config.radii.R300,
}}
grow="Yes"
direction="Column"
gap="400"
justifyContent="Center"
alignItems="Center"
>
<Spinner variant="Secondary" />
</Box>
);
}

View File

@@ -0,0 +1,18 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
export const ThreadsMenu = style({
display: 'flex',
maxWidth: toRem(548),
width: '100vw',
maxHeight: '90vh',
});
export const ThreadsMenuHeader = style({
paddingLeft: config.space.S400,
paddingRight: config.space.S200,
});
export const ThreadsMenuContent = style({
paddingLeft: config.space.S200,
});

View File

@@ -0,0 +1,99 @@
/* eslint-disable react/destructuring-assignment */
import React, { forwardRef, useMemo } from 'react';
import { EventTimelineSet, Room } from 'matrix-js-sdk';
import { Box, config, Header, Icon, IconButton, Icons, Menu, Scroll, Text, toRem } from 'folds';
import * as css from './ThreadsMenu.css';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { useRoomMyThreads } from '../../../hooks/useRoomThreads';
import { AsyncStatus } from '../../../hooks/useAsyncCallback';
import { getLinkedTimelines, getTimelinesEventsCount } from '../utils';
import { ThreadsTimeline } from './ThreadsTimeline';
import { ThreadsLoading } from './ThreadsLoading';
import { ThreadsError } from './ThreadsError';
const getTimelines = (timelineSet: EventTimelineSet) => {
const liveTimeline = timelineSet.getLiveTimeline();
const linkedTimelines = getLinkedTimelines(liveTimeline);
return linkedTimelines;
};
function NoThreads() {
return (
<Box
className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{
marginBottom: config.space.S200,
padding: `${config.space.S700} ${config.space.S400} ${toRem(60)}`,
borderRadius: config.radii.R300,
}}
grow="Yes"
direction="Column"
gap="400"
justifyContent="Center"
alignItems="Center"
>
<Icon src={Icons.Thread} size="600" />
<Box style={{ maxWidth: toRem(300) }} direction="Column" gap="200" alignItems="Center">
<Text size="H4" align="Center">
No Threads Yet
</Text>
<Text size="T400" align="Center">
Threads youre participating in will appear here.
</Text>
</Box>
</Box>
);
}
type ThreadsMenuProps = {
room: Room;
requestClose: () => void;
};
export const ThreadsMenu = forwardRef<HTMLDivElement, ThreadsMenuProps>(
({ room, requestClose }, ref) => {
const threadsState = useRoomMyThreads(room);
const threadsTimelineSet =
threadsState.status === AsyncStatus.Success ? threadsState.data : undefined;
const linkedTimelines = useMemo(() => {
if (!threadsTimelineSet) return undefined;
return getTimelines(threadsTimelineSet);
}, [threadsTimelineSet]);
const hasEvents = linkedTimelines && getTimelinesEventsCount(linkedTimelines) > 0;
return (
<Menu ref={ref} className={css.ThreadsMenu}>
<Box grow="Yes" direction="Column">
<Header className={css.ThreadsMenuHeader} size="500">
<Box grow="Yes">
<Text size="H5">My Threads</Text>
</Box>
<Box shrink="No">
<IconButton size="300" onClick={requestClose} radii="300">
<Icon src={Icons.Cross} size="400" />
</IconButton>
</Box>
</Header>
<Box grow="Yes">
{threadsState.status === AsyncStatus.Success && hasEvents ? (
<ThreadsTimeline timelines={linkedTimelines} requestClose={requestClose} />
) : (
<Scroll size="300" hideTrack visibility="Hover">
<Box className={css.ThreadsMenuContent} direction="Column" gap="100">
{(threadsState.status === AsyncStatus.Loading ||
threadsState.status === AsyncStatus.Idle) && <ThreadsLoading />}
{threadsState.status === AsyncStatus.Success && !hasEvents && <NoThreads />}
{threadsState.status === AsyncStatus.Error && (
<ThreadsError error={threadsState.error} />
)}
</Box>
</Scroll>
)}
</Box>
</Box>
</Menu>
);
}
);

View File

@@ -0,0 +1,488 @@
/* eslint-disable react/destructuring-assignment */
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Direction, EventTimeline, MatrixEvent, Room } from 'matrix-js-sdk';
import { Avatar, Box, Chip, config, Icon, Icons, Scroll, Text } from 'folds';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { HTMLReactParserOptions } from 'html-react-parser';
import { useVirtualizer } from '@tanstack/react-virtual';
import { SequenceCard } from '../../../components/sequence-card';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import {
AvatarBase,
ImageContent,
MessageNotDecryptedContent,
MessageUnsupportedContent,
ModernLayout,
MSticker,
RedactedContent,
Reply,
Time,
Username,
UsernameBold,
} from '../../../components/message';
import { UserAvatar } from '../../../components/user-avatar';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import {
getEditedEvent,
getEventThreadDetail,
getMemberAvatarMxc,
getMemberDisplayName,
} from '../../../utils/room';
import { GetContentCallback, MessageEvent } from '../../../../types/matrix/room';
import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
import {
factoryRenderLinkifyWithMention,
getReactCustomHtmlParser,
LINKIFY_OPTS,
makeMentionCustomProps,
renderMatrixMention,
} from '../../../plugins/react-custom-html-parser';
import { RenderMatrixEvent, useMatrixEventRenderer } from '../../../hooks/useMatrixEventRenderer';
import { RenderMessageContent } from '../../../components/RenderMessageContent';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import * as customHtmlCss from '../../../styles/CustomHtml.css';
import { EncryptedContent } from '../message';
import { Image } from '../../../components/media';
import { ImageViewer } from '../../../components/image-viewer';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { useTheme } from '../../../hooks/useTheme';
import { PowerIcon } from '../../../components/power';
import colorMXID from '../../../../util/colorMXID';
import { useIsDirectRoom, useRoom } from '../../../hooks/useRoom';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import {
GetMemberPowerTag,
getPowerTagIconSrc,
useAccessiblePowerTagColors,
useGetMemberPowerTag,
} from '../../../hooks/useMemberPowerTag';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { ThreadSelector, ThreadSelectorContainer } from '../message/thread-selector';
import {
getTimelineAndBaseIndex,
getTimelineEvent,
getTimelineRelativeIndex,
getTimelinesEventsCount,
} from '../utils';
import { ThreadsLoading } from './ThreadsLoading';
import { VirtualTile } from '../../../components/virtualizer';
import { useAlive } from '../../../hooks/useAlive';
import * as css from './ThreadsMenu.css';
type ThreadMessageProps = {
room: Room;
event: MatrixEvent;
renderContent: RenderMatrixEvent<[string, MatrixEvent, string, GetContentCallback]>;
onOpen: (roomId: string, eventId: string) => void;
getMemberPowerTag: GetMemberPowerTag;
accessibleTagColors: Map<string, string>;
legacyUsernameColor: boolean;
hour24Clock: boolean;
dateFormatString: string;
};
function ThreadMessage({
room,
event,
renderContent,
onOpen,
getMemberPowerTag,
accessibleTagColors,
legacyUsernameColor,
hour24Clock,
dateFormatString,
}: ThreadMessageProps) {
const useAuthentication = useMediaAuthentication();
const mx = useMatrixClient();
const handleOpenClick: MouseEventHandler = (evt) => {
evt.stopPropagation();
const evtId = evt.currentTarget.getAttribute('data-event-id');
if (!evtId) return;
onOpen(room.roomId, evtId);
};
const renderOptions = () => (
<Box shrink="No" gap="200" alignItems="Center">
<Chip
data-event-id={event.getId()}
onClick={handleOpenClick}
variant="Secondary"
radii="Pill"
>
<Text size="T200">Open</Text>
</Chip>
</Box>
);
const sender = event.getSender()!;
const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender;
const senderAvatarMxc = getMemberAvatarMxc(room, sender);
const getContent = (() => event.getContent()) as GetContentCallback;
const memberPowerTag = getMemberPowerTag(sender);
const tagColor = memberPowerTag?.color
? accessibleTagColors?.get(memberPowerTag.color)
: undefined;
const tagIconSrc = memberPowerTag?.icon
? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
: undefined;
const usernameColor = legacyUsernameColor ? colorMXID(sender) : tagColor;
const mEventId = event.getId();
if (!mEventId) return null;
return (
<ModernLayout
before={
<AvatarBase>
<Avatar size="300">
<UserAvatar
userId={sender}
src={
senderAvatarMxc
? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ??
undefined
: undefined
}
alt={displayName}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
</Avatar>
</AvatarBase>
}
>
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
<Box gap="200" alignItems="Baseline">
<Box alignItems="Center" gap="200">
<Username style={{ color: usernameColor }}>
<Text as="span" truncate>
<UsernameBold>{displayName}</UsernameBold>
</Text>
</Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box>
<Time ts={event.getTs()} hour24Clock={hour24Clock} dateFormatString={dateFormatString} />
</Box>
{renderOptions()}
</Box>
{event.replyEventId && (
<Reply
room={room}
replyEventId={event.replyEventId}
threadRootId={event.threadRootId}
onClick={handleOpenClick}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor}
/>
)}
{renderContent(event.getType(), false, mEventId, event, displayName, getContent)}
</ModernLayout>
);
}
type ThreadsTimelineProps = {
timelines: EventTimeline[];
requestClose: () => void;
};
export function ThreadsTimeline({ timelines, requestClose }: ThreadsTimelineProps) {
const mx = useMatrixClient();
const { navigateRoom } = useRoomNavigate();
const alive = useAlive();
const scrollRef = useRef<HTMLDivElement>(null);
const room = useRoom();
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const creatorsTag = useRoomCreatorsTag();
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const theme = useTheme();
const accessibleTagColors = useAccessiblePowerTagColors(theme.kind, creatorsTag, powerLevelTags);
const useAuthentication = useMediaAuthentication();
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const direct = useIsDirectRoom();
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler();
const linkifyOpts = useMemo<LinkifyOpts>(
() => ({
...LINKIFY_OPTS,
render: factoryRenderLinkifyWithMention((href) =>
renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler))
),
}),
[mx, room, mentionClickHandler]
);
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
() =>
getReactCustomHtmlParser(mx, room.roomId, {
linkifyOpts,
useAuthentication,
handleSpoilerClick: spoilerClickHandler,
handleMentionClick: mentionClickHandler,
}),
[mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication]
);
const renderMatrixEvent = useMatrixEventRenderer<
[string, MatrixEvent, string, GetContentCallback]
>(
{
[MessageEvent.RoomMessage]: (eventId, event, displayName, getContent) => {
if (event.isRedacted()) {
return <RedactedContent reason={event.getUnsigned().redacted_because?.content.reason} />;
}
const threadDetail = getEventThreadDetail(event);
return (
<>
<RenderMessageContent
displayName={displayName}
msgType={event.getContent().msgtype ?? ''}
ts={event.getTs()}
getContent={getContent}
edited={!!event.replacingEvent()}
mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment
/>
{threadDetail && (
<ThreadSelectorContainer>
<ThreadSelector
room={room}
threadId={eventId}
threadDetail={threadDetail}
outlined
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</ThreadSelectorContainer>
)}
</>
);
},
[MessageEvent.RoomMessageEncrypted]: (eventId, mEvent, displayName) => {
const evtTimeline = room.getTimelineForEvent(eventId);
return (
<EncryptedContent mEvent={mEvent}>
{() => {
if (mEvent.isRedacted()) return <RedactedContent />;
if (mEvent.getType() === MessageEvent.Sticker)
return (
<MSticker
content={mEvent.getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
/>
);
if (mEvent.getType() === MessageEvent.RoomMessage) {
const editedEvent =
evtTimeline && getEditedEvent(eventId, mEvent, evtTimeline.getTimelineSet());
const getContent = (() =>
editedEvent?.getContent()['m.new_content'] ??
mEvent.getContent()) as GetContentCallback;
return (
<RenderMessageContent
displayName={displayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent || !!mEvent.replacingEvent()}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
return (
<Text>
<MessageNotDecryptedContent />
</Text>
);
return (
<Text>
<MessageUnsupportedContent />
</Text>
);
}}
</EncryptedContent>
);
},
[MessageEvent.Sticker]: (eventId, event, displayName, getContent) => {
if (event.isRedacted()) {
return <RedactedContent reason={event.getUnsigned().redacted_because?.content.reason} />;
}
const threadDetail = getEventThreadDetail(event);
return (
<>
<MSticker
content={getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
/>
{threadDetail && (
<ThreadSelectorContainer>
<ThreadSelector
room={room}
threadId={eventId}
threadDetail={threadDetail}
outlined
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</ThreadSelectorContainer>
)}
</>
);
},
},
undefined,
(eventId, event) => {
if (event.isRedacted()) {
return <RedactedContent reason={event.getUnsigned().redacted_because?.content.reason} />;
}
return (
<Box grow="Yes" direction="Column">
<Text size="T400" priority="300">
<code className={customHtmlCss.Code}>{event.getType()}</code>
{' event'}
</Text>
</Box>
);
}
);
const handleOpen = (roomId: string, eventId: string) => {
navigateRoom(roomId, eventId);
requestClose();
};
const eventsLength = getTimelinesEventsCount(timelines);
const timelineToPaginate = timelines[timelines.length - 1];
const [paginationToken, setPaginationToken] = useState(
timelineToPaginate.getPaginationToken(Direction.Backward)
);
const virtualizer = useVirtualizer({
count: eventsLength,
getScrollElement: () => scrollRef.current,
estimateSize: () => 122,
overscan: 10,
});
const vItems = virtualizer.getVirtualItems();
const paginate = useCallback(async () => {
const moreToLoad = await mx.paginateEventTimeline(timelineToPaginate, {
backwards: true,
limit: 30,
});
if (alive()) {
setPaginationToken(
moreToLoad ? timelineToPaginate.getPaginationToken(Direction.Backward) : null
);
}
}, [mx, alive, timelineToPaginate]);
// auto paginate when scroll reach bottom
useEffect(() => {
const lastVItem = vItems.length > 0 ? vItems[vItems.length - 1] : undefined;
if (paginationToken && lastVItem && lastVItem.index === eventsLength - 1) {
paginate();
}
}, [vItems, paginationToken, eventsLength, paginate]);
return (
<Scroll ref={scrollRef} size="300" hideTrack visibility="Hover">
<Box className={css.ThreadsMenuContent} direction="Column" gap="100">
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{vItems.map((vItem) => {
const reverseTimelineIndex = eventsLength - vItem.index - 1;
const [timeline, baseIndex] = getTimelineAndBaseIndex(timelines, reverseTimelineIndex);
if (!timeline) return null;
const event = getTimelineEvent(
timeline,
getTimelineRelativeIndex(reverseTimelineIndex, baseIndex)
);
if (!event?.getId()) return null;
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingBottom: config.space.S200 }}
ref={virtualizer.measureElement}
key={vItem.index}
>
<SequenceCard
key={event.getId()}
style={{ padding: config.space.S400, borderRadius: config.radii.R300 }}
variant="SurfaceVariant"
direction="Column"
>
<ThreadMessage
room={room}
event={event}
renderContent={renderMatrixEvent}
onOpen={handleOpen}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</SequenceCard>
</VirtualTile>
);
})}
</div>
{paginationToken && <ThreadsLoading />}
</Box>
</Scroll>
);
}

View File

@@ -0,0 +1 @@
export * from './ThreadsMenu';

View File

@@ -0,0 +1 @@
export * from './timeline';

View File

@@ -0,0 +1,66 @@
import { Room, EventTimeline, Direction, MatrixEvent } from 'matrix-js-sdk';
export const getFirstLinkedTimeline = (
timeline: EventTimeline,
direction: Direction
): EventTimeline => {
const linkedTm = timeline.getNeighbouringTimeline(direction);
if (!linkedTm) return timeline;
return getFirstLinkedTimeline(linkedTm, direction);
};
export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
const timelines: EventTimeline[] = [];
for (
let nextTimeline: EventTimeline | null = firstTimeline;
nextTimeline;
nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward)
) {
timelines.push(nextTimeline);
}
return timelines;
};
export const timelineToEventsCount = (t: EventTimeline) => t.getEvents().length;
export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => {
const timelineEventCountReducer = (count: number, tm: EventTimeline) =>
count + timelineToEventsCount(tm);
return timelines.reduce(timelineEventCountReducer, 0);
};
export const getTimelineAndBaseIndex = (
timelines: EventTimeline[],
index: number
): [EventTimeline | undefined, number] => {
let uptoTimelineLen = 0;
const timeline = timelines.find((t) => {
uptoTimelineLen += t.getEvents().length;
if (index < uptoTimelineLen) return true;
return false;
});
if (!timeline) return [undefined, 0];
return [timeline, uptoTimelineLen - timeline.getEvents().length];
};
export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) =>
absoluteIndex - timelineBaseIndex;
export const getTimelineEvent = (timeline: EventTimeline, index: number): MatrixEvent | undefined =>
timeline.getEvents()[index];
export const getEventIdAbsoluteIndex = (
timelines: EventTimeline[],
eventTimeline: EventTimeline,
eventId: string
): number | undefined => {
const timelineIndex = timelines.findIndex((t) => t === eventTimeline);
if (timelineIndex === -1) return undefined;
const eventIndex = eventTimeline.getEvents().findIndex((evt) => evt.getId() === eventId);
if (eventIndex === -1) return undefined;
const baseIndex = timelines
.slice(0, timelineIndex)
.reduce((accValue, timeline) => timeline.getEvents().length + accValue, 0);
return baseIndex + eventIndex;
};

View File

@@ -0,0 +1,20 @@
import { useCallback } from 'react';
import { EventTimelineSet, Room } from 'matrix-js-sdk';
import { AsyncState, useAsyncCallbackValue } from './useAsyncCallback';
export const useRoomMyThreads = (room: Room): AsyncState<EventTimelineSet, Error> => {
const [threadsState] = useAsyncCallbackValue<EventTimelineSet, Error>(
useCallback(async () => {
await room.createThreadsTimelineSets();
await room.fetchRoomThreads();
const timelineSet = room.threadsTimelineSets[0];
if (timelineSet === undefined) {
throw new Error('Failed to fetch My Threads!');
}
return timelineSet;
}, [room])
);
return threadsState;
};

View File

@@ -8,6 +8,8 @@ import { makeNavToActivePathAtom } from '../../state/navToActivePath';
import { NavToActivePathProvider } from '../../state/hooks/navToActivePath';
import { makeOpenedSidebarFolderAtom } from '../../state/openedSidebarFolder';
import { OpenedSidebarFolderProvider } from '../../state/hooks/openedSidebarFolder';
import { makeRoomToActiveThreadAtom } from '../../state/roomToActiveThread';
import { RoomToActiveThreadProvider } from '../../state/hooks/roomToActiveThread';
type ClientInitStorageAtomProps = {
children: ReactNode;
@@ -22,15 +24,19 @@ export function ClientInitStorageAtom({ children }: ClientInitStorageAtomProps)
const navToActivePathAtom = useMemo(() => makeNavToActivePathAtom(userId), [userId]);
const roomToActiveThreadAtom = useMemo(() => makeRoomToActiveThreadAtom(userId), [userId]);
const openedSidebarFolderAtom = useMemo(() => makeOpenedSidebarFolderAtom(userId), [userId]);
return (
<ClosedNavCategoriesProvider value={closedNavCategoriesAtom}>
<ClosedLobbyCategoriesProvider value={closedLobbyCategoriesAtom}>
<NavToActivePathProvider value={navToActivePathAtom}>
<OpenedSidebarFolderProvider value={openedSidebarFolderAtom}>
{children}
</OpenedSidebarFolderProvider>
<RoomToActiveThreadProvider value={roomToActiveThreadAtom}>
<OpenedSidebarFolderProvider value={openedSidebarFolderAtom}>
{children}
</OpenedSidebarFolderProvider>
</RoomToActiveThreadProvider>
</NavToActivePathProvider>
</ClosedLobbyCategoriesProvider>
</ClosedNavCategoriesProvider>

View File

@@ -0,0 +1,55 @@
import { createContext, useCallback, useContext } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { RoomToActiveThreadAtom } from '../roomToActiveThread';
const RoomToActiveThreadAtomContext = createContext<RoomToActiveThreadAtom | null>(null);
export const RoomToActiveThreadProvider = RoomToActiveThreadAtomContext.Provider;
export const useRoomToActiveThreadAtom = (): RoomToActiveThreadAtom => {
const anAtom = useContext(RoomToActiveThreadAtomContext);
if (!anAtom) {
throw new Error('RoomToActiveThreadAtom is not provided!');
}
return anAtom;
};
export const useThreadSelector = (roomId: string): ((threadId: string) => void) => {
const roomToActiveThreadAtom = useRoomToActiveThreadAtom();
const setRoomToActiveThread = useSetAtom(roomToActiveThreadAtom);
const onThreadSelect = useCallback(
(threadId: string) => {
setRoomToActiveThread({
type: 'PUT',
roomId,
threadId,
});
},
[roomId, setRoomToActiveThread]
);
return onThreadSelect;
};
export const useActiveThread = (roomId: string): string | undefined => {
const roomToActiveThreadAtom = useRoomToActiveThreadAtom();
const roomToActiveThread = useAtomValue(roomToActiveThreadAtom);
return roomToActiveThread.get(roomId);
};
export const useThreadClose = (roomId: string): (() => void) => {
const roomToActiveThreadAtom = useRoomToActiveThreadAtom();
const setRoomToActiveThread = useSetAtom(roomToActiveThreadAtom);
const closeThread = useCallback(() => {
setRoomToActiveThread({
type: 'DELETE',
roomId,
});
}, [roomId, setRoomToActiveThread]);
return closeThread;
};

View File

@@ -0,0 +1,75 @@
import { atom, WritableAtom } from 'jotai';
import produce from 'immer';
import {
atomWithLocalStorage,
getLocalStorageItem,
setLocalStorageItem,
} from './utils/atomWithLocalStorage';
const ROOM_TO_ACTIVE_THREAD = 'roomToActiveThread';
const getStoreKey = (userId: string): string => `${ROOM_TO_ACTIVE_THREAD}${userId}`;
type RoomToActiveThread = Map<string, string>;
type RoomToActiveThreadAction =
| {
type: 'PUT';
roomId: string;
threadId: string;
}
| {
type: 'DELETE';
roomId: string;
};
export type RoomToActiveThreadAtom = WritableAtom<
RoomToActiveThread,
[RoomToActiveThreadAction],
undefined
>;
export const makeRoomToActiveThreadAtom = (userId: string): RoomToActiveThreadAtom => {
const storeKey = getStoreKey(userId);
const baseRoomToActiveThread = atomWithLocalStorage<RoomToActiveThread>(
storeKey,
(key) => {
const obj: Record<string, string> = getLocalStorageItem(key, {});
return new Map(Object.entries(obj));
},
(key, value) => {
const obj: Record<string, string> = Object.fromEntries(value);
setLocalStorageItem(key, obj);
}
);
const navToActivePathAtom = atom<RoomToActiveThread, [RoomToActiveThreadAction], undefined>(
(get) => get(baseRoomToActiveThread),
(get, set, action) => {
if (action.type === 'DELETE') {
set(
baseRoomToActiveThread,
produce(get(baseRoomToActiveThread), (draft) => {
draft.delete(action.roomId);
})
);
return;
}
if (action.type === 'PUT') {
set(
baseRoomToActiveThread,
produce(get(baseRoomToActiveThread), (draft) => {
draft.set(action.roomId, action.threadId);
})
);
}
}
);
return navToActivePathAtom;
};
export const clearRoomToActiveThreadStore = (userId: string) => {
localStorage.removeItem(getStoreKey(userId));
};

View File

@@ -8,6 +8,7 @@ import {
IPowerLevelsContent,
IPushRule,
IPushRules,
IThreadBundledRelationship,
JoinRule,
MatrixClient,
MatrixEvent,
@@ -16,6 +17,7 @@ import {
RelationType,
Room,
RoomMember,
THREAD_RELATION_TYPE,
} from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import { AccountDataEvent } from '../../types/matrix/accountData';
@@ -201,6 +203,7 @@ export const isNotificationEvent = (mEvent: MatrixEvent) => {
if (mEvent.isRedacted()) return false;
if (mEvent.getRelation()?.rel_type === 'm.replace') return false;
if (mEvent.getRelation()?.rel_type === 'm.thread') return false;
return true;
};
@@ -551,3 +554,13 @@ export const guessPerfectParent = (
return perfectParent;
};
export const getEventThreadDetail = (
mEvent: MatrixEvent
): IThreadBundledRelationship | undefined => {
const details = mEvent.getServerAggregatedRelation<IThreadBundledRelationship>(
THREAD_RELATION_TYPE.name
);
return details;
};

View File

@@ -2,6 +2,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from
import { cryptoCallbacks } from './secretStorageKeys';
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
import { clearRoomToActiveThreadStore } from '../app/state/roomToActiveThread';
type Session = {
baseUrl: string;
@@ -42,12 +43,14 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
export const startClient = async (mx: MatrixClient) => {
await mx.startClient({
lazyLoadMembers: true,
threadSupport: true,
});
};
export const clearCacheAndReload = async (mx: MatrixClient) => {
mx.stopClient();
clearNavToActivePathStore(mx.getSafeUserId());
clearRoomToActiveThreadStore(mx.getSafeUserId());
await mx.store.deleteAllData();
window.location.reload();
};