Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/merge-legacy-image-pack-rooms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
type: patch
---

Merge image-pack updates from legacy clients and mirror edits or deletions to active legacy pack state keys.
5 changes: 5 additions & 0 deletions .changeset/message-edit-timestamps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Show a timestamp footnote when hovering grouped messages and a tooltip with edit times on the "(edited)" indicator. Clicking the indicator opens the message version history, matching the context-menu "Version History" action.
5 changes: 5 additions & 0 deletions .changeset/show-all-timestamps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Add a setting to show all timestamps, including on grouped messages from the same sender
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
;
pname = "sable";
fetcherVersion = 3;
hash = "sha256-zGUJ7Kl+nHumhrM51hS8nWzzxwUh9xn6TGPLcydakdo=";
hash = "sha256-VLkFY25Lwr4YvhCN8UAkKib3Z1kaNXnPGM2eTc3izYI=";
};

mkPnpmCheck =
Expand Down
9 changes: 5 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ minimumReleaseAgeExclude:
- '@sableclient/twemoji-font'

overrides:
# Temporary: matrix-js-sdk#5501
'@matrix-org/matrix-sdk-crypto-wasm': '18.4.0'
brace-expansion: '>=5.0.9'
esbuild: '>=0.28.1'
fast-uri: '>=3.1.5'
Expand Down
125 changes: 124 additions & 1 deletion src/app/components/RenderMessageContent.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { render, screen } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MsgType } from '$types/matrix-sdk';
import { M_POLL_START } from 'matrix-js-sdk';
import { ClientConfigProvider } from '$hooks/useClientConfig';
import { MatrixClientProvider } from '$hooks/useMatrixClient';
import { ModalType } from '$state/modal';
import { RenderMessageContent } from './RenderMessageContent';

