Skip to content

Commit 2bd706f

Browse files
authored
Add native push notifications with reply actions, content preview, and configurable gateway (#1238)
<!-- Please read https://github.com/SableClient/Sable/blob/dev/CONTRIBUTING.md before submitting your pull request --> ### Description ## Reply actions (#1209) - Inline reply from OS notifications (Android, iOS, desktop) - Action types registered in tauri.conf.json (sable-message / sable-reply) - Reply queue with dedup and expiry (nativeNotificationReplies.ts) - NativeNotificationActionRouting component wired into ClientNonUIFeatures ## Content preview (#1182) - Rich push payloads enabled by default (useRichPushPayloads) - Fetch + decrypt event when not in local timeline (encrypted rooms) - largeBody for single-message BigTextStyle (expanded view shows body) - Sender name fallback for DMs when room name is missing ## Configurable gateway URL (#1183) - pushNotifyUrlOverride setting - PushPusherConfig.ts helper for URL resolution + payload format ## Distributor timeout (#1176) - Bumps tauri-plugin-notifications to e7a8796 (fixes 2+ distributor selection + native warm-path notifications)" <!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. --> Fixes #1209 #1183 #1182 #1176 #### Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update ### Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings ### AI disclosure: - [ ] Partially AI assisted (clarify which code was AI assisted and briefly explain what it does). - [ ] Fully AI generated (explain what all the generated code does in moderate detail). <!-- Write any explanation required here, but do not generate the explanation using AI!! You must prove you understand what the code in this PR does. -->
2 parents 9a37d2e + 807135e commit 2bd706f

18 files changed

Lines changed: 600 additions & 70 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: minor
3+
---
4+
5+
Add native push notifications with reply actions, content preview, and configurable gateway

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,12 @@ windows = { version = "0.61", features = [
7575
tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
7676

7777
[target.'cfg(any(windows, target_os = "linux"))'.dependencies]
78-
tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "0ca647a8d762cbba2ecfbefed7ff13565ec46229" }
78+
tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "cc3a468263db75e40a8a4751f3dae9bce5bc4234" }
7979

8080
# default-features = false drops notify-rust so macOS uses the native
8181
# UNUserNotificationCenter backend (needs a signed .app to deliver).
8282
[target.'cfg(target_os = "macos")'.dependencies]
83-
tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "0ca647a8d762cbba2ecfbefed7ff13565ec46229", default-features = false }
83+
tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "cc3a468263db75e40a8a4751f3dae9bce5bc4234", default-features = false }
8484

8585
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
8686
tauri-plugin-updater = { version = "2", optional = true }
@@ -104,7 +104,7 @@ cef = { version = "=148.0.0", optional = true }
104104
libloading = "0.8"
105105

106106
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
107-
tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "0ca647a8d762cbba2ecfbefed7ff13565ec46229", features = [
107+
tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "cc3a468263db75e40a8a4751f3dae9bce5bc4234", features = [
108108
"push-notifications",
109109
] }
110110
tauri-plugin-edge-to-edge = { git = "https://github.com/SableClient/tauri-plugin-edge-to-edge.git", rev = "33c6116c27be28c06df5a9d02231ecc5fdeb93c5" }

src-tauri/tauri.conf.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,22 @@
7979
"appLink": false
8080
}
8181
]
82+
},
83+
"notifications": {
84+
"actionTypes": [
85+
{
86+
"id": "sable-message",
87+
"actions": [
88+
{
89+
"id": "sable-reply",
90+
"title": "Reply",
91+
"input": true,
92+
"inputButtonTitle": "Send",
93+
"inputPlaceholder": "Type a reply"
94+
}
95+
]
96+
}
97+
]
8298
}
8399
}
84100
}

src/app/features/settings/notifications/NotificationTransportRuntimeFeature.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export function NotificationTransportRuntimeFeature() {
5858
settingsAtom,
5959
'showMessageContentInEncryptedNotifications'
6060
);
61+
const [useRichPushPayloads] = useSetting(settingsAtom, 'useRichPushPayloads');
62+
const [pushNotifyUrlOverride] = useSetting(settingsAtom, 'pushNotifyUrlOverride');
6163

