Remove old alerts

This commit is contained in:
Jon Staab
2026-01-19 16:33:16 -08:00
parent 9f34b33b7e
commit f85748fef9
17 changed files with 126 additions and 1080 deletions
-217
View File
@@ -1,217 +0,0 @@
<script lang="ts">
import {onMount} from "svelte"
import {preventDefault} from "@lib/html"
import {randomInt, map, displayList, identity, TIMEZONE} from "@welshman/lib"
import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
import type {Filter} from "@welshman/util"
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {alertsById, userSpaceUrls} from "@app/core/state"
import {requestRelayClaim} from "@app/core/requests"
import {createAlert} from "@app/core/commands"
import {canSendPushNotifications} from "@app/util/push"
import {pushToast} from "@app/util/toast"
type Props = {
url?: string
channel?: string
notifyChat?: boolean
notifyThreads?: boolean
notifyCalendar?: boolean
hideSpaceField?: boolean
}
let {
url = "",
channel = "email",
notifyChat = true,
notifyThreads = true,
notifyCalendar = true,
hideSpaceField = false,
}: Props = $props()
const timezoneOffset = parseInt(TIMEZONE.split(":")?.[0] || "00")
const minute = randomInt(0, 59)
const hour = (17 - timezoneOffset) % 24
const WEEKLY = `0 ${minute} ${hour} * * 1`
const DAILY = `0 ${minute} ${hour} * * *`
let loading = $state(false)
let cron = $state(WEEKLY)
let email = $state(
map(a => getTagValue("email", a.tags), $alertsById.values()).filter(identity)[0] || "",
)
const back = () => history.back()
const submit = async () => {
if (channel === "email" && !email.includes("@")) {
return pushToast({
theme: "error",
message: "Please provide an email address",
})
}
if (!url) {
return pushToast({
theme: "error",
message: "Please select a space",
})
}
if (!notifyThreads && !notifyCalendar && !notifyChat) {
return pushToast({
theme: "error",
message: "Please select something to be notified about",
})
}
const filters: Filter[] = []
const display: string[] = []
if (notifyThreads) {
display.push("threads")
filters.push({kinds: [THREAD]})
filters.push({kinds: [COMMENT], "#k": [String(THREAD)]})
}
if (notifyCalendar) {
display.push("calendar events")
filters.push({kinds: [EVENT_TIME]})
filters.push({kinds: [COMMENT], "#k": [String(EVENT_TIME)]})
}
if (notifyChat) {
display.push("chat")
filters.push({kinds: [MESSAGE]})
}
loading = true
try {
const claim = url ? await requestRelayClaim(url) : undefined
const {error} = await createAlert({
feed: makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(url)),
claims: claim ? {[url]: claim} : {},
description: `for ${displayList(display)} on ${displayRelayUrl(url)}`,
email: channel === "email" ? {cron, email} : undefined,
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Your alert has been successfully created!"})
back()
}
} finally {
loading = false
}
}
onMount(() => {
if (!canSendPushNotifications()) {
channel = "email"
}
})
</script>
<form class="column gap-4" onsubmit={preventDefault(submit)}>
<ModalHeader>
{#snippet title()}
Add an Alert
{/snippet}
{#snippet info()}
Enable notifications to keep up to date on activity you care about.
{/snippet}
</ModalHeader>
{#if canSendPushNotifications()}
<FieldInline>
{#snippet label()}
<p>Alert Type*</p>
{/snippet}
{#snippet input()}
<select bind:value={channel} class="select select-bordered">
<option value="email">Email Digest</option>
<option value="push">Push Notification</option>
</select>
{/snippet}
</FieldInline>
{/if}
{#if channel === "email"}
<FieldInline>
{#snippet label()}
<p>Email Address*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input placeholder="email@example.com" bind:value={email} />
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Frequency*</p>
{/snippet}
{#snippet input()}
<select bind:value={cron} class="select select-bordered">
<option value={WEEKLY}>Weekly</option>
<option value={DAILY}>Daily</option>
</select>
{/snippet}
</FieldInline>
{/if}
{#if !hideSpaceField}
<FieldInline>
{#snippet label()}
<p>Space*</p>
{/snippet}
{#snippet input()}
<select bind:value={url} class="select select-bordered">
<option value="" disabled selected>Choose a space URL</option>
{#each $userSpaceUrls as url (url)}
<option value={url}>{displayRelayUrl(url)}</option>
{/each}
</select>
{/snippet}
</FieldInline>
{/if}
<FieldInline>
{#snippet label()}
<p>Notifications*</p>
{/snippet}
{#snippet input()}
<div class="flex items-center justify-end gap-4">
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
Threads
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} />
Calendar
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyChat} />
Chat
</span>
</div>
{/snippet}
</FieldInline>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Confirm</Spinner>
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
-20
View File
@@ -1,20 +0,0 @@
<script lang="ts">
import Confirm from "@lib/components/Confirm.svelte"
import type {Alert} from "@app/core/state"
import {deleteAlert} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
type Props = {
alert: Alert
}
const {alert}: Props = $props()
const confirm = () => {
deleteAlert(alert)
pushToast({message: "Your alert has been deleted!"})
history.back()
}
</script>
<Confirm {confirm} message="You'll no longer receive messages for this alert." />
-42
View File
@@ -1,42 +0,0 @@
<script lang="ts">
import {parseJson} from "@welshman/lib"
import {displayFeeds} from "@welshman/feeds"
import {getTagValue, getTagValues} from "@welshman/util"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import AlertDelete from "@app/components/AlertDelete.svelte"
import AlertStatus from "@app/components/AlertStatus.svelte"
import type {Alert} from "@app/core/state"
import {pushModal} from "@app/util/modal"
type Props = {
alert: Alert
}
const {alert}: Props = $props()
const cron = $derived(getTagValue("cron", alert.tags))
const channel = $derived(getTagValue("channel", alert.tags))
const feeds = $derived(getTagValues("feed", alert.tags))
const description = $derived(
getTagValue("description", alert.tags) ||
[
`${cron?.endsWith("1") ? "Weekly" : "Daily"} alert for events`,
displayFeeds(feeds.map(parseJson)),
`sent via ${channel}.`,
].join(" "),
)
const startDelete = () => pushModal(AlertDelete, {alert})
</script>
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4">
<Button class="py-1" onclick={startDelete}>
<Icon icon={TrashBin2} />
</Button>
<div class="flex-inline gap-1">{description}</div>
</div>
<AlertStatus {alert} />
</div>
-42
View File
@@ -1,42 +0,0 @@
<script lang="ts">
import {getAddress, getTagValue} from "@welshman/util"
import type {Alert} from "@app/core/state"
import {deriveAlertStatus} from "@app/core/state"
type Props = {
alert: Alert
}
const {alert}: Props = $props()
const status = deriveAlertStatus(getAddress(alert.event))
</script>
{#if $status}
{@const statusText = getTagValue("status", $status.tags) || "error"}
{#if statusText === "ok"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
data-tip={getTagValue("message", $status.tags)}>
Active
</span>
{:else if statusText === "pending"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500"
data-tip={getTagValue("message", $status.tags)}>
Pending
</span>
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip={getTagValue("message", $status.tags)}>
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
</span>
{/if}
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip="The notification server did not respond to your request.">
Inactive
</span>
{/if}
-155
View File
@@ -1,155 +0,0 @@
<script lang="ts">
import {sleep, filter} from "@welshman/lib"
import {getTagValue, getAddress, RelayMode} from "@welshman/util"
import {isRelayFeed, findFeed} from "@welshman/feeds"
import {getPubkeyRelays, pubkey} from "@welshman/app"
import Inbox from "@assets/icons/inbox.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte"
import AlertItem from "@app/components/AlertItem.svelte"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {
dmAlert,
alertsById,
deriveAlertStatus,
getAlertFeed,
userSettingsValues,
} from "@app/core/state"
import {deleteAlert, createDmAlert} from "@app/core/commands"
import {clearBadges} from "../util/notifications"
type Props = {
url?: string
channel?: string
hideSpaceField?: boolean
}
const {url = "", channel = "push", hideSpaceField = false}: Props = $props()
const dmStatus = $derived($dmAlert ? deriveAlertStatus(getAddress($dmAlert.event)) : undefined)
const filteredAlerts = $derived(
filter(alert => {
const feed = getAlertFeed(alert)
// Skip non-feeds and DM alerts
if (!feed || alert === $dmAlert) return false
// If we have a space url, only match feeds for this space
if (url) return findFeed(feed, f => isRelayFeed(f) && f.includes(url))
return true
}, $alertsById.values()),
)
const startAlert = () => pushModal(AlertAdd, {url, channel, hideSpaceField})
const uncheckDmAlert = async (message: string) => {
await sleep(100)
directMessagesNotificationToggle.checked = false
pushToast({theme: "error", message})
}
const onDirectMessagesNotificationToggle = async () => {
if ($dmAlert) {
deleteAlert($dmAlert)
} else {
if (getPubkeyRelays($pubkey!, RelayMode.Messaging).length === 0) {
return uncheckDmAlert("Please set up your messaging relays before enabling alerts.")
}
const {error} = await createDmAlert()
if (error) {
return uncheckDmAlert(error)
}
pushToast({message: "Your alert has been successfully created!"})
}
}
const onShowBadgeOnUnreadToggle = async () => {
$userSettingsValues.show_notifications_badge = !$userSettingsValues.show_notifications_badge
if (!$userSettingsValues.show_notifications_badge) {
await clearBadges()
}
}
const onDirectMessagesNotificationSoundToggle = async () => {
$userSettingsValues.play_notification_sound = !$userSettingsValues.play_notification_sound
}
let directMessagesNotificationToggle: HTMLInputElement
</script>
<div class="col-4">
<div class="card2 bg-alt flex flex-col gap-6 shadow-md">
<div class="flex items-center justify-between">
<strong class="flex items-center gap-3">
<Icon icon={Inbox} />
Alerts
</strong>
<Button class="btn btn-primary btn-sm" onclick={startAlert}>
<Icon icon={AddCircle} />
Add Alert
</Button>
</div>
<div class="col-4">
{#each filteredAlerts as alert (alert.event.id)}
<AlertItem {alert} />
{:else}
<p class="text-center opacity-75 py-12">Nothing here yet!</p>
{/each}
</div>
</div>
<div class="card2 bg-alt flex flex-col gap-4 shadow-md">
<div class="flex items-center justify-between">
<strong class="flex items-center gap-3">
<Icon icon={Bell} />
Notifications
</strong>
</div>
<div class="flex justify-between">
<p>Notify me about new direct messages</p>
<input
type="checkbox"
class="toggle toggle-primary"
bind:this={directMessagesNotificationToggle}
checked={Boolean($dmAlert)}
oninput={onDirectMessagesNotificationToggle} />
</div>
<div class="flex justify-between">
<p>Show badge for unread direct messages</p>
<input
type="checkbox"
class="toggle toggle-primary"
checked={Boolean($userSettingsValues.show_notifications_badge)}
oninput={onShowBadgeOnUnreadToggle} />
</div>
<div class="flex justify-between">
<p>Play sound for new direct messages</p>
<input
type="checkbox"
class="toggle toggle-primary"
checked={Boolean($userSettingsValues.play_notification_sound)}
oninput={onDirectMessagesNotificationSoundToggle} />
</div>
{#if $dmStatus}
{@const status = getTagValue("status", $dmStatus.tags) || "error"}
{#if status !== "ok"}
<div class="alert alert-error border border-solid border-error bg-transparent text-error">
<p>
{getTagValue("message", $dmStatus.tags) ||
"The notification server did not respond to your request."}
</p>
</div>
{/if}
{/if}
</div>
</div>
+11 -49
View File
@@ -1,19 +1,15 @@
<script lang="ts">
import {RelayMode} from "@welshman/util"
import {waitForThunkCompletion, getPubkeyRelays, pubkey} from "@welshman/app"
import ChatSquare from "@assets/icons/chat-square.svg?dataurl"
import Check from "@assets/icons/check.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import BellOff from "@assets/icons/bell-off.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ChatStart from "@app/components/ChatStart.svelte"
import {setChecked} from "@app/util/notifications"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {dmAlert} from "@app/core/state"
import {deleteAlert, createDmAlert} from "@app/core/commands"
import {userSettingsValues} from "@app/core/state"
import {publishSettings} from "@app/core/commands"
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
@@ -22,39 +18,9 @@
history.back()
}
const enableAlerts = async () => {
if (getPubkeyRelays($pubkey!, RelayMode.Messaging).length === 0) {
return pushToast({
theme: "error",
message: "Please set up your messaging relays before enabling alerts.",
})
}
const enableAlerts = () => publishSettings({...$userSettingsValues, alerts_messages: true})
enablingAlert = true
try {
const {error} = await createDmAlert()
if (error) {
return pushToast({theme: "error", message: error})
}
} finally {
enablingAlert = false
}
}
const disableAlerts = async () => {
disablingAlert = true
try {
await waitForThunkCompletion(deleteAlert($dmAlert!))
} finally {
disablingAlert = false
}
}
let enablingAlert = $state(false)
let disablingAlert = $state(false)
const disableAlerts = () => publishSettings({...$userSettingsValues, alerts_messages: false})
</script>
<div class="col-2">
@@ -66,19 +32,15 @@
<Icon size={5} icon={Check} />
Mark all read
</Button>
{#if (!enablingAlert && $dmAlert) || disablingAlert}
<Button class="btn btn-neutral" onclick={disableAlerts} disabled={disablingAlert}>
{#if !disablingAlert}
<Icon size={4} icon={BellOff} />
{/if}
<Spinner loading={disablingAlert}>Disable alerts</Spinner>
{#if $userSettingsValues.alerts_messages}
<Button class="btn btn-neutral" onclick={disableAlerts}>
<Icon size={4} icon={BellOff} />
Disable alerts
</Button>
{:else}
<Button class="btn btn-neutral" onclick={enableAlerts} disabled={enablingAlert}>
{#if !enablingAlert}
<Icon size={4} icon={Bell} />
{/if}
<Spinner loading={enablingAlert}>Enable alerts</Spinner>
<Button class="btn btn-neutral" onclick={enableAlerts}>
<Icon size={4} icon={Bell} />
Enable alerts
</Button>
{/if}
</div>
@@ -18,7 +18,7 @@
let notificationCount = $state($notifications.size)
const playSound = () => {
if (enabled && $userSettingsValues.play_notification_sound) {
if (enabled && $userSettingsValues.alerts_sound) {
audioElement?.play()
}
}
+3 -17
View File
@@ -1,8 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {derived} from "svelte/store"
import {some} from "@welshman/lib"
import {displayRelayUrl, getTagValue, EVENT_TIME, ZAP_GOAL, THREAD, REPORT} from "@welshman/util"
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, REPORT} from "@welshman/util"
import {deriveRelay, pubkey} from "@welshman/app"
import {fly} from "@lib/transition"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
@@ -34,8 +33,6 @@
import RelayName from "@app/components/RelayName.svelte"
import SpaceMembers from "@app/components/SpaceMembers.svelte"
import SpaceReports from "@app/components/SpaceReports.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte"
import Alerts from "@app/components/Alerts.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte"
import MenuSpaceRoomItem from "@app/components/MenuSpaceRoomItem.svelte"
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
@@ -47,7 +44,6 @@
deriveOtherRooms,
userSpaceUrls,
hasNip29,
alertsById,
deriveUserCanCreateRoom,
deriveUserIsSpaceAdmin,
deriveEventsForUrl,
@@ -68,9 +64,6 @@
const members = deriveSpaceMembers(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}])
const hasAlerts = $derived(
some(a => getTagValue("feed", a.tags)?.includes(url), $alertsById.values()),
)
const spaceKinds = derived(
deriveEventsForUrl(url, [{kinds: CONTENT_KINDS}]),
@@ -101,13 +94,6 @@
const addRoom = () => pushModal(RoomCreate, {url}, {replaceState})
const manageAlerts = () => {
const component = hasAlerts ? Alerts : AlertAdd
const params = {url, channel: "push", hideSpaceField: true}
pushModal(component, params, {replaceState})
}
let showMenu = $state(false)
let replaceState = $state(false)
let element: Element | undefined = $state()
@@ -258,9 +244,9 @@
<Button class="btn btn-neutral btn-sm" onclick={showDetail}>
<SocketStatusIndicator {url} />
</Button>
<Button class="btn btn-neutral btn-sm" onclick={manageAlerts}>
<Link href="/settings/alerts" class="btn btn-neutral btn-sm">
<Icon icon={Bell} />
Manage Alerts
</Button>
</Link>
</div>
</div>