Skip to content

Commit 83d20e0

Browse files
authored
Merge pull request #111 from nushea/sable-edit-history
Feat: Option for showing a message's edit history
2 parents 6fd65e4 + f79d124 commit 83d20e0

8 files changed

Lines changed: 287 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
sable: minor
3+
---
4+
5+
Added a pop-up for showing a message's edit history
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { style } from '@vanilla-extract/css';
2+
import { DefaultReset, color, config } from 'folds';
3+
4+
export const EventHistory = style([
5+
DefaultReset,
6+
{
7+
height: '100%',
8+
},
9+
]);
10+
11+
export const Header = style({
12+
paddingLeft: config.space.S400,
13+
paddingRight: config.space.S300,
14+
15+
flexShrink: 0,
16+
});
17+
18+
export const Content = style({
19+
paddingLeft: config.space.S200,
20+
paddingBottom: config.space.S400,
21+
});
22+
export const EventItem = style({
23+
padding: `${config.space.S200} ${config.space.S200}`,
24+
height: 'unset',
25+
borderRadius: '5px',
26+
border: '2px hidden',
27+
backgroundColor: 'inherit',
28+
selectors: {
29+
'&:hover': {
30+
backgroundColor: color.Surface.ContainerHover,
31+
},
32+
},
33+
});
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import classNames from 'classnames';
2+
import {
3+
Avatar,
4+
Box,
5+
Header,
6+
Icon,
7+
IconButton,
8+
Icons,
9+
MenuItem,
10+
Scroll,
11+
Text,
12+
as,
13+
color,
14+
config,
15+
} from 'folds';
16+
import { MatrixEvent, Room } from '$types/matrix-sdk';
17+
import { getMemberDisplayName } from '$utils/room';
18+
import { getMxIdLocalPart } from '$utils/matrix';
19+
import { useMatrixClient } from '$hooks/useMatrixClient';
20+
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
21+
import { useOpenUserRoomProfile } from '$state/hooks/userRoomProfile';
22+
import { useSpaceOptionally } from '$hooks/useSpace';
23+
import { getMouseEventCords } from '$utils/dom';
24+
import { useAtomValue } from 'jotai';
25+
import { nicknamesAtom } from '$state/nicknames';
26+
import { UserAvatar } from '$components/user-avatar';
27+
import { RenderBody, Time } from '$components/message';
28+
import { useSetting } from '$state/hooks/settings';
29+
import { settingsAtom } from '$state/settings';
30+
import { useMemo } from 'react';
31+
import { getReactCustomHtmlParser, LINKIFY_OPTS } from '$plugins/react-custom-html-parser';
32+
import { Opts as LinkifyOpts } from 'linkifyjs';
33+
import { HTMLReactParserOptions } from 'html-react-parser';
34+
import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler';
35+
import * as css from './EventHistory.css';
36+
37+
export type EventHistoryProps = {
38+
room: Room;
39+
mEvents: MatrixEvent[];
40+
requestClose: () => void;
41+
};
42+
export const EventHistory = as<'div', EventHistoryProps>(
43+
({ className, room, mEvents, requestClose, ...props }, ref) => {
44+
const mx = useMatrixClient();
45+
const useAuthentication = useMediaAuthentication();
46+
const openProfile = useOpenUserRoomProfile();
47+
const space = useSpaceOptionally();
48+
const nicknames = useAtomValue(nicknamesAtom);
49+
50+
const getName = (userId: string) =>
51+
getMemberDisplayName(room, userId, nicknames) ?? getMxIdLocalPart(userId) ?? userId;
52+
53+
const readerId = mEvents[0].event.sender ?? '';
54+
const name = getName(readerId ?? '');
55+
const avatarMxcUrl = room.getMember(readerId ?? '')?.getMxcAvatarUrl();
56+
const avatarUrl = avatarMxcUrl
57+
? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication)
58+
: undefined;
59+
60+
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
61+
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
62+
63+
const linkifyOpts = useMemo<LinkifyOpts>(() => ({ ...LINKIFY_OPTS }), []);
64+
const spoilerClickHandler = useSpoilerClickHandler();
65+
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
66+
() =>
67+
getReactCustomHtmlParser(mx, mEvents[0].getRoomId(), {
68+
linkifyOpts,
69+
useAuthentication,
70+
handleSpoilerClick: spoilerClickHandler,
71+
}),
72+
[linkifyOpts, mEvents, mx, spoilerClickHandler, useAuthentication]
73+
);
74+
return (
75+
<Box
76+
className={classNames(css.EventHistory, className)}
77+
direction="Column"
78+
{...props}
79+
ref={ref}
80+
>
81+
<Header className={css.Header} variant="Surface" size="600">
82+
<Box grow="Yes">
83+
<Text size="H3">Message version history</Text>
84+
</Box>
85+
<IconButton size="300" onClick={requestClose}>
86+
<Icon src={Icons.Cross} />
87+
</IconButton>
88+
</Header>
89+
<Header>
90+
<MenuItem
91+
key={readerId}
92+
style={{
93+
display: 'flex',
94+
justifyContent: 'center',
95+
padding: `${config.space.S200} ${config.space.S200}`,
96+
height: 'unset',
97+
}}
98+
radii="400"
99+
onClick={(event) => {
100+
openProfile(
101+
room.roomId,
102+
space?.roomId,
103+
readerId,
104+
getMouseEventCords(event.nativeEvent),
105+
'Bottom'
106+
);
107+
}}
108+
before={
109+
<Avatar size="300">
110+
<UserAvatar
111+
userId={readerId ?? ''}
112+
src={avatarUrl ?? undefined}
113+
alt={name}
114+
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
115+
/>
116+
</Avatar>
117+
}
118+
>
119+
<Text size="T400">{name}</Text>
120+
</MenuItem>
121+
</Header>
122+
<Box grow="Yes" style={{ overflow: 'scroll' }}>
123+
<Scroll visibility="Hover">
124+
<Box className={css.Content} direction="Column">
125+
{mEvents.map((mEvent) => {
126+
if (!mEvent.event.sender) return <div />;
127+
const EventContent = mEvent.getOriginalContent();
128+
return (
129+
<>
130+
<hr style={{ width: '100%', color: color.Surface.ContainerLine }} />
131+
<Box className={css.EventItem}>
132+
<Time
133+
ts={mEvent.getTs()}
134+
hour24Clock={hour24Clock}
135+
dateFormatString={dateFormatString}
136+
/>
137+
<Text size="T400" style={{ paddingLeft: '10px', wordBreak: 'break-word' }}>
138+
<RenderBody
139+
body={EventContent?.['m.new_content']?.body ?? EventContent?.body ?? ''}
140+
customBody={
141+
EventContent?.['m.new_content']?.formatted_body ??
142+
EventContent?.formatted_body ??
143+
''
144+
}
145+
htmlReactParserOptions={htmlReactParserOptions}
146+
linkifyOpts={linkifyOpts}
147+
/>
148+
</Text>
149+
</Box>
150+
</>
151+
);
152+
})}
153+
</Box>
154+
</Scroll>
155+
</Box>
156+
</Box>
157+
);
158+
}
159+
);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './EventHistory';