6264
const runtimeRef = useRef<NotificationTransportRuntime>();
6365
if (!runtimeRef.current) runtimeRef.current = new NotificationTransportRuntime();
@@ -99,6 +101,8 @@ export function NotificationTransportRuntimeFeature() {
99101
vapidPublicKey?: string;
100102
webPushAppID?: string;
101103
pushNotifyUrl?: string;
104+
useRichPushPayloads?: boolean;
105+
pushNotifyUrlOverride?: string;
102106
}>({});
103107
upConfigRef.current = {
104108
unifiedPushAppID:
@@ -110,6 +114,8 @@ export function NotificationTransportRuntimeFeature() {
110114
vapidPublicKey: clientConfig.pushNotificationDetails?.vapidPublicKey,
111115
webPushAppID: clientConfig.pushNotificationDetails?.webPushAppID,
112116
pushNotifyUrl: clientConfig.pushNotificationDetails?.pushNotifyUrl,
117+
useRichPushPayloads,
118+
pushNotifyUrlOverride,
113119
};
114120

115121
// Keep the pusher current: establish it when UnifiedPush becomes the active
@@ -149,7 +155,14 @@ export function NotificationTransportRuntimeFeature() {
149155
})();
150156

151157
return () => {};
152-
}, [provider, mx, setBackgroundPushEnabled, setBackgroundPushProvider]);
158+
}, [
159+
provider,
160+
mx,
161+
useRichPushPayloads,
162+
pushNotifyUrlOverride,
163+
setBackgroundPushEnabled,
164+
setBackgroundPushProvider,
165+
]);
153166

