diff --git a/web/src/app/Api.js b/web/src/app/Api.js index 0625cb5..ae14c71 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -1,17 +1,17 @@ import { - topicUrlJsonPoll, fetchLinesIterator, - topicUrl, - topicUrlAuth, maybeWithBasicAuth, topicShortUrl, + topicUrl, + topicUrlAuth, + topicUrlJsonPoll, topicUrlJsonPollWithSince } from "./utils"; -import db from "./db"; +import userManager from "./UserManager"; class Api { async poll(baseUrl, topic, since) { - const user = await db.users.get(baseUrl); + const user = await userManager.get(baseUrl); const shortUrl = topicShortUrl(baseUrl, topic); const url = (since) ? topicUrlJsonPollWithSince(baseUrl, topic, since) @@ -27,7 +27,7 @@ class Api { } async publish(baseUrl, topic, message) { - const user = await db.users.get(baseUrl); + const user = await userManager.get(baseUrl); const url = topicUrl(baseUrl, topic); console.log(`[Api] Publishing message to ${url}`); await fetch(url, { diff --git a/web/src/app/NotificationManager.js b/web/src/app/NotificationManager.js index 4524fff..f280a15 100644 --- a/web/src/app/NotificationManager.js +++ b/web/src/app/NotificationManager.js @@ -1,8 +1,10 @@ import {formatMessage, formatTitleWithFallback, topicShortUrl} from "./utils"; import prefs from "./Prefs"; +import subscriptionManager from "./SubscriptionManager"; class NotificationManager { - async notify(subscription, notification, onClickFallback) { + async notify(subscriptionId, notification, onClickFallback) { + const subscription = await subscriptionManager.get(subscriptionId); const shouldNotify = await this.shouldNotify(subscription, notification); if (!shouldNotify) { return; diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index f81cb77..c0ab8bf 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -1,5 +1,6 @@ import db from "./db"; import api from "./Api"; +import subscriptionManager from "./SubscriptionManager"; const delayMillis = 3000; // 3 seconds const intervalMillis = 300000; // 5 minutes @@ -19,7 +20,7 @@ class Poller { async pollAll() { console.log(`[Poller] Polling all subscriptions`); - const subscriptions = await db.subscriptions.toArray(); + const subscriptions = await subscriptionManager.all(); for (const s of subscriptions) { try { await this.poll(s); @@ -38,11 +39,20 @@ class Poller { console.log(`[Poller] No new notifications found for ${subscription.id}`); return; } - const notificationsWithSubscriptionId = notifications - .map(notification => ({ ...notification, subscriptionId: subscription.id })); - await db.notifications.bulkPut(notificationsWithSubscriptionId); // FIXME - await db.subscriptions.update(subscription.id, {last: notifications.at(-1).id}); // FIXME - }; + console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`); + await subscriptionManager.addNotifications(subscription.id, notifications); + } + + pollInBackground(subscription) { + const fn = async () => { + try { + await this.poll(subscription); + } catch (e) { + console.error(`[App] Error polling subscription ${subscription.id}`, e); + } + }; + setTimeout(() => fn(), 0); + } } const poller = new Poller(); diff --git a/web/src/app/Pruner.js b/web/src/app/Pruner.js index 72ea2c1..014ab25 100644 --- a/web/src/app/Pruner.js +++ b/web/src/app/Pruner.js @@ -1,5 +1,5 @@ -import db from "./db"; import prefs from "./Prefs"; +import subscriptionManager from "./SubscriptionManager"; const delayMillis = 15000; // 15 seconds const intervalMillis = 1800000; // 30 minutes @@ -26,9 +26,7 @@ class Pruner { } console.log(`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`); try { - await db.notifications - .where("time").below(pruneThresholdTimestamp) - .delete(); + await subscriptionManager.pruneNotifications(pruneThresholdTimestamp); } catch (e) { console.log(`[Pruner] Error pruning old subscriptions`, e); } diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js new file mode 100644 index 0000000..e2f9711 --- /dev/null +++ b/web/src/app/SubscriptionManager.js @@ -0,0 +1,75 @@ +import db from "./db"; + +class SubscriptionManager { + async all() { + return db.subscriptions.toArray(); + } + + async get(subscriptionId) { + return await db.subscriptions.get(subscriptionId) + } + + async save(subscription) { + await db.subscriptions.put(subscription); + } + + async remove(subscriptionId) { + await db.subscriptions.delete(subscriptionId); + await db.notifications + .where({subscriptionId: subscriptionId}) + .delete(); + } + + async first() { + return db.subscriptions.toCollection().first(); // May be undefined + } + + async getNotifications(subscriptionId) { + return db.notifications + .where({ subscriptionId: subscriptionId }) + .toArray(); + } + + /** Adds notification, or returns false if it already exists */ + async addNotification(subscriptionId, notification) { + const exists = await db.notifications.get(notification.id); + if (exists) { + return false; + } + await db.notifications.add({ ...notification, subscriptionId }); + await db.subscriptions.update(subscriptionId, { + last: notification.id + }); + return true; + } + + /** Adds/replaces notifications, will not throw if they exist */ + async addNotifications(subscriptionId, notifications) { + const notificationsWithSubscriptionId = notifications + .map(notification => ({ ...notification, subscriptionId })); + const lastNotificationId = notifications.at(-1).id; + await db.notifications.bulkPut(notificationsWithSubscriptionId); + await db.subscriptions.update(subscriptionId, { + last: lastNotificationId + }); + } + + async deleteNotification(notificationId) { + await db.notifications.delete(notificationId); + } + + async deleteNotifications(subscriptionId) { + await db.notifications + .where({subscriptionId: subscriptionId}) + .delete(); + } + + async pruneNotifications(thresholdTimestamp) { + await db.notifications + .where("time").below(thresholdTimestamp) + .delete(); + } +} + +const subscriptionManager = new SubscriptionManager(); +export default subscriptionManager; diff --git a/web/src/app/UserManager.js b/web/src/app/UserManager.js new file mode 100644 index 0000000..25ad41e --- /dev/null +++ b/web/src/app/UserManager.js @@ -0,0 +1,22 @@ +import db from "./db"; + +class UserManager { + async all() { + return db.users.toArray(); + } + + async get(baseUrl) { + return db.users.get(baseUrl); + } + + async save(user) { + await db.users.put(user); + } + + async delete(baseUrl) { + await db.users.delete(baseUrl); + } +} + +const userManager = new UserManager(); +export default userManager; diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js index e7255b4..5ea4c96 100644 --- a/web/src/components/ActionBar.js +++ b/web/src/components/ActionBar.js @@ -4,7 +4,7 @@ import Toolbar from "@mui/material/Toolbar"; import IconButton from "@mui/material/IconButton"; import MenuIcon from "@mui/icons-material/Menu"; import Typography from "@mui/material/Typography"; -import IconSubscribeSettings from "./IconSubscribeSettings"; +import SubscribeSettings from "./SubscribeSettings"; import * as React from "react"; import Box from "@mui/material/Box"; import {topicShortUrl} from "../app/utils"; @@ -36,7 +36,7 @@ const ActionBar = (props) => { {title} - {props.selectedSubscription !== null && } diff --git a/web/src/components/App.js b/web/src/components/App.js index a425078..4d939b8 100644 --- a/web/src/components/App.js +++ b/web/src/components/App.js @@ -13,10 +13,11 @@ import ActionBar from "./ActionBar"; import notificationManager from "../app/NotificationManager"; import NoTopics from "./NoTopics"; import Preferences from "./Preferences"; -import db from "../app/db"; import {useLiveQuery} from "dexie-react-hooks"; import poller from "../app/Poller"; import pruner from "../app/Pruner"; +import subscriptionManager from "../app/SubscriptionManager"; +import userManager from "../app/UserManager"; // TODO subscribe dialog: // - check/use existing user @@ -26,7 +27,6 @@ import pruner from "../app/Pruner"; // TODO business logic with callbacks // TODO connection indicator in subscription list // TODO connectionmanager should react on users changes -// TODO attachments const App = () => { console.log(`[App] Rendering main view`); @@ -35,31 +35,21 @@ const App = () => { const [prefsOpen, setPrefsOpen] = useState(false); const [selectedSubscription, setSelectedSubscription] = useState(null); const [notificationsGranted, setNotificationsGranted] = useState(notificationManager.granted()); - const subscriptions = useLiveQuery(() => db.subscriptions.toArray()); - const users = useLiveQuery(() => db.users.toArray()); + const subscriptions = useLiveQuery(() => subscriptionManager.all()); + const users = useLiveQuery(() => userManager.all()); const handleSubscriptionClick = async (subscriptionId) => { - const subscription = await db.subscriptions.get(subscriptionId); // FIXME + const subscription = await subscriptionManager.get(subscriptionId); setSelectedSubscription(subscription); setPrefsOpen(false); } const handleSubscribeSubmit = async (subscription) => { console.log(`[App] New subscription: ${subscription.id}`, subscription); - await db.subscriptions.put(subscription); // FIXME setSelectedSubscription(subscription); handleRequestPermission(); - try { - await poller.poll(subscription); - } catch (e) { - console.error(`[App] Error polling newly added subscription ${subscription.id}`, e); - } }; const handleUnsubscribe = async (subscriptionId) => { console.log(`[App] Unsubscribing from ${subscriptionId}`); - await db.subscriptions.delete(subscriptionId); // FIXME - await db.notifications - .where({subscriptionId: subscriptionId}) - .delete(); // FIXME - const newSelected = await db.subscriptions.toCollection().first(); // FIXME May be undefined + const newSelected = await subscriptionManager.first(); // May be undefined setSelectedSubscription(newSelected); }; const handleRequestPermission = () => { @@ -77,7 +67,7 @@ const App = () => { poller.startWorker(); pruner.startWorker(); const load = async () => { - const subs = await db.subscriptions.toArray(); // Cannot be 'subscriptions' + const subs = await subscriptionManager.all(); // FIXME this is broken const selectedSubscriptionId = await prefs.selectedSubscriptionId(); // Set selected subscription @@ -93,10 +83,10 @@ const App = () => { const notificationClickFallback = (subscription) => setSelectedSubscription(subscription); const handleNotification = async (subscriptionId, notification) => { try { - const subscription = await db.subscriptions.get(subscriptionId); // FIXME - await db.notifications.add({ ...notification, subscriptionId }); // FIXME, will throw if exists! - await db.subscriptions.update(subscriptionId, { last: notification.id }); - await notificationManager.notify(subscription, notification, notificationClickFallback) + const added = await subscriptionManager.addNotification(subscriptionId, notification); + if (added) { + await notificationManager.notify(subscriptionId, notification, notificationClickFallback) + } } catch (e) { console.error(`[App] Error handling notification`, e); } diff --git a/web/src/components/Notifications.js b/web/src/components/Notifications.js index 7307b79..c59b0af 100644 --- a/web/src/components/Notifications.js +++ b/web/src/components/Notifications.js @@ -1,25 +1,22 @@ import Container from "@mui/material/Container"; -import {ButtonBase, CardActions, CardContent, Fade, Link, Modal, Stack, styled} from "@mui/material"; +import {ButtonBase, CardActions, CardContent, Fade, Link, Modal, Stack} from "@mui/material"; import Card from "@mui/material/Card"; import Typography from "@mui/material/Typography"; import * as React from "react"; +import {useState} from "react"; import {formatBytes, formatMessage, formatShortDateTime, formatTitle, topicShortUrl, unmatchedTags} from "../app/utils"; import IconButton from "@mui/material/IconButton"; import CloseIcon from '@mui/icons-material/Close'; import {Paragraph, VerticallyCenteredContainer} from "./styles"; import {useLiveQuery} from "dexie-react-hooks"; -import db from "../app/db"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; -import theme from "./theme"; -import {useState} from "react"; +import subscriptionManager from "../app/SubscriptionManager"; const Notifications = (props) => { const subscription = props.subscription; const notifications = useLiveQuery(() => { - return db.notifications - .where({ subscriptionId: subscription.id }) - .toArray(); + return subscriptionManager.getNotifications(subscription.id); }, [subscription]); if (!notifications || notifications.length === 0) { return ; @@ -49,7 +46,7 @@ const NotificationItem = (props) => { const tags = (otherTags.length > 0) ? otherTags.join(', ') : null; const handleDelete = async () => { console.log(`[Notifications] Deleting notification ${notification.id} from ${subscriptionId}`); - await db.notifications.delete(notification.id); // FIXME + await subscriptionManager.deleteNotification(notification.id) } const expired = attachment && attachment.expires && attachment.expires < Date.now()/1000; return ( diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index c9913bd..a34bfc3 100644 --- a/web/src/components/Preferences.js +++ b/web/src/components/Preferences.js @@ -32,6 +32,7 @@ import Dialog from "@mui/material/Dialog"; import DialogTitle from "@mui/material/DialogTitle"; import DialogContent from "@mui/material/DialogContent"; import DialogActions from "@mui/material/DialogActions"; +import userManager from "../app/UserManager"; const Preferences = (props) => { return ( @@ -165,7 +166,7 @@ const DefaultServer = (props) => { const Users = () => { const [dialogKey, setDialogKey] = useState(0); const [dialogOpen, setDialogOpen] = useState(false); - const users = useLiveQuery(() => db.users.toArray()); + const users = useLiveQuery(() => userManager.all()); const handleAddClick = () => { setDialogKey(prev => prev+1); setDialogOpen(true); @@ -176,7 +177,7 @@ const Users = () => { const handleDialogSubmit = async (user) => { setDialogOpen(false); try { - await db.users.add(user); + await userManager.save(user); console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} added`); } catch (e) { console.log(`[Preferences] Error adding user.`, e); @@ -224,7 +225,7 @@ const UserTable = (props) => { const handleDialogSubmit = async (user) => { setDialogOpen(false); try { - await db.users.put(user); // put() is an upsert + await userManager.save(user); console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} updated`); } catch (e) { console.log(`[Preferences] Error updating user.`, e); @@ -232,7 +233,7 @@ const UserTable = (props) => { }; const handleDeleteClick = async (user) => { try { - await db.users.delete(user.baseUrl); + await userManager.delete(user.baseUrl); console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} deleted`); } catch (e) { console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e); diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index f7194e3..5758339 100644 --- a/web/src/components/SubscribeDialog.js +++ b/web/src/components/SubscribeDialog.js @@ -12,7 +12,9 @@ import theme from "./theme"; import api from "../app/Api"; import {topicUrl, validTopic, validUrl} from "../app/utils"; import Box from "@mui/material/Box"; -import db from "../app/db"; +import userManager from "../app/UserManager"; +import subscriptionManager from "../app/SubscriptionManager"; +import poller from "../app/Poller"; const defaultBaseUrl = "http://127.0.0.1" //const defaultBaseUrl = "https://ntfy.sh" @@ -22,7 +24,7 @@ const SubscribeDialog = (props) => { const [topic, setTopic] = useState(""); const [showLoginPage, setShowLoginPage] = useState(false); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); - const handleSuccess = () => { + const handleSuccess = async () => { const actualBaseUrl = (baseUrl) ? baseUrl : defaultBaseUrl; // FIXME const subscription = { id: topicUrl(actualBaseUrl, topic), @@ -30,6 +32,8 @@ const SubscribeDialog = (props) => { topic: topic, last: null }; + await subscriptionManager.save(subscription); + poller.pollInBackground(subscription); // Dangle! props.onSuccess(subscription); } return ( @@ -141,7 +145,7 @@ const LoginPage = (props) => { return; } console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); - db.users.put(user); + await userManager.save(user); props.onSuccess(); }; return ( diff --git a/web/src/components/IconSubscribeSettings.js b/web/src/components/SubscribeSettings.js similarity index 90% rename from web/src/components/IconSubscribeSettings.js rename to web/src/components/SubscribeSettings.js index 1703221..1c1a4e3 100644 --- a/web/src/components/IconSubscribeSettings.js +++ b/web/src/components/SubscribeSettings.js @@ -9,10 +9,10 @@ import MenuList from '@mui/material/MenuList'; import IconButton from "@mui/material/IconButton"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import api from "../app/Api"; -import db from "../app/db"; +import subscriptionManager from "../app/SubscriptionManager"; // Originally from https://mui.com/components/menus/#MenuListComposition.js -const IconSubscribeSettings = (props) => { +const SubscribeSettings = (props) => { const [open, setOpen] = useState(false); const anchorRef = useRef(null); @@ -27,16 +27,15 @@ const IconSubscribeSettings = (props) => { setOpen(false); }; - const handleClearAll = (event) => { + const handleClearAll = async (event) => { handleClose(event); console.log(`[IconSubscribeSettings] Deleting all notifications from ${props.subscription.id}`); - db.notifications - .where({subscriptionId: props.subscription.id}) - .delete(); // FIXME + await subscriptionManager.deleteNotifications(props.subscription.id); }; - const handleUnsubscribe = (event) => { + const handleUnsubscribe = async (event) => { handleClose(event); + await subscriptionManager.remove(props.subscription.id); props.onUnsubscribe(props.subscription.id); }; @@ -48,7 +47,7 @@ const IconSubscribeSettings = (props) => { setOpen(false); } - function handleListKeyDown(event) { + const handleListKeyDown = (event) => { if (event.key === 'Tab') { event.preventDefault(); setOpen(false); @@ -114,4 +113,4 @@ const IconSubscribeSettings = (props) => { ); } -export default IconSubscribeSettings; +export default SubscribeSettings;