src/app/components/message/modals/GlobalModalManager.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { stopPropagation } from '$utils/keyboard';
55
import { modalAtom, ModalType } from '$state/modal';
66
import { MessageReportInternal } from './MessageReport';
77
import { MessageDeleteInternal } from './MessageDelete';
8+
import { MessageEditHistoryInternal } from './MessageEditHistory';
89
import { MessageSourceInternal } from './MessageSource';
910
import { MessageForwardInternal } from './MessageForward';
1011
import { MessageAllReactionInternal } from './MessageReactions';
@@ -63,6 +64,16 @@ export function GlobalModalManager() {
6364
</Modal>
6465
)}
6566

67+
{modal.type === ModalType.EditHistory && (
68+
<Modal variant="Surface" size="300">
69+
<MessageEditHistoryInternal
70+
room={modal.room}
71+
mEvent={modal.mEvent}
72+
onClose={close}
73+
/>
74+
</Modal>
75+
)}
76+
6677
{modal.type === ModalType.ReadReceipts && (
6778
<Modal variant="Surface" size="300">
6879
<MessageReadReceiptInternal
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { MouseEvent } from 'react';
2+
import { Room, MatrixEvent } from '$types/matrix-sdk';
3+
import { useSetAtom } from 'jotai';
4+
import { MenuItem, Icon, Icons, Text } from 'folds';
5+
import { getEventEdits } from '$utils/room';
6+
import { modalAtom, ModalType } from '$state/modal';
7+
import * as css from '$features/room/message/styles.css';
8+
import { EventHistory } from '$components/event-history';
9+
10+
export function MessageEditHistoryItem({ room, mEvent }: { room: Room; mEvent: MatrixEvent }) {
11+
const setModal = useSetAtom(modalAtom);
12+
13+
return (
14+
<MenuItem
15+
size="300"
16+
after={<Icon size="100" src={Icons.Clock} />}
17+
radii="300"
18+
onClick={(e: MouseEvent) => {
19+
e.preventDefault();
20+
e.stopPropagation();
21+
setModal({
22+
type: ModalType.EditHistory,
23+
room,
24+
mEvent,
25+
});
26+
}}
27+
>
28+
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
29+
Version History
30+
</Text>
31+
</MenuItem>
32+
);
33+
}
34+
35+
type MessageEditHistoryInternalProps = {
36+
room: Room;
37+
mEvent: MatrixEvent;
38+
onClose: () => void;
39+
};
40+
41+
export function MessageEditHistoryInternal({
42+
room,
43+
mEvent,
44+
onClose,
45+
}: MessageEditHistoryInternalProps) {
46+
const getEvents = (): MatrixEvent[] => {
47+
const evtId = mEvent.getId()!;
48+
const evtTimeline = room.getTimelineForEvent(evtId);
49+
const edits =
50+
evtTimeline &&
51+
getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations();
52+
if (!edits) return [mEvent];
53+
edits.sort((a, b) => a.getTs() - b.getTs());
54+
return [mEvent, ...edits];
55+
};
56+
57+
return <EventHistory room={room} mEvents={getEvents()} requestClose={onClose} />;
58+
}

src/app/features/room/message/Message.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import {
4949
Username,
5050
UsernameBold,
5151
} from '$components/message';
52-
import { canEditEvent, getMemberAvatarMxc } from '$utils/room';
52+
import { canEditEvent, getEventEdits, getMemberAvatarMxc } from '$utils/room';
5353
import { mxcUrlToHttp } from '$utils/matrix';
5454
import { getSettings, MessageLayout, MessageSpacing, settingsAtom } from '$state/settings';
5555
import { nicknamesAtom, setNicknameAtom } from '$state/nicknames';
@@ -74,6 +74,7 @@ import { useSetting } from '$state/hooks/settings';
7474
import { useBlobCache } from '$hooks/useBlobCache';
7575
import { MessageAllReactionItem } from '$components/message/modals/MessageReactions';
7676
import { MessageReadReceiptItem } from '$components/message/modals/MessageReadRecipts';
77+
import { MessageEditHistoryItem } from '$components/message/modals/MessageEditHistory';
7778
import { MessageSourceCodeItem } from '$components/message/modals/MessageSource';
7879
import { MessageForwardItem } from '$components/message/modals/MessageForward';
7980
import { MessageDeleteItem } from '$components/message/modals/MessageDelete';
@@ -669,6 +670,13 @@ function MessageInternal(
669670

670671
const isThreadedMessage = mEvent.threadRootId !== undefined;
671672

673+
const evtId = mEvent.getId()!;
674+
const evtTimeline = room.getTimelineForEvent(evtId);
675+
const edits =
676+
evtTimeline &&
677+
getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations();
678+
const isEdited = edits !== undefined;
679+
672680
return (
673681
<MessageBase
674682
className={classNames(css.MessageBase, className, {
@@ -872,6 +880,7 @@ function MessageInternal(
872880
{!hideReadReceipts && (
873881
<MessageReadReceiptItem room={room} eventId={mEvent.getId() ?? ''} />
874882
)}
883+
{isEdited && <MessageEditHistoryItem room={room} mEvent={mEvent} />}
875884
{showDeveloperTools && (
876885
<MessageSourceCodeItem room={room} mEvent={mEvent} />
877886
)}
@@ -1129,6 +1138,13 @@ export const Event = as<'div', EventProps>(
11291138
setMobileOptionsOpen(true);
11301139
});
11311140

1141+
const evtId = mEvent.getId()!;
1142+
const evtTimeline = room.getTimelineForEvent(evtId);
1143+
const edits =
1144+
evtTimeline &&
1145+
getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations();
1146+
const isEdited = edits !== undefined;
1147+
11321148
return (
11331149
<MessageBase
11341150
className={classNames(css.MessageBase, className)}
@@ -1169,6 +1185,7 @@ export const Event = as<'div', EventProps>(
11691185
{!hideReadReceipts && (
11701186
<MessageReadReceiptItem room={room} eventId={mEvent.getId() ?? ''} />
11711187
)}
1188+
{isEdited && <MessageEditHistoryItem room={room} mEvent={mEvent} />}
11721189
{showDeveloperTools && (
11731190
<MessageSourceCodeItem room={room} mEvent={mEvent} />
11741191
)}

src/app/state/modal.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export enum ModalType {
88
Report = 'report',
99
Source = 'source',
1010
Reactions = 'reactions',
11+
EditHistory = 'edit_history',
1112
ReadReceipts = 'read_receipts',
1213
}
1314

@@ -16,6 +17,7 @@ export type ModalState =
1617
| { type: ModalType.Forward; room: Room; mEvent: MatrixEvent }
1718
| { type: ModalType.Report; room: Room; mEvent: MatrixEvent }
1819
| { type: ModalType.Source; room: Room; mEvent: MatrixEvent }
20+
| { type: ModalType.EditHistory; room: Room; mEvent: MatrixEvent }
1921
| { type: ModalType.Reactions; room: Room; relations: Relations }
2022
| { type: ModalType.ReadReceipts; room: Room; eventId: string }
2123
| null;

0 commit comments

Comments
 (0)