merge: main into branch to pick up lint fixes
This commit is contained in:
commit
8866ef3256
22 changed files with 5 additions and 758 deletions
|
|
@ -1,6 +1,5 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
web/dist/
|
|
||||||
.git/
|
.git/
|
||||||
.gitattributes
|
.gitattributes
|
||||||
.gitignore
|
.gitignore
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,7 @@ COPY src/ ./src/
|
||||||
COPY src/pages/ ./src/pages/
|
COPY src/pages/ ./src/pages/
|
||||||
COPY src/emails/ ./src/emails/
|
COPY src/emails/ ./src/emails/
|
||||||
|
|
||||||
# Build web UI
|
# Build TypeScript (runs tsc && build:html)
|
||||||
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml web/tsconfig.json web/vite.config.js web/index.html ./web/
|
|
||||||
COPY web/src/ ./web/src/
|
|
||||||
RUN cd web && pnpm install --frozen-lockfile && pnpm run build
|
|
||||||
|
|
||||||
# Build TypeScript (runs tsc && build:html && build:web)
|
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
# Production image
|
# Production image
|
||||||
|
|
@ -51,7 +46,6 @@ RUN pnpm install --frozen-lockfile --prod
|
||||||
|
|
||||||
# Copy build artifacts
|
# Copy build artifacts
|
||||||
COPY --from=build /app/dist/ ./dist/
|
COPY --from=build /app/dist/ ./dist/
|
||||||
COPY --from=build /app/web/dist/ ./web/dist/
|
|
||||||
COPY --from=build /app/src/pages/ ./dist/pages/
|
COPY --from=build /app/src/pages/ ./dist/pages/
|
||||||
COPY --from=build /app/src/emails/ ./dist/emails/
|
COPY --from=build /app/src/emails/ ./dist/emails/
|
||||||
|
|
||||||
|
|
|
||||||
2
build-in-production.sh
Executable file → Normal file
2
build-in-production.sh
Executable file → Normal file
|
|
@ -2,11 +2,9 @@
|
||||||
|
|
||||||
# Remove link overrides
|
# Remove link overrides
|
||||||
node remove-pnpm-overrides.js package.json
|
node remove-pnpm-overrides.js package.json
|
||||||
node remove-pnpm-overrides.js web/package.json
|
|
||||||
|
|
||||||
# When CI=true as it is on render.com, removing link overrides breaks the lockfile
|
# When CI=true as it is on render.com, removing link overrides breaks the lockfile
|
||||||
pnpm i --no-frozen-lockfile
|
pnpm i --no-frozen-lockfile
|
||||||
(cd web && pnpm i --no-frozen-lockfile)
|
|
||||||
|
|
||||||
# Build everything
|
# Build everything
|
||||||
pnpm run build
|
pnpm run build
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc && pnpm run build:html && pnpm run build:web",
|
"build": "tsc && pnpm run build:html",
|
||||||
"build:web": "cd web && pnpm run build",
|
|
||||||
"build:html": "cp -r src/pages dist/ && cp -r src/emails dist/",
|
"build:html": "cp -r src/pages dist/ && cp -r src/emails dist/",
|
||||||
"check": "tsc --noEmit && eslint src",
|
"check": "tsc --noEmit && eslint src",
|
||||||
"format": "prettier --write \"src/**/*.{ts,js,json,html}\"",
|
"format": "prettier --write \"src/**/*.{ts,js,json,html}\"",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { instrument } from 'succinct-async'
|
import { instrument } from 'succinct-async'
|
||||||
import type { Subscription } from './alert.js'
|
|
||||||
import { getCronExpression } from './alert.js'
|
|
||||||
import * as mailer from './mailer.js'
|
import * as mailer from './mailer.js'
|
||||||
import * as worker from './worker/index.js'
|
import * as worker from './worker/index.js'
|
||||||
import * as db from './database.js'
|
import * as db from './database.js'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { CronExpressionParser } from 'cron-parser'
|
|
||||||
import { tryCatch, int, HOUR } from '@welshman/lib'
|
|
||||||
|
|
||||||
export type Subscription = {
|
export type Subscription = {
|
||||||
id: string
|
id: string
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
import { neventEncode, decode } from 'nostr-tools/nip19'
|
import { neventEncode, decode } from 'nostr-tools/nip19'
|
||||||
import {
|
import {
|
||||||
spec,
|
spec,
|
||||||
now,
|
|
||||||
sortBy,
|
sortBy,
|
||||||
groupBy,
|
groupBy,
|
||||||
displayList,
|
displayList,
|
||||||
nth,
|
|
||||||
nthEq,
|
|
||||||
dateToSeconds,
|
|
||||||
secondsToDate,
|
secondsToDate,
|
||||||
} from '@welshman/lib'
|
} from '@welshman/lib'
|
||||||
import { parse, truncate, renderAsHtml } from '@welshman/content'
|
import { parse, truncate, renderAsHtml } from '@welshman/content'
|
||||||
|
|
@ -15,8 +11,6 @@ import {
|
||||||
TrustedEvent,
|
TrustedEvent,
|
||||||
normalizeRelayUrl,
|
normalizeRelayUrl,
|
||||||
getParentId,
|
getParentId,
|
||||||
getIdFilters,
|
|
||||||
getReplyFilters,
|
|
||||||
NOTE,
|
NOTE,
|
||||||
COMMENT,
|
COMMENT,
|
||||||
REACTION,
|
REACTION,
|
||||||
|
|
@ -24,15 +18,13 @@ import {
|
||||||
displayPubkey,
|
displayPubkey,
|
||||||
getTagValue,
|
getTagValue,
|
||||||
} from '@welshman/util'
|
} from '@welshman/util'
|
||||||
import { Loader, AdapterContext, makeLoader, SocketAdapter } from '@welshman/net'
|
|
||||||
import { call } from '@welshman/lib'
|
|
||||||
import { displayDuration, createElement } from './util.js'
|
import { displayDuration, createElement } from './util.js'
|
||||||
import type { Subscription } from './alert.js'
|
import type { Subscription } from './alert.js'
|
||||||
import { sendDigest } from './mailer.js'
|
import { sendDigest } from './mailer.js'
|
||||||
import { EVENT_VIEWER_URL } from './env.js'
|
import { EVENT_VIEWER_URL } from './env.js'
|
||||||
import {
|
import {
|
||||||
profilesByPubkey,
|
profilesByPubkey,
|
||||||
loadRelaySelections,
|
|
||||||
loadProfile,
|
loadProfile,
|
||||||
} from './repository.js'
|
} from './repository.js'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import 'dotenv/config'
|
import 'dotenv/config'
|
||||||
import { always } from '@welshman/lib'
|
import { always } from '@welshman/lib'
|
||||||
import { normalizeRelayUrl } from '@welshman/util'
|
import { normalizeRelayUrl } from '@welshman/util'
|
||||||
import { netContext } from '@welshman/net'
|
|
||||||
import { Nip01Signer } from '@welshman/signer'
|
import { Nip01Signer } from '@welshman/signer'
|
||||||
import { routerContext } from '@welshman/router'
|
import { routerContext } from '@welshman/router'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,6 @@ server.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
|
||||||
server.use(express.json())
|
server.use(express.json())
|
||||||
|
|
||||||
server.use(express.static('web/dist'))
|
|
||||||
|
|
||||||
// Rate limit for registration endpoints
|
// Rate limit for registration endpoints
|
||||||
server.use(
|
server.use(
|
||||||
'/subscription',
|
'/subscription',
|
||||||
|
|
@ -71,16 +69,6 @@ const addRoute = (method: 'get' | 'post' | 'put' | 'delete', path: string, handl
|
||||||
}
|
}
|
||||||
|
|
||||||
addRoute('get', '/', async (req: Request, res: Response) => {
|
addRoute('get', '/', async (req: Request, res: Response) => {
|
||||||
try {
|
|
||||||
const {existsSync} = await import('fs')
|
|
||||||
const webIndex = new URL('../web/dist/index.html', import.meta.url)
|
|
||||||
if (existsSync(webIndex)) {
|
|
||||||
return res.send(await render('../web/dist/index.html'))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall through to JSON
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
name: 'Mailship',
|
name: 'Mailship',
|
||||||
description: 'Email notification server for Nostr',
|
description: 'Email notification server for Nostr',
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ export const removeJob = (sub: Subscription) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Daily purge of events older than 7 days
|
// Daily purge of events older than 7 days
|
||||||
const purgeJob = CronJob.from({
|
CronJob.from({
|
||||||
cronTime: '0 0 3 * * *', // 3am UTC daily
|
cronTime: '0 0 3 * * *', // 3am UTC daily
|
||||||
onTick: async () => {
|
onTick: async () => {
|
||||||
const weekAgo = Math.floor(Date.now() / 1000) - 7 * 24 * 3600
|
const weekAgo = Math.floor(Date.now() / 1000) - 7 * 24 * 3600
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
VITE_NOTIFIER_PUBKEY=
|
|
||||||
VITE_NOTIFIER_RELAY=
|
|
||||||
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band
|
|
||||||
24
web/.gitignore
vendored
24
web/.gitignore
vendored
|
|
@ -1,24 +0,0 @@
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import js from "@eslint/js";
|
|
||||||
import globals from "globals";
|
|
||||||
import tseslint from "typescript-eslint";
|
|
||||||
import { defineConfig } from "eslint/config";
|
|
||||||
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
{ files: ["**/*.{js,mjs,cjs,ts}"], plugins: { js }, extends: ["js/recommended"] },
|
|
||||||
{ files: ["**/*.{js,mjs,cjs,ts}"], languageOptions: { globals: globals.browser } },
|
|
||||||
tseslint.configs.recommended,
|
|
||||||
{
|
|
||||||
files: ["src/**/*.{js,mjs,cjs,ts}"],
|
|
||||||
rules: {
|
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
|
||||||
"@typescript-eslint/no-unused-vars": [
|
|
||||||
"error",
|
|
||||||
{args: "none", destructuredArrayIgnorePattern: "^_d?$", caughtErrors: "none"},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Anchor Alerts</title>
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-50 min-h-screen">
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<div id="app" class="max-w-2xl mx-auto"></div>
|
|
||||||
</div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
{
|
|
||||||
"name": "web",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc && vite build",
|
|
||||||
"check": "tsc --noEmit && eslint src",
|
|
||||||
"format": "eslint src --fix"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.25.1",
|
|
||||||
"@types/mithril": "^2.2.7",
|
|
||||||
"eslint": "^9.25.1",
|
|
||||||
"globals": "^16.0.0",
|
|
||||||
"typescript": "~5.7.2",
|
|
||||||
"typescript-eslint": "^8.31.1",
|
|
||||||
"vite": "^6.3.1"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
|
||||||
"@welshman/feeds": "^0.6.3",
|
|
||||||
"@welshman/lib": "^0.6.3",
|
|
||||||
"@welshman/net": "^0.6.3",
|
|
||||||
"@welshman/signer": "^0.6.3",
|
|
||||||
"@welshman/store": "^0.6.3",
|
|
||||||
"@welshman/util": "^0.6.3",
|
|
||||||
"events": "^3.3.0",
|
|
||||||
"mithril": "^2.2.15",
|
|
||||||
"svelte": "^5.27.2",
|
|
||||||
"tailwindcss": "^4.1.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
|
@ -1,2 +0,0 @@
|
||||||
allowBuilds:
|
|
||||||
esbuild: true
|
|
||||||
544
web/src/main.ts
544
web/src/main.ts
|
|
@ -1,544 +0,0 @@
|
||||||
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 || 'purplepag.es,relay.damus.io,relay.nostr.band').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 = `
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>`
|
|
||||||
|
|
||||||
const TRASH_ICON = `
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M20.5 6H3.49991" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<path d="M18.8333 8.5L18.3734 15.3991C18.1964 18.054 18.1079 19.3815 17.2429 20.1907C16.3779 21 15.0475 21 12.3867 21H11.6133C8.95252 21 7.62212 21 6.75711 20.1907C5.8921 19.3815 5.80361 18.054 5.62661 15.3991L5.16667 8.5" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<path d="M6.5 6C6.55588 6 6.58382 6 6.60915 5.99936C7.43259 5.97849 8.15902 5.45491 8.43922 4.68032C8.44784 4.65649 8.45667 4.62999 8.47434 4.57697L8.57143 4.28571C8.65431 4.03708 8.69575 3.91276 8.75071 3.8072C8.97001 3.38607 9.37574 3.09364 9.84461 3.01877C9.96213 3 10.0932 3 10.3553 3H13.6447C13.9068 3 14.0379 3 14.1554 3.01877C14.6243 3.09364 15.03 3.38607 15.2493 3.8072C15.3043 3.91276 15.3457 4.03708 15.4286 4.28571L15.5257 4.57697C15.5433 4.62992 15.5522 4.65651 15.5608 4.68032C15.841 5.45491 16.5674 5.97849 17.3909 5.99936C17.4162 6 17.4441 6 17.5 6" stroke="#1C274C" stroke-width="1.5"/>
|
|
||||||
</svg>`
|
|
||||||
|
|
||||||
const ARROW_LEFT_ICON = `
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M19 12H5M5 12L12 19M5 12L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>`
|
|
||||||
|
|
||||||
// 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<AlertValues>) => {
|
|
||||||
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})
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
a, button {
|
|
||||||
@apply cursor-pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tooltip styling */
|
|
||||||
[tooltip] {
|
|
||||||
@apply cursor-pointer relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
[tooltip]:hover::after {
|
|
||||||
content: attr(tooltip);
|
|
||||||
position: absolute;
|
|
||||||
bottom: 100%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
padding: 4px 8px;
|
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
|
||||||
color: white;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
white-space: nowrap;
|
|
||||||
z-index: 1000;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
/* Animation properties */
|
|
||||||
opacity: 0;
|
|
||||||
animation: tooltipFadeIn 0.2s ease-in-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keyframes for fade in animation */
|
|
||||||
@keyframes tooltipFadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-50%) translateY(0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(-50%) translateY(-3px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
web/src/vite-env.d.ts
vendored
1
web/src/vite-env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
server: {
|
|
||||||
port: 2893,
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
tailwindcss(),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
Loading…
Reference in a new issue