Add contributing file, rename some files
This commit is contained in:
@@ -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})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import {synced, localStorageProvider} from "@welshman/store"
|
||||
|
||||
export const theme = synced({
|
||||
key: "theme",
|
||||
defaultValue: "dark",
|
||||
storage: localStorageProvider,
|
||||
})
|
||||
@@ -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!"})
|
||||
}
|
||||
@@ -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")
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user