From 7e7e9030e3d8b6eebfaf58d14d851635e291ba95 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 6 Aug 2026 13:53:37 +0200 Subject: [PATCH 01/56] fix: support YouTube Shorts path redirects --- apps/web/src/lib/shorts-route.ts | 5 +++++ apps/web/src/routeTree.gen.ts | 21 +++++++++++++++++++++ apps/web/src/routes/shorts_.$videoId.tsx | 10 ++++++++++ apps/web/tests/shorts-route.test.ts | 6 ++++++ 4 files changed, 42 insertions(+) create mode 100644 apps/web/src/routes/shorts_.$videoId.tsx diff --git a/apps/web/src/lib/shorts-route.ts b/apps/web/src/lib/shorts-route.ts index 7db0efa..2ad7b4c 100644 --- a/apps/web/src/lib/shorts-route.ts +++ b/apps/web/src/lib/shorts-route.ts @@ -20,6 +20,11 @@ export function shortsRouteKey(value: string): string { return resolveShortsRouteTarget(value)?.publicParam ?? value.trim(); } +export function shortsPathRedirectSearch(value: string): { v: string } | null { + const target = resolveShortsRouteTarget(value); + return target ? { v: target.publicParam } : null; +} + export function createShortsRouteEntry(value: string | undefined): VideoStream | null { const target = resolveShortsRouteTarget(value); if (!target) return null; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 5d8a45b..cfe56bd 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -33,6 +33,7 @@ import { Route as AdminConsoleRouteImport } from './routes/admin-console' import { Route as IndexRouteImport } from './routes/index' import { Route as ImportIndexRouteImport } from './routes/import/index' import { Route as SubscriptionsChannelsRouteImport } from './routes/subscriptions_.channels' +import { Route as ShortsVideoIdRouteImport } from './routes/shorts_.$videoId' import { Route as PlaylistsIdRouteImport } from './routes/playlists_.$id' import { Route as ImportYoutubeRouteImport } from './routes/import/youtube' import { Route as ImportPipepipeRouteImport } from './routes/import/pipepipe' @@ -160,6 +161,11 @@ const SubscriptionsChannelsRoute = SubscriptionsChannelsRouteImport.update({ path: '/subscriptions/channels', getParentRoute: () => rootRouteImport, } as any) +const ShortsVideoIdRoute = ShortsVideoIdRouteImport.update({ + id: '/shorts_/$videoId', + path: '/shorts/$videoId', + getParentRoute: () => rootRouteImport, +} as any) const PlaylistsIdRoute = PlaylistsIdRouteImport.update({ id: '/playlists_/$id', path: '/playlists/$id', @@ -219,6 +225,7 @@ export interface FileRoutesByFullPath { '/import/pipepipe': typeof ImportPipepipeRoute '/import/youtube': typeof ImportYoutubeRoute '/playlists/$id': typeof PlaylistsIdRoute + '/shorts/$videoId': typeof ShortsVideoIdRoute '/subscriptions/channels': typeof SubscriptionsChannelsRoute '/import/': typeof ImportIndexRoute '/auth/oidc/callback': typeof AuthOidcCallbackRoute @@ -250,6 +257,7 @@ export interface FileRoutesByTo { '/import/pipepipe': typeof ImportPipepipeRoute '/import/youtube': typeof ImportYoutubeRoute '/playlists/$id': typeof PlaylistsIdRoute + '/shorts/$videoId': typeof ShortsVideoIdRoute '/subscriptions/channels': typeof SubscriptionsChannelsRoute '/import': typeof ImportIndexRoute '/auth/oidc/callback': typeof AuthOidcCallbackRoute @@ -283,6 +291,7 @@ export interface FileRoutesById { '/import/pipepipe': typeof ImportPipepipeRoute '/import/youtube': typeof ImportYoutubeRoute '/playlists_/$id': typeof PlaylistsIdRoute + '/shorts_/$videoId': typeof ShortsVideoIdRoute '/subscriptions_/channels': typeof SubscriptionsChannelsRoute '/import/': typeof ImportIndexRoute '/auth/oidc/callback': typeof AuthOidcCallbackRoute @@ -317,6 +326,7 @@ export interface FileRouteTypes { | '/import/pipepipe' | '/import/youtube' | '/playlists/$id' + | '/shorts/$videoId' | '/subscriptions/channels' | '/import/' | '/auth/oidc/callback' @@ -348,6 +358,7 @@ export interface FileRouteTypes { | '/import/pipepipe' | '/import/youtube' | '/playlists/$id' + | '/shorts/$videoId' | '/subscriptions/channels' | '/import' | '/auth/oidc/callback' @@ -380,6 +391,7 @@ export interface FileRouteTypes { | '/import/pipepipe' | '/import/youtube' | '/playlists_/$id' + | '/shorts_/$videoId' | '/subscriptions_/channels' | '/import/' | '/auth/oidc/callback' @@ -411,6 +423,7 @@ export interface RootRouteChildren { ChannelChannelIdRoute: typeof ChannelChannelIdRoute EmbedVideoIdRoute: typeof EmbedVideoIdRoute PlaylistsIdRoute: typeof PlaylistsIdRoute + ShortsVideoIdRoute: typeof ShortsVideoIdRoute SubscriptionsChannelsRoute: typeof SubscriptionsChannelsRoute AuthOidcCallbackRoute: typeof AuthOidcCallbackRoute } @@ -585,6 +598,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SubscriptionsChannelsRouteImport parentRoute: typeof rootRouteImport } + '/shorts_/$videoId': { + id: '/shorts_/$videoId' + path: '/shorts/$videoId' + fullPath: '/shorts/$videoId' + preLoaderRoute: typeof ShortsVideoIdRouteImport + parentRoute: typeof rootRouteImport + } '/playlists_/$id': { id: '/playlists_/$id' path: '/playlists/$id' @@ -671,6 +691,7 @@ const rootRouteChildren: RootRouteChildren = { ChannelChannelIdRoute: ChannelChannelIdRoute, EmbedVideoIdRoute: EmbedVideoIdRoute, PlaylistsIdRoute: PlaylistsIdRoute, + ShortsVideoIdRoute: ShortsVideoIdRoute, SubscriptionsChannelsRoute: SubscriptionsChannelsRoute, AuthOidcCallbackRoute: AuthOidcCallbackRoute, } diff --git a/apps/web/src/routes/shorts_.$videoId.tsx b/apps/web/src/routes/shorts_.$videoId.tsx new file mode 100644 index 0000000..d1104d8 --- /dev/null +++ b/apps/web/src/routes/shorts_.$videoId.tsx @@ -0,0 +1,10 @@ +import { createFileRoute, notFound, redirect } from "@tanstack/react-router"; +import { shortsPathRedirectSearch } from "../lib/shorts-route"; + +export const Route = createFileRoute("/shorts_/$videoId")({ + beforeLoad: ({ params }) => { + const search = shortsPathRedirectSearch(params.videoId); + if (!search) throw notFound(); + throw redirect({ to: "/shorts", search, replace: true }); + }, +}); diff --git a/apps/web/tests/shorts-route.test.ts b/apps/web/tests/shorts-route.test.ts index 42536ea..449e335 100644 --- a/apps/web/tests/shorts-route.test.ts +++ b/apps/web/tests/shorts-route.test.ts @@ -3,6 +3,7 @@ import { createShortsRouteEntry, mergeShortsRouteEntries, resolveShortsRouteTarget, + shortsPathRedirectSearch, toPublicShortsUrl, } from "../src/lib/shorts-route"; import type { VideoStream } from "../src/types/stream"; @@ -31,6 +32,11 @@ test("canonicalizes a YouTube Shorts URL to a public video id", () => { ); }); +test("redirects a Shorts path id to canonical route search", () => { + expect(shortsPathRedirectSearch("2-J9d2VbA6o")).toEqual({ v: "2-J9d2VbA6o" }); + expect(shortsPathRedirectSearch("invalid")).toBeNull(); +}); + test("keeps direct route entries stable and replaces placeholders with feed metadata", () => { const direct = createShortsRouteEntry("2-J9d2VbA6o"); if (!direct) throw new Error("Expected a direct route entry"); From 4d983a73cb73e6e819f8a2d2c298881f4ffeebdd Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 6 Aug 2026 10:58:29 +0200 Subject: [PATCH 02/56] chore: prepare frontend 1.4.0 --- apps/web/package.json | 2 +- bun.lock | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 1ba2f80..c3137f8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@typetype/web", "private": true, - "version": "1.3.1", + "version": "1.4.0", "type": "module", "scripts": { "dev": "vite", diff --git a/bun.lock b/bun.lock index 43d5ba2..7e65590 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ }, "apps/web": { "name": "@typetype/web", - "version": "1.3.1", + "version": "1.4.0", "dependencies": { "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", diff --git a/package.json b/package.json index 610b9bb..a7776f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/frontend", - "version": "1.3.1", + "version": "1.4.0", "devDependencies": { "@biomejs/biome": "^2.5.6", "knip": "^6.29.0", From 88ed482c115bb971972ec8b7be7a5fda66e2396e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 7 Aug 2026 09:56:02 +0200 Subject: [PATCH 03/56] feat: add notification toast preview --- .../components/notification-toast-preview.tsx | 88 +++++++++++ apps/web/src/routeTree.gen.ts | 21 +++ apps/web/src/routes/notification-preview.tsx | 148 ++++++++++++++++++ .../src/styles/notification-toast-preview.css | 71 +++++++++ 4 files changed, 328 insertions(+) create mode 100644 apps/web/src/components/notification-toast-preview.tsx create mode 100644 apps/web/src/routes/notification-preview.tsx create mode 100644 apps/web/src/styles/notification-toast-preview.css diff --git a/apps/web/src/components/notification-toast-preview.tsx b/apps/web/src/components/notification-toast-preview.tsx new file mode 100644 index 0000000..e42c10f --- /dev/null +++ b/apps/web/src/components/notification-toast-preview.tsx @@ -0,0 +1,88 @@ +import { BellRing, X } from "lucide-react"; + +type Props = { + side: "left" | "right"; + variant: "single" | "grouped"; + animationKey: number; + onClose: () => void; +}; + +export function NotificationToastPreview({ side, variant, animationKey, onClose }: Props) { + const sideClass = side === "left" ? "notification-toast-left" : "notification-toast-right"; + + return ( + + ); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index cfe56bd..c71d593 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as PrivacyRouteImport } from './routes/privacy' import { Route as PodcastsRouteImport } from './routes/podcasts' import { Route as PlaylistsRouteImport } from './routes/playlists' import { Route as PlaylistRouteImport } from './routes/playlist' +import { Route as NotificationPreviewRouteImport } from './routes/notification-preview' import { Route as LoginRouteImport } from './routes/login' import { Route as ImportRouteImport } from './routes/import' import { Route as HistoryRouteImport } from './routes/history' @@ -111,6 +112,11 @@ const PlaylistRoute = PlaylistRouteImport.update({ path: '/playlist', getParentRoute: () => rootRouteImport, } as any) +const NotificationPreviewRoute = NotificationPreviewRouteImport.update({ + id: '/notification-preview', + path: '/notification-preview', + getParentRoute: () => rootRouteImport, +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', @@ -206,6 +212,7 @@ export interface FileRoutesByFullPath { '/history': typeof HistoryRoute '/import': typeof ImportRouteWithChildren '/login': typeof LoginRoute + '/notification-preview': typeof NotificationPreviewRoute '/playlist': typeof PlaylistRoute '/playlists': typeof PlaylistsRoute '/podcasts': typeof PodcastsRoute @@ -238,6 +245,7 @@ export interface FileRoutesByTo { '/hide-everything': typeof HideEverythingRoute '/history': typeof HistoryRoute '/login': typeof LoginRoute + '/notification-preview': typeof NotificationPreviewRoute '/playlist': typeof PlaylistRoute '/playlists': typeof PlaylistsRoute '/podcasts': typeof PodcastsRoute @@ -272,6 +280,7 @@ export interface FileRoutesById { '/history': typeof HistoryRoute '/import': typeof ImportRouteWithChildren '/login': typeof LoginRoute + '/notification-preview': typeof NotificationPreviewRoute '/playlist': typeof PlaylistRoute '/playlists': typeof PlaylistsRoute '/podcasts': typeof PodcastsRoute @@ -307,6 +316,7 @@ export interface FileRouteTypes { | '/history' | '/import' | '/login' + | '/notification-preview' | '/playlist' | '/playlists' | '/podcasts' @@ -339,6 +349,7 @@ export interface FileRouteTypes { | '/hide-everything' | '/history' | '/login' + | '/notification-preview' | '/playlist' | '/playlists' | '/podcasts' @@ -372,6 +383,7 @@ export interface FileRouteTypes { | '/history' | '/import' | '/login' + | '/notification-preview' | '/playlist' | '/playlists' | '/podcasts' @@ -406,6 +418,7 @@ export interface RootRouteChildren { HistoryRoute: typeof HistoryRoute ImportRoute: typeof ImportRouteWithChildren LoginRoute: typeof LoginRoute + NotificationPreviewRoute: typeof NotificationPreviewRoute PlaylistRoute: typeof PlaylistRoute PlaylistsRoute: typeof PlaylistsRoute PodcastsRoute: typeof PodcastsRoute @@ -528,6 +541,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PlaylistRouteImport parentRoute: typeof rootRouteImport } + '/notification-preview': { + id: '/notification-preview' + path: '/notification-preview' + fullPath: '/notification-preview' + preLoaderRoute: typeof NotificationPreviewRouteImport + parentRoute: typeof rootRouteImport + } '/login': { id: '/login' path: '/login' @@ -674,6 +694,7 @@ const rootRouteChildren: RootRouteChildren = { HistoryRoute: HistoryRoute, ImportRoute: ImportRouteWithChildren, LoginRoute: LoginRoute, + NotificationPreviewRoute: NotificationPreviewRoute, PlaylistRoute: PlaylistRoute, PlaylistsRoute: PlaylistsRoute, PodcastsRoute: PodcastsRoute, diff --git a/apps/web/src/routes/notification-preview.tsx b/apps/web/src/routes/notification-preview.tsx new file mode 100644 index 0000000..21a9c3a --- /dev/null +++ b/apps/web/src/routes/notification-preview.tsx @@ -0,0 +1,148 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { BellRing, Layers3, PanelLeft, PanelRight, RotateCcw } from "lucide-react"; +import { useState } from "react"; +import { NotificationToastPreview } from "../components/notification-toast-preview"; +import "../styles/notification-toast-preview.css"; + +type Side = "left" | "right"; +type Variant = "single" | "grouped"; + +function PreviewButton({ + active, + label, + icon, + onClick, +}: { + active: boolean; + label: string; + icon: React.ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +function NotificationPreviewPage() { + const [side, setSide] = useState("right"); + const [variant, setVariant] = useState("single"); + const [visible, setVisible] = useState(true); + const [animationKey, setAnimationKey] = useState(0); + + function show(next?: { side?: Side; variant?: Variant }) { + if (next?.side) setSide(next.side); + if (next?.variant) setVariant(next.variant); + setVisible(true); + setAnimationKey((value) => value + 1); + } + + return ( +
+
+
+
+

New upload notification

+

+ Compare the placement and grouped state before connecting the toast to live notifications. +

+
+ +
+
+
+ Entrance +
+
+
+ +
+ Content +
+
+
+ + +
+ +
+
+

Desktop

+

+ Below the navbar, aligned to the selected edge. +

+
+
+

Mobile

+

+ Full available width with safe-area spacing. +

+
+
+

Reduced motion

+

+ Uses a short fade instead of horizontal movement. +

+
+
+
+ + {visible && ( + setVisible(false)} + /> + )} +
+ ); +} + +export const Route = createFileRoute("/notification-preview")({ + component: NotificationPreviewPage, +}); diff --git a/apps/web/src/styles/notification-toast-preview.css b/apps/web/src/styles/notification-toast-preview.css new file mode 100644 index 0000000..cb91eb2 --- /dev/null +++ b/apps/web/src/styles/notification-toast-preview.css @@ -0,0 +1,71 @@ +.notification-toast-preview { + position: fixed; + top: calc(4.25rem + env(safe-area-inset-top, 0px)); + z-index: 60; + width: min(24rem, calc(100vw - 1.5rem)); + overflow: hidden; + border: 1px solid var(--color-zinc-700); + border-radius: 0.5rem; + background: color-mix(in oklab, var(--color-zinc-900) 96%, transparent); + box-shadow: 0 18px 48px rgb(0 0 0 / 28%); + backdrop-filter: blur(16px); + animation-duration: 360ms; + animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); + animation-fill-mode: both; +} + +.notification-toast-left { + left: max(0.75rem, env(safe-area-inset-left, 0px)); + animation-name: notification-toast-from-left; +} + +.notification-toast-right { + right: max(0.75rem, env(safe-area-inset-right, 0px)); + animation-name: notification-toast-from-right; +} + +@keyframes notification-toast-from-left { + from { + opacity: 0; + transform: translateX(calc(-100% - 1.5rem)); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes notification-toast-from-right { + from { + opacity: 0; + transform: translateX(calc(100% + 1.5rem)); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@media (max-width: 639px) { + .notification-toast-preview { + left: max(0.75rem, env(safe-area-inset-left, 0px)); + right: max(0.75rem, env(safe-area-inset-right, 0px)); + width: auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .notification-toast-preview { + animation-name: notification-toast-fade; + animation-duration: 120ms; + } + + @keyframes notification-toast-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +} From 8441f2e8d55e064716861c23040093c58de47eff Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 7 Aug 2026 10:06:11 +0200 Subject: [PATCH 04/56] style: compact notification toast preview --- .../components/notification-toast-preview.tsx | 88 +++++++------------ .../src/styles/notification-toast-preview.css | 16 +--- 2 files changed, 38 insertions(+), 66 deletions(-) diff --git a/apps/web/src/components/notification-toast-preview.tsx b/apps/web/src/components/notification-toast-preview.tsx index e42c10f..3cb78f6 100644 --- a/apps/web/src/components/notification-toast-preview.tsx +++ b/apps/web/src/components/notification-toast-preview.tsx @@ -9,6 +9,7 @@ type Props = { export function NotificationToastPreview({ side, variant, animationKey, onClose }: Props) { const sideClass = side === "left" ? "notification-toast-left" : "notification-toast-right"; + const grouped = variant === "grouped"; return ( ); } diff --git a/apps/web/src/styles/notification-toast-preview.css b/apps/web/src/styles/notification-toast-preview.css index cb91eb2..b9b6f22 100644 --- a/apps/web/src/styles/notification-toast-preview.css +++ b/apps/web/src/styles/notification-toast-preview.css @@ -2,14 +2,14 @@ position: fixed; top: calc(4.25rem + env(safe-area-inset-top, 0px)); z-index: 60; - width: min(24rem, calc(100vw - 1.5rem)); + width: min(21rem, calc(100vw - 1.5rem)); overflow: hidden; border: 1px solid var(--color-zinc-700); border-radius: 0.5rem; background: color-mix(in oklab, var(--color-zinc-900) 96%, transparent); - box-shadow: 0 18px 48px rgb(0 0 0 / 28%); - backdrop-filter: blur(16px); - animation-duration: 360ms; + box-shadow: 0 12px 32px rgb(0 0 0 / 24%); + backdrop-filter: blur(12px); + animation-duration: 300ms; animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); animation-fill-mode: both; } @@ -46,14 +46,6 @@ } } -@media (max-width: 639px) { - .notification-toast-preview { - left: max(0.75rem, env(safe-area-inset-left, 0px)); - right: max(0.75rem, env(safe-area-inset-right, 0px)); - width: auto; - } -} - @media (prefers-reduced-motion: reduce) { .notification-toast-preview { animation-name: notification-toast-fade; From 233b5c8d26b859657820ee52293042f120a7d240 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 7 Aug 2026 10:34:51 +0200 Subject: [PATCH 05/56] feat: track unseen notification uploads --- apps/web/src/lib/notification-toast-cursor.ts | 65 ++++++++++++++ .../tests/notification-toast-cursor.test.ts | 88 +++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 apps/web/src/lib/notification-toast-cursor.ts create mode 100644 apps/web/tests/notification-toast-cursor.test.ts diff --git a/apps/web/src/lib/notification-toast-cursor.ts b/apps/web/src/lib/notification-toast-cursor.ts new file mode 100644 index 0000000..8f393db --- /dev/null +++ b/apps/web/src/lib/notification-toast-cursor.ts @@ -0,0 +1,65 @@ +import type { NotificationItem } from "../types/notifications"; + +export type NotificationToastCursor = { + latestCreatedAt: number; + keysAtLatest: string[]; +}; + +function notificationKey(item: NotificationItem): string { + const videoId = item.video.url.trim() || item.video.id; + return `${item.type}:${videoId}`; +} + +export function createNotificationToastCursor( + items: NotificationItem[], + emptyBaseline: number, +): NotificationToastCursor { + const latestCreatedAt = items.reduce( + (latest, item) => Math.max(latest, item.createdAt), + Number.NEGATIVE_INFINITY, + ); + if (!Number.isFinite(latestCreatedAt)) { + return { latestCreatedAt: emptyBaseline, keysAtLatest: [] }; + } + return { + latestCreatedAt, + keysAtLatest: items.filter((item) => item.createdAt === latestCreatedAt).map(notificationKey), + }; +} + +export function findNewNotificationItems( + items: NotificationItem[], + cursor: NotificationToastCursor, +): NotificationItem[] { + const knownAtLatest = new Set(cursor.keysAtLatest); + return items.filter( + (item) => + item.createdAt > cursor.latestCreatedAt || + (item.createdAt === cursor.latestCreatedAt && !knownAtLatest.has(notificationKey(item))), + ); +} + +export function advanceNotificationToastCursor( + cursor: NotificationToastCursor, + items: NotificationItem[], +): NotificationToastCursor { + const candidate = createNotificationToastCursor(items, cursor.latestCreatedAt); + if (candidate.latestCreatedAt < cursor.latestCreatedAt) return cursor; + if (candidate.latestCreatedAt > cursor.latestCreatedAt) return candidate; + return { + latestCreatedAt: cursor.latestCreatedAt, + keysAtLatest: [...new Set([...cursor.keysAtLatest, ...candidate.keysAtLatest])], + }; +} + +export function parseNotificationToastCursor(value: unknown): NotificationToastCursor | null { + if (typeof value !== "object" || value === null) return null; + const candidate = value as Partial; + if (!Number.isFinite(candidate.latestCreatedAt)) return null; + if (!Array.isArray(candidate.keysAtLatest)) return null; + if (!candidate.keysAtLatest.every((key) => typeof key === "string")) return null; + return { + latestCreatedAt: candidate.latestCreatedAt as number, + keysAtLatest: [...new Set(candidate.keysAtLatest)], + }; +} diff --git a/apps/web/tests/notification-toast-cursor.test.ts b/apps/web/tests/notification-toast-cursor.test.ts new file mode 100644 index 0000000..d1d0f03 --- /dev/null +++ b/apps/web/tests/notification-toast-cursor.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { + advanceNotificationToastCursor, + createNotificationToastCursor, + findNewNotificationItems, + parseNotificationToastCursor, +} from "../src/lib/notification-toast-cursor"; +import type { NotificationItem } from "../src/types/notifications"; + +function notification(id: string, createdAt: number): NotificationItem { + return { + type: "subscription_new_video", + title: `Channel uploaded ${id}`, + createdAt, + publishedAt: createdAt, + channelUrl: "https://youtube.com/@channel", + channelName: "Channel", + channelAvatarUrl: "avatar.jpg", + video: { + id, + title: `Video ${id}`, + url: `https://youtube.com/watch?v=${id}`, + thumbnailUrl: "thumbnail.jpg", + uploaderName: "Channel", + uploaderUrl: "https://youtube.com/@channel", + uploaderAvatarUrl: "avatar.jpg", + uploaderVerified: false, + duration: 60, + viewCount: 0, + publishedAt: createdAt, + uploadDate: "", + uploaded: createdAt, + streamType: "VIDEO_STREAM", + isLive: false, + isPostLive: false, + isLiveContent: false, + requiresMembership: false, + isShortFormContent: false, + shortDescription: null, + }, + }; +} + +describe("notification toast cursor", () => { + test("uses the current newest notification as a silent first baseline", () => { + expect( + createNotificationToastCursor([notification("old", 100), notification("latest", 300)], 500), + ).toEqual({ + latestCreatedAt: 300, + keysAtLatest: ["subscription_new_video:https://youtube.com/watch?v=latest"], + }); + }); + + test("uses the current time when the first response is empty", () => { + expect(createNotificationToastCursor([], 500)).toEqual({ + latestCreatedAt: 500, + keysAtLatest: [], + }); + }); + + test("returns only notifications newer than the stored cursor", () => { + const cursor = createNotificationToastCursor([notification("seen", 200)], 0); + expect( + findNewNotificationItems( + [notification("newest", 400), notification("new", 300), notification("seen", 200)], + cursor, + ).map((item) => item.video.id), + ).toEqual(["newest", "new"]); + }); + + test("detects a different video published at the same timestamp", () => { + const cursor = createNotificationToastCursor([notification("seen", 200)], 0); + const simultaneous = notification("simultaneous", 200); + expect(findNewNotificationItems([simultaneous], cursor)).toEqual([simultaneous]); + expect(advanceNotificationToastCursor(cursor, [simultaneous]).keysAtLatest).toHaveLength(2); + }); + + test("never moves the cursor backwards", () => { + const cursor = createNotificationToastCursor([notification("latest", 500)], 0); + expect(advanceNotificationToastCursor(cursor, [notification("stale", 100)])).toEqual(cursor); + }); + + test("rejects malformed persisted cursors", () => { + expect(parseNotificationToastCursor(null)).toBeNull(); + expect(parseNotificationToastCursor({ latestCreatedAt: "now", keysAtLatest: [] })).toBeNull(); + expect(parseNotificationToastCursor({ latestCreatedAt: 100, keysAtLatest: [1] })).toBeNull(); + }); +}); From 9b5d7d491714e486d715a13c1069acf3eb77b99c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 7 Aug 2026 10:34:57 +0200 Subject: [PATCH 06/56] feat: show new subscription upload toasts --- .../src/components/navbar-notifications.tsx | 17 +- .../components/notification-toast-host.tsx | 116 ++++++++++++++ .../components/notification-toast-preview.tsx | 68 -------- .../web/src/components/notification-toast.tsx | 96 ++++++++++++ .../src/components/notifications-dropdown.tsx | 17 +- apps/web/src/hooks/use-blocked-filter.ts | 1 + apps/web/src/hooks/use-notifications.ts | 6 +- apps/web/src/routeTree.gen.ts | 21 --- apps/web/src/routes/notification-preview.tsx | 148 ------------------ apps/web/src/stores/ui-store.ts | 9 ++ ...ast-preview.css => notification-toast.css} | 32 +--- 11 files changed, 256 insertions(+), 275 deletions(-) create mode 100644 apps/web/src/components/notification-toast-host.tsx delete mode 100644 apps/web/src/components/notification-toast-preview.tsx create mode 100644 apps/web/src/components/notification-toast.tsx delete mode 100644 apps/web/src/routes/notification-preview.tsx rename apps/web/src/styles/{notification-toast-preview.css => notification-toast.css} (56%) diff --git a/apps/web/src/components/navbar-notifications.tsx b/apps/web/src/components/navbar-notifications.tsx index 41dde42..e753cc3 100644 --- a/apps/web/src/components/navbar-notifications.tsx +++ b/apps/web/src/components/navbar-notifications.tsx @@ -6,10 +6,21 @@ const NotificationsDropdown = lazy(() => })), ); +const NotificationToastHost = lazy(() => + import("./notification-toast-host").then((module) => ({ + default: module.NotificationToastHost, + })), +); + export function NavbarNotifications() { return ( - - - + <> + + + + + + + ); } diff --git a/apps/web/src/components/notification-toast-host.tsx b/apps/web/src/components/notification-toast-host.tsx new file mode 100644 index 0000000..daf752f --- /dev/null +++ b/apps/web/src/components/notification-toast-host.tsx @@ -0,0 +1,116 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; +import { useAuth } from "../hooks/use-auth"; +import { useBlockedFilter } from "../hooks/use-blocked-filter"; +import { NOTIFICATIONS_UNREAD_KEY } from "../hooks/use-notifications"; +import { fetchNotifications } from "../lib/api-notifications"; +import { + advanceNotificationToastCursor, + createNotificationToastCursor, + findNewNotificationItems, + type NotificationToastCursor, + parseNotificationToastCursor, +} from "../lib/notification-toast-cursor"; +import { watchRouteSearch } from "../lib/watch-url"; +import { useUiStore } from "../stores/ui-store"; +import type { NotificationItem } from "../types/notifications"; +import { NotificationToast } from "./notification-toast"; + +const POLL_INTERVAL_MS = 60_000; +const DISMISS_AFTER_MS = 6_000; +const STORAGE_PREFIX = "typetype-notification-toast:"; + +function readCursor(owner: string): NotificationToastCursor | null { + try { + const raw = localStorage.getItem(`${STORAGE_PREFIX}${owner}`); + return raw ? parseNotificationToastCursor(JSON.parse(raw)) : null; + } catch { + return null; + } +} + +function writeCursor(owner: string, cursor: NotificationToastCursor): void { + try { + localStorage.setItem(`${STORAGE_PREFIX}${owner}`, JSON.stringify(cursor)); + } catch { + // The in-memory cursor still prevents duplicate toasts for this session. + } +} + +export function NotificationToastHost() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { authReady, isAuthed, isGuest, me } = useAuth(); + const { isBlocked, ready: blockedFilterReady } = useBlockedFilter(); + const openNotificationCenter = useUiStore((state) => state.openNotificationCenter); + const owner = me?.id ?? null; + const enabled = authReady && isAuthed && !isGuest && owner !== null; + const cursorRef = useRef<{ owner: string; cursor: NotificationToastCursor } | null>(null); + const [items, setItems] = useState([]); + const [paused, setPaused] = useState(false); + const query = useQuery({ + queryKey: ["notification-toast-candidates", owner], + queryFn: () => fetchNotifications(0, 20), + enabled, + refetchInterval: enabled ? POLL_INTERVAL_MS : false, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + retry: false, + }); + + useEffect(() => { + cursorRef.current = cursorRef.current?.owner === owner ? cursorRef.current : null; + setItems([]); + setPaused(false); + }, [owner]); + + useEffect(() => { + if (!enabled || !owner || !query.data || !blockedFilterReady) return; + queryClient.setQueryData(NOTIFICATIONS_UNREAD_KEY, { + unreadCount: query.data.unreadCount, + }); + let current = cursorRef.current?.owner === owner ? cursorRef.current.cursor : readCursor(owner); + if (!current) { + current = createNotificationToastCursor(query.data.items, Date.now()); + cursorRef.current = { owner, cursor: current }; + writeCursor(owner, current); + return; + } + const newItems = findNewNotificationItems(query.data.items, current); + const next = advanceNotificationToastCursor(current, query.data.items); + cursorRef.current = { owner, cursor: next }; + writeCursor(owner, next); + const visibleItems = newItems.filter((item) => !isBlocked(item.video)); + if (visibleItems.length > 0) setItems(visibleItems); + }, [blockedFilterReady, enabled, isBlocked, owner, query.data, queryClient]); + + useEffect(() => { + if (items.length === 0 || paused) return; + const timeout = window.setTimeout(() => setItems([]), DISMISS_AFTER_MS); + return () => window.clearTimeout(timeout); + }, [items, paused]); + + function openToast() { + if (items.length === 1) { + const item = items[0]; + if (item) { + const videoId = item.video.url.trim() || item.video.id; + void navigate({ to: "/watch", search: watchRouteSearch(videoId) }); + } + } else { + openNotificationCenter(); + } + setItems([]); + } + + if (items.length === 0) return null; + return ( + setItems([])} + onPausedChange={setPaused} + /> + ); +} diff --git a/apps/web/src/components/notification-toast-preview.tsx b/apps/web/src/components/notification-toast-preview.tsx deleted file mode 100644 index 3cb78f6..0000000 --- a/apps/web/src/components/notification-toast-preview.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { BellRing, X } from "lucide-react"; - -type Props = { - side: "left" | "right"; - variant: "single" | "grouped"; - animationKey: number; - onClose: () => void; -}; - -export function NotificationToastPreview({ side, variant, animationKey, onClose }: Props) { - const sideClass = side === "left" ? "notification-toast-left" : "notification-toast-right"; - const grouped = variant === "grouped"; - - return ( - - ); -} diff --git a/apps/web/src/components/notification-toast.tsx b/apps/web/src/components/notification-toast.tsx new file mode 100644 index 0000000..394b168 --- /dev/null +++ b/apps/web/src/components/notification-toast.tsx @@ -0,0 +1,96 @@ +import { BellRing, X } from "lucide-react"; +import { useClientLocale } from "../hooks/use-client-locale"; +import { formatPublishedDate } from "../lib/format"; +import { proxyImage } from "../lib/proxy"; +import type { NotificationItem } from "../types/notifications"; +import "../styles/notification-toast.css"; + +type Props = { + items: NotificationItem[]; + onOpen: () => void; + onClose: () => void; + onPausedChange: (paused: boolean) => void; +}; + +function NotificationVisual({ items }: { items: NotificationItem[] }) { + const thumbnail = items[0]?.video.thumbnailUrl; + const source = thumbnail ? proxyImage(thumbnail) : "/logo.svg"; + if (items.length === 1) { + return ( + + ); + } + return ( +