cheogram-www
created pr with
cheogram-www/tauri-alpha.1
commented
Changes are available at https://tangled.org/mrry.band/cheogram-www on branch tauri-alpha.
added cheogram-www/tauri-alpha.2
1: 45e8ad7 = 1: 45e8ad7 Refactor service worker access
2: fab4022 ! 2: ce66e8f Move notifications code to one file
-: ------- > 3: 10e3574 Refactor notification code
3: d127ee0 ! 4: ba0891f npm run tauri init
4: 9e006bd = 5: 5b3abf9 Request microphone and camera usage on mac
5: 03d4934 = 6: 994a2e9 Tauri file system media store
6: b96216a = 7: 320fa8f Don't register or use serviceworker in tauri
7: faace0a = 8: e76db17 Open links in browser
8: 9c07308 ! 9: 761dd22 Tauri notifications
cmds
checkout latest patchset:
ssh pr.pico.sh pull cheogram-www:tauri-alpha | git am -3checkout specific patchset revision:
ssh pr.pico.sh pull cheogram-www:tauri-alpha [rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh cheogram-www:tauri-alphaadd comment to patch request:
ssh pr.pico.sh comment cheogram-www:tauri-alpha "lgtm!"
Patchset
cheogram-www/tauri-alpha.1
Refactor service worker access
Eric Roberts
→ Refactor notification related code
2026-10-29T14:50:23ZEric Roberts
npm run tauri init
2026-10-29T15:06:32ZEric Roberts
Request microphone and camera usage on mac
2026-10-16T14:01:30ZEric Roberts
Tauri file system media store
2026-10-20T19:29:32ZEric Roberts
Don't register or use serviceworker in tauri
2026-10-16T18:10:16ZEric Roberts
Open links in browser
2026-10-19T18:23:24ZEric Roberts
Tauri notifications
2026-10-20T18:39:35ZEric Roberts
2026-10-19T19:40:11Z
Refactor notification related code
Eric Roberts
2026-10-29T15:06:32ZWith Tauri we'll need to send notifications a different way, so I wanted a common path to show notifications. We had something called showNotification but it was for a specific context, so I renamed it to showMessageNotification and pulled out the part that decided how to send into a new showNotification function. Notification events also had data typed as any, and I wanted to make things a little more explicit about the data we expect. So showNotification expects data of a specific type, and then when we're handling the event we parse it with valibot, instead of checking each property with ? (which we didn't even have to do when it was type any). I removed the return of notification from showMessageNotification because there's only one caller and it doesn't use it.
Semantic diff summary
3 added,
7 modified,
0 signature changed,
0 removed
across 4 analyzed files
(1 file skipped: unsupported file type)
+1
-0
package.json
#
+20
-38
src/App.tsx
#
| ... | ... | @@ -1,3 +1,10 @@ | |
| 1 | + | import { safeParse } from "valibot"; | |
| 2 | + | import { | |
| 3 | + | showMessageNotification, | |
| 4 | + | handleNotificationClick, | |
| 5 | + | handleNotificationClose, | |
| 6 | + | NotificationEventSchema, | |
| 7 | + | } from "./notifications"; | |
| 1 | 8 | import "./App.scss"; | |
| 2 | 9 | import { CircularProgress } from "@mui/material"; | |
| 3 | 10 | import { newAccount } from "./components/Login"; |
| ... | ... | @@ -13,14 +20,7 @@ import { | |
| 13 | 20 | useNavigate, | |
| 14 | 21 | useParams, | |
| 15 | 22 | } from "react-router"; | |
| 16 | - | import { | |
| 17 | - | ios, | |
| 18 | - | webkit, | |
| 19 | - | href, | |
| 20 | - | useEffectEvent, | |
| 21 | - | showNotification, | |
| 22 | - | serviceWorker, | |
| 23 | - | } from "./util"; | |
| 23 | + | import { ios, webkit, useEffectEvent, serviceWorker } from "./util"; | |
| 24 | 24 | import { SettingsContext, SettingsData } from "./components/Settings"; | |
| 25 | 25 | import { Call } from "./components/Call"; | |
| 26 | 26 | import { LoginRequest } from "./components/LoginRequest"; |
| ... | ... | @@ -108,7 +108,7 @@ const AppInner = () => { | |
| 108 | 108 | (chat.notifyReply() && message.replyToMessage && members[1]?.isSelf) | |
| 109 | 109 | ) { | |
| 110 | 110 | if (settings.audibleNotifications) new Audio("/message.opus").play(); | |
| 111 | - | await showNotification( | |
| 111 | + | await showMessageNotification( | |
| 112 | 112 | client.accountId(), | |
| 113 | 113 | members[0] ?? message.senderMemberStub(), | |
| 114 | 114 | message, |
| ... | ... | @@ -163,35 +163,17 @@ const AppInner = () => { | |
| 163 | 163 | ||
| 164 | 164 | useEffect(() => { | |
| 165 | 165 | if (!serviceWorker) return; | |
| 166 | - | const handler = (event: MessageEvent) => { | |
| 167 | - | const accountId = event.data?.data?.accountId; | |
| 168 | - | const chatId = event.data?.data?.chatId; | |
| 169 | - | if (event.data.event === "notificationclick") { | |
| 170 | - | navigate( | |
| 171 | - | href("/accounts/:accountId/chats/:chatId", { accountId, chatId }) + | |
| 172 | - | `#message:${event.data.data.messageId}`, | |
| 173 | - | ); | |
| 174 | - | if (event.data.data.type === borogove.MessageType.MessageCall) { | |
| 175 | - | if (event.data.data.callStatus === "propose") { | |
| 176 | - | setTimeout( | |
| 177 | - | () => accounts[accountId]?.client?.getChat(chatId)?.acceptCall(), | |
| 178 | - | 100, | |
| 179 | - | ); | |
| 180 | - | } | |
| 181 | - | } | |
| 182 | - | } | |
| 183 | - | if (event.data.event === "notificationclose") { | |
| 184 | - | if (event.data.data.type === borogove.MessageType.MessageCall) { | |
| 185 | - | setTimeout(() => { | |
| 186 | - | const chat = accounts[accountId]?.client?.getChat(chatId); | |
| 187 | - | if ( | |
| 188 | - | chat && | |
| 189 | - | chat.callStatus() === borogove.calls.CallStatus.Incoming | |
| 190 | - | ) { | |
| 191 | - | chat.hangup(); | |
| 192 | - | } | |
| 193 | - | }, 100); | |
| 194 | - | } | |
| 166 | + | const handler = (event: MessageEvent<unknown>) => { | |
| 167 | + | const result = safeParse(NotificationEventSchema, event.data); | |
| 168 | + | if (!result.success) return; | |
| 169 | + | const { event: action, data } = result.output; | |
| 170 | + | switch (action) { | |
| 171 | + | case "notificationclick": | |
| 172 | + | handleNotificationClick(data, accounts, navigate); | |
| 173 | + | break; | |
| 174 | + | case "notificationclose": | |
| 175 | + | handleNotificationClose(data, accounts); | |
| 176 | + | break; | |
| 195 | 177 | } | |
| 196 | 178 | }; | |
| 197 | 179 | serviceWorker.addEventListener("message", handler); |
+3
-6
src/components/Call.tsx
#
| ... | ... | @@ -151,12 +152,8 @@ const showCallNotification = async ( | |
| 151 | 152 | }, | |
| 152 | 153 | }; | |
| 153 | 154 | ||
| 154 | - | let notification = null; | |
| 155 | - | if (serviceWorker) { | |
| 156 | - | await serviceWorker.showNotification(title, options); | |
| 157 | - | } else { | |
| 158 | - | notification = new Notification(title, options); | |
| 159 | - | ||
| 155 | + | const notification = await showNotification(title, options); | |
| 156 | + | if (notification) { | |
| 160 | 157 | let clicked = false; | |
| 161 | 158 | notification.addEventListener("click", () => { | |
| 162 | 159 | clicked = true; |
+135
-0
src/notifications.ts
#
| ... | ... | @@ -0,0 +1,135 @@ | |
| 1 | + | import type { NavigateFunction } from "react-router"; | |
| 2 | + | import * as borogove from "borogove"; | |
| 3 | + | import * as v from "valibot"; | |
| 4 | + | import { href, readyServiceWorker } from "./util"; | |
| 5 | + | ||
| 6 | + | export const NotificationDataSchema = v.object({ | |
| 7 | + | accountId: v.string(), | |
| 8 | + | chatId: v.string(), | |
| 9 | + | messageId: v.nullish(v.string()), | |
| 10 | + | type: v.optional( | |
| 11 | + | v.picklist([ | |
| 12 | + | borogove.MessageType.MessageChat, | |
| 13 | + | borogove.MessageType.MessageCall, | |
| 14 | + | borogove.MessageType.MessageChannel, | |
| 15 | + | borogove.MessageType.MessageChannelPrivate, | |
| 16 | + | ]), | |
| 17 | + | ), | |
| 18 | + | callStatus: v.optional(v.string()), | |
| 19 | + | }); | |
| 20 | + | export type NotificationData = v.InferOutput<typeof NotificationDataSchema>; | |
| 21 | + | ||
| 22 | + | export const NotificationEventSchema = v.object({ | |
| 23 | + | event: v.picklist(["notificationclick", "notificationclose"]), | |
| 24 | + | data: NotificationDataSchema, | |
| 25 | + | }); | |
| 26 | + | ||
| 27 | + | export const showNotification = async ( | |
| 28 | + | title: string, | |
| 29 | + | options: NotificationOptions & { data: NotificationData }, | |
| 30 | + | ) => { | |
| 31 | + | const serviceWorker = await readyServiceWorker; | |
| 32 | + | if (serviceWorker) { | |
| 33 | + | await serviceWorker.showNotification(title, options); | |
| 34 | + | return null; | |
| 35 | + | } | |
| 36 | + | return new Notification(title, options); | |
| 37 | + | }; | |
| 38 | + | ||
| 39 | + | export const showMessageNotification = async ( | |
| 40 | + | accountId: string, | |
| 41 | + | sender: borogove.Member, | |
| 42 | + | message: borogove.ChatMessage, | |
| 43 | + | ) => { | |
| 44 | + | if (!Notification?.permission) return; | |
| 45 | + | ||
| 46 | + | const serviceWorker = await readyServiceWorker; | |
| 47 | + | const tag = accountId + ">" + message.chatId(); | |
| 48 | + | if (serviceWorker) { | |
| 49 | + | // Check if we already notified this message | |
| 50 | + | const notifs = await serviceWorker.getNotifications({ | |
| 51 | + | tag: tag, | |
| 52 | + | }); | |
| 53 | + | for (const notif of notifs) { | |
| 54 | + | if (notif.data.messageId === (message.serverId || message.localId)) | |
| 55 | + | return; | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | const title = "Message from " + (sender?.displayName ?? message.chatId()); | |
| 60 | + | let icon = sender.photoUri ?? sender.placeholderUri; | |
| 61 | + | if (!icon.startsWith("data:")) { | |
| 62 | + | const response = await fetch(icon); | |
| 63 | + | const mime = response.headers.get("Content-Type"); | |
| 64 | + | if (response.ok && mime) { | |
| 65 | + | const buffer = await response.arrayBuffer(); | |
| 66 | + | const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer))); | |
| 67 | + | icon = `data:${mime};base64,${base64}`; | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | const body = message.body().toPlainText(); | |
| 71 | + | const options = { | |
| 72 | + | body: !body && message.attachments.length > 0 ? "Media" : body, | |
| 73 | + | icon, | |
| 74 | + | tag, | |
| 75 | + | renotify: true, | |
| 76 | + | timestamp: new Date(message.timeSent).getTime(), | |
| 77 | + | vibrate: [50, 50, 50], | |
| 78 | + | data: { | |
| 79 | + | accountId, | |
| 80 | + | chatId: message.chatId(), | |
| 81 | + | messageId: message.serverId || message.localId, | |
| 82 | + | timestamp: message.timeSent, | |
| 83 | + | }, | |
| 84 | + | }; | |
| 85 | + | const notif = await showNotification(title, options); | |
| 86 | + | if (notif) { | |
| 87 | + | notif.addEventListener("click", () => { | |
| 88 | + | history.pushState( | |
| 89 | + | null, | |
| 90 | + | "", | |
| 91 | + | href("/accounts/:accountId/chats/:chatId", { | |
| 92 | + | accountId: accountId, | |
| 93 | + | chatId: message.chatId(), | |
| 94 | + | }) + `#message:${message.serverId || message.localId}`, | |
| 95 | + | ); | |
| 96 | + | window.dispatchEvent(new PopStateEvent("popstate")); // Notify React Router | |
| 97 | + | }); | |
| 98 | + | } | |
| 99 | + | }; | |
| 100 | + | ||
| 101 | + | export function handleNotificationClick( | |
| 102 | + | data: NotificationData, | |
| 103 | + | accounts: Record<string, { client: borogove.Client }>, | |
| 104 | + | navigate: NavigateFunction, | |
| 105 | + | ) { | |
| 106 | + | const { accountId, chatId } = data; | |
| 107 | + | navigate( | |
| 108 | + | href("/accounts/:accountId/chats/:chatId", { accountId, chatId }) + | |
| 109 | + | (data.messageId ? `#message:${data.messageId}` : ""), | |
| 110 | + | ); | |
| 111 | + | if ( | |
| 112 | + | data.type === borogove.MessageType.MessageCall && | |
| 113 | + | data.callStatus === "propose" | |
| 114 | + | ) { | |
| 115 | + | setTimeout( | |
| 116 | + | () => accounts[accountId]?.client?.getChat(chatId)?.acceptCall(), | |
| 117 | + | 100, | |
| 118 | + | ); | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | export function handleNotificationClose( | |
| 123 | + | data: NotificationData, | |
| 124 | + | accounts: Record<string, { client: borogove.Client }>, | |
| 125 | + | ) { | |
| 126 | + | const { accountId, chatId } = data; | |
| 127 | + | if (data.type === borogove.MessageType.MessageCall) { | |
| 128 | + | setTimeout(() => { | |
| 129 | + | const chat = accounts[accountId]?.client?.getChat(chatId); | |
| 130 | + | if (chat && chat.callStatus() === borogove.calls.CallStatus.Incoming) { | |
| 131 | + | chat.hangup(); | |
| 132 | + | } | |
| 133 | + | }, 100); | |
| 134 | + | } | |
| 135 | + | } |
+0
-65
src/util.ts
#
| ... | ... | @@ -84,71 +84,6 @@ export const useChatExtras = (accountId: string) => { | |
| 84 | 84 | }; | |
| 85 | 85 | }; | |
| 86 | 86 | ||
| 87 | - | export const showNotification = async ( | |
| 88 | - | accountId: string, | |
| 89 | - | sender: borogove.Member, | |
| 90 | - | message: borogove.ChatMessage, | |
| 91 | - | ) => { | |
| 92 | - | if (!Notification?.permission) return; | |
| 93 | - | ||
| 94 | - | const serviceWorker = await readyServiceWorker; | |
| 95 | - | const tag = accountId + ">" + message.chatId(); | |
| 96 | - | if (serviceWorker) { | |
| 97 | - | // Check if we already notified this message | |
| 98 | - | const notifs = await serviceWorker.getNotifications({ | |
| 99 | - | tag: tag, | |
| 100 | - | }); | |
| 101 | - | for (const notif of notifs) { | |
| 102 | - | if (notif.data.messageId === (message.serverId || message.localId)) | |
| 103 | - | return; | |
| 104 | - | } | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | const title = "Message from " + (sender?.displayName ?? message.chatId()); | |
| 108 | - | let icon = sender.photoUri ?? sender.placeholderUri; | |
| 109 | - | if (!icon.startsWith("data:")) { | |
| 110 | - | const response = await fetch(icon); | |
| 111 | - | const mime = response.headers.get("Content-Type"); | |
| 112 | - | if (response.ok && mime) { | |
| 113 | - | const buffer = await response.arrayBuffer(); | |
| 114 | - | const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer))); | |
| 115 | - | icon = `data:${mime};base64,${base64}`; | |
| 116 | - | } | |
| 117 | - | } | |
| 118 | - | const body = message.body().toPlainText(); | |
| 119 | - | const options = { | |
| 120 | - | body: !body && message.attachments.length > 0 ? "Media" : body, | |
| 121 | - | icon, | |
| 122 | - | tag, | |
| 123 | - | renotify: true, | |
| 124 | - | timestamp: new Date(message.timeSent).getTime(), | |
| 125 | - | vibrate: [50, 50, 50], | |
| 126 | - | data: { | |
| 127 | - | accountId, | |
| 128 | - | chatId: message.chatId(), | |
| 129 | - | messageId: message.serverId || message.localId, | |
| 130 | - | timestamp: message.timeSent, | |
| 131 | - | }, | |
| 132 | - | }; | |
| 133 | - | if (serviceWorker) { | |
| 134 | - | await serviceWorker.showNotification(title, options); | |
| 135 | - | } else { | |
| 136 | - | const notif = new Notification(title, options); | |
| 137 | - | notif.addEventListener("click", () => { | |
| 138 | - | history.pushState( | |
| 139 | - | null, | |
| 140 | - | "", | |
| 141 | - | href("/accounts/:accountId/chats/:chatId", { | |
| 142 | - | accountId: accountId, | |
| 143 | - | chatId: message.chatId(), | |
| 144 | - | }) + `#message:${message.serverId || message.localId}`, | |
| 145 | - | ); | |
| 146 | - | window.dispatchEvent(new PopStateEvent("popstate")); // Notify React Router | |
| 147 | - | }); | |
| 148 | - | return notif; | |
| 149 | - | } | |
| 150 | - | }; | |
| 151 | - | ||
| 152 | 87 | const negatedScriptRegex = { | |
| 153 | 88 | Latn: /(?=\p{L})\P{Script=Latin}/u, | |
| 154 | 89 | Cyrl: /(?=\p{L})\P{Script=Cyrillic}/u, |