Skip to content

Commit

Permalink
Various notification fixes (#624)
Browse files Browse the repository at this point in the history
* Send sounds for notifications

* Send unread notification count with notifications

* Update badge count live through app

* Fix unread notification count due to promises

* Fix issues with sending notifications

* Optimistic update notification reads

* Fetch less data for notifications
  • Loading branch information
Macludde authored Dec 9, 2024
1 parent 348c0ac commit d80cb3a
Show file tree
Hide file tree
Showing 28 changed files with 488 additions and 341 deletions.
1 change: 1 addition & 0 deletions src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ declare global {

interface Window {
notificationToken?: string;
unreadNotificationCount?: number;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AddForeignKey
DELETE FROM "expo_tokens" WHERE "member_id" NOT IN (SELECT "id" FROM "members");
ALTER TABLE "expo_tokens" ADD CONSTRAINT "expo_tokens_member_id_foreign" FOREIGN KEY ("member_id") REFERENCES "members"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
2 changes: 2 additions & 0 deletions src/database/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ model EventsTag {
model ExpoToken {
id String @id() @default(dbgenerated("gen_random_uuid()")) @db.Uuid()
memberId String? @map("member_id") @db.Uuid()
member Member? @relation(fields: [memberId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "expo_tokens_member_id_foreign")
expoToken String @unique(map: "expo_tokens_expo_token_unique") @map("expo_token") @db.VarChar(255)
@@map("expo_tokens")
Expand Down Expand Up @@ -600,6 +601,7 @@ model Member {
shopReservations ConsumableReservation[]
bookingRequests BookingRequest[]
recurringEvent RecurringEvent[]
tokens ExpoToken[]
@@map("members")
}
Expand Down
2 changes: 2 additions & 0 deletions src/database/schema.zmodel
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ model EventsTag {
model ExpoToken {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
memberId String? @map("member_id") @db.Uuid
member Member? @relation(fields: [memberId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "expo_tokens_member_id_foreign")
expoToken String @unique(map: "expo_tokens_expo_token_unique") @map("expo_token") @db.VarChar(255)

@@allow('create', auth().memberId == memberId)
Expand Down Expand Up @@ -590,6 +591,7 @@ model Member {
shopReservations ConsumableReservation[]
bookingRequests BookingRequest[]
recurringEvent RecurringEvent[]
tokens ExpoToken[]

@@allow('create', has(auth().policies, "core:member:create"))
@@allow('create', auth().studentId == studentId)
Expand Down
32 changes: 32 additions & 0 deletions src/database/seed/.snaplet/dataModel.json
Original file line number Diff line number Diff line change
Expand Up @@ -4623,6 +4623,24 @@
"hasDefaultValue": false,
"isId": false,
"maxLength": 255
},
{
"name": "members",
"type": "members",
"isRequired": false,
"kind": "object",
"relationName": "expo_tokensTomembers",
"relationFromFields": [
"member_id"
],
"relationToFields": [
"id"
],
"isList": false,
"isId": false,
"isGenerated": false,
"sequence": false,
"hasDefaultValue": false
}
],
"uniqueConstraints": [
Expand Down Expand Up @@ -5904,6 +5922,20 @@
"sequence": false,
"hasDefaultValue": false
},
{
"name": "expo_tokens",
"type": "expo_tokens",
"isRequired": false,
"kind": "object",
"relationName": "expo_tokensTomembers",
"relationFromFields": [],
"relationToFields": [],
"isList": true,
"isId": false,
"isGenerated": false,
"sequence": false,
"hasDefaultValue": false
},
{
"name": "mandates",
"type": "mandates",
Expand Down
63 changes: 23 additions & 40 deletions src/lib/components/Notification.svelte
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
<script lang="ts">
import { browser } from "$app/environment";
import { enhance } from "$app/forms";
import { invalidate } from "$app/navigation";
import { page } from "$app/stores";
import LiveTimeSince from "$lib/components/LiveTimeSince.svelte";
import AuthorAvatars from "$lib/components/socials/AuthorAvatars.svelte";
import { i18n } from "$lib/utils/i18n";
import type { NotificationGroup } from "$lib/utils/notifications/group";
import type { NotificationSchema } from "$lib/zod/schemas";
import type { SuperValidated } from "sveltekit-superforms";
import { superForm } from "$lib/utils/client/superForms";
import { browser } from "$app/environment";
type NotificationItem = Pick<
NotificationGroup,
| "link"
Expand All @@ -22,14 +20,16 @@
>;
export let notification: NotificationItem;
export let form: SuperValidated<NotificationSchema> | undefined = undefined;
export let allowDelete = true;
export let onClick: (() => void) | undefined = undefined;
export let onRead: (() => void) | undefined = undefined;
let readForm: HTMLFormElement;
const readNotification = () => {
console.log("reading notification");
// read notification
readForm?.requestSubmit();
onRead?.();
if (browser) invalidate("/api/notifications/my");
};
Expand All @@ -44,45 +44,28 @@
}
})();
// can be used for reading as well, same types
$: superformDelete =
form && allowDelete
? superForm(form, {
id: notification.id.toString() + "-delete",
})
: undefined;
$: enhanceDelete = superformDelete?.enhance;
$: superformRead = form
? superForm(form, {
id: notification.id.toString() + "-read",
})
: undefined;
$: enhanceRead = superformRead?.enhance;
$: authors = notification.authors.filter(Boolean) as Array<
NonNullable<NotificationItem["authors"][number]>
>;
</script>

<div class="relative flex w-full items-stretch rounded-none p-2">
{#if superformRead && enhanceRead}
<form
bind:this={readForm}
method="POST"
action="/notifications?/readNotifications"
use:enhanceRead
class="hidden"
aria-hidden="true"
>
{#if notification.individualIds.length > 1}
{#each notification.individualIds as id}
<input type="hidden" name="notificationIds" value={id} />
{/each}
{:else}
<input type="hidden" name="notificationId" value={notification.id} />
{/if}
</form>
{/if}
<form
bind:this={readForm}
method="POST"
action="/notifications?/readNotifications"
use:enhance
class="hidden"
aria-hidden="true"
>
{#if notification.individualIds.length > 1}
{#each notification.individualIds as id}
<input type="hidden" name="notificationIds" value={id} />
{/each}
{:else}
<input type="hidden" name="notificationId" value={notification.id} />
{/if}
</form>

<a
href={notification.link}
Expand All @@ -106,13 +89,13 @@
</span>
</div>
</a>
{#if superformDelete && enhanceDelete}
{#if allowDelete}
<!-- Deletes this notification -->
<form
class="flex items-stretch"
method="POST"
action="/notifications?/deleteNotification"
use:enhanceDelete
use:enhance
>
{#if notification.individualIds.length > 1}
{#each notification.individualIds as id}
Expand Down
90 changes: 51 additions & 39 deletions src/lib/components/NotificationModal.svelte
Original file line number Diff line number Diff line change
@@ -1,56 +1,68 @@
<script lang="ts">
import { page } from "$app/stores";
import Notification from "$lib/components/Notification.svelte";
import PostRevealNotification from "$lib/components/postReveal/PostRevealNotification.svelte";
import type { NotificationGroup } from "$lib/utils/notifications/group";
import * as m from "$paraglide/messages";
// eslint-disable-next-line no-restricted-imports -- It's a top level layout so I would say this is fine
import type { GlobalAppLoadData } from "../../routes/(app)/+layout.server";
export let modal: HTMLDialogElement;
$: pageData = $page.data as typeof $page.data & GlobalAppLoadData;
$: notifications = pageData["notifications"];
$: mutateNotificationForm = pageData["mutateNotificationForm"];
export let notifications: NotificationGroup[] | undefined = undefined;
export let allowDelete = true;
export let postReveal = false;
export let onRead = (id: number | "all") => {
console.log("onRead");
if (id === "all") {
notifications = notifications?.map((notification) => ({
...notification,
readAt: new Date(),
}));
} else {
notifications = notifications?.map((notification) =>
notification.id === id
? {
...notification,
readAt: new Date(),
}
: notification,
);
}
notifications = notifications;
console.log(notifications?.filter((n) => !n.readAt).length);
};
</script>

<dialog id="notificationModal" class="modal" bind:this={modal}>
<div
class="modal-box relative flex h-[calc(100dvh-8rem)] w-[calc(100dvw-2rem)] flex-col flex-nowrap"
>
<ul class="-mx-6 flex-nowrap overflow-y-auto">
{#if notifications !== null}
{#await notifications}
<span class="loading loading-lg" />
{:then notifications}
{#if notifications.length > 0}
{#each notifications as notification (notification.id)}
<li>
{#if postReveal}
<PostRevealNotification
onClick={() => modal.close()}
{allowDelete}
{notification}
form={mutateNotificationForm ?? undefined}
/>
{:else}
<Notification
onClick={() => modal.close()}
{allowDelete}
{notification}
form={mutateNotificationForm ?? undefined}
/>
{/if}
</li>
{/each}
{:else}
<li class="p-4">{m.navbar_bell_noNotifications()}</li>
{/if}
{/await}
{:else}
<li class="p-4">{m.navbar_bell_noNotifications()}</li>
{/if}
</ul>
{#if notifications !== undefined}
<ul class="-mx-6 flex-nowrap overflow-y-auto">
{#if notifications.length > 0}
{#each notifications as notification (notification.id)}
<li>
{#if postReveal}
<PostRevealNotification
onClick={() => modal.close()}
{allowDelete}
{notification}
onRead={() => onRead(notification.id)}
/>
{:else}
<Notification
onClick={() => modal.close()}
{allowDelete}
{notification}
onRead={() => onRead(notification.id)}
/>
{/if}
</li>
{/each}
{:else}
<li class="p-4">{m.navbar_bell_noNotifications()}</li>
{/if}
</ul>
{:else}
<span class="loading loading-lg mx-auto self-center" />
{/if}
<form method="dialog">
<button
class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2 z-10 bg-base-100"
Expand Down
Loading

0 comments on commit d80cb3a

Please sign in to comment.