feat(frontend): notifications

This commit is contained in:
sam 2025-03-05 01:18:21 +01:00
parent f99d10ecf0
commit dd9d35249c
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
9 changed files with 129 additions and 6 deletions

View file

@ -112,3 +112,12 @@ export enum ClearableField {
Flags = "FLAGS",
CustomPreferences = "CUSTOM_PREFERENCES",
}
export type Notification = {
id: string;
type: "NOTICE" | "WARNING" | "SUSPENSION";
message?: string;
localization_key?: string;
localization_params: Record<string, string>;
acknowledged: boolean;
};

View file

@ -13,13 +13,21 @@
import Logo from "$components/Logo.svelte";
import { t } from "$lib/i18n";
type Props = { user: MeUser | null; meta: Meta };
let { user, meta }: Props = $props();
type Props = { user: MeUser | null; meta: Meta; unreadNotifications?: boolean };
let { user, meta, unreadNotifications }: Props = $props();
let isOpen = $state(true);
const toggleMenu = () => (isOpen = !isOpen);
</script>
{#if user && unreadNotifications}
<div class="notification-alert text-center py-3 mb-2 px-2">
<strong>{$t("nav.unread-notification-text")}</strong>
<br />
<a href="/settings/notifications">{$t("nav.unread-notification-link")}</a>
</div>
{/if}
{#if user && user.deleted}
<div class="deleted-alert text-center py-3 mb-2 px-2">
{#if user.suspended}
@ -87,6 +95,11 @@
background-color: var(--bs-danger-bg-subtle);
}
.notification-alert {
color: var(--bs-warning-text-emphasis);
background-color: var(--bs-warning-bg-subtle);
}
/* These exact values make it look almost identical to the SVG version, which is what we want */
#beta-text {
font-size: 0.7em;

View file

@ -0,0 +1,43 @@
<script lang="ts">
import type { Notification } from "$api/models/moderation";
import { t } from "$lib/i18n";
import InfoCircleFill from "svelte-bootstrap-icons/lib/InfoCircleFill.svelte";
import ExclamationTriangleFill from "svelte-bootstrap-icons/lib/ExclamationTriangleFill.svelte";
import XOctagonFill from "svelte-bootstrap-icons/lib/XOctagonFill.svelte";
import QuestionCircleFill from "svelte-bootstrap-icons/lib/QuestionCircleFill.svelte";
import { idTimestamp } from "$lib";
import { DateTime } from "luxon";
type Props = { notification: Notification };
let { notification }: Props = $props();
let Icon = $derived.by(() => {
if (notification.type === "NOTICE") return InfoCircleFill;
if (notification.type === "WARNING") return ExclamationTriangleFill;
if (notification.type === "SUSPENSION") return XOctagonFill;
return QuestionCircleFill;
});
</script>
<div class="card mb-2">
<div class="card-body">
<div class="d-flex">
<div aria-hidden="true">
<Icon width={48} height={48} />
</div>
<div class="mx-3">
<p class="card-text text-has-newline">
{#if notification.localization_key}
{$t(notification.localization_key, notification.localization_params)}
{:else}
{notification.message}
{/if}
</p>
</div>
</div>
</div>
<div class="card-footer text-body-secondary">
{idTimestamp(notification.id).toLocaleString(DateTime.DATETIME_MED)}
<a href="/settings/notifications/ack/{notification.id}">Mark as read</a>
</div>
</div>

View file

@ -9,7 +9,9 @@
"reactivate-account-link": "Reactivate account",
"delete-permanently-link": "I want my account deleted permanently",
"reactivate-or-delete-link": "I want to reactivate my account or delete all my data",
"export-link": "I want to export a copy of my data"
"export-link": "I want to export a copy of my data",
"unread-notification-text": "You have an unread notification.",
"unread-notification-link": "Go to your notifications"
},
"avatar-tooltip": "Avatar for {{name}}",
"profile": {
@ -329,7 +331,9 @@
},
"alert": {
"auth-method-remove-success": "Successfully unlinked account!",
"auth-required": "You must log in to access this page."
"auth-required": "You must log in to access this page.",
"notif-ack-successful": "Successfully marked notification as read!",
"notif-ack-fail": "Could not mark notification as read."
},
"footer": {
"version": "Version",
@ -342,5 +346,10 @@
"changelog": "Changelog",
"donate": "Donate",
"about-contact": "About and contact"
},
"notification": {
"suspension": "Your account has been suspended for the following reason: {{reason}}",
"warning": "You have been warned for the following reason: {{reason}}",
"warning-cleared-fields": "You have been warned for the following reason: {{reason}}\n\nAdditionally, the following fields have been cleared from your profile:\n{{clearedFields}}"
}
}

View file

@ -2,16 +2,24 @@ import { clearToken, TOKEN_COOKIE_NAME } from "$lib";
import { apiRequest } from "$api";
import ApiError, { ErrorCode } from "$api/error";
import type { Meta, MeUser } from "$api/models";
import type { Notification } from "$api/models/moderation";
import log from "$lib/log";
import type { LayoutServerLoad } from "./$types";
export const load = (async ({ fetch, cookies }) => {
let token: string | null = null;
let meUser: MeUser | null = null;
let unreadNotifications: boolean = false;
if (cookies.get(TOKEN_COOKIE_NAME)) {
try {
meUser = await apiRequest<MeUser>("GET", "/users/@me", { fetch, cookies });
token = cookies.get(TOKEN_COOKIE_NAME) || null;
const notifications = await apiRequest<Notification[]>("GET", "/notifications", {
fetch,
cookies,
});
unreadNotifications = notifications.filter((n) => !n.acknowledged).length > 0;
} catch (e) {
if (e instanceof ApiError && e.code === ErrorCode.AuthenticationRequired) clearToken(cookies);
else log.error("Could not fetch /users/@me and token has not expired:", e);
@ -19,5 +27,5 @@ export const load = (async ({ fetch, cookies }) => {
}
const meta = await apiRequest<Meta>("GET", "/meta", { fetch, cookies });
return { meta, meUser, token };
return { meta, meUser, token, unreadNotifications };
}) satisfies LayoutServerLoad;

View file

@ -11,7 +11,7 @@
<div class="d-flex flex-column min-vh-100">
<div class="flex-grow-1">
<Navbar user={data.meUser} meta={data.meta} />
<Navbar user={data.meUser} meta={data.meta} unreadNotifications={data.unreadNotifications} />
{@render children?.()}
</div>
<Footer meta={data.meta} />

View file

@ -0,0 +1,11 @@
import { apiRequest } from "$api";
import type { Notification } from "$api/models/moderation";
import { alertKey } from "$lib";
export const load = async ({ url, fetch, cookies }) => {
const notifications = await apiRequest<Notification[]>("GET", "/notifications", {
fetch,
cookies,
});
return { notifications, alertKey: alertKey(url) };
};

View file

@ -0,0 +1,16 @@
<script lang="ts">
import type { PageData } from "./$types";
import Notification from "$components/settings/Notification.svelte";
import UrlAlert from "$components/URLAlert.svelte";
type Props = { data: PageData };
let { data }: Props = $props();
</script>
<UrlAlert {data} />
{#each data.notifications as notification (notification.id)}
<Notification {notification} />
{:else}
You have no notifications.
{/each}

View file

@ -0,0 +1,14 @@
import { redirect } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { fastRequest } from "$api";
import log from "$lib/log";
export const GET: RequestHandler = async ({ params, fetch, cookies }) => {
try {
await fastRequest("PUT", `/notifications/${params.id}/ack`, { fetch, cookies });
} catch (e) {
log.error("error acking notification %s:", params.id, e);
redirect(303, "/settings/notifications?alert=notif-ack-fail");
}
redirect(303, "/settings/notifications?alert=notif-ack-successful");
};