feat(notifications): add account-scoped inbox and persistent preferences
This commit is contained in:
1 parent
8f55ff2d17
commit
1eee6661f9
39 files changed
+1559
-3
No files matched your search
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS `website_notification_reads` (
|
||||
`user_id` int NOT NULL,
|
||||
`event_key` varchar(128) NOT NULL,
|
||||
`read_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`user_id`, `event_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CREATE TABLE IF NOT EXISTS `website_notification_preferences` (
|
||||
`user_id` int NOT NULL,
|
||||
`support` boolean NOT NULL DEFAULT true,
|
||||
`friends` boolean NOT NULL DEFAULT true,
|
||||
`events` boolean NOT NULL DEFAULT true,
|
||||
PRIMARY KEY (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -197,6 +197,7 @@ const server = createServer(async (req, res) => {
|
||||
}
|
||||
if (
|
||||
[
|
||||
"/notifications",
|
||||
"/admin/unsaved-harness",
|
||||
"/admin/attachment-harness",
|
||||
"/admin/studio-harness",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TicketsTable } from "@/app/admin/tickets/tickets-table";
|
||||
import { ArticleForm } from "@/components/admin/article-form";
|
||||
import { FurnitureSyncQueue } from "@/components/admin/studio/furniture-sync-queue";
|
||||
import { StudioClient } from "@/components/admin/studio/studio-client";
|
||||
import { NotificationCenter } from "@/features/notifications/notification-center";
|
||||
import type { ArticleSaveResult } from "@/lib/article-input";
|
||||
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||
import { themePaletteCss } from "@/lib/theme-css";
|
||||
@@ -25,6 +26,11 @@ theme.textContent = themePaletteCss(
|
||||
);
|
||||
document.head.appendChild(theme);
|
||||
const route = window.location.pathname;
|
||||
if (route.includes("notifications")) {
|
||||
document.documentElement.classList.add("app");
|
||||
document.body.classList.add("site-bg");
|
||||
document.body.style.backgroundColor = "var(--color-background)";
|
||||
}
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw Error("Fixture root missing");
|
||||
const rows = [
|
||||
@@ -96,8 +102,18 @@ createRoot(root).render(
|
||||
now={new Date("2030-01-01T12:00:00Z")}
|
||||
messages={messages}
|
||||
>
|
||||
<main data-admin className="min-w-0 mx-auto max-w-6xl p-5">
|
||||
<h1 className="mb-5 text-2xl font-bold">
|
||||
<main
|
||||
data-admin={route.includes("notifications") ? undefined : ""}
|
||||
className={
|
||||
route.includes("notifications")
|
||||
? "min-w-0 mx-auto max-w-7xl px-3 py-3 md:px-6 md:py-6"
|
||||
: "min-w-0 mx-auto max-w-6xl p-5"
|
||||
}
|
||||
>
|
||||
<h1
|
||||
hidden={route.includes("notifications")}
|
||||
className="mb-5 text-2xl font-bold"
|
||||
>
|
||||
{route.includes("settings")
|
||||
? "CMS settings"
|
||||
: route.includes("tickets")
|
||||
@@ -106,7 +122,9 @@ createRoot(root).render(
|
||||
? "News editor"
|
||||
: "Furniture synchronization"}
|
||||
</h1>
|
||||
{route.includes("unsaved-harness") ? (
|
||||
{route.includes("notifications") ? (
|
||||
<NotificationCenter userId={7} />
|
||||
) : route.includes("unsaved-harness") ? (
|
||||
<UnsavedHarness />
|
||||
) : route.includes("studio-harness") ? (
|
||||
<StudioClient
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("pageerror", (error) => {
|
||||
throw error;
|
||||
});
|
||||
});
|
||||
|
||||
test("notification reads persist, new replies are unread and failed preferences stay unchanged", async ({
|
||||
page,
|
||||
}) => {
|
||||
let key = "support:42";
|
||||
const readKeys = new Set<string>();
|
||||
let fail = false;
|
||||
await page.route("**/api/notifications**", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
if (fail)
|
||||
return route.fulfill({ status: 503, json: { error: "Unavailable" } });
|
||||
const body = route.request().postDataJSON();
|
||||
if (body.action === "read") readKeys.add(body.key);
|
||||
return route.fulfill({ json: { ok: true } });
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
key,
|
||||
category: "support",
|
||||
title: "Your account request",
|
||||
href: "/help/tickets/2",
|
||||
occurredAt: "2030-01-01T12:00:00Z",
|
||||
read: readKeys.has(key),
|
||||
},
|
||||
],
|
||||
page: 1,
|
||||
pages: 1,
|
||||
unread: readKeys.has(key) ? 0 : 1,
|
||||
preferences: { support: true, friends: true, events: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto("/notifications");
|
||||
const mark = page.getByRole("button", { name: "Mark as read" });
|
||||
await expect(mark).toBeVisible();
|
||||
await mark.focus();
|
||||
await expect(mark).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(mark).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByText("No unread notifications", { exact: true }),
|
||||
).toBeVisible();
|
||||
key = "support:43";
|
||||
await page.reload();
|
||||
await expect(mark).toBeVisible();
|
||||
fail = true;
|
||||
await mark.click();
|
||||
await expect(page.getByRole("alert")).toHaveText(
|
||||
"The change could not be saved. Please try again.",
|
||||
);
|
||||
await expect(mark).toBeVisible();
|
||||
const support = page.getByRole("checkbox", { name: "Support replies" });
|
||||
await support.click();
|
||||
await expect(support).toBeChecked();
|
||||
expect(await support.evaluate((el) => getComputedStyle(el).color)).not.toBe(
|
||||
"rgba(0, 0, 0, 0)",
|
||||
);
|
||||
expect(
|
||||
await support.evaluate((el) => getComputedStyle(el).backgroundColor),
|
||||
).not.toBe("rgba(0, 0, 0, 0)");
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
await page.screenshot({
|
||||
path: `test-results/notifications-${test.info().project.name}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("notification service failure offers a retry and recovers to empty state", async ({
|
||||
page,
|
||||
}) => {
|
||||
let failing = true;
|
||||
await page.route("**/api/notifications**", (route) =>
|
||||
route.fulfill(
|
||||
failing
|
||||
? { status: 503, json: { error: "Unavailable" } }
|
||||
: {
|
||||
json: {
|
||||
items: [],
|
||||
page: 1,
|
||||
pages: 1,
|
||||
unread: 0,
|
||||
preferences: { support: true, friends: true, events: true },
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
await page.goto("/notifications");
|
||||
await expect(page.getByRole("alert")).toHaveText(
|
||||
"Notifications are temporarily unavailable.",
|
||||
);
|
||||
failing = false;
|
||||
await page.getByRole("button", { name: "Try again" }).click();
|
||||
await expect(
|
||||
page.getByText("No notifications in your enabled categories."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("category changes persist and pagination recovers when filtering shrinks the feed", async ({
|
||||
page,
|
||||
}) => {
|
||||
let enabled = true;
|
||||
await page.route("**/api/notifications**", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
enabled = route.request().postDataJSON().preferences.support;
|
||||
return route.fulfill({ json: { ok: true } });
|
||||
}
|
||||
const requested = Number(
|
||||
new URL(route.request().url()).searchParams.get("page") ?? 1,
|
||||
);
|
||||
const current = enabled ? Math.min(2, requested) : 1;
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: enabled
|
||||
? [
|
||||
{
|
||||
key: `support:${current}`,
|
||||
category: "support",
|
||||
title: `Reply page ${current}`,
|
||||
href: "/help/tickets/2",
|
||||
occurredAt: null,
|
||||
read: false,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
page: current,
|
||||
pages: enabled ? 2 : 1,
|
||||
unread: enabled ? 21 : 0,
|
||||
preferences: { support: enabled, friends: true, events: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto("/notifications");
|
||||
await page.getByRole("button", { name: "Next", exact: true }).click();
|
||||
await expect(page.getByRole("link", { name: "Reply page 2" })).toBeVisible();
|
||||
const support = page.getByRole("checkbox", { name: "Support replies" });
|
||||
await support.click();
|
||||
await expect(support).not.toBeChecked();
|
||||
await expect(
|
||||
page.getByText("No notifications in your enabled categories."),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Next", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(support).not.toBeChecked();
|
||||
await support.click();
|
||||
await expect(page.getByRole("link", { name: "Reply page 1" })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { NotificationCenter } from "@/features/notifications/notification-center";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sessionUserId } from "@/lib/auth/session-user";
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("pages.notifications");
|
||||
return { title: t("title"), robots: { index: false, follow: false } };
|
||||
}
|
||||
export default async function NotificationsPage() {
|
||||
const uid = sessionUserId((await auth())?.user?.id);
|
||||
if (!uid) redirect("/login");
|
||||
return <NotificationCenter userId={uid} />;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
auth: vi.fn(),
|
||||
feed: vi.fn(),
|
||||
mark: vi.fn(),
|
||||
preferences: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth", () => ({ auth: mocks.auth }));
|
||||
vi.mock("@/features/notifications/server", () => ({ notifications: mocks }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ rateLimit: async () => ({ ok: true }) }));
|
||||
|
||||
import { GET, POST } from "./route";
|
||||
|
||||
function request(body: unknown, origin = "https://hotel.test") {
|
||||
return new Request("https://hotel.test/api/notifications", {
|
||||
method: "POST",
|
||||
headers: { origin, "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.auth.mockResolvedValue({ user: { id: "7" } });
|
||||
mocks.feed.mockResolvedValue({ items: [] });
|
||||
mocks.mark.mockResolvedValue(true);
|
||||
});
|
||||
describe("notification route authorization", () => {
|
||||
it("rejects anonymous reads and writes without accessing data", async () => {
|
||||
mocks.auth.mockResolvedValue(null);
|
||||
expect(
|
||||
(await GET(new Request("https://hotel.test/api/notifications"))).status,
|
||||
).toBe(401);
|
||||
expect(
|
||||
(await POST(request({ action: "read", key: "support:1" }))).status,
|
||||
).toBe(401);
|
||||
expect(mocks.feed).not.toHaveBeenCalled();
|
||||
expect(mocks.mark).not.toHaveBeenCalled();
|
||||
});
|
||||
it("uses the session identity even when a query supplies another account", async () => {
|
||||
await GET(
|
||||
new Request("https://hotel.test/api/notifications?userId=99&page=2"),
|
||||
);
|
||||
expect(mocks.feed).toHaveBeenCalledWith(7, 2);
|
||||
});
|
||||
it("rejects cross-origin mutations", async () => {
|
||||
expect(
|
||||
(
|
||||
await POST(
|
||||
request(
|
||||
{ action: "read", key: "support:1" },
|
||||
"https://attacker.test",
|
||||
),
|
||||
)
|
||||
).status,
|
||||
).toBe(403);
|
||||
expect(mocks.mark).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects an injected account in writes", async () => {
|
||||
expect(
|
||||
(await POST(request({ action: "read", key: "support:1", userId: 99 })))
|
||||
.status,
|
||||
).toBe(400);
|
||||
expect(mocks.mark).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects unavailable and other account notifications", async () => {
|
||||
mocks.mark.mockResolvedValue(false);
|
||||
expect(
|
||||
(await POST(request({ action: "read", key: "support:1" }))).status,
|
||||
).toBe(404);
|
||||
expect(mocks.mark).toHaveBeenCalledWith(7, "support:1");
|
||||
});
|
||||
it("persists category preferences under the authenticated account", async () => {
|
||||
const preferences = { support: false, friends: true, events: false };
|
||||
expect(
|
||||
(await POST(request({ action: "preferences", preferences }))).status,
|
||||
).toBe(200);
|
||||
expect(mocks.preferences).toHaveBeenCalledWith(7, preferences);
|
||||
});
|
||||
it("does not turn an infrastructure failure into an empty feed", async () => {
|
||||
mocks.feed.mockRejectedValue(new Error("Database unavailable"));
|
||||
expect(
|
||||
(await GET(new Request("https://hotel.test/api/notifications"))).status,
|
||||
).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a failure when saving read state or preferences fails", async () => {
|
||||
mocks.mark.mockRejectedValue(new Error("Database unavailable"));
|
||||
expect(
|
||||
(await POST(request({ action: "read", key: "support:1" }))).status,
|
||||
).toBe(503);
|
||||
mocks.preferences.mockRejectedValue(new Error("Database unavailable"));
|
||||
expect(
|
||||
(
|
||||
await POST(
|
||||
request({
|
||||
action: "preferences",
|
||||
preferences: { support: true, friends: false, events: true },
|
||||
}),
|
||||
)
|
||||
).status,
|
||||
).toBe(503);
|
||||
});
|
||||
|
||||
it("rejects incomplete and foreign-account preferences", async () => {
|
||||
for (const body of [
|
||||
{ action: "preferences", preferences: { support: false } },
|
||||
{
|
||||
action: "preferences",
|
||||
userId: 99,
|
||||
preferences: { support: false, friends: true, events: true },
|
||||
},
|
||||
])
|
||||
expect((await POST(request(body))).status).toBe(400);
|
||||
expect(mocks.preferences).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { z } from "zod";
|
||||
import { notifications } from "@/features/notifications/server";
|
||||
import { apiError, apiJson, apiUnavailable } from "@/lib/api";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sessionUserId } from "@/lib/auth/session-user";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const mutation = z.discriminatedUnion("action", [
|
||||
z
|
||||
.object({ action: z.literal("read"), key: z.string().min(1).max(128) })
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
action: z.literal("preferences"),
|
||||
preferences: z
|
||||
.object({
|
||||
support: z.boolean(),
|
||||
friends: z.boolean(),
|
||||
events: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
export async function GET(req: Request) {
|
||||
const uid = sessionUserId((await auth())?.user?.id);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
try {
|
||||
return apiJson(
|
||||
await notifications.feed(
|
||||
uid,
|
||||
Number(new URL(req.url).searchParams.get("page") ?? 1),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
return apiUnavailable();
|
||||
}
|
||||
}
|
||||
export async function POST(req: Request) {
|
||||
const uid = sessionUserId((await auth())?.user?.id);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
if (req.headers.get("origin") !== new URL(req.url).origin)
|
||||
return apiError("Invalid origin", 403);
|
||||
if (!req.headers.get("content-type")?.startsWith("application/json"))
|
||||
return apiError("Invalid content type", 415);
|
||||
if (!(await rateLimit(`notifications:${uid}`, 60, 60_000)).ok)
|
||||
return apiError("Too many requests", 429);
|
||||
const body = await req.text();
|
||||
if (body.length > 2048) return apiError("Request too large", 413);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(body);
|
||||
} catch {
|
||||
return apiError("Invalid request");
|
||||
}
|
||||
const parsed = mutation.safeParse(value);
|
||||
if (!parsed.success) return apiError("Invalid request");
|
||||
try {
|
||||
if (parsed.data.action === "read") {
|
||||
if (!(await notifications.mark(uid, parsed.data.key)))
|
||||
return apiError("Notification not found", 404);
|
||||
} else await notifications.preferences(uid, parsed.data.preferences);
|
||||
return apiJson({ ok: true });
|
||||
} catch {
|
||||
return apiUnavailable();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { count, eq, inArray } from "drizzle-orm";
|
||||
import Image from "next/image";
|
||||
import type { Session } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { signOutAndRevokeTicket } from "@/actions/sessions";
|
||||
import { HeaderUserIdentity } from "@/components/header-user-identity";
|
||||
import Link from "@/components/link";
|
||||
@@ -53,6 +54,7 @@ function Currency({
|
||||
|
||||
export async function TopHeader({ session }: { session: Session | null }) {
|
||||
if (!session?.user?.id) return null;
|
||||
const notificationText = await getTranslations("pages.notifications");
|
||||
const id = Number(session.user.id);
|
||||
|
||||
let credits = 0;
|
||||
@@ -343,6 +345,13 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
>
|
||||
My profile
|
||||
</Link>
|
||||
<Link
|
||||
href="/notifications"
|
||||
className="dropdown-item"
|
||||
role="menuitem"
|
||||
>
|
||||
{notificationText("title")}
|
||||
</Link>
|
||||
<Link href="/settings" className="dropdown-item" role="menuitem">
|
||||
User settings
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Notification center
|
||||
|
||||
Account-scoped live inbox at `/notifications`, reachable through the account menu. The authenticated session is the only source of the user ID. Source queries select only owned support tickets, incoming friend requests, and the user's published upcoming event registrations.
|
||||
|
||||
Each source is capped at 100 rows and the resulting feed uses server-clamped pages of 20. Read state uses the immutable support reply/request ID or event ID + registration ID + scheduled start. Repeated marks are idempotent; a new reply or rescheduled event is unread. Categories are opt-out preferences for this inbox only. No email is sent.
|
||||
|
||||
Persistence is additive via `0032_notification_state.sql`. Apply it through the normal deployment migration runner before enabling the page. No production migration was run during implementation.
|
||||
|
||||
Implementation: domain service and authorization tests; Drizzle source/state repository; strict authenticated GET/POST API; Query-powered public UI with loading, empty, retry and mutation-error states; translated en/it/nl labels; desktop/mobile fixture tests. The UI fixture verifies the real client component with mocked network responses; it does not prove live database integration.
|
||||
|
||||
The inbox is a bounded current feed, not a historical notification archive. Removed requests and past/unpublished/unregistered events disappear. Support includes the 100 latest replies from someone other than the ticket owner, including closed tickets. Friend request tables have no timestamp, so the UI does not invent one. Read state stays stored when a category is disabled or the source leaves the live window.
|
||||
|
||||
The exported component owns its query provider, matching the public route in UI tests. The visible inbox refreshes every 30 seconds and cancels abandoned requests. No browser fixture supplies hidden providers.
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { useFormatter, useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import Link from "@/components/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { Category, NotificationFeed, Preferences } from "./service";
|
||||
|
||||
export function NotificationCenter({ userId }: { userId: number }) {
|
||||
const [client] = useState(
|
||||
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
|
||||
);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<NotificationContent key={userId} userId={userId} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationContent({ userId }: { userId: number }) {
|
||||
const t = useTranslations("pages.notifications");
|
||||
const format = useFormatter();
|
||||
const [page, setPage] = useState(1);
|
||||
const client = useQueryClient();
|
||||
const queryKey = ["notifications", userId];
|
||||
const feed = useQuery<NotificationFeed>({
|
||||
queryKey: [...queryKey, page],
|
||||
queryFn: async ({ signal }) => {
|
||||
const response = await fetch(`/api/notifications?page=${page}`, {
|
||||
cache: "no-store",
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new Error("Unavailable");
|
||||
return response.json();
|
||||
},
|
||||
staleTime: 15_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (
|
||||
body:
|
||||
| { action: "read"; key: string }
|
||||
| { action: "preferences"; preferences: Preferences },
|
||||
) => {
|
||||
const response = await fetch("/api/notifications", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) throw new Error("Unavailable");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
const data = feed.data;
|
||||
return (
|
||||
<section className="page-grid" aria-label={t("title")}>
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
<ContentCard icon="🔔" title={t("title")} subtitle={t("subtitle")}>
|
||||
<p className="muted">{t("scope")}</p>
|
||||
</ContentCard>
|
||||
{feed.isPending ? (
|
||||
<ContentCard>
|
||||
<p role="status">{t("loading")}</p>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
{feed.isError ? (
|
||||
<ContentCard>
|
||||
<p role="alert">{t("error")}</p>
|
||||
<Button variant="outline" onClick={() => feed.refetch()}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
{data ? (
|
||||
<>
|
||||
<ContentCard title={t("preferences")}>
|
||||
<fieldset
|
||||
disabled={mutation.isPending}
|
||||
className="flex flex-wrap gap-5"
|
||||
>
|
||||
<legend className="sr-only">{t("preferences")}</legend>
|
||||
{(["support", "friends", "events"] as Category[]).map(
|
||||
(category) => (
|
||||
<label
|
||||
key={category}
|
||||
htmlFor={`notification-${category}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={`notification-${category}`}
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
backgroundColor: data.preferences[category]
|
||||
? "var(--color-primary)"
|
||||
: "transparent",
|
||||
color: "var(--color-primary-foreground)",
|
||||
}}
|
||||
aria-label={t(category)}
|
||||
disabled={mutation.isPending}
|
||||
checked={data.preferences[category]}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({
|
||||
action: "preferences",
|
||||
preferences: {
|
||||
...data.preferences,
|
||||
[category]: checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
{t(category)}
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</fieldset>
|
||||
<p className="muted mt-3">{t("preferencesHint")}</p>
|
||||
</ContentCard>
|
||||
<ContentCard title={t("unreadCount", { count: data.unread })}>
|
||||
{mutation.isError ? <p role="alert">{t("saveError")}</p> : null}
|
||||
{mutation.isSuccess ? (
|
||||
<p role="status" className="muted">
|
||||
{t("saved")}
|
||||
</p>
|
||||
) : null}
|
||||
{!data.items.length ? (
|
||||
<EmptyState>{t("empty")}</EmptyState>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{data.items.map((item) => (
|
||||
<li
|
||||
key={item.key}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-current/10 p-4"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm muted">
|
||||
{t(item.category)} · {t(item.read ? "read" : "unread")}
|
||||
</p>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="font-semibold break-words underline"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
{item.occurredAt ? (
|
||||
<p className="text-sm muted">
|
||||
<time dateTime={item.occurredAt}>
|
||||
{format.dateTime(new Date(item.occurredAt), {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})}
|
||||
</time>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{!item.read ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={mutation.isPending}
|
||||
onClick={() =>
|
||||
mutation.mutate({ action: "read", key: item.key })
|
||||
}
|
||||
>
|
||||
{t("markRead")}
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{data.pages > 1 ? (
|
||||
<nav
|
||||
aria-label={t("pagination")}
|
||||
className="mt-4 flex items-center gap-3"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={data.page <= 1}
|
||||
onClick={() => setPage(data.page - 1)}
|
||||
>
|
||||
{t("previous")}
|
||||
</Button>
|
||||
<span>{t("page", { page: data.page, pages: data.pages })}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={data.page >= data.pages}
|
||||
onClick={() => setPage(data.page + 1)}
|
||||
>
|
||||
{t("next")}
|
||||
</Button>
|
||||
</nav>
|
||||
) : null}
|
||||
</ContentCard>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
boolean,
|
||||
int,
|
||||
mysqlTable,
|
||||
primaryKey,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
export const NotificationRead = mysqlTable(
|
||||
"website_notification_reads",
|
||||
{
|
||||
userId: int("user_id").notNull(),
|
||||
eventKey: varchar("event_key", { length: 128 }).notNull(),
|
||||
readAt: timestamp("read_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.userId, table.eventKey] })],
|
||||
);
|
||||
export const NotificationPreference = mysqlTable(
|
||||
"website_notification_preferences",
|
||||
{
|
||||
userId: int("user_id").primaryKey().notNull(),
|
||||
support: boolean("support").notNull().default(true),
|
||||
friends: boolean("friends").notNull().default(true),
|
||||
events: boolean("events").notNull().default(true),
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,139 @@
|
||||
import "server-only";
|
||||
import { and, asc, desc, eq, gt, inArray, ne } from "drizzle-orm";
|
||||
import {
|
||||
db,
|
||||
MessengerFriendrequests,
|
||||
User,
|
||||
WebsiteEvent,
|
||||
WebsiteEventRegistration,
|
||||
WebsiteHelpCenterTicketReplies,
|
||||
WebsiteHelpCenterTickets,
|
||||
} from "@/lib/db";
|
||||
import { NotificationPreference, NotificationRead } from "./schema";
|
||||
import {
|
||||
createNotificationService,
|
||||
type Notification,
|
||||
type NotificationRepository,
|
||||
} from "./service";
|
||||
|
||||
// A bounded live inbox: up to 100 items per source, not an archival message store.
|
||||
export const notificationRepository: NotificationRepository = {
|
||||
async source(userId) {
|
||||
const [support, friends, events] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: WebsiteHelpCenterTicketReplies.id,
|
||||
ticketId: WebsiteHelpCenterTickets.id,
|
||||
title: WebsiteHelpCenterTickets.title,
|
||||
createdAt: WebsiteHelpCenterTicketReplies.createdAt,
|
||||
})
|
||||
.from(WebsiteHelpCenterTicketReplies)
|
||||
.innerJoin(
|
||||
WebsiteHelpCenterTickets,
|
||||
eq(
|
||||
WebsiteHelpCenterTickets.id,
|
||||
WebsiteHelpCenterTicketReplies.ticketId,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteHelpCenterTickets.userId, userId),
|
||||
ne(WebsiteHelpCenterTicketReplies.userId, userId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(WebsiteHelpCenterTicketReplies.id))
|
||||
.limit(100),
|
||||
db
|
||||
.select({ id: MessengerFriendrequests.id, username: User.username })
|
||||
.from(MessengerFriendrequests)
|
||||
.innerJoin(User, eq(User.id, MessengerFriendrequests.userFromId))
|
||||
.where(eq(MessengerFriendrequests.userToId, userId))
|
||||
.orderBy(desc(MessengerFriendrequests.id))
|
||||
.limit(100),
|
||||
db
|
||||
.select({
|
||||
id: WebsiteEvent.id,
|
||||
title: WebsiteEvent.title,
|
||||
startsAt: WebsiteEvent.startsAt,
|
||||
registrationId: WebsiteEventRegistration.id,
|
||||
})
|
||||
.from(WebsiteEventRegistration)
|
||||
.innerJoin(
|
||||
WebsiteEvent,
|
||||
eq(WebsiteEvent.id, WebsiteEventRegistration.eventId),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteEventRegistration.userId, userId),
|
||||
eq(WebsiteEvent.status, "published"),
|
||||
gt(WebsiteEvent.startsAt, new Date()),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(WebsiteEvent.startsAt), asc(WebsiteEvent.id))
|
||||
.limit(100),
|
||||
]);
|
||||
const items: Notification[] = [
|
||||
...support.map((row) => ({
|
||||
key: `support:${row.id}`,
|
||||
category: "support" as const,
|
||||
title: row.title,
|
||||
href: `/help/tickets/${row.ticketId}`,
|
||||
occurredAt: row.createdAt?.toISOString() ?? null,
|
||||
})),
|
||||
...friends.map((row) => ({
|
||||
key: `friends:${row.id}`,
|
||||
category: "friends" as const,
|
||||
title: row.username,
|
||||
href: "/friends",
|
||||
occurredAt: null,
|
||||
})),
|
||||
...events.map((row) => ({
|
||||
key: `events:${row.id}:${row.registrationId}:${row.startsAt.getTime()}`,
|
||||
category: "events" as const,
|
||||
title: row.title,
|
||||
href: `/events?mine=1`,
|
||||
occurredAt: row.startsAt.toISOString(),
|
||||
})),
|
||||
];
|
||||
return items;
|
||||
},
|
||||
async reads(userId, keys) {
|
||||
if (!keys.length) return [];
|
||||
return (
|
||||
await db
|
||||
.select({ key: NotificationRead.eventKey })
|
||||
.from(NotificationRead)
|
||||
.where(
|
||||
and(
|
||||
eq(NotificationRead.userId, userId),
|
||||
inArray(NotificationRead.eventKey, keys),
|
||||
),
|
||||
)
|
||||
).map((row) => row.key);
|
||||
},
|
||||
async mark(userId, key) {
|
||||
await db
|
||||
.insert(NotificationRead)
|
||||
.values({ userId, eventKey: key })
|
||||
.onDuplicateKeyUpdate({ set: { eventKey: key } });
|
||||
},
|
||||
async preferences(userId) {
|
||||
const [row] = await db
|
||||
.select({
|
||||
support: NotificationPreference.support,
|
||||
friends: NotificationPreference.friends,
|
||||
events: NotificationPreference.events,
|
||||
})
|
||||
.from(NotificationPreference)
|
||||
.where(eq(NotificationPreference.userId, userId))
|
||||
.limit(1);
|
||||
return row ?? { support: true, friends: true, events: true };
|
||||
},
|
||||
async savePreferences(userId, preferences) {
|
||||
await db
|
||||
.insert(NotificationPreference)
|
||||
.values({ userId, ...preferences })
|
||||
.onDuplicateKeyUpdate({ set: preferences });
|
||||
},
|
||||
};
|
||||
export const notifications = createNotificationService(notificationRepository);
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createNotificationService,
|
||||
type NotificationRepository,
|
||||
type Preferences,
|
||||
} from "./service";
|
||||
|
||||
const item = {
|
||||
key: "support:4",
|
||||
category: "support" as const,
|
||||
title: "Ticket",
|
||||
href: "/help/tickets/2",
|
||||
occurredAt: null,
|
||||
};
|
||||
function fixture() {
|
||||
const reads = new Set<string>();
|
||||
const preferences = new Map<number, Preferences>();
|
||||
let items = [item];
|
||||
const repository: NotificationRepository = {
|
||||
source: async (uid) => (uid === 1 ? items : []),
|
||||
reads: async (uid) =>
|
||||
[...reads]
|
||||
.filter((k) => k.startsWith(`${uid}/`))
|
||||
.map((k) => k.split("/")[1]),
|
||||
mark: async (uid, key) => {
|
||||
reads.add(`${uid}/${key}`);
|
||||
},
|
||||
preferences: async (uid) =>
|
||||
preferences.get(uid) ?? { support: true, friends: true, events: true },
|
||||
savePreferences: async (uid, value) => {
|
||||
preferences.set(uid, value);
|
||||
},
|
||||
};
|
||||
return {
|
||||
service: createNotificationService(repository),
|
||||
reads,
|
||||
reply: () => {
|
||||
items = [{ ...item, key: "support:5" }];
|
||||
},
|
||||
many: () => {
|
||||
items = Array.from({ length: 45 }, (_, i) => ({
|
||||
...item,
|
||||
key: `support:${i}`,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
describe("notification account isolation", () => {
|
||||
it("rejects invalid account identifiers for every operation", async () => {
|
||||
const { service } = fixture();
|
||||
for (const uid of [0, -1, Number.NaN, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
await expect(service.feed(uid, 1)).rejects.toThrow();
|
||||
await expect(service.mark(uid, item.key)).rejects.toThrow();
|
||||
await expect(
|
||||
service.preferences(uid, {
|
||||
support: true,
|
||||
friends: true,
|
||||
events: true,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
it("cannot read or mark another account's notification", async () => {
|
||||
const f = fixture();
|
||||
expect((await f.service.feed(2, 1)).items).toEqual([]);
|
||||
expect(await f.service.mark(2, item.key)).toBe(false);
|
||||
expect(f.reads.size).toBe(0);
|
||||
});
|
||||
it("persists idempotent reads but a new reply remains unread", async () => {
|
||||
const f = fixture();
|
||||
await f.service.mark(1, item.key);
|
||||
await f.service.mark(1, item.key);
|
||||
expect(f.reads.size).toBe(1);
|
||||
expect((await f.service.feed(1, 1)).items[0].read).toBe(true);
|
||||
f.reply();
|
||||
expect((await f.service.feed(1, 1)).items[0].read).toBe(false);
|
||||
});
|
||||
it("isolates preferences and retains reads across disabled categories", async () => {
|
||||
const f = fixture();
|
||||
await f.service.mark(1, item.key);
|
||||
await f.service.preferences(1, {
|
||||
support: false,
|
||||
friends: true,
|
||||
events: true,
|
||||
});
|
||||
const hidden = await f.service.feed(1, 1);
|
||||
expect(hidden.items).toEqual([]);
|
||||
expect(hidden.unread).toBe(0);
|
||||
expect((await f.service.feed(2, 1)).preferences.support).toBe(true);
|
||||
await f.service.preferences(1, {
|
||||
support: true,
|
||||
friends: true,
|
||||
events: true,
|
||||
});
|
||||
expect((await f.service.feed(1, 1)).items[0].read).toBe(true);
|
||||
});
|
||||
it("paginates the feed with total unread count and clamps hostile page numbers", async () => {
|
||||
const f = fixture();
|
||||
f.many();
|
||||
expect((await f.service.feed(1, Number.POSITIVE_INFINITY)).page).toBe(1);
|
||||
const last = await f.service.feed(1, 999);
|
||||
expect(last.page).toBe(3);
|
||||
expect(last.items).toHaveLength(5);
|
||||
expect(last.unread).toBe(45);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
export type Category = "support" | "friends" | "events";
|
||||
export type Preferences = Record<Category, boolean>;
|
||||
export type Notification = {
|
||||
key: string;
|
||||
category: Category;
|
||||
title: string;
|
||||
href: string;
|
||||
occurredAt: string | null;
|
||||
};
|
||||
export interface NotificationRepository {
|
||||
source(userId: number): Promise<Notification[]>;
|
||||
reads(userId: number, keys: string[]): Promise<string[]>;
|
||||
mark(userId: number, key: string): Promise<void>;
|
||||
preferences(userId: number): Promise<Preferences>;
|
||||
savePreferences(userId: number, preferences: Preferences): Promise<void>;
|
||||
}
|
||||
function account(userId: number) {
|
||||
if (!Number.isSafeInteger(userId) || userId < 1)
|
||||
throw new Error("Invalid user");
|
||||
}
|
||||
export function createNotificationService(repo: NotificationRepository) {
|
||||
return {
|
||||
async feed(userId: number, requestedPage: number) {
|
||||
account(userId);
|
||||
const [source, preferences] = await Promise.all([
|
||||
repo.source(userId),
|
||||
repo.preferences(userId),
|
||||
]);
|
||||
const reads = await repo.reads(
|
||||
userId,
|
||||
source.map((item) => item.key),
|
||||
);
|
||||
const read = new Set(reads);
|
||||
const items = source
|
||||
.filter((item) => preferences[item.category])
|
||||
.map((item) => ({ ...item, read: read.has(item.key) }));
|
||||
const pages = Math.max(1, Math.ceil(items.length / 20));
|
||||
const page = Number.isSafeInteger(requestedPage)
|
||||
? Math.max(1, Math.min(pages, requestedPage))
|
||||
: 1;
|
||||
return {
|
||||
items: items.slice((page - 1) * 20, page * 20),
|
||||
page,
|
||||
pages,
|
||||
unread: items.filter((item) => !item.read).length,
|
||||
preferences,
|
||||
};
|
||||
},
|
||||
async mark(userId: number, key: string) {
|
||||
account(userId);
|
||||
if (!(await repo.source(userId)).some((item) => item.key === key))
|
||||
return false;
|
||||
await repo.mark(userId, key);
|
||||
return true;
|
||||
},
|
||||
async preferences(userId: number, preferences: Preferences) {
|
||||
account(userId);
|
||||
await repo.savePreferences(userId, preferences);
|
||||
},
|
||||
};
|
||||
}
|
||||
export type NotificationFeed = Awaited<
|
||||
ReturnType<ReturnType<typeof createNotificationService>["feed"]>
|
||||
>;
|
||||
@@ -4820,6 +4820,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
|
||||
@@ -4816,6 +4816,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -4816,6 +4816,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -4816,6 +4816,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -4816,6 +4816,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -4816,6 +4816,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -4826,6 +4826,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
|
||||
@@ -4816,6 +4816,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -4820,6 +4820,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
|
||||
@@ -4816,6 +4816,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -4816,6 +4816,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -4816,6 +4816,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -4825,6 +4825,30 @@
|
||||
"previous": "Precedente",
|
||||
"next": "Successiva",
|
||||
"pageSummary": "Pagina {page} di {pages} · {total} risultati"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifiche",
|
||||
"subtitle": "Le risposte dell’assistenza, le richieste di amicizia e gli eventi a cui sei iscritto.",
|
||||
"scope": "Qui trovi fino a 100 risposte recenti dell’assistenza, 100 richieste di amicizia in sospeso e 100 eventi futuri a cui sei iscritto.",
|
||||
"loading": "Caricamento notifiche…",
|
||||
"error": "Le notifiche sono temporaneamente non disponibili.",
|
||||
"retry": "Riprova",
|
||||
"preferences": "Preferenze delle notifiche",
|
||||
"preferencesHint": "Scegli le categorie da mostrare. Queste impostazioni non inviano email e non modificano le iscrizioni.",
|
||||
"support": "Risposte dell’assistenza",
|
||||
"friends": "Richieste di amicizia",
|
||||
"events": "Prossimi eventi",
|
||||
"unreadCount": "{count, plural, =0 {Nessuna notifica da leggere} one {# notifica da leggere} other {# notifiche da leggere}}",
|
||||
"saveError": "Impossibile salvare la modifica. Riprova.",
|
||||
"saved": "Modifica salvata.",
|
||||
"empty": "Non ci sono notifiche nelle categorie abilitate.",
|
||||
"read": "Letta",
|
||||
"unread": "Da leggere",
|
||||
"markRead": "Segna come letta",
|
||||
"pagination": "Pagine delle notifiche",
|
||||
"previous": "Precedente",
|
||||
"next": "Successiva",
|
||||
"page": "Pagina {page} di {pages}"
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
|
||||
@@ -4820,6 +4820,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
|
||||
@@ -4826,6 +4826,30 @@
|
||||
"description": "Try again later or contact an administrator."
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Meldingen",
|
||||
"subtitle": "Je supportreacties, vriendschapsverzoeken en evenementen waarvoor je bent ingeschreven.",
|
||||
"scope": "Hier zie je maximaal 100 recente supportreacties, 100 openstaande vriendschapsverzoeken en 100 komende evenementen waarvoor je bent ingeschreven.",
|
||||
"loading": "Meldingen laden…",
|
||||
"error": "Meldingen zijn tijdelijk niet beschikbaar.",
|
||||
"retry": "Opnieuw proberen",
|
||||
"preferences": "Meldingsvoorkeuren",
|
||||
"preferencesHint": "Kies welke categorieën hier verschijnen. Deze instellingen versturen geen e-mails en wijzigen je inschrijvingen niet.",
|
||||
"support": "Supportreacties",
|
||||
"friends": "Vriendschapsverzoeken",
|
||||
"events": "Komende evenementen",
|
||||
"unreadCount": "{count, plural, =0 {Geen ongelezen meldingen} one {# ongelezen melding} other {# ongelezen meldingen}}",
|
||||
"saveError": "De wijziging kon niet worden opgeslagen. Probeer het opnieuw.",
|
||||
"saved": "Wijziging opgeslagen.",
|
||||
"empty": "Geen meldingen in je ingeschakelde categorieën.",
|
||||
"read": "Gelezen",
|
||||
"unread": "Ongelezen",
|
||||
"markRead": "Markeren als gelezen",
|
||||
"pagination": "Meldingspagina’s",
|
||||
"previous": "Vorige",
|
||||
"next": "Volgende",
|
||||
"page": "Pagina {page} van {pages}"
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
|
||||
@@ -4816,6 +4816,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -4816,6 +4816,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -4816,6 +4816,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -4816,6 +4816,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -4814,6 +4814,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -4816,6 +4816,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -4816,6 +4816,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -4816,6 +4816,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -4816,6 +4816,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -4816,6 +4816,30 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageSummary": "Page {page} of {pages} · {total} results"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Your support replies, friend requests and registered events.",
|
||||
"scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.",
|
||||
"loading": "Loading notifications…",
|
||||
"error": "Notifications are temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"preferences": "Notification preferences",
|
||||
"preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.",
|
||||
"support": "Support replies",
|
||||
"friends": "Friend requests",
|
||||
"events": "Upcoming events",
|
||||
"unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"saved": "Change saved.",
|
||||
"empty": "No notifications in your enabled categories.",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"markRead": "Mark as read",
|
||||
"pagination": "Notification pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page} of {pages}"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
Reference in new issue
Block a user