Skip to content

Commit c2b599e

Browse files
committed
feat(newsroom): add new post notifications
1 parent a74b033 commit c2b599e

10 files changed

Lines changed: 449 additions & 4 deletions

File tree

.changeset/bright-posts-wink.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"skycrypt-frontend": minor
3+
---
4+
5+
Add local newsroom notifications for new CMS posts.

src/context/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from "./createContext.svelte";
22
export * from "./favorites.svelte";
33
export * from "./internal.svelte";
4+
export * from "./newsroom-notifications.svelte";
45
export * from "./packs.svelte";
56
export * from "./preferences.svelte";
67
export * from "./searches.svelte";
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import type { Post } from "$types";
2+
import { flushSync, untrack } from "svelte";
3+
import { afterEach, beforeEach, describe, it } from "vitest";
4+
import { NewsroomNotificationsContext } from "./newsroom-notifications.svelte";
5+
6+
const post = (id: string, publishedAt: string | null = `2026-01-${id.padStart(2, "0")}T00:00:00.000Z`): Post => ({
7+
id,
8+
title: `Post ${id}`,
9+
slug: `post-${id}`,
10+
excerpt: null,
11+
type: "news",
12+
tags: null,
13+
featured: false,
14+
heroImage: null,
15+
body: null,
16+
publishedAt,
17+
author: "author",
18+
updatedAt: publishedAt ?? "2026-01-01T00:00:00.000Z",
19+
createdAt: publishedAt ?? "2026-01-01T00:00:00.000Z"
20+
});
21+
22+
describe("NewsroomNotificationsContext", () => {
23+
beforeEach(() => {
24+
localStorage.clear();
25+
});
26+
27+
afterEach(() => {
28+
localStorage.clear();
29+
});
30+
31+
it("starts with zero unread posts", ({ expect }) => {
32+
const cleanup = $effect.root(() => {
33+
const notifications = new NewsroomNotificationsContext();
34+
35+
untrack(() => {
36+
expect(notifications.current.seenPostIds).toEqual([]);
37+
expect(notifications.current.lastSeenPublishedAt).toBeNull();
38+
expect(notifications.unreadCount).toBe(0);
39+
expect(notifications.newestUnseen).toBeNull();
40+
});
41+
});
42+
43+
cleanup();
44+
});
45+
46+
it("marks latest posts as unseen until they are seen", ({ expect }) => {
47+
const cleanup = $effect.root(() => {
48+
const notifications = new NewsroomNotificationsContext();
49+
const posts = [post("1"), post("2")];
50+
51+
untrack(() => {
52+
notifications.setLatestPosts(posts);
53+
expect(notifications.unreadCount).toBe(2);
54+
expect(notifications.unseenPosts.map((item) => item.id)).toEqual(["2", "1"]);
55+
});
56+
});
57+
58+
cleanup();
59+
});
60+
61+
it("markPostSeen removes exactly one post from unread state", ({ expect }) => {
62+
const cleanup = $effect.root(() => {
63+
const notifications = new NewsroomNotificationsContext();
64+
const posts = [post("1"), post("2")];
65+
66+
untrack(() => {
67+
notifications.setLatestPosts(posts);
68+
notifications.markPostSeen(posts[0]);
69+
flushSync();
70+
71+
expect(notifications.unreadCount).toBe(1);
72+
expect(notifications.unseenPosts[0].id).toBe("2");
73+
expect(notifications.current.seenPostIds).toEqual(["1"]);
74+
});
75+
});
76+
77+
cleanup();
78+
});
79+
80+
it("markAllSeen clears all currently tracked posts", ({ expect }) => {
81+
const cleanup = $effect.root(() => {
82+
const notifications = new NewsroomNotificationsContext();
83+
const posts = [post("1"), post("2")];
84+
85+
untrack(() => {
86+
notifications.setLatestPosts(posts);
87+
notifications.markAllSeen();
88+
flushSync();
89+
90+
expect(notifications.unreadCount).toBe(0);
91+
expect(notifications.current.seenPostIds).toEqual(["1", "2"]);
92+
});
93+
});
94+
95+
cleanup();
96+
});
97+
98+
it("de-duplicates seen ids", ({ expect }) => {
99+
const cleanup = $effect.root(() => {
100+
const notifications = new NewsroomNotificationsContext();
101+
const item = post("1");
102+
103+
untrack(() => {
104+
notifications.markPostSeen(item);
105+
notifications.markPostSeen(item);
106+
flushSync();
107+
108+
expect(notifications.current.seenPostIds).toEqual(["1"]);
109+
});
110+
});
111+
112+
cleanup();
113+
});
114+
115+
it("bounds seen ids to 100", ({ expect }) => {
116+
const cleanup = $effect.root(() => {
117+
const notifications = new NewsroomNotificationsContext();
118+
const posts = Array.from({ length: 101 }, (_, index) => post(String(index + 1)));
119+
120+
untrack(() => {
121+
notifications.markAllSeen(posts);
122+
flushSync();
123+
124+
expect(notifications.current.seenPostIds).toHaveLength(100);
125+
expect(notifications.current.seenPostIds[0]).toBe("2");
126+
expect(notifications.current.seenPostIds.at(-1)).toBe("101");
127+
});
128+
});
129+
130+
cleanup();
131+
});
132+
133+
it("shows newer posts after previously dismissing current latest posts", ({ expect }) => {
134+
const cleanup = $effect.root(() => {
135+
const notifications = new NewsroomNotificationsContext();
136+
const firstPosts = [post("1"), post("2")];
137+
const newerPost = post("3", "2026-02-01T00:00:00.000Z");
138+
139+
untrack(() => {
140+
notifications.setLatestPosts(firstPosts);
141+
notifications.markAllSeen();
142+
notifications.setLatestPosts([newerPost, ...firstPosts]);
143+
144+
expect(notifications.unreadCount).toBe(1);
145+
expect(notifications.newestUnseen?.id).toBe("3");
146+
});
147+
});
148+
149+
cleanup();
150+
});
151+
});
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import type { Post } from "$types";
2+
import { PersistedState } from "runed";
3+
import { createContext } from "svelte";
4+
5+
export interface SeenNewsroomPost {
6+
id: string;
7+
slug: string;
8+
publishedAt: string | null;
9+
}
10+
11+
export interface NewsroomNotificationsData {
12+
seenPostIds: string[];
13+
lastSeenPublishedAt: string | null;
14+
}
15+
16+
const STORAGE_KEY = "skycryptNewsroomNotifications";
17+
const MAX_SEEN_POST_IDS = 100;
18+
19+
const defaultData = (): NewsroomNotificationsData => ({
20+
seenPostIds: [],
21+
lastSeenPublishedAt: null
22+
});
23+
24+
const isValidDate = (value: string | null | undefined): value is string => {
25+
if (!value) return false;
26+
return Number.isFinite(Date.parse(value));
27+
};
28+
29+
const newestDate = (current: string | null, next: string | null | undefined): string | null => {
30+
if (!isValidDate(next)) return current;
31+
if (!isValidDate(current)) return next;
32+
return Date.parse(next) > Date.parse(current) ? next : current;
33+
};
34+
35+
const sanitizeData = (value: unknown): NewsroomNotificationsData => {
36+
if (!value || typeof value !== "object") return defaultData();
37+
38+
const data = value as Partial<NewsroomNotificationsData>;
39+
const seenPostIds = Array.isArray(data.seenPostIds) ? data.seenPostIds.filter((id): id is string => typeof id === "string" && id.length > 0) : [];
40+
return {
41+
seenPostIds: seenPostIds.filter((id, index) => seenPostIds.indexOf(id) === index).slice(-MAX_SEEN_POST_IDS),
42+
lastSeenPublishedAt: isValidDate(data.lastSeenPublishedAt) ? data.lastSeenPublishedAt : null
43+
};
44+
};
45+
46+
export class NewsroomNotificationsContext {
47+
#data = new PersistedState<NewsroomNotificationsData>(STORAGE_KEY, defaultData());
48+
#latestPosts: Post[] = $state.raw([]);
49+
50+
get current() {
51+
return sanitizeData(this.#data.current);
52+
}
53+
54+
set current(value: NewsroomNotificationsData) {
55+
this.#data.current = sanitizeData(value);
56+
}
57+
58+
get unseenPosts() {
59+
return this.getUnseenPosts();
60+
}
61+
62+
get unreadCount() {
63+
return this.unseenPosts.length;
64+
}
65+
66+
get newestUnseen() {
67+
return this.unseenPosts[0] ?? null;
68+
}
69+
70+
getUnseenPosts(posts: Post[] = this.#latestPosts) {
71+
const seen = this.current.seenPostIds;
72+
return posts
73+
.filter((post) => !seen.includes(post.id))
74+
.map((post, index) => ({ post, index }))
75+
.sort((a, b) => {
76+
const aTime = isValidDate(a.post.publishedAt) ? Date.parse(a.post.publishedAt) : null;
77+
const bTime = isValidDate(b.post.publishedAt) ? Date.parse(b.post.publishedAt) : null;
78+
79+
if (aTime !== null && bTime !== null && aTime !== bTime) return bTime - aTime;
80+
if (aTime !== null && bTime === null) return -1;
81+
if (aTime === null && bTime !== null) return 1;
82+
return a.index - b.index;
83+
})
84+
.map(({ post }) => post);
85+
}
86+
87+
getUnreadCount(posts: Post[] = this.#latestPosts) {
88+
return this.getUnseenPosts(posts).length;
89+
}
90+
91+
getNewestUnseen(posts: Post[] = this.#latestPosts) {
92+
return this.getUnseenPosts(posts)[0] ?? null;
93+
}
94+
95+
setLatestPosts(posts: Post[]) {
96+
this.#latestPosts = posts;
97+
}
98+
99+
markPostSeen(post: Pick<Post, "id" | "slug" | "publishedAt">) {
100+
const current = this.current;
101+
const seenPostIds = current.seenPostIds.includes(post.id) ? current.seenPostIds : [...current.seenPostIds, post.id];
102+
103+
this.current = {
104+
seenPostIds: seenPostIds.slice(-MAX_SEEN_POST_IDS),
105+
lastSeenPublishedAt: newestDate(current.lastSeenPublishedAt, post.publishedAt)
106+
};
107+
}
108+
109+
markAllSeen(posts: Pick<Post, "id" | "slug" | "publishedAt">[] = this.#latestPosts) {
110+
let lastSeenPublishedAt = this.current.lastSeenPublishedAt;
111+
const seenPostIds = [...this.current.seenPostIds];
112+
113+
for (const post of posts) {
114+
if (!seenPostIds.includes(post.id)) seenPostIds.push(post.id);
115+
lastSeenPublishedAt = newestDate(lastSeenPublishedAt, post.publishedAt);
116+
}
117+
118+
this.current = {
119+
seenPostIds: seenPostIds.slice(-MAX_SEEN_POST_IDS),
120+
lastSeenPublishedAt
121+
};
122+
}
123+
}
124+
125+
const [getNewsroomNotifications, setNewsroomNotifications] = createContext<NewsroomNotificationsContext>();
126+
127+
function initNewsroomNotifications() {
128+
const notifications = new NewsroomNotificationsContext();
129+
setNewsroomNotifications(notifications);
130+
return notifications;
131+
}
132+
133+
export { getNewsroomNotifications, initNewsroomNotifications };
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<script lang="ts">
2+
import { getNewsroomNotifications } from "$ctx";
3+
import type { Post } from "$types";
4+
import { toast } from "svelte-sonner";
5+
import NewPostsToast from "./NewPostsToast.svelte";
6+
7+
interface Props {
8+
posts: Post[];
9+
}
10+
11+
const { posts }: Props = $props();
12+
13+
const notifications = getNewsroomNotifications();
14+
const unseenPosts = $derived(notifications.getUnseenPosts(posts));
15+
const unreadCount = $derived(unseenPosts.length);
16+
const newestUnseen = $derived(unseenPosts[0] ?? null);
17+
const unreadSignature = $derived(unseenPosts.map((post) => post.id).join("|"));
18+
19+
let toastId: string | number | undefined;
20+
let shownSignature = "";
21+
22+
$effect(() => {
23+
if (!newestUnseen || unreadCount === 0) {
24+
if (toastId !== undefined) {
25+
toast.dismiss(toastId);
26+
toastId = undefined;
27+
}
28+
shownSignature = "";
29+
return;
30+
}
31+
32+
if (unreadSignature === shownSignature) return;
33+
34+
shownSignature = unreadSignature;
35+
toastId = toast.custom(NewPostsToast, {
36+
id: "newsroom-new-posts",
37+
position: "bottom-right",
38+
duration: Number.POSITIVE_INFINITY,
39+
dismissible: true,
40+
componentProps: {
41+
posts,
42+
newestUnseen
43+
},
44+
onDismiss: () => {
45+
notifications.markAllSeen(posts);
46+
toastId = undefined;
47+
shownSignature = "";
48+
}
49+
});
50+
});
51+
</script>

0 commit comments

Comments
 (0)