diff --git a/.gitignore b/.gitignore index 8126520..8c27453 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,7 @@ dist # Rust (Tauri) target/ - +**/src-tauri/gen/schemas # Debug npm-debug.log* diff --git a/ROADMAP.md b/ROADMAP.md index bee1964..a68636c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -56,8 +56,10 @@ - [x] Typing indicators - [x] Pinned messages panel - [ ] Thread support -- [ ] Desktop app (Tauri) with native notifications for mentions, DMs, etc. -- [ ] Notification preferences +- [x] Desktop app (Tauri) — native window wrapper, notification plugin wired up +- [x] Desktop/browser notifications — notification:bootstrap on connect, unread state context, auto-mark-as-read, browser Notification API + Tauri native notifications +- [x] Notification preferences — user_notification_settings table, API (get/update), settings UI (desktop/DM notification levels, permission request) +- [x] Unread indicators (Discord-style) — channel/DM text highlights, mention badges, left-side unread pill - [x] Reaction tooltips (who reacted with each emoji) - [x] User profile popover (bio, status, online indicator, ally actions) - [x] Remember last visited channel per guild (localStorage) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c160c0d..c61a5bf 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,6 +10,7 @@ import channelsRouter from "@/routes/v1/channels/index" import dmsRouter from "@/routes/v1/dms/index" import guildsRouter from "@/routes/v1/guilds/index" import invitesRouter from "@/routes/v1/invites/index" +import notificationSettingsRouter from "@/routes/v1/notification-settings/index" import privacySettingsRouter from "@/routes/v1/privacy-settings/index" import uploadsRouter from "@/routes/v1/uploads/index" import usersRouter from "@/routes/v1/users/index" @@ -44,6 +45,7 @@ const routes = app .route("/v1", channelsRouter) .route("/v1", guildsRouter) .route("/v1", invitesRouter) + .route("/v1", notificationSettingsRouter) .route("/v1", privacySettingsRouter) .route("/v1", dmsRouter) .route("/v1", uploadsRouter) diff --git a/apps/api/src/routes/v1/notification-settings/handlers.ts b/apps/api/src/routes/v1/notification-settings/handlers.ts new file mode 100644 index 0000000..8654f5b --- /dev/null +++ b/apps/api/src/routes/v1/notification-settings/handlers.ts @@ -0,0 +1,61 @@ +import { db, eq, schema } from "@repo/db" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import type { AppRouteHandler } from "@/lib/types/app-types" +import type { + GetNotificationSettingsRoute, + UpdateNotificationSettingsRoute, +} from "./routes" + +const DEFAULT_SETTINGS = { + desktopNotifications: "all_messages" as const, + dmNotifications: "all_messages" as const, +} + +export const getNotificationSettings: AppRouteHandler< + GetNotificationSettingsRoute +> = async (c) => { + const currentUser = c.var.user + + const settings = await db + .select({ + desktopNotifications: + schema.userNotificationSettings.desktopNotifications, + dmNotifications: schema.userNotificationSettings.dmNotifications, + }) + .from(schema.userNotificationSettings) + .where(eq(schema.userNotificationSettings.userId, currentUser.id)) + .limit(1) + .then((rows) => rows[0]) + + return c.json(settings ?? DEFAULT_SETTINGS, HttpStatusCodes.OK) +} + +export const updateNotificationSettings: AppRouteHandler< + UpdateNotificationSettingsRoute +> = async (c) => { + const currentUser = c.var.user + const body = c.req.valid("json") + + const updated = await db + .insert(schema.userNotificationSettings) + .values({ + userId: currentUser.id, + ...body, + }) + .onConflictDoUpdate({ + target: schema.userNotificationSettings.userId, + set: body, + }) + .returning({ + desktopNotifications: + schema.userNotificationSettings.desktopNotifications, + dmNotifications: schema.userNotificationSettings.dmNotifications, + }) + .then((rows) => rows[0]) + + if (!updated) { + return c.json(DEFAULT_SETTINGS, HttpStatusCodes.OK) + } + + return c.json(updated, HttpStatusCodes.OK) +} diff --git a/apps/api/src/routes/v1/notification-settings/index.ts b/apps/api/src/routes/v1/notification-settings/index.ts new file mode 100644 index 0000000..2c11491 --- /dev/null +++ b/apps/api/src/routes/v1/notification-settings/index.ts @@ -0,0 +1,12 @@ +import { createRouter } from "@/lib/helpers/app/create-app" +import * as handlers from "@/routes/v1/notification-settings/handlers" +import * as routes from "@/routes/v1/notification-settings/routes" + +const notificationSettingsRouter = createRouter() + .openapi(routes.getNotificationSettings, handlers.getNotificationSettings) + .openapi( + routes.updateNotificationSettings, + handlers.updateNotificationSettings + ) + +export default notificationSettingsRouter diff --git a/apps/api/src/routes/v1/notification-settings/routes.ts b/apps/api/src/routes/v1/notification-settings/routes.ts new file mode 100644 index 0000000..5ddb501 --- /dev/null +++ b/apps/api/src/routes/v1/notification-settings/routes.ts @@ -0,0 +1,57 @@ +import { createRoute } from "@hono/zod-openapi" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import jsonContent from "@/lib/helpers/openapi/json-content" +import { + internalServerErrorSchema, + unauthorizedSchema, +} from "@/lib/helpers/openapi/schemas" +import { sessionAuthMiddleware } from "@/middleware/session-auth" +import { + getNotificationSettingsResponseSchema, + updateNotificationSettingsBodySchema, + updateNotificationSettingsResponseSchema, +} from "./schema" + +export const getNotificationSettings = createRoute({ + path: "/notification-settings", + method: "get", + summary: "Get notification settings", + description: "Returns the current user's notification settings.", + tags: ["Notification Settings"], + middleware: [sessionAuthMiddleware] as const, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: getNotificationSettingsResponseSchema, + description: "Notification settings", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type GetNotificationSettingsRoute = typeof getNotificationSettings + +export const updateNotificationSettings = createRoute({ + path: "/notification-settings", + method: "patch", + summary: "Update notification settings", + description: "Updates the current user's notification settings.", + tags: ["Notification Settings"], + middleware: [sessionAuthMiddleware] as const, + request: { + body: jsonContent({ + schema: updateNotificationSettingsBodySchema, + description: "Notification settings to update", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: updateNotificationSettingsResponseSchema, + description: "Updated notification settings", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type UpdateNotificationSettingsRoute = typeof updateNotificationSettings diff --git a/apps/api/src/routes/v1/notification-settings/schema.ts b/apps/api/src/routes/v1/notification-settings/schema.ts new file mode 100644 index 0000000..53cd266 --- /dev/null +++ b/apps/api/src/routes/v1/notification-settings/schema.ts @@ -0,0 +1,11 @@ +import { + notificationSettingsResponseSchema, + updateNotificationSettingsSchema, +} from "@repo/db/schema" + +export const getNotificationSettingsResponseSchema = + notificationSettingsResponseSchema +export const updateNotificationSettingsBodySchema = + updateNotificationSettingsSchema +export const updateNotificationSettingsResponseSchema = + notificationSettingsResponseSchema diff --git a/apps/desktop/package.json b/apps/desktop/package.json index fd7c5e7..e22fd5f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -4,7 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "dev": "pnpm tauri dev", + "build": "pnpm tauri build" }, "keywords": [], "author": "", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index ee56b49..0a655b8 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -85,6 +85,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-log", + "tauri-plugin-notification", ] [[package]] @@ -93,6 +94,137 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atk" version = "0.18.2" @@ -200,6 +332,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borsh" version = "1.6.1" @@ -444,6 +589,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -819,6 +973,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -846,6 +1027,37 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -998,6 +1210,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1386,6 +1611,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1876,6 +2107,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -1906,6 +2143,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2053,6 +2302,20 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify-rust" +version = "4.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2169,6 +2432,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2234,6 +2498,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -2259,6 +2533,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2487,6 +2767,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2501,7 +2792,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -2519,6 +2810,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2647,6 +2952,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -2708,6 +3022,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2728,6 +3052,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -2746,6 +3080,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -2942,6 +3285,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3251,6 +3607,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -3686,6 +4052,25 @@ dependencies = [ "time", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.2", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "tauri-runtime" version = "2.10.1" @@ -3786,6 +4171,31 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.4.3" @@ -4089,9 +4499,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4141,6 +4563,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -4989,6 +5422,9 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] [[package]] name = "winnow" @@ -5200,6 +5636,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.47" @@ -5279,3 +5776,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 0.7.15", +] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 074cc3d..283564a 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -23,3 +23,4 @@ serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.10.3", features = [] } tauri-plugin-log = "2" +tauri-plugin-notification = "2" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 8e906f7..c85f85d 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -3,5 +3,5 @@ "identifier": "default", "description": "enables the default permissions", "windows": ["main"], - "permissions": ["core:default"] + "permissions": ["core:default", "notification:default"] } diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-tauri/icons/128x128.png index 77e7d23..dfd7df2 100644 Binary files a/apps/desktop/src-tauri/icons/128x128.png and b/apps/desktop/src-tauri/icons/128x128.png differ diff --git a/apps/desktop/src-tauri/icons/128x128@2x.png b/apps/desktop/src-tauri/icons/128x128@2x.png index 0f7976f..33fe576 100644 Binary files a/apps/desktop/src-tauri/icons/128x128@2x.png and b/apps/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-tauri/icons/32x32.png index 98fda06..a74c9a6 100644 Binary files a/apps/desktop/src-tauri/icons/32x32.png and b/apps/desktop/src-tauri/icons/32x32.png differ diff --git a/apps/desktop/src-tauri/icons/64x64.png b/apps/desktop/src-tauri/icons/64x64.png new file mode 100644 index 0000000..05f488a Binary files /dev/null and b/apps/desktop/src-tauri/icons/64x64.png differ diff --git a/apps/desktop/src-tauri/icons/Square107x107Logo.png b/apps/desktop/src-tauri/icons/Square107x107Logo.png index f35d84f..58f8eb6 100644 Binary files a/apps/desktop/src-tauri/icons/Square107x107Logo.png and b/apps/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square142x142Logo.png b/apps/desktop/src-tauri/icons/Square142x142Logo.png index 1823bb2..693c6ec 100644 Binary files a/apps/desktop/src-tauri/icons/Square142x142Logo.png and b/apps/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square150x150Logo.png b/apps/desktop/src-tauri/icons/Square150x150Logo.png index dc2b22c..f13469c 100644 Binary files a/apps/desktop/src-tauri/icons/Square150x150Logo.png and b/apps/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square284x284Logo.png b/apps/desktop/src-tauri/icons/Square284x284Logo.png index 0ed3984..1bed5d7 100644 Binary files a/apps/desktop/src-tauri/icons/Square284x284Logo.png and b/apps/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square30x30Logo.png b/apps/desktop/src-tauri/icons/Square30x30Logo.png index 60bf0ea..bc05cdd 100644 Binary files a/apps/desktop/src-tauri/icons/Square30x30Logo.png and b/apps/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square310x310Logo.png b/apps/desktop/src-tauri/icons/Square310x310Logo.png index c8ca0ad..7b04d00 100644 Binary files a/apps/desktop/src-tauri/icons/Square310x310Logo.png and b/apps/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square44x44Logo.png b/apps/desktop/src-tauri/icons/Square44x44Logo.png index 8756459..80cf66e 100644 Binary files a/apps/desktop/src-tauri/icons/Square44x44Logo.png and b/apps/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square71x71Logo.png b/apps/desktop/src-tauri/icons/Square71x71Logo.png index 2c8023c..afd39c1 100644 Binary files a/apps/desktop/src-tauri/icons/Square71x71Logo.png and b/apps/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square89x89Logo.png b/apps/desktop/src-tauri/icons/Square89x89Logo.png index 2c5e603..2e6be57 100644 Binary files a/apps/desktop/src-tauri/icons/Square89x89Logo.png and b/apps/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/apps/desktop/src-tauri/icons/StoreLogo.png b/apps/desktop/src-tauri/icons/StoreLogo.png index 17d142c..ca3f696 100644 Binary files a/apps/desktop/src-tauri/icons/StoreLogo.png and b/apps/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/apps/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/apps/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..7acb941 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..4b63005 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..c80287c Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..0d9d3da Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ca0ec7d Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..5391240 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..5457609 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8e23832 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..da44061 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..ae5c2fa Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..92b6ff7 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..2cb8873 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..91ecf22 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ef50fd7 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d04020b Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/values/ic_launcher_background.xml b/apps/desktop/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..ea9c223 --- /dev/null +++ b/apps/desktop/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns index a2993ad..34693ed 100644 Binary files a/apps/desktop/src-tauri/icons/icon.icns and b/apps/desktop/src-tauri/icons/icon.icns differ diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico index 06c23c8..3fd541e 100644 Binary files a/apps/desktop/src-tauri/icons/icon.ico and b/apps/desktop/src-tauri/icons/icon.ico differ diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png index d1756ce..308654b 100644 Binary files a/apps/desktop/src-tauri/icons/icon.png and b/apps/desktop/src-tauri/icons/icon.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..dd2ce5e Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..5aba648 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..5aba648 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..898d374 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..8c7eb4a Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..bd2feac Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..bd2feac Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..9931fb1 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..5aba648 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..80756dc Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..80756dc Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..da4f30a Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..2c4f4b1 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..da4f30a Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..8b06a43 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..68d8d00 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..7390e22 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..ca0f991 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/apps/desktop/src-tauri/src/icon.png b/apps/desktop/src-tauri/src/icon.png new file mode 100644 index 0000000..38d74f2 Binary files /dev/null and b/apps/desktop/src-tauri/src/icon.png differ diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9c3118c..9c17c7f 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_notification::init()) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 7aae910..bbdffe1 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -2,9 +2,9 @@ "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "townhall", "version": "0.1.0", - "identifier": "com.tauri.dev", + "identifier": "com.townhall.desktop", "build": { - "frontendDist": "../../apps/web/dist", + "frontendDist": "../../../apps/web/dist", "devUrl": "http://localhost:3000", "beforeDevCommand": "pnpm --filter web dev", "beforeBuildCommand": "pnpm --filter web build" diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index 220e9d8..0619ca8 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -48,7 +48,7 @@ import { enforceDmMessageRateLimit, enforceGuildMessageRateLimit, } from "@/services/rate-limit" -import { markChannelRead } from "@/services/read-states" +import { getUnreadStatesForUser, markChannelRead } from "@/services/read-states" type SocketData = { user: Session["user"] @@ -89,7 +89,11 @@ function toHeaders( const realtimePort = env.REALTIME_PORT -const defaultOrigins = ["http://localhost:3000", "http://localhost:3001"] +const defaultOrigins = [ + "http://localhost:3000", + "http://localhost:3001", + "tauri://localhost", +] const corsOrigins = (env.REALTIME_CORS_ORIGIN || defaultOrigins.join(",")) .split(",") .map((origin) => origin.trim()) @@ -193,7 +197,6 @@ async function initializeConnection(socket: RealtimeSocket) { try { const initSocketId = socket.id const userPresenceRoom = userRoom(socket.data.user.id) - await socket.join(userPresenceRoom) const guildMembershipRows = await db .select({ @@ -279,6 +282,21 @@ async function initializeConnection(socket: RealtimeSocket) { }, }) + // Bootstrap unread state BEFORE joining userRoom so live notifications + // arriving after join don't get wiped by a later bootstrap emit + try { + const bootstrap = await getUnreadStatesForUser(socket.data.user.id) + socket.emit("notification:bootstrap", bootstrap) + } catch (err) { + console.error("Failed to bootstrap unread states:", { + socketId: socket.id, + userId: socket.data.user.id, + error: err, + }) + } + + await socket.join(userPresenceRoom) + return true } catch (error) { console.error( @@ -610,7 +628,10 @@ io.on("connection", (socket) => { lastReadMessageId: parsed.lastReadMessageId, }) + // Broadcast to other tabs/devices for this user socket.to(userRoom(socket.data.user.id)).emit("channel:read-state", state) + // Also send back to the requesting socket + socket.emit("channel:read-state", state) ack?.({ ok: true, state }) } catch (error) { ack?.({ ok: false, error: toErrorMessage(error) }) diff --git a/apps/realtime/src/services/channel-access.ts b/apps/realtime/src/services/channel-access.ts index db3f440..122e862 100644 --- a/apps/realtime/src/services/channel-access.ts +++ b/apps/realtime/src/services/channel-access.ts @@ -2,6 +2,7 @@ import { and, db, eq, schema } from "@repo/db" export type AccessibleChannel = { id: string + name: string | null type: (typeof schema.channel.$inferSelect)["type"] guildId: string | null memberRole: string | null @@ -17,6 +18,7 @@ export async function assertUserCanAccessChannel( const channelRecord = await db .select({ id: schema.channel.id, + name: schema.channel.name, type: schema.channel.type, guildId: schema.channel.guildId, }) diff --git a/apps/realtime/src/services/notifications.ts b/apps/realtime/src/services/notifications.ts index 6823b24..2966b9a 100644 --- a/apps/realtime/src/services/notifications.ts +++ b/apps/realtime/src/services/notifications.ts @@ -122,6 +122,14 @@ export async function buildMessageFanout(input: MessageFanoutInput) { (id) => id !== input.authorId ) + const contentPreview = input.message.content + ? input.message.content.length > 100 + ? `${input.message.content.slice(0, 100)}…` + : input.message.content + : input.message.attachments.length > 0 + ? `sent ${input.message.attachments.length} attachment${input.message.attachments.length > 1 ? "s" : ""}` + : null + const unreadNotifications: Array> = recipientIds.map((userId) => ({ userId, @@ -130,6 +138,9 @@ export async function buildMessageFanout(input: MessageFanoutInput) { guildId: input.channel.guildId, messageId: input.message.id, unreadCountDelta: 1, + authorName: input.message.author.name, + contentPreview, + channelName: input.channel.name, }, })) diff --git a/apps/realtime/src/services/read-states.ts b/apps/realtime/src/services/read-states.ts index 367b729..28392b2 100644 --- a/apps/realtime/src/services/read-states.ts +++ b/apps/realtime/src/services/read-states.ts @@ -1,5 +1,19 @@ -import { and, count, db, desc, eq, gt, ne, schema, sql } from "@repo/db" -import type { ChannelReadState } from "@repo/realtime-types" +import { + and, + count, + db, + desc, + eq, + gt, + inArray, + ne, + schema, + sql, +} from "@repo/db" +import type { + ChannelReadState, + NotificationBootstrap, +} from "@repo/realtime-types" import { assertUserCanAccessChannel } from "./channel-access" type MarkChannelReadInput = { @@ -48,7 +62,9 @@ export async function markChannelRead( if (latestMessage) { lastReadMessageId = latestMessage.id - lastReadAt = latestMessage.createdAt + // Use current time to avoid precision mismatches between + // JS Date (ms) and Postgres timestamp (μs) + lastReadAt = new Date() } } @@ -132,3 +148,108 @@ export async function markChannelRead( mentionCount: Number(mentionCountRow?.count ?? 0), } } + +/** + * Get unread message and mention counts for all channels a user is a member of. + * Used to bootstrap the frontend unread state on socket connect. + */ +export async function getUnreadStatesForUser( + userId: string +): Promise { + // Get DM/group DM channel IDs via channel_member + const dmMemberships = await db + .select({ channelId: schema.channelMember.channelId }) + .from(schema.channelMember) + .where(eq(schema.channelMember.userId, userId)) + + // Get guild channel IDs via guild_member -> channels + const guildMemberships = await db + .select({ guildId: schema.guildMember.guildId }) + .from(schema.guildMember) + .where(eq(schema.guildMember.userId, userId)) + + let guildChannelIds: string[] = [] + if (guildMemberships.length > 0) { + const guildIds = guildMemberships.map((m) => m.guildId) + const guildChannels = await db + .select({ id: schema.channel.id }) + .from(schema.channel) + .where(inArray(schema.channel.guildId, guildIds)) + guildChannelIds = guildChannels.map((c) => c.id) + } + + const channelIds = [ + ...new Set([...dmMemberships.map((m) => m.channelId), ...guildChannelIds]), + ] + + if (channelIds.length === 0) { + return { readStates: [] } + } + + // Get existing read states for these channels + const readStates = await db + .select({ + channelId: schema.channelReadState.channelId, + lastReadAt: schema.channelReadState.lastReadAt, + lastReadMessageId: schema.channelReadState.lastReadMessageId, + }) + .from(schema.channelReadState) + .where( + and( + eq(schema.channelReadState.userId, userId), + inArray(schema.channelReadState.channelId, channelIds) + ) + ) + + const readStateMap = new Map(readStates.map((rs) => [rs.channelId, rs])) + + // For each channel, compute unread and mention counts + const results = await Promise.all( + channelIds.map(async (channelId) => { + const readState = readStateMap.get(channelId) + const lastReadAt = readState?.lastReadAt ?? new Date(0) + + const [unreadRow, mentionRow] = await Promise.all([ + db + .select({ count: count() }) + .from(schema.message) + .where( + and( + eq(schema.message.channelId, channelId), + gt(schema.message.createdAt, lastReadAt), + ne(schema.message.authorId, userId) + ) + ) + .then((rows) => rows[0]), + db + .select({ count: count() }) + .from(schema.messageMention) + .where( + and( + eq(schema.messageMention.channelId, channelId), + eq(schema.messageMention.mentionedUserId, userId), + gt(schema.messageMention.createdAt, lastReadAt) + ) + ) + .then((rows) => rows[0]), + ]) + + const unreadCount = Number(unreadRow?.count ?? 0) + const mentionCount = Number(mentionRow?.count ?? 0) + + // Only include channels with unread activity + if (unreadCount === 0 && mentionCount === 0) return null + + return { + channelId, + unreadCount, + mentionCount, + lastReadMessageId: readState?.lastReadMessageId ?? null, + } + }) + ) + + return { + readStates: results.filter((r): r is NonNullable => r !== null), + } +} diff --git a/apps/web/package.json b/apps/web/package.json index 6d41372..b63adac 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "@tailwindcss/postcss": "^4.1.18", "@tanstack/react-query": "^5.90.21", "@tanstack/react-router": "^1.120.3", + "@tauri-apps/plugin-notification": "^2.3.3", "@tiptap/extension-link": "^3.20.0", "@tiptap/extension-mention": "^3.20.0", "@tiptap/markdown": "^3.20.0", diff --git a/apps/web/public/townhallicon.png b/apps/web/public/townhallicon.png new file mode 100644 index 0000000..326d7e2 Binary files /dev/null and b/apps/web/public/townhallicon.png differ diff --git a/apps/web/src/components/auth/auth-layout.tsx b/apps/web/src/components/auth/auth-layout.tsx new file mode 100644 index 0000000..949a791 --- /dev/null +++ b/apps/web/src/components/auth/auth-layout.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react" + +export function AuthLayout({ children }: { children: ReactNode }) { + return ( +
+
+ {/* Branding */} +
+ Townhall + + Townhall + +
+ + {children} +
+
+ ) +} diff --git a/apps/web/src/components/auth/password-input.tsx b/apps/web/src/components/auth/password-input.tsx new file mode 100644 index 0000000..53e6c72 --- /dev/null +++ b/apps/web/src/components/auth/password-input.tsx @@ -0,0 +1,34 @@ +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@repo/ui/components/input-group" +import { Eye, EyeOff } from "lucide-react" +import { type ComponentProps, useState } from "react" + +type PasswordInputProps = Omit, "type"> + +export function PasswordInput(props: PasswordInputProps) { + const [visible, setVisible] = useState(false) + + return ( + + + + setVisible((v) => !v)} + aria-label={visible ? "Hide password" : "Show password"} + > + {visible ? ( + + ) : ( + + )} + + + + ) +} diff --git a/apps/web/src/components/onboarding/onboarding-dialog.tsx b/apps/web/src/components/onboarding/onboarding-dialog.tsx index b699901..1b74188 100644 --- a/apps/web/src/components/onboarding/onboarding-dialog.tsx +++ b/apps/web/src/components/onboarding/onboarding-dialog.tsx @@ -11,14 +11,19 @@ import { } from "@repo/ui/components/dialog" import { Input } from "@repo/ui/components/input" import { Label } from "@repo/ui/components/label" +import { cn } from "@repo/ui/lib/utils" import { sluggify } from "@repo/utils/slug" import { useQueryClient } from "@tanstack/react-query" import { useNavigate } from "@tanstack/react-router" -import { ArrowLeft, Loader2, Plus, Users } from "lucide-react" -import { useEffect, useState } from "react" +import { ArrowLeft, Check, Loader2, Plus, Users, X } from "lucide-react" +import { useCallback, useEffect, useRef, useState } from "react" import { apiClient } from "@/lib/api-client" -type Step = "welcome" | "create" | "join" +type Step = "username" | "welcome" | "create" | "join" + +const MIN_USERNAME_LENGTH = 3 +const MAX_USERNAME_LENGTH = 30 +const USERNAME_REGEX = /^[a-zA-Z0-9_.]+$/ function normalizeSlugInput(value: string) { return value @@ -43,7 +48,18 @@ function parseInviteCode(value: string) { } export function OnboardingDialog({ open }: { open: boolean }) { - const [step, setStep] = useState("welcome") + const { data: session } = authClient.useSession() + const hasUsername = !!( + session?.user?.username && + session.user.username.length >= MIN_USERNAME_LENGTH + ) + const [step, setStep] = useState(hasUsername ? "welcome" : "username") + // Sync step with session hydration — session may be null on first render + useEffect(() => { + if (hasUsername && step === "username") { + setStep("welcome") + } + }, [hasUsername, step]) const [name, setName] = useState("") const [slug, setSlug] = useState("") const [slugEdited, setSlugEdited] = useState(false) @@ -53,6 +69,75 @@ export function OnboardingDialog({ open }: { open: boolean }) { const queryClient = useQueryClient() const navigate = useNavigate() + // Username step state + const [username, setUsername] = useState("") + const [usernameAvailability, setUsernameAvailability] = useState< + "idle" | "checking" | "available" | "taken" | "invalid" + >("idle") + const usernameCheckTimer = useRef | null>(null) + + useEffect(() => { + return () => { + if (usernameCheckTimer.current) clearTimeout(usernameCheckTimer.current) + } + }, []) + + const handleUsernameChange = useCallback((value: string) => { + setUsername(value) + if (usernameCheckTimer.current) clearTimeout(usernameCheckTimer.current) + + const trimmed = value.trim() + if (!trimmed) { + setUsernameAvailability("idle") + return + } + if ( + trimmed.length < MIN_USERNAME_LENGTH || + trimmed.length > MAX_USERNAME_LENGTH || + !USERNAME_REGEX.test(trimmed) + ) { + setUsernameAvailability("invalid") + return + } + + setUsernameAvailability("checking") + usernameCheckTimer.current = setTimeout(async () => { + try { + const { data } = await authClient.isUsernameAvailable({ + username: trimmed, + }) + setUsernameAvailability((prev) => + prev === "checking" ? (data?.available ? "available" : "taken") : prev + ) + } catch { + setUsernameAvailability((prev) => (prev === "checking" ? "idle" : prev)) + } + }, 500) + }, []) + + const handleSetUsername = async (e: React.FormEvent) => { + e.preventDefault() + const trimmed = username.trim() + if (!trimmed || usernameAvailability !== "available") return + setError(null) + setLoading(true) + try { + const { error } = await authClient.updateUser({ + username: trimmed, + displayUsername: trimmed, + }) + if (error) { + setError(error.message ?? "Failed to set username") + return + } + setStep("welcome") + } catch { + setError("Something went wrong. Please try again.") + } finally { + setLoading(false) + } + } + useEffect(() => { if (!slugEdited) { setSlug(sluggify(name)) @@ -88,7 +173,11 @@ export function OnboardingDialog({ open }: { open: boolean }) { }) if (res.error) { - setError(res.error.message ?? "Failed to create guild") + const message = (res.error.message ?? "Failed to create guild").replace( + /organization/gi, + "Guild" + ) + setError(message) return } @@ -161,6 +250,86 @@ export function OnboardingDialog({ open }: { open: boolean }) { {/* Right content panel */}
+ {step === "username" && ( + <> + + + Choose a username + + + Pick a unique username for your Townhall identity. + + + +
+
+ +
+ handleUsernameChange(e.target.value)} + disabled={loading} + autoFocus + className={cn( + "pr-9", + usernameAvailability === "available" && + "border-green-500 focus-visible:ring-green-500/50", + (usernameAvailability === "taken" || + usernameAvailability === "invalid") && + "border-destructive focus-visible:ring-destructive/50" + )} + /> +
+ {usernameAvailability === "checking" && ( + + )} + {usernameAvailability === "available" && ( + + )} + {usernameAvailability === "taken" && ( + + )} + {usernameAvailability === "invalid" && ( + + )} +
+
+

+ 3–30 characters. Letters, numbers, underscores, and + periods only. +

+ {usernameAvailability === "taken" && ( +

+ That username is already taken. +

+ )} + {usernameAvailability === "invalid" && + username.trim().length > 0 && ( +

+ Username must be 3–30 characters using only letters, + numbers, underscores, and periods. +

+ )} +
+ + {error &&

{error}

} + + +
+ + )} + {step === "welcome" && ( <> diff --git a/apps/web/src/components/settings/notification-settings.tsx b/apps/web/src/components/settings/notification-settings.tsx new file mode 100644 index 0000000..14250d2 --- /dev/null +++ b/apps/web/src/components/settings/notification-settings.tsx @@ -0,0 +1,171 @@ +import { Button } from "@repo/ui/components/button" +import { Label } from "@repo/ui/components/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@repo/ui/components/select" +import { Separator } from "@repo/ui/components/separator" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { Bell, Loader2 } from "lucide-react" +import { useEffect, useState } from "react" +import { toast } from "sonner" +import { apiClient } from "@/lib/api-client" +import { + getNotificationPermission, + requestNotificationPermission, +} from "@/lib/notification-dispatcher" + +type NotificationSettings = { + desktopNotifications: "all_messages" | "mentions_only" | "nothing" + dmNotifications: "all_messages" | "nothing" +} + +const DESKTOP_NOTIFICATION_OPTIONS = [ + { value: "all_messages", label: "All Messages" }, + { value: "mentions_only", label: "Mentions Only" }, + { value: "nothing", label: "Nothing" }, +] as const + +const DM_NOTIFICATION_OPTIONS = [ + { value: "all_messages", label: "All Messages" }, + { value: "nothing", label: "Nothing" }, +] as const + +export function NotificationSettings() { + const queryClient = useQueryClient() + const [permissionState, setPermissionState] = useState< + "granted" | "denied" | "default" + >("default") + + useEffect(() => { + getNotificationPermission().then(setPermissionState) + }, []) + + const { data: settings, isPending } = useQuery({ + queryKey: ["notification-settings"], + queryFn: async () => { + const res = await apiClient.v1["notification-settings"].$get() + if (!res.ok) throw new Error("Failed to fetch notification settings") + return res.json() as Promise + }, + }) + + const { mutate: updateSettings } = useMutation({ + mutationFn: async (update: Partial) => { + const res = await apiClient.v1["notification-settings"].$patch({ + json: update, + }) + if (!res.ok) throw new Error("Failed to update notification settings") + return res.json() as Promise + }, + onSuccess: (data) => { + queryClient.setQueryData(["notification-settings"], data) + }, + onError: () => { + toast.error("Failed to update notification setting") + }, + }) + + const handleChange = (key: keyof NotificationSettings, value: string) => { + updateSettings({ [key]: value }) + } + + const handleRequestPermission = async () => { + const granted = await requestNotificationPermission() + setPermissionState(granted ? "granted" : "denied") + if (granted) { + toast.success("Notifications enabled") + } else { + toast.error("Notification permission denied") + } + } + + if (isPending) { + return ( +
+ +
+ ) + } + + return ( +
+
+

Notifications

+

+ Control how and when you receive notifications. +

+
+ + + + {permissionState !== "granted" && ( +
+ +
+

Enable Desktop Notifications

+

+ {permissionState === "denied" + ? "Notifications are blocked. Please enable them in your browser settings." + : "Allow Townhall to send you desktop notifications."} +

+
+ {permissionState === "default" && ( + + )} +
+ )} + +
+
+ +

+ Choose what triggers a desktop notification. +

+ +
+ +
+ +

+ Choose whether you get notified for new direct messages. +

+ +
+
+
+ ) +} diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index f465eb0..8c1ff0a 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -38,6 +38,7 @@ import { import { useMemo, useState } from "react" import { useSettings } from "@/context/settings-context" import { MyAccountSettings } from "./my-account-settings" +import { NotificationSettings } from "./notification-settings" import { PrivacySafetySettings } from "./privacy-safety-settings" interface SettingsNav { @@ -126,6 +127,8 @@ export function SettingsDialog() {
{activeItem === "My Account" ? ( + ) : activeItem === "Notifications" ? ( + ) : activeItem === "Privacy & Safety" ? ( ) : ( diff --git a/apps/web/src/components/sidebar/channel-panel/channel-list.tsx b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx index 9a33bdf..d47a543 100644 --- a/apps/web/src/components/sidebar/channel-panel/channel-list.tsx +++ b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx @@ -38,6 +38,7 @@ import { } from "lucide-react" import { AnimatePresence, motion } from "motion/react" import { useCallback, useState } from "react" +import { useUnread } from "@/context/unread-context" import { apiClient } from "@/lib/api-client" import type { Channel, ListChannelsResponse } from "@/lib/api-types" import { canDeleteChannels, canManageChannels } from "@/lib/permissions" @@ -559,6 +560,11 @@ function SortableChannelItem({ const [editOpen, setEditOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) + const { getUnreadCount, getMentionCount } = useUnread() + const unreadCount = active ? 0 : getUnreadCount(ch.id) + const mentionCount = active ? 0 : getMentionCount(ch.id) + const hasUnread = unreadCount > 0 + const style = { transform: CSS.Translate.toString(transform), transition, @@ -577,63 +583,74 @@ function SortableChannelItem({ className={cn( "group relative flex w-full items-center gap-2 rounded-lg px-2 py-[6px] text-[14px] hover:bg-foreground/[0.06] cursor-pointer", active && "bg-foreground/[0.06] font-medium text-foreground", - !active && "text-muted-foreground", + !active && hasUnread && "font-medium text-foreground", + !active && !hasUnread && "text-muted-foreground", menuOpen && "bg-foreground/[0.06]" )} > {active && (
)} + {!active && hasUnread && ( +
+ )} {ch.name} - {canManage && ( - - e.stopPropagation()} - className={cn( - "ml-auto flex size-5 items-center justify-center rounded opacity-0 hover:bg-foreground/10 group-hover:opacity-100", - menuOpen && "opacity-100" - )} - > - - - - { - e.stopPropagation() - setMenuOpen(false) - setEditOpen(true) - }} +
+ {mentionCount > 0 && ( + + {mentionCount} + + )} + {canManage && ( + + e.stopPropagation()} + className={cn( + "flex size-5 items-center justify-center rounded opacity-0 hover:bg-foreground/10 group-hover:opacity-100", + menuOpen && "opacity-100" + )} > - Edit Channel - - { - e.stopPropagation() - navigator.clipboard.writeText(ch.id) - setMenuOpen(false) - }} - > - Copy Channel ID - - {canDelete && ( - <> - - { - e.stopPropagation() - setMenuOpen(false) - setDeleteOpen(true) - }} - className="text-destructive focus:text-destructive" - > - Delete Channel - - - )} - - - )} + + + + { + e.stopPropagation() + setMenuOpen(false) + setEditOpen(true) + }} + > + Edit Channel + + { + e.stopPropagation() + navigator.clipboard.writeText(ch.id) + setMenuOpen(false) + }} + > + Copy Channel ID + + {canDelete && ( + <> + + { + e.stopPropagation() + setMenuOpen(false) + setDeleteOpen(true) + }} + className="text-destructive focus:text-destructive" + > + Delete Channel + + + )} + + + )} +
{canManage && ( void }) { + const { getUnreadCount, getMentionCount } = useUnread() + const unreadCount = active ? 0 : getUnreadCount(channelId) + const mentionCount = active ? 0 : getMentionCount(channelId) + const hasUnread = unreadCount > 0 + const preview = isGroupDM && lastMessageAuthor && lastMessage ? `${lastMessageAuthor}: ${lastMessage}` @@ -118,18 +127,26 @@ function DMItem({ onClick={onClick} className={cn( "group relative flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-foreground/[0.06]", - active - ? "bg-foreground/[0.06] text-foreground" - : "text-muted-foreground" + active && "bg-foreground/[0.06] text-foreground", + !active && hasUnread && "text-foreground", + !active && !hasUnread && "text-muted-foreground" )} > + {!active && hasUnread && ( +
+ )} {isGroupDM ? ( ) : ( )}
-
+
{name}
{preview && ( @@ -138,6 +155,11 @@ function DMItem({
)}
+ {mentionCount > 0 && ( + + {mentionCount} + + )} ) } diff --git a/apps/web/src/context/unread-context.tsx b/apps/web/src/context/unread-context.tsx new file mode 100644 index 0000000..921e7af --- /dev/null +++ b/apps/web/src/context/unread-context.tsx @@ -0,0 +1,169 @@ +import type { ReactNode } from "react" +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react" +import { useSocket } from "./socket-context" + +type UnreadState = { + unreadCount: number + mentionCount: number +} + +type UnreadContextValue = { + stateMap: Map + markChannelRead: (channelId: string, lastReadMessageId?: string) => void +} + +const UnreadContext = createContext({ + stateMap: new Map(), + markChannelRead: () => {}, +}) + +export function useUnread() { + const { stateMap, markChannelRead } = useContext(UnreadContext) + + const getUnreadCount = useCallback( + (channelId: string) => stateMap.get(channelId)?.unreadCount ?? 0, + [stateMap] + ) + + const getMentionCount = useCallback( + (channelId: string) => stateMap.get(channelId)?.mentionCount ?? 0, + [stateMap] + ) + + return { getUnreadCount, getMentionCount, markChannelRead } +} + +export function UnreadProvider({ children }: { children: ReactNode }) { + const socket = useSocket() + const [stateMap, setStateMap] = useState>( + () => new Map() + ) + + useEffect(() => { + if (!socket) return + + const onBootstrap = (payload: { + readStates: Array<{ + channelId: string + unreadCount: number + mentionCount: number + lastReadMessageId: string | null + }> + }) => { + const newMap = new Map() + for (const rs of payload.readStates) { + newMap.set(rs.channelId, { + unreadCount: rs.unreadCount, + mentionCount: rs.mentionCount, + }) + } + setStateMap(newMap) + } + + const onUnread = (payload: { + channelId: string + unreadCountDelta: number + }) => { + setStateMap((prev) => { + const next = new Map(prev) + const current = next.get(payload.channelId) ?? { + unreadCount: 0, + mentionCount: 0, + } + next.set(payload.channelId, { + ...current, + unreadCount: current.unreadCount + payload.unreadCountDelta, + }) + return next + }) + } + + const onMention = (payload: { channelId: string }) => { + setStateMap((prev) => { + const next = new Map(prev) + const current = next.get(payload.channelId) ?? { + unreadCount: 0, + mentionCount: 0, + } + next.set(payload.channelId, { + ...current, + mentionCount: current.mentionCount + 1, + }) + return next + }) + } + + const onReadState = (payload: { + channelId: string + unreadCount: number + mentionCount: number + }) => { + setStateMap((prev) => { + const next = new Map(prev) + if (payload.unreadCount === 0 && payload.mentionCount === 0) { + next.delete(payload.channelId) + } else { + next.set(payload.channelId, { + unreadCount: payload.unreadCount, + mentionCount: payload.mentionCount, + }) + } + return next + }) + } + + socket.on("notification:bootstrap", onBootstrap) + socket.on("notification:unread", onUnread) + socket.on("notification:mention", onMention) + socket.on("channel:read-state", onReadState) + + return () => { + socket.off("notification:bootstrap", onBootstrap) + socket.off("notification:unread", onUnread) + socket.off("notification:mention", onMention) + socket.off("channel:read-state", onReadState) + } + }, [socket]) + + const markChannelRead = useCallback( + (channelId: string, lastReadMessageId?: string) => { + if (!socket) return + + let snapshot: UnreadState | undefined + setStateMap((prev) => { + const next = new Map(prev) + snapshot = prev.get(channelId) + next.delete(channelId) + return next + }) + + socket.emit( + "channel:mark-read", + { channelId, lastReadMessageId }, + (res: { ok: boolean }) => { + if (!res.ok && snapshot) { + const restore = snapshot + setStateMap((prev) => { + const next = new Map(prev) + next.set(channelId, restore) + return next + }) + } + } + ) + }, + [socket] + ) + + return ( + + {children} + + ) +} diff --git a/apps/web/src/hooks/use-auto-mark-read.ts b/apps/web/src/hooks/use-auto-mark-read.ts new file mode 100644 index 0000000..6b6c88f --- /dev/null +++ b/apps/web/src/hooks/use-auto-mark-read.ts @@ -0,0 +1,69 @@ +import type { RealtimeMessage } from "@repo/realtime-types" +import { useEffect, useRef } from "react" +import { useSocket } from "@/context/socket-context" +import { useUnread } from "@/context/unread-context" + +const DEBOUNCE_MS = 1000 + +export function useAutoMarkRead(channelId: string | undefined) { + const { markChannelRead } = useUnread() + const socket = useSocket() + const timerRef = useRef | null>(null) + const channelIdRef = useRef(channelId) + channelIdRef.current = channelId + + const debouncedMarkRead = () => { + if (!channelIdRef.current) return + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => { + if (channelIdRef.current) { + markChannelRead(channelIdRef.current) + } + }, DEBOUNCE_MS) + } + + // Mark read on mount + useEffect(() => { + if (!channelId) return + if (document.visibilityState !== "visible") return + debouncedMarkRead() + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, [channelId]) + + // Mark read when tab becomes visible + useEffect(() => { + if (!channelId) return + + const onVisibilityChange = () => { + if (document.visibilityState === "visible") { + debouncedMarkRead() + } + } + + document.addEventListener("visibilitychange", onVisibilityChange) + return () => { + document.removeEventListener("visibilitychange", onVisibilityChange) + } + }, [channelId]) + + // Mark read when new messages arrive while focused + useEffect(() => { + if (!socket || !channelId) return + + const onMessageCreated = (message: RealtimeMessage) => { + if ( + message.channelId === channelIdRef.current && + document.visibilityState === "visible" + ) { + debouncedMarkRead() + } + } + + socket.on("message:created", onMessageCreated) + return () => { + socket.off("message:created", onMessageCreated) + } + }, [socket, channelId]) +} diff --git a/apps/web/src/hooks/use-browser-notifications.ts b/apps/web/src/hooks/use-browser-notifications.ts new file mode 100644 index 0000000..091a3fb --- /dev/null +++ b/apps/web/src/hooks/use-browser-notifications.ts @@ -0,0 +1,83 @@ +import type { + MentionNotification, + UnreadNotification, +} from "@repo/realtime-types" +import { useQuery } from "@tanstack/react-query" +import { useEffect } from "react" +import { useSocket } from "@/context/socket-context" +import { apiClient } from "@/lib/api-client" +import { showNotification } from "@/lib/notification-dispatcher" + +type NotificationSettings = { + desktopNotifications: "all_messages" | "mentions_only" | "nothing" + dmNotifications: "all_messages" | "nothing" +} + +/** + * Fires browser/desktop notifications for incoming messages and mentions + * based on user's notification preferences. + * Only fires when the tab is not focused. + */ +export function useBrowserNotifications() { + const socket = useSocket() + const { data: settings } = useQuery({ + queryKey: ["notification-settings"], + queryFn: async () => { + const res = await apiClient.v1["notification-settings"].$get() + if (!res.ok) throw new Error("Failed to fetch notification settings") + return res.json() as Promise + }, + }) + + useEffect(() => { + if (!socket) return + + const onMention = (payload: MentionNotification) => { + if (document.visibilityState === "visible") return + if (!settings) return + if (settings.desktopNotifications === "nothing") return + + // For DM mentions, check dmNotifications setting + if (payload.guildId === null && settings.dmNotifications === "nothing") { + return + } + + const mentionType = + payload.type === "everyone_mention" ? "@everyone" : "a mention" + + showNotification("New Mention", `You received ${mentionType}`, { + tag: `mention-${payload.messageId}`, + }) + } + + const onUnread = (payload: UnreadNotification) => { + if (document.visibilityState === "visible") return + if (!settings) return + if (settings.desktopNotifications !== "all_messages") return + + // For DMs, check dmNotifications setting + if (payload.guildId === null && settings.dmNotifications === "nothing") { + return + } + + const title = payload.authorName + const body = payload.contentPreview + ? payload.channelName + ? `#${payload.channelName}: ${payload.contentPreview}` + : payload.contentPreview + : "Sent an attachment" + + showNotification(title, body, { + tag: `unread-${payload.channelId}`, + }) + } + + socket.on("notification:mention", onMention) + socket.on("notification:unread", onUnread) + + return () => { + socket.off("notification:mention", onMention) + socket.off("notification:unread", onUnread) + } + }, [socket, settings]) +} diff --git a/apps/web/src/lib/notification-dispatcher.ts b/apps/web/src/lib/notification-dispatcher.ts new file mode 100644 index 0000000..ed04d7d --- /dev/null +++ b/apps/web/src/lib/notification-dispatcher.ts @@ -0,0 +1,95 @@ +const isTauri = () => + typeof window !== "undefined" && "__TAURI_INTERNALS__" in window + +export async function requestNotificationPermission(): Promise { + if (isTauri()) { + try { + const { requestPermission, isPermissionGranted } = await import( + "@tauri-apps/plugin-notification" + ) + if (await isPermissionGranted()) return true + const result = await requestPermission() + return result === "granted" + } catch { + return false + } + } + + if (!("Notification" in window)) return false + if (Notification.permission === "granted") return true + if (Notification.permission === "denied") return false + + const result = await Notification.requestPermission() + return result === "granted" +} + +export function getNotificationPermissionSync(): + | "granted" + | "denied" + | "default" { + if (isTauri()) return "default" + if (!("Notification" in window)) return "denied" + return Notification.permission +} + +export async function getNotificationPermission(): Promise< + "granted" | "denied" | "default" +> { + if (isTauri()) { + try { + const { isPermissionGranted } = await import( + "@tauri-apps/plugin-notification" + ) + return (await isPermissionGranted()) ? "granted" : "default" + } catch { + return "denied" + } + } + + if (!("Notification" in window)) return "denied" + return Notification.permission +} + +export async function showNotification( + title: string, + body: string, + options?: { + tag?: string + onClick?: () => void + } +) { + if (isTauri()) { + try { + const { sendNotification } = await import( + "@tauri-apps/plugin-notification" + ) + // Tauri notifications don't support click callbacks natively + sendNotification({ + title, + body, + ...(options?.tag && { tag: options.tag }), + }) + } catch { + // Tauri notification plugin not available + } + return + } + + if (!("Notification" in window) || Notification.permission !== "granted") { + return + } + + const notification = new Notification(title, { + body, + tag: options?.tag, + icon: "/favicon.ico", + }) + + if (options?.onClick) { + notification.onclick = () => { + window.focus() + options.onClick?.() + notification.close() + } + } +} diff --git a/apps/web/src/routes/_authenticated.tsx b/apps/web/src/routes/_authenticated.tsx index c2d92b6..2c1d43f 100644 --- a/apps/web/src/routes/_authenticated.tsx +++ b/apps/web/src/routes/_authenticated.tsx @@ -12,6 +12,13 @@ import { SettingsDialog } from "../components/settings/settings-dialog" import { Sidebar } from "../components/sidebar" import { SettingsProvider } from "../context/settings-context" import { SocketProvider } from "../context/socket-context" +import { UnreadProvider } from "../context/unread-context" +import { useBrowserNotifications } from "../hooks/use-browser-notifications" + +function BrowserNotifications() { + useBrowserNotifications() + return null +} const LAST_PATH_KEY = "townhall:last-path" @@ -68,15 +75,18 @@ function AuthenticatedLayout() { return ( - -
- - - - - -
-
+ + + +
+ + + + + +
+
+
) } diff --git a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx index 745859d..54389ff 100644 --- a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx +++ b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx @@ -16,6 +16,7 @@ import { MessageList } from "@/components/chat/message-list" import { TypingIndicator } from "@/components/chat/typing-indicator" import { useRightSidebar } from "@/components/sidebar/right-panel/right-sidebar-context" import { useSocket } from "@/context/socket-context" +import { useAutoMarkRead } from "@/hooks/use-auto-mark-read" import { useBlockedUserIds } from "@/hooks/use-blocked-users" import { useFileUpload } from "@/hooks/use-file-upload" import { useMessageDeletion } from "@/hooks/use-message-deletion" @@ -53,8 +54,10 @@ function ChannelView() { const { msgId } = Route.useSearch() const navigate = Route.useNavigate() const socket = useSocket() + useAutoMarkRead(channelId) const queryClient = useQueryClient() - const { view, setView, clearView } = useRightSidebar() + const { view, setView, clearView, isCollapsed, toggleCollapsed } = + useRightSidebar() const { data: session } = authClient.useSession() const currentUserId = session?.user.id const blockedUserIds = useBlockedUserIds() @@ -192,12 +195,15 @@ function ChannelView() { }) const togglePinnedMessages = useCallback(() => { - if (view?.type === "pinned-messages") { + if (view?.type === "pinned-messages" && !isCollapsed) { setView({ type: "guild-members", guildSlug, channelId }) } else { setView({ type: "pinned-messages", guildSlug, channelId }) + if (isCollapsed) { + toggleCollapsed() + } } - }, [view, setView, guildSlug, channelId]) + }, [view, setView, guildSlug, channelId, isCollapsed, toggleCollapsed]) const { replyingTo, setReplyingTo, clearReply } = useReplyState() diff --git a/apps/web/src/routes/_authenticated/dms/$dmId.tsx b/apps/web/src/routes/_authenticated/dms/$dmId.tsx index c0b463e..e6414cd 100644 --- a/apps/web/src/routes/_authenticated/dms/$dmId.tsx +++ b/apps/web/src/routes/_authenticated/dms/$dmId.tsx @@ -10,6 +10,7 @@ import { ChatHeader } from "@/components/chat/header" import { MessageList } from "@/components/chat/message-list" import { TypingIndicator } from "@/components/chat/typing-indicator" import { useSocket } from "@/context/socket-context" +import { useAutoMarkRead } from "@/hooks/use-auto-mark-read" import { useBlockedUserIds } from "@/hooks/use-blocked-users" import { useFileUpload } from "@/hooks/use-file-upload" import { useMessageDeletion } from "@/hooks/use-message-deletion" @@ -46,6 +47,7 @@ function DMConversation() { const { msgId } = Route.useSearch() const navigate = Route.useNavigate() const socket = useSocket() + useAutoMarkRead(dmId) const queryClient = useQueryClient() const { data: session } = authClient.useSession() const currentUserId = session?.user.id diff --git a/apps/web/src/routes/check-email.tsx b/apps/web/src/routes/check-email.tsx new file mode 100644 index 0000000..398555c --- /dev/null +++ b/apps/web/src/routes/check-email.tsx @@ -0,0 +1,96 @@ +import { authClient } from "@repo/auth/client" +import { Button } from "@repo/ui/components/button" +import { + Card, + CardContent, + CardFooter, + CardHeader, +} from "@repo/ui/components/card" +import { useMutation } from "@tanstack/react-query" +import { createFileRoute, Link } from "@tanstack/react-router" +import { Loader2, Mail, MailCheck } from "lucide-react" +import { AuthLayout } from "../components/auth/auth-layout" + +export const Route = createFileRoute("/check-email")({ + component: CheckEmailPage, + validateSearch: (search: Record) => ({ + email: (search.email as string) ?? "", + }), +}) + +function CheckEmailPage() { + const { email } = Route.useSearch() + + const { + mutate: resendEmail, + isPending, + isSuccess, + } = useMutation({ + mutationFn: async () => { + if (!email) return + const { error } = await authClient.sendVerificationEmail({ + email, + callbackURL: `${window.location.origin}/`, + }) + if (error) + throw new Error(error.message ?? "Failed to resend verification email") + }, + }) + + return ( + + + +
+ +
+
+

+ Check your email +

+

+ We sent a verification link to +

+ {email &&

{email}

} +
+
+ +

+ Click the link in the email to verify your account. If you don't see + it, check your spam folder. +

+
+ + {email && ( + + )} + + Back to sign in + + +
+
+ ) +} diff --git a/apps/web/src/routes/forgot-password.tsx b/apps/web/src/routes/forgot-password.tsx new file mode 100644 index 0000000..67d5f40 --- /dev/null +++ b/apps/web/src/routes/forgot-password.tsx @@ -0,0 +1,134 @@ +import { authClient } from "@repo/auth/client" +import { Button } from "@repo/ui/components/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@repo/ui/components/card" +import { Input } from "@repo/ui/components/input" +import { Label } from "@repo/ui/components/label" +import { useMutation } from "@tanstack/react-query" +import { createFileRoute, Link } from "@tanstack/react-router" +import { AlertCircle, ArrowLeft, Loader2, MailCheck } from "lucide-react" +import { type FormEvent, useState } from "react" +import { AuthLayout } from "../components/auth/auth-layout" + +export const Route = createFileRoute("/forgot-password")({ + component: ForgotPasswordPage, +}) + +function ForgotPasswordPage() { + const [email, setEmail] = useState("") + + const { + mutate: sendReset, + isPending, + isSuccess, + error, + } = useMutation({ + mutationFn: async () => { + const { error } = await authClient.requestPasswordReset({ + email, + redirectTo: "/reset-password", + }) + if (error) throw new Error(error.message ?? "Failed to send reset email") + }, + }) + + if (isSuccess) { + return ( + + + +
+ +
+
+

+ Check your email +

+

+ If an account exists for{" "} + {email}, we + sent a password reset link. +

+
+
+ + + + Back to sign in + + +
+
+ ) + } + + return ( + + + + + Reset password + + + Enter your email and we'll send you a reset link + + +
{ + e.preventDefault() + sendReset() + }} + > + + {error && ( +
+ + {error.message} +
+ )} +
+ + setEmail(e.target.value)} + autoComplete="email" + required + /> +
+
+ + + + + Back to sign in + + +
+
+
+ ) +} diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index 38bf79a..37b4d67 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -4,7 +4,6 @@ import { Card, CardContent, CardDescription, - CardFooter, CardHeader, CardTitle, } from "@repo/ui/components/card" @@ -12,7 +11,9 @@ import { Input } from "@repo/ui/components/input" import { Label } from "@repo/ui/components/label" import { useMutation } from "@tanstack/react-query" import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" +import { AlertCircle, Loader2 } from "lucide-react" import { type FormEvent, useEffect, useState } from "react" +import { PasswordInput } from "../components/auth/password-input" export const Route = createFileRoute("/login")({ component: LoginPage, @@ -36,66 +37,122 @@ function LoginPage() { error, } = useMutation({ mutationFn: async () => { - const { error } = await authClient.signIn.email({ email, password }) - if (error) throw new Error(error.message ?? "Failed to sign in") + const { error } = await authClient.signIn.email({ + email, + password, + callbackURL: `${window.location.origin}/`, + }) + if (error) { + // 403 = email not verified — better-auth re-sends the verification email automatically + if (error.status === 403) { + navigate({ to: "/check-email", search: { email } }) + return { needsVerification: true } + } + throw new Error(error.message ?? "Failed to sign in") + } + return { needsVerification: false } + }, + onSuccess: (result) => { + if (!result?.needsVerification) { + navigate({ to: "/" }) + } }, - onSuccess: () => navigate({ to: "/" }), }) return ( -
- - - Login - - Enter your credentials to access your account - - -
{ - e.preventDefault() - signIn() - }} +
+
+ - - {error && ( -

{error.message}

- )} -
- - setEmail(e.target.value)} - required - /> -
-
- - setPassword(e.target.value)} - required - /> -
-
- - -

