Compare commits
129 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1510f39a8a | |||
| bbbe011482 | |||
| 82ab7a043f | |||
| 798253a50e | |||
| 52432ca068 | |||
| b3f1d8464b | |||
| 87bb62b359 | |||
| 3f914d02cc | |||
| d1db77d0f5 | |||
| 6aa297c1a4 | |||
| f3647e9bc1 | |||
| 5b43c62f2d | |||
| 23ffb15a8d | |||
| adb2ce4846 | |||
| cdee6ca743 | |||
| fe30aa4af2 | |||
| 9943728eab | |||
| 8ae7cf05cc | |||
| a7c944e8ef | |||
| 102339d7e8 | |||
| 9a0ad0c663 | |||
| f86afc08fa | |||
| cd1b328b1b | |||
| 48f2bb1c75 | |||
| d416fe913e | |||
| 7f8744725c | |||
| e5d1b82a9d | |||
| 619cf2e134 | |||
| 28b522f015 | |||
| 39233f261e | |||
| 00f0127caf | |||
| f69b575381 | |||
| 986973a605 | |||
| 0d6b4591f1 | |||
| 2c62749d9b | |||
| 4be4288ef0 | |||
| c7eec167cf | |||
| 7bae956ffa | |||
| a2f59a5b1b | |||
| df56af9b0e | |||
| 83f7f9584f | |||
| a2d440e54f | |||
| 4132e8449b | |||
| ee444416e4 | |||
| 10c12c3c48 | |||
| db3775ae99 | |||
| 393acce884 | |||
| 68fe663730 | |||
| f65a4b0db0 | |||
| cdfb502e6e | |||
| 1a2c83e49b | |||
| e6c7a675a9 | |||
| 69c04f29f4 | |||
| 04c6f9b4fe | |||
| 86ec12a9db | |||
| 72b3111c64 | |||
| 6709c91779 | |||
| bb6e7495f5 | |||
| df17929681 | |||
| e083719ceb | |||
| bfdc69f18c | |||
| e7ae20afb7 | |||
| 229d92055f | |||
| 64c77cfd13 | |||
| 3a63894562 | |||
| 1d272f8b37 | |||
| bac433b640 | |||
| 62f573eac0 | |||
| b3ea62c53c | |||
| b0731503a8 | |||
| 2421c02c24 | |||
| 25e868118d | |||
| 2880044e0e | |||
| 5300404b46 | |||
| d949d58076 | |||
| 997b223e95 | |||
| ba52a97e26 | |||
| cc4c7b5fe9 | |||
| 8e2ebd11fc | |||
| 9cae4da9f4 | |||
| c05d7e99e2 | |||
| 2390599e8f | |||
| 1a4d45fa9c | |||
| 57447e5bf4 | |||
| 8e411daaef | |||
| 183aebf841 | |||
| e3e500ccc2 | |||
| e7a2535ece | |||
| 761e369313 | |||
| 5248275d73 | |||
| cb033279dd | |||
| 41d50d8c28 | |||
| a52c2b4c3c | |||
| b5917cb184 | |||
| 57348472f8 | |||
| 4b6223dc00 | |||
| 5525e45a15 | |||
| 80a2ae60b0 | |||
| d7e95f5d2f | |||
| ca4e5ae5ee | |||
| b673658c0c | |||
| 5c5c130700 | |||
| 2d89ca6c0e | |||
| 806a7c2609 | |||
| 501ce8067d | |||
| 6429f82829 | |||
| fe626218ea | |||
| b62b1bc063 | |||
| d980f36246 | |||
| b469addd29 | |||
| 6923c2a8b7 | |||
| 1d3f32fb99 | |||
| 42a550788a | |||
| b1c68972c9 | |||
| 3978e32d5f | |||
| ba2b5d182e | |||
| bef04fa899 | |||
| 4f8609421c | |||
| 07660c9d44 | |||
| a324dad2ba | |||
| dbaa0f5d49 | |||
| 478721d349 | |||
| a669a23dbc | |||
| cfeb6478cc | |||
| 64539c49c1 | |||
| 0399ae37ec | |||
| 173a411a36 | |||
| 62013a2ea2 | |||
| c82cf4a4c2 |
+7
-5
@@ -1,7 +1,7 @@
|
||||
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
|
||||
VITE_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/
|
||||
VITE_BURROW_URL=
|
||||
VITE_PLATFORM_URL=https://flotilla.social
|
||||
VITE_POMADE_SIGNERS=
|
||||
VITE_PLATFORM_URL=https://app.flotilla.social
|
||||
VITE_PLATFORM_TERMS=https://flotilla.social/terms
|
||||
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
|
||||
VITE_PLATFORM_NAME=Flotilla
|
||||
@@ -10,10 +10,12 @@ VITE_PLATFORM_RELAYS=
|
||||
VITE_PLATFORM_ACCENT="#7161FF"
|
||||
VITE_PLATFORM_SECONDARY="#EB5E28"
|
||||
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
|
||||
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/,wss://indexer.coracle.social/
|
||||
VITE_SIGNER_RELAYS=wss://relay.nsec.app/,wss://offchain.pub/
|
||||
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
|
||||
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
|
||||
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com
|
||||
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
|
||||
VITE_NOTIFIER_PUBKEY=27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df
|
||||
VITE_NOTIFIER_RELAY=wss://anchor.coracle.social/
|
||||
VITE_NOTIFIER_RELAY=anchor.coracle.social
|
||||
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
|
||||
VITE_GLITCHTIP_API_KEY=
|
||||
GLITCHTIP_AUTH_TOKEN=
|
||||
|
||||
@@ -1,5 +1,89 @@
|
||||
# Changelog
|
||||
|
||||
# 1.6.3
|
||||
|
||||
* Fix scroll down button z index
|
||||
* Hide tooltips on mobile
|
||||
* Sort comments ascending
|
||||
* Make video embeds rounded
|
||||
* Fix ProfileMultiSelect styling
|
||||
* Accept hex pubkeys/npubs/nprofiles in ProfileMultiSelect
|
||||
* Tweak room edit form design
|
||||
* Report pending signer to user
|
||||
* Update default relays
|
||||
* Fix chat list responsiveness
|
||||
* Fix memory leak, notification badge not showing
|
||||
* Improve space join flow
|
||||
* Fix opening images in fullscreen dialog
|
||||
* Add support for blocked relays
|
||||
* Add authentication policy setting
|
||||
* Add login with key if no signer is detected
|
||||
* Publish default relay selections on signup
|
||||
|
||||
# 1.6.2
|
||||
|
||||
* Fix modal scrolling and style
|
||||
|
||||
# 1.6.1
|
||||
|
||||
* Fix skinny profile images
|
||||
* Custom handler for relay urls
|
||||
* Improve time based chat partitioning
|
||||
* Improve authenticated image access interop
|
||||
* Fix image detail dialog
|
||||
* Fix zapper loading
|
||||
* Fix recent events missing in feeds
|
||||
|
||||
# 1.6.0
|
||||
|
||||
* Switch back to indexeddb to fix memory and performance
|
||||
* Add pay invoice functionality
|
||||
* Add space membership management and bans
|
||||
* Add event info to profile dialog
|
||||
* Add better room membership management
|
||||
* Refactor stores for performance
|
||||
* Hide nav when keyboard is open
|
||||
* Handle flotilla links in-app
|
||||
* Fix new messages indicator z-index
|
||||
* Fix some display bugs
|
||||
* Add date to chat items
|
||||
* Refine data synchronization
|
||||
* Hide nav when keyboard is open on mobile
|
||||
|
||||
# 1.5.3
|
||||
|
||||
* Add space edit form
|
||||
* Improve room syncing
|
||||
* Return better blossom errors
|
||||
* Fix access restricted bugs
|
||||
* Add room detail dialog
|
||||
* Fix broken link to self hosting
|
||||
* Tweak shadows
|
||||
* Always join spaces when visiting them
|
||||
|
||||
# 1.5.2
|
||||
|
||||
* Fix negentropy room syncing
|
||||
|
||||
# 1.5.1
|
||||
|
||||
* Fix chat path link
|
||||
|
||||
# 1.5.0
|
||||
|
||||
* Restyle mobile dialogs
|
||||
* Add room membership lists
|
||||
* Add space membership lists
|
||||
* Add edit room form
|
||||
* Support closed/private/restricted/hidden rooms
|
||||
* Add hosting services page
|
||||
* Improve performance and UI
|
||||
* Fix push notifications
|
||||
* Improve error detection and handling
|
||||
* Support invite links on discover page
|
||||
* Add link to landlubber if user is admin
|
||||
* Clear reply/share/edit on escape
|
||||
|
||||
# 1.4.1
|
||||
|
||||
* Improve data synchronization
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Flotilla - AI Assistant Context
|
||||
|
||||
## Project Overview
|
||||
|
||||
Flotilla is a Discord-like Nostr client based on the concept of "relays as groups". It's built with SvelteKit, TypeScript, and Capacitor for cross-platform support (web, Android, iOS).
|
||||
|
||||
On boot, please run `tree -I assets src` to get an idea of the project structure.
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
`@welshman/*` libraries contain the majority of nostr-related functionality.
|
||||
`@app/core/*` contains additional app-specific data stores and commands.
|
||||
|
||||
When creating an import statement, first identify what functionality you need. Search the codebase for components with similar functionality, and imitate their imports.
|
||||
|
||||
## Dependency Graph (Acyclic)
|
||||
|
||||
The project follows a strict dependency hierarchy:
|
||||
1. **External libraries** (bottom layer)
|
||||
2. **`lib/`** - Only depends on external libraries
|
||||
3. **`app/core/`** and **`app/util/`** - Can depend on `lib` only
|
||||
4. **`app/components/`** - Can depend on anything in `app` or `lib`
|
||||
5. **`routes/`** - Can depend on anything (top layer)
|
||||
|
||||
**Import Ordering Convention:** Always sort imports by dependency level:
|
||||
1. Third-party libraries first
|
||||
2. Then `lib` imports
|
||||
3. Then `app` imports
|
||||
|
||||
## Development Conventions
|
||||
|
||||
When creating components related to a given space or room, parameterize them only with the entity's identifier (i.e., `url` and `h`). Only pass additional props if they can't be derived from the identifiers. For example, a room's `members` should be derived inside the child component, not passed in by the parent.
|
||||
|
||||
Do not use null, only undefined.
|
||||
+1
-1
@@ -69,7 +69,7 @@ Here are a few important domain objects:
|
||||
|
||||
- Spaces are relays used as community groups. Their `url`s are core to a lot of data and components, and are frequently passed around from place to place.
|
||||
- Chats are direct message conversations. There is currently some ambiguity in routing, since relays that don't support NIP 29 also have a "chat" tab, which uses vanilla NIP-C7.
|
||||
- NIP 29 groups are variously called "rooms" and "channels". Conventionally, a "room" is a group id, while a "channel" as an object representing the group's metadata.
|
||||
- NIP 29 groups are called "rooms". Conventionally, "h" is a group id, while a "room" as an object representing the group's metadata.
|
||||
- "Alerts" are records of requests the user has made to be notified, following [this NIP](https://github.com/nostr-protocol/nips/pull/1796)
|
||||
|
||||
`app/core/requests` contains utilities related to loading data from the nostr network. This might include feed manager utilities, loaders, or listeners.
|
||||
|
||||
@@ -8,8 +8,8 @@ If you would like to be interoperable with Flotilla, please check out this guide
|
||||
|
||||
You can also optionally create an `.env` file and populate it with the following environment variables (see `.env` for examples):
|
||||
|
||||
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust.
|
||||
- `VITE_PLATFORM_URL` - The url where the app will be hosted. This is only used for build-time population of meta tags.
|
||||
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust
|
||||
- `VITE_PLATFORM_URL` - The url where the app will be hosted
|
||||
- `VITE_PLATFORM_NAME` - The name of the app
|
||||
- `VITE_PLATFORM_LOGO` - A logo url for the app
|
||||
- `VITE_PLATFORM_RELAYS` - A list of comma-separated relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "social.flotilla"
|
||||
minSdk rootProject.ext.minSdkVersion
|
||||
targetSdk rootProject.ext.targetSdkVersion
|
||||
versionCode 30
|
||||
versionName "1.4.1"
|
||||
versionCode 39
|
||||
versionName "1.6.3"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -7,7 +7,7 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.8.0'
|
||||
classpath 'com.android.tools.build:gradle:8.13.2'
|
||||
classpath 'com.google.gms:google-services:4.4.2'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -6,10 +6,6 @@ if [ -f .env.template ]; then
|
||||
source .env.template
|
||||
fi
|
||||
|
||||
if [ -f .env ]; then
|
||||
source .env
|
||||
fi
|
||||
|
||||
# Avoid overwriting env vars provided directly
|
||||
# https://stackoverflow.com/a/69127685/1467342
|
||||
eval "$temp_env"
|
||||
|
||||
@@ -358,14 +358,14 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
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 = 1.4.1;
|
||||
MARKETING_VERSION = 1.6.3;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -384,14 +384,14 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
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 = 1.4.1;
|
||||
MARKETING_VERSION = 1.6.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { execSync } from 'child_process'
|
||||
|
||||
if (execSync('git status --porcelain', { encoding: 'utf8' }).trim()) {
|
||||
console.error('Error: Git working tree is dirty. Please commit or stash your changes first.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
|
||||
|
||||
pkg.pnpm.overrides = pkg.pnpm.overrides || {}
|
||||
pkg.pnpm.overrides["@welshman/app"] = "link:../welshman/packages/app"
|
||||
pkg.pnpm.overrides["@welshman/content"] = "link:../welshman/packages/content"
|
||||
pkg.pnpm.overrides["@welshman/editor"] = "link:../welshman/packages/editor"
|
||||
pkg.pnpm.overrides["@welshman/feeds"] = "link:../welshman/packages/feeds"
|
||||
pkg.pnpm.overrides["@welshman/lib"] = "link:../welshman/packages/lib"
|
||||
pkg.pnpm.overrides["@welshman/net"] = "link:../welshman/packages/net"
|
||||
pkg.pnpm.overrides["@welshman/router"] = "link:../welshman/packages/router"
|
||||
pkg.pnpm.overrides["@welshman/signer"] = "link:../welshman/packages/signer"
|
||||
pkg.pnpm.overrides["@welshman/store"] = "link:../welshman/packages/store"
|
||||
pkg.pnpm.overrides["@welshman/util"] = "link:../welshman/packages/util"
|
||||
pkg.pnpm.overrides["@pomade/core"] = "link:../pomade/packages/core"
|
||||
|
||||
fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n')
|
||||
|
||||
execSync('pnpm i', { stdio: 'inherit' })
|
||||
|
||||
execSync('git checkout -f pnpm-lock.yaml', { stdio: 'inherit' })
|
||||
execSync('git checkout -f package.json', { stdio: 'inherit' })
|
||||
+15
-16
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flotilla",
|
||||
"version": "1.4.1",
|
||||
"version": "1.6.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -10,14 +10,13 @@
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check src && eslint src",
|
||||
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte)$' | xargs -r prettier --write",
|
||||
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
|
||||
"format:all": "prettier --write src",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@eslint/js": "^9.37.0",
|
||||
"@sentry/cli": "^2.56.1",
|
||||
"@sveltejs/kit": "^2.46.5",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"@types/eslint": "^9.6.1",
|
||||
@@ -51,25 +50,26 @@
|
||||
"@capacitor/push-notifications": "^7.0.3",
|
||||
"@capawesome/capacitor-android-dark-mode-support": "^7.0.0",
|
||||
"@capawesome/capacitor-badge": "^7.0.1",
|
||||
"@getalby/lightning-tools": "^6.0.0",
|
||||
"@getalby/sdk": "^5.1.2",
|
||||
"@pomade/core": "^0.0.12",
|
||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
||||
"@sentry/browser": "^8.55.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@tiptap/core": "^2.26.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/throttle-debounce": "^5.0.2",
|
||||
"@vite-pwa/assets-generator": "^0.2.6",
|
||||
"@vite-pwa/sveltekit": "^0.6.8",
|
||||
"@welshman/app": "^0.6.3",
|
||||
"@welshman/content": "^0.6.3",
|
||||
"@welshman/editor": "^0.6.3",
|
||||
"@welshman/feeds": "^0.6.3",
|
||||
"@welshman/lib": "^0.6.3",
|
||||
"@welshman/net": "^0.6.3",
|
||||
"@welshman/router": "^0.6.3",
|
||||
"@welshman/signer": "^0.6.3",
|
||||
"@welshman/store": "^0.6.3",
|
||||
"@welshman/util": "^0.6.3",
|
||||
"@welshman/app": "^0.8.1",
|
||||
"@welshman/content": "^0.8.1",
|
||||
"@welshman/editor": "^0.8.1",
|
||||
"@welshman/feeds": "^0.8.1",
|
||||
"@welshman/lib": "^0.8.1",
|
||||
"@welshman/net": "^0.8.1",
|
||||
"@welshman/router": "^0.8.1",
|
||||
"@welshman/signer": "^0.8.1",
|
||||
"@welshman/store": "^0.8.1",
|
||||
"@welshman/util": "^0.8.1",
|
||||
"compressorjs": "^1.2.1",
|
||||
"daisyui": "^4.12.24",
|
||||
"date-picker-svelte": "^2.16.0",
|
||||
@@ -79,7 +79,7 @@
|
||||
"husky": "^9.1.7",
|
||||
"idb": "^8.0.3",
|
||||
"nostr-signer-capacitor-plugin": "^0.0.4",
|
||||
"nostr-tools": "^2.14.2",
|
||||
"nostr-tools": "^2.19.4",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -88,7 +88,6 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"ignoredBuiltDependencies": [
|
||||
"@sentry/cli",
|
||||
"esbuild"
|
||||
],
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
Generated
+300
-414
File diff suppressed because it is too large
Load Diff
@@ -1,15 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
hash=$(find build -type f -print0 | sort -z | xargs -0 sha1sum | sha1sum | awk '{print $1}')
|
||||
|
||||
sentry-cli \
|
||||
--url https://glitchtip.coracle.social \
|
||||
--auth-token $GLITCHTIP_AUTH_TOKEN \
|
||||
--api-key $VITE_GLITCHTIP_API_KEY \
|
||||
sourcemaps \
|
||||
--org coracle \
|
||||
--project flotilla \
|
||||
--release $hash \
|
||||
upload \
|
||||
--url-prefix /_app/immutable/ \
|
||||
build/_app/immutable
|
||||
+16
-2
@@ -66,6 +66,10 @@
|
||||
--neutral-content: oklch(var(--nc));
|
||||
}
|
||||
|
||||
.mobile [data-tip]::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* safe area insets */
|
||||
|
||||
@layer components {
|
||||
@@ -392,12 +396,22 @@ progress[value]::-webkit-progress-value {
|
||||
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
|
||||
}
|
||||
|
||||
/* Keyboard open state adjustments */
|
||||
|
||||
body.keyboard-open .cb {
|
||||
@apply bottom-sai;
|
||||
}
|
||||
|
||||
body.keyboard-open .hide-on-keyboard {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* chat view */
|
||||
|
||||
.chat__compose {
|
||||
@apply cb cw fixed;
|
||||
@apply cb cw fixed z-compose;
|
||||
}
|
||||
|
||||
.chat__scroll-down {
|
||||
@apply fixed bottom-28 right-4 md:bottom-16;
|
||||
@apply fixed bottom-28 right-4 z-feature md:bottom-16;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib"
|
||||
import {randomInt, map, displayList, identity, TIMEZONE} from "@welshman/lib"
|
||||
import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
|
||||
import type {Filter} from "@welshman/util"
|
||||
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
|
||||
@@ -13,7 +13,7 @@
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {alerts, userSpaceUrls} from "@app/core/state"
|
||||
import {alertsById, userSpaceUrls} from "@app/core/state"
|
||||
import {requestRelayClaim} from "@app/core/requests"
|
||||
import {createAlert} from "@app/core/commands"
|
||||
import {canSendPushNotifications} from "@app/util/push"
|
||||
@@ -37,7 +37,7 @@
|
||||
hideSpaceField = false,
|
||||
}: Props = $props()
|
||||
|
||||
const timezoneOffset = parseInt(TIMEZONE.slice(3)) / 100
|
||||
const timezoneOffset = parseInt(TIMEZONE.split(":")?.[0] || "00")
|
||||
const minute = randomInt(0, 59)
|
||||
const hour = (17 - timezoneOffset) % 24
|
||||
const WEEKLY = `0 ${minute} ${hour} * * 1`
|
||||
@@ -45,7 +45,9 @@
|
||||
|
||||
let loading = $state(false)
|
||||
let cron = $state(WEEKLY)
|
||||
let email = $state($alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || "")
|
||||
let email = $state(
|
||||
map(a => getTagValue("email", a.tags), $alertsById.values()).filter(identity)[0] || "",
|
||||
)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import {sleep} from "@welshman/lib"
|
||||
import {getTagValue, getAddress} from "@welshman/util"
|
||||
import {sleep, filter} from "@welshman/lib"
|
||||
import {getTagValue, getAddress, RelayMode} from "@welshman/util"
|
||||
import {isRelayFeed, findFeed} from "@welshman/feeds"
|
||||
import {getPubkeyRelays, pubkey} from "@welshman/app"
|
||||
import Inbox from "@assets/icons/inbox.svg?dataurl"
|
||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
@@ -12,10 +13,9 @@
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {
|
||||
alerts,
|
||||
dmAlert,
|
||||
alertsById,
|
||||
deriveAlertStatus,
|
||||
userInboxRelays,
|
||||
getAlertFeed,
|
||||
userSettingsValues,
|
||||
} from "@app/core/state"
|
||||
@@ -33,7 +33,7 @@
|
||||
const dmStatus = $derived($dmAlert ? deriveAlertStatus(getAddress($dmAlert.event)) : undefined)
|
||||
|
||||
const filteredAlerts = $derived(
|
||||
$alerts.filter(alert => {
|
||||
filter(alert => {
|
||||
const feed = getAlertFeed(alert)
|
||||
|
||||
// Skip non-feeds and DM alerts
|
||||
@@ -43,7 +43,7 @@
|
||||
if (url) return findFeed(feed, f => isRelayFeed(f) && f.includes(url))
|
||||
|
||||
return true
|
||||
}),
|
||||
}, $alertsById.values()),
|
||||
)
|
||||
|
||||
const startAlert = () => pushModal(AlertAdd, {url, channel, hideSpaceField})
|
||||
@@ -59,7 +59,7 @@
|
||||
if ($dmAlert) {
|
||||
deleteAlert($dmAlert)
|
||||
} else {
|
||||
if ($userInboxRelays.length === 0) {
|
||||
if (getPubkeyRelays($pubkey!, RelayMode.Messaging).length === 0) {
|
||||
return uncheckDmAlert("Please set up your messaging relays before enabling alerts.")
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
</script>
|
||||
|
||||
<div class="col-4">
|
||||
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
|
||||
<div class="card2 bg-alt flex flex-col gap-6 shadow-md">
|
||||
<div class="flex items-center justify-between">
|
||||
<strong class="flex items-center gap-3">
|
||||
<Icon icon={Inbox} />
|
||||
@@ -108,7 +108,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card2 bg-alt flex flex-col gap-4 shadow-xl">
|
||||
<div class="card2 bg-alt flex flex-col gap-4 shadow-md">
|
||||
<div class="flex items-center justify-between">
|
||||
<strong class="flex items-center gap-3">
|
||||
<Icon icon={Bell} />
|
||||
|
||||
@@ -5,32 +5,13 @@
|
||||
import Landing from "@app/components/Landing.svelte"
|
||||
import Toast from "@app/components/Toast.svelte"
|
||||
import PrimaryNav from "@app/components/PrimaryNav.svelte"
|
||||
import EmailConfirm from "@app/components/EmailConfirm.svelte"
|
||||
import PasswordReset from "@app/components/PasswordReset.svelte"
|
||||
import {BURROW_URL} from "@app/core/state"
|
||||
import {modals, pushModal} from "@app/util/modal"
|
||||
import {modals} from "@app/util/modal"
|
||||
|
||||
interface Props {
|
||||
children: Snippet
|
||||
}
|
||||
|
||||
const {children}: Props = $props()
|
||||
|
||||
if (BURROW_URL && !$pubkey) {
|
||||
if ($page.url.pathname === "/confirm-email") {
|
||||
pushModal(EmailConfirm, {
|
||||
email: $page.url.searchParams.get("email"),
|
||||
confirm_token: $page.url.searchParams.get("confirm_token"),
|
||||
})
|
||||
}
|
||||
|
||||
if ($page.url.pathname === "/reset-password") {
|
||||
pushModal(PasswordReset, {
|
||||
email: $page.url.searchParams.get("email"),
|
||||
reset_token: $page.url.searchParams.get("reset_token"),
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import ChannelName from "@app/components/ChannelName.svelte"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||
import EventActivity from "@app/components/EventActivity.svelte"
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
const {url, event, showRoom, showActivity}: Props = $props()
|
||||
|
||||
const room = getTagValue("h", event.tags)
|
||||
const h = getTagValue("h", event.tags)
|
||||
const path = makeCalendarPath(url, event.id)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
@@ -39,9 +39,9 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||
{#if room && showRoom}
|
||||
<Link href={makeSpacePath(url, room)} class="btn btn-neutral btn-xs rounded-full">
|
||||
Posted in #<ChannelName {room} {url} />
|
||||
{#if h && showRoom}
|
||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||
Posted in #<RoomName {h} {url} />
|
||||
</Link>
|
||||
{/if}
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
room?: string
|
||||
h?: string
|
||||
}
|
||||
|
||||
const {url, room}: Props = $props()
|
||||
const {url, h}: Props = $props()
|
||||
</script>
|
||||
|
||||
<CalendarEventForm {url} {room}>
|
||||
<CalendarEventForm {url} {h}>
|
||||
{#snippet header()}
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
room?: string
|
||||
h?: string
|
||||
header: Snippet
|
||||
initialValues?: {
|
||||
d: string
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const {url, room, header, initialValues}: Props = $props()
|
||||
const {url, h, header, initialValues}: Props = $props()
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
@@ -85,8 +85,8 @@
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
if (room) {
|
||||
tags.push(["h", room])
|
||||
if (h) {
|
||||
tags.push(["h", h])
|
||||
}
|
||||
|
||||
const event = makeEvent(EVENT_TIME, {content, tags})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
|
||||
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import ChannelLink from "@app/components/ChannelLink.svelte"
|
||||
import RoomLink from "@app/components/RoomLink.svelte"
|
||||
import {makeCalendarPath} from "@app/util/routes"
|
||||
|
||||
type Props = {
|
||||
@@ -15,16 +15,18 @@
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const room = getTagValue("h", event.tags)
|
||||
const h = getTagValue("h", event.tags)
|
||||
</script>
|
||||
|
||||
<Link class="col-3 card2 bg-alt w-full cursor-pointer" href={makeCalendarPath(url, event.id)}>
|
||||
<Link
|
||||
class="col-3 card2 bg-alt w-full cursor-pointer shadow-md"
|
||||
href={makeCalendarPath(url, event.id)}>
|
||||
<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} {url} />
|
||||
{#if room}
|
||||
in <ChannelLink {url} {room} />
|
||||
{#if h}
|
||||
in <RoomLink {url} {h} />
|
||||
{/if}
|
||||
</span>
|
||||
<CalendarEventActions showActivity {url} {event} />
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {channelsById, makeChannelId} from "@app/core/state"
|
||||
|
||||
const {url, room} = $props()
|
||||
</script>
|
||||
|
||||
{$channelsById.get(makeChannelId(url, room))?.name || room}
|
||||
@@ -28,8 +28,8 @@
|
||||
tagPubkey,
|
||||
sendWrapped,
|
||||
mergeThunks,
|
||||
loadInboxRelaySelections,
|
||||
inboxRelaySelectionsByPubkey,
|
||||
loadMessagingRelayList,
|
||||
messagingRelayListsByPubkey,
|
||||
} from "@welshman/app"
|
||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
@@ -43,36 +43,31 @@
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import ProfileList from "@app/components/ProfileList.svelte"
|
||||
import ChatMembers from "@app/components/ChatMembers.svelte"
|
||||
import ChatMessage from "@app/components/ChatMessage.svelte"
|
||||
import ChatCompose from "@app/components/ChatCompose.svelte"
|
||||
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||
import {
|
||||
INDEXER_RELAYS,
|
||||
userSettingsValues,
|
||||
deriveChat,
|
||||
splitChatId,
|
||||
PLATFORM_NAME,
|
||||
} from "@app/core/state"
|
||||
import {INDEXER_RELAYS, userSettingsValues, PLATFORM_NAME, deriveChat} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {prependParent} from "@app/core/commands"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
type Props = {
|
||||
id: string
|
||||
pubkeys: string[]
|
||||
info?: Snippet
|
||||
}
|
||||
|
||||
const {id, info}: Props = $props()
|
||||
const {pubkeys, info}: Props = $props()
|
||||
|
||||
const chat = deriveChat(id)
|
||||
const pubkeys = splitChatId(id)
|
||||
const chat = deriveChat(pubkeys)
|
||||
const others = remove($pubkey!, pubkeys)
|
||||
const missingInboxes = $derived(pubkeys.filter(pk => !$inboxRelaySelectionsByPubkey.has(pk)))
|
||||
const missingRelayLists = $derived(pubkeys.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
|
||||
|
||||
const showMembers = () =>
|
||||
pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`})
|
||||
others.length === 1
|
||||
? pushModal(ProfileDetail, {pubkey: others[0]})
|
||||
: pushModal(ChatMembers, {pubkeys: others})
|
||||
|
||||
const replyTo = (event: TrustedEvent) => {
|
||||
parent = event
|
||||
@@ -183,7 +178,7 @@
|
||||
|
||||
onMount(() => {
|
||||
for (const pubkey of others) {
|
||||
loadInboxRelaySelections(pubkey, INDEXER_RELAYS, true)
|
||||
loadMessagingRelayList(pubkey, INDEXER_RELAYS, true)
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
@@ -208,19 +203,17 @@
|
||||
|
||||
<PageBar>
|
||||
{#snippet title()}
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:gap-2">
|
||||
<Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
|
||||
{#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>
|
||||
<div class="row-2">
|
||||
<ProfileCircle pubkey={others[0]} size={5} />
|
||||
<ProfileName pubkey={others[0]} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<ProfileCircles pubkeys={others} size={5} />
|
||||
@@ -235,55 +228,49 @@
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{#if others.length > 2}
|
||||
<Button onclick={showMembers} class="btn btn-link hidden sm:block"
|
||||
>Show all members</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Button>
|
||||
{/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>
|
||||
{#if remove($pubkey, missingRelayLists).length > 0}
|
||||
{@const count = remove($pubkey, missingRelayLists).length}
|
||||
{@const label = count > 1 ? "lists are" : "list is"}
|
||||
<div
|
||||
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
|
||||
data-tip="{count} messaging {label} not configured.">
|
||||
<Icon icon={Danger} />
|
||||
{count}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PageBar>
|
||||
|
||||
<PageContent class="flex flex-col-reverse gap-2 pt-4">
|
||||
<div bind:this={dynamicPadding}></div>
|
||||
{#if missingInboxes.includes($pubkey!)}
|
||||
{#if missingRelayLists.includes($pubkey!)}
|
||||
<div class="py-12">
|
||||
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
||||
<p class="row-2 text-lg text-error">
|
||||
<Icon icon={Danger} />
|
||||
Your inbox is not configured.
|
||||
Your messaging relays are not configured.
|
||||
</p>
|
||||
<p>
|
||||
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit
|
||||
your <Link class="link" href="/settings/relays">relay settings page</Link> to set up your inbox.
|
||||
your <Link class="link" href="/settings/relays">relay settings page</Link> to receive messages.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if missingInboxes.length > 0}
|
||||
{:else if missingRelayLists.length > 0}
|
||||
<div class="py-12">
|
||||
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
||||
<p class="row-2 text-lg text-error">
|
||||
<Icon icon={Danger} />
|
||||
{missingInboxes.length}
|
||||
{missingInboxes.length > 1 ? "inboxes are" : "inbox is"} not configured.
|
||||
{missingRelayLists.length} messaging
|
||||
{missingRelayLists.length > 1 ? "lists are" : "list is"} not configured.
|
||||
</p>
|
||||
<p>
|
||||
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make
|
||||
sure everyone in this conversation has set up their inbox relays.
|
||||
sure everyone in this conversation has set up their messaging relays.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {page} from "$app/stores"
|
||||
import {remove} from "@welshman/lib"
|
||||
import {remove, uniq, formatTimestamp} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {pubkey, loadInboxRelaySelections} from "@welshman/app"
|
||||
import {pubkey, loadMessagingRelayList} from "@welshman/app"
|
||||
import {fade} from "@lib/transition"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import ProfileName from "@app/components/ProfileName.svelte"
|
||||
@@ -21,13 +21,13 @@
|
||||
|
||||
const {...props}: Props = $props()
|
||||
|
||||
const others = remove($pubkey!, props.pubkeys)
|
||||
const others = uniq(remove($pubkey!, props.pubkeys))
|
||||
const active = $derived($page.params.chat === props.id)
|
||||
const path = makeChatPath(props.pubkeys)
|
||||
|
||||
onMount(() => {
|
||||
for (const pk of others) {
|
||||
loadInboxRelaySelections(pk)
|
||||
loadMessagingRelayList(pk)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -59,13 +59,16 @@
|
||||
{/if}
|
||||
</div>
|
||||
<p class="overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
<span class="opacity-50">
|
||||
<span class="opacity-70">
|
||||
{#if props.messages[0].pubkey === $pubkey}
|
||||
You:
|
||||
{/if}
|
||||
</span>
|
||||
{props.messages[0].content}
|
||||
</p>
|
||||
<p class="text-xs opacity-70">
|
||||
{formatTimestamp(props.messages[0].created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import Profile from "@app/components/Profile.svelte"
|
||||
|
||||
interface Props {
|
||||
pubkeys: string[]
|
||||
}
|
||||
|
||||
const {pubkeys}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="column gap-4">
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
<div>People in this conversation</div>
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
{#each pubkeys as pubkey (pubkey)}
|
||||
<div class="card2 bg-alt">
|
||||
<Profile {pubkey} />
|
||||
</div>
|
||||
{/each}
|
||||
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
|
||||
</div>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {waitForThunkCompletion} from "@welshman/app"
|
||||
import {RelayMode} from "@welshman/util"
|
||||
import {waitForThunkCompletion, getPubkeyRelays, pubkey} from "@welshman/app"
|
||||
import ChatSquare from "@assets/icons/chat-square.svg?dataurl"
|
||||
import Check from "@assets/icons/check.svg?dataurl"
|
||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||
@@ -11,7 +12,7 @@
|
||||
import {setChecked} from "@app/util/notifications"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {dmAlert, userInboxRelays} from "@app/core/state"
|
||||
import {dmAlert} from "@app/core/state"
|
||||
import {deleteAlert, createDmAlert} from "@app/core/commands"
|
||||
|
||||
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
|
||||
@@ -22,7 +23,7 @@
|
||||
}
|
||||
|
||||
const enableAlerts = async () => {
|
||||
if ($userInboxRelays.length === 0) {
|
||||
if (getPubkeyRelays($pubkey!, RelayMode.Messaging).length === 0) {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: "Please set up your messaging relays before enabling alerts.",
|
||||
|
||||
@@ -2,21 +2,14 @@
|
||||
import {type Instance} from "tippy.js"
|
||||
import {hash, formatTimestampAsTime} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {
|
||||
thunks,
|
||||
mergeThunks,
|
||||
pubkey,
|
||||
deriveProfile,
|
||||
deriveProfileDisplay,
|
||||
sendWrapped,
|
||||
} from "@welshman/app"
|
||||
import {thunks, mergeThunks, pubkey, deriveProfileDisplay, sendWrapped} from "@welshman/app"
|
||||
import {isMobile} from "@lib/html"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Tippy from "@lib/components/Tippy.svelte"
|
||||
import TapTarget from "@lib/components/TapTarget.svelte"
|
||||
import Avatar from "@lib/components/Avatar.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||
import ThunkFailure from "@app/components/ThunkFailure.svelte"
|
||||
@@ -37,7 +30,6 @@
|
||||
const {event, replyTo, pubkeys, showPubkey = false}: Props = $props()
|
||||
|
||||
const isOwn = event.pubkey === $pubkey
|
||||
const profile = deriveProfile(event.pubkey)
|
||||
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
||||
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
|
||||
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
|
||||
@@ -107,8 +99,8 @@
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !isOwn}
|
||||
<Button onclick={openProfile} class="flex items-center gap-1">
|
||||
<Avatar
|
||||
src={$profile?.picture}
|
||||
<ProfileCircle
|
||||
pubkey={event.pubkey}
|
||||
class="border border-solid border-base-content"
|
||||
size={4} />
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -46,20 +46,20 @@
|
||||
</script>
|
||||
|
||||
<div class="col-2">
|
||||
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
|
||||
<Icon size={4} icon={SmileCircle} />
|
||||
Send Reaction
|
||||
</Button>
|
||||
<Button class="btn btn-neutral w-full" onclick={sendReply}>
|
||||
<Icon size={4} icon={Reply} />
|
||||
Send Reply
|
||||
<Button class="btn btn-neutral" onclick={showInfo}>
|
||||
<Icon size={4} icon={Code2} />
|
||||
Message Info
|
||||
</Button>
|
||||
<Button class="btn btn-neutral w-full" onclick={copyText}>
|
||||
<Icon size={4} icon={Copy} />
|
||||
Copy Text
|
||||
</Button>
|
||||
<Button class="btn btn-neutral" onclick={showInfo}>
|
||||
<Icon size={4} icon={Code2} />
|
||||
Message Details
|
||||
<Button class="btn btn-neutral w-full" onclick={sendReply}>
|
||||
<Icon size={4} icon={Reply} />
|
||||
Send Reply
|
||||
</Button>
|
||||
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
|
||||
<Icon size={4} icon={SmileCircle} />
|
||||
Send Reaction
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import {goto} from "$app/navigation"
|
||||
import {tryCatch, uniq} from "@welshman/lib"
|
||||
import {fromNostrURI} from "@welshman/util"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
@@ -19,7 +18,7 @@
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const onSubmit = () => goto(makeChatPath([...pubkeys, $pubkey!]))
|
||||
const onSubmit = () => goto(makeChatPath(pubkeys))
|
||||
|
||||
const addPubkey = (pubkey: string) => {
|
||||
pubkeys = uniq([...pubkeys, pubkey])
|
||||
|
||||
@@ -13,16 +13,16 @@
|
||||
type Props = {
|
||||
url: string
|
||||
onClick: () => void
|
||||
room?: string
|
||||
h?: string
|
||||
}
|
||||
|
||||
const {url, room, onClick}: Props = $props()
|
||||
const {url, h, onClick}: Props = $props()
|
||||
|
||||
const createGoal = () => pushModal(GoalCreate, {url, room})
|
||||
const createGoal = () => pushModal(GoalCreate, {url, h})
|
||||
|
||||
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, room})
|
||||
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h})
|
||||
|
||||
const createThread = () => pushModal(ThreadCreate, {url, room})
|
||||
const createThread = () => pushModal(ThreadCreate, {url, h})
|
||||
|
||||
let ul: Element
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-xl" bind:this={ul}>
|
||||
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-md" bind:this={ul}>
|
||||
<li>
|
||||
<Button onclick={createGoal}>
|
||||
<Icon size={4} icon={StarFallMinimalistic} />
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
{#if isBlock(i)}
|
||||
<ContentLinkBlock value={parsed.value} {event} />
|
||||
{:else}
|
||||
<ContentLinkInline value={parsed.value} />
|
||||
<ContentLinkInline value={parsed.value} {event} />
|
||||
{/if}
|
||||
{:else if isProfile(parsed)}
|
||||
<ContentMention value={parsed.value} {url} />
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<script lang="ts">
|
||||
import {ellipsize, displayUrl, postJson} from "@welshman/lib"
|
||||
import {dufflepud} from "@app/core/state"
|
||||
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
|
||||
import {isRelayUrl} from "@welshman/util"
|
||||
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/util/modal"
|
||||
import {dufflepud, PLATFORM_URL} from "@app/core/state"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
|
||||
const {value, event} = $props()
|
||||
|
||||
let hideImage = $state(false)
|
||||
|
||||
const url = value.url.toString()
|
||||
const [href, external] = call(() => {
|
||||
if (isRelayUrl(url)) return [makeSpacePath(url), false]
|
||||
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
|
||||
|
||||
return [url, true]
|
||||
})
|
||||
|
||||
const loadPreview = async () => {
|
||||
const json = await postJson(dufflepud("link/preview"), {url})
|
||||
@@ -30,10 +38,10 @@
|
||||
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
|
||||
</script>
|
||||
|
||||
<Link external href={url} class="my-2 block">
|
||||
<Link {external} {href} class="my-2 block">
|
||||
<div class="overflow-hidden rounded-box">
|
||||
{#if url.match(/\.(mov|webm|mp4)$/)}
|
||||
<video controls src={url} class="max-h-96 object-contain object-center">
|
||||
<video controls src={url} class="max-h-96 rounded-box object-contain object-center">
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{:else if url.match(/\.(jpe?g|png|gif|webp)$/)}
|
||||
@@ -49,7 +57,7 @@
|
||||
<div class="bg-alt flex max-w-xl flex-col leading-normal">
|
||||
{#if preview.image && !hideImage}
|
||||
<img
|
||||
alt="Link preview"
|
||||
alt=""
|
||||
onerror={onError}
|
||||
src={preview.image}
|
||||
class="bg-alt max-h-72 rounded-t-box object-contain object-center" />
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
.map(tagsFromIMeta)
|
||||
.find(meta => getTagValue("url", meta) === url) || event.tags
|
||||
|
||||
const hash = getTagValue("x", meta)
|
||||
// Fallback to filename if hash was omitted from the message for interoperability
|
||||
const hash = getTagValue("x", meta) || url.split(/[\/\.]/).slice(-2)[0]
|
||||
const key = getTagValue("decryption-key", meta)
|
||||
const nonce = getTagValue("decryption-nonce", meta)
|
||||
const algorithm = getTagValue("encryption-algorithm", meta)
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
<script lang="ts">
|
||||
import {displayUrl} from "@welshman/lib"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import {call, displayUrl} from "@welshman/lib"
|
||||
import {isRelayUrl} from "@welshman/util"
|
||||
import {preventDefault, stopPropagation} from "@lib/html"
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {PLATFORM_URL} from "@app/core/state"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
|
||||
const {value} = $props()
|
||||
const {value, event} = $props()
|
||||
|
||||
const url = value.url.toString()
|
||||
const [href, external] = call(() => {
|
||||
if (isRelayUrl(url)) return [makeSpacePath(url), false]
|
||||
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
|
||||
|
||||
const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true})
|
||||
return [url, true]
|
||||
})
|
||||
|
||||
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
|
||||
</script>
|
||||
|
||||
{#if url.match(/\.(jpe?g|png|gif|webp)$/)}
|
||||
<!-- Use a real link so people can copy the href -->
|
||||
<a href={url} class="link-content whitespace-nowrap" onclick={preventDefault(expand)}>
|
||||
<a
|
||||
href={url}
|
||||
class="link-content whitespace-nowrap"
|
||||
onclick={stopPropagation(preventDefault(expand))}>
|
||||
<Icon icon={LinkRound} size={3} class="inline-block" />
|
||||
{displayUrl(url)}
|
||||
</a>
|
||||
{:else}
|
||||
<Link external href={url} class="link-content whitespace-nowrap">
|
||||
<Link {external} {href} class="link-content whitespace-nowrap">
|
||||
<Icon icon={LinkRound} size={3} class="inline-block" />
|
||||
{displayUrl(url)}
|
||||
</Link>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {removeNil} from "@welshman/lib"
|
||||
import {removeUndefined} from "@welshman/lib"
|
||||
import type {ProfilePointer} from "@welshman/content"
|
||||
import {deriveProfileDisplay} from "@welshman/app"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
const {value, url}: Props = $props()
|
||||
|
||||
const display = deriveProfileDisplay(value.pubkey, removeNil([url]))
|
||||
const display = deriveProfileDisplay(value.pubkey, removeUndefined([url]))
|
||||
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url})
|
||||
</script>
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
{:else if isCashu(parsed) || isInvoice(parsed)}
|
||||
<ContentToken value={parsed.value} />
|
||||
{:else if isLink(parsed)}
|
||||
<ContentLinkInline value={parsed.value} />
|
||||
<ContentLinkInline value={parsed.value} {event} />
|
||||
{:else if isProfile(parsed)}
|
||||
<ContentMention value={parsed.value} {url} />
|
||||
{:else if isQuote(parsed)}
|
||||
|
||||
@@ -8,29 +8,29 @@
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||
import {goToEvent} from "@app/util/routes"
|
||||
import {displayChannel} from "@app/core/state"
|
||||
import {displayRoom} from "@app/core/state"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
room?: string
|
||||
h?: string
|
||||
events: TrustedEvent[]
|
||||
latest: TrustedEvent
|
||||
earliest: TrustedEvent
|
||||
participants: string[]
|
||||
}
|
||||
|
||||
const {url, room, events, latest, earliest, participants}: Props = $props()
|
||||
const {url, h, events, latest, earliest, participants}: Props = $props()
|
||||
</script>
|
||||
|
||||
<Button class="card2 bg-alt" onclick={() => goToEvent(earliest)}>
|
||||
<Button class="card2 bg-alt shadow-lg" onclick={() => goToEvent(earliest)}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<ProfileCircle pubkey={earliest.pubkey} size={10} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">
|
||||
{#if room}
|
||||
{#if h}
|
||||
<span class="truncate font-medium text-blue-400">
|
||||
#{displayChannel(url, room)}
|
||||
#{displayRoom(url, h)}
|
||||
</span>
|
||||
<span class="opacity-50">•</span>
|
||||
{/if}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {postJson, sleep} from "@welshman/lib"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import LogInPassword from "@app/components/LogInPassword.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {BURROW_URL} from "@app/core/state"
|
||||
|
||||
const {email, confirm_token} = $props()
|
||||
|
||||
const login = () => {
|
||||
pushModal(LogInPassword, {email}, {path: "/"})
|
||||
}
|
||||
|
||||
let error = $state("")
|
||||
let loading = $state(true)
|
||||
|
||||
onMount(async () => {
|
||||
const [res] = await Promise.all([
|
||||
postJson(BURROW_URL + "/user/confirm-email", {email, confirm_token}),
|
||||
sleep(2000),
|
||||
])
|
||||
|
||||
error = res.error
|
||||
loading = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="column gap-4">
|
||||
<h1 class="heading">
|
||||
{#if loading}
|
||||
Just a second...
|
||||
{:else if error}
|
||||
Oops!
|
||||
{:else}
|
||||
Success!
|
||||
{/if}
|
||||
</h1>
|
||||
<p class="m-auto max-w-sm text-center">
|
||||
<Spinner {loading}>
|
||||
{#if loading}
|
||||
Hang tight, we're checking your confirmation link.
|
||||
{:else if error}
|
||||
Looks like something went wrong. {error}
|
||||
{:else}
|
||||
You're all set - click below to log in.
|
||||
{/if}
|
||||
</Spinner>
|
||||
</p>
|
||||
<Button class="btn btn-primary" onclick={login} disabled={loading}>Continue to Login</Button>
|
||||
</div>
|
||||
@@ -3,7 +3,7 @@
|
||||
import {max, formatTimestampRelative} from "@welshman/lib"
|
||||
import {COMMENT} from "@welshman/util"
|
||||
import {load} from "@welshman/net"
|
||||
import {deriveEvents} from "@welshman/store"
|
||||
import {deriveArray, deriveEventsById} from "@welshman/store"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {repository} from "@welshman/app"
|
||||
import {notifications} from "@app/util/notifications"
|
||||
@@ -13,7 +13,7 @@
|
||||
const {url, path, event}: {url: string; path: string; event: TrustedEvent} = $props()
|
||||
|
||||
const filters = [{kinds: [COMMENT], "#E": [event.id]}]
|
||||
const replies = deriveEvents(repository, {filters})
|
||||
const replies = deriveArray(deriveEventsById({repository, filters}))
|
||||
const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
|
||||
|
||||
onMount(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import {LOCALE, secondsToDate} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import {tracker} from "@welshman/app"
|
||||
import FileText from "@assets/icons/file-text.svg?dataurl"
|
||||
import Copy from "@assets/icons/copy.svg?dataurl"
|
||||
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||
@@ -11,7 +12,6 @@
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import {trackerStore} from "@app/core/state"
|
||||
import {clip} from "@app/util/toast"
|
||||
|
||||
type Props = {
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
const relays = url ? [url] : Router.get().Event(event).getUrls()
|
||||
const nevent1 = nip19.neventEncode({...event, relays})
|
||||
const seenOn = $trackerStore.getRelays(event.id)
|
||||
const seenOn = tracker.getRelays(event.id)
|
||||
const npub1 = nip19.npubEncode(event.pubkey)
|
||||
const json = JSON.stringify(event, null, 2)
|
||||
const copyLink = () => clip(nevent1)
|
||||
|
||||
@@ -3,21 +3,23 @@
|
||||
import type {Snippet} from "svelte"
|
||||
import {goto} from "$app/navigation"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {COMMENT} from "@welshman/util"
|
||||
import {pubkey, relaysByUrl} from "@welshman/app"
|
||||
import {COMMENT, ManagementMethod} from "@welshman/util"
|
||||
import {pubkey, repository, relaysByUrl, manageRelay} from "@welshman/app"
|
||||
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
import Danger from "@assets/icons/danger.svg?dataurl"
|
||||
import {setKey} from "@lib/implicit"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import EventInfo from "@app/components/EventInfo.svelte"
|
||||
import EventReport from "@app/components/EventReport.svelte"
|
||||
import Report from "@app/components/Report.svelte"
|
||||
import EventShare from "@app/components/EventShare.svelte"
|
||||
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
||||
import {hasNip29} from "@app/core/state"
|
||||
import {hasNip29, deriveUserIsSpaceAdmin} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {makeSpaceChatPath} from "@app/util/routes"
|
||||
|
||||
type Props = {
|
||||
@@ -31,8 +33,9 @@
|
||||
const {url, noun, event, onClick, customActions}: Props = $props()
|
||||
|
||||
const isRoot = event.kind !== COMMENT
|
||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||
|
||||
const report = () => pushModal(EventReport, {url, event})
|
||||
const report = () => pushModal(Report, {url, event})
|
||||
|
||||
const showInfo = () => pushModal(EventInfo, {url, event})
|
||||
|
||||
@@ -47,6 +50,26 @@
|
||||
|
||||
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
|
||||
|
||||
const showAdminDelete = () =>
|
||||
pushModal(Confirm, {
|
||||
title: `Delete ${noun}`,
|
||||
message: `Are you sure you want to delete this ${noun.toLowerCase()} from the space?`,
|
||||
confirm: async () => {
|
||||
const {error} = await manageRelay(url, {
|
||||
method: ManagementMethod.BanEvent,
|
||||
params: [event.id],
|
||||
})
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: "Event has successfully been deleted!"})
|
||||
repository.removeEvent(event.id)
|
||||
history.back()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
@@ -54,7 +77,7 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-xl" bind:this={ul}>
|
||||
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-md" bind:this={ul}>
|
||||
{#if isRoot}
|
||||
<li>
|
||||
<Button onclick={share}>
|
||||
@@ -84,5 +107,13 @@
|
||||
Report Content
|
||||
</Button>
|
||||
</li>
|
||||
{#if $userIsAdmin}
|
||||
<li>
|
||||
<Button class="text-error" onclick={showAdminDelete}>
|
||||
<Icon size={4} icon={TrashBin2} />
|
||||
Delete {noun}
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{/if}
|
||||
</ul>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {getTag, REPORT} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {deriveEvents} from "@welshman/store"
|
||||
import {pubkey, repository} from "@welshman/app"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Profile from "@app/components/Profile.svelte"
|
||||
import {publishDelete, canEnforceNip70} from "@app/core/commands"
|
||||
|
||||
const {url, event} = $props()
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const reports = deriveEvents(repository, {
|
||||
filters: [{kinds: [REPORT], "#e": [event.id]}],
|
||||
})
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const deleteReport = async (report: TrustedEvent) => {
|
||||
publishDelete({event: report, relays: [url], protect: await shouldProtect})
|
||||
|
||||
if ($reports.length === 0) {
|
||||
history.back()
|
||||
}
|
||||
}
|
||||
|
||||
const getReason = (tags: string[][]) => getTag("e", tags)?.[2] || "other"
|
||||
</script>
|
||||
|
||||
<div class="column gap-4">
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
<div>Report Details</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>All reports for this event are shown below.</div>
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
{#each $reports as report (report.id)}
|
||||
{@const reason = getReason(report.tags)}
|
||||
{@const remove = () => deleteReport(report)}
|
||||
<div class="column gap-2">
|
||||
<div class="flex justify-between">
|
||||
<div>
|
||||
<Profile pubkey={report.pubkey} {url} />
|
||||
<span>Reported this event as "{reason}"</span>
|
||||
</div>
|
||||
{#if report.pubkey === $pubkey}
|
||||
<Button class="btn-default btn" onclick={remove}>Delete Report</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if report.content}
|
||||
<p>"{report.content}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<Button class="btn btn-primary" onclick={back}>Got it</Button>
|
||||
</div>
|
||||
@@ -9,8 +9,8 @@
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import ChannelName from "@app/components/ChannelName.svelte"
|
||||
import {channelsByUrl} from "@app/core/state"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import {roomsByUrl} from "@app/core/state"
|
||||
import {makeRoomPath} from "@app/util/routes"
|
||||
|
||||
const {url, noun, event}: {url: string; noun: string; event: TrustedEvent} = $props()
|
||||
@@ -22,8 +22,8 @@
|
||||
goto(makeRoomPath(url, selection), {replaceState: true})
|
||||
}
|
||||
|
||||
const toggleRoom = (room: string) => {
|
||||
selection = room === selection ? "" : room
|
||||
const toggleRoom = (h: string) => {
|
||||
selection = h === selection ? "" : h
|
||||
}
|
||||
|
||||
let selection = $state("")
|
||||
@@ -39,14 +39,14 @@
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each $channelsByUrl.get(url) || [] as channel (channel.room)}
|
||||
{#each $roomsByUrl.get(url) || [] as room (room.h)}
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
class:btn-neutral={selection !== channel.room}
|
||||
class:btn-primary={selection === channel.room}
|
||||
onclick={() => toggleRoom(channel.room)}>
|
||||
#<ChannelName {...channel} />
|
||||
class:btn-neutral={selection !== room.h}
|
||||
class:btn-primary={selection === room.h}
|
||||
onclick={() => toggleRoom(room.h)}>
|
||||
#<RoomName {...room} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||
import EventActivity from "@app/components/EventActivity.svelte"
|
||||
import EventActions from "@app/components/EventActions.svelte"
|
||||
import ChannelName from "@app/components/ChannelName.svelte"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||
import {makeGoalPath, makeSpacePath} from "@app/util/routes"
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
const {url, event, showRoom, showActivity}: Props = $props()
|
||||
|
||||
const path = makeGoalPath(url, event.id)
|
||||
const room = getTagValue("h", event.tags)
|
||||
const h = getTagValue("h", event.tags)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const deleteReaction = async (event: TrustedEvent) =>
|
||||
@@ -31,9 +31,9 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||
{#if room && showRoom}
|
||||
<Link href={makeSpacePath(url, room)} class="btn btn-neutral btn-xs rounded-full">
|
||||
Posted in #<ChannelName {room} {url} />
|
||||
{#if h && showRoom}
|
||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||
Posted in #<RoomName {h} {url} />
|
||||
</Link>
|
||||
{/if}
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
room?: string
|
||||
h?: string
|
||||
}
|
||||
|
||||
const {url, room}: Props = $props()
|
||||
const {url, h}: Props = $props()
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
@@ -64,8 +64,8 @@
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
if (room) {
|
||||
tags.push(["h", room])
|
||||
if (h) {
|
||||
tags.push(["h", h])
|
||||
}
|
||||
|
||||
publishThunk({
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import GoalActions from "@app/components/GoalActions.svelte"
|
||||
import GoalSummary from "@app/components/GoalSummary.svelte"
|
||||
import ChannelLink from "@app/components/ChannelLink.svelte"
|
||||
import RoomLink from "@app/components/RoomLink.svelte"
|
||||
import {makeGoalPath} from "@app/util/routes"
|
||||
|
||||
type Props = {
|
||||
@@ -17,10 +17,10 @@
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const summary = getTagValue("summary", event.tags)
|
||||
const room = getTagValue("h", event.tags)
|
||||
const h = getTagValue("h", event.tags)
|
||||
</script>
|
||||
|
||||
<Link class="col-2 card2 bg-alt w-full cursor-pointer" href={makeGoalPath(url, event.id)}>
|
||||
<Link class="col-2 card2 bg-alt w-full cursor-pointer shadow-md" href={makeGoalPath(url, event.id)}>
|
||||
<p class="text-2xl">{event.content}</p>
|
||||
<Content
|
||||
event={{content: summary, tags: event.tags}}
|
||||
@@ -32,8 +32,8 @@
|
||||
<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} {url} />
|
||||
{#if room}
|
||||
in <ChannelLink {url} {room} />
|
||||
{#if h}
|
||||
in <RoomLink {url} {h} />
|
||||
{/if}
|
||||
</span>
|
||||
<GoalActions showActivity {url} {event} />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import {now, DAY, uniq, sum} from "@welshman/lib"
|
||||
import type {Zap, TrustedEvent} from "@welshman/util"
|
||||
import {getTagValue, fromMsats, ZAP_RESPONSE} from "@welshman/util"
|
||||
import {deriveEventsMapped} from "@welshman/store"
|
||||
import {deriveItemsByKey, deriveArray} from "@welshman/store"
|
||||
import {repository, getValidZap} from "@welshman/app"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
@@ -16,11 +16,14 @@
|
||||
|
||||
const {url, event, ...props}: Props = $props()
|
||||
|
||||
const zaps = deriveEventsMapped<Zap>(repository, {
|
||||
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
|
||||
itemToEvent: item => item.response,
|
||||
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
|
||||
})
|
||||
const zaps = deriveArray(
|
||||
deriveItemsByKey<Zap>({
|
||||
repository,
|
||||
getKey: zap => zap.response.id,
|
||||
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
|
||||
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
|
||||
}),
|
||||
)
|
||||
|
||||
const goalAmount = parseInt(getTagValue("amount", event.tags) || "0")
|
||||
const zapAmount = $derived(fromMsats(sum($zaps.map(zap => zap.invoiceAmount))))
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-96 rounded-box bg-base-100 p-4 shadow-lg">
|
||||
<div class="w-96 rounded-box bg-base-100 p-4 shadow-2xl">
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={Magnifier} />
|
||||
<input bind:value={searchTerm} class="grow" type="text" placeholder="Search icons..." />
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import ProfileEject from "@app/components/ProfileEject.svelte"
|
||||
import KeyRecoveryRequest from "@app/components/KeyRecoveryRequest.svelte"
|
||||
import {PLATFORM_NAME} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const startEject = () => pushModal(ProfileEject)
|
||||
const startRecoveryRequest = () => pushModal(KeyRecoveryRequest)
|
||||
</script>
|
||||
|
||||
<div class="column gap-4">
|
||||
@@ -44,7 +44,7 @@
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button class="btn btn-primary" onclick={startEject}>
|
||||
<Button class="btn btn-primary" onclick={startRecoveryRequest}>
|
||||
<Icon icon={CheckCircle} />
|
||||
I want to hold my own keys
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
import {nsecEncode} from "nostr-tools/nip19"
|
||||
import {encrypt} from "nostr-tools/nip49"
|
||||
import {hexToBytes} from "@welshman/lib"
|
||||
import {preventDefault, downloadText} from "@lib/html"
|
||||
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
|
||||
import ArrowDown from "@assets/icons/arrow-down.svg?dataurl"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {PLATFORM_NAME} from "@app/core/state"
|
||||
|
||||
type Props = {
|
||||
secret: string
|
||||
next: () => unknown
|
||||
submitText?: string
|
||||
}
|
||||
|
||||
const {secret, next, submitText = "Continue"}: Props = $props()
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const cleanupCopy = (copy: string) =>
|
||||
copy
|
||||
.replace(/\n\s*\n\s*/g, "NEWLINE")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/NEWLINE/g, "\n\n")
|
||||
.trim()
|
||||
|
||||
const downloadKey = () => {
|
||||
const sharedCopy = `
|
||||
Most online services keep track of users by giving them a username and password. This gives the
|
||||
service total control over their users, allowing them to ban them at any time, or sell their activity.
|
||||
|
||||
On Nostr, you control your own identity and social data, through the magic of cryptography. The basic
|
||||
idea is that you have a public key, which acts as your user ID, and a private key which allows you to
|
||||
prove your identity.
|
||||
|
||||
It's very important to keep your private key secret because it grants permanent and complete access to your
|
||||
account.
|
||||
`
|
||||
|
||||
if (usePassword) {
|
||||
if (password.length < 12) {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: "Your password must be at least 12 characters long.",
|
||||
})
|
||||
}
|
||||
|
||||
const ncryptsec = encrypt(hexToBytes(secret), password)
|
||||
const instructions = `
|
||||
This file contains a backup of your Nostr secret key, downloaded from ${PLATFORM_NAME} and encrypted using
|
||||
a password you chose when you signed up.
|
||||
|
||||
${sharedCopy}
|
||||
|
||||
Your encrypted private key is:
|
||||
|
||||
${ncryptsec}
|
||||
|
||||
To use it to log in to other Nostr apps, find a Nostr Signer app (https://nostrapps.com/#signers is a good
|
||||
place to look), and import your key.
|
||||
`
|
||||
|
||||
downloadText("Nostr Secret Key.txt", cleanupCopy(instructions))
|
||||
} else {
|
||||
const nsec = nsecEncode(hexToBytes(secret))
|
||||
const instructions = `
|
||||
This file contains a backup of your Nostr secret key, downloaded from ${PLATFORM_NAME}.
|
||||
|
||||
${sharedCopy}
|
||||
|
||||
Your private key is:
|
||||
|
||||
${nsec}
|
||||
|
||||
To use it to log in to other Nostr apps, find a Nostr Signer app (https://nostrapps.com/#signers is a good
|
||||
place to look), and import your key.
|
||||
`
|
||||
|
||||
downloadText("Nostr Secret Key.txt", cleanupCopy(instructions))
|
||||
}
|
||||
|
||||
didDownload = true
|
||||
}
|
||||
|
||||
const onPasswordChange = () => {
|
||||
didDownload = false
|
||||
}
|
||||
|
||||
const toggleUsePassword = () => {
|
||||
usePassword = !usePassword
|
||||
didDownload = false
|
||||
}
|
||||
|
||||
let password = $state("")
|
||||
let usePassword = $state(false)
|
||||
let didDownload = $state(false)
|
||||
</script>
|
||||
|
||||
<form class="column gap-4" onsubmit={preventDefault(next)}>
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
<div>Your Keys are Ready!</div>
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
<p>
|
||||
A cryptographic key pair has two parts: your <strong>public key</strong> identifies your
|
||||
account, while your <strong>private key</strong> acts sort of like a master password.
|
||||
</p>
|
||||
<p>
|
||||
Securing your private key is very important, so make sure to take the time to save your key in a
|
||||
secure place (like a password manager).
|
||||
</p>
|
||||
{#if usePassword}
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
Password*
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={Key} />
|
||||
<input bind:value={password} onchange={onPasswordChange} class="grow" type="password" />
|
||||
</label>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<p>Passwords should be at least 12 characters long. Write this down!</p>
|
||||
{/snippet}
|
||||
</Field>
|
||||
{/if}
|
||||
<div class="flex flex-col">
|
||||
<Button class="btn {didDownload ? 'btn-neutral' : 'btn-primary'}" onclick={downloadKey}>
|
||||
Download my key
|
||||
<Icon icon={ArrowDown} />
|
||||
</Button>
|
||||
<Button class="btn btn-link no-underline" onclick={toggleUsePassword}>
|
||||
{#if usePassword}
|
||||
Nevermind, I want to download the plain version
|
||||
{:else}
|
||||
I want to download an encrypted version
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button disabled={!didDownload} class="btn btn-primary" type="submit">
|
||||
{submitText}
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import {Client} from "@pomade/core"
|
||||
import {getPubkey} from "@welshman/util"
|
||||
import type {SessionPomade} from "@welshman/app"
|
||||
import {session} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.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 KeyDownload from "@app/components/KeyDownload.svelte"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {pushModal, clearModals} from "@app/util/modal"
|
||||
import {POMADE_SIGNERS} from "@app/core/state"
|
||||
|
||||
type Props = {
|
||||
peersByPrefix: Map<string, string>
|
||||
}
|
||||
|
||||
const {peersByPrefix}: Props = $props()
|
||||
|
||||
const {
|
||||
email,
|
||||
clientOptions: {secret, peers},
|
||||
} = $session as SessionPomade
|
||||
|
||||
const confirmRecovery = async () => {
|
||||
const otps = input
|
||||
.split(/\n/)
|
||||
.map(x => x.trim())
|
||||
.filter(x => x.match(/^[0-9]{8}$/))
|
||||
|
||||
if (otps.length < 2) {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: "Failed to recover, not enough valid recovery codes were provided.",
|
||||
})
|
||||
}
|
||||
|
||||
const request = await Client.recoverWithChallenge(email, peersByPrefix, otps)
|
||||
|
||||
if (!request.ok) {
|
||||
console.log(request.messages)
|
||||
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: `Failed to recover: ${request.messages[0]?.payload.message.toLowerCase()}`,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await Client.selectRecovery(request.clientSecret, getPubkey(secret), peers)
|
||||
|
||||
if (!result.ok) {
|
||||
console.log(result.messages)
|
||||
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: `Failed to recover: ${result.messages[0]?.payload.message.toLowerCase()}`,
|
||||
})
|
||||
}
|
||||
|
||||
pushModal(KeyDownload, {secret: result.userSecret, next: clearModals, submitText: "Done"})
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
await confirmRecovery()
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
let loading = $state(false)
|
||||
let input = $state("")
|
||||
</script>
|
||||
|
||||
<form class="column gap-4" onsubmit={preventDefault(submit)}>
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
Recover your Key
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
Take control over your cryptographic identity
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
<p>Your recovery codes have been sent!</p>
|
||||
<p>
|
||||
For security reasons, you may receive three or more emails with recovery codes in them. Please
|
||||
paste <strong>all</strong> recovery codes into the text box below, on separate lines.
|
||||
</p>
|
||||
<textarea
|
||||
rows={POMADE_SIGNERS.length + 1}
|
||||
class="textarea textarea-bordered leading-4"
|
||||
bind:value={input}></textarea>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||
<Spinner {loading}>Confirm recovery</Spinner>
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import {Client} from "@pomade/core"
|
||||
import type {SessionPomade} from "@welshman/app"
|
||||
import {session} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.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 {pushModal} from "@app/util/modal"
|
||||
import KeyRecoveryConfirm from "@app/components/KeyRecoveryConfirm.svelte"
|
||||
|
||||
const {
|
||||
email,
|
||||
clientOptions: {peers},
|
||||
} = $session as SessionPomade
|
||||
|
||||
const requestRecovery = async () => {
|
||||
const {peersByPrefix} = await Client.requestChallenge(email, peers)
|
||||
|
||||
pushModal(KeyRecoveryConfirm, {peersByPrefix})
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
await requestRecovery()
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
let loading = $state(false)
|
||||
</script>
|
||||
|
||||
<form class="column gap-4" onsubmit={preventDefault(submit)}>
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
Recover your Key
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
Take control over your cryptographic identity
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
<p>
|
||||
When you signed up, your Nostr secret key was split into multiple pieces and stored on separate
|
||||
third-party servers to keep it safe.
|
||||
</p>
|
||||
<p>
|
||||
If you're ready to take control of your cryptographic identity, click below. We'll confirm your
|
||||
email by sending you some recovery codes.
|
||||
</p>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||
<Spinner {loading}>Request recovery</Spinner>
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
@@ -4,24 +4,28 @@
|
||||
import {getNip07, getNip55, Nip55Signer} from "@welshman/signer"
|
||||
import {addSession, type Session, makeNip07Session, makeNip55Session} from "@welshman/app"
|
||||
import Widget from "@assets/icons/widget-2.svg?dataurl"
|
||||
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
|
||||
import Letter from "@assets/icons/letter.svg?dataurl"
|
||||
import Cpu from "@assets/icons/cpu-bolt.svg?dataurl"
|
||||
import Compass from "@assets/icons/compass-big.svg?dataurl"
|
||||
import Key from "@assets/icons/key.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import SignUp from "@app/components/SignUp.svelte"
|
||||
import InfoNostr from "@app/components/InfoNostr.svelte"
|
||||
import LogInBunker from "@app/components/LogInBunker.svelte"
|
||||
import LogInPassword from "@app/components/LogInPassword.svelte"
|
||||
import LogInEmail from "@app/components/LogInEmail.svelte"
|
||||
import LogInKey from "@app/components/LogInKey.svelte"
|
||||
import {pushModal, clearModals} from "@app/util/modal"
|
||||
import {PLATFORM_NAME, BURROW_URL} from "@app/core/state"
|
||||
import {PLATFORM_NAME, POMADE_SIGNERS} from "@app/core/state"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {setChecked} from "@app/util/notifications"
|
||||
|
||||
let signers: any[] = $state([])
|
||||
let loading: string | undefined = $state()
|
||||
|
||||
const hasPomade = POMADE_SIGNERS.length >= 3
|
||||
|
||||
const disabled = $derived(loading ? true : undefined)
|
||||
|
||||
const signUp = () => pushModal(SignUp)
|
||||
@@ -72,10 +76,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
const loginWithPassword = () => pushModal(LogInPassword)
|
||||
const loginWithEmail = () => pushModal(LogInEmail)
|
||||
|
||||
const loginWithBunker = () => pushModal(LogInBunker)
|
||||
|
||||
const loginWithKey = () => pushModal(LogInKey)
|
||||
|
||||
const hasSigner = $derived(getNip07() || signers.length > 0)
|
||||
|
||||
onMount(async () => {
|
||||
@@ -112,39 +118,37 @@
|
||||
Log in with {app.name}
|
||||
</Button>
|
||||
{/each}
|
||||
{#if BURROW_URL && !hasSigner}
|
||||
<Button {disabled} onclick={loginWithPassword} class="btn btn-primary">
|
||||
{#if loading === "password"}
|
||||
<span class="loading loading-spinner mr-3"></span>
|
||||
{:else}
|
||||
<Icon icon={Key} />
|
||||
{/if}
|
||||
Log in with Password
|
||||
{#if hasPomade && !hasSigner}
|
||||
<Button {disabled} onclick={loginWithEmail} class="btn btn-primary">
|
||||
<Icon icon={Letter} />
|
||||
Log in with Email
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
onclick={loginWithBunker}
|
||||
{disabled}
|
||||
class="btn {hasSigner || BURROW_URL ? 'btn-neutral' : 'btn-primary'}">
|
||||
class="btn {hasSigner || hasPomade ? 'btn-neutral' : 'btn-primary'}">
|
||||
<Icon icon={Cpu} />
|
||||
Log in with Remote Signer
|
||||
</Button>
|
||||
{#if BURROW_URL && hasSigner}
|
||||
<Button {disabled} onclick={loginWithPassword} class="btn">
|
||||
{#if loading === "password"}
|
||||
<span class="loading loading-spinner mr-3"></span>
|
||||
{:else}
|
||||
<Icon icon={Key} />
|
||||
{/if}
|
||||
Log in with Password
|
||||
{#if hasPomade && hasSigner}
|
||||
<Button {disabled} onclick={loginWithEmail} class="btn">
|
||||
<Icon icon={Letter} />
|
||||
Log in with Email
|
||||
</Button>
|
||||
{/if}
|
||||
{#if !hasSigner || !BURROW_URL}
|
||||
{#if !hasSigner}
|
||||
<Button {disabled} onclick={loginWithKey} class="btn btn-neutral">
|
||||
<Icon icon={Key} />
|
||||
Log in with Key
|
||||
</Button>
|
||||
{/if}
|
||||
{#if !hasSigner || !hasPomade}
|
||||
<Link
|
||||
external
|
||||
{disabled}
|
||||
href="https://nostrapps.com#signers"
|
||||
class="btn {hasSigner || BURROW_URL ? '' : 'btn-neutral'}">
|
||||
class="btn {hasSigner || hasPomade ? '' : 'btn-neutral'}">
|
||||
<Icon icon={Compass} />
|
||||
Browse Signer Apps
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import {onMount, onDestroy} from "svelte"
|
||||
import type {Nip46ResponseWithResult} from "@welshman/signer"
|
||||
import {Nip46Broker, makeSecret} from "@welshman/signer"
|
||||
import {Nip46Broker} from "@welshman/signer"
|
||||
import {makeSecret} from "@welshman/util"
|
||||
import {loginWithNip01, loginWithNip46} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import {Client} from "@pomade/core"
|
||||
import {loginWithPomade} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Letter from "@assets/icons/letter.svg?dataurl"
|
||||
import Key from "@assets/icons/key.svg?dataurl"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import LogInOTP from "@app/components/LogInOTP.svelte"
|
||||
import {pushModal, clearModals} from "@app/util/modal"
|
||||
import {setChecked} from "@app/util/notifications"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
interface Props {
|
||||
email?: string
|
||||
}
|
||||
|
||||
let {email = $bindable("")}: Props = $props()
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const loginWithOTP = () => pushModal(LogInOTP, {email})
|
||||
|
||||
const onSubmit = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const {ok, options, messages, clientSecret} = await Client.loginWithPassword(email, password)
|
||||
|
||||
if (!ok) {
|
||||
console.error(messages)
|
||||
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: "Sorry, we were unable to log you in.",
|
||||
})
|
||||
}
|
||||
|
||||
const [client, peers] = options[0]!
|
||||
const {clientOptions, ...res} = await Client.selectLogin(clientSecret, client, peers)
|
||||
|
||||
if (res.ok && clientOptions) {
|
||||
loginWithPomade(clientOptions.group.group_pk.slice(2), email, clientOptions)
|
||||
pushToast({message: "Successfully logged in!"})
|
||||
setChecked("*")
|
||||
clearModals()
|
||||
} else {
|
||||
console.error(res.messages)
|
||||
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: "Sorry, we were unable to log you in.",
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
let loading = $state(false)
|
||||
let password = $state("")
|
||||
</script>
|
||||
|
||||
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
<div>Log In</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Log in using your email and password</div>
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Email*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={Letter} />
|
||||
<input bind:value={email} />
|
||||
</label>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Password*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={Key} />
|
||||
<input type="password" bind:value={password} />
|
||||
</label>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<p class="text-sm">
|
||||
Forgot your password? <Button class="link" onclick={loginWithOTP}
|
||||
>Log in with a one-time access code</Button
|
||||
>.
|
||||
</p>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back} disabled={loading}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary" disabled={loading || !email || !password}>
|
||||
<Spinner {loading}>Log in</Spinner>
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import {bytesToHex} from "@welshman/lib"
|
||||
import {loginWithNip01} from "@welshman/app"
|
||||
import {decrypt} from "nostr-tools/nip49"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import {nsecDecode} from "@lib/util"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Key from "@assets/icons/key.svg?dataurl"
|
||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {clearModals} from "@app/util/modal"
|
||||
import {setChecked} from "@app/util/notifications"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
let loading = $state(false)
|
||||
let keyInput = $state("")
|
||||
let password = $state("")
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const isHex = $derived(keyInput.match(/^[0-9a-f]{64}$/i))
|
||||
|
||||
const isNsec = $derived(keyInput.startsWith("nsec1"))
|
||||
|
||||
const isNcryptsec = $derived(keyInput.startsWith("ncryptsec1"))
|
||||
|
||||
const canSubmit = $derived(!loading && (isHex || isNsec || isNcryptsec))
|
||||
|
||||
const onSubmit = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
let secret: string
|
||||
|
||||
if (isNcryptsec) {
|
||||
secret = bytesToHex(decrypt(keyInput, password))
|
||||
} else if (isNsec) {
|
||||
secret = nsecDecode(keyInput)
|
||||
} else if (isHex) {
|
||||
secret = keyInput.toLowerCase()
|
||||
} else {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: "Invalid key format. Please enter a hex key, nsec, or ncryptsec.",
|
||||
})
|
||||
}
|
||||
|
||||
loginWithNip01(secret)
|
||||
pushToast({message: "Successfully logged in!"})
|
||||
setChecked("*")
|
||||
clearModals()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: isNcryptsec
|
||||
? "Failed to decrypt key. Please check your password."
|
||||
: "Invalid key format. Please check your input.",
|
||||
})
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
<div>Log In with Key</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Enter your nostr private key to log in.</div>
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Your Key*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={Key} />
|
||||
<input type="password" bind:value={keyInput} placeholder="nsec1..." />
|
||||
</label>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
{#if isNcryptsec}
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Password*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={Key} />
|
||||
<input type="password" bind:value={password} placeholder="Your password" />
|
||||
</label>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
{/if}
|
||||
<div class="card2 card2-sm bg-alt flex flex-col gap-2 text-sm">
|
||||
<strong class="flex items-center gap-2">
|
||||
<Icon icon={Danger} />
|
||||
Please note!
|
||||
</strong>
|
||||
<p>
|
||||
Logging in this way is not a best practice. For better security, please consider using a
|
||||
<Link external href="https://nostrapps.com#signers" class="link">signer app</Link>
|
||||
to keep your keys safe.
|
||||
</p>
|
||||
</div>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back} disabled={loading}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary" disabled={!canSubmit}>
|
||||
<Spinner {loading}>Log in</Spinner>
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
+28
-28
@@ -1,24 +1,24 @@
|
||||
<script lang="ts">
|
||||
import {postJson, sleep} from "@welshman/lib"
|
||||
import {Client} from "@pomade/core"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Letter from "@assets/icons/letter.svg?dataurl"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import LogInPassword from "@app/components/LogInPassword.svelte"
|
||||
import LogInOTPConfirm from "@app/components/LogInOTPConfirm.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {BURROW_URL} from "@app/core/state"
|
||||
|
||||
interface Props {
|
||||
email: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
let {email = $bindable()}: Props = $props()
|
||||
let {email = $bindable("")}: Props = $props()
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
@@ -26,16 +26,15 @@
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const [res] = await Promise.all([
|
||||
postJson(BURROW_URL + "/user/request-reset", {email}),
|
||||
sleep(1000),
|
||||
])
|
||||
const {ok, peersByPrefix} = await Client.requestChallenge(email)
|
||||
|
||||
if (res.error) {
|
||||
pushToast({message: res.error, theme: "error"})
|
||||
if (ok) {
|
||||
pushModal(LogInOTPConfirm, {email, peersByPrefix})
|
||||
} else {
|
||||
pushToast({message: `Password reset email has been sent!`})
|
||||
pushModal(LogInPassword, {email}, {path: "/"})
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: "Sorry, we were unable to request a login code.",
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
@@ -48,30 +47,31 @@
|
||||
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
<div>Reset your password</div>
|
||||
<div>Log In</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Log in using a one-time login code</div>
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
<FieldInline disabled={loading}>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Email Address</p>
|
||||
<p>Email*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={UserRounded} />
|
||||
<input bind:value={email} class="grow" />
|
||||
<Icon icon={Letter} />
|
||||
<input bind:value={email} />
|
||||
</label>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<p>You'll be sent an email with a password reset link.</p>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Button class="btn btn-link" onclick={back} disabled={loading}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||
<Spinner {loading}>Request password reset link</Spinner>
|
||||
<Button type="submit" class="btn btn-primary" disabled={loading || !email}>
|
||||
<Spinner {loading}>Log in</Spinner>
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import {Client} from "@pomade/core"
|
||||
import {loginWithPomade} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {clearModals} from "@app/util/modal"
|
||||
import {setChecked} from "@app/util/notifications"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {POMADE_SIGNERS} from "@app/core/state"
|
||||
|
||||
type Props = {
|
||||
email: string
|
||||
peersByPrefix: Map<string, string>
|
||||
}
|
||||
|
||||
const {email, peersByPrefix}: Props = $props()
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const onSubmit = async () => {
|
||||
const otps = input
|
||||
.split(/\n/)
|
||||
.map(x => x.trim())
|
||||
.filter(x => x.match(/^[0-9]{8}$/))
|
||||
|
||||
if (otps.length < 2) {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: "Failed to recover, not enough valid recovery codes were provided.",
|
||||
})
|
||||
}
|
||||
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const {ok, options, messages, clientSecret} = await Client.loginWithChallenge(
|
||||
email,
|
||||
peersByPrefix,
|
||||
otps,
|
||||
)
|
||||
|
||||
if (!ok) {
|
||||
console.error(messages)
|
||||
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: "Sorry, we were unable to log you in.",
|
||||
})
|
||||
}
|
||||
|
||||
const [client, peers] = options[0]!
|
||||
const {clientOptions, ...res} = await Client.selectLogin(clientSecret, client, peers)
|
||||
|
||||
if (res.ok && clientOptions) {
|
||||
loginWithPomade(clientOptions.group.group_pk.slice(2), email, clientOptions)
|
||||
pushToast({message: "Successfully logged in!"})
|
||||
setChecked("*")
|
||||
clearModals()
|
||||
} else {
|
||||
console.error(res.messages)
|
||||
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: "Sorry, we were unable to log you in.",
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
let input = $state("")
|
||||
let loading = $state(false)
|
||||
</script>
|
||||
|
||||
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
<div>Log In</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Enter the login codes sent to your email</div>
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
<p>Your login codes have been sent!</p>
|
||||
<p>
|
||||
For security reasons, you may receive three or more emails with login codes in them. Please
|
||||
paste <strong>all</strong> login codes into the text box below, on separate lines.
|
||||
</p>
|
||||
<textarea
|
||||
rows={POMADE_SIGNERS.length + 1}
|
||||
class="textarea textarea-bordered leading-4"
|
||||
bind:value={input}></textarea>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back} disabled={loading}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||
<Spinner {loading}>Log In</Spinner>
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
@@ -1,156 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {onMount, onDestroy} from "svelte"
|
||||
import {postJson, stripProtocol} from "@welshman/lib"
|
||||
import {Nip46Broker, makeSecret} from "@welshman/signer"
|
||||
import {normalizeRelayUrl} from "@welshman/util"
|
||||
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"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import PasswordResetRequest from "@app/components/PasswordResetRequest.svelte"
|
||||
import {clearModals, pushModal} from "@app/util/modal"
|
||||
import {setChecked} from "@app/util/notifications"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {
|
||||
NIP46_PERMS,
|
||||
BURROW_URL,
|
||||
PLATFORM_URL,
|
||||
PLATFORM_NAME,
|
||||
PLATFORM_LOGO,
|
||||
} from "@app/core/state"
|
||||
|
||||
interface Props {
|
||||
email?: string
|
||||
}
|
||||
|
||||
let {email = $bindable("")}: Props = $props()
|
||||
|
||||
const clientSecret = makeSecret()
|
||||
|
||||
const startReset = () => pushModal(PasswordResetRequest, {email})
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
const relays = BURROW_URL.startsWith("http://")
|
||||
? [normalizeRelayUrl("ws://" + stripProtocol(BURROW_URL))]
|
||||
: [normalizeRelayUrl(BURROW_URL)]
|
||||
|
||||
const broker = new Nip46Broker({clientSecret, relays})
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const onSubmit = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const res = await postJson(BURROW_URL + "/session", {email, password, nostrconnect: url})
|
||||
|
||||
if (res.error) {
|
||||
pushToast({message: res.error, theme: "error"})
|
||||
loading = false
|
||||
}
|
||||
} catch (e) {
|
||||
pushToast({message: "Something went wrong, please try again!", theme: "error"})
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
let url = ""
|
||||
let password = $state("")
|
||||
let loading = $state(false)
|
||||
|
||||
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.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) {
|
||||
loading = true
|
||||
|
||||
const pubkey = await broker.getPublicKey()
|
||||
const session = makeNip46Session(pubkey, clientSecret, response.event.pubkey, relays)
|
||||
|
||||
addSession({...session, email})
|
||||
broker.cleanup()
|
||||
setChecked("*")
|
||||
clearModals()
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
abortController.abort()
|
||||
})
|
||||
</script>
|
||||
|
||||
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
<div>Log In</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Log in using your email and password</div>
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Email</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={UserRounded} />
|
||||
<input bind:value={email} />
|
||||
</label>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Password</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={Key} />
|
||||
<input bind:value={password} type="password" />
|
||||
</label>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<p class="text-sm">
|
||||
Your email and password only work to log in to {PLATFORM_NAME}. To use your key on other nostr
|
||||
applications, visit your settings page. <Button class="link" onclick={startReset}
|
||||
>Forgot your password?</Button>
|
||||
</p>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back} disabled={loading}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary" disabled={loading || !email || !password}>
|
||||
<Spinner {loading}>Next</Spinner>
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
@@ -17,6 +17,7 @@
|
||||
await logout()
|
||||
window.location.href = "/"
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,24 @@
|
||||
<script lang="ts">
|
||||
import Lock from "@assets/icons/lock-keyhole.svg?dataurl"
|
||||
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
||||
import ChannelName from "@app/components/ChannelName.svelte"
|
||||
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
|
||||
import {makeRoomPath} from "@app/util/routes"
|
||||
import {deriveChannel} from "@app/core/state"
|
||||
import {notifications} from "@app/util/notifications"
|
||||
|
||||
interface Props {
|
||||
url: any
|
||||
room: any
|
||||
h: any
|
||||
notify?: boolean
|
||||
replaceState?: boolean
|
||||
}
|
||||
|
||||
const {url, room, notify = false, replaceState = false}: Props = $props()
|
||||
const {url, h, notify = false, replaceState = false}: Props = $props()
|
||||
|
||||
const path = makeRoomPath(url, room)
|
||||
const channel = deriveChannel(url, room)
|
||||
const path = makeRoomPath(url, h)
|
||||
</script>
|
||||
|
||||
<SecondaryNavItem
|
||||
href={path}
|
||||
{replaceState}
|
||||
notification={notify ? $notifications.has(path) : false}>
|
||||
{#if $channel?.picture}
|
||||
{@const src = $channel.picture}
|
||||
{#if src.match("\.(png|svg)$") || src.match("image/(png|svg)")}
|
||||
<Icon icon={src} />
|
||||
{:else}
|
||||
<img alt="Room icon" {src} class="h-6 w-6 rounded-lg" />
|
||||
{/if}
|
||||
{:else if $channel?.closed || $channel?.private}
|
||||
<Icon icon={Lock} />
|
||||
{:else}
|
||||
<Icon icon={Hashtag} />
|
||||
{/if}
|
||||
<div class="min-w-0 overflow-hidden text-ellipsis">
|
||||
<ChannelName {url} {room} />
|
||||
</div>
|
||||
<RoomNameWithImage {url} {h} />
|
||||
</SecondaryNavItem>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Compass from "@assets/icons/compass.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import CardButton from "@lib/components/CardButton.svelte"
|
||||
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
|
||||
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
|
||||
</script>
|
||||
|
||||
<div class="column menu gap-2">
|
||||
{#each PLATFORM_RELAYS as url (url)}
|
||||
<MenuSpacesItem {url} />
|
||||
{:else}
|
||||
{#if $userSpaceUrls.length > 0}
|
||||
{#each $userSpaceUrls as url (url)}
|
||||
<MenuSpacesItem {url} />
|
||||
{/each}
|
||||
<Divider />
|
||||
{/if}
|
||||
<Link href="/discover">
|
||||
<CardButton class="btn-neutral">
|
||||
{#snippet icon()}
|
||||
<div><Icon icon={Compass} size={7} /></div>
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div>Explore Spaces</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Join create, or browse spaces</div>
|
||||
{/snippet}
|
||||
</CardButton>
|
||||
</Link>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import CardButton from "@lib/components/CardButton.svelte"
|
||||
import SpaceAvatar from "@app/components/SpaceAvatar.svelte"
|
||||
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import RelayDescription from "@app/components/RelayDescription.svelte"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
@@ -13,9 +13,9 @@
|
||||
</script>
|
||||
|
||||
<Link replaceState href={path}>
|
||||
<CardButton class="btn-neutral">
|
||||
<CardButton class="btn-neutral shadow-md">
|
||||
{#snippet icon()}
|
||||
<div><SpaceAvatar {url} /></div>
|
||||
<RelayIcon {url} size={12} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div class="flex gap-1">
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
import Dialog from "@lib/components/Dialog.svelte"
|
||||
import {modal, clearModals} from "@app/util/modal"
|
||||
|
||||
const closeModals = () => {
|
||||
if ($modal && !$modal.options.noEscape) {
|
||||
clearModals()
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (e: any) => {
|
||||
if (e.code === "Escape" && e.target === document.body) {
|
||||
clearModals()
|
||||
closeModals()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +33,8 @@
|
||||
instance = mount(wrapper as any, {
|
||||
target: element,
|
||||
props: {
|
||||
onClose: clearModals,
|
||||
onClose: closeModals,
|
||||
fullscreen: options.fullscreen,
|
||||
children: createRawSnippet(() => ({
|
||||
render: () => "<div></div>",
|
||||
setup: (target: Element) => {
|
||||
|
||||
@@ -23,16 +23,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($notifications.size > notificationCount) {
|
||||
playSound()
|
||||
}
|
||||
|
||||
notificationCount = $notifications.size
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
audioElement.load()
|
||||
|
||||
notifications.subscribe(notifications => {
|
||||
if (notifications.size > notificationCount) {
|
||||
playSound()
|
||||
}
|
||||
|
||||
notificationCount = notifications.size
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import type {Snippet} from "svelte"
|
||||
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 {Router} from "@welshman/router"
|
||||
import {userMutes} from "@welshman/app"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import {userMuteList} from "@welshman/app"
|
||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Profile from "@app/components/Profile.svelte"
|
||||
import ProfileName from "@app/components/ProfileName.svelte"
|
||||
import {entityLink} from "@app/core/state"
|
||||
import {goToEvent} from "@app/util/routes"
|
||||
|
||||
const {
|
||||
event,
|
||||
@@ -31,14 +28,11 @@
|
||||
class?: string
|
||||
} = $props()
|
||||
|
||||
const relays = Router.get().Event(event).getUrls()
|
||||
const nevent = nip19.neventEncode({id: event.id, relays})
|
||||
|
||||
const ignoreMute = () => {
|
||||
muted = false
|
||||
}
|
||||
|
||||
let muted = $state(getPubkeyTagValues(getListTags($userMutes)).includes(event.pubkey))
|
||||
let muted = $state(getPubkeyTagValues(getListTags($userMuteList)).includes(event.pubkey))
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 {restProps.class}">
|
||||
@@ -59,12 +53,11 @@
|
||||
<Profile pubkey={event.pubkey} {url} />
|
||||
{/if}
|
||||
{/if}
|
||||
<Link
|
||||
external
|
||||
href={entityLink(nevent)}
|
||||
class={cx("text-sm opacity-75", {"text-xs": minimal})}>
|
||||
<Button
|
||||
class={cx("text-sm opacity-75", {"text-xs": minimal})}
|
||||
onclick={() => goToEvent(event)}>
|
||||
{formatTimestamp(event.created_at)}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{@render children()}
|
||||
{/if}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import {sum} from "@welshman/lib"
|
||||
import type {Zap, TrustedEvent} from "@welshman/util"
|
||||
import {getTagValue, fromMsats, ZAP_RESPONSE} from "@welshman/util"
|
||||
import {deriveEventsMapped} from "@welshman/store"
|
||||
import {deriveItemsByKey, deriveArray} from "@welshman/store"
|
||||
import {repository, getValidZap} from "@welshman/app"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
@@ -14,11 +14,14 @@
|
||||
const content = getTagValue("summary", props.event.tags)
|
||||
const fakeEvent = {content, tags: props.event.tags}
|
||||
|
||||
const zaps = deriveEventsMapped<Zap>(repository, {
|
||||
filters: [{kinds: [ZAP_RESPONSE], "#e": [props.event.id]}],
|
||||
itemToEvent: item => item.response,
|
||||
eventToItem: (response: TrustedEvent) => getValidZap(response, props.event),
|
||||
})
|
||||
const zaps = deriveArray(
|
||||
deriveItemsByKey<Zap>({
|
||||
repository,
|
||||
getKey: zap => zap.response.id,
|
||||
filters: [{kinds: [ZAP_RESPONSE], "#e": [props.event.id]}],
|
||||
eventToItem: (response: TrustedEvent) => getValidZap(response, props.event),
|
||||
}),
|
||||
)
|
||||
|
||||
const goalAmount = parseInt(getTagValue("amount", props.event.tags) || "0")
|
||||
const zapAmount = $derived(fromMsats(sum($zaps.map(zap => zap.invoiceAmount))))
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {postJson, sleep} from "@welshman/lib"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import LogInPassword from "@app/components/LogInPassword.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {BURROW_URL} from "@app/core/state"
|
||||
|
||||
const {email, reset_token} = $props()
|
||||
|
||||
const onSubmit = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const [res] = await Promise.all([
|
||||
postJson(BURROW_URL + "/user/confirm-reset", {email, password, reset_token}),
|
||||
sleep(1000),
|
||||
])
|
||||
|
||||
if (res.error) {
|
||||
pushToast({message: res.error, theme: "error"})
|
||||
} else {
|
||||
pushToast({message: "Password reset successfully!"})
|
||||
pushModal(LogInPassword, {email}, {path: "/"})
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
let loading = $state(false)
|
||||
let password = $state("")
|
||||
</script>
|
||||
|
||||
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
<div>Reset your password</div>
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
<FieldInline disabled={loading}>
|
||||
{#snippet label()}
|
||||
<p>Email Address</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={UserRounded} />
|
||||
<input readonly value={email} class="grow" />
|
||||
</label>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<FieldInline disabled={loading}>
|
||||
{#snippet label()}
|
||||
<p>New Password</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={Key} />
|
||||
<input bind:value={password} class="grow" type="password" />
|
||||
</label>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||
<Spinner {loading}>Reset password</Spinner>
|
||||
</Button>
|
||||
</form>
|
||||
@@ -18,7 +18,7 @@
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
|
||||
</script>
|
||||
|
||||
<div class="card2 bg-alt flex flex-col gap-4 shadow-xl">
|
||||
<div class="card2 bg-alt flex flex-col gap-4 shadow-md">
|
||||
<div class="flex justify-between">
|
||||
<Profile {pubkey} {url} />
|
||||
<Button onclick={openProfile} class="btn btn-primary hidden sm:flex">
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import {Client} from "@pomade/core"
|
||||
import type {SessionItem} from "@pomade/core"
|
||||
import {session, isPomadeSession} from "@welshman/app"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {onMount} from "svelte"
|
||||
|
||||
type SessionWithPeers = SessionItem & {peers: string[]}
|
||||
|
||||
let sessions = $state<SessionWithPeers[]>([])
|
||||
let deletingSession = $state<string | null>(null)
|
||||
|
||||
const loadSessions = async () => {
|
||||
if (!isPomadeSession($session)) return
|
||||
|
||||
const client = new Client($session.clientOptions)
|
||||
|
||||
try {
|
||||
const result = await client.listSessions()
|
||||
const pubkey = await client.getPubkey()
|
||||
|
||||
if (result.ok) {
|
||||
// Group sessions by client pubkey and collect peers
|
||||
const sessionMap = new Map<string, SessionWithPeers>()
|
||||
|
||||
for (const message of result.messages) {
|
||||
if (!message?.payload.items) continue
|
||||
|
||||
const peer = message.event.pubkey
|
||||
|
||||
for (const item of message.payload.items) {
|
||||
const existing = sessionMap.get(item.client)
|
||||
|
||||
if (existing) {
|
||||
existing.peers.push(peer)
|
||||
} else if (item.client !== pubkey) {
|
||||
sessionMap.set(item.client, {...item, peers: [peer]})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessions = Array.from(sessionMap.values())
|
||||
}
|
||||
} finally {
|
||||
client.stop()
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSession = async (sessionItem: SessionWithPeers) => {
|
||||
if (!isPomadeSession($session)) return
|
||||
|
||||
deletingSession = sessionItem.client
|
||||
|
||||
try {
|
||||
const client = new Client($session.clientOptions)
|
||||
const result = await client.deleteSession(sessionItem.client, sessionItem.peers)
|
||||
|
||||
if (result.ok) {
|
||||
pushToast({
|
||||
message: "Session deleted successfully",
|
||||
})
|
||||
|
||||
// Remove from local list
|
||||
sessions = sessions.filter(s => s.client !== sessionItem.client)
|
||||
} else {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: "Failed to delete session",
|
||||
})
|
||||
}
|
||||
|
||||
client.stop()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: "Failed to delete session",
|
||||
})
|
||||
} finally {
|
||||
deletingSession = null
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000)
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadSessions()
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if sessions.length > 0}
|
||||
<div class="flex flex-col gap-4 border-t border-solid border-base-100 pt-4">
|
||||
<strong>Other Sessions</strong>
|
||||
{#each sessions as sessionItem (sessionItem.client)}
|
||||
<div class="flex justify-between text-sm">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span>{sessionItem.client.slice(0, 8)}</span>
|
||||
<div class="flex gap-1">
|
||||
<div class="badge badge-neutral">
|
||||
Created {formatDate(sessionItem.created_at)}
|
||||
</div>
|
||||
<div class="badge badge-neutral">
|
||||
Last active: {formatDate(sessionItem.last_activity)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="btn btn-error btn-sm"
|
||||
disabled={deletingSession !== null}
|
||||
onclick={() => deleteSession(sessionItem)}>
|
||||
{#if deletingSession === sessionItem.client}
|
||||
<span class="loading loading-spinner"></span>
|
||||
{:else}
|
||||
<Icon icon={TrashBin2} />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -4,12 +4,18 @@
|
||||
import {goto} from "$app/navigation"
|
||||
import {splitAt} from "@welshman/lib"
|
||||
import {userProfile, shouldUnwrap} from "@welshman/app"
|
||||
import Avatar from "@lib/components/Avatar.svelte"
|
||||
import Widget from "@assets/icons/widget.svg?dataurl"
|
||||
import Compass from "@assets/icons/compass.svg?dataurl"
|
||||
import Letter from "@assets/icons/letter.svg?dataurl"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
import HomeSmile from "@assets/icons/home-smile.svg?dataurl"
|
||||
import SettingsMinimalistic from "@assets/icons/settings-minimalistic.svg?dataurl"
|
||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||
import Settings from "@assets/icons/settings.svg?dataurl"
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
||||
import ChatEnable from "@app/components/ChatEnable.svelte"
|
||||
import MenuSpaces from "@app/components/MenuSpaces.svelte"
|
||||
import MenuOtherSpaces from "@app/components/MenuOtherSpaces.svelte"
|
||||
import MenuSettings from "@app/components/MenuSettings.svelte"
|
||||
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
|
||||
@@ -17,13 +23,6 @@
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
import {notifications} from "@app/util/notifications"
|
||||
import Widget from "@assets/icons/widget.svg?dataurl"
|
||||
import Compass from "@assets/icons/compass.svg?dataurl"
|
||||
import Letter from "@assets/icons/letter.svg?dataurl"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
import HomeSmile from "@assets/icons/home-smile.svg?dataurl"
|
||||
import SettingsMinimalistic from "@assets/icons/settings-minimalistic.svg?dataurl"
|
||||
import Settings from "@assets/icons/settings.svg?dataurl"
|
||||
|
||||
type Props = {
|
||||
children?: Snippet
|
||||
@@ -31,9 +30,6 @@
|
||||
|
||||
const {children}: Props = $props()
|
||||
|
||||
const showSpacesMenu = () =>
|
||||
$userSpaceUrls.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd)
|
||||
|
||||
const showOtherSpacesMenu = () => pushModal(MenuOtherSpaces, {urls: secondarySpaceUrls})
|
||||
|
||||
const showSettingsMenu = () => pushModal(MenuSettings)
|
||||
@@ -66,7 +62,7 @@
|
||||
<PrimaryNavItemSpace {url} />
|
||||
{:else}
|
||||
<PrimaryNavItem title="Home" href="/home" class="tooltip-right">
|
||||
<Avatar src={PLATFORM_LOGO} class="!h-10 !w-10" />
|
||||
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
|
||||
</PrimaryNavItem>
|
||||
<Divider />
|
||||
{#each primarySpaceUrls as url (url)}
|
||||
@@ -78,11 +74,11 @@
|
||||
class="tooltip-right"
|
||||
onclick={showOtherSpacesMenu}
|
||||
notification={otherSpaceNotifications}>
|
||||
<Avatar icon={Widget} class="!h-10 !w-10" />
|
||||
<ImageIcon alt="Other Spaces" src={Widget} size={8} />
|
||||
</PrimaryNavItem>
|
||||
{/if}
|
||||
<PrimaryNavItem title="Add a Space" href="/discover" class="tooltip-right">
|
||||
<Avatar icon={Compass} class="!h-10 !w-10" />
|
||||
<ImageIcon alt="Add a Space" src={Compass} size={8} />
|
||||
</PrimaryNavItem>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -95,17 +91,21 @@
|
||||
href="/settings/profile"
|
||||
prefix="/settings"
|
||||
class="tooltip-right">
|
||||
<Avatar src={$userProfile?.picture} class="!h-10 !w-10" />
|
||||
{#if $userProfile?.picture}
|
||||
<ImageIcon alt="Settings" src={$userProfile?.picture} class="rounded-full" size={10} />
|
||||
{:else}
|
||||
<ImageIcon alt="Settings" src={UserRounded} class="rounded-full" size={8} />
|
||||
{/if}
|
||||
</PrimaryNavItem>
|
||||
<PrimaryNavItem
|
||||
title="Messages"
|
||||
onclick={openChat}
|
||||
class="tooltip-right"
|
||||
notification={$notifications.has("/chat")}>
|
||||
<Avatar icon={Letter} class="!h-10 !w-10" />
|
||||
<ImageIcon alt="Messages" src={Letter} size={8} />
|
||||
</PrimaryNavItem>
|
||||
<PrimaryNavItem title="Search" href="/people" class="tooltip-right">
|
||||
<Avatar icon={Magnifier} class="!h-10 !w-10" />
|
||||
<ImageIcon alt="Search" src={Magnifier} size={8} />
|
||||
</PrimaryNavItem>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,31 +114,34 @@
|
||||
{@render children?.()}
|
||||
|
||||
<!-- a little extra something for ios -->
|
||||
<div class="fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden"></div>
|
||||
<div
|
||||
class="border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
|
||||
class="bottom-nav hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden">
|
||||
</div>
|
||||
<div
|
||||
class="bottom-nav hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
|
||||
<div class="content-padding-x content-sizing flex justify-between px-2">
|
||||
<div class="flex gap-2 sm:gap-8">
|
||||
<div class="flex gap-2 sm:gap-6">
|
||||
<PrimaryNavItem title="Home" href="/home">
|
||||
<Avatar icon={HomeSmile} class="!h-10 !w-10" />
|
||||
<ImageIcon alt="Home" src={HomeSmile} size={8} />
|
||||
</PrimaryNavItem>
|
||||
<PrimaryNavItem
|
||||
title="Messages"
|
||||
onclick={openChat}
|
||||
notification={$notifications.has("/chat")}>
|
||||
<Avatar icon={Letter} class="!h-10 !w-10" />
|
||||
<ImageIcon alt="Messages" src={Letter} size={8} />
|
||||
</PrimaryNavItem>
|
||||
{#if PLATFORM_RELAYS.length !== 1}
|
||||
<PrimaryNavItem
|
||||
title="Spaces"
|
||||
onclick={showSpacesMenu}
|
||||
notification={anySpaceNotifications}>
|
||||
<Avatar icon={SettingsMinimalistic} class="!h-10 !w-10" />
|
||||
<PrimaryNavItem title="Spaces" href="/spaces" notification={anySpaceNotifications}>
|
||||
<ImageIcon alt="Spaces" src={SettingsMinimalistic} size={8} />
|
||||
</PrimaryNavItem>
|
||||
{/if}
|
||||
</div>
|
||||
<PrimaryNavItem title="Settings" onclick={showSettingsMenu}>
|
||||
<Avatar icon={Settings} src={$userProfile?.picture} class="!h-10 !w-10" />
|
||||
{#if $userProfile?.picture}
|
||||
<ImageIcon alt="Settings" src={$userProfile?.picture} size={10} class="rounded-full" />
|
||||
{:else}
|
||||
<ImageIcon alt="Settings" src={Settings} size={8} class="rounded-full" />
|
||||
{/if}
|
||||
</PrimaryNavItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
<script lang="ts">
|
||||
import {goto} from "$app/navigation"
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||
import SpaceAvatar from "@app/components/SpaceAvatar.svelte"
|
||||
import {encodeRelay} from "@app/core/state"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
import {lastPageBySpaceUrl} from "@app/util/history"
|
||||
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||
import {makeSpacePath, goToSpace} from "@app/util/routes"
|
||||
import {notifications} from "@app/util/notifications"
|
||||
|
||||
const {url} = $props()
|
||||
type Props = {
|
||||
url: string
|
||||
}
|
||||
|
||||
const path = makeSpacePath(url)
|
||||
const {url}: Props = $props()
|
||||
|
||||
const onClick = () => goto(lastPageBySpaceUrl.get(encodeRelay(url)) || path)
|
||||
const onClick = () => goToSpace(url)
|
||||
</script>
|
||||
|
||||
<PrimaryNavItem
|
||||
onclick={onClick}
|
||||
title={displayRelayUrl(url)}
|
||||
class="tooltip-right"
|
||||
notification={$notifications.has(path)}>
|
||||
<SpaceAvatar {url} />
|
||||
notification={$notifications.has(makeSpacePath(url))}>
|
||||
<RelayIcon {url} size={10} class="rounded-full" />
|
||||
</PrimaryNavItem>
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
<script lang="ts">
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {removeNil} from "@welshman/lib"
|
||||
import {removeUndefined} from "@welshman/lib"
|
||||
import {displayPubkey} from "@welshman/util"
|
||||
import {
|
||||
deriveHandleForPubkey,
|
||||
displayHandle,
|
||||
deriveProfile,
|
||||
deriveProfileDisplay,
|
||||
} from "@welshman/app"
|
||||
import {deriveHandleForPubkey, displayHandle, deriveProfileDisplay} from "@welshman/app"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Avatar from "@lib/components/Avatar.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import WotScore from "@app/components/WotScore.svelte"
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
@@ -26,8 +21,7 @@
|
||||
|
||||
const {pubkey, url, showPubkey, avatarSize = 10}: Props = $props()
|
||||
|
||||
const relays = removeNil([url])
|
||||
const profile = deriveProfile(pubkey, relays)
|
||||
const relays = removeUndefined([url])
|
||||
const profileDisplay = deriveProfileDisplay(pubkey, relays)
|
||||
const handle = deriveHandleForPubkey(pubkey)
|
||||
|
||||
@@ -38,7 +32,7 @@
|
||||
|
||||
<div class="flex max-w-full items-start gap-3">
|
||||
<Button onclick={openProfile} class="py-1">
|
||||
<Avatar src={$profile?.picture} size={avatarSize} />
|
||||
<ProfileCircle {pubkey} size={avatarSize} />
|
||||
</Button>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -3,17 +3,13 @@
|
||||
import {load} from "@welshman/net"
|
||||
import {Router} from "@welshman/router"
|
||||
import type {Filter} from "@welshman/util"
|
||||
import {deriveEvents} from "@welshman/store"
|
||||
import {deriveArray, deriveEventsById} from "@welshman/store"
|
||||
import {formatTimestampRelative} from "@welshman/lib"
|
||||
import {NOTE, ROOMS, COMMENT} from "@welshman/util"
|
||||
import {repository, loadRelaySelections} from "@welshman/app"
|
||||
import {repository, loadRelayList} from "@welshman/app"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ProfileSpaces from "@app/components/ProfileSpaces.svelte"
|
||||
import {
|
||||
deriveGroupSelections,
|
||||
getSpaceUrlsFromGroupSelections,
|
||||
MESSAGE_KINDS,
|
||||
} from "@app/core/state"
|
||||
import {deriveGroupList, getSpaceUrlsFromGroupList, MESSAGE_KINDS} from "@app/core/state"
|
||||
import {goToEvent} from "@app/util/routes"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
|
||||
@@ -24,9 +20,9 @@
|
||||
|
||||
const {pubkey, url}: Props = $props()
|
||||
const filters: Filter[] = [{authors: [pubkey], limit: 1}]
|
||||
const events = deriveEvents(repository, {filters})
|
||||
const selections = deriveGroupSelections(pubkey)
|
||||
const spaceUrls = $derived(getSpaceUrlsFromGroupSelections($selections))
|
||||
const events = deriveArray(deriveEventsById({repository, filters}))
|
||||
const groupList = deriveGroupList(pubkey)
|
||||
const spaceUrls = $derived(getSpaceUrlsFromGroupList($groupList))
|
||||
|
||||
const viewEvent = () => goToEvent($events[0]!)
|
||||
|
||||
@@ -34,7 +30,7 @@
|
||||
|
||||
onMount(async () => {
|
||||
// Make sure we have their relay selections before we load their posts
|
||||
await loadRelaySelections(pubkey)
|
||||
await loadRelayList(pubkey)
|
||||
|
||||
// Load groups and at least one note, regardless of time frame
|
||||
load({
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
<script lang="ts">
|
||||
import Avatar from "@lib/components/Avatar.svelte"
|
||||
import {removeNil} from "@welshman/lib"
|
||||
import cx from "classnames"
|
||||
import {removeUndefined} from "@welshman/lib"
|
||||
import {deriveProfile} from "@welshman/app"
|
||||
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
class?: string
|
||||
size?: number
|
||||
url?: string
|
||||
} & Record<string, any>
|
||||
}
|
||||
|
||||
const {pubkey, url, ...props}: Props = $props()
|
||||
const {pubkey, url, size = 7, ...props}: Props = $props()
|
||||
|
||||
const profile = deriveProfile(pubkey, removeNil([url]))
|
||||
const profile = deriveProfile(pubkey, removeUndefined([url]))
|
||||
</script>
|
||||
|
||||
<Avatar src={$profile?.picture} icon={UserCircle} {...props} />
|
||||
<ImageIcon
|
||||
{size}
|
||||
alt=""
|
||||
class={cx(props.class, "rounded-full")}
|
||||
src={$profile?.picture || UserRounded} />
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
<script lang="ts">
|
||||
import {getProfile, loadProfile} from "@welshman/app"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
|
||||
const {...props} = $props()
|
||||
type Props = {
|
||||
pubkeys: string[]
|
||||
size?: number
|
||||
}
|
||||
|
||||
const {pubkeys, size = 7}: Props = $props()
|
||||
|
||||
for (const pubkey of pubkeys) {
|
||||
loadProfile(pubkey)
|
||||
}
|
||||
|
||||
const visiblePubkeys = $derived.by(() => {
|
||||
const filtered = pubkeys.filter(pubkey => getProfile(pubkey)?.picture)
|
||||
|
||||
return filtered.length > 0 ? filtered : pubkeys.slice(0, 1)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex pr-3">
|
||||
{#each props.pubkeys.toSorted().slice(0, 15) as pubkey (pubkey)}
|
||||
<div class="z-feature -mr-3 inline-block">
|
||||
<ProfileCircle class="h-8 w-8 bg-base-300" {pubkey} {...props} />
|
||||
{#each visiblePubkeys.toSorted().slice(0, 15) as pubkey (pubkey)}
|
||||
<div
|
||||
class="z-feature -mr-3 inline-block flex h-8 w-8 items-center justify-center rounded-full bg-base-100">
|
||||
<ProfileCircle class="h-8 w-8 bg-base-300" {pubkey} {size} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
DELETE,
|
||||
isReplaceable,
|
||||
getAddress,
|
||||
RelayMode,
|
||||
} from "@welshman/util"
|
||||
import {pubkey, publishThunk, repository} from "@welshman/app"
|
||||
import {pubkey, publishThunk, repository, derivePubkeyRelays} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
@@ -19,12 +20,13 @@
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {logout} from "@app/core/commands"
|
||||
import {INDEXER_RELAYS, PLATFORM_NAME, userSpaceUrls, userWriteRelays} from "@app/core/state"
|
||||
import {INDEXER_RELAYS, PLATFORM_NAME, userSpaceUrls} from "@app/core/state"
|
||||
|
||||
let progress: number | undefined = $state(undefined)
|
||||
let confirmText = $state("")
|
||||
|
||||
const CONFIRM_TEXT = "permanently delete my nostr account"
|
||||
const userWriteRelays = derivePubkeyRelays($pubkey!, RelayMode.Write)
|
||||
const confirmOk = $derived(confirmText.toLowerCase().trim() === CONFIRM_TEXT)
|
||||
const showProgress = $derived(progress !== undefined)
|
||||
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
<script lang="ts">
|
||||
import {goto} from "$app/navigation"
|
||||
import {shouldUnwrap} from "@welshman/app"
|
||||
import {removeUndefined} from "@welshman/lib"
|
||||
import {ManagementMethod} from "@welshman/util"
|
||||
import {shouldUnwrap, manageRelay, deriveProfile, displayProfileByPubkey} from "@welshman/app"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
import Letter from "@assets/icons/letter-opened.svg?dataurl"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||
import {fly} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Avatar from "@lib/components/Avatar.svelte"
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Popover from "@lib/components/Popover.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import Profile from "@app/components/Profile.svelte"
|
||||
import ProfileInfo from "@app/components/ProfileInfo.svelte"
|
||||
import EventInfo from "@app/components/EventInfo.svelte"
|
||||
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
||||
import ChatEnable from "@app/components/ChatEnable.svelte"
|
||||
import {pubkeyLink} from "@app/core/state"
|
||||
import {pubkeyLink, deriveUserIsSpaceAdmin} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {makeChatPath} from "@app/util/routes"
|
||||
|
||||
export type Props = {
|
||||
@@ -23,15 +33,83 @@
|
||||
|
||||
const {pubkey, url}: Props = $props()
|
||||
|
||||
const profile = deriveProfile(pubkey, removeUndefined([url]))
|
||||
|
||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const chatPath = makeChatPath([pubkey])
|
||||
|
||||
const showInfo = () => pushModal(EventInfo, {url, event: $profile!.event})
|
||||
|
||||
const openChat = () => ($shouldUnwrap ? goto(chatPath) : pushModal(ChatEnable, {next: chatPath}))
|
||||
|
||||
const toggleMenu = (pubkey: string) => {
|
||||
showMenu = !showMenu
|
||||
}
|
||||
|
||||
const closeMenu = () => {
|
||||
showMenu = false
|
||||
}
|
||||
|
||||
const banMember = () =>
|
||||
pushModal(Confirm, {
|
||||
title: "Ban User",
|
||||
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
|
||||
confirm: async () => {
|
||||
const {error} = await manageRelay(url!, {
|
||||
method: ManagementMethod.BanPubkey,
|
||||
params: [pubkey],
|
||||
})
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: "User has successfully been banned!"})
|
||||
back()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
let showMenu = $state(false)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<Profile showPubkey avatarSize={14} {pubkey} {url} />
|
||||
<div class="flex justify-between">
|
||||
<Profile showPubkey avatarSize={14} {pubkey} {url} />
|
||||
{#if $profile || $userIsAdmin}
|
||||
<div class="relative">
|
||||
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
|
||||
<Icon icon={MenuDots} />
|
||||
</Button>
|
||||
{#if showMenu}
|
||||
<Popover hideOnClick onClose={closeMenu}>
|
||||
<ul
|
||||
transition:fly
|
||||
class="bg-alt menu absolute right-0 z-popover w-48 gap-1 rounded-box p-2 shadow-md">
|
||||
{#if $profile}
|
||||
<li>
|
||||
<Button onclick={showInfo}>
|
||||
<Icon icon={Code2} />
|
||||
User Details
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if $userIsAdmin}
|
||||
<li>
|
||||
<Button class="text-error" onclick={banMember}>
|
||||
<Icon icon={MinusCircle} />
|
||||
Ban User
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<ProfileInfo {pubkey} {url} />
|
||||
<ProfileBadges {pubkey} {url} />
|
||||
<ModalFooter>
|
||||
@@ -41,7 +119,7 @@
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Link external href={pubkeyLink(pubkey)} class="btn btn-neutral">
|
||||
<Avatar src="/coracle.png" />
|
||||
<ImageIcon alt="" src="/coracle.png" />
|
||||
Open in Coracle
|
||||
</Link>
|
||||
<Button onclick={openChat} class="btn btn-primary">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import {clearModals} from "@app/util/modal"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {PROTECTED} from "@app/core/state"
|
||||
import {updateProfile} from "../core/commands"
|
||||
import {updateProfile} from "@app/core/commands"
|
||||
|
||||
const profile = $profilesByPubkey.get($pubkey!) || makeProfile()
|
||||
const shouldBroadcast = !getTag(PROTECTED, profile.event?.tags || [])
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {postJson} from "@welshman/lib"
|
||||
import {session} from "@welshman/app"
|
||||
import {slideAndFade} from "@lib/transition"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {PLATFORM_NAME, BURROW_URL} from "@app/core/state"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {logout} from "@app/core/commands"
|
||||
|
||||
const email = $session?.email
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const confirm = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const payload = {email, password, eject: true}
|
||||
const res = await postJson(BURROW_URL + "/user", payload, {method: "delete"})
|
||||
|
||||
if (res.error) {
|
||||
return pushToast({message: res.error, theme: "error"})
|
||||
}
|
||||
|
||||
success = true
|
||||
pushToast({message: "Success! Please check your inbox and continue when you're ready."})
|
||||
|
||||
await logout()
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const reload = () => {
|
||||
loading = true
|
||||
window.location.href = "/"
|
||||
}
|
||||
|
||||
let password = $state("")
|
||||
let success = $state(false)
|
||||
let loading = $state(false)
|
||||
</script>
|
||||
|
||||
<div class="column gap-4">
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
<div>Export your keys</div>
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
<p>Here's what the process looks like:</p>
|
||||
<ul class="flex list-inside list-decimal flex-col gap-4">
|
||||
<li>When you're ready, enter your account password below to continue.</li>
|
||||
<li>
|
||||
{PLATFORM_NAME} will send an email to "{email}" with your encrypted private key in it.
|
||||
</li>
|
||||
<li>
|
||||
Store your "ncryptsec" in a password manager like
|
||||
<Link class="link" external href="https://bitwarden.com/">Bitwarden</Link>. This is the key to
|
||||
your social identity; keep it safe and secret.
|
||||
</li>
|
||||
<li>
|
||||
Choose a <Link class="link" href="https://nostrapps.com/#signers">signer app</Link> and import
|
||||
your private key into it. Don't forget your account password; you'll need it to decrypt your key.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Once you export your key, you'll be <strong>logged out</strong> and won't be able to log in using
|
||||
your email and password any more. Going forward, you'll need to use your signer app instead.
|
||||
</p>
|
||||
{#if !success}
|
||||
<div out:slideAndFade>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>To confirm, please enter your password below:</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={Key} />
|
||||
<input type="password" disabled={loading} bind:value={password} class="grow" />
|
||||
</label>
|
||||
{/snippet}
|
||||
</Field>
|
||||
</div>
|
||||
{/if}
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" disabled={loading || success} onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
{#if success}
|
||||
<Button class="btn btn-primary" disabled={loading} onclick={reload}>
|
||||
<Icon icon={CheckCircle} />
|
||||
<Spinner {loading}>Refresh the page</Spinner>
|
||||
</Button>
|
||||
{:else}
|
||||
<Button class="btn btn-error" disabled={loading} onclick={confirm}>
|
||||
<Icon icon={CheckCircle} />
|
||||
<Spinner {loading}>I understand, send me my private key</Spinner>
|
||||
</Button>
|
||||
{/if}
|
||||
</ModalFooter>
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {removeNil} from "@welshman/lib"
|
||||
import {removeUndefined} from "@welshman/lib"
|
||||
import {deriveProfile} from "@welshman/app"
|
||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
const {pubkey, url}: Props = $props()
|
||||
|
||||
const profile = deriveProfile(pubkey, removeNil([url]))
|
||||
const profile = deriveProfile(pubkey, removeUndefined([url]))
|
||||
</script>
|
||||
|
||||
{#if $profile}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
import type {Snippet} from "svelte"
|
||||
import {load} from "@welshman/net"
|
||||
import {NOTE} from "@welshman/util"
|
||||
import {fly} from "@lib/transition"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import NoteItem from "@app/components/NoteItem.svelte"
|
||||
|
||||
interface Props {
|
||||
@@ -24,16 +22,16 @@
|
||||
<div class="col-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
{#await events}
|
||||
<p class="center my-12 flex">
|
||||
<Spinner loading />
|
||||
<p class="center flex min-h-6">
|
||||
<span class="loading loading-spinner"></span>
|
||||
</p>
|
||||
{:then events}
|
||||
{#each events as event (event.id)}
|
||||
<div in:fly>
|
||||
<NoteItem {url} {event} />
|
||||
</div>
|
||||
<NoteItem {url} {event} />
|
||||
{:else}
|
||||
{@render fallback?.()}
|
||||
<div class="min-h-6">
|
||||
{@render fallback?.()}
|
||||
</div>
|
||||
{/each}
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {writable} from "svelte/store"
|
||||
import type {Writable} from "svelte/store"
|
||||
import {type Instance} from "tippy.js"
|
||||
@@ -35,6 +36,26 @@
|
||||
value = remove(pubkey, value)
|
||||
}
|
||||
|
||||
const onInput = (e: any) => {
|
||||
if (e.target.value.match(/^[a-f0-9]{64}$/)) {
|
||||
selectPubkey(e.target.value)
|
||||
}
|
||||
|
||||
try {
|
||||
const {type, data} = nip19.decode(e.target.value) as any
|
||||
|
||||
if (type === "npub") {
|
||||
selectPubkey(data)
|
||||
}
|
||||
|
||||
if (type === "nprofile") {
|
||||
selectPubkey(data.pubkey)
|
||||
}
|
||||
} catch (e) {
|
||||
// pass
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (e: Event) => {
|
||||
if (instance.onKeyDown(e)) {
|
||||
e.preventDefault()
|
||||
@@ -80,6 +101,7 @@
|
||||
type="text"
|
||||
placeholder="Search for profiles..."
|
||||
bind:value={$term}
|
||||
oninput={onInput}
|
||||
onkeydown={onKeyDown} />
|
||||
</label>
|
||||
<Tippy
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {removeNil} from "@welshman/lib"
|
||||
import {removeUndefined} from "@welshman/lib"
|
||||
import {deriveProfileDisplay} from "@welshman/app"
|
||||
|
||||
type Props = {
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
const {pubkey, url}: Props = $props()
|
||||
|
||||
const profileDisplay = deriveProfileDisplay(pubkey, removeNil([url]))
|
||||
const profileDisplay = deriveProfileDisplay(pubkey, removeUndefined([url]))
|
||||
</script>
|
||||
|
||||
{$profileDisplay}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import SpaceAvatar from "@app/components/SpaceAvatar.svelte"
|
||||
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
import {deriveGroupSelections, getSpaceUrlsFromGroupSelections} from "@app/core/state"
|
||||
import {deriveGroupList, getSpaceUrlsFromGroupList} from "@app/core/state"
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
const {pubkey}: Props = $props()
|
||||
|
||||
const selections = deriveGroupSelections(pubkey)
|
||||
const spaceUrls = $derived(getSpaceUrlsFromGroupSelections($selections))
|
||||
const groupList = deriveGroupList(pubkey)
|
||||
const spaceUrls = $derived(getSpaceUrlsFromGroupList($groupList))
|
||||
|
||||
const back = () => history.back()
|
||||
</script>
|
||||
@@ -26,7 +26,7 @@
|
||||
{#each spaceUrls as url (url)}
|
||||
<div class="card2 bg-alt flex flex-row items-center gap-2">
|
||||
<div class="flex-shrink-0">
|
||||
<SpaceAvatar {url} />
|
||||
<RelayIcon {url} size={12} />
|
||||
</div>
|
||||
<div class="flex flex-grow flex-col">
|
||||
<RelayName {url} />
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
const canvasRect = canvas.getBoundingClientRect()
|
||||
|
||||
scale = wrapperRect.width / (canvasRect.width * 10)
|
||||
height = canvasRect.width * 10 * scale
|
||||
height = canvasRect.height * 10 * scale
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {onMount} from "svelte"
|
||||
import type {Snippet} from "svelte"
|
||||
import {groupBy, sum, uniq, uniqBy, batch, displayList} from "@welshman/lib"
|
||||
import {groupBy, map, sum, uniq, uniqBy, batch, displayList} from "@welshman/lib"
|
||||
import {
|
||||
REPORT,
|
||||
REACTION,
|
||||
@@ -14,14 +15,14 @@
|
||||
DELETE,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent, EventContent, Zap} from "@welshman/util"
|
||||
import {deriveEvents, deriveEventsMapped} from "@welshman/store"
|
||||
import {deriveArray, deriveEventsById, deriveItemsByKey} from "@welshman/store"
|
||||
import {load} from "@welshman/net"
|
||||
import {pubkey, repository, getValidZap, displayProfileByPubkey} from "@welshman/app"
|
||||
import {isMobile, preventDefault, stopPropagation} from "@lib/html"
|
||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Reaction from "@app/components/Reaction.svelte"
|
||||
import EventReportDetails from "@app/components/EventReportDetails.svelte"
|
||||
import ReportDetails from "@app/components/ReportDetails.svelte"
|
||||
import {REACTION_KINDS} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
|
||||
@@ -45,19 +46,22 @@
|
||||
children,
|
||||
}: Props = $props()
|
||||
|
||||
const reports = deriveEvents(repository, {
|
||||
filters: [{kinds: [REPORT], "#e": [event.id]}],
|
||||
})
|
||||
const reports = deriveArray(
|
||||
deriveEventsById({repository, filters: [{kinds: [REPORT], "#e": [event.id]}]}),
|
||||
)
|
||||
|
||||
const reactions = deriveEvents(repository, {
|
||||
filters: [{kinds: [REACTION], "#e": [event.id]}],
|
||||
})
|
||||
const reactions = deriveArray(
|
||||
deriveEventsById({repository, filters: [{kinds: [REACTION], "#e": [event.id]}]}),
|
||||
)
|
||||
|
||||
const zaps = deriveEventsMapped<Zap>(repository, {
|
||||
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
|
||||
itemToEvent: item => item.response,
|
||||
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
|
||||
})
|
||||
const zaps = deriveArray(
|
||||
deriveItemsByKey<Zap>({
|
||||
repository,
|
||||
getKey: zap => zap.response.id,
|
||||
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
|
||||
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
|
||||
}),
|
||||
)
|
||||
|
||||
const onReactionClick = (events: TrustedEvent[]) => {
|
||||
const reaction = events.find(e => e.pubkey === $pubkey)
|
||||
@@ -74,20 +78,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
const onReportClick = () => pushModal(EventReportDetails, {url, event})
|
||||
const onReportClick = () => pushModal(ReportDetails, {url, event})
|
||||
|
||||
const reportReasons = $derived(uniq($reports.map(e => getTag("e", e.tags)?.[2])))
|
||||
const reportReasons = $derived(uniq(map(e => getTag("e", e.tags)?.[2], $reports.values())))
|
||||
|
||||
const getReactionKey = (e: TrustedEvent) => getEmojiTag(e.content, e.tags)?.join("") || e.content
|
||||
|
||||
const groupedReactions = $derived(
|
||||
groupBy(
|
||||
getReactionKey,
|
||||
uniqBy(e => `${e.pubkey}${getReactionKey(e)}`, $reactions),
|
||||
uniqBy(e => `${e.pubkey}${getReactionKey(e)}`, $reactions.values()),
|
||||
),
|
||||
)
|
||||
|
||||
const groupedZaps = $derived(groupBy(e => getReactionKey(e.request), $zaps))
|
||||
const groupedZaps = $derived(groupBy(e => getReactionKey(e.request), $zaps.values()))
|
||||
|
||||
onMount(() => {
|
||||
const controller = new AbortController()
|
||||
@@ -134,10 +138,15 @@
|
||||
<button
|
||||
type="button"
|
||||
data-tip={tooltip}
|
||||
class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full text-xs font-normal {reactionClass}"
|
||||
class:tooltip={!noTooltip && !isMobile}
|
||||
class:btn-neutral={!isOwn}
|
||||
class:btn-primary={isOwn}>
|
||||
class={cx(
|
||||
reactionClass,
|
||||
"flex-inline btn btn-outline btn-neutral btn-xs flex items-center gap-1 rounded-full text-xs font-normal",
|
||||
{
|
||||
tooltip: !noTooltip && !isMobile,
|
||||
"border-neutral-content/20": !isOwn,
|
||||
"btn-primary": isOwn,
|
||||
},
|
||||
)}>
|
||||
<Reaction event={zaps[0].request} />
|
||||
<span>{amount}</span>
|
||||
</button>
|
||||
@@ -151,10 +160,15 @@
|
||||
<button
|
||||
type="button"
|
||||
data-tip={tooltip}
|
||||
class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full font-normal {reactionClass}"
|
||||
class:tooltip={!noTooltip && !isMobile}
|
||||
class:btn-neutral={!isOwn}
|
||||
class:btn-primary={isOwn}
|
||||
class={cx(
|
||||
reactionClass,
|
||||
"flex-inline btn btn-outline btn-neutral btn-xs gap-1 rounded-full font-normal",
|
||||
{
|
||||
tooltip: !noTooltip && !isMobile,
|
||||
"border-neutral-content/20": !isOwn,
|
||||
"btn-primary": isOwn,
|
||||
},
|
||||
)}
|
||||
onclick={stopPropagation(preventDefault(onClick))}>
|
||||
<Reaction event={events[0]} />
|
||||
{#if events.length > 1}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import {deriveRelay} from "@welshman/app"
|
||||
import RemoteControllerMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl"
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
size?: number
|
||||
class?: string
|
||||
}
|
||||
|
||||
const {url, size = 7, ...props}: Props = $props()
|
||||
|
||||
const relay = deriveRelay(url)
|
||||
</script>
|
||||
|
||||
{#if $relay?.icon}
|
||||
<ImageIcon {size} alt="" src={$relay?.icon} class={props.class} />
|
||||
{:else}
|
||||
<ImageIcon size={size - 2} alt="" src={RemoteControllerMinimalistic} class={props.class} />
|
||||
{/if}
|
||||
@@ -1,7 +1,11 @@
|
||||
<script lang="ts">
|
||||
import {deriveRelayDisplay} from "@welshman/app"
|
||||
|
||||
const {url} = $props()
|
||||
type Props = {
|
||||
url: string
|
||||
}
|
||||
|
||||
const {url}: Props = $props()
|
||||
|
||||
const display = $derived(deriveRelayDisplay(url))
|
||||
</script>
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
<script lang="ts">
|
||||
import {deriveRelay} from "@welshman/app"
|
||||
import Ghost from "@assets/icons/ghost-smile.svg?dataurl"
|
||||
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||
import RelayDescription from "@app/components/RelayDescription.svelte"
|
||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||
import {deriveSpaceMembers, deriveUserRooms} from "@app/core/state"
|
||||
import {deriveGroupListPubkeys, deriveUserRooms} from "@app/core/state"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
}
|
||||
|
||||
const {url}: Props = $props()
|
||||
const relay = deriveRelay(url)
|
||||
const rooms = deriveUserRooms(url)
|
||||
const members = deriveSpaceMembers(url)
|
||||
const favorited = deriveGroupListPubkeys(url)
|
||||
</script>
|
||||
|
||||
<div class="col-4 text-left">
|
||||
@@ -25,11 +23,7 @@
|
||||
<div class="avatar relative">
|
||||
<div
|
||||
class="center !flex h-12 w-12 min-w-12 rounded-full border-2 border-solid border-base-300 bg-base-300">
|
||||
{#if $relay?.icon}
|
||||
<img alt="" src={$relay.icon} />
|
||||
{:else}
|
||||
<Icon icon={Ghost} size={5} />
|
||||
{/if}
|
||||
<RelayIcon {url} />
|
||||
</div>
|
||||
</div>
|
||||
{#if $rooms.includes(url)}
|
||||
@@ -49,10 +43,10 @@
|
||||
</div>
|
||||
<RelayDescription {url} />
|
||||
</div>
|
||||
{#if $members.length > 0}
|
||||
{#if $favorited.size > 0}
|
||||
<div class="row-2 card2 card2-sm bg-alt">
|
||||
Members:
|
||||
<ProfileCircles pubkeys={$members} />
|
||||
Favorited By:
|
||||
<ProfileCircles pubkeys={Array.from($favorited)} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import {REPORT} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {deriveEventsById} from "@welshman/store"
|
||||
import {repository} from "@welshman/app"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ReportItem from "@app/components/ReportItem.svelte"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const reports = deriveEventsById({
|
||||
repository,
|
||||
filters: [{kinds: [REPORT], "#e": [event.id]}],
|
||||
})
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const onDelete = () => {
|
||||
if ($reports.size === 0) {
|
||||
back()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="column gap-4">
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
<div>Report Details</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>All reports for this event are shown below.</div>
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
{#each $reports.values() as report (report.id)}
|
||||
<div class="card2 card2-sm bg-alt">
|
||||
<ReportItem {url} event={report} {onDelete} />
|
||||
</div>
|
||||
{/each}
|
||||
<Button class="btn btn-primary" onclick={back}>Got it</Button>
|
||||
</div>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import {formatTimestamp} from "@welshman/lib"
|
||||
import {getTag, getIdFilters} from "@welshman/util"
|
||||
import {load, LOCAL_RELAY_URL} from "@welshman/net"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Profile from "@app/components/Profile.svelte"
|
||||
import ProfileName from "@app/components/ProfileName.svelte"
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import ReportMenu from "@app/components/ReportMenu.svelte"
|
||||
import {publishDelete, canEnforceNip70} from "@app/core/commands"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {goToEvent} from "@app/util/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
const {url, event, onDelete}: Props = $props()
|
||||
|
||||
const etag = getTag("e", event.tags)
|
||||
const ptag = getTag("p", event.tags)
|
||||
const reason = etag?.[2] || ptag?.[2]
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const onClick = (e: Event, event: TrustedEvent) => {
|
||||
// @ts-ignore
|
||||
if (e.target?.classList.contains("profile-name")) {
|
||||
pushModal(ProfileDetail, {pubkey: event.pubkey, url})
|
||||
} else {
|
||||
goToEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteReport = async () => {
|
||||
publishDelete({event, relays: [url], protect: await shouldProtect})
|
||||
onDelete?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="column gap-4">
|
||||
<div class="flex justify-between">
|
||||
<div>
|
||||
<Profile pubkey={event.pubkey} {url} avatarSize={5} />
|
||||
<span>
|
||||
Reported this event
|
||||
{#if reason}
|
||||
as "{reason}"
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if event.pubkey === $pubkey}
|
||||
<Button class="btn-default btn" onclick={deleteReport}>Delete Report</Button>
|
||||
{:else}
|
||||
<ReportMenu {url} {event} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if event.content}
|
||||
<div class="border-l-2 border-primary pl-3">
|
||||
<NoteContent {event} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="card2 card2-sm bg-alt">
|
||||
{#if etag}
|
||||
{#await load({relays: [url, LOCAL_RELAY_URL], filters: getIdFilters([etag[1]])})}
|
||||
<p>Loading</p>
|
||||
{:then reportedEvents}
|
||||
{#if reportedEvents.length === 0}
|
||||
<p>Unable to find reported note.</p>
|
||||
{:else}
|
||||
{@const event = reportedEvents[0]}
|
||||
<Button class="col-2 w-full" onclick={(e: Event) => onClick(e, event)}>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="profile-name">
|
||||
@<ProfileName pubkey={event.pubkey} {url} />
|
||||
</span>
|
||||
<span class="text-xs opacity-75">
|
||||
{formatTimestamp(event.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<NoteContent {event} />
|
||||
</Button>
|
||||
{/if}
|
||||
{/await}
|
||||
{:else if ptag}
|
||||
<Profile pubkey={ptag[1]} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import {getTag, ManagementMethod} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {manageRelay, repository, displayProfileByPubkey} from "@welshman/app"
|
||||
import InboxOut from "@assets/icons/inbox-out.svg?dataurl"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
import {fly} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Popover from "@lib/components/Popover.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const etag = getTag("e", event.tags)
|
||||
const ptag = getTag("p", event.tags)
|
||||
|
||||
const toggleMenu = () => {
|
||||
isOpen = !isOpen
|
||||
}
|
||||
|
||||
const closeMenu = () => {
|
||||
isOpen = false
|
||||
}
|
||||
|
||||
const dismissReport = async () => {
|
||||
const {error} = await manageRelay(url, {
|
||||
method: ManagementMethod.BanEvent,
|
||||
params: [event.id, "Dismissed by admin"],
|
||||
})
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: "Content has successfully been deleted!"})
|
||||
repository.removeEvent(event.id)
|
||||
history.back()
|
||||
}
|
||||
}
|
||||
|
||||
const banContent = () => {
|
||||
const [_, id, reason = ""] = etag!
|
||||
|
||||
pushModal(Confirm, {
|
||||
title: `Delete Content`,
|
||||
message: `Are you sure you want to delete this content from the space?`,
|
||||
confirm: async () => {
|
||||
const {error} = await manageRelay(url, {
|
||||
method: ManagementMethod.BanEvent,
|
||||
params: [id, reason],
|
||||
})
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: "Content has successfully been deleted!"})
|
||||
repository.removeEvent(id)
|
||||
history.back()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const banMember = () => {
|
||||
const [pubkey, reason = ""] = ptag!
|
||||
|
||||
pushModal(Confirm, {
|
||||
title: "Ban User",
|
||||
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
|
||||
confirm: async () => {
|
||||
const {error} = await manageRelay(url, {
|
||||
method: ManagementMethod.BanPubkey,
|
||||
params: [pubkey, reason],
|
||||
})
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: "User has successfully been banned!"})
|
||||
history.back()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let isOpen = $state(false)
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<Button class="btn btn-circle btn-ghost btn-sm" onclick={toggleMenu}>
|
||||
<Icon icon={MenuDots} />
|
||||
</Button>
|
||||
{#if isOpen}
|
||||
<Popover hideOnClick onClose={closeMenu}>
|
||||
<ul
|
||||
transition:fly
|
||||
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
|
||||
<li>
|
||||
<Button onclick={dismissReport}>
|
||||
<Icon icon={InboxOut} />
|
||||
Dismiss Report
|
||||
</Button>
|
||||
</li>
|
||||
{#if etag}
|
||||
<li>
|
||||
<Button class="text-error" onclick={banContent}>
|
||||
<Icon icon={TrashBin2} />
|
||||
Remove Content
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if ptag}
|
||||
<li>
|
||||
<Button class="text-error" onclick={banMember}>
|
||||
<Icon icon={MinusCircle} />
|
||||
Ban User
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import EyeClosed from "@assets/icons/eye-closed.svg?dataurl"
|
||||
import Lock from "@assets/icons/lock.svg?dataurl"
|
||||
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import {deriveRoom} from "@app/core/state"
|
||||
|
||||
interface Props {
|
||||
h: any
|
||||
url: any
|
||||
}
|
||||
|
||||
const {url, h}: Props = $props()
|
||||
|
||||
const room = deriveRoom(url, h)
|
||||
</script>
|
||||
|
||||
{#if $room.isHidden}
|
||||
<Button
|
||||
class="btn btn-neutral btn-sm tooltip tooltip-left"
|
||||
data-tip="This room is not visible to non-members.">
|
||||
<Icon size={4} icon={EyeClosed} />
|
||||
</Button>
|
||||
{:else if $room.isPrivate}
|
||||
<Button
|
||||
class="btn btn-neutral btn-sm tooltip tooltip-left"
|
||||
data-tip="Only members can view messages.">
|
||||
<Icon size={4} icon={Lock} />
|
||||
</Button>
|
||||
{:else if $room.isRestricted}
|
||||
<Button
|
||||
class="btn btn-neutral btn-sm tooltip tooltip-left"
|
||||
data-tip="Only members can send messages.">
|
||||
<Icon size={4} icon={Microphone} />
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -16,13 +16,14 @@
|
||||
|
||||
type Props = {
|
||||
url?: string
|
||||
room?: string
|
||||
h?: string
|
||||
content?: string
|
||||
onEscape?: () => void
|
||||
onEditPrevious?: () => void
|
||||
onSubmit: (event: EventContent) => void
|
||||
}
|
||||
|
||||
const {url, room, content, onEditPrevious, onSubmit}: Props = $props()
|
||||
const {url, h, content, onEscape, onEditPrevious, onSubmit}: Props = $props()
|
||||
|
||||
const autofocus = !isMobile
|
||||
|
||||
@@ -34,6 +35,10 @@
|
||||
editor.then(ed => ed.getText({blockSeparator: "\n"}) === "")
|
||||
|
||||
const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onEscape?.()
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" && (await canEnterEditPrevious())) {
|
||||
onEditPrevious?.()
|
||||
}
|
||||
@@ -74,7 +79,7 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
|
||||
<form class="relative flex gap-2 p-2" onsubmit={preventDefault(submit)}>
|
||||
<div class="join">
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
@@ -90,7 +95,7 @@
|
||||
<Tippy
|
||||
bind:popover
|
||||
component={ComposeMenu}
|
||||
props={{url, room, onClick: hidePopover}}
|
||||
props={{url, h, onClick: hidePopover}}
|
||||
params={{trigger: "manual", interactive: true}}>
|
||||
<Button
|
||||
data-tip="More options"
|
||||
+2
-2
@@ -12,10 +12,10 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
|
||||
class="relative flex h-8 items-center justify-between border-l-2 border-solid border-primary bg-base-300 px-2 pr-7 text-xs"
|
||||
transition:slide>
|
||||
<p class="text-primary">Editing message</p>
|
||||
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
|
||||
<Button onclick={clear} class="flex items-center">
|
||||
<Icon icon={CloseCircle} />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1,181 +1,47 @@
|
||||
<script lang="ts">
|
||||
import {goto} from "$app/navigation"
|
||||
import {uniqBy, nth} from "@welshman/lib"
|
||||
import {displayRelayUrl, makeRoomMeta} from "@welshman/util"
|
||||
import {deriveRelay, waitForThunkError, createRoom, editRoom, joinRoom} from "@welshman/app"
|
||||
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
|
||||
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
|
||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
import type {RoomMeta} from "@welshman/util"
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
|
||||
import {preventDefault, compressFile} from "@lib/html"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import IconPickerButton from "@lib/components/IconPickerButton.svelte"
|
||||
import {hasNip29, loadChannel} from "@app/core/state"
|
||||
import RoomForm from "@app/components/RoomForm.svelte"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {uploadFile} from "@app/core/commands"
|
||||
|
||||
const {url} = $props()
|
||||
|
||||
const room = makeRoomMeta()
|
||||
const relay = deriveRelay(url)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const tryCreate = async () => {
|
||||
room.tags = uniqBy(nth(0), [...room.tags, ["name", name]])
|
||||
|
||||
if (imageFile) {
|
||||
const {error, result} = await uploadFile(imageFile)
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
}
|
||||
|
||||
room.tags.push(["picture", result.url, ...result.tags])
|
||||
} else if (selectedIcon) {
|
||||
room.tags.push(["picture", selectedIcon])
|
||||
}
|
||||
|
||||
const createMessage = await waitForThunkError(createRoom(url, room))
|
||||
|
||||
if (createMessage && !createMessage.match(/^duplicate:|already a member/)) {
|
||||
return pushToast({theme: "error", message: createMessage})
|
||||
}
|
||||
|
||||
const editMessage = await waitForThunkError(editRoom(url, room))
|
||||
|
||||
if (editMessage) {
|
||||
return pushToast({theme: "error", message: editMessage})
|
||||
}
|
||||
|
||||
const joinMessage = await waitForThunkError(joinRoom(url, room))
|
||||
|
||||
if (joinMessage && !joinMessage.includes("already")) {
|
||||
return pushToast({theme: "error", message: joinMessage})
|
||||
}
|
||||
|
||||
await loadChannel(url, room.id)
|
||||
|
||||
goto(makeSpacePath(url, room.id))
|
||||
}
|
||||
|
||||
const create = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
await tryCreate()
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
let name = $state("")
|
||||
let loading = $state(false)
|
||||
let imageFile = $state<File | undefined>()
|
||||
let imagePreview = $state<string | undefined>()
|
||||
let selectedIcon = $state<string | undefined>()
|
||||
|
||||
const handleImageUpload = async (event: Event) => {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
|
||||
if (file && file.type.startsWith("image/")) {
|
||||
selectedIcon = undefined
|
||||
imageFile = await compressFile(file, {maxWidth: 64, maxHeight: 64})
|
||||
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = e => {
|
||||
imagePreview = e.target?.result as string
|
||||
}
|
||||
|
||||
reader.readAsDataURL(imageFile)
|
||||
}
|
||||
}
|
||||
|
||||
const handleIconSelect = (iconUrl: string) => {
|
||||
imageFile = undefined
|
||||
imagePreview = undefined
|
||||
selectedIcon = iconUrl
|
||||
}
|
||||
const onsubmit = (room: RoomMeta) => goto(makeSpacePath(url, room.h))
|
||||
</script>
|
||||
|
||||
<form class="column gap-4" onsubmit={preventDefault(create)}>
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
<div>Create a Room</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>
|
||||
On <span class="text-primary">{displayRelayUrl(url)}</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
{#if hasNip29($relay)}
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Room Name</p>
|
||||
<RoomForm {url} {onsubmit}>
|
||||
{#snippet header()}
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
<div>Create a Room</div>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={Hashtag} />
|
||||
<input bind:value={name} class="grow" type="text" />
|
||||
</label>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-bold">Room Icon</p>
|
||||
<div class="flex items-center gap-4">
|
||||
{#if imagePreview}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm opacity-75">Selected:</span>
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Room icon preview"
|
||||
class="h-8 w-8 rounded-lg object-cover" />
|
||||
</div>
|
||||
{:else if selectedIcon}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm opacity-75">Selected:</span>
|
||||
<Icon icon={selectedIcon} class="h-8 w-8" />
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-sm opacity-75">No icon selected</span>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<IconPickerButton onSelect={handleIconSelect} class="btn btn-primary btn-sm">
|
||||
<Icon icon={StickerSmileSquare} size={4} />
|
||||
Select
|
||||
</IconPickerButton>
|
||||
<label class="btn btn-neutral btn-sm cursor-pointer">
|
||||
<Icon icon={UploadMinimalistic} size={4} />
|
||||
Upload
|
||||
<input type="file" accept="image/*" class="hidden" onchange={handleImageUpload} />
|
||||
</label>
|
||||
{#snippet info()}
|
||||
<div>
|
||||
On <span class="text-primary">{displayRelayUrl(url)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="bg-alt card2 row-2">
|
||||
<Icon icon={Danger} />
|
||||
This relay does not support creating rooms.
|
||||
</p>
|
||||
{/if}
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary" disabled={!name || loading || !hasNip29($relay)}>
|
||||
<Spinner {loading}>Create Room</Spinner>
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
{/snippet}
|
||||
{#snippet footer({loading})}
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||
<Spinner {loading}>Create Room</Spinner>
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
{/snippet}
|
||||
</RoomForm>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user