Compare commits

...

42 Commits

Author SHA1 Message Date
Jon Staab ea3f5a6779 Bump version 2025-05-06 17:06:18 -07:00
Jon Staab f5fce8e2e7 Bump welshman and signer plugin 2025-05-06 10:34:14 -07:00
Jon Staab 46b5c01c49 Allow use of cleartext relays on native 2025-05-06 09:50:05 -07:00
Jon Staab dd069329ee Add timezone and locale to alerts 2025-05-05 15:39:07 -07:00
Jon Staab c1b52b66ff Use lib version of date functions 2025-05-05 10:11:02 -07:00
Jon Staab 5873e8aa60 Fix modal stuff 2025-04-29 15:20:40 -07:00
Jon Staab c582082816 Fix link detail for authenticated images 2025-04-29 12:30:01 -07:00
Jon Staab 6ddba63ff9 Use space as blossom server if supported 2025-04-29 12:26:29 -07:00
Jon Staab 5a7750a91b Use user blossom server list for settings, add InputList 2025-04-29 11:04:39 -07:00
Jon Staab 8c71b7d9b9 Update welshman 2025-04-29 09:56:52 -07:00
Jon Staab b5a28c71ad Support auth-protected images 2025-04-28 15:46:48 -07:00
Jon Staab ccdd18a863 Fill in default email for alerts 2025-04-28 12:28:39 -07:00
Jon Staab 2244ecad9b Update alerts to use new anchor 2025-04-28 09:48:09 -07:00
Jon Staab da2457da9f Use new relay getters 2025-04-25 10:41:38 -07:00
Jon Staab c18b29e7d6 Update welshman stuff, fix bug in makeFeed 2025-04-24 12:35:41 -07:00
Jon Staab 3a954201ce Tweak boot, stop saving alert events 2025-04-23 11:05:28 -07:00
Jon Staab c8bc8ee8bf Fix thunk indicator 2025-04-16 14:19:09 -07:00
Jon Staab 8c3e52ce8c Update storage adapters 2025-04-16 14:08:58 -07:00
Jon Staab 303b8967e9 Remove aliases, their time has not yet come 2025-04-16 10:36:21 -07:00
Jon Staab f3debe6c02 Use new ALIAS kind 2025-04-15 15:45:48 -07:00
Jon Staab 374ca7f265 Add per-url aliases 2025-04-15 15:07:54 -07:00
Jon Staab 91689e5b90 Optionally protect profiles 2025-04-15 09:36:59 -07:00
Jon Staab a64eaba45c Fix modal flash 2025-04-14 17:13:16 -07:00
Jon Staab 394a1e7d30 Update to new thunk stuff 2025-04-14 16:50:48 -07:00
Jon Staab d5b1fab1e7 Tweak data loading 2025-04-11 14:44:27 -07:00
Jon Staab 10a1e6e640 Update welshman session stuff 2025-04-11 11:51:15 -07:00
Jon Staab 84af4d2d8e Update welshman stuff again 2025-04-11 09:27:19 -07:00
Jon Staab acddff79f0 Improve loading a bit 2025-04-11 08:41:50 -07:00
Jon Staab 489707b9b2 Switch to pnpm, use new welshman stuff 2025-04-09 15:32:35 -07:00
Jon Staab 33902dbefe Make calendar window smaller to avoid tag limits 2025-04-03 15:56:37 -07:00
Jon Staab 1b318a7a52 Fix reactions on mobile 2025-04-03 15:40:52 -07:00
Jon Staab b6a4b38d14 Make relays configurable 2025-04-03 15:35:56 -07:00
Jon Staab a3eb6d52c0 Fix nip46 signer connect 2025-03-24 12:40:47 -07:00
Jon Staab d2c537d275 Refactor login, pass bunker to alerts 2025-03-20 13:00:07 -07:00
Jon Staab 9eefd6600d Add handler for alerts 2025-03-20 09:38:57 -07:00
Jon Staab ad034b1641 Tweak layout css 2025-03-19 11:22:57 -07:00
Jon Staab d94860014c Fix chat spacing 2025-03-19 09:56:00 -07:00
Jon Staab 33af39ee93 Add calendar event editing 2025-03-18 15:36:52 -07:00
Jon Staab 1d56a2193d Clean up calendar header 2025-03-17 09:53:14 -07:00
Jon Staab 75905e4652 Take a guess at fixing android keyboard issue 2025-03-07 09:01:47 -08:00
Jon Staab d07b9cde5f Tweak spacing 2025-03-04 17:39:54 -08:00
Jon Staab d8a9cc5a7e Fix sizing for big chat inputs 2025-03-04 12:53:38 -08:00
111 changed files with 11570 additions and 17103 deletions
+2
View File
@@ -8,5 +8,7 @@ VITE_PLATFORM_LOGO=static/flotilla.png
VITE_PLATFORM_RELAY=
VITE_PLATFORM_ACCENT="#7161FF"
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/
VITE_SIGNER_RELAYS=wss://relay.nsec.app/,wss://bucket.coracle.social/
VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN=
+2 -2
View File
@@ -1,2 +1,2 @@
npm run lint
npm run check
pnpm run lint
pnpm run check
+19
View File
@@ -3,6 +3,25 @@
# 1.0.0
* Add alerts via Anchor
* Fix nip46 signer connect
* Allow use of cleartext relays on native builds
* Fix some modal state bugs caused by svelte 5
* Detect blossom support on community relays
* Use user blossom server list in settings
* Fix some feed bugs
* Improve thunk indicator
* Update storage adapters
* Fix modal flash
* Switch to pnpm
* Improve calendar windowing
# 0.2.14
* Add calendar event editing
# 0.2.13
* Fix android keyboard issue
# 0.2.12
+6 -6
View File
@@ -2,14 +2,14 @@
A discord-like nostr client based on the idea of "relays as groups".
If you would like to be interoperable with Flotilla, please check out this draft NIP: https://github.com/coracle-social/nips/blob/relay-chat/xx.md
If you would like to be interoperable with Flotilla, please check out this guide: https://habla.news/u/hodlbod@coracle.social/1741286140797
# Deploy
To run your own Flotilla, it's as simple as:
- `npm install`
- `npm run build`
- `pnpm install`
- `pnpm run build`
- `npx serve build`
## Environment
@@ -65,12 +65,12 @@ git clone https://github.com/coracle-social/flotilla.git
cd ~/flotilla
nvm install
nvm use
npm i
pnpm i
# Optionally create and populate .env.local to suit your use case
# Build the app
NODE_OPTIONS=--max_old_space_size=16384 npm run build
NODE_OPTIONS=--max_old_space_size=16384 pnpm run build
# Exit back to root
exit
@@ -108,4 +108,4 @@ Now, visit your domain. You should be all set up!
# Development
Run `npm run dev` to get a dev server, and `npm run check:watch` to watch for typescript errors. When you're ready to commit, run `npm run format && npm run lint` and fix any errors that come up.
Run `pnpm run dev` to get a dev server, and `pnpm run check:watch` to watch for typescript errors. When you're ready to commit, run `pnpm run format && pnpm run lint` and fix any errors that come up.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "social.flotilla"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 12
versionName "0.2.12"
versionCode 14
versionName "1.0.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1
View File
@@ -10,6 +10,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-keyboard')
implementation project(':nostr-signer-capacitor-plugin')
}
+3 -1
View File
@@ -6,12 +6,14 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
+6 -3
View File
@@ -1,9 +1,12 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/android/capacitor')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app/android')
include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard/android')
include ':nostr-signer-capacitor-plugin'
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/nostr-signer-capacitor-plugin/android')
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin/android')
+4
View File
@@ -11,6 +11,10 @@ const config: CapacitorConfig = {
SplashScreen: {
androidSplashResourceName: "splash"
},
Keyboard: {
style: "DARK",
resizeOnFullScreen: true,
},
},
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload
// server: {
+4 -4
View File
@@ -351,14 +351,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.2.12;
MARKETING_VERSION = 1.0.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -376,14 +376,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.2.12;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
+6 -5
View File
@@ -1,4 +1,4 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
require_relative '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '14.0'
use_frameworks!
@@ -9,10 +9,11 @@ use_frameworks!
install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/nostr-signer-capacitor-plugin'
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app'
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin'
end
target 'Flotilla Chat' do
-15439
View File
File diff suppressed because it is too large Load Diff
+38 -12
View File
@@ -1,6 +1,6 @@
{
"name": "flotilla",
"version": "0.2.12",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -42,6 +42,7 @@
"@capacitor/cli": "^7.0.0",
"@capacitor/core": "^7.0.1",
"@capacitor/ios": "^7.0.0",
"@capacitor/keyboard": "^7.0.0",
"@noble/curves": "^1.5.0",
"@noble/hashes": "^1.4.0",
"@poppanator/sveltekit-svg": "^4.2.1",
@@ -50,16 +51,18 @@
"@types/qrcode": "^1.5.5",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "^0.0.43",
"@welshman/content": "^0.1.0",
"@welshman/dvm": "^0.0.15",
"@welshman/editor": "^0.1.0",
"@welshman/feeds": "^0.1.0",
"@welshman/lib": "^0.1.0",
"@welshman/net": "^0.0.49",
"@welshman/signer": "^0.1.0",
"@welshman/store": "^0.1.0",
"@welshman/util": "^0.1.0",
"@welshman/app": "^0.2.3",
"@welshman/content": "^0.2.0",
"@welshman/dvm": "^0.2.0",
"@welshman/editor": "^0.2.0",
"@welshman/feeds": "^0.2.2",
"@welshman/lib": "^0.2.1",
"@welshman/net": "^0.2.2",
"@welshman/relay": "^0.2.0",
"@welshman/router": "^0.2.0",
"@welshman/signer": "^0.2.1",
"@welshman/store": "^0.2.0",
"@welshman/util": "^0.2.2",
"daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5",
@@ -67,9 +70,32 @@
"fuse.js": "^7.0.0",
"husky": "^9.1.6",
"idb": "^8.0.0",
"nostr-signer-capacitor-plugin": "coracle-social/nostr-signer-capacitor-plugin#9fbe4f8",
"nostr-signer-capacitor-plugin": "^0.0.4",
"nostr-tools": "^2.7.2",
"prettier-plugin-tailwindcss": "^0.6.5",
"qrcode": "^1.5.4"
},
"pnpm": {
"overrides": {
"@welshman/lib": "link:../welshman/packages/lib",
"@welshman/util": "link:../welshman/packages/util",
"@welshman/app": "link:../welshman/packages/app",
"@welshman/content": "link:../welshman/packages/content",
"@welshman/dvm": "link:../welshman/packages/dvm",
"@welshman/feeds": "link:../welshman/packages/feeds",
"@welshman/net": "link:../welshman/packages/net",
"@welshman/relay": "link:../welshman/packages/relay",
"@welshman/router": "link:../welshman/packages/router",
"@welshman/signer": "link:../welshman/packages/signer",
"@welshman/store": "link:../welshman/packages/store",
"@welshman/editor": "link:../welshman/packages/editor"
},
"ignoredBuiltDependencies": [
"@sentry/cli",
"esbuild"
],
"onlyBuiltDependencies": [
"sharp"
]
}
}
+9190
View File
File diff suppressed because it is too large Load Diff
+5 -9
View File
@@ -338,18 +338,14 @@ progress[value]::-webkit-progress-value {
@apply w-full md:w-[calc(100%-18.5rem)];
}
.cb {
@apply saib bottom-14 md:bottom-0;
}
/* chat view */
.chat__page-bar {
@apply sait cw !fixed top-0;
}
.chat__messages {
@apply saib cw fixed top-12 flex h-[calc(100%-10rem)] flex-col-reverse overflow-y-auto overflow-x-hidden md:h-[calc(100%-6rem)];
}
.chat__compose {
@apply saib cw fixed bottom-14 md:bottom-0;
@apply cb cw fixed;
}
.chat__scroll-down {
+76 -123
View File
@@ -1,6 +1,8 @@
import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store"
import {ctx, randomId, uniq, equals} from "@welshman/lib"
import {randomId, ifLet, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
import type {Feed} from "@welshman/feeds"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {
DELETE,
REPORT,
@@ -28,32 +30,29 @@ import {
getRelayTags,
getRelayTagValues,
toNostrURI,
unionFilters,
getRelaysFromList,
RelayMode,
} from "@welshman/util"
import type {TrustedEvent, Filter, EventContent, EventTemplate} from "@welshman/util"
import {PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
import {Nip59, makeSecret, stamp, Nip46Broker} from "@welshman/signer"
import {Pool, PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
import {Router} from "@welshman/router"
import {
pubkey,
signer,
repository,
publishThunk,
publishThunks,
profilesByPubkey,
relaySelectionsByPubkey,
getWriteRelayUrls,
tagEvent,
tagEventForReaction,
getRelayUrls,
userRelaySelections,
userInboxRelaySelections,
nip44EncryptToSelf,
loadRelay,
addSession,
clearStorage,
dropSession,
tagEventForComment,
tagEventForQuote,
thunkIsComplete,
} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import {
@@ -61,19 +60,17 @@ import {
PROTECTED,
userMembership,
INDEXER_RELAYS,
NIP46_PERMS,
ALERT,
NOTIFIER_PUBKEY,
NOTIFIER_RELAY,
userRoomsByUrl,
} from "@app/state"
import {loadUserData} from "@app/requests"
// Utils
export const getPubkeyHints = (pubkey: string) => {
const selections = relaySelectionsByPubkey.get().get(pubkey)
const relays = selections ? getWriteRelayUrls(selections) : []
const relays = selections ? getRelaysFromList(selections, RelayMode.Write) : []
const hints = relays.length ? relays : INDEXER_RELAYS
return hints
@@ -86,14 +83,20 @@ export const getPubkeyPetname = (pubkey: string) => {
return display
}
export const getThunkError = async (thunk: Thunk) => {
const result = await thunk.result
const [{status, message}] = Object.values(result) as any
export const getThunkError = (thunk: Thunk) =>
new Promise<string>(resolve => {
thunk.subscribe($thunk => {
for (const [relay, status] of Object.entries($thunk.status)) {
if (status === PublishStatus.Failure) {
resolve($thunk.details[relay])
}
}
if (status !== PublishStatus.Success) {
return message
}
}
if (thunkIsComplete($thunk)) {
resolve("")
}
})
})
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
if (parent) {
@@ -101,7 +104,7 @@ export const prependParent = (parent: TrustedEvent | undefined, {content, tags}:
id: parent.id,
kind: parent.kind,
author: parent.pubkey,
relays: ctx.app.router.Event(parent).limit(3).getUrls(),
relays: Router.get().Event(parent).limit(3).getUrls(),
})
tags = [...tags, tagEventForQuote(parent)]
@@ -111,38 +114,6 @@ export const prependParent = (parent: TrustedEvent | undefined, {content, tags}:
return {content, tags}
}
// Log in
export const loginWithNip46 = async ({
relays,
signerPubkey,
clientSecret = makeSecret(),
connectSecret = "",
}: {
relays: string[]
signerPubkey: string
clientSecret?: string
connectSecret?: string
}) => {
const broker = Nip46Broker.get({relays, clientSecret, signerPubkey})
const result = await broker.connect(connectSecret, NIP46_PERMS)
// TODO: remove ack result
if (!["ack", connectSecret].includes(result)) return false
const pubkey = await broker.getPublicKey()
if (!pubkey) return false
await loadUserData(pubkey)
const handler = {relays, pubkey: signerPubkey}
addSession({method: "nip46", pubkey, secret: clientSecret, handler})
return true
}
// Log out
export const logout = async () => {
@@ -203,7 +174,7 @@ export const nip29 = {
export const addSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)])
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
@@ -212,11 +183,7 @@ export const removeSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([
url,
...ctx.app.router.FromUser().getUrls(),
...getRelayTagValues(event.tags),
])
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
@@ -228,7 +195,7 @@ export const addRoomMembership = async (url: string, room: string, name: string)
["group", room, url, name],
]
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)])
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
@@ -237,11 +204,7 @@ export const removeRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([
url,
...ctx.app.router.FromUser().getUrls(),
...getRelayTagValues(event.tags),
])
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
@@ -263,7 +226,7 @@ export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
relays: [
url,
...INDEXER_RELAYS,
...ctx.app.router.FromUser().getUrls(),
...Router.get().FromUser().getUrls(),
...userRoomsByUrl.get().keys(),
],
})
@@ -273,7 +236,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
const list = get(userInboxRelaySelections) || makeList({kind: INBOX_RELAYS})
// Only update inbox policies if they already exist or we're adding them
if (enabled || getRelayUrls(list).includes(url)) {
if (enabled || getRelaysFromList(list).includes(url)) {
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
if (enabled) {
@@ -284,7 +247,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
event: createEvent(list.kind, {tags}),
relays: [
...INDEXER_RELAYS,
...ctx.app.router.FromUser().getUrls(),
...Router.get().FromUser().getUrls(),
...userRoomsByUrl.get().keys(),
],
})
@@ -294,21 +257,19 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
// Relay access
export const checkRelayAccess = async (url: string, claim = "") => {
const connection = ctx.net.pool.get(url)
const socket = Pool.get().get(url)
await connection.auth.attempt(5000)
await socket.auth.attemptAuth(signer.get().sign)
const thunk = publishThunk({
event: createEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
relays: [url],
})
const result = await thunk.result
if (result[url].status === PublishStatus.Failure) {
ifLet(await getThunkError(thunk), error => {
const message =
connection.auth.message?.replace(/^.*: /, "") ||
result[url].message?.replace(/^.*: /, "") ||
socket.auth.details?.replace(/^.*: /, "") ||
error?.replace(/^.*: /, "") ||
"join request rejected"
// If it's a strict NIP 29 relay don't worry about requesting access
@@ -316,7 +277,7 @@ export const checkRelayAccess = async (url: string, claim = "") => {
if (message !== "missing group (`h`) tag") {
return `Failed to join relay (${message})`
}
}
})
}
export const checkRelayProfile = async (url: string) => {
@@ -328,26 +289,30 @@ export const checkRelayProfile = async (url: string) => {
}
export const checkRelayConnection = async (url: string) => {
const connection = ctx.net.pool.get(url)
const socket = Pool.get().get(url)
await connection.socket.open()
await connection.socket.wait(3000)
socket.attemptToOpen()
if (connection.socket.status !== SocketStatus.Open) {
await poll({
signal: AbortSignal.timeout(3000),
condition: () => socket.status === SocketStatus.Open,
})
if (socket.status !== SocketStatus.Open) {
return `Failed to connect`
}
}
export const checkRelayAuth = async (url: string, timeout = 3000) => {
const connection = ctx.net.pool.get(url)
const socket = Pool.get().get(url)
const okStatuses = [AuthStatus.None, AuthStatus.Ok]
await connection.auth.attempt(timeout)
await socket.auth.attemptAuth(signer.get().sign)
// Only raise an error if it's not a timeout.
// If it is, odds are the problem is with our signer, not the relay
if (!okStatuses.includes(connection.auth.status) && connection.auth.message) {
return `Failed to authenticate (${connection.auth.message})`
if (!okStatuses.includes(socket.auth.status) && socket.auth.details) {
return `Failed to authenticate (${socket.auth.details})`
}
}
@@ -369,28 +334,6 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
// Actions
export const sendWrapped = async ({
template,
pubkeys,
delay,
}: {
template: EventTemplate
pubkeys: string[]
delay?: number
}) => {
const nip59 = Nip59.fromSigner(signer.get()!)
return publishThunks(
await Promise.all(
uniq(pubkeys).map(async recipient => ({
event: await nip59.wrap(recipient, stamp(template)),
relays: ctx.app.router.PubkeyInbox(recipient).getUrls(),
delay,
})),
),
)
}
export const makeDelete = ({event}: {event: TrustedEvent}) => {
const tags = [["k", String(event.kind)], ...tagEvent(event)]
const groupTag = getTag("h", event.tags)
@@ -462,33 +405,43 @@ export const publishComment = ({relays, ...params}: CommentParams & {relays: str
publishThunk({event: makeComment(params), relays})
export type AlertParams = {
feed: Feed
cron: string
email: string
relay: string
handler: string
filters: Filter[]
bunker: string
secret: string
description: string
}
export const makeAlert = async ({cron, email, handler, relay, filters}: AlertParams) =>
createEvent(ALERT, {
content: await signer
.get()
.nip44.encrypt(
NOTIFIER_PUBKEY,
JSON.stringify([
["cron", cron],
["email", email],
["relay", relay],
["handler", handler],
["channel", "email"],
...unionFilters(filters).map(filter => ["filter", JSON.stringify(filter)]),
]),
),
export const makeAlert = async ({cron, email, feed, bunker, secret, description}: AlertParams) => {
const tags = [
["feed", JSON.stringify(feed)],
["cron", cron],
["email", email],
["locale", LOCALE],
["timezone", TIMEZONE],
["description", description],
["channel", "email"],
[
"handler",
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
"wss://relay.nostr.band/",
"web",
],
]
if (bunker) {
tags.push(["nip46", secret, bunker])
}
return createEvent(ALERT, {
content: await signer.get().nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
tags: [
["d", randomId()],
["p", NOTIFIER_PUBKEY],
],
})
}
export const publishAlert = async (params: AlertParams) =>
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
+141 -70
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import {Capacitor} from "@capacitor/core"
import {preventDefault} from "@lib/html"
import {randomInt} from "@welshman/lib"
import {displayRelayUrl, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib"
import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
import type {Filter} from "@welshman/util"
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -11,20 +12,21 @@
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {getMembershipUrls, getMembershipRoomsByUrl, userMembership} from "@app/state"
import InfoBunker from "@app/components/InfoBunker.svelte"
import BunkerConnect, {BunkerConnectController} from "@app/components/BunkerConnect.svelte"
import {
GENERAL,
alerts,
getMembershipUrls,
getMembershipRoomsByUrl,
userMembership,
} from "@app/state"
import {loadAlertStatuses} from "@app/requests"
import {publishAlert} from "@app/commands"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
const handler = Capacitor.isNativePlatform()
? "https://app.flotilla.social"
: window.location.origin
const timezone = new Date()
.toString()
.match(/GMT[^\s]+/)![0]
.slice(3)
const timezoneOffset = parseInt(timezone) / 100
const timezoneOffset = parseInt(TIMEZONE.slice(3)) / 100
const minute = randomInt(0, 59)
const hour = (17 - timezoneOffset) % 24
const WEEKLY = `0 ${minute} ${hour} * * 1`
@@ -32,14 +34,38 @@
let loading = false
let cron = WEEKLY
let email = ""
let email = $alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || ""
let relay = ""
let bunker = ""
let secret = ""
let notifyThreads = true
let notifyCalendar = true
let notifyChat = false
let showBunker = false
const back = () => history.back()
const controller = new BunkerConnectController({
onNostrConnect: (response: Nip46ResponseWithResult) => {
bunker = controller.broker.getBunkerUrl()
secret = controller.broker.params.clientSecret
showBunker = false
},
})
const connectBunker = () => {
showBunker = true
}
const hideBunker = () => {
showBunker = false
}
const clearBunker = () => {
bunker = ""
secret = ""
}
const submit = async () => {
if (!email.includes("@")) {
return pushToast({
@@ -63,25 +89,37 @@
}
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) {
filters.push({kinds: [MESSAGE], "#h": getMembershipRoomsByUrl(relay, $userMembership)})
display.push("chat")
filters.push({
kinds: [MESSAGE],
"#h": [GENERAL, ...getMembershipRoomsByUrl(relay, $userMembership)],
})
}
loading = true
try {
await publishAlert({cron, email, relay, handler, filters})
const cadence = cron?.endsWith("1") ? "Weekly" : "Daily"
const description = `${cadence} alert for ${displayList(display)} on ${displayRelayUrl(relay)}, sent via email.`
const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(relay))
const thunk = await publishAlert({cron, email, feed, bunker, secret, description})
await thunk.result
await loadAlertStatuses($pubkey!)
pushToast({message: "Your alert has been successfully created!"})
@@ -98,67 +136,100 @@
Add an Alert
{/snippet}
</ModalHeader>
<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>
<FieldInline>
{#snippet label()}
<p>Space*</p>
{/snippet}
{#snippet input()}
<select bind:value={relay} class="select select-bordered">
<option value="" disabled selected>Choose a space URL</option>
{#each getMembershipUrls($userMembership) as url (url)}
<option value={url}>{displayRelayUrl(url)}</option>
{/each}
</select>
{/snippet}
</FieldInline>
<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
{#if showBunker}
<div class="card2 flex flex-col items-center gap-4 bg-base-300">
<p>Scan using a nostr signer, or click to copy.</p>
<BunkerConnect {controller} />
<Button class="btn btn-neutral btn-sm" onclick={hideBunker}>Cancel</Button>
</div>
{:else}
<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>
<FieldInline>
{#snippet label()}
<p>Space*</p>
{/snippet}
{#snippet input()}
<select bind:value={relay} class="select select-bordered">
<option value="" disabled selected>Choose a space URL</option>
{#each getMembershipUrls($userMembership) as url (url)}
<option value={url}>{displayRelayUrl(url)}</option>
{/each}
</select>
{/snippet}
</FieldInline>
<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>
<div class="card2 flex flex-col gap-3 bg-base-300">
<div class="flex items-center justify-between">
<strong>Connect a Bunker</strong>
<span class="flex items-center gap-2 text-sm" class:text-primary={bunker}>
{#if bunker}
<Icon icon="check-circle" size={5} />
Connected
{:else}
<Icon icon="close-circle" size={5} />
Not Connected
{/if}
</span>
</div>
{/snippet}
</FieldInline>
<p class="text-sm">
Required for receiving alerts about spaces with access controls. You can get one from your
<Button class="text-primary" onclick={() => pushModal(InfoBunker)}>remote signer app</Button
>.
</p>
{#if bunker}
<Button class="btn btn-neutral btn-sm flex-grow" onclick={clearBunker}>Disconnect</Button>
{:else}
<Button class="btn btn-primary btn-sm w-full flex-grow" onclick={connectBunker}
>Connect</Button>
{/if}
</div>
{/if}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Button type="submit" class="btn btn-primary" disabled={loading || showBunker}>
<Spinner {loading}>Confirm</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
+16 -32
View File
@@ -1,22 +1,12 @@
<script lang="ts">
import {parseJson, nthEq} from "@welshman/lib"
import {
getAddress,
getTagValue,
getTagValues,
displayRelayUrl,
EVENT_TIME,
MESSAGE,
THREAD,
} from "@welshman/util"
import {displayList} from "@lib/util"
import Link from "@lib/components/Link.svelte"
import {displayFeeds} from "@welshman/feeds"
import {getAddress, getTagValue, getTagValues} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import AlertDelete from "@app/components/AlertDelete.svelte"
import type {Alert} from "@app/state"
import {alertStatuses} from "@app/state"
import {makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal"
type Props = {
@@ -29,31 +19,25 @@
const status = $derived($alertStatuses.find(s => s.event.tags.some(nthEq(1, address))))
const cron = $derived(getTagValue("cron", alert.tags))
const channel = $derived(getTagValue("channel", alert.tags))
const relay = $derived(getTagValue("relay", alert.tags)!)
const filters = $derived(getTagValues("filter", alert.tags).map(parseJson))
const types = $derived.by(() => {
const t: string[] = []
if (filters.some(f => f.kinds?.includes(THREAD))) t.push("threads")
if (filters.some(f => f.kinds?.includes(EVENT_TIME))) t.push("calendar events")
if (filters.some(f => f.kinds?.includes(MESSAGE))) t.push("chat")
return t
})
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">
<Button class="py-1" onclick={startDelete}>
<Icon icon="trash-bin-2" />
</Button>
<div class="flex-inline gap-1">
{cron?.endsWith("1") ? "Weekly" : "Daily"} alert for
{displayList(types)} on
<Link class="link" href={makeSpacePath(relay)}>
{displayRelayUrl(relay)}
</Link>, sent via {channel}.
<div class="flex items-start gap-4">
<Button class="py-1" onclick={startDelete}>
<Icon icon="trash-bin-2" />
</Button>
<div class="flex-inline gap-1">{description}</div>
</div>
{#if status}
{@const statusText = getTagValue("status", status.tags) || "error"}
+8
View File
@@ -1,12 +1,20 @@
<script lang="ts">
import {onMount} from "svelte"
import {pubkey} from "@welshman/app"
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 {loadAlertStatuses, loadAlerts} from "@app/requests"
import {pushModal} from "@app/modal"
import {alerts} from "@app/state"
const startAlert = () => pushModal(AlertAdd)
onMount(() => {
loadAlertStatuses($pubkey!)
loadAlerts($pubkey!)
})
</script>
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
+78
View File
@@ -0,0 +1,78 @@
<script module lang="ts">
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/state"
export class BunkerConnectController {
url = $state("")
bunker = $state("")
loading = $state(false)
clientSecret = makeSecret()
abortController = new AbortController()
broker = Nip46Broker.get({clientSecret: this.clientSecret, relays: SIGNER_RELAYS})
onNostrConnect: (response: Nip46ResponseWithResult) => void
constructor({onNostrConnect}: {onNostrConnect: (response: Nip46ResponseWithResult) => void}) {
this.onNostrConnect = onNostrConnect
}
async start() {
this.url = await this.broker.makeNostrconnectUrl({
perms: NIP46_PERMS,
url: PLATFORM_URL,
name: PLATFORM_NAME,
image: PLATFORM_LOGO,
})
let response
try {
response = await this.broker.waitForNostrconnect(this.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 = true
this.onNostrConnect(response)
}
}
stop() {
this.abortController.abort()
}
}
</script>
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import {slideAndFade} from "@lib/transition"
import QRCode from "@app/components/QRCode.svelte"
import {pushToast} from "@app/toast"
type Props = {
controller: BunkerConnectController
}
const {controller}: Props = $props()
onMount(() => {
controller.start()
})
onDestroy(() => {
controller.stop()
})
</script>
{#if controller.url}
<div class="flex justify-center" out:slideAndFade>
<QRCode code={controller.url} />
</div>
{/if}
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import {pushModal} from "@app/modal"
import InfoBunker from "@app/components/InfoBunker.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
type Props = {
bunker: string
loading: boolean
}
let {loading, bunker = $bindable("")}: Props = $props()
</script>
<Field>
{#snippet label()}
<p>Bunker Link*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="cpu" />
<input disabled={loading} bind:value={bunker} class="grow" placeholder="bunker://" />
</label>
{/snippet}
{#snippet info()}
<p>
A login link provided by a nostr signing app.
<Button class="link" onclick={() => pushModal(InfoBunker)}>What is a bunker link?</Button>
</p>
{/snippet}
</Field>
+18 -1
View File
@@ -1,12 +1,16 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
import {publishDelete, publishReaction} from "@app/commands"
import {makeCalendarPath} from "@app/routes"
import {pushModal} from "@app/modal"
const {
url,
@@ -20,6 +24,8 @@
const path = makeCalendarPath(url, event.id)
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
const onReactionClick = (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
@@ -38,6 +44,17 @@
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Event" />
<EventActions {url} {event} noun="Event">
{#snippet customActions()}
{#if event.pubkey === $pubkey}
<li>
<Button onclick={editEvent}>
<Icon size={4} icon="pen" />
Edit Event
</Button>
</li>
{/if}
{/snippet}
</EventActions>
</div>
</div>
+16 -157
View File
@@ -1,164 +1,23 @@
<script lang="ts">
import {writable} from "svelte/store"
import {randomId, HOUR} from "@welshman/lib"
import {createEvent, EVENT_TIME} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {preventDefault} from "@lib/html"
import {daysBetween} from "@lib/util"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED, GENERAL, tagRoom} from "@app/state"
import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast"
import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
const {url} = $props()
const uploading = writable(false)
const back = () => history.back()
const submit = () => {
if ($uploading) return
if (!title) {
return pushToast({
theme: "error",
message: "Please provide a title.",
})
}
if (!start || !end) {
return pushToast({
theme: "error",
message: "Please provide start and end times.",
})
}
if (start >= end) {
return pushToast({
theme: "error",
message: "End time must be later than start time.",
})
}
const event = createEvent(EVENT_TIME, {
content: editor.getText({blockSeparator: "\n"}).trim(),
tags: [
["d", randomId()],
["title", title],
["location", location],
["start", start.toString()],
["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]),
...editor.storage.nostr.getEditorTags(),
tagRoom(GENERAL, url),
PROTECTED,
],
})
pushToast({message: "Your event has been published!"})
publishThunk({event, relays: [url]})
history.back()
type Props = {
url: string
}
const editor = makeEditor({submit, uploading})
let title = $state("")
let location = $state("")
let start: number | undefined = $state()
let end: number | undefined = $state()
let endDirty = false
$effect(() => {
if (!endDirty && start) {
end = start + HOUR
} else if (end) {
endDirty = true
}
})
const {url}: Props = $props()
</script>
<form class="column gap-4" onsubmit={preventDefault(submit)}>
<ModalHeader>
{#snippet title()}
<div>Create an Event</div>
{/snippet}
{#snippet info()}
<div>Invite other group members to events online or in real life.</div>
{/snippet}
</ModalHeader>
<Field>
{#snippet label()}
<p>Title*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={title} class="grow" type="text" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Summary</p>
{/snippet}
{#snippet input()}
<div class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
<div class="input-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="Add an image"
class="center btn tooltip"
onclick={() => editor.chain().selectFiles().run()}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="gallery-send" />
{/if}
</Button>
</div>
{/snippet}
</Field>
<Field>
{#snippet label()}
Start*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={start} />
{/snippet}
</Field>
<Field>
{#snippet label()}
End*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={end} />
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Location (optional)</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="map-point" />
<input bind:value={location} class="grow" type="text" />
</label>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
<Spinner loading={$uploading}>Create Event</Spinner>
</Button>
</ModalFooter>
</form>
<CalendarEventForm {url}>
{#snippet header()}
<ModalHeader>
{#snippet title()}
<div>Create an Event</div>
{/snippet}
{#snippet info()}
<div>Invite other group members to events online or in real life.</div>
{/snippet}
</ModalHeader>
{/snippet}
</CalendarEventForm>
+6 -5
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import {fromPairs} from "@welshman/lib"
import {fromPairs, LOCALE, secondsToDate} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {LOCALE, secondsToDate} from "@welshman/app"
type Props = {
event: TrustedEvent
@@ -13,7 +12,9 @@
</script>
<div
class="flex h-14 w-14 flex-col items-center justify-center gap-1 rounded-box border border-solid border-base-content p-2 sm:h-24 sm:w-24">
<span class="sm:text-lg">{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</span>
<span class="sm:text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
class="hidden h-32 w-32 min-w-32 flex-col items-center justify-center gap-1 rounded-box bg-base-300 p-2 sm:flex">
<strong>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</strong>
<span class="text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
<span class="text-xs opacity-75"
>{Intl.DateTimeFormat(LOCALE, {weekday: "long"}).format(startDate)}</span>
</div>
@@ -0,0 +1,35 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const initialValues = {
d: getTagValue("d", event.tags)!,
title: getTagValue("title", event.tags)!,
location: getTagValue("location", event.tags)!,
start: parseInt(getTagValue("start", event.tags)!),
end: parseInt(getTagValue("end", event.tags)!),
content: event.content,
}
</script>
<CalendarEventForm {url} {initialValues}>
{#snippet header()}
<ModalHeader>
{#snippet title()}
<div>Edit this Event</div>
{/snippet}
{#snippet info()}
<div>Invite other group members to events online or in real life.</div>
{/snippet}
</ModalHeader>
{/snippet}
</CalendarEventForm>
+171
View File
@@ -0,0 +1,171 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {writable} from "svelte/store"
import {randomId, HOUR} from "@welshman/lib"
import {createEvent, EVENT_TIME} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {preventDefault} from "@lib/html"
import {daysBetween} from "@lib/util"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED, GENERAL, tagRoom} from "@app/state"
import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast"
type Props = {
url: string
header: Snippet
initialValues?: {
d: string
title: string
content: string
location: string
start: number
end: number
}
}
const {url, header, initialValues}: Props = $props()
const uploading = writable(false)
const back = () => history.back()
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => {
if ($uploading) return
if (!title) {
return pushToast({
theme: "error",
message: "Please provide a title.",
})
}
if (!start || !end) {
return pushToast({
theme: "error",
message: "Please provide start and end times.",
})
}
if (start >= end) {
return pushToast({
theme: "error",
message: "End time must be later than start time.",
})
}
const ed = await editor
const event = createEvent(EVENT_TIME, {
content: ed.getText({blockSeparator: "\n"}).trim(),
tags: [
["d", initialValues?.d || randomId()],
["title", title],
["location", location || ""],
["start", start.toString()],
["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]),
...ed.storage.nostr.getEditorTags(),
tagRoom(GENERAL, url),
PROTECTED,
],
})
pushToast({message: "Your event has been saved!"})
publishThunk({event, relays: [url]})
history.back()
}
const content = initialValues?.content || ""
const editor = makeEditor({url, submit, uploading, content})
let title = $state(initialValues?.title || "")
let location = $state(initialValues?.location || "")
let start: number | undefined = $state(initialValues?.start)
let end: number | undefined = $state(initialValues?.end)
let endDirty = Boolean(initialValues?.end)
$effect(() => {
if (!endDirty && start) {
end = start + HOUR
} else if (end) {
endDirty = true
}
})
</script>
<form novalidate class="column gap-4" onsubmit={preventDefault(submit)}>
{@render header()}
<Field>
{#snippet label()}
<p>Title*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={title} class="grow" type="text" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Summary</p>
{/snippet}
{#snippet input()}
<div class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
<div class="input-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="gallery-send" />
{/if}
</Button>
</div>
{/snippet}
</Field>
<Field>
{#snippet label()}
Start*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={start} />
{/snippet}
</Field>
<Field>
{#snippet label()}
End*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={end} />
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Location (optional)</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="map-point" />
<input bind:value={location} class="grow" type="text" />
</label>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
<Spinner loading={$uploading}>Save Event</Spinner>
</Button>
</ModalFooter>
</form>
+15 -6
View File
@@ -1,8 +1,12 @@
<script lang="ts">
import {fromPairs} from "@welshman/lib"
import {
fromPairs,
formatTimestamp,
formatTimestampAsDate,
formatTimestampAsTime,
} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import {formatTimestamp, formatTimestampAsDate, formatTimestampAsTime} from "@welshman/app"
type Props = {
event: TrustedEvent
@@ -17,8 +21,13 @@
const isSingleDay = $derived(startDateDisplay === endDateDisplay)
</script>
<p class="text-xl">{meta.title || meta.name}</p>
<div class="flex items-center gap-2 text-sm">
<Icon icon="clock-circle" size={4} />
{formatTimestampAsTime(start)}{isSingleDay ? formatTimestampAsTime(end) : formatTimestamp(end)}
<div class="flex flex-grow flex-wrap justify-between gap-2">
<p class="text-xl">{meta.title || meta.name}</p>
<div class="flex items-center gap-2 text-sm">
<Icon icon="clock-circle" size={4} />
<span class="sm:hidden">{formatTimestampAsDate(start)}</span>
{formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end)
: formatTimestamp(end)}
</div>
</div>
+2 -4
View File
@@ -15,12 +15,10 @@
</script>
<Link class="col-3 card2 bg-alt w-full cursor-pointer" href={makeCalendarPath(url, event.id)}>
<div class="flex items-center justify-between gap-2">
<CalendarEventHeader {event} />
</div>
<CalendarEventHeader {event} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} />
Posted by <ProfileLink pubkey={event.pubkey} {url} />
</span>
<CalendarEventActions showActivity {url} {event} />
</div>
+12 -9
View File
@@ -6,19 +6,22 @@
type Props = {
event: TrustedEvent
url: string
}
const {event}: Props = $props()
const {event, url}: Props = $props()
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
</script>
<span>
Posted by <ProfileLink pubkey={event.pubkey} />
</span>
{#if meta.location}
<span></span>
<div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">
<span class="flex items-center gap-1">
<Icon icon="map-point" size={4} />
{meta.location}
<Icon icon="user-circle" size={4} />
Posted by <ProfileLink pubkey={event.pubkey} {url} />
</span>
{/if}
{#if meta.location}
<span class="flex items-start gap-1">
<Icon icon="map-point" class="mt-[2px]" size={4} />
<span class="break-words">{meta.location}</span>
</span>
{/if}
</div>
+13 -10
View File
@@ -1,39 +1,42 @@
<script lang="ts">
import {writable} from "svelte/store"
import type {EventContent} from "@welshman/util"
import {isMobile, preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
interface Props {
onSubmit: any
type Props = {
url?: string
onSubmit: (event: EventContent) => void
}
const {onSubmit}: Props = $props()
const {onSubmit, url}: Props = $props()
const autofocus = !isMobile
const uploading = writable(false)
export const focus = () => editor.chain().focus().run()
export const focus = () => editor.then(ed => ed.chain().focus().run())
const uploadFiles = () => editor.chain().selectFiles().run()
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = () => {
const submit = async () => {
if ($uploading) return
const content = editor.getText({blockSeparator: "\n"}).trim()
const tags = editor.storage.nostr.getEditorTags()
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = ed.storage.nostr.getEditorTags()
if (!content) return
onSubmit({content, tags})
editor.chain().clearContent().run()
ed.chain().clearContent().run()
}
const editor = makeEditor({autofocus, submit, uploading, aggressive: true})
const editor = makeEditor({url, autofocus, submit, uploading, aggressive: true})
</script>
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
+10 -18
View File
@@ -1,15 +1,7 @@
<script lang="ts">
import {hash} from "@welshman/lib"
import {now} from "@welshman/lib"
import {hash, now, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {
thunks,
pubkey,
deriveProfile,
deriveProfileDisplay,
formatTimestampAsDate,
formatTimestampAsTime,
} from "@welshman/app"
import {thunks, pubkey, deriveProfile, deriveProfileDisplay} from "@welshman/app"
import {isMobile} from "@lib/html"
import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte"
@@ -27,10 +19,10 @@
import {pushModal} from "@app/modal"
interface Props {
url: any
room: any
url: string
room: string
event: TrustedEvent
replyTo?: any
replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean
inert?: boolean
}
@@ -39,15 +31,15 @@
const thunk = $thunks[event.id]
const today = formatTimestampAsDate(now())
const profile = deriveProfile(event.pubkey)
const profileDisplay = deriveProfileDisplay(event.pubkey)
const profile = deriveProfile(event.pubkey, [url])
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
const reply = () => replyTo(event)
const reply = () => replyTo!(event)
const onTap = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
const onReactionClick = (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
@@ -89,7 +81,7 @@
</div>
{/if}
<div class="text-sm">
<Content {event} relays={[url]} />
<Content {event} {url} />
{#if thunk}
<ThunkStatus {thunk} class="mt-2" />
{/if}
+85 -68
View File
@@ -1,20 +1,21 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {onMount} from "svelte"
import {int, nthNe, MINUTE, sortBy, remove} from "@welshman/lib"
import {int, nthNe, MINUTE, sortBy, remove, formatTimestampAsDate} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util"
import {
pubkey,
tagPubkey,
formatTimestampAsDate,
sendWrapped,
loadUsingOutbox,
inboxRelaySelectionsByPubkey,
load,
} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte"
import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
@@ -23,19 +24,24 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ProfileList from "@app/components/ProfileList.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChannelCompose.svelte"
import ChatComposeParent from "@app/components/ChannelComposeParent.svelte"
import {userSettingValues, deriveChat, splitChatId, PLATFORM_NAME} from "@app/state"
import ChatCompose from "@app/components/ChatCompose.svelte"
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import {
INDEXER_RELAYS,
userSettingValues,
deriveChat,
splitChatId,
PLATFORM_NAME,
} from "@app/state"
import {pushModal} from "@app/modal"
import {sendWrapped, prependParent} from "@app/commands"
import {prependParent} from "@app/commands"
const {
id,
info,
}: {
type Props = {
id: string
info?: Snippet
} = $props()
}
const {id, info}: Props = $props()
const chat = deriveChat(id)
const pubkeys = splitChatId(id)
@@ -70,7 +76,7 @@
let loading = $state(true)
let compose: ChatCompose | undefined = $state()
let parent: TrustedEvent | undefined = $state()
let parentPreview: HTMLElement | undefined = $state()
let chatCompose: HTMLElement | undefined = $state()
let dynamicPadding: HTMLElement | undefined = $state()
const elements = $derived.by(() => {
@@ -105,16 +111,24 @@
onMount(() => {
// Don't use loadInboxRelaySelection because we want to force reload
load({filters: [{kinds: [INBOX_RELAYS], authors: others}]})
for (const pubkey of others) {
loadUsingOutbox({
pubkey,
kind: INBOX_RELAYS,
relays: INDEXER_RELAYS,
})
}
const observer = new ResizeObserver(() => {
dynamicPadding!.style.minHeight = `${parentPreview!.offsetHeight}px`
if (dynamicPadding && chatCompose) {
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
}
})
observer.observe(parentPreview!)
observer.observe(chatCompose!)
return () => {
observer.unobserve(parentPreview!)
observer.unobserve(chatCompose!)
}
})
@@ -123,56 +137,59 @@
}, 5000)
</script>
{#if others.length > 0}
<PageBar class="chat__page-bar">
{#snippet title()}
<div class="flex flex-col gap-1 sm:flex-row sm:gap-2">
{#if others.length === 1}
{@const pubkey = others[0]}
{@const onClick = () => pushModal(ProfileDetail, {pubkey})}
<Button onclick={onClick} class="row-2">
<ProfileCircle {pubkey} size={5} />
<ProfileName {pubkey} />
</Button>
{:else}
<div class="flex items-center gap-2">
<ProfileCircles pubkeys={others} size={5} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ProfileName pubkey={others[0]} />
and
{#if others.length === 2}
<ProfileName pubkey={others[1]} />
{:else}
{others.length - 1}
{others.length > 2 ? "others" : "other"}
{/if}
</p>
</div>
{#if others.length > 2}
<Button onclick={showMembers} class="btn btn-link hidden sm:block"
>Show all members</Button>
{/if}
<PageBar>
{#snippet title()}
<div class="flex flex-col gap-1 sm:flex-row sm:gap-2">
{#if others.length === 0}
<div class="row-2">
<ProfileCircle pubkey={$pubkey!} size={5} />
<ProfileName pubkey={$pubkey!} />
</div>
{:else if others.length === 1}
{@const pubkey = others[0]}
{@const onClick = () => pushModal(ProfileDetail, {pubkey})}
<Button onclick={onClick} class="row-2">
<ProfileCircle {pubkey} size={5} />
<ProfileName {pubkey} />
</Button>
{:else}
<div class="flex items-center gap-2">
<ProfileCircles pubkeys={others} size={5} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ProfileName pubkey={others[0]} />
and
{#if others.length === 2}
<ProfileName pubkey={others[1]} />
{:else}
{others.length - 1}
{others.length > 2 ? "others" : "other"}
{/if}
</p>
</div>
{#if others.length > 2}
<Button onclick={showMembers} class="btn btn-link hidden sm:block"
>Show all members</Button>
{/if}
</div>
{/snippet}
{#snippet action()}
<div>
{#if remove($pubkey, missingInboxes).length > 0}
{@const count = remove($pubkey, missingInboxes).length}
{@const label = count > 1 ? "inboxes are" : "inbox is"}
<div
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
data-tip="{count} {label} not configured.">
<Icon icon="danger" />
{count}
</div>
{/if}
</div>
{/snippet}
</PageBar>
{/if}
{/if}
</div>
{/snippet}
{#snippet action()}
<div>
{#if remove($pubkey, missingInboxes).length > 0}
{@const count = remove($pubkey, missingInboxes).length}
{@const label = count > 1 ? "inboxes are" : "inbox is"}
<div
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
data-tip="{count} {label} not configured.">
<Icon icon="danger" />
{count}
</div>
{/if}
</div>
{/snippet}
</PageBar>
<div class="chat__messages scroll-container">
<PageContent class="flex flex-col-reverse pt-4">
<div bind:this={dynamicPadding}></div>
{#if missingInboxes.includes($pubkey!)}
<div class="py-12">
@@ -223,10 +240,10 @@
</Spinner>
{@render info?.()}
</p>
</div>
</PageContent>
<div class="chat__compose bg-base-200">
<div bind:this={parentPreview}>
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
<div>
{#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import {writable} from "svelte/store"
import type {EventContent} from "@welshman/util"
import {isMobile, preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
type Props = {
url?: string
onSubmit: (event: EventContent) => void
}
const {onSubmit, url}: Props = $props()
const autofocus = !isMobile
const uploading = writable(false)
export const focus = () => editor.then(ed => ed.chain().focus().run())
const submit = async () => {
if ($uploading) return
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = ed.storage.nostr.getEditorTags()
if (!content) return
onSubmit({content, tags})
ed.chain().clearContent().run()
}
const editor = makeEditor({
url,
autofocus,
submit,
uploading,
aggressive: true,
disableFileUpload: true,
})
</script>
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
<div class="chat-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
disabled={$uploading}
onclick={submit}>
<Icon icon="plain" />
</Button>
</form>
@@ -0,0 +1,35 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {displayProfileByPubkey} from "@welshman/app"
import {slide} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
const {
verb,
event,
clear,
}: {
verb: string
event: TrustedEvent
clear: () => void
} = $props()
</script>
<div
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
transition:slide>
<p class="text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
{#key event.id}
<NoteContent
{event}
hideMediaAtDepth={0}
minLength={100}
maxLength={300}
expandMode="disabled" />
{/key}
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
<Icon icon="close-circle" />
</Button>
</div>
+3 -1
View File
@@ -13,6 +13,8 @@
const {next} = $props()
const nextUrl = $state.snapshot(next)
let loading = $state(false)
const enableChat = async () => {
@@ -23,7 +25,7 @@
}
clearModals()
goto(next)
goto(nextUrl)
}
const submit = async () => {
+1 -1
View File
@@ -40,7 +40,7 @@
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
{#if others.length === 0}
<ProfileCircle pubkey={$pubkey} size={5} />
<ProfileCircle pubkey={$pubkey!} size={5} />
Note to self
{:else if others.length === 1}
<ProfileCircle pubkey={others[0]} size={5} />
+4 -10
View File
@@ -1,14 +1,8 @@
<script lang="ts">
import {type Instance} from "tippy.js"
import {hash} from "@welshman/lib"
import {hash, formatTimestampAsTime} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {
thunks,
deriveProfile,
deriveProfileDisplay,
formatTimestampAsTime,
pubkey,
} from "@welshman/app"
import {thunks, pubkey, deriveProfile, deriveProfileDisplay, sendWrapped} from "@welshman/app"
import {isMobile} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -22,7 +16,7 @@
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
import {colors} from "@app/state"
import {makeDelete, makeReaction, sendWrapped} from "@app/commands"
import {makeDelete, makeReaction} from "@app/commands"
import {pushModal} from "@app/modal"
interface Props {
@@ -125,7 +119,7 @@
<Content showEntire {event} />
</div>
</TapTarget>
<div class="row-2 z-feature -mt-1 ml-4">
<div class="row-2 z-feature -mt-4 ml-4">
<ReactionSummary {event} {onReactionClick} noTooltip />
</div>
</div>
@@ -1,9 +1,10 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {sendWrapped} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import {makeReaction, sendWrapped} from "@app/commands"
import {makeReaction} from "@app/commands"
interface Props {
event: TrustedEvent
@@ -1,11 +1,12 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {sendWrapped} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import {makeReaction, sendWrapped} from "@app/commands"
import {makeReaction} from "@app/commands"
import {pushModal} from "@app/modal"
import {clip} from "@app/toast"
@@ -17,10 +18,10 @@
const {event, pubkeys, reply}: Props = $props()
const onEmoji = ((event: TrustedEvent, emoji: NativeEmoji) => {
const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
history.back()
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
}).bind(undefined, event)
}).bind(undefined, event, pubkeys)
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
+5 -5
View File
@@ -38,8 +38,8 @@
showEntire?: boolean
hideMediaAtDepth?: number
expandMode?: string
relays?: string[]
depth?: number
url?: string
}
let {
@@ -49,8 +49,8 @@
showEntire = $bindable(false),
hideMediaAtDepth = 1,
expandMode = "block",
relays = [],
depth = 0,
url,
}: Props = $props()
const fullContent = parse(event)
@@ -141,15 +141,15 @@
<ContentToken value={parsed.value} />
{:else if isLink(parsed)}
{#if isBlock(i)}
<ContentLinkBlock value={parsed.value} />
<ContentLinkBlock value={parsed.value} {event} />
{:else}
<ContentLinkInline value={parsed.value} />
{/if}
{:else if isProfile(parsed)}
<ContentMention value={parsed.value} />
<ContentMention value={parsed.value} {url} />
{:else if isEvent(parsed) || isAddress(parsed)}
{#if isBlock(i)}
<ContentQuote {depth} {relays} {hideMediaAtDepth} value={parsed.value} {event} />
<ContentQuote {depth} {url} {hideMediaAtDepth} value={parsed.value} {event} />
{:else}
<Link
external
+4 -3
View File
@@ -4,9 +4,10 @@
import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import {pushModal} from "@app/modal"
const {value} = $props()
const {value, event} = $props()
let hideImage = $state(false)
@@ -26,7 +27,7 @@
hideImage = true
}
const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true})
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script>
<Link external href={url} class="my-2 block">
@@ -37,7 +38,7 @@
</video>
{:else if url.match(/\.(jpe?g|png|gif|webp)$/)}
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
<img alt="Link preview" src={imgproxy(url)} class="m-auto max-h-96 rounded-box" />
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
</button>
{:else}
{#await loadPreview()}
@@ -0,0 +1,49 @@
<script lang="ts">
import {onDestroy} from "svelte"
import {now} from "@welshman/lib"
import {BLOSSOM_AUTH, makeEvent, getTags, getTagValue, tagsFromIMeta} from "@welshman/util"
import {signer} from "@welshman/app"
import {imgproxy} from "@app/state"
const {value, event, ...props} = $props()
const url = value.url.toString()
// If we fail to fetch the image, try authenticating if we have a blossom hash
const onerror = async () => {
const meta = getTags("imeta", event.tags)
.map(tagsFromIMeta)
.find(meta => getTagValue("url", meta) === url)
const hash = meta ? getTagValue("x", meta) : undefined
if (hash && $signer) {
const event = await signer.get().sign(
makeEvent(BLOSSOM_AUTH, {
tags: [
["t", "get"],
["x", hash],
["expiration", String(now() + 30)],
],
}),
)
const res = await fetch(url, {
headers: {
Authorization: `Nostr ${btoa(JSON.stringify(event))}`,
},
})
if (res.status === 200) {
src = URL.createObjectURL(await res.blob())
}
}
}
let src = $state(imgproxy(url))
onDestroy(() => {
URL.revokeObjectURL(src)
})
</script>
<img alt="" {src} {onerror} {...props} />
+3 -3
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import Button from "@lib/components/Button.svelte"
import {imgproxy} from "@app/state"
const {url} = $props()
const {value, event} = $props()
const back = () => history.back()
</script>
<Button class="m-auto h-screen w-screen cursor-pointer p-4" onclick={back}>
<img alt="" src={imgproxy(url)} class="m-auto max-h-full max-w-full rounded-box" />
<ContentLinkBlockImage {value} {event} class="m-auto max-h-full max-w-full rounded-box" />
</Button>
+10 -3
View File
@@ -1,15 +1,22 @@
<script lang="ts">
import {removeNil} from "@welshman/lib"
import type {ProfilePointer} from "@welshman/content"
import {displayProfile} from "@welshman/util"
import {deriveProfile} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
const {value} = $props()
type Props = {
value: ProfilePointer
url?: string
}
const profile = deriveProfile(value.pubkey)
const {value, url}: Props = $props()
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey})
const profile = deriveProfile(value.pubkey, removeNil([url]))
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url})
</script>
<Button onclick={openProfile} class="link-content">
+24 -33
View File
@@ -1,9 +1,12 @@
<script lang="ts">
import {nip19} from "nostr-tools"
import * as nip19 from "nostr-tools/nip19"
import {goto} from "$app/navigation"
import {ctx, nthEq} from "@welshman/lib"
import {nthEq} from "@welshman/lib"
import {Router} from "@welshman/router"
import {tracker, repository} from "@welshman/app"
import type {TrustedEvent} from "@welshman/util"
import {Address, DIRECT_MESSAGE, MESSAGE, THREAD, EVENT_TIME} from "@welshman/util"
import {scrollToEvent} from "@lib/html"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
@@ -11,47 +14,35 @@
import {deriveEvent, entityLink, ROOM} from "@app/state"
import {makeThreadPath, makeCalendarPath, makeRoomPath} from "@app/routes"
const {value, event, depth, hideMediaAtDepth, relays = []} = $props()
type Props = {
value: any
hideMediaAtDepth: number
event: TrustedEvent
depth: number
url?: string
}
const {id, identifier, kind, pubkey, relays: relayHints = []} = value
const {value, event, depth, hideMediaAtDepth, url}: Props = $props()
const {id, identifier, kind, pubkey, relays = []} = value
const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
const mergedRelays = [
...relays,
...ctx.app.router.Quote(event, idOrAddress, relayHints).getUrls(),
]
const mergedRelays = Router.get().Quote(event, idOrAddress, relays).getUrls()
if (url) {
mergedRelays.push(url)
}
const quote = deriveEvent(idOrAddress, mergedRelays)
const entity = id
? nip19.neventEncode({id, relays: mergedRelays})
: new Address(kind, pubkey, identifier, mergedRelays).toNaddr()
const scrollToEvent = (id: string) => {
const element = document.querySelector(`[data-event="${id}"]`) as any
if (element) {
element.scrollIntoView({behavior: "smooth"})
element.style =
"filter: brightness(1.5); transition-property: all; transition-duration: 400ms;"
setTimeout(() => {
element.style = "transition-property: all; transition-duration: 300ms;"
}, 800)
setTimeout(() => {
element.style = ""
}, 800 + 400)
}
return Boolean(element)
}
const openMessage = (url: string, room: string, id: string) => {
const event = repository.getEvent(id)
if (event) {
goto(makeRoomPath(url, room))
// TODO: if the event doesn't immediately load, this won't work. Scroll up until it's found
setTimeout(() => scrollToEvent(id), 300)
scrollToEvent(id)
}
return Boolean(event)
@@ -104,8 +95,8 @@
<Button class="my-2 block max-w-full text-left" {onclick}>
{#if $quote}
<NoteCard event={$quote} class="bg-alt rounded-box p-4">
<NoteContent {hideMediaAtDepth} {relays} event={$quote} depth={depth + 1} />
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
</NoteCard>
{:else}
<div class="rounded-box p-4">
+7 -7
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import type {Snippet} from "svelte"
import type {Instance} from "tippy.js"
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
@@ -9,15 +10,14 @@
import EventMenu from "@app/components/EventMenu.svelte"
import {publishReaction} from "@app/commands"
const {
url,
noun,
event,
}: {
type Props = {
url: string
noun: string
event: TrustedEvent
} = $props()
customActions?: Snippet
}
const {url, noun, event, customActions}: Props = $props()
const showPopover = () => popover?.show()
@@ -36,7 +36,7 @@
<Tippy
bind:popover
component={EventMenu}
props={{url, noun, event, onClick: hidePopover}}
props={{url, noun, event, customActions, onClick: hidePopover}}
params={{trigger: "manual", interactive: true}}>
<Button class="btn join-item btn-neutral btn-xs" onclick={showPopover}>
<Icon icon="menu-dots" size={4} />
+3 -2
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import {onMount} from "svelte"
import {max} from "@welshman/lib"
import {max, formatTimestampRelative} from "@welshman/lib"
import {COMMENT} from "@welshman/util"
import {load} from "@welshman/net"
import {deriveEvents} from "@welshman/store"
import type {TrustedEvent} from "@welshman/util"
import {formatTimestampRelative, repository, load} from "@welshman/app"
import {repository} from "@welshman/app"
import {notifications} from "@app/notifications"
import Icon from "@lib/components/Icon.svelte"
+3 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {nip19} from "nostr-tools"
import {ctx} from "@welshman/lib"
import * as nip19 from "nostr-tools/nip19"
import {Router} from "@welshman/router"
import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
@@ -15,7 +15,7 @@
const {url, event}: Props = $props()
const relays = url ? [url] : ctx.app.router.Event(event).getUrls()
const relays = url ? [url] : Router.get().Event(event).getUrls()
const nevent1 = nip19.neventEncode({...event, relays})
const npub1 = nip19.npubEncode(event.pubkey)
const json = JSON.stringify(event, null, 2)
+19 -24
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import {onMount} from "svelte"
import type {Snippet} from "svelte"
import type {TrustedEvent} from "@welshman/util"
import {COMMENT} from "@welshman/util"
import {pubkey} from "@welshman/app"
@@ -10,42 +12,34 @@
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {pushModal} from "@app/modal"
const {
url,
noun,
event,
onClick,
}: {
type Props = {
url: string
noun: string
event: TrustedEvent
onClick: () => void
} = $props()
customActions?: Snippet
}
const {url, noun, event, onClick, customActions}: Props = $props()
const isRoot = event.kind !== COMMENT
const report = () => {
onClick()
pushModal(EventReport, {url, event})
}
const report = () => pushModal(EventReport, {url, event})
const showInfo = () => {
onClick()
pushModal(EventInfo, {url, event})
}
const showInfo = () => pushModal(EventInfo, {url, event})
const share = () => {
onClick()
pushModal(EventShare, {url, event})
}
const share = () => pushModal(EventShare, {url, event})
const showDelete = () => {
onClick()
pushModal(EventDeleteConfirm, {url, event})
}
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-xl">
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-xl" bind:this={ul}>
{#if isRoot}
<li>
<Button onclick={share}>
@@ -60,6 +54,7 @@
{noun} Details
</Button>
</li>
{@render customActions?.()}
{#if event.pubkey === $pubkey}
<li>
<Button onclick={showDelete} class="text-error">
+50 -24
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import {onMount} from "svelte"
import {writable} from "svelte/store"
import {isMobile, preventDefault} from "@lib/html"
import {fly, slideAndFade} from "@lib/transition"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
@@ -15,11 +16,14 @@
const uploading = writable(false)
const submit = () => {
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading) return
const content = editor.getText({blockSeparator: "\n"}).trim()
const tags = [...editor.storage.nostr.getEditorTags(), tagRoom(GENERAL, url), PROTECTED]
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = [...ed.storage.nostr.getEditorTags(), tagRoom(GENERAL, url), PROTECTED]
if (!content) {
return pushToast({
@@ -31,31 +35,53 @@
onSubmit(publishComment({event, content, tags, relays: [url]}))
}
const editor = makeEditor({submit, uploading, autofocus: !isMobile})
const editor = makeEditor({url, submit, uploading, autofocus: !isMobile})
let form: HTMLElement
let spacer: HTMLElement
onMount(() => {
setTimeout(() => {
spacer.scrollIntoView({block: "end", behavior: "smooth"})
})
const observer = new ResizeObserver(() => {
spacer!.style.minHeight = `${form!.offsetHeight}px`
})
observer.observe(form!)
return () => {
observer.unobserve(form!)
}
})
</script>
<div bind:this={spacer}></div>
<form
in:fly
out:slideAndFade
bind:this={form}
onsubmit={preventDefault(submit)}
class="card2 sticky bottom-2 z-feature mx-2 mt-4 bg-neutral">
<div class="relative">
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
class="cb cw fixed z-feature -mx-2 pt-3">
<div class="card2 mx-2 my-2 bg-neutral">
<div class="relative">
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="paperclip" size={3} />
{/if}
</Button>
</div>
<Button
data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={editor.commands.selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="paperclip" size={3} />
{/if}
</Button>
<ModalFooter>
<Button class="btn btn-link" onclick={onClose}>Cancel</Button>
<Button type="submit" class="btn btn-primary">Post Reply</Button>
</ModalFooter>
</div>
<ModalFooter>
<Button class="btn btn-link" onclick={onClose}>Cancel</Button>
<Button type="submit" class="btn btn-primary">Post Reply</Button>
</ModalFooter>
</form>
+1 -1
View File
@@ -42,7 +42,7 @@
<div class="column gap-2">
<div class="flex justify-between">
<div>
<Profile pubkey={report.pubkey} />
<Profile pubkey={report.pubkey} {url} />
<span>Reported this event as "{reason}"</span>
</div>
{#if report.pubkey === $pubkey}
+4 -4
View File
@@ -2,7 +2,7 @@
import {onMount} from "svelte"
import {Capacitor} from "@capacitor/core"
import {getNip07, getNip55, Nip55Signer} from "@welshman/signer"
import {addSession, type Session} from "@welshman/app"
import {addSession, type Session, makeNip07Session, makeNip55Session} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
@@ -24,7 +24,7 @@
const signUp = () => pushModal(SignUp)
const onSuccess = async (session: Session, relays: string[] = []) => {
await loadUserData(session.pubkey, {relays})
await loadUserData(session.pubkey, relays)
addSession(session)
pushToast({message: "Successfully logged in!"})
@@ -39,7 +39,7 @@
const pubkey = await getNip07()?.getPublicKey()
if (pubkey) {
await onSuccess({method: "nip07", pubkey})
await onSuccess(makeNip07Session(pubkey))
} else {
pushToast({
theme: "error",
@@ -59,7 +59,7 @@
const pubkey = await signer.getPubkey()
if (pubkey) {
await onSuccess({method: "nip55", pubkey, signer: app.packageName})
await onSuccess(makeNip55Session(pubkey, app.packageName))
} else {
pushToast({
theme: "error",
+45 -107
View File
@@ -1,38 +1,39 @@
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import {Nip46Broker, getPubkey, makeSecret} from "@welshman/signer"
import {addSession} from "@welshman/app"
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import {loginWithNip01, loginWithNip46} from "@welshman/app"
import {preventDefault} from "@lib/html"
import {slideAndFade} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import QRCode from "@app/components/QRCode.svelte"
import InfoBunker from "@app/components/InfoBunker.svelte"
import {loginWithNip46} from "@app/commands"
import BunkerConnect, {BunkerConnectController} from "@app/components/BunkerConnect.svelte"
import BunkerUrl from "@app/components/BunkerUrl.svelte"
import {loadUserData} from "@app/requests"
import {pushModal, clearModals} from "@app/modal"
import {clearModals} from "@app/modal"
import {setChecked} from "@app/notifications"
import {pushToast} from "@app/toast"
import {NIP46_PERMS, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state"
const clientSecret = makeSecret()
const abortController = new AbortController()
const broker = Nip46Broker.get({clientSecret, relays: SIGNER_RELAYS})
import {SIGNER_RELAYS, NIP46_PERMS} from "@app/state"
const back = () => history.back()
const onSubmit = async () => {
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(bunker)
const controller = new BunkerConnectController({
onNostrConnect: async (response: Nip46ResponseWithResult) => {
const pubkey = await controller.broker.getPublicKey()
if (loading) {
return
}
await loadUserData(pubkey)
loginWithNip46(pubkey, controller.clientSecret, response.event.pubkey, SIGNER_RELAYS)
setChecked("*")
clearModals()
},
})
const onSubmit = async () => {
if (controller.loading) return
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(controller.bunker)
if (!signerPubkey || relays.length === 0) {
return pushToast({
@@ -41,13 +42,21 @@
})
}
loading = true
controller.loading = true
try {
const success = await loginWithNip46({connectSecret, clientSecret, signerPubkey, relays})
const {clientSecret} = controller
const broker = Nip46Broker.get({relays, clientSecret, signerPubkey})
const result = await broker.connect(connectSecret, NIP46_PERMS)
const pubkey = await broker.getPublicKey()
if (success) {
abortController.abort()
// TODO: remove ack result
if (pubkey && ["ack", connectSecret].includes(result)) {
controller.stop()
await loadUserData(pubkey)
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
} else {
return pushToast({
theme: "error",
@@ -55,72 +64,18 @@
})
}
} finally {
loading = false
controller.loading = false
}
clearModals()
}
let url = $state("")
let bunker = $state("")
let loading = $state(false)
$effect(() => {
// For testing and for play store reviewers
if (bunker === "reviewkey") {
const secret = makeSecret()
addSession({method: "nip01", secret, pubkey: getPubkey(secret)})
if (controller.bunker === "reviewkey") {
loginWithNip01(makeSecret())
}
})
onMount(async () => {
url = await broker.makeNostrconnectUrl({
perms: NIP46_PERMS,
url: PLATFORM_URL,
name: PLATFORM_NAME,
image: PLATFORM_LOGO,
})
let response
try {
response = await broker.waitForNostrconnect(url, abortController)
} catch (errorResponse: any) {
if (errorResponse?.error) {
pushToast({
theme: "error",
message: `Received error from signer: ${errorResponse.error}`,
})
} else if (errorResponse) {
console.error(errorResponse)
}
}
if (response) {
loading = true
const userPubkey = await broker.getPublicKey()
await loadUserData(userPubkey)
addSession({
method: "nip46",
pubkey: userPubkey,
secret: clientSecret,
handler: {
pubkey: response.event.pubkey,
relays: SIGNER_RELAYS,
},
})
setChecked("*")
clearModals()
}
})
onDestroy(() => {
abortController.abort()
})
</script>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
@@ -132,35 +87,18 @@
<div>Connect your signer by scanning the QR code below or pasting a bunker link.</div>
{/snippet}
</ModalHeader>
{#if !loading && url}
<div class="flex justify-center" out:slideAndFade>
<QRCode code={url} />
</div>
{/if}
<Field>
{#snippet label()}
<p>Bunker Link*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="cpu" />
<input disabled={loading} bind:value={bunker} class="grow" placeholder="bunker://" />
</label>
{/snippet}
{#snippet info()}
<p>
A login link provided by a nostr signing app.
<Button class="link" onclick={() => pushModal(InfoBunker)}>What is a bunker link?</Button>
</p>
{/snippet}
</Field>
<BunkerConnect {controller} />
<BunkerUrl loading={controller.loading} bind:bunker={controller.bunker} />
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Button class="btn btn-link" onclick={back} disabled={controller.loading}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading || !bunker}>
<Spinner {loading}>Next</Spinner>
<Button
type="submit"
class="btn btn-primary"
disabled={controller.loading || !controller.bunker}>
<Spinner loading={controller.loading}>Next</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
+6 -11
View File
@@ -3,7 +3,7 @@
import {postJson, stripProtocol} from "@welshman/lib"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import {normalizeRelayUrl} from "@welshman/util"
import {addSession} from "@welshman/app"
import {addSession, makeNip46Session} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
@@ -68,7 +68,7 @@
let response
try {
response = await broker.waitForNostrconnect(url, abortController)
response = await broker.waitForNostrconnect(url, abortController.signal)
} catch (errorResponse: any) {
if (errorResponse?.error) {
pushToast({
@@ -83,17 +83,12 @@
if (response) {
loading = true
const userPubkey = await broker.getPublicKey()
const pubkey = await broker.getPublicKey()
const session = makeNip46Session(pubkey, clientSecret, response.event.pubkey, relays)
await loadUserData(userPubkey)
await loadUserData(pubkey)
addSession({
email,
method: "nip46",
pubkey: userPubkey,
secret: clientSecret,
handler: {pubkey: response.event.pubkey, relays},
})
addSession({...session, email})
setChecked("*")
clearModals()
+2 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {onMount} from "svelte"
import {displayRelayUrl, GROUP_META} from "@welshman/util"
import {displayRelayUrl} from "@welshman/util"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -22,7 +22,6 @@
deriveOtherRooms,
} from "@app/state"
import {notifications} from "@app/notifications"
import {pullConservatively} from "@app/requests"
import {pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes"
@@ -44,7 +43,7 @@
const showMembers = () =>
pushModal(
ProfileList,
{pubkeys: members, title: `Members of`, subtitle: displayRelayUrl(url)},
{url, pubkeys: members, title: `Members of`, subtitle: displayRelayUrl(url)},
{replaceState},
)
@@ -66,7 +65,6 @@
onMount(() => {
replaceState = Boolean(element?.closest(".drawer"))
pullConservatively({relays: [url], filters: [{kinds: [GROUP_META]}]})
})
</script>
+3 -10
View File
@@ -10,26 +10,19 @@
}
}
let modal: any = $state.raw()
const hash = $derived($page.url.hash.slice(1))
const hashIsValid = $derived(Boolean($modals[hash]))
$effect(() => {
if ($modals[hash]) {
modal = $modals[hash]
}
})
const modal = $derived($modals[hash])
</script>
<svelte:window onkeydown={onKeyDown} />
{#if hashIsValid && modal?.options?.drawer}
{#if modal?.options?.drawer}
<Drawer onClose={clearModals} {...modal.options}>
{#key modal.id}
<modal.component {...modal.props} />
{/key}
</Drawer>
{:else if hashIsValid && modal}
{:else if modal}
<Dialog onClose={clearModals} {...modal.options}>
{#key modal.id}
<modal.component {...modal.props} />
+9 -6
View File
@@ -1,11 +1,12 @@
<script lang="ts">
import cx from "classnames"
import type {Snippet} from "svelte"
import {nip19} from "nostr-tools"
import {ctx} from "@welshman/lib"
import * as nip19 from "nostr-tools/nip19"
import {formatTimestamp} from "@welshman/lib"
import {getListTags, getPubkeyTagValues} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {formatTimestamp, userMutes} from "@welshman/app"
import {Router} from "@welshman/router"
import {userMutes} from "@welshman/app"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -18,16 +19,18 @@
children,
minimal = false,
hideProfile = false,
url,
...restProps
}: {
event: TrustedEvent
children: Snippet
minimal?: boolean
hideProfile?: boolean
url?: string
class?: string
} = $props()
const relays = ctx.app.router.Event(event).getUrls()
const relays = Router.get().Event(event).getUrls()
const nevent = nip19.neventEncode({id: event.id, relays})
const ignoreMute = () => {
@@ -50,9 +53,9 @@
<div class="flex justify-between gap-2">
{#if !hideProfile}
{#if minimal}
@<ProfileName pubkey={event.pubkey} />
@<ProfileName pubkey={event.pubkey} {url} />
{:else}
<Profile pubkey={event.pubkey} />
<Profile pubkey={event.pubkey} {url} />
{/if}
{/if}
<Link
+1 -3
View File
@@ -12,9 +12,7 @@
<div class="flex items-start gap-4">
<CalendarEventDate event={props.event} />
<div class="flex flex-grow flex-col">
<div class="flex flex-grow flex-wrap justify-between gap-2">
<CalendarEventHeader event={props.event} />
</div>
<CalendarEventHeader event={props.event} />
<div class="flex py-2 opacity-50">
<div class="h-px flex-grow bg-base-content opacity-25"></div>
</div>
+1 -1
View File
@@ -25,7 +25,7 @@
publishReaction({event, content: emoji.unicode, relays: [url]})
</script>
<NoteCard {event} class="card2 bg-alt">
<NoteCard {event} {url} class="card2 bg-alt">
<NoteContent {event} expandMode="inline" />
<div class="flex w-full justify-between gap-2">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right">
+13 -6
View File
@@ -1,16 +1,23 @@
<script lang="ts">
import {onMount} from "svelte"
import {ctx} from "@welshman/lib"
import {formatTimestampRelative} from "@welshman/lib"
import type {Filter} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import {repository, load, loadRelaySelections, formatTimestampRelative} from "@welshman/app"
import {load} from "@welshman/net"
import {Router} from "@welshman/router"
import {repository, loadRelaySelections} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Profile from "@app/components/Profile.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte"
import {makeChatPath} from "@app/routes"
const {pubkey} = $props()
type Props = {
pubkey: string
url?: string
}
const {pubkey, url}: Props = $props()
const filters: Filter[] = [{authors: [pubkey], limit: 1}]
const events = deriveEvents(repository, {filters})
@@ -22,20 +29,20 @@
// Load at least one note, regardless of time frame
load({
filters: [{authors: [pubkey], limit: 1}],
relays: ctx.app.router.FromPubkeys([pubkey]).getUrls(),
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
})
})
</script>
<div class="card2 bg-alt col-2 shadow-xl">
<div class="flex justify-between">
<Profile {pubkey} />
<Profile {pubkey} {url} />
<Link class="btn btn-primary hidden sm:flex" href={makeChatPath([pubkey])}>
<Icon icon="letter" />
Start a Chat
</Link>
</div>
<ProfileInfo {pubkey} />
<ProfileInfo {pubkey} {url} />
{#if $events.length > 0}
<div class="bg-alt badge badge-neutral border-none">
Last active {formatTimestampRelative($events[0].created_at)}
+12 -5
View File
@@ -1,12 +1,13 @@
<script lang="ts">
import {removeNil} from "@welshman/lib"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import {
session,
userFollows,
deriveUserWotScore,
deriveProfile,
deriveHandleForPubkey,
displayHandle,
deriveProfile,
deriveProfileDisplay,
} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
@@ -15,14 +16,20 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
const {pubkey} = $props()
type Props = {
pubkey: string
url?: string
}
const profile = deriveProfile(pubkey)
const profileDisplay = deriveProfileDisplay(pubkey)
const {pubkey, url}: Props = $props()
const relays = removeNil([url])
const profile = deriveProfile(pubkey, relays)
const profileDisplay = deriveProfileDisplay(pubkey, relays)
const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey)
const openProfile = () => pushModal(ProfileDetail, {pubkey})
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
const following = $derived(
pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey),
+9 -3
View File
@@ -1,10 +1,16 @@
<script lang="ts">
import {deriveProfile} from "@welshman/app"
import Avatar from "@lib/components/Avatar.svelte"
import {removeNil} from "@welshman/lib"
import {deriveProfile} from "@welshman/app"
const {...props} = $props()
type Props = {
pubkey: string
url?: string
} & Record<string, any>
const profile = deriveProfile(props.pubkey)
const {pubkey, url, ...props}: Props = $props()
const profile = deriveProfile(pubkey, removeNil([url]))
</script>
<Avatar src={$profile?.picture} icon="user-circle" {...props} />
+3 -2
View File
@@ -7,8 +7,9 @@
DELETE,
isReplaceable,
getAddress,
getRelaysFromList,
} from "@welshman/util"
import {pubkey, userRelaySelections, publishThunk, getRelayUrls, repository} from "@welshman/app"
import {pubkey, userRelaySelections, publishThunk, repository} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -40,7 +41,7 @@
const denominator = chunks.length + 2
const relays = uniq([
...INDEXER_RELAYS,
...getRelayUrls($userRelaySelections),
...getRelaysFromList($userRelaySelections),
...getMembershipUrls($userMembership),
])
+13 -6
View File
@@ -1,13 +1,14 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {removeNil} from "@welshman/lib"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import {
session,
userFollows,
deriveUserWotScore,
deriveProfile,
deriveHandleForPubkey,
displayHandle,
deriveProfile,
deriveProfileDisplay,
} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
@@ -22,10 +23,16 @@
import {pushModal} from "@app/modal"
import {makeChatPath} from "@app/routes"
const {pubkey} = $props()
export type Props = {
pubkey: string
url?: string
}
const profile = deriveProfile(pubkey)
const profileDisplay = deriveProfileDisplay(pubkey)
const {pubkey, url}: Props = $props()
const relays = removeNil([url])
const profile = deriveProfile(pubkey, relays)
const display = deriveProfileDisplay(pubkey, relays)
const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey)
@@ -48,7 +55,7 @@
<div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2">
<span class="text-bold overflow-hidden text-ellipsis">
{$profileDisplay}
{$display}
</span>
<WotScore score={$score} active={following} />
</div>
@@ -57,7 +64,7 @@
</div>
</div>
</div>
<ProfileInfo {pubkey} />
<ProfileInfo {pubkey} {url} />
<ModalFooter>
<Button onclick={back} class="btn btn-link">
<Icon icon="alt-arrow-left" />
+16 -4
View File
@@ -1,26 +1,38 @@
<script lang="ts">
import {ctx} from "@welshman/lib"
import type {Profile} from "@welshman/util"
import {
getTag,
createEvent,
makeProfile,
editProfile,
createProfile,
isPublishedProfile,
uniqTags,
} from "@welshman/util"
import {Router} from "@welshman/router"
import {pubkey, profilesByPubkey, publishThunk} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import {clearModals} from "@app/modal"
import {pushToast} from "@app/toast"
import {PROTECTED, getMembershipUrls, userMembership} from "@app/state"
const initialValues = {...($profilesByPubkey.get($pubkey!) || makeProfile())}
const profile = $profilesByPubkey.get($pubkey!) || makeProfile()
const shouldBroadcast = !getTag(PROTECTED, profile.event?.tags || [])
const initialValues = {profile, shouldBroadcast}
const back = () => history.back()
const onsubmit = (profile: Profile) => {
const relays = ctx.app.router.FromUser().getUrls()
const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => {
const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
const relays = [...getMembershipUrls($userMembership)]
if (shouldBroadcast) {
relays.push(...Router.get().FromUser().getUrls())
} else {
template.tags = uniqTags([...template.tags, PROTECTED])
}
const event = createEvent(template.kind, template)
publishThunk({event, relays})
+30 -9
View File
@@ -1,23 +1,28 @@
<script lang="ts">
import type {Snippet} from "svelte"
import type {Profile} from "@welshman/util"
import {makeProfile} from "@welshman/util"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import InputProfilePicture from "@lib/components/InputProfilePicture.svelte"
import InfoHandle from "@app/components/InfoHandle.svelte"
import {pushModal} from "@app/modal"
type Values = {
profile: Profile
shouldBroadcast: boolean
}
type Props = {
initialValues?: Profile
onsubmit: (profile: Profile) => void
initialValues: Values
onsubmit: (values: Values) => void
hideAddress?: boolean
footer: Snippet
}
const {initialValues = makeProfile(), hideAddress, onsubmit, footer}: Props = $props()
const {initialValues, hideAddress, onsubmit, footer}: Props = $props()
const values = $state(initialValues)
@@ -28,7 +33,7 @@
<form class="col-4" onsubmit={preventDefault(submit)}>
<div class="flex justify-center py-2">
<InputProfilePicture bind:file bind:url={values.picture} />
<InputProfilePicture bind:file bind:url={values.profile.picture} />
</div>
<Field>
{#snippet label()}
@@ -37,7 +42,7 @@
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="user-circle" />
<input bind:value={values.name} class="grow" type="text" />
<input bind:value={values.profile.name} class="grow" type="text" />
</label>
{/snippet}
{#snippet info()}
@@ -49,8 +54,10 @@
<p>About You</p>
{/snippet}
{#snippet input()}
<textarea class="textarea textarea-bordered leading-4" rows="3" bind:value={values.about}
></textarea>
<textarea
class="textarea textarea-bordered leading-4"
rows="5"
bind:value={values.profile.about}></textarea>
{/snippet}
{#snippet info()}
Give a brief introduction to why you're here.
@@ -64,7 +71,7 @@
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="map-point" />
<input bind:value={values.nip05} class="grow" type="text" />
<input bind:value={values.profile.nip05} class="grow" type="text" />
</label>
{/snippet}
{#snippet info()}
@@ -75,5 +82,19 @@
{/snippet}
</Field>
{/if}
<FieldInline>
{#snippet label()}
<p>Broadcast Profile</p>
{/snippet}
{#snippet input()}
<input type="checkbox" class="toggle toggle-primary" bind:checked={values.shouldBroadcast} />
{/snippet}
{#snippet info()}
<p>
If enabled, changes will be published to the broader nostr network in addition to spaces you
are a member of.
</p>
{/snippet}
</FieldInline>
{@render footer()}
</form>
+2 -2
View File
@@ -4,7 +4,7 @@
import {feedFromFilter, makeIntersectionFeed, makeRelayFeed} from "@welshman/feeds"
import {NOTE, getReplyTags} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {createFeedController} from "@welshman/app"
import {makeFeedController} from "@welshman/app"
import {createScroller} from "@lib/html"
import {fly} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte"
@@ -19,7 +19,7 @@
let {url, pubkey, events = $bindable([]), hideLoading = false}: Props = $props()
const ctrl = createFeedController({
const ctrl = makeFeedController({
useWindowing: true,
feed: makeIntersectionFeed(
makeRelayFeed(url),
+9 -3
View File
@@ -1,12 +1,18 @@
<script lang="ts">
import {removeNil} from "@welshman/lib"
import {deriveProfile} from "@welshman/app"
import Content from "@app/components/Content.svelte"
const {pubkey} = $props()
export type Props = {
pubkey: string
url?: string
}
const profile = deriveProfile(pubkey)
const {pubkey, url}: Props = $props()
const profile = deriveProfile(pubkey, removeNil([url]))
</script>
{#if $profile}
<Content event={{content: $profile.about, tags: []}} />
<Content event={{content: $profile.about, tags: []}} hideMediaAtDepth={0} />
{/if}
+8 -3
View File
@@ -5,11 +5,16 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
const {pubkey}: {pubkey: string} = $props()
type Props = {
pubkey: string
url?: string
}
const openProfile = () => pushModal(ProfileDetail, {pubkey})
const {pubkey, url}: Props = $props()
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
</script>
<Button onclick={preventDefault(openProfile)} class="link-content">
@<ProfileName {pubkey} />
@<ProfileName {pubkey} {url} />
</Button>
+4 -3
View File
@@ -5,11 +5,12 @@
interface Props {
title: any
subtitle?: string
pubkeys: any
subtitle?: string
url?: string
}
const {subtitle = "", pubkeys, ...restProps}: Props = $props()
const {subtitle = "", pubkeys, url, ...restProps}: Props = $props()
</script>
<div class="column gap-4">
@@ -23,7 +24,7 @@
</ModalHeader>
{#each pubkeys as pubkey (pubkey)}
<div class="card2 bg-alt">
<Profile {pubkey} />
<Profile {pubkey} {url} />
</div>
{/each}
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
+8 -2
View File
@@ -1,9 +1,15 @@
<script lang="ts">
import {removeNil} from "@welshman/lib"
import {deriveProfileDisplay} from "@welshman/app"
const {pubkey} = $props()
type Props = {
pubkey: string
url?: string
}
const profileDisplay = deriveProfileDisplay(pubkey)
const {pubkey, url}: Props = $props()
const profileDisplay = deriveProfileDisplay(pubkey, removeNil([url]))
</script>
{$profileDisplay}
+13 -6
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import {onMount} from "svelte"
import type {Snippet} from "svelte"
import {groupBy, uniq, uniqBy, batch} from "@welshman/lib"
import {REACTION, getTag, REPORT, DELETE} from "@welshman/util"
import {groupBy, uniq, uniqBy, batch, displayList} from "@welshman/lib"
import {REACTION, getReplyFilters, getTag, REPORT, DELETE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import {pubkey, repository, load, displayProfileByPubkey} from "@welshman/app"
import {displayList} from "@lib/util"
import {load} from "@welshman/net"
import {pubkey, repository, displayProfileByPubkey} from "@welshman/app"
import {isMobile, preventDefault, stopPropagation} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import EventReportDetails from "@app/components/EventReportDetails.svelte"
@@ -51,18 +51,25 @@
)
onMount(() => {
const controller = new AbortController()
if (url) {
load({
relays: [url],
filters: [{kinds: [REACTION, REPORT, DELETE], "#e": [event.id]}],
signal: controller.signal,
filters: getReplyFilters([event], {kinds: [REACTION, REPORT, DELETE]}),
onEvent: batch(300, (events: TrustedEvent[]) => {
load({
relays: [url],
filters: [{kinds: [DELETE], "#e": events.map(e => e.id)}],
filters: getReplyFilters(events, {kinds: [DELETE]}),
})
}),
})
}
return () => {
controller.abort()
}
})
</script>
+2 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {encrypt} from "nostr-tools/nip49"
import {hexToBytes} from "@noble/hashes/utils"
import {makeSecret, getPubkey} from "@welshman/signer"
import {makeSecret} from "@welshman/signer"
import {preventDefault, downloadText} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
@@ -15,8 +15,6 @@
const secret = makeSecret()
const pubkey = getPubkey(secret)
const back = () => history.back()
const next = () => {
@@ -31,7 +29,7 @@
downloadText("Nostr Secret Key.txt", ncryptsec)
pushModal(SignUpKeyConfirm, {secret, pubkey, ncryptsec})
pushModal(SignUpKeyConfirm, {secret, ncryptsec})
}
let password = ""
+2 -3
View File
@@ -11,11 +11,10 @@
type Props = {
secret: string
pubkey: string
ncryptsec: string
}
const {secret, pubkey, ncryptsec}: Props = $props()
const {secret, ncryptsec}: Props = $props()
const back = () => history.back()
@@ -25,7 +24,7 @@
}
const next = () => {
pushModal(SignUpProfile, {secret, pubkey})
pushModal(SignUpProfile, {secret})
}
</script>
+13 -8
View File
@@ -1,27 +1,32 @@
<script lang="ts">
import type {Profile} from "@welshman/util"
import {PROFILE, createProfile, createEvent} from "@welshman/util"
import {addSession, publishThunk} from "@welshman/app"
import {PROFILE, createProfile, makeProfile, createEvent} from "@welshman/util"
import {loginWithNip01, publishThunk} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import {INDEXER_RELAYS} from "@app/state"
type Props = {
secret: string
pubkey: string
}
const {secret, pubkey}: Props = $props()
const {secret}: Props = $props()
const onsubmit = (profile: Profile) => {
const initialValues = {
profile: makeProfile(),
shouldBroadcast: true,
}
const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => {
const event = createEvent(PROFILE, createProfile(profile))
const relays = shouldBroadcast ? INDEXER_RELAYS : []
addSession({method: "nip01", secret, pubkey})
publishThunk({event, relays: INDEXER_RELAYS})
loginWithNip01(secret)
publishThunk({event, relays})
}
</script>
<ProfileEditForm hideAddress {onsubmit}>
<ProfileEditForm hideAddress {initialValues} {onsubmit}>
{#snippet footer()}
<Button type="submit" class="btn btn-primary">Create Account</Button>
{/snippet}
+3 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {ctx, sleep} from "@welshman/lib"
import {sleep} from "@welshman/lib"
import {Pool, AuthStatus} from "@welshman/net"
import {displayRelayUrl} from "@welshman/util"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
@@ -17,7 +18,7 @@
const back = () => history.back()
const next = () => {
if (!error && ctx.net.pool.get(url).stats.lastAuth === 0) {
if (!error && Pool.get().get(url).auth.status === AuthStatus.None) {
pushModal(SpaceVisitConfirm, {url}, {replaceState: true})
} else {
confirmSpaceVisit(url)
+6 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {sleep, identity, nthEq} from "@welshman/lib"
import {load} from "@welshman/app"
import {request} from "@welshman/net"
import {displayRelayUrl, AUTH_INVITE} from "@welshman/util"
import {slide} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte"
@@ -29,7 +29,11 @@
onMount(async () => {
const [[event]] = await Promise.all([
load({filters: [{kinds: [AUTH_INVITE]}], relays: [url]}),
request({
relays: [url],
autoClose: true,
filters: [{kinds: [AUTH_INVITE]}],
}),
sleep(2000),
])
+4 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import {ctx, tryCatch} from "@welshman/lib"
import {tryCatch} from "@welshman/lib"
import {isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {Pool, AuthStatus} from "@welshman/net"
import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
@@ -25,9 +26,9 @@
return pushToast({theme: "error", message: error})
}
const connection = ctx.net.pool.get(url)
const socket = Pool.get().get(url)
if (connection.stats.lastAuth === 0) {
if (socket.auth.status === AuthStatus.None) {
pushModal(SpaceJoinConfirm, {url}, {replaceState: true})
} else {
await confirmSpaceJoin(url)
+8 -5
View File
@@ -19,7 +19,9 @@
const back = () => history.back()
const submit = () => {
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading) return
if (!title) {
@@ -29,7 +31,8 @@
})
}
const content = editor.getText({blockSeparator: "\n"}).trim()
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
if (!content.trim()) {
return pushToast({
@@ -39,7 +42,7 @@
}
const tags = [
...editor.storage.nostr.getEditorTags(),
...ed.storage.nostr.getEditorTags(),
tagRoom(GENERAL, url),
["title", title],
PROTECTED,
@@ -53,7 +56,7 @@
history.back()
}
const editor = makeEditor({submit, uploading, placeholder: "What's on your mind?"})
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
let title: string = $state("")
</script>
@@ -97,7 +100,7 @@
<Button
data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={editor.commands.selectFiles}>
onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
+7 -9
View File
@@ -1,20 +1,18 @@
<script lang="ts">
import {nthEq} from "@welshman/lib"
import {nthEq, formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {formatTimestamp} from "@welshman/app"
import Link from "@lib/components/Link.svelte"
import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import ThreadActions from "@app/components/ThreadActions.svelte"
import {makeThreadPath} from "@app/routes"
const {
url,
event,
}: {
type Props = {
url: string
event: TrustedEvent
} = $props()
}
const {url, event}: Props = $props()
const title = event.tags.find(nthEq(0, "title"))?.[1]
</script>
@@ -32,10 +30,10 @@
{formatTimestamp(event.created_at)}
</p>
{/if}
<Content {event} expandMode="inline" relays={[url]} />
<Content {event} {url} expandMode="inline" />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} />
Posted by <ProfileLink pubkey={event.pubkey} {url} />
</span>
<ThreadActions showActivity {url} {event} />
</div>
+39 -25
View File
@@ -1,12 +1,16 @@
<script lang="ts">
import {get} from "svelte/store"
import {nth} from "@welshman/lib"
import {PublishStatus} from "@welshman/net"
import {mergeThunks, publishThunk} from "@welshman/app"
import type {Thunk, MergedThunk} from "@welshman/app"
import {throttled} from "@welshman/store"
import {
MergedThunk,
publishThunk,
isMergedThunk,
thunkIsComplete,
thunkHasStatus,
} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte"
import ThunkStatusDetail from "@app/components/ThunkStatusDetail.svelte"
import {userSettingValues} from "@app/state"
@@ -17,31 +21,34 @@
let {thunk, ...restProps}: Props = $props()
const {Pending, Failure, Timeout} = PublishStatus
const abort = () => thunk.controller.abort()
const retry = () => {
thunk = (thunk as any).thunks
? mergeThunks((thunk as MergedThunk).thunks.map(t => publishThunk(t.request)))
: publishThunk((thunk as Thunk).request)
thunk = isMergedThunk(thunk)
? new MergedThunk(thunk.thunks.map(t => publishThunk(t.options)))
: publishThunk(thunk.options)
}
const status = $derived(throttled(300, thunk.status))
const ps = $derived(Object.values($status))
const canCancel = $derived(ps.length === 0 && $userSettingValues.send_delay > 0)
const isFailure = $derived(!canCancel && ps.every(s => [Failure, Timeout].includes(s.status)))
const failure = $derived(
Object.entries($status).find(([url, s]) => [Failure, Timeout].includes(s.status)),
const statuses = $derived(Object.entries($thunk.status))
const isSending = $derived(thunkHasStatus($thunk, PublishStatus.Sending))
const canCancel = $derived(isSending && $userSettingValues.send_delay > 0)
const failedUrls = $derived(
statuses
.filter(([_, status]) => [PublishStatus.Failure, PublishStatus.Timeout].includes(status))
.map(nth(0)),
)
let isPending = $state(Object.values(get(thunk.status)).some(s => s.status == Pending))
const showFailure = $derived(thunkIsComplete($thunk) && failedUrls.length > 0)
let isPending = $state(thunkHasStatus($thunk, PublishStatus.Pending))
const showPending = $derived(canCancel || isPending)
// Delay updating isPending so users can see that the message is sent
$effect(() => {
isPending = isPending || ps.some(s => s.status === Pending)
isPending = isPending || thunkHasStatus($thunk, PublishStatus.Pending)
if (!ps.some(s => s.status === Pending)) {
if (!thunkHasStatus($thunk, PublishStatus.Pending)) {
setTimeout(() => {
isPending = false
}, 2000)
@@ -49,8 +56,10 @@
})
</script>
{#if isFailure && failure}
{@const [url, {message, status}] = failure}
{#if showFailure}
{@const url = failedUrls[0]}
{@const status = $thunk.status[url]}
{@const message = $thunk.details[url]}
<div class="flex justify-end px-1 text-xs {restProps.class}">
<Tippy
class="flex items-center {restProps.class}"
@@ -65,14 +74,19 @@
{/snippet}
</Tippy>
</div>
{:else if canCancel || isPending}
{:else if showPending}
<div class="flex justify-end px-1 text-xs {restProps.class}">
<span class="flex items-center gap-1 {restProps.class}">
<span class="loading loading-spinner mx-1 h-3 w-3 translate-y-px"></span>
<span class="opacity-50">Sending...</span>
{#if canCancel}
<Button class="link" onclick={abort}>Cancel</Button>
{/if}
<button
type="button"
class="underline transition-all"
class:link={canCancel}
class:opacity-25={!canCancel}
onclick={abort}>
Cancel
</button>
</span>
</div>
{/if}
+15 -8
View File
@@ -1,22 +1,29 @@
<script lang="ts">
import {Editor} from "@welshman/editor"
import {onDestroy, onMount} from "svelte"
const {editor} = $props()
type Props = {
editor: Promise<Editor>
}
const {editor}: Props = $props()
let element: HTMLElement
onMount(() => {
if (editor.options.element) {
element?.append(editor.options.element)
}
editor.then(({options}) => {
if (options.element) {
element?.append(options.element)
}
if (editor.options.autofocus) {
;(element?.querySelector("[contenteditable]") as HTMLElement)?.focus()
}
if (options.autofocus) {
;(element?.querySelector("[contenteditable]") as HTMLElement)?.focus()
}
})
})
onDestroy(() => {
editor.destroy()
editor.then($editor => $editor.destroy())
})
</script>
+22 -19
View File
@@ -1,26 +1,29 @@
import type {NodeViewProps} from "@tiptap/core"
import {removeNil} from "@welshman/lib"
import {deriveProfileDisplay} from "@welshman/app"
export const MentionNodeView = ({node}: NodeViewProps) => {
const dom = document.createElement("span")
const display = deriveProfileDisplay(node.attrs.pubkey)
export const makeMentionNodeView =
(url?: string) =>
({node}: NodeViewProps) => {
const dom = document.createElement("span")
const display = deriveProfileDisplay(node.attrs.pubkey, removeNil([url]))
dom.classList.add("tiptap-object")
dom.classList.add("tiptap-object")
const unsubDisplay = display.subscribe($display => {
dom.textContent = "@" + $display
})
const unsubDisplay = display.subscribe($display => {
dom.textContent = "@" + $display
})
return {
dom,
destroy: () => {
unsubDisplay()
},
selectNode() {
dom.classList.add("tiptap-active")
},
deselectNode() {
dom.classList.remove("tiptap-active")
},
return {
dom,
destroy: () => {
unsubDisplay()
},
selectNode() {
dom.classList.add("tiptap-active")
},
deselectNode() {
dom.classList.remove("tiptap-active")
},
}
}
}
+9 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import {removeNil} from "@welshman/lib"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import {
userFollows,
@@ -10,10 +11,15 @@
import WotScore from "@lib/components/WotScore.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
const {value} = $props()
type Props = {
value: string
url?: string
}
const {value, url}: Props = $props()
const pubkey = value
const profileDisplay = deriveProfileDisplay(pubkey)
const profileDisplay = deriveProfileDisplay(pubkey, removeNil([url]))
const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey)
@@ -22,7 +28,7 @@
<div class="flex max-w-full gap-3">
<div class="py-1">
<ProfileCircle {pubkey} />
<ProfileCircle {pubkey} {url} />
</div>
<div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2">
+71 -29
View File
@@ -1,23 +1,58 @@
import {mount} from "svelte"
import type {Writable} from "svelte/store"
import {get} from "svelte/store"
import {Editor} from "@tiptap/core"
import {ctx} from "@welshman/lib"
import type {StampedEvent} from "@welshman/util"
import {signer, profileSearch} from "@welshman/app"
import {MentionSuggestion, WelshmanExtension} from "@welshman/editor"
import {getSetting, userSettingValues} from "@app/state"
import {MentionNodeView} from "./MentionNodeView"
import {makeEvent, getTagValues, getListTags, BLOSSOM_AUTH} from "@welshman/util"
import {simpleCache, normalizeUrl, removeNil, now} from "@welshman/lib"
import {Router} from "@welshman/router"
import {signer, profileSearch, userBlossomServers} from "@welshman/app"
import {Editor, MentionSuggestion, WelshmanExtension} from "@welshman/editor"
import {makeMentionNodeView} from "./MentionNodeView"
import ProfileSuggestion from "./ProfileSuggestion.svelte"
export const getUploadType = () => getSetting<"nip96" | "blossom">("upload_type")
export const hasBlossomSupport = simpleCache(async ([url]: [string]) => {
try {
const event = await signer.get()!.sign(
makeEvent(BLOSSOM_AUTH, {
tags: [
["t", "upload"],
["server", url],
["expiration", String(now() + 30)],
],
}),
)
export const getUploadUrl = () => {
const {upload_type, nip96_urls, blossom_urls} = userSettingValues.get()
const res = await fetch(normalizeUrl(url) + "/upload", {
method: "head",
headers: {
Authorization: `Nostr ${btoa(JSON.stringify(event))}`,
"X-Content-Type": "text/plain",
"X-Content-Length": "1",
"X-SHA-256": "73cb3858a687a8494ca3323053016282f3dad39d42cf62ca4e79dda2aac7d9ac",
},
})
return upload_type === "nip96"
? nip96_urls[0] || "https://nostr.build"
: blossom_urls[0] || "https://cdn.satellite.earth"
return res.status === 200
} catch (e) {
if (!String(e).includes("Failed to fetch")) {
console.error(e)
}
}
})
export const getUploadUrl = async (spaceUrl?: string) => {
const userUrls = getTagValues("server", getListTags(userBlossomServers.get()))
const allUrls = removeNil([spaceUrl, ...userUrls])
for (let url of allUrls) {
url = url.replace(/^ws/, "http")
if (await hasBlossomSupport(url)) {
return url
}
}
return "https://cdn.satellite.earth"
}
export const signWithAssert = async (template: StampedEvent) => {
@@ -26,26 +61,30 @@ export const signWithAssert = async (template: StampedEvent) => {
return event!
}
export const makeEditor = ({
export const makeEditor = async ({
aggressive = false,
autofocus = false,
charCount,
content = "",
placeholder = "",
url,
submit,
uploading,
wordCount,
disableFileUpload,
}: {
aggressive?: boolean
autofocus?: boolean
charCount?: Writable<number>
content?: string
placeholder?: string
url?: string
submit: () => void
uploading?: Writable<boolean>
wordCount?: Writable<number>
}) =>
new Editor({
disableFileUpload?: boolean
}) => {
return new Editor({
content,
autofocus,
element: document.createElement("div"),
@@ -53,8 +92,8 @@ export const makeEditor = ({
WelshmanExtension.configure({
submit,
sign: signWithAssert,
defaultUploadType: getUploadType(),
defaultUploadUrl: getUploadUrl(),
defaultUploadType: "blossom",
defaultUploadUrl: await getUploadUrl(url),
extensions: {
placeholder: {
config: {
@@ -66,29 +105,31 @@ export const makeEditor = ({
aggressive,
},
},
fileUpload: {
config: {
onDrop() {
uploading?.set(true)
fileUpload: disableFileUpload
? false
: {
config: {
onDrop() {
uploading?.set(true)
},
onComplete() {
uploading?.set(false)
},
},
},
onComplete() {
uploading?.set(false)
},
},
},
nprofile: {
extend: {
addNodeView: () => MentionNodeView,
addNodeView: () => makeMentionNodeView(url),
addProseMirrorPlugins() {
return [
MentionSuggestion({
editor: (this as any).editor,
search: (term: string) => get(profileSearch).searchValues(term),
getRelays: (pubkey: string) => ctx.app.router.FromPubkeys([pubkey]).getUrls(),
getRelays: (pubkey: string) => Router.get().FromPubkeys([pubkey]).getUrls(),
createSuggestion: (value: string) => {
const target = document.createElement("div")
mount(ProfileSuggestion, {target, props: {value}})
mount(ProfileSuggestion, {target, props: {value, url}})
return target
},
@@ -105,3 +146,4 @@ export const makeEditor = ({
charCount?.set(editor.storage.wordCount.chars)
},
})
}
+91 -66
View File
@@ -8,8 +8,8 @@ import {
uniq,
int,
YEAR,
MONTH,
insert,
DAY,
insertAt,
sortBy,
assoc,
now,
@@ -23,26 +23,27 @@ import {
matchFilters,
getTagValues,
getTagValue,
getAddress,
isShareableRelayUrl,
getRelaysFromList,
} from "@welshman/util"
import type {TrustedEvent, Filter, List} from "@welshman/util"
import {feedFromFilters, makeRelayFeed, makeIntersectionFeed} from "@welshman/feeds"
import type {Subscription, SubscribeRequestWithHandlers} from "@welshman/net"
import {load, request} from "@welshman/net"
import type {AppSyncOpts, Thunk} from "@welshman/app"
import {
subscribe,
load,
repository,
pull,
hasNegentropy,
thunkWorker,
createFeedController,
thunkQueue,
makeFeedController,
loadRelay,
loadMutes,
loadFollows,
loadProfile,
loadBlossomServers,
loadRelaySelections,
loadInboxRelaySelections,
getRelayUrls,
} from "@welshman/app"
import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util"
@@ -101,8 +102,9 @@ export const makeFeed = ({
const seen = new Set<string>()
const buffer = writable<TrustedEvent[]>([])
const events = writable(initialEvents)
const controller = new AbortController()
for (const event of initialEvents) {
const markEvent = (event: TrustedEvent) => {
if (!seen.has(event.id)) {
seen.add(event.id)
onEvent?.(event)
@@ -110,19 +112,32 @@ export const makeFeed = ({
}
const insertEvent = (event: TrustedEvent) => {
buffer.update($buffer => {
for (let i = 0; i < $buffer.length; i++) {
if ($buffer[i].id === event.id) return $buffer
if ($buffer[i].created_at < event.created_at) return insert(i, event, $buffer)
let handled = false
events.update($events => {
for (let i = 0; i < $events.length; i++) {
if ($events[i].id === event.id) return $events
if ($events[i].created_at < event.created_at) {
handled = true
return insertAt(i, event, $events)
}
}
return [...$buffer, event]
return $events
})
if (!seen.has(event.id)) {
seen.add(event.id)
onEvent?.(event)
if (!handled) {
buffer.update($buffer => {
for (let i = 0; i < $buffer.length; i++) {
if ($buffer[i].id === event.id) return $buffer
if ($buffer[i].created_at < event.created_at) return insertAt(i, event, $buffer)
}
return [...$buffer, event]
})
}
markEvent(event)
}
const removeEvents = (ids: string[]) => {
@@ -144,15 +159,20 @@ export const makeFeed = ({
}
}
const ctrl = createFeedController({
const ctrl = makeFeedController({
useWindowing: true,
feed: makeIntersectionFeed(makeRelayFeed(...relays), feedFromFilters(feedFilters)),
onEvent: insertEvent,
onExhausted,
})
const sub = subscribe({
for (const event of initialEvents) {
markEvent(event)
}
request({
relays,
signal: controller.signal,
filters: subscriptionFilters,
onEvent: (e: TrustedEvent) => {
if (matchFilters(feedFilters, e)) insertEvent(e)
@@ -175,14 +195,14 @@ export const makeFeed = ({
},
})
thunkWorker.addGlobalHandler(onThunk)
const unsubscribe = thunkQueue.subscribe(onThunk)
return {
events,
cleanup: () => {
sub.close()
unsubscribe()
scroller.stop()
thunkWorker.removeGlobalHandler(onThunk)
controller.abort()
},
}
}
@@ -202,9 +222,12 @@ export const makeCalendarFeed = ({
onExhausted?: () => void
initialEvents?: TrustedEvent[]
}) => {
const interval = int(5, DAY)
const controller = new AbortController()
let exhaustedScrollers = 0
let backwardWindow = [now() - MONTH, now()]
let forwardWindow = [now(), now() + MONTH]
let backwardWindow = [now() - interval, now()]
let forwardWindow = [now(), now() + interval]
const getStart = (event: TrustedEvent) => parseInt(getTagValue("start", event.tags) || "")
@@ -214,16 +237,17 @@ export const makeCalendarFeed = ({
const insertEvent = (event: TrustedEvent) => {
const start = getStart(event)
const address = getAddress(event)
if (isNaN(start) || isNaN(getEnd(event))) return
events.update($events => {
for (let i = 0; i < $events.length; i++) {
if ($events[i].id === event.id) return $events
if (getStart($events[i]) > start) return insert(i, event, $events)
if (getStart($events[i]) > start) return insertAt(i, event, $events)
}
return [...$events, event]
return [...$events.filter(e => getAddress(e) !== address), event]
})
}
@@ -241,8 +265,9 @@ export const makeCalendarFeed = ({
}
}
const sub = subscribe({
request({
relays,
signal: controller.signal,
filters: subscriptionFilters,
onEvent: (e: TrustedEvent) => {
if (matchFilters(feedFilters, e)) insertEvent(e)
@@ -252,8 +277,10 @@ export const makeCalendarFeed = ({
const loadTimeframe = (since: number, until: number) => {
const hashes = daysBetween(since, until).map(String)
load({
request({
relays,
signal: controller.signal,
autoClose: true,
filters: [{kinds: [EVENT_TIME], "#D": hashes}],
onEvent: insertEvent,
})
@@ -271,7 +298,7 @@ export const makeCalendarFeed = ({
onScroll: () => {
const [since, until] = backwardWindow
backwardWindow = [since - MONTH, since]
backwardWindow = [since - interval, since]
if (until > now() - int(2, YEAR)) {
loadTimeframe(since, until)
@@ -287,7 +314,7 @@ export const makeCalendarFeed = ({
onScroll: () => {
const [since, until] = forwardWindow
forwardWindow = [until, until + MONTH]
forwardWindow = [until, until + interval]
if (until < now() + int(2, YEAR)) {
loadTimeframe(since, until)
@@ -298,15 +325,15 @@ export const makeCalendarFeed = ({
},
})
thunkWorker.addGlobalHandler(onThunk)
const unsubscribe = thunkQueue.subscribe(onThunk)
return {
events,
cleanup: () => {
thunkWorker.removeGlobalHandler(onThunk)
backwardScroller.stop()
forwardScroller.stop()
sub.close()
controller.abort()
unsubscribe()
},
}
}
@@ -328,7 +355,7 @@ export const loadAlertStatuses = (pubkey: string) =>
// Application requests
export const listenForNotifications = () => {
const subs: Subscription[] = []
const controller = new AbortController()
for (const [url, allRooms] of userRoomsByUrl.get()) {
// Limit how many rooms we load at a time, since we have to send a separate filter
@@ -336,48 +363,42 @@ export const listenForNotifications = () => {
const rooms = shuffle(Array.from(allRooms)).slice(0, 30)
load({
signal: controller.signal,
relays: [url],
filters: [
{kinds: [THREAD], limit: 1},
{kinds: [EVENT_TIME], limit: 1},
{kinds: [COMMENT], "#K": [String(THREAD)], limit: 1},
{kinds: [COMMENT], "#K": [String(EVENT_TIME)], limit: 1},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})),
],
})
subs.push(
subscribe({
relays: [url],
filters: [
{kinds: [THREAD, EVENT_TIME], since: now()},
{kinds: [COMMENT], "#K": [String(THREAD), String(EVENT_TIME)], since: now()},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})),
],
}),
)
request({
signal: controller.signal,
relays: [url],
filters: [
{kinds: [THREAD], since: now()},
{kinds: [COMMENT], "#K": [String(THREAD)], since: now()},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})),
],
})
}
return () => {
for (const sub of subs) {
sub.close()
}
}
return () => controller.abort()
}
export const loadUserData = (
pubkey: string,
request: Partial<SubscribeRequestWithHandlers> = {},
) => {
export const loadUserData = async (pubkey: string, relays: string[] = []) => {
await Promise.race([sleep(3000), loadRelaySelections(pubkey, relays)])
const promise = Promise.race([
sleep(3000),
Promise.all([
loadInboxRelaySelections(pubkey, request),
loadMembership(pubkey, request),
loadSettings(pubkey, request),
loadProfile(pubkey, request),
loadFollows(pubkey, request),
loadMutes(pubkey, request),
loadInboxRelaySelections(pubkey, relays),
loadBlossomServers(pubkey, relays),
loadMembership(pubkey, relays),
loadSettings(pubkey, relays),
loadProfile(pubkey, relays),
loadFollows(pubkey, relays),
loadMutes(pubkey, relays),
loadAlertStatuses(pubkey),
loadAlerts(pubkey),
]),
@@ -392,10 +413,10 @@ export const loadUserData = (
await sleep(1000)
for (const pubkey of pubkeys) {
loadMembership(pubkey, {relays})
loadProfile(pubkey, {relays})
loadFollows(pubkey, {relays})
loadMutes(pubkey, {relays})
loadMembership(pubkey, relays)
loadProfile(pubkey, relays)
loadFollows(pubkey, relays)
loadMutes(pubkey, relays)
}
}
})
@@ -404,4 +425,8 @@ export const loadUserData = (
}
export const discoverRelays = (lists: List[]) =>
Promise.all(uniq(lists.flatMap(getRelayUrls)).filter(isShareableRelayUrl).map(loadRelay))
Promise.all(
uniq(lists.flatMap($l => getRelaysFromList($l)))
.filter(isShareableRelayUrl)
.map(url => loadRelay(url)),
)
+29 -42
View File
@@ -1,9 +1,7 @@
import twColors from "tailwindcss/colors"
import {get, derived} from "svelte/store"
import {nip19} from "nostr-tools"
import * as nip19 from "nostr-tools/nip19"
import {
ctx,
setContext,
remove,
sortBy,
sort,
@@ -16,7 +14,11 @@ import {
fromPairs,
memoize,
addToMapKey,
identity,
always,
} from "@welshman/lib"
import {load} from "@welshman/net"
import {collection} from "@welshman/store"
import {
getIdFilters,
WRAP,
@@ -42,15 +44,11 @@ import {
} from "@welshman/util"
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util"
import {Nip59, decrypt} from "@welshman/signer"
import {routerContext, Router} from "@welshman/router"
import {
pubkey,
repository,
load,
collection,
profilesByPubkey,
getDefaultAppContext,
getDefaultNetContext,
makeRouter,
tracker,
makeTrackerStore,
makeRepositoryStore,
@@ -63,11 +61,14 @@ import {
thunks,
walkThunks,
signer,
makeOutboxLoader,
appContext,
} from "@welshman/app"
import type {Thunk, Relay} from "@welshman/app"
import type {SubscribeRequestWithHandlers} from "@welshman/net"
import {deriveEvents, deriveEventsMapped, withGetter, synced} from "@welshman/store"
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
export const ROOM = "h"
export const GENERAL = "_"
@@ -83,13 +84,9 @@ export const NOTIFIER_PUBKEY = "27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c
// export const NOTIFIER_RELAY = 'wss://notifier.flotilla.social/'
export const NOTIFIER_RELAY = "ws://localhost:4738/"
export const INDEXER_RELAYS = [
"wss://purplepag.es/",
"wss://relay.damus.io/",
"wss://relay.nostr.band/",
]
export const INDEXER_RELAYS = fromCsv(import.meta.env.VITE_INDEXER_RELAYS)
export const SIGNER_RELAYS = ["wss://relay.nsec.app/", "wss://bucket.coracle.social/"]
export const SIGNER_RELAYS = fromCsv(import.meta.env.VITE_SIGNER_RELAYS)
export const PLATFORM_URL = window.location.origin
@@ -118,7 +115,7 @@ export const IMGPROXY_URL = "https://imgproxy.coracle.social"
export const REACTION_KINDS = [REACTION, ZAP_RESPONSE]
export const NIP46_PERMS =
"nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt," +
"nip44_encrypt,nip44_decrypt," +
[CLIENT_AUTH, AUTH_JOIN, MESSAGE, THREAD, COMMENT, GROUPS, WRAP, REACTION]
.map(k => `sign_event:${k}`)
.join(",")
@@ -163,10 +160,8 @@ export const imgproxy = (url: string, {w = 640, h = 1024} = {}) => {
export const entityLink = (entity: string) => `https://coracle.social/${entity}`
export const pubkeyLink = (
pubkey: string,
relays = ctx.app.router.FromPubkeys([pubkey]).getUrls(),
) => entityLink(nip19.nprofileEncode({pubkey, relays}))
export const pubkeyLink = (pubkey: string, relays = Router.get().FromPubkeys([pubkey]).getUrls()) =>
entityLink(nip19.nprofileEncode({pubkey, relays}))
export const tagRoom = (room: string, url: string) => [ROOM, room]
@@ -255,7 +250,7 @@ export const getUrlsForEvent = derived([trackerStore, thunks], ([$tracker, $thun
const urls = Array.from($tracker.getRelays(id))
for (const thunk of getThunksByEventId().get(id) || []) {
for (const url of thunk.request.relays) {
for (const url of thunk.options.relays) {
urls.push(url)
}
}
@@ -284,15 +279,9 @@ export const deriveEventsForUrl = (url: string, filters: Filter[]) =>
// Context
setContext({
net: getDefaultNetContext(),
app: getDefaultAppContext({
dufflepudUrl: DUFFLEPUD_URL,
indexerRelays: INDEXER_RELAYS,
requestTimeout: 5000,
router: makeRouter(),
}),
})
appContext.dufflepudUrl = DUFFLEPUD_URL
routerContext.getIndexerRelays = always(INDEXER_RELAYS)
// Settings
@@ -308,9 +297,6 @@ export type Settings = {
report_usage: boolean
report_errors: boolean
send_delay: number
upload_type: "nip96" | "blossom"
nip96_urls: string[]
blossom_urls: string[]
}
}
@@ -318,11 +304,8 @@ export const defaultSettings = {
show_media: true,
hide_sensitive: true,
report_usage: true,
report_errors: false,
report_errors: true,
send_delay: 3000,
upload_type: "nip96",
nip96_urls: ["https://nostr.build"],
blossom_urls: ["https://cdn.satellite.earth"],
}
export const settings = deriveEventsMapped<Settings>(repository, {
@@ -342,8 +325,7 @@ export const {
name: "settings",
store: settings,
getKey: settings => settings.event.pubkey,
load: (pubkey: string, request: Partial<SubscribeRequestWithHandlers> = {}) =>
load({...request, filters: [{kinds: [SETTINGS], authors: [pubkey]}]}),
load: makeOutboxLoader(SETTINGS),
})
// Alerts
@@ -420,8 +402,7 @@ export const {
name: "memberships",
store: memberships,
getKey: list => list.event.pubkey,
load: (pubkey: string, request: Partial<SubscribeRequestWithHandlers> = {}) =>
load({...request, filters: [{kinds: [GROUPS], authors: [pubkey]}]}),
load: makeOutboxLoader(GROUPS),
})
// Chats
@@ -482,6 +463,7 @@ export const {
name: "chats",
store: chats,
getKey: chat => chat.id,
load: always(Promise.resolve()),
})
export const chatSearch = derived(chats, $chats =>
@@ -673,7 +655,12 @@ export const deriveOtherRooms = (url: string) =>
// Other utils
export const encodeRelay = (url: string) => encodeURIComponent(normalizeRelayUrl(url))
export const encodeRelay = (url: string) =>
encodeURIComponent(
normalizeRelayUrl(url)
.replace(/^wss:\/\//, "")
.replace(/\/$/, ""),
)
export const decodeRelay = (url: string) => normalizeRelayUrl(decodeURIComponent(url))
+5
View File
@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 7L4 7" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
<path d="M20 12L4 12" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
<path d="M20 17L4 17" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

+6
View File
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.1414 2.07816C20.9097 3.88191 22 6.3527 22 9.07816C22 11.836 20.8836 14.333 19.0782 16.1421M5 16.2196C3.14864 14.4047 2 11.8756 2 9.07816C2 6.31313 3.12222 3.8102 4.93603 2" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16.2849 5.1221C17.3458 6.13689 18 7.52697 18 9.06033C18 10.6119 17.3302 12.0167 16.2469 13.0345M7.8 13.0781C6.68918 12.057 6 10.6342 6 9.06033C6 7.50471 6.67333 6.09655 7.76162 5.07812" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="9.07812" r="2" stroke="#1C274C" stroke-width="1.5"/>
<path d="M12.5 11L16 22L10.5 15.5M11.5 11L8 22L13.5 15.5" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 871 B

+11 -7
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {DateInput} from "date-picker-svelte"
import {secondsToDate, dateToSeconds} from "@welshman/app"
import {secondsToDate, dateToSeconds} from "@welshman/lib"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -12,7 +12,7 @@
const pad = (n: number) => ("00" + String(n)).slice(-2)
const getTime = (d: Date) => `${pad(d.getHours())}:${minutes}`
const getTime = (d: Date, m: string) => `${pad(d.getHours())}:${m}`
const setTime = (d: Date, time: string) => {
const [hours, minutes] = time.split(":").map(x => parseInt(x))
@@ -37,15 +37,19 @@
time = undefined
}
let date: Date | undefined = $state()
let time: string | undefined = $state()
let minutes: string = $state("00")
const initialDate = value ? secondsToDate(value) : undefined
const initialTime = initialDate ? getTime(initialDate, pad(initialDate.getMinutes())) : undefined
const initialMinutes = initialTime ? initialTime.slice(-2) : "00"
let date: Date | undefined = $state(initialDate)
let time: string | undefined = $state(initialTime)
let minutes: string = $state(initialMinutes)
let element: HTMLElement
// Sync date to time and value
$effect(() => {
if (date) {
time = getTime(date)
time = getTime(date, minutes)
value = dateToSeconds(date)
} else {
value = undefined
@@ -55,7 +59,7 @@
// Sync updates to value to date/time
$effect(() => {
const derivedDate = value ? secondsToDate(value) : undefined
const derivedTime = derivedDate ? getTime(derivedDate) : undefined
const derivedTime = derivedDate ? getTime(derivedDate, minutes) : undefined
date = derivedDate
time = derivedTime
+4
View File
@@ -41,6 +41,7 @@
import GallerySend from "@assets/icons/Gallery Send.svg?dataurl"
import Ghost from "@assets/icons/Ghost.svg?dataurl"
import Hashtag from "@assets/icons/Hashtag.svg?dataurl"
import HamburgerMenu from "@assets/icons/Hamburger Menu.svg?dataurl"
import HandPills from "@assets/icons/Hand Pills.svg?dataurl"
import HomeSmile from "@assets/icons/Home Smile.svg?dataurl"
import Inbox from "@assets/icons/Inbox.svg?dataurl"
@@ -68,6 +69,7 @@
import Server from "@assets/icons/Server.svg?dataurl"
import Settings from "@assets/icons/Settings.svg?dataurl"
import SettingsMinimalistic from "@assets/icons/Settings Minimalistic.svg?dataurl"
import Station from "@assets/icons/Station.svg?dataurl"
import TagHorizontal from "@assets/icons/Tag Horizontal.svg?dataurl"
import ShareCircle from "@assets/icons/Share Circle.svg?dataurl"
import ShopMinimalistic from "@assets/icons/Shop Minimalistic.svg?dataurl"
@@ -130,6 +132,7 @@
"gallery-send": GallerySend,
ghost: Ghost,
hashtag: Hashtag,
"hamburger-menu": HamburgerMenu,
"hand-pills": HandPills,
"home-smile": HomeSmile,
inbox: Inbox,
@@ -160,6 +163,7 @@
server: Server,
settings: Settings,
"settings-minimalistic": SettingsMinimalistic,
station: Station,
"tag-horizontal": TagHorizontal,
"trash-bin-2": TrashBin2,
"ufo-3": UFO3,
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {append, removeAt, replaceAt, insertAt} from "@welshman/lib"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
type Props = {
value: string[]
addLabel?: Snippet
placeholder?: string
}
let {value = $bindable(), addLabel, placeholder = "Enter text..."}: Props = $props()
let draggedIndex: number | null = $state(null)
const onChange = (newValue: string[]) => {
value = newValue
}
const addItem = () => onChange(append("", value))
const removeItem = (index: number) => onChange(removeAt(index, value))
const updateItem = (index: number, item: string) => onChange(replaceAt(index, item, value))
const handleDragStart = (e: DragEvent, index: number) => {
draggedIndex = index
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move"
}
}
const handleDragOver = (e: DragEvent, index: number) => {
e.preventDefault()
if (draggedIndex !== null && draggedIndex !== index) {
onChange(insertAt(index, value[draggedIndex], removeAt(draggedIndex, value)))
draggedIndex = index
}
}
const handleDragEnd = () => {
draggedIndex = null
}
</script>
<div class="flex flex-col gap-2" role="list">
{#each value as item, index (item)}
<div
class="flex items-center gap-2"
draggable="true"
role="listitem"
aria-label="Draggable item"
ondragstart={e => handleDragStart(e, index)}
ondragover={e => handleDragOver(e, index)}
ondragend={handleDragEnd}>
<Button onclick={() => removeItem(index)}>
<Icon icon="trash-bin-2" />
</Button>
<input
type="text"
class="input input-bordered w-full"
value={item}
{placeholder}
oninput={e => updateItem(index, e.currentTarget.value)} />
<div class="cursor-move" role="button" aria-label="Drag handle">
<Icon icon="hamburger-menu" />
</div>
</div>
{/each}
<Button onclick={addItem} class="btn btn-link w-fit px-0">
<Icon icon="add-circle" size={5} />
{#if addLabel}
{@render addLabel?.()}
{:else}
Add Item
{/if}
</Button>
</div>
+6 -4
View File
@@ -1,15 +1,17 @@
<script lang="ts">
import type {Snippet} from "svelte"
interface Props {
icon?: import("svelte").Snippet
title?: import("svelte").Snippet
action?: import("svelte").Snippet
icon?: Snippet
title?: Snippet
action?: Snippet
[key: string]: any
}
const {...props}: Props = $props()
</script>
<div class="relative z-feature rounded-xl px-2 pt-2 {props.class}">
<div class="sait cw fixed top-2 z-feature rounded-xl px-2 pt-2 {props.class}">
<div
class="flex min-h-12 items-center justify-between gap-4 rounded-xl bg-base-100 px-4 shadow-xl">
<div class="ellipsize flex items-center gap-4 whitespace-nowrap">
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import type {Snippet} from "svelte"
interface Props {
element?: Element
children?: Snippet
[key: string]: any
}
let {children, element = $bindable(), ...props}: Props = $props()
</script>
<div
{...props}
bind:this={element}
class="scroll-container saib cw fixed top-12 h-[calc(100%-6.5rem)] overflow-y-auto overflow-x-hidden md:h-[calc(100%-3rem)] {props.class}">
{@render children?.()}
</div>
+2 -2
View File
@@ -24,7 +24,7 @@
data-tip={title}>
{@render children?.()}
{#if !active && notification}
<div class="absolute right-2 top-2 h-2 w-2 rounded-full bg-primary"></div>
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"></div>
{/if}
</div>
</a>
@@ -37,7 +37,7 @@
data-tip={title}>
{@render children?.()}
{#if !active && notification}
<div class="absolute right-2 top-2 h-2 w-2 rounded-full bg-primary"></div>
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"></div>
{/if}
</div>
</Button>
+38 -1
View File
@@ -1,4 +1,4 @@
import {sleep} from "@welshman/lib"
import {sleep, last} from "@welshman/lib"
export {preventDefault, stopPropagation} from "svelte/legacy"
export const copyToClipboard = (text: string) => {
@@ -89,3 +89,40 @@ export const downloadText = (filename: string, text: string) => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
export const isIntersecting = async (element: Element) =>
new Promise(resolve => {
const observer = new IntersectionObserver(xs => {
resolve(xs.some(x => x.isIntersecting))
observer.unobserve(element)
})
observer.observe(element)
})
export const scrollToEvent = async (id: string) => {
const element = document.querySelector(`[data-event="${id}"]`) as any
if (element) {
element.scrollIntoView({behavior: "smooth", block: "center"})
element.style = "filter: brightness(1.5); transition-property: all; transition-duration: 400ms;"
setTimeout(() => {
element.style = "transition-property: all; transition-duration: 300ms;"
}, 800)
setTimeout(() => {
element.style = ""
}, 800 + 400)
} else {
const lastElement = last(Array.from(document.querySelectorAll("[data-event]")))
if (lastElement && !isIntersecting(lastElement)) {
lastElement.scrollIntoView({behavior: "smooth", block: "center"})
}
await sleep(300)
scrollToEvent(id)
}
}
-14
View File
@@ -2,20 +2,6 @@ import {hexToBytes, bytesToHex} from "@noble/hashes/utils"
import * as nip19 from "nostr-tools/nip19"
import {range, DAY} from "@welshman/lib"
export const displayList = <T>(xs: T[], conj = "and", n = 6, locale = "en-US") => {
const stringItems = xs.map(String)
if (xs.length > n + 2) {
const formattedList = new Intl.ListFormat(locale, {style: "long", type: "unit"}).format(
stringItems.slice(0, n),
)
return `${formattedList}, ${conj} ${xs.length - n} others`
}
return new Intl.ListFormat(locale, {style: "long", type: "conjunction"}).format(stringItems)
}
export const nsecEncode = (secret: string) => nip19.nsecEncode(hexToBytes(secret))
export const nsecDecode = (nsec: string) => {
+76 -121
View File
@@ -7,53 +7,39 @@
import {dev} from "$app/environment"
import {goto} from "$app/navigation"
import {bytesToHex, hexToBytes} from "@noble/hashes/utils"
import {identity, memoize, sleep, defer, ago, WEEK, TaskQueue} from "@welshman/lib"
import type {TrustedEvent, StampedEvent} from "@welshman/util"
import {
identity,
sleep,
take,
sortBy,
defer,
ago,
now,
HOUR,
WEEK,
MONTH,
Worker,
} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {
MESSAGE,
PROFILE,
DELETE,
REACTION,
ZAP_RESPONSE,
FOLLOWS,
RELAYS,
INBOX_RELAYS,
WRAP,
getPubkeyTagValues,
getListTags,
EVENT_TIME,
THREAD,
MESSAGE,
INBOX_RELAYS,
DIRECT_MESSAGE,
MUTES,
FOLLOWS,
PROFILE,
RELAYS,
BLOSSOM_SERVERS,
getRelaysFromList,
} from "@welshman/util"
import {Nip46Broker, getPubkey, makeSecret} from "@welshman/signer"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import type {Socket} from "@welshman/net"
import {request, defaultSocketPolicies, makeSocketPolicyAuth} from "@welshman/net"
import {
relays,
handles,
loadRelay,
db,
initStorage,
repository,
pubkey,
plaintext,
freshness,
storageAdapters,
tracker,
defaultStorageAdapters,
session,
signer,
dropSession,
getRelayUrls,
subscribe,
userInboxRelaySelections,
addSession,
loginWithNip01,
loginWithNip46,
EventsStorageAdapter,
} from "@welshman/app"
import * as lib from "@welshman/lib"
import * as util from "@welshman/util"
@@ -68,7 +54,6 @@
import {theme} from "@app/theme"
import {INDEXER_RELAYS, userMembership, ensureUnwrapped, canDecrypt} from "@app/state"
import {loadUserData, listenForNotifications} from "@app/requests"
import {loginWithNip46} from "@app/commands"
import * as commands from "@app/commands"
import * as requests from "@app/requests"
import * as notifications from "@app/notifications"
@@ -109,14 +94,21 @@
try {
if (login?.startsWith("bunker://")) {
success = await loginWithNip46({
clientSecret: makeSecret(),
...Nip46Broker.parseBunkerUrl(login),
})
} else if (login) {
const secret = nsecDecode(login)
const clientSecret = makeSecret()
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(login)
const broker = Nip46Broker.get({relays, clientSecret, signerPubkey})
const result = await broker.connect(connectSecret, appState.NIP46_PERMS)
const pubkey = await broker.getPublicKey()
addSession({method: "nip01", secret, pubkey: getPubkey(secret)})
// TODO: remove ack result
if (pubkey && ["ack", connectSecret].includes(result)) {
await loadUserData(pubkey)
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
success = true
}
} else if (login) {
loginWithNip01(nsecDecode(login))
success = true
}
} catch (e) {
@@ -140,78 +132,8 @@
}
})
initStorage("flotilla", 5, {
relays: storageAdapters.fromCollectionStore("url", relays, {throttle: 3000}),
handles: storageAdapters.fromCollectionStore("nip05", handles, {throttle: 3000}),
freshness: storageAdapters.fromObjectStore(freshness, {
throttle: 3000,
migrate: (data: {key: string; value: number}[]) => {
const cutoff = ago(HOUR)
return data.filter(({value}) => value > cutoff)
},
}),
plaintext: storageAdapters.fromObjectStore(plaintext, {
throttle: 3000,
migrate: (data: {key: string; value: number}[]) => data.slice(0, 10_000),
}),
events2: storageAdapters.fromRepositoryAndTracker(repository, tracker, {
throttle: 3000,
migrate: (events: TrustedEvent[]) => {
if (events.length < 15_000) {
return events
}
const NEVER_KEEP = 0
const ALWAYS_KEEP = Infinity
const reactionKinds = [REACTION, ZAP_RESPONSE, DELETE]
const metaKinds = [PROFILE, FOLLOWS, RELAYS, INBOX_RELAYS]
const sessionKeys = new Set(Object.keys(app.sessions.get()))
const userFollows = new Set(getPubkeyTagValues(getListTags(get(app.userFollows))))
const maxWot = get(app.maxWot)
const scoreEvent = (e: TrustedEvent) => {
const isFollowing = userFollows.has(e.pubkey)
// No need to keep a record of everyone who follows the current user
if (e.kind === FOLLOWS && !isFollowing) return NEVER_KEEP
// Drop room messages after a month, re-load on demand
if (e.kind === MESSAGE && e.created_at < ago(MONTH)) return NEVER_KEEP
// Always keep stuff by or tagging a signed in user
if (sessionKeys.has(e.pubkey)) return ALWAYS_KEEP
if (e.tags.some(t => sessionKeys.has(t[1]))) return ALWAYS_KEEP
// Get rid of irrelevant messages, reactions, and likes
if (e.wrap || e.kind === 4 || e.kind === WRAP) return NEVER_KEEP
if (reactionKinds.includes(e.kind)) return NEVER_KEEP
// If the user follows this person, use max wot score
let score = isFollowing ? maxWot : app.getUserWotScore(e.pubkey)
// Inflate the score for profiles/relays/follows to avoid redundant fetches
// Demote non-metadata type events, and introduce recency bias
score *= metaKinds.includes(e.kind) ? 2 : e.created_at / now()
return score
}
return take(
10_000,
sortBy(e => -scoreEvent(e), events),
)
},
}),
}).then(async () => {
await sleep(300)
ready.resolve()
})
// Unwrap gift wraps as they come in, but throttled
const unwrapper = new Worker<TrustedEvent>({chunkSize: 10})
unwrapper.addGlobalHandler(ensureUnwrapped)
const unwrapper = new TaskQueue<TrustedEvent>({batchSize: 10, processItem: ensureUnwrapped})
repository.on("update", ({added}) => {
if (!$canDecrypt) {
@@ -225,6 +147,35 @@
}
})
await initStorage("flotilla", 8, {
...defaultStorageAdapters,
events: new EventsStorageAdapter({
name: "events",
limit: 10_000,
repository,
rankEvent: (e: TrustedEvent) => {
if ([PROFILE, FOLLOWS, MUTES, RELAYS, BLOSSOM_SERVERS, INBOX_RELAYS].includes(e.kind)) {
return 1
}
if ([EVENT_TIME, THREAD, MESSAGE, DIRECT_MESSAGE].includes(e.kind)) {
return 0.9
}
return 0
},
}),
})
sleep(300).then(() => ready.resolve())
defaultSocketPolicies.push(
makeSocketPolicyAuth({
sign: (event: StampedEvent) => signer.get()?.sign(event),
shouldAuth: (socket: Socket) => true,
}),
)
// Load relay info
for (const url of INDEXER_RELAYS) {
loadRelay(url)
@@ -238,25 +189,29 @@
// Listen for space data, populate space-based notifications
let unsubSpaces: any
userMembership.subscribe($membership => {
unsubSpaces?.()
unsubSpaces = listenForNotifications()
})
userMembership.subscribe(
memoize($membership => {
unsubSpaces?.()
unsubSpaces = listenForNotifications()
}),
)
// Listen for chats, populate chat-based notifications
let chatsSub: any
let controller: AbortController
derived([pubkey, canDecrypt, userInboxRelaySelections], identity).subscribe(
([$pubkey, $canDecrypt, $userInboxRelaySelections]) => {
chatsSub?.close()
controller?.abort()
controller = new AbortController()
if ($pubkey && $canDecrypt) {
chatsSub = subscribe({
request({
signal: controller.signal,
filters: [
{kinds: [WRAP], "#p": [$pubkey], since: ago(WEEK, 2)},
{kinds: [WRAP], "#p": [$pubkey], limit: 100},
],
relays: getRelayUrls($userInboxRelaySelections),
relays: getRelaysFromList($userInboxRelaySelections),
})
}
},
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
import {onMount} from "svelte"
import * as nip19 from "nostr-tools/nip19"
import type {TrustedEvent} from "@welshman/util"
import {Address, getIdFilters, getTagValue} from "@welshman/util"
import {load} from "@welshman/net"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import {scrollToEvent} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import {makeRoomPath, makeThreadPath} from "@app/routes"
const {bech32} = $page.params
const attemptToNavigate = async () => {
const {type, data} = nip19.decode(bech32) as any
if (!["nevent", "naddr"].includes(type) && data.relays.length > 0) {
return goto("/", {replaceState: true})
}
let found = false
load({
relays: data.relays,
filters: getIdFilters([type === "nevent" ? data.id : Address.fromNaddr(bech32).toString()]),
onEvent: (event: TrustedEvent) => {
found = true
if (event.kind === 9) {
goto(makeRoomPath(data.relays[0], getTagValue("h", event.tags)!), {replaceState: true})
scrollToEvent(event.id)
} else if (event.kind === 11) {
goto(makeThreadPath(data.relays[0], event.id), {replaceState: true})
} else {
goto("/", {replaceState: true})
}
},
onClose: () => {
if (!found) {
goto("/", {replaceState: true})
}
},
})
}
onMount(async () => {
try {
await attemptToNavigate()
} catch (e) {
goto("/", {replaceState: true})
}
})
</script>
<Spinner />
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {page} from "$app/stores"
import {ctx} from "@welshman/lib"
import {WRAP} from "@welshman/util"
import {Router} from "@welshman/router"
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Page from "@lib/components/Page.svelte"
@@ -25,7 +25,7 @@
const promise = pullConservatively({
filters: [{kinds: [WRAP], "#p": [$pubkey!]}],
relays: ctx.app.router.UserInbox().getUrls(),
relays: Router.get().UserInbox().getUrls(),
})
let term = $state("")

Some files were not shown because too many files have changed in this diff Show More