Skip to content
Closed
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
13 changes: 12 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ yarn workspaces:build # Build all workspaces
- `release/X.Y.x` branches are patch lines for a shipped stable version.
Fixes land on `dev` first and are cherry-picked onto the release branch.
A hotfix authored directly on a release branch must be forward-ported to
`dev` immediately via a cherry-pick PR.
`dev` in the same change or immediately after (cherry-pick PR). Do not
ship a stable cut from `dev` that is missing a hotfix that already
shipped on a release line.
- Never back-merge `master` or a `release/X.Y.x` branch into `dev`.
- Tags are created only via `yarn release:tag` (channel-aware guard).
Release builds trigger on semver tag pushes only and always produce a
Expand Down Expand Up @@ -135,6 +137,15 @@ yarn workspaces:build # Build all workspaces
semantics and traps, Fuselage geometry/timing facts, the button-dimming and
SVG transform-origin pitfalls, and layout rules learned in PRs #3441/#3443.

## Desktop Notifications

- Windows / Electron 42 can emit `reply` twice for one toast (WinRT + COM
activation paths). Guard `NOTIFICATIONS_NOTIFICATION_REPLIED` with a
per-notification `Set`; re-arm (delete the id) on `show`; never clear on
`close` — the duplicate can arrive after dismiss, and clearing there
reopens the race. If Windows replies are routed through
`Notification.handleActivation`, that path must use the same `Set`.

## Testing

- Renderer specs use `*.spec.ts` / `*.spec.tsx`.
Expand Down
9 changes: 9 additions & 0 deletions src/notifications/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const resolveIcon = async (
const notifications = new Map();
const notificationTypes = new Map<string, 'voice' | 'text'>();
const notificationCategories = new Map<string, 'DOWNLOADS' | 'SERVER'>();
const repliedNotifications = new Set<string>();

const createNotification = async (
id: string,
Expand Down Expand Up @@ -79,6 +80,8 @@ const createNotification = async (
});

notification.addListener('show', () => {
repliedNotifications.delete(id);

dispatchSingle({
type: NOTIFICATIONS_NOTIFICATION_SHOWN,
payload: { id },
Expand Down Expand Up @@ -126,6 +129,12 @@ const createNotification = async (
});

notification.addListener('reply', (_event, reply) => {
// Electron 42 on Windows can dispatch a single toast reply twice (WinRT+COM activation paths)
if (repliedNotifications.has(id)) {
return;
}
repliedNotifications.add(id);

dispatchSingle({
type: NOTIFICATIONS_NOTIFICATION_REPLIED,
payload: { id, reply },
Expand Down
64 changes: 64 additions & 0 deletions src/notifications/main/setup.main.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,4 +233,68 @@ describe('notifications/main setupNotifications', () => {
expect(dispatch).not.toHaveBeenCalled();
expect(notificationInstances).toHaveLength(0);
});

const repliedCalls = () =>
dispatchSingle.mock.calls.filter(
([action]) => action.type === NOTIFICATIONS_NOTIFICATION_REPLIED
);

it('dispatches REPLIED exactly once when the same reply event fires twice', async () => {
await create({
title: 'Hello',
body: 'World',
tag: 'dedup-1',
canReply: true,
});
const n = notificationInstances[0];

n.emit('reply', {}, 'hi there');
n.emit('reply', {}, 'hi there');

expect(repliedCalls()).toHaveLength(1);
expect(repliedCalls()[0][0].payload).toEqual({
id: 'dedup-1',
reply: 'hi there',
});
});

it('re-arms the guard after another show event, allowing a subsequent reply to dispatch', async () => {
await create({
title: 'Hello',
body: 'World',
tag: 'dedup-2',
canReply: true,
});
const n = notificationInstances[0];

n.emit('reply', {}, 'first reply');
n.emit('show');
n.emit('reply', {}, 'second reply');

expect(repliedCalls()).toHaveLength(2);
expect(repliedCalls()[1][0].payload).toEqual({
id: 'dedup-2',
reply: 'second reply',
});
});

it('does not re-arm the reply guard on close (duplicate can arrive after dismiss)', async () => {
await create({
title: 'Hello',
body: 'World',
tag: 'dedup-3',
canReply: true,
});
const n = notificationInstances[0];

n.emit('reply', {}, 'once');
n.emit('close');
n.emit('reply', {}, 'late duplicate');

expect(repliedCalls()).toHaveLength(1);
expect(repliedCalls()[0][0].payload).toEqual({
id: 'dedup-3',
reply: 'once',
});
});
});
Loading