import './style.css'
import m from "mithril"
import {writable} from 'svelte/store'
import {getJson, removeNil, spec, parseJson, setJson, assoc, randomId, TIMEZONE, tryCatch, LOCALE} from '@welshman/lib'
import {withGetter} from '@welshman/store'
import {Router} from '@welshman/router'
import {validateFeed, ValidationError, displayFeeds, Feed} from '@welshman/feeds'
import {getAddress, getRelaysFromList, RelayMode, readList, asDecryptedEvent, normalizeRelayUrl, getTagValue, getTagValues, makeEvent, DELETE, TrustedEvent, StampedEvent, FEED, Address, getIdFilters, fromNostrURI, RELAYS} from '@welshman/util'
import {load, publish, defaultSocketPolicies, makeSocketPolicyAuth} from '@welshman/net'
import type {ISigner} from '@welshman/signer'
import {Nip07Signer, decrypt} from '@welshman/signer'
// Constants
const NOTIFIER_PUBKEY = import.meta.env.VITE_NOTIFIER_PUBKEY
const NOTIFIER_RELAY = import.meta.env.VITE_NOTIFIER_RELAY ? normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY) : undefined
const INDEXER_RELAYS = import.meta.env.VITE_INDEXER_RELAYS.split(',').map(normalizeRelayUrl)
const ALERT = 32830
const ALERT_STATUS = 32831
const TZ_OFFSET = parseInt(TIMEZONE.split(':')[0]!)
const CRON_DAILY_PATTERN = /^0 \d{1,2} \d{1,2} \* \* \*$/
const CRON_WEEKLY_PATTERN = /^0 \d{1,2} \d{1,2} \* \* 1$/
const PLUS_ICON = `
`
const TRASH_ICON = `
`
const ARROW_LEFT_ICON = `
`
// Types and state
type Alert = {
event: TrustedEvent
tags: string[][]
}
type AlertStatus = {
event: TrustedEvent
tags: string[][]
}
type AlertValues = {
feedAddress: string
freq: string
time: string,
email: string
secret: string
}
type State = {
failedToLogin: boolean
signer: ISigner
pubkey: string | undefined
alerts: Alert[]
alertDraft?: AlertValues,
alertStatuses: AlertStatus[]
alertsLoading: boolean
}
const state = withGetter(
writable({
failedToLogin: false,
signer: new Nip07Signer(),
pubkey: getJson('pubkey'),
alerts: [],
alertStatuses: [],
alertsLoading: false,
} as State)
)
// Actions
const login = async () => {
const {signer} = state.get()
try {
const pubkey = await signer.getPubkey()
state.update(assoc('pubkey', pubkey))
setJson('pubkey', pubkey)
} catch (e) {
state.update(assoc('failedToLogin', true))
}
}
const loadAlerts = async () => {
const {signer, pubkey} = state.get()
if (!NOTIFIER_RELAY) {
state.update(assoc('alertsLoading', false))
return
}
state.update(assoc('alertsLoading', true))
const events = await load({
relays: [NOTIFIER_RELAY],
filters: [
{kinds: [ALERT], authors: [pubkey!]},
{kinds: [ALERT_STATUS], "#p": [pubkey!]},
],
})
const alerts = await Promise.all(
events
.filter(spec({kind: ALERT}))
.map(async event => {
const tags = parseJson(await decrypt(signer, NOTIFIER_PUBKEY, event.content))
return {event, tags}
})
)
const alertStatuses = await Promise.all(
events
.filter(spec({kind: ALERT_STATUS}))
.map(async event => {
const tags = parseJson(await decrypt(signer, NOTIFIER_PUBKEY, event.content))
return {event, tags}
})
)
state.update($state => ({...$state, alertsLoading: false, alerts, alertStatuses}))
}
const deleteAlert = async (alert: Alert) => {
if (!NOTIFIER_RELAY) return
if (confirm("Are you sure you want to delete this alert?")) {
state.update(assoc('alertsLoading', true))
await publish({
relays: [NOTIFIER_RELAY],
event: await state.get().signer!.sign(
makeEvent(DELETE, {
tags: [
["k", String(alert.event.kind)],
["a", getAddress(alert.event)]
],
})
),
})
await loadAlerts()
}
}
export type AlertParams = {
feeds: Feed[]
freq: string
time: string
email: string
secret: string
}
export const makeAlert = async ({freq, time, email, feeds, secret}: AlertParams) => {
const {signer} = state.get()
const [hour, minute] = time.split(':')
const utcHour = (parseInt(hour) - TZ_OFFSET) % 24
const dow = freq === 'daily' ? '*' : freq
const cron = `0 ${minute} ${utcHour} * * ${dow}`
const tags = [
["cron", cron],
["email", email],
["channel", "email"],
["locale", LOCALE],
["timezone", TIMEZONE],
[
"handler",
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1685968093690",
"wss://relay.nostr.band/",
"web",
],
]
for (const feed of feeds) {
tags.push(["feed", JSON.stringify(feed)])
}
return signer.sign(
makeEvent(ALERT, {
content: await signer.nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
tags: [
["d", randomId()],
["p", NOTIFIER_PUBKEY],
],
})
)
}
export const publishAlert = async (params: AlertParams) => {
if (!NOTIFIER_RELAY) return
await publish({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
}
// Components
const Loader = {
view: () => m("div", { class: "flex justify-center py-4" }, [
m("div", {
class: "animate-spin rounded-full h-8 w-8 border-4 border-purple-200 border-t-purple-600"
})
])
}
const Login = {
view: () =>
m("button", {
onclick: login,
class: "w-full bg-purple-600 text-white font-semibold py-2 px-4 rounded-lg hover:bg-purple-700 transition-colors"
}, "Connect with Nostr"),
}
const AlertStatus: m.Component<{alert: Alert}> = {
view: vnode => {
const {alert} = vnode.attrs
const {alertStatuses} = state.get()
const address = getAddress(alert.event)
const alertStatus = alertStatuses.find(s => getTagValue('d', s.event.tags) === address)
const status = getTagValue('status', alertStatus?.tags || [])
const message = getTagValue('message', alertStatus?.tags || [])
const getStatusClasses = () => {
const baseClasses = "rounded-full px-3 py-1 text-sm border"
if (status === 'ok') return `${baseClasses} border-green-500 text-green-500`
if (status === 'pending') return `${baseClasses} border-yellow-500 text-yellow-500`
return `${baseClasses} border-red-500 text-red-500`
}
const getStatusDisplay = () => {
if (!status) return 'Inactive'
if (status === 'ok') return 'Active'
if (status === 'pending') return 'Pending'
return status.replace('-', ' ').replace(/^(.)/, x => x.toUpperCase())
}
return m("div", {class: getStatusClasses(), tooltip: message}, getStatusDisplay())
},
}
const AlertListItem: m.Component<{alert: Alert}> = {
view: vnode => {
const {alert} = vnode.attrs
const cron = getTagValue('cron', alert.tags)
const feeds = getTagValues('feed', alert.tags)
const channel = getTagValue('channel', alert.tags)
const description = displayFeeds(feeds.map(feed => parseJson(feed))) || "[invalid feed]"
let frequency = cron || "Unknown"
if (cron) {
if (CRON_DAILY_PATTERN.test(cron)) {
frequency = 'Daily'
} else if (CRON_WEEKLY_PATTERN.test(cron)) {
frequency = 'Weekly'
}
}
return m("div", { class: "flex items-start justify-between p-4" }, [
m("button", {
onclick: () => deleteAlert(alert),
class: "mr-4 mt-1",
tooltip: "Delete alert"
}, [m.trust(TRASH_ICON)]),
m("div", { class: "space-y-2 flex-grow" }, [
m("div", { class: "text-gray-600" }, `${frequency} alert via ${channel}`),
m("div", { class: "text-sm text-gray-500" }, `Events ${description}`)
]),
m(AlertStatus, {alert}),
])
}
}
const AlertList = {
oninit: loadAlerts,
view: () => {
const {alerts, alertsLoading} = state.get()
const content = alertsLoading
? m(Loader)
: alerts.length > 0
? alerts.map(alert => m(AlertListItem, {alert, key: alert.event.id}))
: m("div", { class: "text-center text-gray-500 py-8" }, [
"You don't have any alerts set up.",
])
return m("div", { class: "space-y-4" }, [
m("div", { class: "flex items-center justify-between mb-6" }, [
m("h1", { class: "text-2xl font-bold text-gray-900" }, "Your Nostr Alerts"),
m("a", {
href: "#!/alerts/new",
class: "flex items-center gap-2 bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors",
}, [
m.trust(PLUS_ICON),
"Add Alert"
])
]),
m("div", { class: "bg-white shadow rounded-lg p-6" }, content)
])
}
}
const AlertCreate = {
oninit: () => {
state.update(assoc('alertDraft', {
email: getTagValue('email', state.get().alerts[0]?.tags || []) || "",
freq: 'daily',
time: '17:00',
feedAddress: "",
secret: "",
}))
},
view: () => {
const {pubkey, alertDraft, alertsLoading} = state.get()
const {email, feedAddress, freq, time, secret} = alertDraft!
const update = (newValues: Partial) => {
state.update(assoc('alertDraft', {...alertDraft, ...newValues}))
}
const submit = async (e: Event) => {
e.preventDefault()
state.update(assoc('alertsLoading', true))
try {
if (!email.includes("@")) return alert("Please provide a valid email address")
const address = tryCatch(() => Address.fromNaddr(fromNostrURI(feedAddress)))
if (!address) return alert("Please provide a valid feed address")
if (address.kind !== FEED) return alert(`Please provide a valid feed address (kind ${FEED})`)
const selections = await load({
relays: INDEXER_RELAYS,
filters: [{kinds: [RELAYS], authors: [pubkey!, address.pubkey]}],
})
const router = Router.get()
const filters = getIdFilters([address.toString()])
const scenario = router.merge([
router.FromRelays(selections.flatMap(e => getRelaysFromList(readList(asDecryptedEvent(e)), RelayMode.Write))),
router.FromRelays(address.relays),
router.FromRelays(INDEXER_RELAYS),
])
const relays = scenario.limit(10).getUrls()
const [event] = await load({relays, filters})
if (!event) return alert("Sorry, we weren't able to find that feed")
const feedStrings = getTagValues('feed', event.tags)
if (feedStrings.length === 0) return alert('At least one feed is required')
const feeds = removeNil(feedStrings.map(parseJson))
if (feeds.length < feedStrings.length) return alert("At least one feed is invalid (must be valid JSON)")
const feedError = feeds.map(validateFeed).find(e => e instanceof ValidationError)
if (feedError) return alert(`At least one feed is invalid (${feedError.data.toLowerCase()}).`)
await publishAlert({freq, time, email, feeds, secret})
m.route.set("/alerts")
} catch (error) {
alert("Failed to create alert. Please try again.")
console.error('Error creating alert:', error)
} finally {
state.update(assoc('alertsLoading', false))
}
}
return m("div", { class: "space-y-4" }, [
m("div", { class: "flex items-center gap-4 mb-6" }, [
m("button", {
onclick: () => m.route.set("/alerts"),
class: "text-gray-600 hover:text-gray-900 cursor-pointer",
tooltip: "Back to alerts"
}, m.trust(ARROW_LEFT_ICON)),
m("h1", { class: "text-2xl font-bold text-gray-900" }, "Create Alert")
]),
m("div", { class: "bg-white shadow rounded-lg p-6" }, [
m("form", { class: "space-y-6", onsubmit: submit }, [
m("div", [
m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Email"),
m("input", {
type: "email",
placeholder: "Enter your email address",
value: email,
oninput: (e: InputEvent) => update({email: (e.target as HTMLInputElement).value}),
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500"
})
]),
m("div", {class: "w-full flex gap-2"}, [
m("div", {class: "flex-grow"}, [
m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Frequency"),
m("select", {
value: freq,
onchange: (e: Event) => update({freq: (e.target as HTMLSelectElement).value}),
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500"
}, [
m("option", { value: 'daily' }, "Daily"),
m("option", { value: '0' }, "Weekly on Sunday"),
m("option", { value: '1' }, "Weekly on Monday"),
m("option", { value: '2' }, "Weekly on Tuesday"),
m("option", { value: '3' }, "Weekly on Wednesday"),
m("option", { value: '4' }, "Weekly on Thursday"),
m("option", { value: '5' }, "Weekly on Friday"),
m("option", { value: '6' }, "Weekly on Saturday"),
])
]),
m("div", [
m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Time"),
m("input", {
value: time,
onchange: (e: Event) => update({time: (e.target as HTMLSelectElement).value}),
type: "time",
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500"
})
]),
]),
m("div", [
m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Feed Address"),
m("div", { class: "space-y-2" }, [
m("input", {
type: "text",
placeholder: "naddr1...",
value: feedAddress,
oninput: (e: InputEvent) => update({feedAddress: (e.target as HTMLInputElement).value}),
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500"
}),
m("p", { class: "text-sm text-gray-500" }, [
"Visit ",
m("a", {
href: "https://coracle.social/feeds",
target: "_blank",
class: "text-purple-600 hover:text-purple-800"
}, "coracle.social/feeds"),
" to search for existing feeds or create a new one. Copy the feed address (starts with 'naddr1') and paste it here."
])
])
]),
m("div", { class: "flex justify-end" }, [
m("button", {
type: "submit",
disabled: alertsLoading,
class: "bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
}, alertsLoading ? "Creating..." : "Create Alert")
])
])
])
])
}
}
const FailedToLogin = {
view: () =>
m("div", { class: "space-y-6 text-center" }, [
m("div", { class: "bg-red-50 border border-red-200 rounded-lg p-6" }, [
m("h2", { class: "text-red-800 font-semibold mb-2" }, "Unable to Connect"),
m("p", { class: "text-red-600 mb-4" }, "To use Anchor Alerts, you need a Nostr signer extension installed in your browser."),
m("div", { class: "space-y-3" }, [
m("button", {
onclick: () => window.location.reload(),
class: "w-full bg-red-100 text-red-700 font-medium py-2 px-4 rounded-lg hover:bg-red-200 transition-colors"
}, "Try Again"),
m("a", {
href: "https://nostrapps.com/#signers",
target: "_blank",
class: "block w-full bg-purple-600 text-white font-medium py-2 px-4 rounded-lg hover:bg-purple-700 transition-colors"
}, "Install a Nostr Signer")
])
])
])
}
const Layout: m.Component<{children: m.Children}> = {
view: vnode => {
const {children} = vnode.attrs
const {failedToLogin, pubkey} = state.get()
if (failedToLogin) {
return m(FailedToLogin)
}
if (!pubkey) {
return m("div", { class: "text-center space-y-4" }, [
m("h1", { class: "text-2xl font-bold text-gray-900 mb-2" }, "Welcome to Anchor Alerts"),
m("p", { class: "text-gray-600 mb-6" }, "Connect your Nostr signer to get started"),
m(Login)
])
}
return children
}
}
m.route(document.querySelector('#app')!, "/alerts", {
"/alerts": {
view: () => {
return m(Layout, {children: [m(AlertList)]})
},
},
"/alerts/new": {
view: () => {
return m(Layout, {children: [m(AlertCreate)]})
}
},
})
state.subscribe(() => m.redraw())
defaultSocketPolicies.push(
makeSocketPolicyAuth({
sign: (event: StampedEvent) => {
return state.get().signer?.sign(event)
},
}),
)
Object.assign(window, {setJson, getJson})