Skip to content

Commit 867f67b

Browse files
authored
Feat/notifications in eid wallet (#916)
* feat: add role management UI to group member list Add dropdown menu per member in the signing status sidebar allowing admins to grant admin privileges and owners to revoke admin or transfer ownership. Controls are only visible to users with appropriate permissions. * feat: push notifications for new chat messages Send native push notifications to chat/group participants when a new message is created. Notifications route through eVault's existing notification infrastructure and display in eid-wallet with a notification panel. Tapping a notification opens a public app-choice page (Pictique or Blabsy), which resolves the global chat ID to a local ID and redirects to the appropriate chat view. * feat: notifications * feat: push notifications * feat: pictique header & eid privacy thing * chore: bump version * chore: format * chore: lint * chore: fix build
1 parent e78bc5b commit 867f67b

26 files changed

Lines changed: 988 additions & 71 deletions

File tree

infrastructure/eid-wallet/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "eid-wallet",
3-
"version": "0.6.0",
3+
"version": "0.7.0",
44
"description": "",
55
"type": "module",
66
"scripts": {

infrastructure/eid-wallet/src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"active": true,
2929
"targets": "all",
3030
"android": {
31-
"versionCode": 22
31+
"versionCode": 23
3232
},
3333
"icon": [
3434
"icons/32x32.png",

infrastructure/eid-wallet/src/env.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@ declare module "$env/static/public" {
77
export const PUBLIC_PROVISIONER_URL: string;
88
export const PUBLIC_EID_WALLET_TOKEN: string;
99
export const PUBLIC_PROVISIONER_SHARED_SECRET: string;
10+
export const PUBLIC_PICTIQUE_BASE_URL: string;
11+
export const PUBLIC_BLABSY_BASE_URL: string;
1012
}

infrastructure/eid-wallet/src/lib/services/NotificationService.ts

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { PUBLIC_PROVISIONER_URL } from "$env/static/public";
2+
import { addNotification } from "$lib/stores/notifications";
23
import {
34
isPermissionGranted,
45
registerForPushNotifications,
@@ -118,7 +119,20 @@ class NotificationService {
118119
*/
119120
async sendLocalNotification(payload: NotificationPayload): Promise<void> {
120121
try {
121-
console.log("Attempting to send local notification:", payload);
122+
// Store notification for the notification panel — coerce to string values only
123+
const data = payload.data
124+
? Object.fromEntries(
125+
Object.entries(payload.data).filter(
126+
(entry): entry is [string, string] =>
127+
typeof entry[1] === "string",
128+
),
129+
)
130+
: undefined;
131+
addNotification({
132+
title: payload.title,
133+
body: payload.body,
134+
data: Object.keys(data ?? {}).length > 0 ? data : undefined,
135+
});
122136

123137
// Check permissions first
124138
const hasPermission = await isPermissionGranted();
@@ -192,7 +206,12 @@ class NotificationService {
192206
/**
193207
* Check for notifications from provisioner and show them locally
194208
*/
195-
async checkAndShowNotifications(): Promise<void> {
209+
async checkAndShowNotifications(): Promise<{
210+
globalMessageId?: string;
211+
globalChatId?: string;
212+
title?: string;
213+
body?: string;
214+
} | null> {
196215
try {
197216
console.log("🔍 Checking for notifications from provisioner...");
198217

@@ -220,25 +239,25 @@ class NotificationService {
220239
console.log("Device registered successfully");
221240
} else {
222241
console.log("Failed to register device");
223-
return;
242+
return null;
224243
}
225244
} else {
226245
console.log(
227246
"No eName found in vault, skipping notification check",
228247
);
229-
return;
248+
return null;
230249
}
231250
} catch (error) {
232251
console.error("Error getting vault eName:", error);
233-
return;
252+
return null;
234253
}
235254
}
236255

237256
if (!registration) {
238257
console.log(
239258
"Still no device registration, skipping notification check",
240259
);
241-
return;
260+
return null;
242261
}
243262

244263
// Check for notifications from provisioner
@@ -260,24 +279,42 @@ class NotificationService {
260279
const data = await response.json();
261280
if (data.notifications && data.notifications.length > 0) {
262281
console.log(
263-
`📱 Found ${data.notifications.length} notification(s)`,
282+
`Found ${data.notifications.length} notification(s)`,
264283
);
265284

266-
// Show each notification locally
285+
// Show each notification locally — intentionally keep only the
286+
// most recent new_message so the caller navigates to it.
287+
let lastMessageNotif: {
288+
globalMessageId?: string;
289+
globalChatId?: string;
290+
title?: string;
291+
body?: string;
292+
} | null = null;
267293
for (const notification of data.notifications) {
268294
await this.sendLocalNotification(notification);
295+
if (notification.data?.type === "new_message") {
296+
lastMessageNotif = {
297+
globalMessageId:
298+
notification.data.globalMessageId,
299+
globalChatId: notification.data.globalChatId,
300+
title: notification.title,
301+
body: notification.body,
302+
};
303+
}
269304
}
270-
} else {
271-
console.log("No new notifications");
305+
return lastMessageNotif;
272306
}
273-
} else {
274-
console.log(
275-
"No notifications endpoint available or error:",
276-
response.status,
277-
);
307+
console.log("No new notifications");
308+
return null;
278309
}
310+
console.log(
311+
"No notifications endpoint available or error:",
312+
response.status,
313+
);
314+
return null;
279315
} catch (error) {
280316
console.error("Error checking notifications:", error);
317+
return null;
281318
}
282319
}
283320

@@ -289,7 +326,7 @@ class NotificationService {
289326
await this.sendLocalNotification({
290327
title: "Test Notification",
291328
body: "This is a test notification from eid-wallet!",
292-
data: { test: true, timestamp: new Date().toISOString() },
329+
data: { test: "true", timestamp: new Date().toISOString() },
293330
});
294331
}
295332

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
export interface StoredNotification {
2+
id: string;
3+
title: string;
4+
body: string;
5+
data?: Record<string, string>;
6+
createdAt: string;
7+
}
8+
9+
const STORAGE_KEY = "eid_wallet_notifications";
10+
const MAX_NOTIFICATIONS = 200;
11+
12+
let cachedNotifications: StoredNotification[] | null = null;
13+
14+
function loadNotifications(): StoredNotification[] {
15+
if (cachedNotifications !== null) return cachedNotifications;
16+
try {
17+
const raw = localStorage.getItem(STORAGE_KEY);
18+
const parsed: StoredNotification[] = raw ? JSON.parse(raw) : [];
19+
cachedNotifications = parsed;
20+
return parsed;
21+
} catch {
22+
cachedNotifications = [];
23+
return cachedNotifications;
24+
}
25+
}
26+
27+
function saveNotifications(notifications: StoredNotification[]): void {
28+
cachedNotifications = notifications;
29+
localStorage.setItem(STORAGE_KEY, JSON.stringify(notifications));
30+
}
31+
32+
let listeners: Array<() => void> = [];
33+
34+
function notify() {
35+
for (const fn of listeners) fn();
36+
}
37+
38+
export function subscribe(fn: () => void): () => void {
39+
listeners.push(fn);
40+
return () => {
41+
listeners = listeners.filter((l) => l !== fn);
42+
};
43+
}
44+
45+
export function getNotifications(): StoredNotification[] {
46+
return loadNotifications();
47+
}
48+
49+
export function getUnreadCount(): number {
50+
return loadNotifications().length;
51+
}
52+
53+
export function addNotification(
54+
notification: Omit<StoredNotification, "id" | "createdAt">,
55+
): void {
56+
const notifications = loadNotifications();
57+
notifications.unshift({
58+
...notification,
59+
id: crypto.randomUUID(),
60+
createdAt: new Date().toISOString(),
61+
});
62+
saveNotifications(notifications.slice(0, MAX_NOTIFICATIONS));
63+
notify();
64+
}
65+
66+
export function clearNotificationsForChat(globalChatId: string): void {
67+
const notifications = loadNotifications();
68+
const filtered = notifications.filter(
69+
(n) => n.data?.globalChatId !== globalChatId,
70+
);
71+
saveNotifications(filtered);
72+
notify();
73+
}
74+
75+
export function removeNotification(id: string): void {
76+
const notifications = loadNotifications();
77+
const filtered = notifications.filter((n) => n.id !== id);
78+
saveNotifications(filtered);
79+
notify();
80+
}
81+
82+
export function clearAllNotifications(): void {
83+
saveNotifications([]);
84+
notify();
85+
}

infrastructure/eid-wallet/src/routes/(app)/+layout.svelte

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,21 @@ onMount(async () => {
4747
);
4848
}
4949
50-
// Check for notifications after successful authentication
50+
// Check for pending notifications and navigate to the message's open page
5151
try {
5252
const notificationService = globalState.notificationService;
53-
await notificationService.checkAndShowNotifications();
53+
const notifData =
54+
await notificationService.checkAndShowNotifications();
55+
if (notifData?.globalChatId) {
56+
const params = new URLSearchParams({
57+
chatId: notifData.globalChatId,
58+
...(notifData.title && { title: notifData.title }),
59+
...(notifData.body && { body: notifData.body }),
60+
});
61+
await goto(
62+
`/open-message/${encodeURIComponent(notifData.globalMessageId || notifData.globalChatId)}?${params.toString()}`,
63+
);
64+
}
5465
} catch (error) {
5566
console.error("Failed to check notifications:", error);
5667
}

infrastructure/eid-wallet/src/routes/(app)/main/+page.svelte

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@ import {
77
} from "$env/static/public";
88
import { Hero, IdentityCard } from "$lib/fragments";
99
import type { GlobalState } from "$lib/global";
10+
import {
11+
getUnreadCount,
12+
subscribe as subscribeNotifications,
13+
} from "$lib/stores/notifications";
1014
import { BottomSheet, Toast } from "$lib/ui";
1115
import * as Button from "$lib/ui/Button";
1216
import { capitalize } from "$lib/utils";
1317
import {
18+
ChatNotificationIcon,
1419
LinkSquare02Icon,
1520
QrCodeIcon,
1621
Settings02Icon,
@@ -95,6 +100,8 @@ let diditRejectionReason = $state<string | null>(null);
95100
let duplicateEName = $state<string | null>(null);
96101
// ─────────────────────────────────────────────────────────────────────────────
97102
103+
let notificationCount = $state(0);
104+
let unsubNotifications: (() => void) | undefined;
98105
let shareQRdrawerOpen = $state(false);
99106
let statusInterval: ReturnType<typeof setInterval> | undefined =
100107
$state(undefined);
@@ -424,6 +431,11 @@ async function handleUpgrade() {
424431
// ─────────────────────────────────────────────────────────────────────────────
425432
426433
onMount(() => {
434+
notificationCount = getUnreadCount();
435+
unsubNotifications = subscribeNotifications(() => {
436+
notificationCount = getUnreadCount();
437+
});
438+
427439
const shouldSkipProfileSetupGate =
428440
localStorage.getItem(RECOVERY_SKIP_PROFILE_SETUP_KEY) === "true";
429441
if (shouldSkipProfileSetupGate) {
@@ -467,6 +479,7 @@ onDestroy(() => {
467479
if (statusInterval) {
468480
clearInterval(statusInterval);
469481
}
482+
unsubNotifications?.();
470483
});
471484
</script>
472485

@@ -508,14 +521,31 @@ onDestroy(() => {
508521
{/snippet}
509522
</Hero>
510523

511-
<Button.Nav href="/settings">
512-
<HugeiconsIcon
513-
size={32}
514-
strokeWidth={2}
515-
className="mt-1.5"
516-
icon={Settings02Icon}
517-
/>
518-
</Button.Nav>
524+
<div class="flex items-center gap-2">
525+
<Button.Nav href="/notifications" class="relative" aria-label={notificationCount > 0 ? `Notifications (${notificationCount} unread)` : "Notifications"}>
526+
<HugeiconsIcon
527+
size={28}
528+
strokeWidth={2}
529+
className="mt-1.5"
530+
icon={ChatNotificationIcon}
531+
/>
532+
{#if notificationCount > 0}
533+
<span
534+
class="absolute -top-0.5 -right-0.5 bg-red-500 text-white text-[10px] font-bold rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1"
535+
>
536+
{notificationCount > 99 ? "99+" : notificationCount}
537+
</span>
538+
{/if}
539+
</Button.Nav>
540+
<Button.Nav href="/settings" aria-label="Settings">
541+
<HugeiconsIcon
542+
size={32}
543+
strokeWidth={2}
544+
className="mt-1.5"
545+
icon={Settings02Icon}
546+
/>
547+
</Button.Nav>
548+
</div>
519549
</div>
520550

521551
{#snippet Section(title: string, children: Snippet)}

0 commit comments

Comments
 (0)