154167
useEffect(
155168
() => () => {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export type PushPusherSettings = {
2+
useRichPushPayloads?: boolean;
3+
pushNotifyUrlOverride?: string;
4+
};
5+
6+
export function resolvePushNotifyUrl(
7+
configuredUrl: string | undefined,
8+
override: string | undefined
9+
): string {
10+
const candidate = override?.trim() || configuredUrl?.trim();
11+
if (!candidate)
12+
throw new Error('Push requires pushNotificationDetails.pushNotifyUrl in config.json.');
13+
14+
let url: URL;
15+
try {
16+
url = new URL(candidate);
17+
} catch {
18+
throw new Error('Push gateway URL must be a full HTTPS URL ending in /notify.');
19+
}
20+
if (
21+
url.protocol !== 'https:' ||
22+
url.username ||
23+
url.password ||
24+
url.hash ||
25+
url.pathname !== '/_matrix/push/v1/notify'
26+
) {
27+
throw new Error('Push gateway URL must be an HTTPS Matrix /_matrix/push/v1/notify endpoint.');
28+
}
29+
return url.toString();
30+
}
31+
32+
export function withPushPayloadFormat<T extends Record<string, unknown>>(
33+
data: T,
34+
useRichPushPayloads = false
35+
): T | (T & { format: 'event_id_only' }) {
36+
return useRichPushPayloads ? data : { ...data, format: 'event_id_only' };
37+
}

src/app/features/settings/notifications/SystemNotification.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,8 @@ function BackgroundPushNotificationSetting() {
458458
settingsAtom,
459459
'pushTransportOverride'
460460
);
461+
const [useRichPushPayloads] = useSetting(settingsAtom, 'useRichPushPayloads');
462+
const [pushNotifyUrlOverride] = useSetting(settingsAtom, 'pushNotifyUrlOverride');
461463
const [legacyPushNotifications, setLegacyPushNotifications] = useSetting(
462464
settingsAtom,
463465
'usePushNotifications'
@@ -591,6 +593,8 @@ function BackgroundPushNotificationSetting() {
591593
vapidPublicKey: clientConfig.pushNotificationDetails?.vapidPublicKey,
592594
webPushAppID: clientConfig.pushNotificationDetails?.webPushAppID,
593595
pushNotifyUrl: clientConfig.pushNotificationDetails?.pushNotifyUrl,
596+
useRichPushPayloads,
597+
pushNotifyUrlOverride,
594598
});
595599

596600
const buildRegisteredUnifiedPushState = (
@@ -1002,6 +1006,10 @@ export function SystemNotification() {
10021006
settingsAtom,
10031007
'clearNotificationsOnRead'
10041008
);
1009+
const [useRichPushPayloads, setUseRichPushPayloads] = useSetting(
1010+
settingsAtom,
1011+
'useRichPushPayloads'
1012+
);
10051013
const [showUnreadCounts, setShowUnreadCounts] = useSetting(settingsAtom, 'showUnreadCounts');
10061014
const [badgeCountDMsOnly, setBadgeCountDMsOnly] = useSetting(settingsAtom, 'badgeCountDMsOnly');
10071015
const [showPingCounts, setShowPingCounts] = useSetting(settingsAtom, 'showPingCounts');
@@ -1126,6 +1134,19 @@ export function SystemNotification() {
11261134
}
11271135
/>
11281136
</SequenceCard>
1137+
<SequenceCard
1138+
className={SequenceCardStyle}
1139+
variant="SurfaceVariant"
1140+
direction="Column"
1141+
gap="400"
1142+
>
1143+
<SettingTile
1144+
title="Rich Push Payloads"
1145+
focusId="rich-push-payloads"
1146+
description="Include message content in push payloads for faster notifications. Your push gateway can see unencrypted message text."
1147+
after={<Switch value={useRichPushPayloads} onChange={setUseRichPushPayloads} />}
1148+
/>
1149+
</SequenceCard>
11291150
<SequenceCard
11301151
className={SequenceCardStyle}
11311152
variant="SurfaceVariant"

src/app/features/settings/notifications/TauriNotificationsApiClient.ts

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isTauri } from '@tauri-apps/api/core';
1+
import { addPluginListener, invoke, isTauri } from '@tauri-apps/api/core';
22
import { type as osType } from '@tauri-apps/plugin-os';
33

44
export type NotificationPluginListener = {
@@ -30,14 +30,84 @@ export type TauriNotificationsApi = {
3030
onNotificationClicked: (
3131
listener: (data: { id: number; data?: Record<string, string> }) => void
3232
) => Promise<NotificationPluginListener>;
33+
registerActionTypes: (types: NotificationActionType[]) => Promise<void>;
34+
onAction: (
35+
listener: (event: NotificationActionEvent) => void
36+
) => Promise<NotificationPluginListener>;
37+
};
38+
39+
export type NotificationActionType = {
40+
id: string;
41+
actions: Array<{ id: string; title: string; input?: boolean }>;
42+
};
43+
44+
export type NotificationActionNotification = Record<string, unknown> & {
45+
actionTypeId: string;
46+
extra?: Record<string, unknown>;
47+
};
48+
49+
export type NotificationActionEvent = {
50+
actionId: string;
51+
inputValue?: string | null;
52+
notification: NotificationActionNotification;
53+
};
54+
55+
type ActionListenerDependencies = { addListener: typeof addPluginListener; invoke: typeof invoke };
56+
let actionListenerCount = 0;
57+
let actionListenerTransition: Promise<void> = Promise.resolve();
58+
59+
const transitionActionListener = (invokeFn: typeof invoke, active: boolean): Promise<void> => {
60+
actionListenerTransition = actionListenerTransition
61+
.catch(() => {})
62+
.then(async () => {
63+
await invokeFn('plugin:notifications|set_action_listener_active', { active });
64+
});
65+
return actionListenerTransition;
3366
};
3467

68+
export async function subscribeToNativeNotificationActions(
69+
listener: (event: NotificationActionEvent) => void,
70+
dependencies: ActionListenerDependencies = { addListener: addPluginListener, invoke }
71+
): Promise<NotificationPluginListener> {
72+
const pluginListener = await dependencies.addListener(
73+
'notifications',
74+
'actionPerformed',
75+
listener
76+
);
77+
try {
78+
actionListenerCount += 1;
79+
if (actionListenerCount === 1) await transitionActionListener(dependencies.invoke, true);
80+
} catch (error) {
81+
actionListenerCount = Math.max(0, actionListenerCount - 1);
82+
await pluginListener.unregister();
83+
throw error;
84+
}
85+
return {
86+
unregister: async () => {
87+
try {
88+
actionListenerCount = Math.max(0, actionListenerCount - 1);
89+
if (actionListenerCount === 0) await transitionActionListener(dependencies.invoke, false);
90+
} finally {
91+
await pluginListener.unregister();
92+
}
93+
},
94+
};
95+
}
96+
3597
let notificationsApiPromise: Promise<TauriNotificationsApi> | null = null;
3698

3799
export async function getTauriNotificationsApi(): Promise<TauriNotificationsApi> {
38100
if (!notificationsApiPromise) {
39-
notificationsApiPromise =
40-
import('@choochmeque/tauri-plugin-notifications-api') as unknown as Promise<TauriNotificationsApi>;
101+
notificationsApiPromise = import('@choochmeque/tauri-plugin-notifications-api').then(
102+
(api) =>
103+
({
104+
...api,
105+
onAction: subscribeToNativeNotificationActions,
106+
}) as unknown as TauriNotificationsApi
107+
);
108+
notificationsApiPromise.catch(() => {
109+
notificationsApiPromise = null;
110+
});
41111
}
42112

43113
return notificationsApiPromise;
@@ -70,6 +140,7 @@ export async function ensureTauriNotificationPermission(): Promise<boolean> {
70140
const DESKTOP_TAURI_OS = new Set(['linux', 'macos', 'windows']);
71141
export const isDesktopTauri = (): boolean => isTauri() && DESKTOP_TAURI_OS.has(osType());
72142
export const isIosTauri = (): boolean => isTauri() && osType() === 'ios';
143+
export const isAndroidTauri = (): boolean => isTauri() && osType() === 'android';
73144
// Platforms where OS notifications go through the native plugin instead of web APIs.
74145
export const isNativeNotificationTauri = (): boolean => isDesktopTauri() || isIosTauri();
75146

@@ -84,15 +155,20 @@ export type NativeTauriNotification = {
84155
title: string;
85156
body?: string;
86157
silent?: boolean;
87-
/** Attached to the notification and handed back by onNotificationClicked. */
88158
extra?: Record<string, string>;
159+
actionTypeId?: string;
160+
group?: string;
161+
icon?: string;
89162
};
90163

91164
export async function sendNativeTauriNotification({
92165
title,
93166
body,
94167
silent,
95168
extra,
169+
actionTypeId,
170+
group,
171+
icon,
96172
}: NativeTauriNotification): Promise<void> {
97173
if (!(await ensureTauriNotificationPermission())) return;
98174
const api = await getTauriNotificationsApi();
@@ -102,5 +178,8 @@ export async function sendNativeTauriNotification({
102178
body,
103179
silent: silent ?? false,
104180
extra,
181+
...(actionTypeId ? { actionTypeId } : {}),
182+
...(group ? { group } : {}),
183+
...(icon ? { icon } : {}),
105184
});
106185
}

src/app/features/settings/notifications/UnifiedPushNotifications.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const matrixClient = vi.hoisted(() => ({
3535
getPushers: vi
3636
.fn<() => Promise<{ pushers: Array<unknown> }>>()
3737
.mockResolvedValue({ pushers: [] }),
38+
getSafeUserId: vi.fn<() => string>(() => '@user:example.com'),
3839
}));
3940

4041
vi.mock('./UnifiedPushTransport', () => unifiedPushTransport);

0 commit comments

Comments
 (0)