Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
43 changes: 43 additions & 0 deletions src/app/features/room/RoomInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ const testState = vi.hoisted(() => ({
handleFiles: undefined as ((files: File[]) => Promise<void>) | undefined,
safeUploadFile: vi.fn(),
encryptFile: vi.fn(),
accountPersonaSelection: undefined as any,
roomPersonaSelection: undefined as any,
editingEvent: undefined as
| { getId: () => string; getContent: () => Record<string, unknown> }
| undefined,
Expand Down Expand Up @@ -411,9 +413,18 @@ vi.mock('$hooks/useImagePackRooms', () => ({ useImagePackRooms: () => [] }));
vi.mock('$hooks/useComposingCheck', () => ({ useComposingCheck: () => () => false }));
vi.mock('$hooks/usePerMessageProfile', () => ({
convertPerMessageProfileToBeeperFormat: () => ({}),
resolvePersona: () => undefined,
resolvePersonaProxy: () => undefined,
getCurrentlyUsedPerMessageProfileForAccount: async () => undefined,
getCurrentlyUsedPerMessageProfileForRoom: async () => undefined,
}));
vi.mock('$app/persona/catalog', () => ({
ProfileCatalog: class {
list = async () => [];
getSelection = async (scope: 'account' | { roomId: string }) =>
scope === 'account' ? testState.accountPersonaSelection : testState.roomPersonaSelection;
},
}));
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
Expand Down Expand Up @@ -743,6 +754,8 @@ beforeEach(() => {
testState.safeUploadFile.mockReset().mockImplementation(async (file: File) => file);
testState.encryptFile.mockReset();
testState.editingEvent = undefined;
testState.accountPersonaSelection = undefined;
testState.roomPersonaSelection = undefined;
testState.matrix.sendMessage.mockReset().mockResolvedValue({ event_id: '$event' });
testState.matrix.sendEvent.mockReset().mockResolvedValue({});
testState.cancelDelayedEvent.mockReset();
Expand Down Expand Up @@ -785,6 +798,36 @@ describe('RoomInput submit regressions', () => {
expect(testState.matrix.sendMessage.mock.calls[1]?.[2]?.body).toBe('retry me');
});

it('uses the active resolved persona for attachments and stickers', async () => {
testState.accountPersonaSelection = {
persona: { id: 'account', displayname: 'Account', trigger: { prefix: [] } },
};
testState.roomPersonaSelection = {
persona: { id: 'expired-room', displayname: 'Expired', trigger: { prefix: [] } },
validUntil: Date.now() - 1,
};
render(<RoomInputHarness />);

fireEvent.click(screen.getByRole('button', { name: 'Prepare attachment' }));
fireEvent.click(sendButton());
await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce());
expect(
testState.matrix.sendMessage.mock.calls[0]?.[2]?.['com.beeper.per_message_profile']
).toMatchObject({
id: 'account',
displayname: 'Account',
});

fireEvent.click(screen.getByRole('button', { name: 'Select sticker' }));
await waitFor(() => expect(testState.matrix.sendEvent).toHaveBeenCalledOnce());
expect(
testState.matrix.sendEvent.mock.calls[0]?.[2]?.['com.beeper.per_message_profile']
).toMatchObject({
id: 'account',
displayname: 'Account',
});
});

it('keeps attachment retry locked until every sibling send settles', async () => {
const delayedSecondSend = deferred<{ event_id: string }>();
let sendNumber = 0;
Expand Down
64 changes: 29 additions & 35 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,11 @@ import { usePowerLevelsContext } from '$hooks/usePowerLevels';
import { useRoomCreators } from '$hooks/useRoomCreators';
import { useRoomPermissions } from '$hooks/useRoomPermissions';
import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice';
import {
convertPerMessageProfileToBeeperFormat,
getCurrentlyUsedPerMessageProfileForAccount,
getCurrentlyUsedPerMessageProfileForRoom,
type PerMessageProfileMsc4461,
setCurrentlyUsedPerMessageProfileIdForRoom,
} from '$hooks/usePerMessageProfile';
import { setCurrentlyUsedPerMessageProfileIdForRoom } from '$hooks/usePerMessageProfile';
import type { PerMessageProfileMsc4461 } from '$app/persona';
import { ProfileCatalog } from '$app/persona/catalog';
import { projectPersona } from '$app/persona/projection';
import { resolvePersona } from '$app/persona/selection';
import {
Bell,
BellSlash,
Expand Down Expand Up @@ -177,7 +175,6 @@ import {
import { getSupportedAudioExtension } from '$plugins/voice-recorder-kit/supportedCodec';
import { ErrorCode } from '../../cs-errorcode';
import { PKitCommandMessageHandler } from '$plugins/pluralkit-handler/PKitCommandMessageHandler';
import { PKitProxyMessageHandler } from '$plugins/pluralkit-handler/PKitProxyMessageHandler';
import type { IGenericMSC4459, MSC4459ImagePackReference } from '$types/matrix/common';
import {
getImagePackReferencesForMxc,
Expand Down Expand Up @@ -352,10 +349,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
() => new PKitCommandMessageHandler(mx, room),
[mx, room]
);
const pluralkitProxyMessageHandler = useMemo(() => new PKitProxyMessageHandler(mx), [mx]);
useEffect(() => {
pluralkitProxyMessageHandler.init();
}, [pluralkitProxyMessageHandler]);

const [pkCompatEnable] = useSetting(settingsAtom, 'pkCompat');
const [pmpProxyingEnable] = useSetting(settingsAtom, 'pmpProxying');
Expand Down Expand Up @@ -1108,22 +1101,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const submittedReplyDraft = submission.replyClaim?.snapshot;
const submittedSilentReply = submission.replyClaim?.silentReply ?? silentReply;

/**
* the currently with the room associated per-message profile, if any, so that it can be included in the message content when sending.
* This allows the server to apply the correct profile-based transformations (e.g. font size adjustments) when processing the message,
* and also allows clients to display an accurate preview of how the message will look with the profile applied while it's being composed.
*/
const globalPerMessageProfile = await getCurrentlyUsedPerMessageProfileForAccount(mx);
const roomPerMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId);
const perMessageProfile = roomPerMessageProfile ?? globalPerMessageProfile;
const catalog = new ProfileCatalog(mx);
const [account, roomSelection] = await Promise.all([
catalog.getSelection('account'),
catalog.getSelection({ roomId }),
]);
const perMessageProfile = resolvePersona({
latched: latchedPersona,
room: roomSelection,
account,
now: Date.now(),
});

if (perMessageProfile) {
contents.forEach((c) => {
// We intentionally mutate the objects here to avoid unnecessary copying
// mutating should be unproblematic here, since contents isn't a react component,
// or used for rendering
c[prefix.MATRIX_UNSTABLE_PER_MESSAGE_PROFILE_PROPERTY_NAME] =
convertPerMessageProfileToBeeperFormat(perMessageProfile, false);
projectPersona(perMessageProfile);
});
}

Expand Down Expand Up @@ -1493,7 +1486,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
pmpNoFallback,
latchedPersona,
isPKCommand: (text) => PKitCommandMessageHandler.isPKCommand(text),
pluralkitProxyMessageHandler,
imagePacksUsed: imagePacksUsedRef.current,
});

Expand Down Expand Up @@ -1659,7 +1651,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
pkCompatEnable,
silentReply,
pmpProxyingEnable,
pluralkitProxyMessageHandler,
scheduledTime,
editingScheduledDelayId,
nicknames,
Expand Down Expand Up @@ -1857,18 +1848,21 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
content[prefix.MATRIX_UNSTABLE_IMAGE_SOURCE_PACK_PROPERTY_NAME] =
getImagePackReferencesForMxcWrappedInMap(mxc, mx, ImageUsage.Sticker, room);

/**
* the currently with the room associated per-message profile, if any, so that it can be included in the message content when sending.
* This allows the server to apply the correct profile-based transformations (e.g. font size adjustments) when processing the message,
* and also allows clients to display an accurate preview of how the message will look with the profile applied while it's being composed.
*/
const globalPerMessageProfile = await getCurrentlyUsedPerMessageProfileForAccount(mx);
const roomPerMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId);
const perMessageProfile = roomPerMessageProfile ?? globalPerMessageProfile;
const catalog = new ProfileCatalog(mx);
const [account, roomSelection] = await Promise.all([
catalog.getSelection('account'),
catalog.getSelection({ roomId }),
]);
const perMessageProfile = resolvePersona({
latched: latchedPersona,
room: roomSelection,
account,
now: Date.now(),
});

if (perMessageProfile) {
content[prefix.MATRIX_UNSTABLE_PER_MESSAGE_PROFILE_PROPERTY_NAME] =
convertPerMessageProfileToBeeperFormat(perMessageProfile, false);
projectPersona(perMessageProfile);
}
content[prefix.MATRIX_UNSTABLE_IMAGE_SOURCE_PACK_PROPERTY_NAME] =
getImagePackReferencesForMxcWrappedInMap(mxc, mx, ImageUsage.Sticker, room);
Expand Down
53 changes: 32 additions & 21 deletions src/app/features/room/composerMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import { Command, SHRUG } from '$hooks/useCommands';
import type { MatrixClient, Room } from '$types/matrix-sdk';
import { SerializableMap } from '$types/wrapper/SerializableMap';
import type { MSC4459ImagePackReference } from '$types/matrix/common';
import type { PKitProxyMessageHandler } from '$plugins/pluralkit-handler/PKitProxyMessageHandler';
import type * as PerMessageProfileModule from '$hooks/usePerMessageProfile';
import type { PerMessageProfileMsc4461 } from '$hooks/usePerMessageProfile';
import type { PerMessageProfileMsc4461 } from '$app/persona';

const { profiles } = vi.hoisted(() => ({
profiles: {
Expand All @@ -15,13 +13,17 @@ const { profiles } = vi.hoisted(() => ({
},
}));

vi.mock('$hooks/usePerMessageProfile', async (importOriginal) => ({
...(await importOriginal<typeof PerMessageProfileModule>()),
getCurrentlyUsedPerMessageProfileForAccount: () => Promise.resolve(profiles.account),
getCurrentlyUsedPerMessageProfileForRoom: () => Promise.resolve(profiles.room),
vi.mock('$app/persona/catalog', () => ({
ProfileCatalog: class {
list = () => Promise.resolve([profiles.account, profiles.room].filter(Boolean));
getSelection = (scope: 'account' | { roomId: string }) => {
const persona = scope === 'account' ? profiles.account : profiles.room;
return Promise.resolve(persona ? { persona } : undefined);
};
},
}));

const { buildOutgoingMessage } = await import('./composerMessage');
const { buildEditReplacement, buildOutgoingMessage } = await import('./composerMessage');

const ROOM_ID = '!room:example.org';

Expand All @@ -36,11 +38,6 @@ const mx = {
getRoom: () => room,
} as unknown as MatrixClient;

const noProxyHandler = {
getPmpBasedOnMessage: () => Promise.resolve(undefined),
stripProxyFromMessage: () => undefined,
} as unknown as PKitProxyMessageHandler;

const profile = (id: string, displayname: string): PerMessageProfileMsc4461 => ({
id,
displayname,
Expand Down Expand Up @@ -79,7 +76,6 @@ const build = (
pmpNoFallback: false,
latchedPersona: undefined,
isPKCommand: () => false,
pluralkitProxyMessageHandler: noProxyHandler,
imagePacksUsed: new SerializableMap<string, MSC4459ImagePackReference>(),
...overrides,
});
Expand Down Expand Up @@ -184,16 +180,11 @@ describe('buildOutgoingMessage', () => {
});

it('strips a pluralkit proxy wrapper and lets its profile win', async () => {
const proxied = profile('proxy', 'Proxied');
const handler = {
getPmpBasedOnMessage: () => Promise.resolve(proxied),
stripProxyFromMessage: (text: string) => text.replace(/^A:\s*/, ''),
} as unknown as PKitProxyMessageHandler;
profiles.account = profile('global', 'Global');
const proxied = { ...profile('proxy', 'Proxied'), trigger: { prefix: ['A: '] } };
profiles.account = proxied;

const result = await build('A: hello there', {
pmpProxyingEnable: true,
pluralkitProxyMessageHandler: handler,
});
if (result.kind !== 'message') throw new Error('expected a message');
// The wrapper must never reach the wire, and the proxy's profile wins.
Expand All @@ -208,4 +199,24 @@ describe('buildOutgoingMessage', () => {
| undefined;
expect(previews?.map((preview) => preview.matched_url)).toContain('https://example.com/page');
});

it('preserves the original per-message profile when editing', () => {
const originalProfile = { id: 'original', displayname: 'Original' };
const edited = buildEditReplacement(plainToEditorInput('updated'), {
mx,
room,
roomId: ROOM_ID,
editingEvent: {
getId: () => '$event',
getContent: () => ({ msgtype: 'm.text', body: 'Original: before' }),
} as never,
currentContent: { 'com.beeper.per_message_profile': originalProfile },
pmpNoFallback: false,
});

expect(edited?.['m.new_content']).toMatchObject({
body: 'Original: updated',
'com.beeper.per_message_profile': originalProfile,
});
});
});
Loading
Loading