Make web push toggle global

This commit is contained in:
nimbleghost 2023-06-08 09:22:56 +02:00
parent a8db08c7d4
commit 46798ac322
10 changed files with 99 additions and 91 deletions

View file

@ -95,6 +95,7 @@
"notifications_no_subscriptions_description": "Click the \"{{linktext}}\" link to create or subscribe to a topic. After that, you can send messages via PUT or POST and you'll receive notifications here.",
"notifications_example": "Example",
"notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.",
"notification_toggle_mute": "Mute",
"notification_toggle_unmute": "Unmute",
"notification_toggle_background": "Background notifications",
"display_name_dialog_title": "Change display name",
@ -369,6 +370,10 @@
"prefs_reservations_dialog_description": "Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.",
"prefs_reservations_dialog_topic_label": "Topic",
"prefs_reservations_dialog_access_label": "Access",
"prefs_notifications_web_push_title": "Enable web push notifications",
"prefs_notifications_web_push_description": "Enable this to receive notifications in the background even when ntfy isn't running",
"prefs_notifications_web_push_enabled": "Enabled",
"prefs_notifications_web_push_disabled": "Disabled",
"reservation_delete_dialog_description": "Removing a reservation gives up ownership over the topic, and allows others to reserve it. You can keep, or delete existing messages and attachments.",
"reservation_delete_dialog_action_keep_title": "Keep cached messages and attachments",
"reservation_delete_dialog_action_keep_description": "Messages and attachments that are cached on the server will become publicly visible for people with knowledge of the topic name.",

View file

@ -45,15 +45,11 @@ class ConnectionManager {
return;
}
console.log(`[ConnectionManager] Refreshing connections`);
const subscriptionsWithUsersAndConnectionId = subscriptions
.map((s) => {
const [user] = users.filter((u) => u.baseUrl === s.baseUrl);
const connectionId = makeConnectionId(s, user);
return { ...s, user, connectionId };
})
// background notifications don't need this as they come over web push.
// however, if they are muted, we again need the ws while the page is active
.filter((s) => !s.webPushEnabled && s.mutedUntil !== 1);
const subscriptionsWithUsersAndConnectionId = subscriptions.map((s) => {
const [user] = users.filter((u) => u.baseUrl === s.baseUrl);
const connectionId = makeConnectionId(s, user);
return { ...s, user, connectionId };
});
console.log();
const targetIds = subscriptionsWithUsersAndConnectionId.map((s) => s.connectionId);

View file

