Add browser notification support for new videos
- Settings: Notifications section with toggle that requests browser permission and stores preference in localStorage - Layout: fires a Notification when new_count increases and user isn't on /following - Works whenever the tab is open (foreground or background) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { Outlet, NavLink, Link, useLocation } from "react-router-dom";
|
import { Outlet, NavLink, Link, useLocation } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import SearchBar from "./SearchBar";
|
import SearchBar from "./SearchBar";
|
||||||
import { getDownloads, getChannels, getActiveTasks, getMe } from "../api";
|
import { getDownloads, getChannels, getActiveTasks, getMe } from "../api";
|
||||||
@@ -259,6 +260,24 @@ function useNewVideosCount() {
|
|||||||
return channels.reduce((sum, c) => sum + (c.new_count ?? 0), 0);
|
return channels.reduce((sum, c) => sum + (c.new_count ?? 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useNewVideoNotifications(newCount) {
|
||||||
|
const location = useLocation();
|
||||||
|
const prevCount = useRef(newCount);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const enabled = localStorage.getItem("notifications_enabled") === "true";
|
||||||
|
if (!enabled) return;
|
||||||
|
if (Notification.permission !== "granted") return;
|
||||||
|
if (location.pathname === "/following") return;
|
||||||
|
if (newCount > 0 && newCount > prevCount.current) {
|
||||||
|
new Notification("New videos", {
|
||||||
|
body: `${newCount} new video${newCount !== 1 ? "s" : ""} from channels you follow`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prevCount.current = newCount;
|
||||||
|
}, [newCount, location.pathname]);
|
||||||
|
}
|
||||||
|
|
||||||
function useOffline() {
|
function useOffline() {
|
||||||
const { isError, error } = useQuery({
|
const { isError, error } = useQuery({
|
||||||
queryKey: ["health"],
|
queryKey: ["health"],
|
||||||
@@ -274,6 +293,7 @@ export default function Layout() {
|
|||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const newCount = useNewVideosCount();
|
const newCount = useNewVideosCount();
|
||||||
const offline = useOffline();
|
const offline = useOffline();
|
||||||
|
useNewVideoNotifications(newCount);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col overflow-hidden" style={{ height: "100dvh" }}>
|
<div className="flex flex-col overflow-hidden" style={{ height: "100dvh" }}>
|
||||||
|
|||||||
@@ -600,6 +600,62 @@ function OAuth2Section({ s, qc }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotificationsSection() {
|
||||||
|
const [permission, setPermission] = useState(() =>
|
||||||
|
"Notification" in window ? Notification.permission : "unsupported"
|
||||||
|
);
|
||||||
|
const [enabled, setEnabled] = useState(() => localStorage.getItem("notifications_enabled") === "true");
|
||||||
|
|
||||||
|
const requestPermission = async () => {
|
||||||
|
if (!("Notification" in window)) return;
|
||||||
|
const result = await Notification.requestPermission();
|
||||||
|
setPermission(result);
|
||||||
|
if (result === "granted") {
|
||||||
|
setEnabled(true);
|
||||||
|
localStorage.setItem("notifications_enabled", "true");
|
||||||
|
new Notification("YTContinue notifications on", {
|
||||||
|
body: "You'll be notified when new videos arrive from channels you follow.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (permission !== "granted") {
|
||||||
|
requestPermission();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = !enabled;
|
||||||
|
setEnabled(next);
|
||||||
|
localStorage.setItem("notifications_enabled", next ? "true" : "false");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (permission === "unsupported") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title="Notifications">
|
||||||
|
<Row
|
||||||
|
label="New video alerts"
|
||||||
|
hint={
|
||||||
|
permission === "denied"
|
||||||
|
? "Blocked by browser — allow notifications for this site in browser settings"
|
||||||
|
: "Get a browser notification when followed channels upload new videos"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{permission === "denied" ? (
|
||||||
|
<span className="text-xs text-zinc-600">Blocked</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled && permission === "granted" ? "bg-accent" : "bg-zinc-700"}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled && permission === "granted" ? "translate-x-6" : "translate-x-1"}`} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -823,6 +879,9 @@ export default function SettingsPage() {
|
|||||||
</Row>
|
</Row>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<NotificationsSection />
|
||||||
|
|
||||||
{/* Data */}
|
{/* Data */}
|
||||||
<Section title="Data">
|
<Section title="Data">
|
||||||
<div className="px-5 py-4 flex items-center justify-between">
|
<div className="px-5 py-4 flex items-center justify-between">
|
||||||
|
|||||||
Reference in New Issue
Block a user