- Don't have an account?{" "} - - Sign up - -

-
- - + Townhall + Townhall + +
+ + + Welcome back + + Sign in to your Townhall account + + + +
{ + e.preventDefault() + signIn() + }} + > +
+ {error && ( +
+ + {error.message} +
+ )} +
+ + setEmail(e.target.value)} + autoComplete="email" + required + /> +
+
+
+ + + Forgot password? + +
+ setPassword(e.target.value)} + autoComplete="current-password" + required + /> +
+
+ +

+ Don't have an account?{" "} + + Sign up + +

+
+
+
+
+
+
+
) } diff --git a/apps/web/src/routes/reset-password.tsx b/apps/web/src/routes/reset-password.tsx new file mode 100644 index 0000000..99cba57 --- /dev/null +++ b/apps/web/src/routes/reset-password.tsx @@ -0,0 +1,170 @@ +import { authClient } from "@repo/auth/client" +import { Button } from "@repo/ui/components/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@repo/ui/components/card" +import { Label } from "@repo/ui/components/label" +import { useMutation } from "@tanstack/react-query" +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" +import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react" +import { type FormEvent, useState } from "react" +import { AuthLayout } from "../components/auth/auth-layout" +import { PasswordInput } from "../components/auth/password-input" + +export const Route = createFileRoute("/reset-password")({ + component: ResetPasswordPage, + validateSearch: (search: Record) => ({ + token: (search.token as string) ?? "", + }), +}) + +function ResetPasswordPage() { + const navigate = useNavigate() + const { token } = Route.useSearch() + const [password, setPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + + const { + mutate: resetPassword, + isPending, + isSuccess, + error, + } = useMutation({ + mutationFn: async () => { + if (password !== confirmPassword) { + throw new Error("Passwords do not match") + } + const { error } = await authClient.resetPassword({ + newPassword: password, + token, + }) + if (error) throw new Error(error.message ?? "Failed to reset password") + }, + }) + + if (!token) { + return ( + + + +
+ +
+
+

+ Invalid reset link +

+

+ This password reset link is invalid or has expired. +

+
+
+ + + Request a new reset link + + +
+
+ ) + } + + if (isSuccess) { + return ( + + + +
+ +
+
+

+ Password reset! +

+

+ Your password has been successfully reset. +

+
+
+ + + +
+
+ ) + } + + return ( + + + + + Set new password + + Enter your new password below + +
{ + e.preventDefault() + resetPassword() + }} + > + + {error && ( +
+ + {error.message} +
+ )} +
+ + setPassword(e.target.value)} + minLength={8} + autoComplete="new-password" + required + /> +
+
+ + setConfirmPassword(e.target.value)} + minLength={8} + autoComplete="new-password" + required + /> +
+
+ + + +
+
+
+ ) +} diff --git a/apps/web/src/routes/signup.tsx b/apps/web/src/routes/signup.tsx index 4e93d20..e85228a 100644 --- a/apps/web/src/routes/signup.tsx +++ b/apps/web/src/routes/signup.tsx @@ -4,7 +4,6 @@ import { Card, CardContent, CardDescription, - CardFooter, CardHeader, CardTitle, } from "@repo/ui/components/card" @@ -12,7 +11,9 @@ import { Input } from "@repo/ui/components/input" import { Label } from "@repo/ui/components/label" import { useMutation } from "@tanstack/react-query" import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" +import { AlertCircle, Loader2 } from "lucide-react" import { type FormEvent, useEffect, useState } from "react" +import { PasswordInput } from "../components/auth/password-input" export const Route = createFileRoute("/signup")({ component: SignUpPage, @@ -22,7 +23,6 @@ function SignUpPage() { const navigate = useNavigate() const { data: session, isPending: sessionPending } = authClient.useSession() const [name, setName] = useState("") - const [username, setUsername] = useState("") const [email, setEmail] = useState("") const [password, setPassword] = useState("") @@ -40,91 +40,132 @@ function SignUpPage() { mutationFn: async () => { const { error } = await authClient.signUp.email({ name, - username, email, password, + callbackURL: `${window.location.origin}/`, }) if (error) throw new Error(error.message ?? "Failed to create account") }, - onSuccess: () => navigate({ to: "/" }), + onSuccess: () => + navigate({ + to: "/check-email", + search: { email }, + }), }) return ( -
- - - Sign up - Create your Townhall account - -
{ - e.preventDefault() - signUp() - }} +
+
+ - - {error && ( -

{error.message}

- )} -
- - setName(e.target.value)} - required - /> -
-
- - setUsername(e.target.value)} - required - /> -
-
- - setEmail(e.target.value)} - required - /> -
-
- - setPassword(e.target.value)} - minLength={8} - required - /> -
-
- - -