@ -114,6 +114,11 @@ class Notifier {
return this.pushSupported() && this.contextSupported() && this.granted() && !this.iosSupportedButInstallRequired();
}
async pushEnabled() {
const enabled = await prefs.webPushEnabled();
return this.pushPossible() && enabled;
}
/**
* Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification

View file

@ -31,6 +31,15 @@ class Prefs {
const deleteAfter = await this.db.prefs.get("deleteAfter");
return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week
}
async webPushEnabled() {
const obj = await this.db.prefs.get("webPushEnabled");
return obj?.value ?? false;
}
async setWebPushEnabled(enabled) {
await this.db.prefs.put({ key: "webPushEnabled", value: enabled });
}
}
const prefs = new Prefs(getDb());

View file

@ -21,8 +21,16 @@ class SubscriptionManager {
}
async webPushTopics() {
const subscriptions = await this.db.subscriptions.where({ webPushEnabled: 1, mutedUntil: 0 }).toArray();
return subscriptions.map(({ topic }) => topic);
// the Promise.resolve wrapper is not superfluous, without it the live query breaks:
// https://dexie.org/docs/dexie-react-hooks/useLiveQuery()#calling-non-dexie-apis-from-querier
if (!(await Promise.resolve(notifier.pushEnabled()))) {
return [];
}
const subscriptions = await this.db.subscriptions.where({ mutedUntil: 0, baseUrl: config.base_url }).toArray();
// internal is currently a bool, it could be a 0/1 to be indexable, but for now just filter them out here
return subscriptions.filter(({ internal }) => !internal).map(({ topic }) => topic);
}
async get(subscriptionId) {
@ -49,7 +57,6 @@ class SubscriptionManager {
* @param {string} topic
* @param {object} opts
* @param {boolean} opts.internal
* @param {boolean} opts.webPushEnabled
* @returns
*/
async add(baseUrl, topic, opts = {}) {
@ -67,7 +74,6 @@ class SubscriptionManager {
topic,
mutedUntil: 0,
last: null,
webPushEnabled: opts.webPushEnabled ? 1 : 0,
};
await this.db.subscriptions.put(subscription);
@ -211,12 +217,6 @@ class SubscriptionManager {
});
}
async toggleBackgroundNotifications(subscription) {
await this.db.subscriptions.update(subscription.id, {
webPushEnabled: subscription.webPushEnabled === 1 ? 0 : 1,
});
}
async setDisplayName(subscriptionId, displayName) {
await this.db.subscriptions.update(subscriptionId, {
displayName,

View file

@ -14,7 +14,7 @@ const getDbBase = (username) => {
const db = new Dexie(dbName);
db.version(2).stores({
subscriptions: "&id,baseUrl,[webPushEnabled+mutedUntil]",
subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]",
notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
users: "&baseUrl,username",
prefs: "&key",

View file

@ -69,6 +69,16 @@ const Layout = () => {
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
const users = useLiveQuery(() => userManager.all());
const subscriptions = useLiveQuery(() => subscriptionManager.all());
const webPushTopics = useLiveQuery(() => subscriptionManager.webPushTopics());
const websocketSubscriptions = useMemo(
() => (subscriptions && webPushTopics ? subscriptions.filter((s) => !webPushTopics.includes(s.topic)) : []),
// websocketSubscriptions should stay stable unless the list of subscription ids changes.
// without the memoization, the connection listener calls a refresh for no reason.
// this isn't a problem due to the makeConnectionId, but it triggers an
// unnecessary recomputation for every received message.
[JSON.stringify({ subscriptions: subscriptions?.map(({ id }) => id), webPushTopics })]
);
const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal);
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
const [selected] = (subscriptionsWithoutInternal || []).filter(
@ -77,7 +87,7 @@ const Layout = () => {
(config.base_url === s.baseUrl && params.topic === s.topic)
);
useConnectionListeners(account, subscriptions, users);
useConnectionListeners(account, websocketSubscriptions, users);
useAccountListener(setAccount);
useBackgroundProcesses();
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);

View file

@ -48,6 +48,7 @@ import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
import { UnauthorizedError } from "../app/errors";
import { subscribeTopic } from "./SubscribeDialog";
import notifier from "../app/Notifier";
const maybeUpdateAccountSettings = async (payload) => {
if (!session.exists()) {
@ -85,6 +86,7 @@ const Notifications = () => {
<Sound />
<MinPriority />
<DeleteAfter />
<WebPushEnabled />
</PrefGroup>
</Card>
);
@ -232,6 +234,35 @@ const DeleteAfter = () => {
);
};
const WebPushEnabled = () => {
const { t } = useTranslation();
const labelId = "prefWebPushEnabled";
const defaultEnabled = useLiveQuery(async () => prefs.webPushEnabled());
const handleChange = async (ev) => {
await prefs.setWebPushEnabled(ev.target.value);
};
// while loading
if (defaultEnabled == null) {
return null;
}
if (!notifier.pushPossible()) {
return null;
}
return (
<Pref labelId={labelId} title={t("prefs_notifications_web_push_title")} description={t("prefs_notifications_web_push_description")}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={defaultEnabled} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value>{t("prefs_notifications_web_push_enabled")}</MenuItem>
<MenuItem value={false}>{t("prefs_notifications_web_push_disabled")}</MenuItem>
</Select>
</FormControl>
</Pref>
);
};
const Users = () => {
const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0);

View file

@ -28,7 +28,6 @@ import ReserveTopicSelect from "./ReserveTopicSelect";
import { AccountContext } from "./App";
import { TopicReservedError, UnauthorizedError } from "../app/errors";
import { ReserveLimitChip } from "./SubscriptionPopup";
import notifier from "../app/Notifier";
const publicBaseUrl = "https://ntfy.sh";
@ -53,12 +52,10 @@ const SubscribeDialog = (props) => {
const [showLoginPage, setShowLoginPage] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSuccess = async (webPushEnabled) => {
const handleSuccess = async () => {
console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
const actualBaseUrl = baseUrl || config.base_url;
const subscription = await subscribeTopic(actualBaseUrl, topic, {
webPushEnabled,
});
const subscription = await subscribeTopic(actualBaseUrl, topic, {});
poller.pollInBackground(subscription); // Dangle!
props.onSuccess(subscription);
};
@ -99,12 +96,6 @@ const SubscribePage = (props) => {
const reserveTopicEnabled =
session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0));
const [backgroundNotificationsEnabled, setBackgroundNotificationsEnabled] = useState(false);
const handleBackgroundNotificationsChanged = (e) => {
setBackgroundNotificationsEnabled(e.target.checked);
};
const handleSubscribe = async () => {
const user = await userManager.get(baseUrl); // May be undefined
const username = user ? user.username : t("subscribe_dialog_error_user_anonymous");
@ -142,15 +133,12 @@ const SubscribePage = (props) => {
}
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
props.onSuccess(backgroundNotificationsEnabled);
props.onSuccess();
};
const handleUseAnotherChanged = (e) => {
props.setBaseUrl("");
setAnotherServerVisible(e.target.checked);
if (e.target.checked) {
setBackgroundNotificationsEnabled(false);
}
};
const subscribeButtonEnabled = (() => {
@ -256,22 +244,6 @@ const SubscribePage = (props) => {
)}
</FormGroup>
)}
{notifier.pushPossible() && !anotherServerVisible && (
<FormGroup>
<FormControlLabel
control={
<Switch
onChange={handleBackgroundNotificationsChanged}
checked={backgroundNotificationsEnabled}
inputProps={{
"aria-label": t("subscribe_dialog_subscribe_enable_background_notifications_label"),
}}
/>
}
label={t("subscribe_dialog_subscribe_enable_background_notifications_label")}
/>
</FormGroup>
)}
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>

View file

@ -15,19 +15,17 @@ import {
MenuItem,
IconButton,
ListItemIcon,
ListItemText,
Divider,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
Check,
Clear,
ClearAll,
Edit,
EnhancedEncryption,
Lock,
LockOpen,
Notifications,
NotificationsOff,
RemoveCircle,
Send,
@ -44,7 +42,6 @@ import api from "../app/Api";
import { AccountContext } from "./App";
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
import { UnauthorizedError } from "../app/errors";
import notifier from "../app/Notifier";
export const SubscriptionPopup = (props) => {
const { t } = useTranslation();
@ -169,8 +166,8 @@ export const SubscriptionPopup = (props) => {
return (
<>
<PopupMenu horizontal={placement} anchorEl={props.anchor} open={!!props.anchor} onClose={props.onClose}>
{notifier.pushPossible() && <NotificationToggle subscription={subscription} />}
<Divider />
<NotificationToggle subscription={subscription} />
<MenuItem onClick={handleChangeDisplayName}>
<ListItemIcon>
<Edit fontSize="small" />
@ -334,44 +331,27 @@ const DisplayNameDialog = (props) => {
);
};
const checkedItem = (
<ListItemIcon>
<Check />
</ListItemIcon>
);
const NotificationToggle = ({ subscription }) => {
const { t } = useTranslation();
const handleToggleBackground = async () => {
try {
await subscriptionManager.toggleBackgroundNotifications(subscription);
} catch (e) {
console.error("[NotificationToggle] Error setting notification type", e);
}
const handleToggleMute = async () => {
const mutedUntil = subscription.mutedUntil ? 0 : 1; // Make this a timestamp in the future
await subscriptionManager.setMutedUntil(subscription.id, mutedUntil);
};
const unmute = async () => {
await subscriptionManager.setMutedUntil(subscription.id, 0);
};
if (subscription.mutedUntil === 1) {
return (
<MenuItem onClick={unmute}>
<ListItemIcon>
<NotificationsOff />
</ListItemIcon>
{t("notification_toggle_unmute")}
</MenuItem>
);
}
return (
<MenuItem>
{subscription.webPushEnabled === 1 && checkedItem}
<ListItemText inset={subscription.webPushEnabled !== 1} onClick={handleToggleBackground}>
{t("notification_toggle_background")}
</ListItemText>
return subscription.mutedUntil ? (
<MenuItem onClick={handleToggleMute}>
<ListItemIcon>
<Notifications />
</ListItemIcon>
{t("notification_toggle_unmute")}
</MenuItem>
) : (
<MenuItem onClick={handleToggleMute}>
<ListItemIcon>
<NotificationsOff />
</ListItemIcon>
{t("notification_toggle_mute")}
</MenuItem>
);
};