vi.mock('./message/content/UploadedSableCssContent', () => ({
Expand All @@ -16,6 +17,20 @@ vi.mock('$hooks/useMediaAuthentication', () => ({
useMediaAuthentication: () => false,
}));

const { setModalSpy } = vi.hoisted(() => ({
setModalSpy: vi.fn<(next: Record<string, unknown>) => void>(),
}));

vi.mock('$state/modal', async () => {
const { atom } = await import('jotai');
return {
ModalType: { EditHistory: 'edit_history' },
modalAtom: atom(null, (_get: unknown, _set: unknown, next: Record<string, unknown>) =>
setModalSpy(next)
),
};
});

vi.mock('./url-preview', () => ({
UrlPreviewHolder: ({ children }: { children: React.ReactNode }) => (
<div data-testid="url-preview-holder">{children}</div>
Expand Down Expand Up @@ -223,3 +238,111 @@ describe('RenderMessageContent', () => {
expect(screen.getByTestId('poll-event')).toBeInTheDocument();
});
});

const roomWithEdits = {
getTimelineForEvent: () => ({
getTimelineSet: () => ({
relations: {
getChildEventsForEvent: () => ({
getRelations: () => [{ getTs: () => 1710000000000 }],
}),
},
}),
}),
hasEncryptionStateEvent: () => false,
};

const messageEvent = {
getId: () => '$evt:example.com',
getType: () => 'm.room.message',
getTs: () => 1700000000000,
};

function renderEditedMessage(room?: unknown) {
return render(
<ClientConfigProvider value={{}}>
<MatrixClientProvider value={matrixClient}>
<RenderMessageContent
displayName="Alice"
msgType={MsgType.Text as string}
ts={1700000000000}
edited
getContent={() => ({ msgtype: MsgType.Text, body: 'hello world' }) as never}
htmlReactParserOptions={{}}
linkifyOpts={{}}
room={room as never}
mEvent={messageEvent as never}
/>
</MatrixClientProvider>
</ClientConfigProvider>
);
}

describe('edited indicator', () => {
beforeEach(() => {
setModalSpy.mockClear();
});

it('opens version history on click with the same modal payload as the context menu', () => {
renderEditedMessage(roomWithEdits);

const indicator = screen.getByText('(edited)');
expect(indicator).toHaveAttribute('role', 'button');

fireEvent.click(indicator);

expect(setModalSpy).toHaveBeenCalledTimes(1);
expect(setModalSpy).toHaveBeenCalledWith({
type: ModalType.EditHistory,
room: roomWithEdits,
mEvent: messageEvent,
});
});

it('keeps the tooltip-capable wrapper around the clickable indicator', () => {
renderEditedMessage(roomWithEdits);

const wrapper = screen.getByText('(edited)').closest('span[style*="inline"]');
expect(wrapper?.firstElementChild).toHaveAttribute('role', 'button');
});

it('stops propagation so surrounding message click handlers do not fire', () => {
const outsideClick = vi.fn<(event: Event) => void>();
document.addEventListener('click', outsideClick);

try {
renderEditedMessage(roomWithEdits);

fireEvent.click(screen.getByText('(edited)'));

expect(setModalSpy).toHaveBeenCalledTimes(1);
expect(outsideClick).not.toHaveBeenCalled();
} finally {
document.removeEventListener('click', outsideClick);
}
});

it('opens version history on Enter and Space keys', () => {
renderEditedMessage(roomWithEdits);

fireEvent.keyDown(screen.getByText('(edited)'), { key: 'Enter' });
expect(setModalSpy).toHaveBeenCalledTimes(1);
expect(setModalSpy).toHaveBeenCalledWith({
type: ModalType.EditHistory,
room: roomWithEdits,
mEvent: messageEvent,
});

fireEvent.keyDown(screen.getByText('(edited)'), { key: ' ' });
expect(setModalSpy).toHaveBeenCalledTimes(2);
});

it('is not interactive when the event context is missing', () => {
renderEditedMessage();

expect(screen.queryByRole('button', { name: '(edited)' })).not.toBeInTheDocument();

fireEvent.click(screen.getByText('(edited)'));
expect(setModalSpy).not.toHaveBeenCalled();
});
});
41 changes: 40 additions & 1 deletion src/app/components/RenderMessageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME,
MATRIX_UNSTABLE_SPOILER_PROPERTY_NAME,
} from '$unstable/prefixes';
import { getEventEdits } from '$utils/room/relations';
import {
convertBeeperFormatToOurPerMessageProfile,
type PerMessageProfileBeeperFormat,
Expand Down Expand Up @@ -130,6 +131,22 @@ function RenderMessageContentInternal({
}: RenderMessageContentProps) {
const content = useMemo(() => getContent() as Record<string, unknown>, [getContent]);

// Edit timestamps for the "(edited)" hover tooltip, newest last.
const editTimestamps = useMemo(() => {
if (!mEvent || !room || typeof mEvent.getId !== 'function') return undefined;
const evtId = mEvent.getId();
const evtTimeline = evtId ? room.getTimelineForEvent(evtId) : undefined;
const edits =
evtTimeline && evtId
? getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations()
: undefined;
return edits
? Array.from(edits)
.map((evt) => evt.getTs())
.toSorted((a, b) => a - b)
: undefined;
}, [mEvent, room]);

const [autoplayGifs] = useSetting(settingsAtom, 'autoplayGifs');
const [captionPosition] = useSetting(settingsAtom, 'captionPosition');
const [themeChatSableWidgets] = useSetting(settingsAtom, 'themeChatSableWidgetsEnabled');
Expand Down Expand Up @@ -263,6 +280,9 @@ function RenderMessageContentInternal({
<MText
style={CAPTION_STYLE}
edited={edited}
editTimestamps={editTimestamps}
room={room}
mEvent={mEvent}
content={content}
renderBody={renderBody}
renderUrlsPreview={messageUrlsPreview}
Expand All @@ -284,6 +304,9 @@ function RenderMessageContentInternal({
>
<MText
edited={edited}
editTimestamps={editTimestamps}
room={room}
mEvent={mEvent}
content={content}
renderBody={renderBody}
renderUrlsPreview={messageUrlsPreview}
Expand Down Expand Up @@ -312,7 +335,14 @@ function RenderMessageContentInternal({
wordBreak: 'break-word',
}}
>
<MText edited={edited} content={content} renderBody={renderBody} style={TEXT_STYLE} />
<MText
edited={edited}
room={room}
mEvent={mEvent}
content={content}
renderBody={renderBody}
style={TEXT_STYLE}
/>
</Box>
)}
<ImageContent
Expand Down Expand Up @@ -429,6 +459,9 @@ function RenderMessageContentInternal({
return (
<MText
edited={edited}
editTimestamps={editTimestamps}
room={room}
mEvent={mEvent}
content={content}
renderBody={renderBody}
renderUrlsPreview={messageUrlsPreview}
Expand Down Expand Up @@ -469,6 +502,9 @@ function RenderMessageContentInternal({
<MEmote
displayName={pmp?.displayname ?? displayName}
edited={edited}
editTimestamps={editTimestamps}
room={room}
mEvent={mEvent}
content={strippedContent}
renderBody={renderBody}
renderUrlsPreview={messageUrlsPreview}
Expand All @@ -481,6 +517,9 @@ function RenderMessageContentInternal({
return (
<MNotice
edited={edited}
editTimestamps={editTimestamps}
room={room}
mEvent={mEvent}
content={content}
renderBody={renderBody}
renderUrlsPreview={messageUrlsPreview}
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/editor/Editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe('CustomEditor layout', () => {
act(() => editor.insertText('two'));

await waitFor(() => expect(editor.getText()).toBe('one\ntwo'));
expect(row(container)).toHaveClass(css.EditorRowMultiline);
await waitFor(() => expect(row(container)).toHaveClass(css.EditorRowMultiline));
});

it('installs a hidden measurer for text layout', () => {
Expand Down
Loading