Add contributing file, rename some files

This commit is contained in:
Jon Staab
2025-08-21 15:01:31 -07:00
parent d4943daa82
commit ba80ebac63
155 changed files with 438 additions and 349 deletions
+20
View File
@@ -0,0 +1,20 @@
/* eslint prefer-rest-params: 0 */
import {page} from "$app/stores"
import {getSetting} from "@app/core/state"
const w = window as any
w.plausible =
w.plausible ||
function () {
;(w.plausible.q = w.plausible.q || []).push(arguments)
}
export const setupAnalytics = () => {
page.subscribe($page => {
if ($page.route && getSetting("report_usage")) {
w.plausible("pageview", {u: $page.route.id})
}
})
}
+48
View File
@@ -0,0 +1,48 @@
import type {Component} from "svelte"
import {writable} from "svelte/store"
import {randomId, always, assoc, Emitter} from "@welshman/lib"
import {goto} from "$app/navigation"
export type ModalOptions = {
drawer?: boolean
fullscreen?: boolean
replaceState?: boolean
path?: string
}
export type Modal = {
id: string
component: Component
props: Record<string, any>
options: ModalOptions
}
export const emitter = new Emitter()
export const modals = writable<Record<string, Modal>>({})
export const pushModal = (
component: Component<any>,
props: Record<string, any> = {},
options: ModalOptions = {},
) => {
const id = randomId()
const path = options.path || ""
modals.update(assoc(id, {id, component, props, options}))
goto(path + "#" + id, {replaceState: options.replaceState})
return id
}
export const pushDrawer = (
component: Component<any>,
props: Record<string, any> = {},
options: ModalOptions = {},
) => pushModal(component, props, {...options, drawer: true})
export const clearModals = () => {
modals.update(always({}))
emitter.emit("close")
}
+60
View File
@@ -0,0 +1,60 @@
import {writable} from "svelte/store"
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import {
NIP46_PERMS,
PLATFORM_URL,
PLATFORM_NAME,
PLATFORM_LOGO,
SIGNER_RELAYS,
} from "@app/core/state"
import {pushToast} from "@app/util/toast"
export class Nip46Controller {
url = writable("")
bunker = writable("")
loading = writable(false)
clientSecret = makeSecret()
abortController = new AbortController()
broker = new Nip46Broker({clientSecret: this.clientSecret, relays: SIGNER_RELAYS})
onNostrConnect: (response: Nip46ResponseWithResult) => void
constructor({onNostrConnect}: {onNostrConnect: (response: Nip46ResponseWithResult) => void}) {
this.onNostrConnect = onNostrConnect
}
async start() {
const url = await this.broker.makeNostrconnectUrl({
perms: NIP46_PERMS,
url: PLATFORM_URL,
name: PLATFORM_NAME,
image: PLATFORM_LOGO,
})
this.url.set(url)
let response
try {
response = await this.broker.waitForNostrconnect(url, this.abortController.signal)
} catch (errorResponse: any) {
if (errorResponse?.error) {
pushToast({
theme: "error",
message: `Received error from signer: ${errorResponse.error}`,
})
} else if (errorResponse) {
console.error(errorResponse)
}
}
if (response) {
this.loading.set(true)
this.onNostrConnect(response)
}
}
stop() {
this.broker.cleanup()
this.abortController.abort()
}
}
+151
View File
@@ -0,0 +1,151 @@
import {derived} from "svelte/store"
import {synced, localStorageProvider, throttled} from "@welshman/store"
import {pubkey, relaysByUrl} from "@welshman/app"
import {prop, spec, identity, now, groupBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {EVENT_TIME, MESSAGE, THREAD, COMMENT, getTagValue} from "@welshman/util"
import {
makeSpacePath,
makeChatPath,
makeThreadPath,
makeCalendarPath,
makeSpaceChatPath,
makeRoomPath,
} from "@app/util/routes"
import {chats, hasNip29, getUrlsForEvent, userRoomsByUrl, repositoryStore} from "@app/core/state"
// Checked state
export const checked = synced<Record<string, number>>({
key: "checked",
defaultValue: {},
storage: localStorageProvider,
})
export const deriveChecked = (key: string) => derived(checked, prop(key))
export const setChecked = (key: string) => checked.update(state => ({...state, [key]: now()}))
// Derived notifications state
export const notifications = derived(
throttled(
1000,
derived(
[pubkey, checked, chats, userRoomsByUrl, repositoryStore, getUrlsForEvent, relaysByUrl],
identity,
),
),
([$pubkey, $checked, $chats, $userRoomsByUrl, $repository, $getUrlsForEvent, $relaysByUrl]) => {
const hasNotification = (path: string, latestEvent: TrustedEvent | undefined) => {
if (!latestEvent || latestEvent.pubkey === $pubkey) {
return false
}
for (const [entryPath, ts] of Object.entries($checked)) {
const isMatch =
entryPath === "*" ||
entryPath.startsWith(path) ||
(entryPath === "/chat/*" && path.startsWith("/chat/"))
if (isMatch && ts > latestEvent.created_at) {
return false
}
}
return true
}
const paths = new Set<string>()
for (const {pubkeys, messages} of $chats) {
const chatPath = makeChatPath(pubkeys)
if (hasNotification(chatPath, messages[0])) {
paths.add("/chat")
paths.add(chatPath)
}
}
const allThreadEvents = $repository.query([
{kinds: [THREAD]},
{kinds: [COMMENT], "#K": [String(THREAD)]},
])
const allCalendarEvents = $repository.query([
{kinds: [EVENT_TIME]},
{kinds: [COMMENT], "#K": [String(EVENT_TIME)]},
])
const allMessageEvents = $repository.query([{kinds: [MESSAGE]}])
for (const [url, rooms] of $userRoomsByUrl.entries()) {
const spacePath = makeSpacePath(url)
const threadPath = makeThreadPath(url)
const calendarPath = makeCalendarPath(url)
const messagesPath = makeSpaceChatPath(url)
const threadEvents = allThreadEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
const calendarEvents = allCalendarEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
const messagesEvents = allMessageEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
if (hasNotification(threadPath, threadEvents[0])) {
paths.add(spacePath)
paths.add(threadPath)
}
if (hasNotification(calendarPath, calendarEvents[0])) {
paths.add(spacePath)
paths.add(calendarPath)
}
const commentsByThreadId = groupBy(
e => getTagValue("E", e.tags),
threadEvents.filter(spec({kind: COMMENT})),
)
for (const [threadId, [comment]] of commentsByThreadId.entries()) {
const threadItemPath = makeThreadPath(url, threadId)
if (hasNotification(threadItemPath, comment)) {
paths.add(threadItemPath)
}
}
const commentsByEventId = groupBy(
e => getTagValue("E", e.tags),
calendarEvents.filter(spec({kind: COMMENT})),
)
for (const [eventId, [comment]] of commentsByEventId.entries()) {
const calendarEventPath = makeCalendarPath(url, eventId)
if (hasNotification(calendarEventPath, comment)) {
paths.add(calendarEventPath)
}
}
if (hasNip29($relaysByUrl.get(url))) {
for (const room of rooms) {
const roomPath = makeRoomPath(url, room)
const latestEvent = allMessageEvents.find(
e =>
$getUrlsForEvent(e.id).includes(url) &&
e.tags.find(t => t[0] === "h" && t[1] === room),
)
if (hasNotification(roomPath, latestEvent)) {
paths.add(spacePath)
paths.add(roomPath)
}
}
} else {
if (hasNotification(messagesPath, messagesEvents[0])) {
paths.add(spacePath)
paths.add(messagesPath)
}
}
}
return paths
},
)
+131
View File
@@ -0,0 +1,131 @@
import * as nip19 from "nostr-tools/nip19"
import {Capacitor} from "@capacitor/core"
import type {ActionPerformed, RegistrationError, Token} from "@capacitor/push-notifications"
import {PushNotifications} from "@capacitor/push-notifications"
import {parseJson, poll} from "@welshman/lib"
import {isSignedEvent} from "@welshman/util"
import {goto} from "$app/navigation"
import {ucFirst} from "@lib/util"
import {VAPID_PUBLIC_KEY} from "@app/core/state"
export const platform = Capacitor.getPlatform()
export const platformName = platform === "ios" ? "iOS" : ucFirst(platform)
export const initializePushNotifications = () => {
if (platform === "web") return
PushNotifications.addListener("pushNotificationActionPerformed", (action: ActionPerformed) => {
const event = parseJson(action.notification.data.event)
const parsedRelays = parseJson(action.notification.data.relays)
const relays = Array.isArray(parsedRelays) ? parsedRelays : []
if (isSignedEvent(event)) {
goto("/" + nip19.neventEncode({id: event.id, relays}))
}
})
}
export const canSendPushNotifications = () => ["web", "android", "ios"].includes(platform)
export const getWebPushInfo = async () => {
if (!("serviceWorker" in navigator)) {
throw new Error("Service Worker not supported")
}
if (!("PushManager" in window)) {
throw new Error("Push notifications are not supported")
}
if (Notification.permission === "denied") {
throw new Error("Push notifications are blocked")
}
if (Notification.permission !== "granted") {
const permission = await Notification.requestPermission()
if (permission !== "granted") {
throw new Error("Push notification permission denied")
}
}
const registration = await navigator.serviceWorker.ready
let subscription = await registration.pushManager.getSubscription()
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID_PUBLIC_KEY,
})
}
const {keys} = subscription.toJSON()
if (!keys) {
throw new Error(`Failed to get push info: no keys were returned`)
}
return {
endpoint: subscription.endpoint,
p256dh: keys.p256dh,
auth: keys.auth,
}
}
export type PushInfo = {
device_token: string
bundle_identifier?: string
}
export const getCapacitorPushInfo = async () => {
let status = await PushNotifications.checkPermissions()
if (status.receive === "prompt") {
status = await PushNotifications.requestPermissions()
}
if (status.receive !== "granted") {
throw new Error("Failed to register for push notifications")
}
let device_token = ""
let error = "Failed to register for push notifications"
PushNotifications.addListener("registration", (token: Token) => {
device_token = token.value
})
PushNotifications.addListener("registrationError", (_error: RegistrationError) => {
error = _error.error
})
await PushNotifications.register()
await poll({
condition: () => Boolean(device_token),
signal: AbortSignal.timeout(5000),
})
if (!device_token) {
throw new Error(error)
}
const info: PushInfo = {device_token}
if (platform === "ios") {
info.bundle_identifier = "social.flotilla"
}
return info
}
export const getPushInfo = (): Promise<Record<string, string>> => {
switch (platform) {
case "web":
return getWebPushInfo()
case "ios":
case "android":
return getCapacitorPushInfo()
default:
throw new Error(`Invalid push platform: ${platform}`)
}
}
+139
View File
@@ -0,0 +1,139 @@
import type {Page} from "@sveltejs/kit"
import * as nip19 from "nostr-tools/nip19"
import {goto} from "$app/navigation"
import {nthEq, sleep} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {tracker} from "@welshman/app"
import {scrollToEvent} from "@lib/html"
import {identity} from "@welshman/lib"
import {
getTagValue,
DIRECT_MESSAGE,
DIRECT_MESSAGE_FILE,
MESSAGE,
THREAD,
ZAP_GOAL,
EVENT_TIME,
} from "@welshman/util"
import {
makeChatId,
entityLink,
decodeRelay,
encodeRelay,
userRoomsByUrl,
ROOM,
} from "@app/core/state"
export const makeSpacePath = (url: string, ...extra: (string | undefined)[]) => {
let path = `/spaces/${encodeRelay(url)}`
if (extra.length > 0) {
path +=
"/" +
extra
.filter(identity)
.map(s => encodeURIComponent(s as string))
.join("/")
}
return path
}
export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}`
export const makeRoomPath = (url: string, room: string) => `/spaces/${encodeRelay(url)}/${room}`
export const makeSpaceChatPath = (url: string) => makeRoomPath(url, "chat")
export const makeGoalPath = (url: string, eventId?: string) => makeSpacePath(url, "goals", eventId)
export const makeThreadPath = (url: string, eventId?: string) =>
makeSpacePath(url, "threads", eventId)
export const makeCalendarPath = (url: string, eventId?: string) =>
makeSpacePath(url, "calendar", eventId)
export const getPrimaryNavItem = ($page: Page) => $page.route?.id?.split("/")[1]
export const getPrimaryNavItemIndex = ($page: Page) => {
const urls = Array.from(userRoomsByUrl.get().keys())
switch (getPrimaryNavItem($page)) {
case "discover":
return urls.length + 2
case "spaces": {
const routeUrl = decodeRelay($page.params.relay)
return urls.findIndex(url => url === routeUrl) + 1
}
case "settings":
return urls.length + 3
default:
return 0
}
}
export const goToEvent = async (event: TrustedEvent, options: Record<string, any> = {}) => {
if (event.kind === DIRECT_MESSAGE || event.kind === DIRECT_MESSAGE_FILE) {
await scrollToEvent(event.id)
}
const urls = Array.from(tracker.getRelays(event.id))
const path = await getEventPath(event, urls)
if (path.includes("://")) {
window.open(path)
} else {
goto(path, options)
await sleep(300)
await scrollToEvent(event.id)
}
}
export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
const room = getTagValue(ROOM, event.tags)
if (urls.length > 0) {
const url = urls[0]
if (event.kind === ZAP_GOAL) {
return makeGoalPath(url, event.id)
}
if (event.kind === THREAD) {
return makeThreadPath(url, event.id)
}
if (event.kind === EVENT_TIME) {
return makeCalendarPath(url, event.id)
}
if (event.kind === MESSAGE) {
return room ? makeRoomPath(url, room) : makeSpacePath(url, "chat")
}
const kind = event.tags.find(nthEq(0, "K"))?.[1]
const id = event.tags.find(nthEq(0, "E"))?.[1]
if (id && kind) {
if (parseInt(kind) === ZAP_GOAL) {
return makeGoalPath(url, id)
}
if (parseInt(kind) === THREAD) {
return makeThreadPath(url, id)
}
if (parseInt(kind) === EVENT_TIME) {
return makeCalendarPath(url, id)
}
if (parseInt(kind) === MESSAGE) {
return room ? makeRoomPath(url, room) : makeSpacePath(url, "chat")
}
}
}
return entityLink(nip19.neventEncode({id: event.id, relays: urls}))
}
+7
View File
@@ -0,0 +1,7 @@
import {synced, localStorageProvider} from "@welshman/store"
export const theme = synced({
key: "theme",
defaultValue: "dark",
storage: localStorageProvider,
})
+41
View File
@@ -0,0 +1,41 @@
import type {Component} from "svelte"
import {writable} from "svelte/store"
import {randomId} from "@welshman/lib"
import {copyToClipboard} from "@lib/html"
export type ToastParams = {
message?: string
timeout?: number
theme?: "error"
children?: {
component: Component<any>
props: Record<string, any>
}
action?: {
message: string
onclick: () => void
}
}
export type Toast = ToastParams & {
id: string
}
export const toast = writable<Toast | null>(null)
export const pushToast = (params: ToastParams) => {
const id = randomId()
toast.set({id, ...params})
setTimeout(() => popToast(id), params.timeout || 5000)
return id
}
export const popToast = (id: string) => toast.update($t => ($t?.id === id ? null : $t))
export const clip = (value: string) => {
copyToClipboard(value)
pushToast({message: "Copied to clipboard!"})
}
+20
View File
@@ -0,0 +1,20 @@
import * as Sentry from "@sentry/browser"
import {getSetting} from "@app/core/state"
export const setupTracking = () => {
if (import.meta.env.VITE_GLITCHTIP_API_KEY) {
Sentry.init({
dsn: import.meta.env.VITE_GLITCHTIP_API_KEY,
beforeSend(event: any) {
if (!getSetting("report_errors")) {
return null
}
return event
},
integrations(integrations) {
return integrations.filter(integration => integration.name !== "Breadcrumbs")
},
})
}
}