- Already have an account?{" "} - - Sign in - -

-
- - + Townhall + Townhall + +
+ + + Create your account + + Enter your information below to get started + + + +
{ + e.preventDefault() + signUp() + }} + > +
+ {error && ( +
+ + {error.message} +
+ )} +
+ + setName(e.target.value)} + autoComplete="name" + required + /> +
+
+ + setEmail(e.target.value)} + autoComplete="email" + required + /> +
+
+ + setPassword(e.target.value)} + minLength={8} + autoComplete="new-password" + required + /> +

+ Must be at least 8 characters long. +

+
+
+ +

+ Already have an account?{" "} + + Sign in + +

+
+
+
+
+
+

+ By clicking continue, you agree to our{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + + . +

+
+
) } diff --git a/package.json b/package.json index fe86c68..592800e 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "generate:auth-schema": "pnpm --filter @repo/auth exec npx @better-auth/cli@latest generate --output ../../packages/db/src/generated-schema.ts", "db:push": "pnpm --filter @repo/db db:push", "db:studio": "pnpm --filter @repo/db db:studio", + "desktop": "pnpm --filter desktop dev", "prepare": "husky" }, "devDependencies": { diff --git a/packages/auth/package.json b/packages/auth/package.json index 46e8ba3..f0d5e19 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -11,7 +11,8 @@ "@repo/db": "workspace:*", "@repo/env": "workspace:*", "better-auth": "^1.4.18", - "ioredis": "^5.10.0" + "ioredis": "^5.10.0", + "resend": "^6.9.4" }, "devDependencies": { "@repo/typescript-config": "workspace:*" diff --git a/packages/auth/src/lib/auth.ts b/packages/auth/src/lib/auth.ts index c867368..e26e874 100644 --- a/packages/auth/src/lib/auth.ts +++ b/packages/auth/src/lib/auth.ts @@ -5,6 +5,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle" import { betterAuth } from "better-auth/minimal" import { admin, organization, twoFactor, username } from "better-auth/plugins" import Redis from "ioredis" +import { Resend } from "resend" import { ac, admin as adminRole, @@ -14,6 +15,7 @@ import { } from "./permissions" const redis = new Redis(env.REDIS_URL) +const resend = new Resend(env.RESEND_API_KEY) const defaultGuildChannels = { uncategorized: [ @@ -122,17 +124,69 @@ export const auth = betterAuth({ }, }, }, - trustedOrigins: - env.NODE_ENV === "development" + trustedOrigins: [ + ...(env.NODE_ENV === "development" ? [ "http://localhost:3000", "http://localhost:3001", "http://127.0.0.1:3000", "http://127.0.0.1:3001", ] - : [], + : []), + "tauri://localhost", + ], emailAndPassword: { enabled: true, + requireEmailVerification: true, + async sendResetPassword({ user, url }) { + resend.emails + .send({ + from: env.EMAIL_FROM, + to: user.email, + subject: "Reset your Townhall password", + html: ` +
+

Reset Your Password

+

Click the button below to reset your password.

+ Reset Password +

If you didn't request a password reset, you can safely ignore this email.

+
+ `, + }) + .then(({ data, error }) => { + if (error) { + console.error("Failed to send reset password email:", error) + } else { + console.log("Reset password email sent:", data?.id) + } + }) + }, + }, + emailVerification: { + sendOnSignIn: true, + sendOnSignUp: true, + autoSignInAfterVerification: true, + async sendVerificationEmail({ user, url, token }) { + resend.emails + .send({ + from: env.EMAIL_FROM, + to: user.email, + subject: "Verify your Townhall email", + html: ` +
+

Welcome to Townhall

+

Click the button below to verify your email address and get started.

+ Verify Email +

If you didn't create a Townhall account, you can safely ignore this email.

+
+ `, + }) + .then(({ error }) => { + if (error) { + console.error("Failed to send verification email:", error.message) + } + }) + }, }, advanced: { cookiePrefix: "townhall", diff --git a/packages/db/src/schemas/index.ts b/packages/db/src/schemas/index.ts index 0a7cdaf..b9bff76 100644 --- a/packages/db/src/schemas/index.ts +++ b/packages/db/src/schemas/index.ts @@ -15,6 +15,7 @@ export * from "./notification-events" export * from "./sessions" export * from "./two-factors" export * from "./user-blocks" +export * from "./user-notification-settings" export * from "./user-privacy-settings" export * from "./users" export * from "./verifications" diff --git a/packages/db/src/schemas/user-notification-settings.ts b/packages/db/src/schemas/user-notification-settings.ts new file mode 100644 index 0000000..19e9318 --- /dev/null +++ b/packages/db/src/schemas/user-notification-settings.ts @@ -0,0 +1,69 @@ +import { relations } from "drizzle-orm" +import { pgEnum, pgTable, timestamp, uuid } from "drizzle-orm/pg-core" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" +import { user } from "./users" + +export const desktopNotificationEnum = pgEnum("desktop_notification", [ + "all_messages", + "mentions_only", + "nothing", +]) + +export const dmNotificationEnum = pgEnum("dm_notification", [ + "all_messages", + "nothing", +]) + +export const userNotificationSettings = pgTable("user_notification_settings", { + id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id") + .notNull() + .unique() + .references(() => user.id, { onDelete: "cascade" }), + desktopNotifications: desktopNotificationEnum("desktop_notifications") + .default("all_messages") + .notNull(), + dmNotifications: dmNotificationEnum("dm_notifications") + .default("all_messages") + .notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), +}) + +export const selectUserNotificationSettingsSchema = createSelectSchema( + userNotificationSettings +) +export const insertUserNotificationSettingsSchema = createInsertSchema( + userNotificationSettings +).omit({ + id: true, + createdAt: true, + updatedAt: true, +}) + +export const notificationSettingsResponseSchema = + selectUserNotificationSettingsSchema.pick({ + desktopNotifications: true, + dmNotifications: true, + }) + +export const updateNotificationSettingsSchema = + insertUserNotificationSettingsSchema + .pick({ + desktopNotifications: true, + dmNotifications: true, + }) + .partial() + +export const userNotificationSettingsRelations = relations( + userNotificationSettings, + ({ one }) => ({ + user: one(user, { + fields: [userNotificationSettings.userId], + references: [user.id], + }), + }) +) diff --git a/packages/env/src/server.ts b/packages/env/src/server.ts index 623542b..0269751 100644 --- a/packages/env/src/server.ts +++ b/packages/env/src/server.ts @@ -21,7 +21,7 @@ const addProtocol = (url: string) => { /** 20 MB default — keep in sync with client.ts */ const DEFAULT_MAX_FILE_UPLOAD_SIZE = 20 * 1024 * 1024 const DEFAULT_REALTIME_CORS_ORIGIN = - "http://localhost:3000,http://localhost:3001" + "http://localhost:3000,http://localhost:3001,tauri://localhost" const serverSchema = z.object({ NODE_ENV: z @@ -42,6 +42,8 @@ const serverSchema = z.object({ S3_BUCKET_NAME: z.string().min(1), S3_REGION: z.string().default("auto"), S3_PUBLIC_URL: z.string().url(), + RESEND_API_KEY: z.string().min(1), + EMAIL_FROM: z.string().default("Townhall "), }) export const env = serverSchema.parse(process.env) diff --git a/packages/realtime-types/src/events.ts b/packages/realtime-types/src/events.ts index 4e662ba..86f145a 100644 --- a/packages/realtime-types/src/events.ts +++ b/packages/realtime-types/src/events.ts @@ -209,6 +209,9 @@ export type UnreadNotification = { guildId: string | null messageId: string unreadCountDelta: number + authorName: string + contentPreview: string | null + channelName: string | null } export type MentionNotification = { @@ -220,6 +223,15 @@ export type MentionNotification = { createdAt: string } +export type NotificationBootstrap = { + readStates: Array<{ + channelId: string + unreadCount: number + mentionCount: number + lastReadMessageId: string | null + }> +} + export const guildMemberJoinedPayloadSchema = z.object({ guildId: z.string().uuid(), }) @@ -298,6 +310,7 @@ export interface ServerToClientEvents { "message:reaction:updated": (payload: RealtimeMessageReactionUpdated) => void "message:embeds:updated": (payload: RealtimeMessageEmbedsUpdated) => void "message:pin:toggled": (payload: RealtimeMessagePinToggled) => void + "notification:bootstrap": (payload: NotificationBootstrap) => void "notification:unread": (payload: UnreadNotification) => void "notification:mention": (payload: MentionNotification) => void "channel:read-state": (payload: ChannelReadState) => void diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6729804..a0c66c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,9 @@ importers: '@tanstack/react-router': specifier: ^1.120.3 version: 1.159.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tauri-apps/plugin-notification': + specifier: ^2.3.3 + version: 2.3.3 '@tiptap/extension-link': specifier: ^3.20.0 version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) @@ -419,6 +422,9 @@ importers: ioredis: specifier: ^5.10.0 version: 5.10.0 + resend: + specifier: ^6.9.4 + version: 6.9.4 devDependencies: '@repo/typescript-config': specifier: workspace:* @@ -2937,6 +2943,9 @@ packages: '@socket.io/redis-emitter@5.1.0': resolution: {integrity: sha512-QQUFPBq6JX7JIuM/X1811ymKlAfwufnQ8w6G2/59Jaqp09hdF1GJ/+e8eo/XdcmT0TqkvcSa2TT98ggTXa5QYw==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3134,6 +3143,9 @@ packages: resolution: {integrity: sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg==} engines: {node: '>=12'} + '@tauri-apps/api@2.10.1': + resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} + '@tauri-apps/cli-darwin-arm64@2.10.1': resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==} engines: {node: '>= 10'} @@ -3205,6 +3217,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-notification@2.3.3': + resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==} + '@tiptap/core@3.20.0': resolution: {integrity: sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==} peerDependencies: @@ -4226,6 +4241,9 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -5234,6 +5252,9 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + postal-mime@2.7.3: + resolution: {integrity: sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==} + postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -5523,6 +5544,15 @@ packages: reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resend@6.9.4: + resolution: {integrity: sha512-/M3dsJzu5OgozqVsA4Psd/1L7EdePgOIIxClas453GOQYFG3VHc2ZyCHZFlvqsc9aZCCd2BJRRqZgWC8D9c7/g==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5704,6 +5734,9 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -5781,6 +5814,9 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + svix@1.86.0: + resolution: {integrity: sha512-/HTvXwjLJe1l/MsLXAO1ddCYxElJk4eNR4DzOjDOEmGrPN/3BtBE8perGwMAaJ2sT5T172VkBYzmHcjUfM1JRQ==} + tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} @@ -6032,6 +6068,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -8753,6 +8793,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -8949,6 +8991,8 @@ snapshots: '@tanstack/virtual-file-routes@1.154.7': {} + '@tauri-apps/api@2.10.1': {} + '@tauri-apps/cli-darwin-arm64@2.10.1': optional: true @@ -8996,6 +9040,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.10.1 '@tauri-apps/cli-win32-x64-msvc': 2.10.1 + '@tauri-apps/plugin-notification@2.3.3': + dependencies: + '@tauri-apps/api': 2.10.1 + '@tiptap/core@3.20.0(@tiptap/pm@3.20.0)': dependencies: '@tiptap/pm': 3.20.0 @@ -10002,6 +10050,8 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-sha256@1.3.0: {} + fast-uri@3.1.0: {} fast-xml-builder@1.0.0: {} @@ -11180,6 +11230,8 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 + postal-mime@2.7.3: {} + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 @@ -11585,6 +11637,11 @@ snapshots: reselect@5.1.1: {} + resend@6.9.4: + dependencies: + postal-mime: 2.7.3 + svix: 1.86.0 + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -11881,6 +11938,11 @@ snapshots: standard-as-callback@2.1.0: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@2.0.2: {} stdin-discarder@0.2.2: {} @@ -11953,6 +12015,11 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + svix@1.86.0: + dependencies: + standardwebhooks: 1.0.0 + uuid: 10.0.0 + tabbable@6.4.0: {} tagged-tag@1.0.0: {} @@ -12191,6 +12258,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@10.0.0: {} + uuid@11.1.0: {} validate-npm-package-name@7.0.2: {} diff --git a/turbo.json b/turbo.json index 9ad5f39..d51f876 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,6 @@ { "$schema": "https://turborepo.dev/schema.json", - "ui": "tui", + "ui": "stream-with-experimental-timestamps", "tasks": { "build": { "dependsOn": ["^build"],