Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea3f5a6779 | |||
| f5fce8e2e7 | |||
| 46b5c01c49 | |||
| dd069329ee | |||
| c1b52b66ff | |||
| 5873e8aa60 | |||
| c582082816 | |||
| 6ddba63ff9 | |||
| 5a7750a91b | |||
| 8c71b7d9b9 | |||
| b5a28c71ad | |||
| ccdd18a863 | |||
| 2244ecad9b | |||
| da2457da9f | |||
| c18b29e7d6 | |||
| 3a954201ce | |||
| c8bc8ee8bf | |||
| 8c3e52ce8c | |||
| 303b8967e9 | |||
| f3debe6c02 | |||
| 374ca7f265 | |||
| 91689e5b90 | |||
| a64eaba45c | |||
| 394a1e7d30 | |||
| d5b1fab1e7 | |||
| 10a1e6e640 | |||
| 84af4d2d8e | |||
| acddff79f0 | |||
| 489707b9b2 | |||
| 33902dbefe | |||
| 1b318a7a52 | |||
| b6a4b38d14 | |||
| a3eb6d52c0 | |||
| d2c537d275 | |||
| 9eefd6600d | |||
| ad034b1641 | |||
| d94860014c | |||
| 33af39ee93 | |||
| 1d56a2193d | |||
| 75905e4652 | |||
| d07b9cde5f | |||
| d8a9cc5a7e |
@@ -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
@@ -1,2 +1,2 @@
|
||||
npm run lint
|
||||
npm run check
|
||||
pnpm run lint
|
||||
pnpm run check
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
-15439
File diff suppressed because it is too large
Load Diff
+38
-12
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+9190
File diff suppressed because it is too large
Load Diff
+5
-9
@@ -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
@@ -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]})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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,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} />
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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),
|
||||
])
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 />
|
||||
@@ -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
Reference in New Issue
Block a user