Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e7e439e3f | |||
| 3368cba1be | |||
| 4f0579bb7f | |||
| 08e80262a4 | |||
| e10b83bed8 | |||
| fa17c398ca | |||
| e0840f24dd | |||
| 8e38271534 | |||
| 86928fc12c | |||
| e15fb3ce9c | |||
| bf1ab5f0ee | |||
| 59568f95f1 | |||
| 75d52e7e17 | |||
| bdb5d3dfaa | |||
| c387b65460 | |||
| 01c4219922 | |||
| 9ca4440038 | |||
| d6cc414f41 | |||
| 7ccb2949a9 | |||
| 8d4e657af5 | |||
| 4886650dfa | |||
| e36e6093e9 | |||
| edd6e5c8fc | |||
| be7a42d951 | |||
| af91fe129b | |||
| 6fcf0e7f12 | |||
| b6defe59a8 | |||
| f618e4e1f3 | |||
| 5253980cdc | |||
| 5931a268cf | |||
| 268028a968 | |||
| d6669f42c1 | |||
| 19d69005a1 | |||
| 9917970760 | |||
| 814c5974c4 | |||
| f5dced433a | |||
| 9e96d5e483 | |||
| 420dfc41f3 | |||
| 23ae530cd4 | |||
| 8dfbc99a34 | |||
| 75bca31c14 | |||
| 0c9109f387 | |||
| 7a17dc772f | |||
| 7bd98270f8 | |||
| c15f57c9a5 | |||
| 0f311c45c0 | |||
| 055d539b88 | |||
| b8e23c47d4 | |||
| 39c72a61ce | |||
| 166bd81310 | |||
| d0565e7c62 | |||
| 7ddc1657ad | |||
| fe789c461d | |||
| cd8d8b548f | |||
| 3b202b31cb | |||
| fd846d41ea | |||
| 3d3ffaf406 | |||
| 85e5413951 | |||
| 9f3bfd5ac0 | |||
| 9d6531c0d5 | |||
| 77d20966ee | |||
| daf5cc84bd | |||
| 167cd045f4 | |||
| c83461688f | |||
| b19881a8a9 | |||
| b6524f4a58 | |||
| 2ee370e78b | |||
| a378ecbad4 | |||
| 72ced31625 | |||
| 6f7a1c690f | |||
| df42ec9915 | |||
| 19d67783fc | |||
| d8c3378e5c | |||
| 73c6b9656c | |||
| 80d44a097a | |||
| a5dfa02771 | |||
| 66f3686ef4 | |||
| a65f6f6323 | |||
| 523c54a1f1 | |||
| 7e3cf94ee8 | |||
| 404dc94c34 | |||
| ea0e1a6c9a | |||
| 880093296e | |||
| e17cda1eff | |||
| 1e0cb93183 | |||
| 14cd49caf3 | |||
| 64916f5d29 | |||
| 7b58cdf855 | |||
| 2e05eee9e7 | |||
| efb0528f76 | |||
| 1ea39c1d56 | |||
| c2aa829334 | |||
| a58fc68235 | |||
| 220f26253d | |||
| 08fef7aa51 | |||
| b8c77c20cd | |||
| aa27a05fa6 | |||
| 9a68101a64 | |||
| dd5384f7e4 | |||
| 71d63ed21a | |||
| de4e1c8677 | |||
| e6e1eb8897 | |||
| 603653574c | |||
| e83a72b426 | |||
| eb5bcd8948 | |||
| 7c46dfb6bc | |||
| dcc6f463a7 | |||
| 86d082b1ab | |||
| 659403c308 | |||
| 1c0e680c17 | |||
| 05f7d128e4 | |||
| dfcb88dcce | |||
| 5890fb64a5 | |||
| f52142bc52 | |||
| f4f60a5333 | |||
| 6a646b3240 | |||
| e5fd172994 | |||
| 7cc2a2f264 | |||
| ad58af8605 | |||
| 5b7985e5d9 | |||
| 6ff798f4e8 | |||
| ed738f64c8 | |||
| cbc4c524c4 | |||
| bf599cb190 | |||
| 06a03f5ab1 |
@@ -1,5 +1,8 @@
|
|||||||
VITE_DEFAULT_PUBKEYS=fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322
|
VITE_DEFAULT_PUBKEYS=fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322
|
||||||
|
VITE_BURROW_URL=
|
||||||
VITE_PLATFORM_URL=https://flotilla.social
|
VITE_PLATFORM_URL=https://flotilla.social
|
||||||
|
VITE_PLATFORM_TERMS=https://flotilla.social/terms
|
||||||
|
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
|
||||||
VITE_PLATFORM_NAME=Flotilla
|
VITE_PLATFORM_NAME=Flotilla
|
||||||
VITE_PLATFORM_LOGO=static/flotilla.png
|
VITE_PLATFORM_LOGO=static/flotilla.png
|
||||||
VITE_PLATFORM_RELAY=
|
VITE_PLATFORM_RELAY=
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
src/assets
|
src/assets
|
||||||
android
|
|
||||||
build
|
build
|
||||||
|
.idea
|
||||||
|
.gradle
|
||||||
|
*.png
|
||||||
|
*.ttf
|
||||||
|
gradlew*
|
||||||
|
_app
|
||||||
|
release
|
||||||
|
android/capacitor-cordova-android-plugins
|
||||||
|
android/app/src/androidTest
|
||||||
|
android/app/src/test
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ Thumbs.db
|
|||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
# Android
|
||||||
|
.idea
|
||||||
|
|
||||||
# Generated assets
|
# Generated assets
|
||||||
static/favicon.ico
|
static/favicon.ico
|
||||||
static/pwa-64x64.png
|
static/pwa-64x64.png
|
||||||
@@ -24,5 +27,5 @@ static/pwa-192x192.png
|
|||||||
static/pwa-512x512.png
|
static/pwa-512x512.png
|
||||||
static/apple-touch-icon-180x180.png
|
static/apple-touch-icon-180x180.png
|
||||||
static/maskable-icon-512x512.png
|
static/maskable-icon-512x512.png
|
||||||
src/assets
|
src/assets/icons/*.webp
|
||||||
manifest.webmanifest
|
manifest.webmanifest
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
# 0.2.5
|
||||||
|
|
||||||
|
* Improve room and data loading
|
||||||
|
* Use @welshman/editor
|
||||||
|
* Drop support for legacy event kinds
|
||||||
|
* Add support for back button navigation on android
|
||||||
|
* Remove note to self page (still available via chat)
|
||||||
|
* Improve chat conversation search
|
||||||
|
* Change how reply UI works
|
||||||
|
|
||||||
|
# 0.2.4
|
||||||
|
|
||||||
|
* Update icons
|
||||||
|
|
||||||
|
# 0.2.3
|
||||||
|
|
||||||
|
* Add NIP 56 reports for messages and threads
|
||||||
|
* Add ToS and privacy policy
|
||||||
|
* Add avatar fallback icons
|
||||||
|
* Add mark as read to chats
|
||||||
|
* Add send button to chat compose
|
||||||
|
* Accommodate onion URLs
|
||||||
|
* Improve loading and notifications
|
||||||
|
|
||||||
|
# 0.2.2
|
||||||
|
|
||||||
|
* Fix bug with sending messages
|
||||||
|
|
||||||
|
# 0.2.1
|
||||||
|
|
||||||
|
* Improve performance, as well as scrolling and loading
|
||||||
|
* Integrate @welshman/editor
|
||||||
|
* Improve NIP 29 compatibility
|
||||||
|
* Fix incorrect connection errors
|
||||||
|
* Refine notifications
|
||||||
|
* Add room menu to space homepage
|
||||||
|
* Fix storage bugs
|
||||||
|
* Add join space CTA
|
||||||
@@ -6,7 +6,11 @@ If you would like to be interoperable with Flotilla, please check out this draft
|
|||||||
|
|
||||||
# Deploy
|
# Deploy
|
||||||
|
|
||||||
To run your own Flotilla, it's as simple as `npm run build`, then serve the `build` directory.
|
To run your own Flotilla, it's as simple as:
|
||||||
|
|
||||||
|
- `npm install`
|
||||||
|
- `npm run build`
|
||||||
|
- `npx serve build`
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "social.flotilla"
|
applicationId "social.flotilla"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 5
|
||||||
versionName "1.0"
|
versionName "0.2.5"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ android {
|
|||||||
|
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation project(':capacitor-app')
|
||||||
implementation project(':nostr-signer-capacitor-plugin')
|
implementation project(':nostr-signer-capacitor-plugin')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 531 B After Width: | Height: | Size: 899 B |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 916 B After Width: | Height: | Size: 705 B |
|
Before Width: | Height: | Size: 277 B After Width: | Height: | Size: 329 B |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 350 B After Width: | Height: | Size: 550 B |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 697 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 5.3 KiB |
@@ -1,9 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<!-- Base application theme. -->
|
|
||||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
<!-- Customize your theme here. -->
|
|
||||||
<item name="colorPrimary">@color/colorPrimary</item>
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
<item name="colorAccent">@color/colorAccent</item>
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
@@ -19,4 +17,4 @@
|
|||||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
<item name="android:background">@drawable/splash</item>
|
<item name="android:background">@drawable/splash</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:8.2.1'
|
classpath 'com.android.tools.build:gradle:8.8.0'
|
||||||
classpath 'com.google.gms:google-services:4.4.0'
|
classpath 'com.google.gms:google-services:4.4.0'
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
|||||||
@@ -2,5 +2,8 @@
|
|||||||
include ':capacitor-android'
|
include ':capacitor-android'
|
||||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||||
|
|
||||||
|
include ':capacitor-app'
|
||||||
|
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||||
|
|
||||||
include ':nostr-signer-capacitor-plugin'
|
include ':nostr-signer-capacitor-plugin'
|
||||||
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/nostr-signer-capacitor-plugin/android')
|
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/nostr-signer-capacitor-plugin/android')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -16,7 +16,6 @@ eval "$temp_env"
|
|||||||
|
|
||||||
if [[ $VITE_PLATFORM_LOGO =~ ^https://* ]]; then
|
if [[ $VITE_PLATFORM_LOGO =~ ^https://* ]]; then
|
||||||
curl $VITE_PLATFORM_LOGO > static/logo.png
|
curl $VITE_PLATFORM_LOGO > static/logo.png
|
||||||
cp static/logo.png assets/logo.png
|
|
||||||
export VITE_PLATFORM_LOGO=static/logo.png
|
export VITE_PLATFORM_LOGO=static/logo.png
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -28,3 +27,10 @@ perl -i -pe"s|{DESCRIPTION}|$VITE_PLATFORM_DESCRIPTION|g" build/index.html
|
|||||||
perl -i -pe"s|{ACCENT}|$VITE_PLATFORM_ACCENT|g" build/index.html
|
perl -i -pe"s|{ACCENT}|$VITE_PLATFORM_ACCENT|g" build/index.html
|
||||||
perl -i -pe"s|{NAME}|$VITE_PLATFORM_NAME|g" build/index.html
|
perl -i -pe"s|{NAME}|$VITE_PLATFORM_NAME|g" build/index.html
|
||||||
perl -i -pe"s|{URL}|$VITE_PLATFORM_URL|g" build/index.html
|
perl -i -pe"s|{URL}|$VITE_PLATFORM_URL|g" build/index.html
|
||||||
|
|
||||||
|
npx cap sync
|
||||||
|
npx @capacitor/assets generate \
|
||||||
|
--iconBackgroundColor '#eeeeee' \
|
||||||
|
--iconBackgroundColorDark '#222222' \
|
||||||
|
--splashBackgroundColor '#ffffff' \
|
||||||
|
--splashBackgroundColorDark '#191E24'
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ const config: CapacitorConfig = {
|
|||||||
appId: 'social.flotilla',
|
appId: 'social.flotilla',
|
||||||
appName: 'Flotilla',
|
appName: 'Flotilla',
|
||||||
webDir: 'build'
|
webDir: 'build'
|
||||||
|
server: {
|
||||||
|
androidScheme: "https"
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
SplashScreen: {
|
SplashScreen: {
|
||||||
androidSplashResourceName: "splash"
|
androidSplashResourceName: "splash"
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "flotilla",
|
"name": "flotilla",
|
||||||
"version": "0.1.0",
|
"version": "0.2.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "./build.sh",
|
"build": "./build.sh",
|
||||||
"sourcemaps": "sentry-cli --url https://glitchtip.coracle.social --auth-token $GLITCHTIP_AUTH_TOKEN --api-key $VITE_GLITCHTIP_API_KEY sourcemaps --org coracle --project flotilla --release $(cat package.json|jq -r '.version') upload --url-prefix /_app/immutable/ build/_app/immutable",
|
"sourcemaps": "sentry-cli --url https://glitchtip.coracle.social --auth-token $GLITCHTIP_AUTH_TOKEN --api-key $VITE_GLITCHTIP_API_KEY sourcemaps --org coracle --project flotilla --release $(cat package.json|jq -r '.version') upload --url-prefix /_app/immutable/ build/_app/immutable",
|
||||||
"release:android": "cap sync && cap build android --androidreleasetype APK --signing-type apksigner",
|
"release:android": "cap build android --androidreleasetype APK --signing-type apksigner",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"lint": "prettier --check src && eslint src",
|
"lint": "prettier --check src && eslint src",
|
||||||
"format": "prettier --write src",
|
"format": "prettier --write src",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@capacitor/core": "^7.0.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/assets": "^3.0.5",
|
"@capacitor/assets": "^3.0.5",
|
||||||
|
"@sentry/cli": "^2.40.0",
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||||
"@types/eslint": "^9.6.0",
|
"@types/eslint": "^9.6.0",
|
||||||
@@ -36,38 +40,28 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^6.1.2",
|
"@capacitor/android": "^7.0.1",
|
||||||
"@capacitor/cli": "^6.1.2",
|
"@capacitor/app": "^7.0.0",
|
||||||
"@capacitor/core": "^6.1.2",
|
"@capacitor/cli": "^6.2.0",
|
||||||
|
"@capacitor/core": "^7.0.1",
|
||||||
"@noble/curves": "^1.5.0",
|
"@noble/curves": "^1.5.0",
|
||||||
"@noble/hashes": "^1.4.0",
|
"@noble/hashes": "^1.4.0",
|
||||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
"@poppanator/sveltekit-svg": "^4.2.1",
|
||||||
"@sentry/browser": "^8.35.0",
|
"@sentry/browser": "^8.35.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.4",
|
"@sveltejs/adapter-static": "^3.0.4",
|
||||||
"@tiptap/extension-code": "^2.6.6",
|
|
||||||
"@tiptap/extension-code-block": "^2.6.6",
|
|
||||||
"@tiptap/extension-document": "^2.6.6",
|
|
||||||
"@tiptap/extension-dropcursor": "^2.6.6",
|
|
||||||
"@tiptap/extension-gapcursor": "^2.6.6",
|
|
||||||
"@tiptap/extension-hard-break": "^2.6.6",
|
|
||||||
"@tiptap/extension-history": "^2.6.6",
|
|
||||||
"@tiptap/extension-paragraph": "^2.6.6",
|
|
||||||
"@tiptap/extension-placeholder": "^2.9.1",
|
|
||||||
"@tiptap/extension-text": "^2.6.6",
|
|
||||||
"@tiptap/suggestion": "^2.6.4",
|
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/throttle-debounce": "^5.0.2",
|
|
||||||
"@vite-pwa/assets-generator": "^0.2.6",
|
"@vite-pwa/assets-generator": "^0.2.6",
|
||||||
"@vite-pwa/sveltekit": "^0.6.6",
|
"@vite-pwa/sveltekit": "^0.6.6",
|
||||||
"@welshman/app": "~0.0.27",
|
"@welshman/app": "~0.0.41",
|
||||||
"@welshman/content": "~0.0.12",
|
"@welshman/content": "~0.0.15",
|
||||||
"@welshman/dvm": "~0.0.10",
|
"@welshman/dvm": "~0.0.14",
|
||||||
"@welshman/feeds": "~0.0.25",
|
"@welshman/editor": "~0.0.8",
|
||||||
"@welshman/lib": "~0.0.26",
|
"@welshman/feeds": "~0.0.30",
|
||||||
"@welshman/net": "~0.0.36",
|
"@welshman/lib": "~0.0.38",
|
||||||
"@welshman/signer": "~0.0.14",
|
"@welshman/net": "~0.0.46",
|
||||||
"@welshman/store": "~0.0.12",
|
"@welshman/signer": "~0.0.20",
|
||||||
"@welshman/util": "~0.0.45",
|
"@welshman/store": "~0.0.15",
|
||||||
|
"@welshman/util": "~0.0.59",
|
||||||
"daisyui": "^4.12.10",
|
"daisyui": "^4.12.10",
|
||||||
"date-picker-svelte": "^2.13.0",
|
"date-picker-svelte": "^2.13.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
@@ -75,12 +69,9 @@
|
|||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"husky": "^9.1.6",
|
"husky": "^9.1.6",
|
||||||
"idb": "^8.0.0",
|
"idb": "^8.0.0",
|
||||||
"nostr-editor": "^0.0.3",
|
|
||||||
"nostr-signer-capacitor-plugin": "^0.0.3",
|
"nostr-signer-capacitor-plugin": "^0.0.3",
|
||||||
"nostr-tools": "^2.7.2",
|
"nostr-tools": "^2.7.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4"
|
||||||
"svelte-tiptap": "^1.1.3",
|
|
||||||
"throttle-debounce": "^5.0.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,15 +40,14 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: Lato;
|
font-family: Lato;
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme] {
|
|
||||||
--base-100: oklch(var(--b1));
|
--base-100: oklch(var(--b1));
|
||||||
--base-200: oklch(var(--b2));
|
--base-200: oklch(var(--b2));
|
||||||
--base-300: oklch(var(--b3));
|
--base-300: oklch(var(--b3));
|
||||||
--base-content: oklch(var(--bc));
|
--base-content: oklch(var(--bc));
|
||||||
--primary: oklch(var(--p));
|
--primary: oklch(var(--p));
|
||||||
|
--primary-content: oklch(var(--pc));
|
||||||
--secondary: oklch(var(--s));
|
--secondary: oklch(var(--s));
|
||||||
|
--secondary-content: oklch(var(--sc));
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-alt,
|
.bg-alt,
|
||||||
@@ -120,6 +119,16 @@
|
|||||||
@apply overflow-hidden text-ellipsis;
|
@apply overflow-hidden text-ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-tip]::before {
|
||||||
|
@apply ellipsize;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
[data-tip]::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.content-padding-x {
|
.content-padding-x {
|
||||||
@apply px-4 sm:px-8 md:px-12;
|
@apply px-4 sm:px-8 md:px-12;
|
||||||
}
|
}
|
||||||
@@ -239,3 +248,19 @@ emoji-picker {
|
|||||||
--input-font-color: var(--base-content);
|
--input-font-color: var(--base-content);
|
||||||
--outline-color: var(--base-100);
|
--outline-color: var(--base-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* tiptap */
|
||||||
|
|
||||||
|
.tiptap {
|
||||||
|
--tiptap-object-bg: var(--base-100);
|
||||||
|
--tiptap-object-fg: var(--base-content);
|
||||||
|
--tiptap-active-bg: var(--primary);
|
||||||
|
--tiptap-active-fg: var(--primary-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-suggestions {
|
||||||
|
--tiptap-object-bg: var(--base-100);
|
||||||
|
--tiptap-object-fg: var(--base-content);
|
||||||
|
--tiptap-active-bg: var(--base-300);
|
||||||
|
--tiptap-active-fg: var(--base-content);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {get} from "svelte/store"
|
import {get} from "svelte/store"
|
||||||
import {ctx, uniq, sleep, chunk, equals, choice} from "@welshman/lib"
|
import {ctx, sample, uniq, sleep, chunk, equals} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
DELETE,
|
DELETE,
|
||||||
|
REPORT,
|
||||||
PROFILE,
|
PROFILE,
|
||||||
INBOX_RELAYS,
|
INBOX_RELAYS,
|
||||||
RELAYS,
|
RELAYS,
|
||||||
FOLLOWS,
|
FOLLOWS,
|
||||||
REACTION,
|
REACTION,
|
||||||
AUTH_JOIN,
|
AUTH_JOIN,
|
||||||
|
GROUP_JOIN,
|
||||||
|
GROUP_LEAVE,
|
||||||
|
GROUP_CREATE,
|
||||||
|
GROUP_EDIT_META,
|
||||||
|
GROUPS,
|
||||||
|
COMMENT,
|
||||||
isSignedEvent,
|
isSignedEvent,
|
||||||
createEvent,
|
createEvent,
|
||||||
displayProfile,
|
displayProfile,
|
||||||
@@ -15,16 +23,17 @@ import {
|
|||||||
makeList,
|
makeList,
|
||||||
addToListPublicly,
|
addToListPublicly,
|
||||||
removeFromListByPredicate,
|
removeFromListByPredicate,
|
||||||
|
getTag,
|
||||||
getListTags,
|
getListTags,
|
||||||
getRelayTags,
|
getRelayTags,
|
||||||
isShareableRelayUrl,
|
isShareableRelayUrl,
|
||||||
getRelayTagValues,
|
getRelayTagValues,
|
||||||
|
toNostrURI,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import type {TrustedEvent, EventTemplate, List} from "@welshman/util"
|
import type {TrustedEvent, EventContent, EventTemplate, List} from "@welshman/util"
|
||||||
import type {SubscribeRequestWithHandlers, Subscription} from "@welshman/net"
|
import type {SubscribeRequestWithHandlers} from "@welshman/net"
|
||||||
import {PublishStatus, AuthStatus, SocketStatus, SubscriptionEvent} from "@welshman/net"
|
import {PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
|
||||||
import {Nip59, makeSecret, stamp, Nip46Broker} from "@welshman/signer"
|
import {Nip59, makeSecret, stamp, Nip46Broker} from "@welshman/signer"
|
||||||
import type {Nip46Handler} from "@welshman/signer"
|
|
||||||
import {
|
import {
|
||||||
pubkey,
|
pubkey,
|
||||||
signer,
|
signer,
|
||||||
@@ -39,26 +48,29 @@ import {
|
|||||||
loadFollows,
|
loadFollows,
|
||||||
loadMutes,
|
loadMutes,
|
||||||
tagEvent,
|
tagEvent,
|
||||||
tagReactionTo,
|
tagEventForReaction,
|
||||||
getRelayUrls,
|
getRelayUrls,
|
||||||
userRelaySelections,
|
userRelaySelections,
|
||||||
userInboxRelaySelections,
|
userInboxRelaySelections,
|
||||||
nip44EncryptToSelf,
|
nip44EncryptToSelf,
|
||||||
loadRelay,
|
loadRelay,
|
||||||
addSession,
|
addSession,
|
||||||
nip46Perms,
|
clearStorage,
|
||||||
subscribe,
|
dropSession,
|
||||||
|
tagEventForComment,
|
||||||
|
tagEventForQuote,
|
||||||
} from "@welshman/app"
|
} from "@welshman/app"
|
||||||
|
import type {Thunk} from "@welshman/app"
|
||||||
import {
|
import {
|
||||||
COMMENT,
|
|
||||||
tagRoom,
|
tagRoom,
|
||||||
|
PROTECTED,
|
||||||
userMembership,
|
userMembership,
|
||||||
MEMBERSHIPS,
|
|
||||||
INDEXER_RELAYS,
|
INDEXER_RELAYS,
|
||||||
|
NIP46_PERMS,
|
||||||
loadMembership,
|
loadMembership,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
getDefaultPubkeys,
|
getDefaultPubkeys,
|
||||||
getMembershipUrls,
|
userRoomsByUrl,
|
||||||
} from "@app/state"
|
} from "@app/state"
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
@@ -78,89 +90,108 @@ export const getPubkeyPetname = (pubkey: string) => {
|
|||||||
return display
|
return display
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makeMention = (pubkey: string, hints?: string[]) => [
|
export const getThunkError = async (thunk: Thunk) => {
|
||||||
"p",
|
const result = await thunk.result
|
||||||
pubkey,
|
const [{status, message}] = Object.values(result) as any
|
||||||
choice(hints || getPubkeyHints(pubkey)),
|
|
||||||
getPubkeyPetname(pubkey),
|
|
||||||
]
|
|
||||||
|
|
||||||
export const makeIMeta = (url: string, data: Record<string, string>) => [
|
if (status !== PublishStatus.Success) {
|
||||||
"imeta",
|
return message
|
||||||
`url ${url}`,
|
}
|
||||||
...Object.entries(data).map(([k, v]) => [k, v].join(" ")),
|
}
|
||||||
]
|
|
||||||
|
|
||||||
export const subscribePersistent = (request: SubscribeRequestWithHandlers) => {
|
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
|
||||||
let sub: Subscription
|
if (parent) {
|
||||||
let done = false
|
const nevent = nip19.neventEncode({
|
||||||
|
id: parent.id,
|
||||||
|
kind: parent.kind,
|
||||||
|
author: parent.pubkey,
|
||||||
|
relays: ctx.app.router.Event(parent).limit(3).getUrls(),
|
||||||
|
})
|
||||||
|
|
||||||
const start = async () => {
|
tags = [...tags, tagEventForQuote(parent)]
|
||||||
// If the subscription gets closed quickly, don't start flapping
|
content = toNostrURI(nevent) + "\n\n" + content
|
||||||
await Promise.all([
|
|
||||||
sleep(30_000),
|
|
||||||
new Promise(resolve => {
|
|
||||||
sub = subscribe(request)
|
|
||||||
sub.emitter.on(SubscriptionEvent.Complete, resolve)
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!done) {
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start()
|
return {content, tags}
|
||||||
|
|
||||||
return () => {
|
|
||||||
done = true
|
|
||||||
sub?.close()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log in
|
// Log in
|
||||||
|
|
||||||
export const loginWithNip46 = async (token: string, handler: Nip46Handler) => {
|
export const loginWithNip46 = async ({
|
||||||
const secret = makeSecret()
|
relays,
|
||||||
const broker = Nip46Broker.get({secret, handler})
|
signerPubkey,
|
||||||
const result = await broker.connect(token, nip46Perms)
|
clientSecret = makeSecret(),
|
||||||
|
connectSecret = "",
|
||||||
|
}: {
|
||||||
|
relays: string[]
|
||||||
|
signerPubkey: string
|
||||||
|
clientSecret?: string
|
||||||
|
connectSecret?: string
|
||||||
|
}) => {
|
||||||
|
const broker = Nip46Broker.get({relays, clientSecret, signerPubkey})
|
||||||
|
const result = await broker.connect(connectSecret, NIP46_PERMS)
|
||||||
|
|
||||||
if (!result) return false
|
// TODO: remove ack result
|
||||||
|
if (!["ack", connectSecret].includes(result)) return false
|
||||||
|
|
||||||
const pubkey = await broker.getPublicKey()
|
const pubkey = await broker.getPublicKey()
|
||||||
|
|
||||||
if (!pubkey) return false
|
if (!pubkey) return false
|
||||||
|
|
||||||
addSession({method: "nip46", pubkey, secret, handler})
|
await loadUserData(pubkey)
|
||||||
|
|
||||||
|
const handler = {relays, pubkey: signerPubkey}
|
||||||
|
|
||||||
|
addSession({method: "nip46", pubkey, secret: clientSecret, handler})
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log out
|
||||||
|
|
||||||
|
export const logout = async () => {
|
||||||
|
const $pubkey = pubkey.get()
|
||||||
|
|
||||||
|
if ($pubkey) {
|
||||||
|
dropSession($pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
await clearStorage()
|
||||||
|
|
||||||
|
localStorage.clear()
|
||||||
|
}
|
||||||
|
|
||||||
// Loaders
|
// Loaders
|
||||||
|
|
||||||
export const loadUserData = (
|
export const loadUserData = (
|
||||||
pubkey: string,
|
pubkey: string,
|
||||||
request: Partial<SubscribeRequestWithHandlers> = {},
|
request: Partial<SubscribeRequestWithHandlers> = {},
|
||||||
) => {
|
) => {
|
||||||
const promise = Promise.all([
|
const promise = Promise.race([
|
||||||
loadInboxRelaySelections(pubkey, request),
|
sleep(3000),
|
||||||
loadMembership(pubkey, request),
|
Promise.all([
|
||||||
loadSettings(pubkey, request),
|
loadInboxRelaySelections(pubkey, request),
|
||||||
loadProfile(pubkey, request),
|
loadMembership(pubkey, request),
|
||||||
loadFollows(pubkey, request),
|
loadSettings(pubkey, request),
|
||||||
loadMutes(pubkey, request),
|
loadProfile(pubkey, request),
|
||||||
|
loadFollows(pubkey, request),
|
||||||
|
loadMutes(pubkey, request),
|
||||||
|
]),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Load followed profiles slowly in the background without clogging other stuff up
|
// Load followed profiles slowly in the background without clogging other stuff up. Only use a single
|
||||||
|
// indexer relay to avoid too many redundant validations, which slow things down and eat bandwidth
|
||||||
promise.then(async () => {
|
promise.then(async () => {
|
||||||
for (const pubkeys of chunk(50, getDefaultPubkeys())) {
|
for (const pubkeys of chunk(50, getDefaultPubkeys())) {
|
||||||
await sleep(300)
|
const relays = sample(1, INDEXER_RELAYS)
|
||||||
|
|
||||||
|
await sleep(1000)
|
||||||
|
|
||||||
for (const pubkey of pubkeys) {
|
for (const pubkey of pubkeys) {
|
||||||
loadMembership(pubkey)
|
loadMembership(pubkey, {relays})
|
||||||
loadProfile(pubkey)
|
loadProfile(pubkey, {relays})
|
||||||
loadFollows(pubkey)
|
loadFollows(pubkey, {relays})
|
||||||
loadMutes(pubkey)
|
loadMutes(pubkey, {relays})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -185,10 +216,37 @@ export const broadcastUserData = async (relays: string[]) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NIP 29 stuff
|
||||||
|
|
||||||
|
export const nip29 = {
|
||||||
|
createRoom: (url: string, room: string) => {
|
||||||
|
const event = createEvent(GROUP_CREATE, {tags: [tagRoom(room, url)]})
|
||||||
|
|
||||||
|
return publishThunk({event, relays: [url]})
|
||||||
|
},
|
||||||
|
editMeta: (url: string, room: string, meta: Record<string, string>) => {
|
||||||
|
const event = createEvent(GROUP_EDIT_META, {
|
||||||
|
tags: [tagRoom(room, url), ...Object.entries(meta)],
|
||||||
|
})
|
||||||
|
|
||||||
|
return publishThunk({event, relays: [url]})
|
||||||
|
},
|
||||||
|
joinRoom: (url: string, room: string) => {
|
||||||
|
const event = createEvent(GROUP_JOIN, {tags: [tagRoom(room, url)]})
|
||||||
|
|
||||||
|
return publishThunk({event, relays: [url]})
|
||||||
|
},
|
||||||
|
leaveRoom: (url: string, room: string) => {
|
||||||
|
const event = createEvent(GROUP_LEAVE, {tags: [tagRoom(room, url)]})
|
||||||
|
|
||||||
|
return publishThunk({event, relays: [url]})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// List updates
|
// List updates
|
||||||
|
|
||||||
export const addSpaceMembership = async (url: string) => {
|
export const addSpaceMembership = async (url: string) => {
|
||||||
const list = get(userMembership) || makeList({kind: MEMBERSHIPS})
|
const list = get(userMembership) || makeList({kind: GROUPS})
|
||||||
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
|
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
|
||||||
const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
||||||
|
|
||||||
@@ -196,7 +254,7 @@ export const addSpaceMembership = async (url: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const removeSpaceMembership = async (url: string) => {
|
export const removeSpaceMembership = async (url: string) => {
|
||||||
const list = get(userMembership) || makeList({kind: MEMBERSHIPS})
|
const list = get(userMembership) || makeList({kind: GROUPS})
|
||||||
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url
|
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url
|
||||||
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
|
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
|
||||||
const relays = uniq([
|
const relays = uniq([
|
||||||
@@ -208,17 +266,21 @@ export const removeSpaceMembership = async (url: string) => {
|
|||||||
return publishThunk({event, relays})
|
return publishThunk({event, relays})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addRoomMembership = async (url: string, room: string) => {
|
export const addRoomMembership = async (url: string, room: string, name: string) => {
|
||||||
const list = get(userMembership) || makeList({kind: MEMBERSHIPS})
|
const list = get(userMembership) || makeList({kind: GROUPS})
|
||||||
const event = await addToListPublicly(list, tagRoom(room, url)).reconcile(nip44EncryptToSelf)
|
const newTags = [
|
||||||
|
["r", url],
|
||||||
|
["group", room, url, name],
|
||||||
|
]
|
||||||
|
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
|
||||||
const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
||||||
|
|
||||||
return publishThunk({event, relays})
|
return publishThunk({event, relays})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const removeRoomMembership = async (url: string, room: string) => {
|
export const removeRoomMembership = async (url: string, room: string) => {
|
||||||
const list = get(userMembership) || makeList({kind: MEMBERSHIPS})
|
const list = get(userMembership) || makeList({kind: GROUPS})
|
||||||
const pred = (t: string[]) => equals(tagRoom(room, url), t)
|
const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
|
||||||
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
|
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
|
||||||
const relays = uniq([
|
const relays = uniq([
|
||||||
url,
|
url,
|
||||||
@@ -247,7 +309,7 @@ export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
|
|||||||
url,
|
url,
|
||||||
...INDEXER_RELAYS,
|
...INDEXER_RELAYS,
|
||||||
...ctx.app.router.FromUser().getUrls(),
|
...ctx.app.router.FromUser().getUrls(),
|
||||||
...getMembershipUrls(userMembership.get()),
|
...userRoomsByUrl.get().keys(),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -268,7 +330,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
|
|||||||
relays: [
|
relays: [
|
||||||
...INDEXER_RELAYS,
|
...INDEXER_RELAYS,
|
||||||
...ctx.app.router.FromUser().getUrls(),
|
...ctx.app.router.FromUser().getUrls(),
|
||||||
...getMembershipUrls(userMembership.get()),
|
...userRoomsByUrl.get().keys(),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -288,13 +350,17 @@ export const checkRelayAccess = async (url: string, claim = "") => {
|
|||||||
|
|
||||||
const result = await thunk.result
|
const result = await thunk.result
|
||||||
|
|
||||||
if (result[url].status !== PublishStatus.Success) {
|
if (result[url].status === PublishStatus.Failure) {
|
||||||
const message =
|
const message =
|
||||||
connection.auth.message?.replace(/^.*: /, "") ||
|
connection.auth.message?.replace(/^.*: /, "") ||
|
||||||
result[url].message?.replace(/^.*: /, "") ||
|
result[url].message?.replace(/^.*: /, "") ||
|
||||||
"join request rejected"
|
"join request rejected"
|
||||||
|
|
||||||
return `Failed to join relay (${message})`
|
// If it's a strict NIP 29 relay don't worry about requesting access
|
||||||
|
// TODO: remove this if relay29 ever gets less strict
|
||||||
|
if (message !== "missing group (`h`) tag") {
|
||||||
|
return `Failed to join relay (${message})`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,10 +396,14 @@ export const checkRelayAuth = async (url: string, timeout = 3000) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const attemptRelayAccess = async (url: string, claim = "") => {
|
export const attemptRelayAccess = async (url: string, claim = "") => {
|
||||||
const checks = [checkRelayProfile, checkRelayConnection, checkRelayAccess, checkRelayAuth]
|
const checks = [
|
||||||
|
() => checkRelayConnection(url),
|
||||||
|
() => checkRelayAccess(url, claim),
|
||||||
|
() => checkRelayAuth(url),
|
||||||
|
]
|
||||||
|
|
||||||
for (const check of checks) {
|
for (const check of checks) {
|
||||||
const error = await check(url)
|
const error = await check()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return error
|
return error
|
||||||
@@ -365,55 +435,72 @@ export const sendWrapped = async ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const makeDelete = ({event}: {event: TrustedEvent}) => {
|
||||||
|
const tags = [["k", String(event.kind)], ...tagEvent(event)]
|
||||||
|
const groupTag = getTag("h", event.tags)
|
||||||
|
|
||||||
|
if (groupTag) {
|
||||||
|
tags.push(PROTECTED)
|
||||||
|
tags.push(groupTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createEvent(DELETE, {tags})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publishDelete = ({relays, event}: {relays: string[]; event: TrustedEvent}) =>
|
||||||
|
publishThunk({event: makeDelete({event}), relays})
|
||||||
|
|
||||||
|
export type ReportParams = {
|
||||||
|
event: TrustedEvent
|
||||||
|
content: string
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeReport = ({event, reason, content}: ReportParams) => {
|
||||||
|
const tags = [
|
||||||
|
["p", event.pubkey],
|
||||||
|
["e", event.id, reason],
|
||||||
|
]
|
||||||
|
|
||||||
|
return createEvent(REPORT, {content, tags})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publishReport = ({
|
||||||
|
relays,
|
||||||
|
event,
|
||||||
|
reason,
|
||||||
|
content,
|
||||||
|
}: ReportParams & {relays: string[]}) =>
|
||||||
|
publishThunk({event: makeReport({event, reason, content}), relays})
|
||||||
|
|
||||||
export type ReactionParams = {
|
export type ReactionParams = {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
content: string
|
content: string
|
||||||
tags?: string[][]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makeReaction = ({event, content, tags = []}: ReactionParams) =>
|
export const makeReaction = ({event, content}: ReactionParams) => {
|
||||||
createEvent(REACTION, {content, tags: [...tags, ...tagReactionTo(event)]})
|
const tags = tagEventForReaction(event)
|
||||||
|
const groupTag = getTag("h", event.tags)
|
||||||
|
|
||||||
|
if (groupTag) {
|
||||||
|
tags.push(PROTECTED)
|
||||||
|
tags.push(groupTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createEvent(REACTION, {content, tags})
|
||||||
|
}
|
||||||
|
|
||||||
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
|
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
|
||||||
publishThunk({event: makeReaction(params), relays})
|
publishThunk({event: makeReaction(params), relays})
|
||||||
|
|
||||||
export type ReplyParams = {
|
export type CommentParams = {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
content: string
|
content: string
|
||||||
tags?: string[][]
|
tags?: string[][]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makeComment = ({event, content, tags = []}: ReplyParams) => {
|
export const makeComment = ({event, content, tags = []}: CommentParams) =>
|
||||||
const seenRoots = new Set<string>()
|
createEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event)]})
|
||||||
|
|
||||||
for (const [raw, ...tag] of event.tags.filter(t => t[0].match(/^(k|e|a|i)$/i))) {
|
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
|
||||||
const T = raw.toUpperCase()
|
|
||||||
const t = raw.toLowerCase()
|
|
||||||
|
|
||||||
if (seenRoots.has(T)) {
|
|
||||||
tags.push([t, ...tag])
|
|
||||||
} else {
|
|
||||||
tags.push([T, ...tag])
|
|
||||||
seenRoots.add(T)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seenRoots.size === 0) {
|
|
||||||
tags.push(["K", String(event.kind)])
|
|
||||||
tags.push(["E", event.id])
|
|
||||||
} else {
|
|
||||||
tags.push(["k", String(event.kind)])
|
|
||||||
tags.push(["e", event.id])
|
|
||||||
}
|
|
||||||
|
|
||||||
return createEvent(COMMENT, {content, tags})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const publishComment = ({relays, ...params}: ReplyParams & {relays: string[]}) =>
|
|
||||||
publishThunk({event: makeComment(params), relays})
|
publishThunk({event: makeComment(params), relays})
|
||||||
|
|
||||||
export const makeDelete = ({event}: {event: TrustedEvent}) =>
|
|
||||||
createEvent(DELETE, {tags: [["k", String(event.kind)], ...tagEvent(event)]})
|
|
||||||
|
|
||||||
export const publishDelete = ({relays, event}: {relays: string[]; event: TrustedEvent}) =>
|
|
||||||
publishThunk({event: makeDelete({event}), relays})
|
|
||||||
|
|||||||
@@ -4,7 +4,26 @@
|
|||||||
import Landing from "@app/components/Landing.svelte"
|
import Landing from "@app/components/Landing.svelte"
|
||||||
import Toast from "@app/components/Toast.svelte"
|
import Toast from "@app/components/Toast.svelte"
|
||||||
import PrimaryNav from "@app/components/PrimaryNav.svelte"
|
import PrimaryNav from "@app/components/PrimaryNav.svelte"
|
||||||
import {modals} from "@app/modal"
|
import EmailConfirm from "@app/components/EmailConfirm.svelte"
|
||||||
|
import PasswordReset from "@app/components/PasswordReset.svelte"
|
||||||
|
import {BURROW_URL} from "@app/state"
|
||||||
|
import {modals, pushModal} from "@app/modal"
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-screen overflow-hidden">
|
<div class="flex h-screen overflow-hidden">
|
||||||
|
|||||||
@@ -1,54 +1,50 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import type {Readable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
import {createEditor, type Editor, EditorContent} from "svelte-tiptap"
|
import {EditorContent} from "svelte-tiptap"
|
||||||
import {isMobile} from "@lib/html"
|
import {isMobile} from "@lib/html"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import {getEditorOptions, getEditorTags} from "@lib/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
import {getPubkeyHints} from "@app/commands"
|
|
||||||
|
|
||||||
export let onSubmit
|
export let onSubmit: any
|
||||||
export let content = ""
|
export let content = ""
|
||||||
|
|
||||||
let editor: Readable<Editor>
|
export const focus = () => $editor.chain().focus().run()
|
||||||
|
|
||||||
|
const uploading = writable(false)
|
||||||
|
|
||||||
|
const uploadFiles = () => $editor!.chain().selectFiles().run()
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if ($loading) return
|
if ($uploading) return
|
||||||
|
|
||||||
onSubmit({
|
const content = $editor!.getText({blockSeparator: "\n"}).trim()
|
||||||
content: $editor.getText({blockSeparator: "\n"}),
|
const tags = $editor!.storage.nostr.getEditorTags()
|
||||||
tags: getEditorTags($editor),
|
|
||||||
})
|
|
||||||
|
|
||||||
$editor.chain().clearContent().run()
|
if (!content) return
|
||||||
|
|
||||||
|
onSubmit({content, tags})
|
||||||
|
|
||||||
|
$editor!.chain().clearContent().run()
|
||||||
}
|
}
|
||||||
|
|
||||||
$: loading = $editor?.storage.fileUpload.loading
|
const editor = makeEditor({autofocus: !isMobile, submit, uploading, aggressive: true})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
editor = createEditor(
|
$editor!.chain().setContent(content).run()
|
||||||
getEditorOptions({
|
|
||||||
submit,
|
|
||||||
getPubkeyHints,
|
|
||||||
submitOnEnter: true,
|
|
||||||
autofocus: !isMobile,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
$editor.commands.setContent(content)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
class="relative z-feature flex gap-2 p-2"
|
class="relative z-feature flex gap-2 p-2"
|
||||||
on:submit|preventDefault={$loading ? undefined : submit}>
|
on:submit|preventDefault={$uploading ? undefined : submit}>
|
||||||
<Button
|
<Button
|
||||||
data-tip="Add an image"
|
data-tip="Add an image"
|
||||||
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
|
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
|
||||||
disabled={$loading}
|
disabled={$uploading}
|
||||||
on:click={$editor.commands.selectFiles}>
|
on:click={uploadFiles}>
|
||||||
{#if $loading}
|
{#if $uploading}
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon="gallery-send" />
|
<Icon icon="gallery-send" />
|
||||||
@@ -57,4 +53,11 @@
|
|||||||
<div class="chat-editor flex-grow overflow-hidden">
|
<div class="chat-editor flex-grow overflow-hidden">
|
||||||
<EditorContent editor={$editor} />
|
<EditorContent editor={$editor} />
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
||||||
|
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
|
||||||
|
disabled={$uploading}
|
||||||
|
on:click={submit}>
|
||||||
|
<Icon icon="plain" />
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {displayProfileByPubkey} from "@welshman/app"
|
||||||
|
import {slide} from "@lib/transition"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Content from "@app/components/Content.svelte"
|
||||||
|
|
||||||
|
export let event: TrustedEvent
|
||||||
|
export let clear: () => void
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
|
||||||
|
transition:slide>
|
||||||
|
<p class="text-primary">Replying to @{displayProfileByPubkey(event.pubkey)}</p>
|
||||||
|
{#key event.id}
|
||||||
|
<Content {event} minLength={100} maxLength={300} expandMode="disabled" />
|
||||||
|
{/key}
|
||||||
|
<Button class="absolute right-2 top-2 cursor-pointer" on:click={clear}>
|
||||||
|
<Icon icon="close-circle" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {sortBy, append} from "@welshman/lib"
|
|
||||||
import type {EventContent, TrustedEvent} from "@welshman/util"
|
|
||||||
import {repository} from "@welshman/app"
|
|
||||||
import {deriveEvents} from "@welshman/store"
|
|
||||||
import ChannelMessage from "@app/components/ChannelMessage.svelte"
|
|
||||||
import ChannelCompose from "@app/components/ChannelCompose.svelte"
|
|
||||||
import {tagRoom, COMMENT} from "@app/state"
|
|
||||||
import {publishComment} from "@app/commands"
|
|
||||||
|
|
||||||
export let url, room, event: TrustedEvent
|
|
||||||
|
|
||||||
const replies = deriveEvents(repository, {
|
|
||||||
filters: [{kinds: [COMMENT], "#E": [event.id]}],
|
|
||||||
})
|
|
||||||
|
|
||||||
const onSubmit = ({content, tags}: EventContent) =>
|
|
||||||
publishComment({
|
|
||||||
event,
|
|
||||||
content,
|
|
||||||
tags: append(tagRoom(room, url), tags),
|
|
||||||
relays: [url],
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="col-2">
|
|
||||||
<div class="overflow-auto pt-3">
|
|
||||||
<ChannelMessage {url} {room} {event} showPubkey isHead inert />
|
|
||||||
{#each sortBy(e => e.created_at, $replies) as reply (reply.id)}
|
|
||||||
<ChannelMessage {url} {room} event={reply} showPubkey inert />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="bottom-0 left-0 right-0">
|
|
||||||
<ChannelCompose {onSubmit} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,115 +1,109 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {readable} from "svelte/store"
|
|
||||||
import {hash} from "@welshman/lib"
|
import {hash} from "@welshman/lib"
|
||||||
|
import {now} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {
|
import {
|
||||||
thunks,
|
thunks,
|
||||||
|
pubkey,
|
||||||
deriveProfile,
|
deriveProfile,
|
||||||
deriveProfileDisplay,
|
deriveProfileDisplay,
|
||||||
|
formatTimestampAsDate,
|
||||||
formatTimestampAsTime,
|
formatTimestampAsTime,
|
||||||
pubkey,
|
|
||||||
} from "@welshman/app"
|
} from "@welshman/app"
|
||||||
import {isMobile} from "@lib/html"
|
import {isMobile} from "@lib/html"
|
||||||
import LongPress from "@lib/components/LongPress.svelte"
|
import LongPress from "@lib/components/LongPress.svelte"
|
||||||
import Avatar from "@lib/components/Avatar.svelte"
|
import Avatar from "@lib/components/Avatar.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
import ThunkStatus from "@app/components/ThunkStatus.svelte"
|
import ThunkStatus from "@app/components/ThunkStatus.svelte"
|
||||||
import ReplySummary from "@app/components/ReplySummary.svelte"
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||||
import ChannelConversation from "@app/components/ChannelConversation.svelte"
|
|
||||||
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
|
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
|
||||||
import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte"
|
import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte"
|
||||||
import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte"
|
import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte"
|
||||||
import {colors, tagRoom, deriveEvent, pubkeyLink} from "@app/state"
|
import {colors} from "@app/state"
|
||||||
import {publishDelete, publishReaction} from "@app/commands"
|
import {publishDelete, publishReaction} from "@app/commands"
|
||||||
import {pushDrawer, pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
export let url, room
|
export let url, room
|
||||||
export let event: TrustedEvent
|
export let event: TrustedEvent
|
||||||
|
export let replyTo: any = undefined
|
||||||
export let showPubkey = false
|
export let showPubkey = false
|
||||||
export let isHead = false
|
|
||||||
export let inert = false
|
export let inert = false
|
||||||
|
|
||||||
const thunk = $thunks[event.id]
|
const thunk = $thunks[event.id]
|
||||||
|
const today = formatTimestampAsDate(now())
|
||||||
const profile = deriveProfile(event.pubkey)
|
const profile = deriveProfile(event.pubkey)
|
||||||
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
||||||
const rootTag = event.tags.find(t => t[0].match(/^e$/i))
|
|
||||||
const rootId = rootTag?.[1]
|
|
||||||
const rootHints = [rootTag?.[2]].filter(Boolean) as string[]
|
|
||||||
const rootEvent = rootId ? deriveEvent(rootId, rootHints) : readable(null)
|
|
||||||
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
|
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
|
||||||
|
|
||||||
const onClick = () => {
|
const reply = () => replyTo(event)
|
||||||
const root = $rootEvent || event
|
|
||||||
|
|
||||||
pushDrawer(ChannelConversation, {url, room, event: root})
|
|
||||||
}
|
|
||||||
|
|
||||||
const onLongPress = () => pushModal(ChannelMessageMenuMobile, {url, event})
|
const onLongPress = () => pushModal(ChannelMessageMenuMobile, {url, event})
|
||||||
|
|
||||||
|
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
|
||||||
|
|
||||||
const onReactionClick = (content: string, events: TrustedEvent[]) => {
|
const onReactionClick = (content: string, events: TrustedEvent[]) => {
|
||||||
const reaction = events.find(e => e.pubkey === $pubkey)
|
const reaction = events.find(e => e.pubkey === $pubkey)
|
||||||
|
|
||||||
if (reaction) {
|
if (reaction) {
|
||||||
publishDelete({relays: [url], event: reaction})
|
publishDelete({relays: [url], event: reaction})
|
||||||
} else {
|
} else {
|
||||||
publishReaction({
|
publishReaction({event, content, relays: [url]})
|
||||||
event,
|
|
||||||
content,
|
|
||||||
relays: [url],
|
|
||||||
tags: [tagRoom(room, url)],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<LongPress
|
<LongPress
|
||||||
on:click={isMobile || inert ? null : onClick}
|
data-event={event.id}
|
||||||
onLongPress={inert ? null : onLongPress}
|
onLongPress={inert ? null : onLongPress}
|
||||||
class="group relative flex w-full flex-col gap-1 p-2 text-left transition-colors {inert
|
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left">
|
||||||
? 'hover:bg-base-300'
|
|
||||||
: ''}">
|
|
||||||
<div class="flex w-full gap-3 overflow-auto">
|
<div class="flex w-full gap-3 overflow-auto">
|
||||||
{#if showPubkey}
|
{#if showPubkey}
|
||||||
<Link external href={pubkeyLink(event.pubkey)} class="flex items-start">
|
<Button on:click={openProfile} class="flex items-start">
|
||||||
<Avatar src={$profile?.picture} class="border border-solid border-base-content" size={10} />
|
<Avatar src={$profile?.picture} class="border border-solid border-base-content" size={8} />
|
||||||
</Link>
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="w-10 min-w-10 max-w-10" />
|
<div class="w-8 min-w-8 max-w-8" />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="-mt-1 min-w-0 flex-grow pr-1">
|
<div class="min-w-0 flex-grow pr-1">
|
||||||
{#if showPubkey}
|
{#if showPubkey}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Link
|
<Button on:click={openProfile} class="text-sm font-bold" style="color: {colorValue}">
|
||||||
external
|
|
||||||
href={pubkeyLink(event.pubkey)}
|
|
||||||
class="text-sm font-bold"
|
|
||||||
style="color: {colorValue}">
|
|
||||||
{$profileDisplay}
|
{$profileDisplay}
|
||||||
</Link>
|
</Button>
|
||||||
<span class="text-xs opacity-50">{formatTimestampAsTime(event.created_at)}</span>
|
<span class="text-xs opacity-50">
|
||||||
|
{#if formatTimestampAsDate(event.created_at) === today}
|
||||||
|
Today
|
||||||
|
{:else}
|
||||||
|
{formatTimestampAsDate(event.created_at)}
|
||||||
|
{/if}
|
||||||
|
at {formatTimestampAsTime(event.created_at)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<Content {event} />
|
<Content {event} quoteProps={{minimal: true, relays: [url]}} />
|
||||||
{#if thunk}
|
{#if thunk}
|
||||||
<ThunkStatus {thunk} class="mt-2" />
|
<ThunkStatus {thunk} class="mt-2" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-2 ml-12">
|
<div class="row-2 ml-10 mt-1">
|
||||||
{#if !isHead}
|
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right" />
|
||||||
<ReplySummary relays={[url]} {event} on:click={onClick} />
|
|
||||||
{/if}
|
|
||||||
<ReactionSummary relays={[url]} {event} {onReactionClick} />
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
|
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
|
||||||
class:group-hover:opacity-100={!isMobile}
|
class:group-hover:opacity-100={!isMobile}
|
||||||
on:click|stopPropagation>
|
on:click|stopPropagation>
|
||||||
<ChannelMessageEmojiButton {url} {room} {event} />
|
<ChannelMessageEmojiButton {url} {room} {event} />
|
||||||
|
{#if replyTo}
|
||||||
|
<Button class="btn join-item btn-xs" on:click={reply}>
|
||||||
|
<Icon icon="reply" size={4} />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
<ChannelMessageMenuButton {url} {event} />
|
<ChannelMessageMenuButton {url} {event} />
|
||||||
</button>
|
</button>
|
||||||
</LongPress>
|
</LongPress>
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {noop} from "@welshman/lib"
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import {tagRoom} from "@app/state"
|
|
||||||
import {publishReaction} from "@app/commands"
|
import {publishReaction} from "@app/commands"
|
||||||
|
|
||||||
export let url, room, event
|
export let url, room, event
|
||||||
|
|
||||||
|
// Tell svelte-check to shut up
|
||||||
|
noop(room)
|
||||||
|
|
||||||
const onEmoji = (emoji: NativeEmoji) =>
|
const onEmoji = (emoji: NativeEmoji) =>
|
||||||
publishReaction({
|
publishReaction({event, relays: [url], content: emoji.unicode})
|
||||||
event,
|
|
||||||
relays: [url],
|
|
||||||
content: emoji.unicode,
|
|
||||||
tags: [tagRoom(room, url)],
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<EmojiButton {onEmoji} class="btn join-item btn-xs">
|
<EmojiButton {onEmoji} class="btn join-item btn-xs">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import EventInfo from "@app/components/EventInfo.svelte"
|
import EventInfo from "@app/components/EventInfo.svelte"
|
||||||
|
import EventReport from "@app/components/EventReport.svelte"
|
||||||
import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
|
import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
@@ -10,6 +11,11 @@
|
|||||||
export let event
|
export let event
|
||||||
export let onClick
|
export let onClick
|
||||||
|
|
||||||
|
const report = () => {
|
||||||
|
onClick()
|
||||||
|
pushModal(EventReport, {url, event})
|
||||||
|
}
|
||||||
|
|
||||||
const showInfo = () => {
|
const showInfo = () => {
|
||||||
onClick()
|
onClick()
|
||||||
pushModal(EventInfo, {event})
|
pushModal(EventInfo, {event})
|
||||||
@@ -35,5 +41,12 @@
|
|||||||
Delete Message
|
Delete Message
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li>
|
||||||
|
<Button class="text-error" on:click={report}>
|
||||||
|
<Icon size={4} icon="danger" />
|
||||||
|
Report Content
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
{/if}
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -5,31 +5,20 @@
|
|||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
|
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
|
||||||
import EventInfo from "@app/components/EventInfo.svelte"
|
import EventInfo from "@app/components/EventInfo.svelte"
|
||||||
import ChannelConversation from "@app/components/ChannelConversation.svelte"
|
|
||||||
import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
|
import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
|
||||||
import {publishReaction} from "@app/commands"
|
import {publishReaction} from "@app/commands"
|
||||||
import {pushModal, pushDrawer} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {tagRoom} from "@app/state"
|
|
||||||
|
|
||||||
export let url
|
export let url
|
||||||
export let room
|
|
||||||
export let event
|
export let event
|
||||||
|
|
||||||
const onEmoji = (emoji: NativeEmoji) => {
|
const onEmoji = (emoji: NativeEmoji) => {
|
||||||
history.back()
|
history.back()
|
||||||
publishReaction({
|
publishReaction({event, relays: [url], content: emoji.unicode})
|
||||||
event,
|
|
||||||
relays: [url],
|
|
||||||
content: emoji.unicode,
|
|
||||||
tags: [tagRoom(room, url)],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
|
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
|
||||||
|
|
||||||
const showConversation = () =>
|
|
||||||
pushDrawer(ChannelConversation, {url, room, event}, {replaceState: true})
|
|
||||||
|
|
||||||
const showInfo = () => pushModal(EventInfo, {event}, {replaceState: true})
|
const showInfo = () => pushModal(EventInfo, {event}, {replaceState: true})
|
||||||
|
|
||||||
const showDelete = () => pushModal(ConfirmDelete, {url, event})
|
const showDelete = () => pushModal(ConfirmDelete, {url, event})
|
||||||
@@ -40,10 +29,6 @@
|
|||||||
<Icon size={4} icon="smile-circle" />
|
<Icon size={4} icon="smile-circle" />
|
||||||
Send Reaction
|
Send Reaction
|
||||||
</Button>
|
</Button>
|
||||||
<Button class="btn btn-neutral w-full" on:click={showConversation}>
|
|
||||||
<Icon size={4} icon="reply" />
|
|
||||||
View Conversation
|
|
||||||
</Button>
|
|
||||||
<Button class="btn btn-neutral" on:click={showInfo}>
|
<Button class="btn btn-neutral" on:click={showInfo}>
|
||||||
<Icon size={4} icon="code-2" />
|
<Icon size={4} icon="code-2" />
|
||||||
Message Details
|
Message Details
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {GENERAL, channelsById, makeChannelId} from "@app/state"
|
||||||
|
|
||||||
|
export let url
|
||||||
|
export let room
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if room === GENERAL}
|
||||||
|
general
|
||||||
|
{:else}
|
||||||
|
{$channelsById.get(makeChannelId(url, room))?.name || room}
|
||||||
|
{/if}
|
||||||
@@ -13,13 +13,7 @@
|
|||||||
import {int, nthNe, MINUTE, sortBy, remove} from "@welshman/lib"
|
import {int, nthNe, MINUTE, sortBy, remove} from "@welshman/lib"
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util"
|
import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util"
|
||||||
import {
|
import {pubkey, formatTimestampAsDate, inboxRelaySelectionsByPubkey, load} from "@welshman/app"
|
||||||
pubkey,
|
|
||||||
formatTimestampAsDate,
|
|
||||||
inboxRelaySelectionsByPubkey,
|
|
||||||
load,
|
|
||||||
tagPubkey,
|
|
||||||
} from "@welshman/app"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
@@ -29,12 +23,14 @@
|
|||||||
import ProfileName from "@app/components/ProfileName.svelte"
|
import ProfileName from "@app/components/ProfileName.svelte"
|
||||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||||
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
import ProfileList from "@app/components/ProfileList.svelte"
|
import ProfileList from "@app/components/ProfileList.svelte"
|
||||||
import ChatMessage from "@app/components/ChatMessage.svelte"
|
import ChatMessage from "@app/components/ChatMessage.svelte"
|
||||||
import ChatCompose from "@app/components/ChannelCompose.svelte"
|
import ChatCompose from "@app/components/ChannelCompose.svelte"
|
||||||
import {userSettingValues, deriveChat, splitChatId, PLATFORM_NAME, pubkeyLink} from "@app/state"
|
import ChatComposeParent from "@app/components/ChannelComposeParent.svelte"
|
||||||
|
import {userSettingValues, deriveChat, splitChatId, PLATFORM_NAME} from "@app/state"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {sendWrapped} from "@app/commands"
|
import {sendWrapped, prependParent} from "@app/commands"
|
||||||
|
|
||||||
export let id
|
export let id
|
||||||
|
|
||||||
@@ -52,19 +48,32 @@
|
|||||||
const showMembers = () =>
|
const showMembers = () =>
|
||||||
pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`})
|
pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`})
|
||||||
|
|
||||||
const onSubmit = async ({content, ...params}: EventContent) => {
|
const replyTo = (event: TrustedEvent) => {
|
||||||
// Remove p tags since they result in forking the conversation
|
parent = event
|
||||||
const tags = [...params.tags.filter(nthNe(0, "p")), ...remove($pubkey!, pubkeys).map(tagPubkey)]
|
compose.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearParent = () => {
|
||||||
|
parent = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async ({content, tags}: EventContent) => {
|
||||||
await sendWrapped({
|
await sendWrapped({
|
||||||
pubkeys,
|
pubkeys,
|
||||||
template: createEvent(DIRECT_MESSAGE, {content, tags}),
|
template: createEvent(
|
||||||
|
DIRECT_MESSAGE,
|
||||||
|
prependParent(parent, {content, tags: tags.filter(nthNe(0, "p"))}),
|
||||||
|
),
|
||||||
delay: $userSettingValues.send_delay,
|
delay: $userSettingValues.send_delay,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
clearParent()
|
||||||
}
|
}
|
||||||
|
|
||||||
let loading = true
|
let loading = true
|
||||||
|
let parent: TrustedEvent | undefined
|
||||||
let elements: Element[] = []
|
let elements: Element[] = []
|
||||||
|
let compose: ChatCompose
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
elements = []
|
elements = []
|
||||||
@@ -112,10 +121,11 @@
|
|||||||
<div slot="title" class="flex flex-col gap-1 sm:flex-row sm:gap-2">
|
<div slot="title" class="flex flex-col gap-1 sm:flex-row sm:gap-2">
|
||||||
{#if others.length === 1}
|
{#if others.length === 1}
|
||||||
{@const pubkey = others[0]}
|
{@const pubkey = others[0]}
|
||||||
<Link external href={pubkeyLink(pubkey)} class="row-2">
|
{@const onClick = () => pushModal(ProfileDetail, {pubkey})}
|
||||||
|
<Button on:click={onClick} class="row-2">
|
||||||
<ProfileCircle {pubkey} size={5} />
|
<ProfileCircle {pubkey} size={5} />
|
||||||
<ProfileName {pubkey} />
|
<ProfileName {pubkey} />
|
||||||
</Link>
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ProfileCircles pubkeys={others} size={5} />
|
<ProfileCircles pubkeys={others} size={5} />
|
||||||
@@ -170,7 +180,7 @@
|
|||||||
{#if type === "date"}
|
{#if type === "date"}
|
||||||
<Divider>{value}</Divider>
|
<Divider>{value}</Divider>
|
||||||
{:else}
|
{:else}
|
||||||
<ChatMessage event={assertEvent(value)} {pubkeys} {showPubkey} />
|
<ChatMessage event={assertEvent(value)} {pubkeys} {showPubkey} {replyTo} />
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<p
|
<p
|
||||||
@@ -185,5 +195,8 @@
|
|||||||
<slot name="info" />
|
<slot name="info" />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChatCompose {onSubmit} />
|
{#if parent}
|
||||||
|
<ChatComposeParent event={parent} clear={clearParent} />
|
||||||
|
{/if}
|
||||||
|
<ChatCompose bind:this={compose} {onSubmit} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {goto} from "$app/navigation"
|
||||||
|
import {WRAP} from "@welshman/util"
|
||||||
|
import {repository} from "@welshman/app"
|
||||||
|
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 {canDecrypt, PLATFORM_NAME, ensureUnwrapped} from "@app/state"
|
||||||
|
import {clearModals} from "@app/modal"
|
||||||
|
|
||||||
|
export let next
|
||||||
|
|
||||||
|
let loading = false
|
||||||
|
|
||||||
|
const enableChat = async () => {
|
||||||
|
canDecrypt.set(true)
|
||||||
|
|
||||||
|
for (const event of repository.query([{kinds: [WRAP]}])) {
|
||||||
|
ensureUnwrapped(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearModals()
|
||||||
|
goto(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await enableChat()
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form class="column gap-4" on:submit|preventDefault={submit}>
|
||||||
|
<ModalHeader>
|
||||||
|
<div slot="title">Enable Messages</div>
|
||||||
|
<div slot="info">Do you want to enable direct messages?</div>
|
||||||
|
</ModalHeader>
|
||||||
|
<p>
|
||||||
|
By default, direct messages are disabled, since loading them requires
|
||||||
|
{PLATFORM_NAME} to download and decrypt a lot of data.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you'd like to enable them, please make sure your signer is set up to to auto-approve requests
|
||||||
|
to decrypt data.
|
||||||
|
</p>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="btn btn-link" on:click={back}>
|
||||||
|
<Icon icon="alt-arrow-left" />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||||
|
<Spinner {loading}>Enable Messages</Spinner>
|
||||||
|
<Icon icon="alt-arrow-right" />
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {remove, assoc} from "@welshman/lib"
|
import {remove} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {pubkey, loadInboxRelaySelections} from "@welshman/app"
|
import {pubkey, loadInboxRelaySelections} from "@welshman/app"
|
||||||
import {fade} from "@lib/transition"
|
import {fade} from "@lib/transition"
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||||
import {makeChatPath} from "@app/routes"
|
import {makeChatPath} from "@app/routes"
|
||||||
import {CHAT_FILTERS, deriveNotification} from "@app/notifications"
|
import {notifications} from "@app/notifications"
|
||||||
|
|
||||||
export let id: string
|
export let id: string
|
||||||
export let pubkeys: string[]
|
export let pubkeys: string[]
|
||||||
@@ -19,7 +19,6 @@
|
|||||||
const others = remove($pubkey!, pubkeys)
|
const others = remove($pubkey!, pubkeys)
|
||||||
const active = $page.params.chat === id
|
const active = $page.params.chat === id
|
||||||
const path = makeChatPath(pubkeys)
|
const path = makeChatPath(pubkeys)
|
||||||
const notification = deriveNotification(path, CHAT_FILTERS.map(assoc("authors", pubkeys)))
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
for (const pk of others) {
|
for (const pk of others) {
|
||||||
@@ -35,7 +34,10 @@
|
|||||||
<div class="flex flex-col justify-start gap-1">
|
<div class="flex flex-col justify-start gap-1">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
{#if others.length === 1}
|
{#if others.length === 0}
|
||||||
|
<ProfileCircle pubkey={$pubkey} size={5} />
|
||||||
|
Note to self
|
||||||
|
{:else if others.length === 1}
|
||||||
<ProfileCircle pubkey={others[0]} size={5} />
|
<ProfileCircle pubkey={others[0]} size={5} />
|
||||||
<ProfileName pubkey={others[0]} />
|
<ProfileName pubkey={others[0]} />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -47,7 +49,7 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if !active && $notification}
|
{#if !active && $notifications.has(path)}
|
||||||
<div class="h-2 w-2 rounded-full bg-primary" transition:fade />
|
<div class="h-2 w-2 rounded-full bg-primary" transition:fade />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import ChatStart from "@app/components/ChatStart.svelte"
|
||||||
|
import {setChecked} from "@app/notifications"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
|
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
|
||||||
|
|
||||||
|
const markAsRead = () => {
|
||||||
|
setChecked("/chat/*")
|
||||||
|
history.back()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="col-2">
|
||||||
|
<Button class="btn btn-primary" on:click={startChat}>
|
||||||
|
<Icon size={4} icon="add-circle" />
|
||||||
|
Start chat
|
||||||
|
</Button>
|
||||||
|
<Button class="btn btn-neutral" on:click={markAsRead}>
|
||||||
|
<Icon size={4} icon="check-circle" />
|
||||||
|
Mark all read
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
@@ -11,25 +11,27 @@
|
|||||||
} from "@welshman/app"
|
} from "@welshman/app"
|
||||||
import {isMobile} from "@lib/html"
|
import {isMobile} from "@lib/html"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Tippy from "@lib/components/Tippy.svelte"
|
import Tippy from "@lib/components/Tippy.svelte"
|
||||||
import LongPress from "@lib/components/LongPress.svelte"
|
import LongPress from "@lib/components/LongPress.svelte"
|
||||||
import Avatar from "@lib/components/Avatar.svelte"
|
import Avatar from "@lib/components/Avatar.svelte"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
import ReplySummary from "@app/components/ReplySummary.svelte"
|
|
||||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||||
import ThunkStatus from "@app/components/ThunkStatus.svelte"
|
import ThunkStatus from "@app/components/ThunkStatus.svelte"
|
||||||
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
|
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
|
||||||
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
|
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
|
||||||
import {colors, pubkeyLink} from "@app/state"
|
import {colors} from "@app/state"
|
||||||
import {makeDelete, makeReaction, sendWrapped} from "@app/commands"
|
import {makeDelete, makeReaction, sendWrapped} from "@app/commands"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
export let event: TrustedEvent
|
export let event: TrustedEvent
|
||||||
|
export let replyTo: any = undefined
|
||||||
export let pubkeys: string[]
|
export let pubkeys: string[]
|
||||||
export let showPubkey = false
|
export let showPubkey = false
|
||||||
|
|
||||||
const thunk = $thunks[event.id]
|
const thunk = $thunks[event.id]
|
||||||
|
const isOwn = event.pubkey === $pubkey
|
||||||
const profile = deriveProfile(event.pubkey)
|
const profile = deriveProfile(event.pubkey)
|
||||||
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
||||||
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
|
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
|
||||||
@@ -41,6 +43,8 @@
|
|||||||
await sendWrapped({template, pubkeys})
|
await sendWrapped({template, pubkeys})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
|
||||||
|
|
||||||
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys})
|
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys})
|
||||||
|
|
||||||
const togglePopover = () => {
|
const togglePopover = () => {
|
||||||
@@ -59,14 +63,15 @@
|
|||||||
<ThunkStatus {thunk} class="mt-1" />
|
<ThunkStatus {thunk} class="mt-1" />
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
|
data-event={event.id}
|
||||||
class="group chat flex items-center justify-end gap-1 px-2"
|
class="group chat flex items-center justify-end gap-1 px-2"
|
||||||
class:chat-start={event.pubkey !== $pubkey}
|
class:chat-start={!isOwn}
|
||||||
class:flex-row-reverse={event.pubkey !== $pubkey}
|
class:flex-row-reverse={!isOwn}
|
||||||
class:chat-end={event.pubkey === $pubkey}>
|
class:chat-end={isOwn}>
|
||||||
<Tippy
|
<Tippy
|
||||||
bind:popover
|
bind:popover
|
||||||
component={ChatMessageMenu}
|
component={ChatMessageMenu}
|
||||||
props={{event, pubkeys, popover}}
|
props={{event, pubkeys, popover, replyTo}}
|
||||||
params={{
|
params={{
|
||||||
interactive: true,
|
interactive: true,
|
||||||
trigger: "manual",
|
trigger: "manual",
|
||||||
@@ -85,28 +90,30 @@
|
|||||||
<Icon icon="menu-dots" size={4} />
|
<Icon icon="menu-dots" size={4} />
|
||||||
</button>
|
</button>
|
||||||
</Tippy>
|
</Tippy>
|
||||||
<div class="flex min-w-0 flex-col" class:items-end={event.pubkey === $pubkey}>
|
<div class="flex min-w-0 flex-col" class:items-end={isOwn}>
|
||||||
<LongPress
|
<LongPress
|
||||||
class="bg-alt chat-bubble mx-1 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
|
class="bg-alt chat-bubble mx-1 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
|
||||||
onLongPress={showMobileMenu}>
|
onLongPress={showMobileMenu}>
|
||||||
{#if showPubkey && event.pubkey !== $pubkey}
|
{#if showPubkey}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Link external href={pubkeyLink(event.pubkey)} class="flex items-center gap-1">
|
{#if !isOwn}
|
||||||
<Avatar
|
<Button on:click={openProfile} class="flex items-center gap-1">
|
||||||
src={$profile?.picture}
|
<Avatar
|
||||||
class="border border-solid border-base-content"
|
src={$profile?.picture}
|
||||||
size={4} />
|
class="border border-solid border-base-content"
|
||||||
<div class="flex items-center gap-2">
|
size={4} />
|
||||||
<Link
|
<div class="flex items-center gap-2">
|
||||||
external
|
<Button
|
||||||
href={pubkeyLink(event.pubkey)}
|
on:click={openProfile}
|
||||||
class="text-sm font-bold"
|
class="text-sm font-bold"
|
||||||
style="color: {colorValue}">
|
style="color: {colorValue}">
|
||||||
{$profileDisplay}
|
{$profileDisplay}
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Button>
|
||||||
<span class="text-xs opacity-50">{formatTimestampAsTime(event.created_at)}</span>
|
{/if}
|
||||||
|
<span class="whitespace-nowrap text-xs opacity-50"
|
||||||
|
>{formatTimestampAsTime(event.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
@@ -114,8 +121,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</LongPress>
|
</LongPress>
|
||||||
<div class="row-2 z-feature -mt-1 ml-4">
|
<div class="row-2 z-feature -mt-1 ml-4">
|
||||||
<ReplySummary {event} />
|
<ReactionSummary {event} {onReactionClick} noTooltip />
|
||||||
<ReactionSummary {event} {onReactionClick} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
export let event
|
export let event
|
||||||
export let pubkeys
|
export let pubkeys
|
||||||
export let popover
|
export let popover
|
||||||
|
export let replyTo
|
||||||
|
|
||||||
|
const reply = () => replyTo(event)
|
||||||
|
|
||||||
const showInfo = () => {
|
const showInfo = () => {
|
||||||
popover.hide()
|
popover.hide()
|
||||||
@@ -17,6 +20,11 @@
|
|||||||
|
|
||||||
<div class="join border border-solid border-neutral text-xs">
|
<div class="join border border-solid border-neutral text-xs">
|
||||||
<ChatMessageEmojiButton {event} {pubkeys} />
|
<ChatMessageEmojiButton {event} {pubkeys} />
|
||||||
|
{#if replyTo}
|
||||||
|
<Button class="btn join-item btn-xs" on:click={reply}>
|
||||||
|
<Icon size={4} icon="reply" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
<Button class="btn join-item btn-xs" on:click={showInfo}>
|
<Button class="btn join-item btn-xs" on:click={showInfo}>
|
||||||
<Icon size={4} icon="code-2" />
|
<Icon size={4} icon="code-2" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import {
|
import {
|
||||||
parse,
|
parse,
|
||||||
truncate,
|
truncate,
|
||||||
render as renderParsed,
|
renderAsHtml,
|
||||||
isText,
|
isText,
|
||||||
isTopic,
|
isTopic,
|
||||||
isCode,
|
isCode,
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
export let showEntire = false
|
export let showEntire = false
|
||||||
export let hideMedia = false
|
export let hideMedia = false
|
||||||
export let expandMode = "block"
|
export let expandMode = "block"
|
||||||
|
export let quoteProps: Record<string, any> = {}
|
||||||
export let depth = 0
|
export let depth = 0
|
||||||
|
|
||||||
const fullContent = parse(event)
|
const fullContent = parse(event)
|
||||||
@@ -44,18 +45,38 @@
|
|||||||
showEntire = true
|
showEntire = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const isBoundary = (i: number) => {
|
const isBlock = (i: number) => {
|
||||||
const parsed = fullContent[i]
|
const parsed = fullContent[i]
|
||||||
|
|
||||||
if (!parsed || isNewline(parsed)) return true
|
if (!parsed || hideMedia) return false
|
||||||
if (isText(parsed)) return parsed.value.match(/^\s+$/)
|
|
||||||
|
if (isLink(parsed) && $userSettingValues.show_media && isStartOrEnd(i)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((isEvent(parsed) || isAddress(parsed)) && isStartOrEnd(i) && depth < 1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const isStartAndEnd = (i: number) => Boolean(isBoundary(i - 1) && isBoundary(i + 1))
|
const isBoundary = (i: number) => {
|
||||||
|
const parsed = fullContent[i]
|
||||||
|
|
||||||
const isStartOrEnd = (i: number) => Boolean(isBoundary(i - 1) || isBoundary(i + 1))
|
if (!parsed || isNewline(parsed)) return true
|
||||||
|
if (isText(parsed)) return Boolean(parsed.value.match(/^\s+$/))
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStart = (i: number) => isBoundary(i - 1)
|
||||||
|
|
||||||
|
const isEnd = (i: number) => isBoundary(i + 1)
|
||||||
|
|
||||||
|
const isStartAndEnd = (i: number) => isStart(i) && isEnd(i)
|
||||||
|
|
||||||
|
const isStartOrEnd = (i: number) => isStart(i) || isEnd(i)
|
||||||
|
|
||||||
const ignoreWarning = () => {
|
const ignoreWarning = () => {
|
||||||
warning = null
|
warning = null
|
||||||
@@ -72,7 +93,7 @@
|
|||||||
mediaLength: hideMedia ? 20 : 200,
|
mediaLength: hideMedia ? 20 : 200,
|
||||||
})
|
})
|
||||||
|
|
||||||
$: hasEllipsis = shortContent.find(isEllipsis)
|
$: hasEllipsis = shortContent.some(isEllipsis)
|
||||||
$: expandInline = hasEllipsis && expandMode === "inline"
|
$: expandInline = hasEllipsis && expandMode === "inline"
|
||||||
$: expandBlock = hasEllipsis && expandMode === "block"
|
$: expandBlock = hasEllipsis && expandMode === "block"
|
||||||
</script>
|
</script>
|
||||||
@@ -92,15 +113,17 @@
|
|||||||
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
|
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
|
||||||
{#each shortContent as parsed, i}
|
{#each shortContent as parsed, i}
|
||||||
{#if isNewline(parsed)}
|
{#if isNewline(parsed)}
|
||||||
<ContentNewline value={parsed.value} />
|
<ContentNewline value={parsed.value.slice(isBlock(i - 1) ? 1 : 0)} />
|
||||||
{:else if isTopic(parsed)}
|
{:else if isTopic(parsed)}
|
||||||
<ContentTopic value={parsed.value} />
|
<ContentTopic value={parsed.value} />
|
||||||
{:else if isCode(parsed)}
|
{:else if isCode(parsed)}
|
||||||
<ContentCode value={parsed.value} isBlock={isStartAndEnd(i)} />
|
<ContentCode
|
||||||
|
value={parsed.value}
|
||||||
|
isBlock={isStartAndEnd(i) || parsed.value.includes("\n")} />
|
||||||
{:else if isCashu(parsed) || isInvoice(parsed)}
|
{:else if isCashu(parsed) || isInvoice(parsed)}
|
||||||
<ContentToken value={parsed.value} />
|
<ContentToken value={parsed.value} />
|
||||||
{:else if isLink(parsed)}
|
{:else if isLink(parsed)}
|
||||||
{#if isStartOrEnd(i) && !hideMedia && $userSettingValues.show_media}
|
{#if isBlock(i)}
|
||||||
<ContentLinkBlock value={parsed.value} />
|
<ContentLinkBlock value={parsed.value} />
|
||||||
{:else}
|
{:else}
|
||||||
<ContentLinkInline value={parsed.value} />
|
<ContentLinkInline value={parsed.value} />
|
||||||
@@ -108,10 +131,10 @@
|
|||||||
{:else if isProfile(parsed)}
|
{:else if isProfile(parsed)}
|
||||||
<ContentMention value={parsed.value} />
|
<ContentMention value={parsed.value} />
|
||||||
{:else if isEvent(parsed) || isAddress(parsed)}
|
{:else if isEvent(parsed) || isAddress(parsed)}
|
||||||
{#if isStartOrEnd(i) && depth < 2 && !hideMedia}
|
{#if isBlock(i)}
|
||||||
<ContentQuote value={parsed.value} {depth} {event}>
|
<ContentQuote {...quoteProps} value={parsed.value} {depth} {event}>
|
||||||
<div slot="note-content" let:event>
|
<div slot="note-content" let:event>
|
||||||
<svelte:self {hideMedia} {event} depth={depth + 1} />
|
<svelte:self {quoteProps} {hideMedia} {event} depth={depth + 1} />
|
||||||
</div>
|
</div>
|
||||||
</ContentQuote>
|
</ContentQuote>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -123,16 +146,19 @@
|
|||||||
</Link>
|
</Link>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if isEllipsis(parsed) && expandInline}
|
{:else if isEllipsis(parsed) && expandInline}
|
||||||
{@html renderParsed(parsed)}
|
{@html renderAsHtml(parsed)}
|
||||||
<button type="button" class="text-sm underline"> Read more </button>
|
<button type="button" class="text-sm underline"> Read more </button>
|
||||||
{:else}
|
{:else}
|
||||||
{@html renderParsed(parsed)}
|
{@html renderAsHtml(parsed)}
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if expandBlock}
|
{#if expandBlock}
|
||||||
<div class="relative z-feature -mt-6 flex justify-center bg-gradient-to-t from-base-100 py-2">
|
<div class="relative z-feature -mt-6 flex justify-center py-2">
|
||||||
<button type="button" class="btn" on:click|stopPropagation|preventDefault={expand}>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-neutral"
|
||||||
|
on:click|stopPropagation|preventDefault={expand}>
|
||||||
See more
|
See more
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
export let isBlock
|
export let isBlock
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<code class="link-content w-full" class:block={isBlock}>
|
<code
|
||||||
{value}
|
class="w-full overflow-auto whitespace-pre rounded bg-neutral px-1 text-neutral-content"
|
||||||
|
class:block={isBlock}>
|
||||||
|
{value.trim()}
|
||||||
</code>
|
</code>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true})
|
const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Link external href={url} class="my-2 flex">
|
<Link external href={url} class="my-2 block">
|
||||||
<div class="overflow-hidden rounded-box leading-[0]">
|
<div class="overflow-hidden rounded-box leading-[0]">
|
||||||
{#if url.match(/\.(mov|webm|mp4)$/)}
|
{#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 object-contain object-center">
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:catch}
|
{:catch}
|
||||||
<p class="bg-alt p-12 text-center">
|
<p class="bg-alt p-12 text-center leading-normal">
|
||||||
Unable to load a preview for {url}
|
Unable to load a preview for {url}
|
||||||
</p>
|
</p>
|
||||||
{/await}
|
{/await}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {displayProfile} from "@welshman/util"
|
import {displayProfile} from "@welshman/util"
|
||||||
import {deriveProfile} from "@welshman/app"
|
import {deriveProfile} from "@welshman/app"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import {pubkeyLink} from "@app/state"
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
export let value
|
export let value
|
||||||
|
|
||||||
const profile = deriveProfile(value.pubkey)
|
const profile = deriveProfile(value.pubkey)
|
||||||
|
|
||||||
|
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Link external href={pubkeyLink(value.pubkey)} class="link-content">
|
<Button on:click={openProfile} class="link-content">
|
||||||
@{displayProfile($profile)}
|
@{displayProfile($profile)}
|
||||||
</Link>
|
</Button>
|
||||||
|
|||||||
@@ -1,46 +1,105 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {nip19} from "nostr-tools"
|
import {nip19} from "nostr-tools"
|
||||||
|
import {goto} from "$app/navigation"
|
||||||
import {ctx, nthEq} from "@welshman/lib"
|
import {ctx, nthEq} from "@welshman/lib"
|
||||||
import {Address} from "@welshman/util"
|
import {tracker, repository} from "@welshman/app"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import {Address, DIRECT_MESSAGE, MESSAGE, THREAD} from "@welshman/util"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import NoteCard from "@app/components/NoteCard.svelte"
|
import NoteCard from "@app/components/NoteCard.svelte"
|
||||||
import {deriveEvent, entityLink, MESSAGE, THREAD} from "@app/state"
|
import {deriveEvent, entityLink, ROOM} from "@app/state"
|
||||||
import {makeThreadPath} from "@app/routes"
|
import {makeThreadPath, makeRoomPath} from "@app/routes"
|
||||||
|
|
||||||
export let value
|
export let value
|
||||||
export let event
|
export let event
|
||||||
export let depth = 0
|
export let depth = 0
|
||||||
|
export let relays: string[] = []
|
||||||
|
export let minimal = false
|
||||||
|
|
||||||
const {id, identifier, kind, pubkey, relays: relayHints = []} = value
|
const {id, identifier, kind, pubkey, relays: relayHints = []} = value
|
||||||
const addr = new Address(kind, pubkey, identifier)
|
const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
|
||||||
const idOrAddress = id || addr.toString()
|
const mergedRelays = [
|
||||||
const relays = ctx.app.router.Quote(event, idOrAddress, relayHints).getUrls()
|
...relays,
|
||||||
const quote = deriveEvent(idOrAddress, relays)
|
...ctx.app.router.Quote(event, idOrAddress, relayHints).getUrls(),
|
||||||
const entity = id ? nip19.neventEncode({id, relays}) : addr.toNaddr()
|
]
|
||||||
|
const quote = deriveEvent(idOrAddress, mergedRelays)
|
||||||
|
const entity = id
|
||||||
|
? nip19.neventEncode({id, relays: mergedRelays})
|
||||||
|
: new Address(kind, pubkey, identifier, mergedRelays).toNaddr()
|
||||||
|
|
||||||
const getLocalHref = (e: TrustedEvent) => {
|
const scrollToEvent = (id: string) => {
|
||||||
const url = e.tags.find(nthEq(0, "~"))?.[2]
|
const element = document.querySelector(`[data-event="${id}"]`) as any
|
||||||
|
|
||||||
if (!url) return
|
if (element) {
|
||||||
if ([MESSAGE, THREAD].includes(e.kind)) return makeThreadPath(url, e.id)
|
element.scrollIntoView({behavior: "smooth"})
|
||||||
|
element.style =
|
||||||
|
"filter: brightness(1.5); transition-property: all; transition-duration: 400ms;"
|
||||||
|
|
||||||
const kind = e.tags.find(nthEq(0, "K"))?.[1]
|
setTimeout(() => {
|
||||||
const id = e.tags.find(nthEq(0, "E"))?.[1]
|
element.style = "transition-property: all; transition-duration: 300ms;"
|
||||||
|
}, 800)
|
||||||
|
|
||||||
if (!id || !kind) return
|
setTimeout(() => {
|
||||||
if ([MESSAGE, THREAD].includes(parseInt(kind))) return makeThreadPath(url, id)
|
element.style = ""
|
||||||
|
}, 800 + 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(element)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we found this event on a relay that the user is a member of, redirect internally
|
const openMessage = (url: string, room: string, id: string) => {
|
||||||
$: localHref = $quote ? getLocalHref($quote) : null
|
const event = repository.getEvent(id)
|
||||||
$: href = localHref || entityLink(entity)
|
|
||||||
|
if (event) {
|
||||||
|
goto(makeRoomPath(url, room))
|
||||||
|
|
||||||
|
// TODO: if the event doesn't immediately load, this won't work. Scroll up until it's found
|
||||||
|
setTimeout(() => scrollToEvent(id), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClick = (e: Event) => {
|
||||||
|
if ($quote) {
|
||||||
|
if ($quote.kind === DIRECT_MESSAGE) {
|
||||||
|
return scrollToEvent($quote.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [url] = tracker.getRelays($quote.id)
|
||||||
|
const room = $quote.tags.find(nthEq(0, ROOM))?.[1]
|
||||||
|
|
||||||
|
if (url && room) {
|
||||||
|
if ($quote.kind === THREAD) {
|
||||||
|
return goto(makeThreadPath(url, $quote.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($quote.kind === MESSAGE) {
|
||||||
|
return scrollToEvent($quote.id) || openMessage(url, room, $quote.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = $quote.tags.find(nthEq(0, "K"))?.[1]
|
||||||
|
const id = $quote.tags.find(nthEq(0, "E"))?.[1]
|
||||||
|
|
||||||
|
if (id && kind) {
|
||||||
|
if (parseInt(kind) === THREAD) {
|
||||||
|
return goto(makeThreadPath(url, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parseInt(kind) === MESSAGE) {
|
||||||
|
return scrollToEvent(id) || openMessage(url, room, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(entityLink(entity))
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Link external={!localHref} {href} class="my-2 block max-w-full text-left">
|
<Button class="my-2 block max-w-full text-left" on:click={onClick}>
|
||||||
{#if $quote}
|
{#if $quote}
|
||||||
<NoteCard event={$quote} class="bg-alt rounded-box p-4">
|
<NoteCard {minimal} event={$quote} class="bg-alt rounded-box p-4">
|
||||||
<slot name="note-content" event={$quote} {depth} />
|
<slot name="note-content" event={$quote} {depth} />
|
||||||
</NoteCard>
|
</NoteCard>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -48,4 +107,4 @@
|
|||||||
<Spinner loading>Loading event...</Spinner>
|
<Spinner loading>Loading event...</Spinner>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Link>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<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/modal"
|
||||||
|
import {BURROW_URL} from "@app/state"
|
||||||
|
|
||||||
|
export let email
|
||||||
|
export let confirm_token
|
||||||
|
|
||||||
|
const login = () => {
|
||||||
|
pushModal(LogInPassword, {email}, {path: "/"})
|
||||||
|
}
|
||||||
|
|
||||||
|
let error: string
|
||||||
|
let loading = 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" on:click={login} disabled={loading}>Continue to Login</Button>
|
||||||
|
</div>
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {EditorContent} from "svelte-tiptap"
|
||||||
import type {Readable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
import {createEditor, type Editor, EditorContent} from "svelte-tiptap"
|
|
||||||
import {randomId} from "@welshman/lib"
|
import {randomId} from "@welshman/lib"
|
||||||
import {createEvent, EVENT_DATE, EVENT_TIME} from "@welshman/util"
|
import {createEvent, EVENT_TIME} from "@welshman/util"
|
||||||
import {publishThunk, dateToSeconds} from "@welshman/app"
|
import {publishThunk, dateToSeconds} from "@welshman/app"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Field from "@lib/components/Field.svelte"
|
import Field from "@lib/components/Field.svelte"
|
||||||
@@ -11,16 +10,18 @@
|
|||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
||||||
import {getPubkeyHints} from "@app/commands"
|
import {PROTECTED} from "@app/state"
|
||||||
import {getEditorOptions, getEditorTags} from "@lib/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
export let url
|
export let url
|
||||||
|
|
||||||
|
const uploading = writable(false)
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if ($loading) return
|
if ($uploading) return
|
||||||
|
|
||||||
if (!title) {
|
if (!title) {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
@@ -36,16 +37,16 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const kind = isAllDay ? EVENT_DATE : EVENT_TIME
|
const event = createEvent(EVENT_TIME, {
|
||||||
const event = createEvent(kind, {
|
content: $editor.getText({blockSeparator: "\n"}).trim(),
|
||||||
content: $editor.getText({blockSeparator: "\n"}),
|
|
||||||
tags: [
|
tags: [
|
||||||
["d", randomId()],
|
["d", randomId()],
|
||||||
["title", title],
|
["title", title],
|
||||||
["location", location],
|
["location", location],
|
||||||
["start", dateToSeconds(start).toString()],
|
["start", dateToSeconds(start).toString()],
|
||||||
["end", dateToSeconds(end).toString()],
|
["end", dateToSeconds(end).toString()],
|
||||||
...getEditorTags($editor),
|
...$editor.storage.nostr.getEditorTags(),
|
||||||
|
PROTECTED,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -53,18 +54,12 @@
|
|||||||
history.back()
|
history.back()
|
||||||
}
|
}
|
||||||
|
|
||||||
let editor: Readable<Editor>
|
const editor = makeEditor({submit, uploading})
|
||||||
const isAllDay = false
|
|
||||||
let title = ""
|
let title = ""
|
||||||
let location = ""
|
let location = ""
|
||||||
let start: Date
|
let start: Date
|
||||||
let end: Date
|
let end: Date
|
||||||
|
|
||||||
$: loading = $editor?.storage.fileUpload.loading
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
editor = createEditor(getEditorOptions({submit, getPubkeyHints}))
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form class="column gap-4" on:submit|preventDefault={submit}>
|
<form class="column gap-4" on:submit|preventDefault={submit}>
|
||||||
@@ -89,8 +84,8 @@
|
|||||||
<Button
|
<Button
|
||||||
data-tip="Add an image"
|
data-tip="Add an image"
|
||||||
class="center btn tooltip"
|
class="center btn tooltip"
|
||||||
on:click={$editor.commands.selectFiles}>
|
on:click={() => $editor.chain().selectFiles().run()}>
|
||||||
{#if $loading}
|
{#if $uploading}
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon="gallery-send" />
|
<Icon icon="gallery-send" />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {nip19} from "nostr-tools"
|
import {nip19} from "nostr-tools"
|
||||||
|
import {ctx} from "@welshman/lib"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
@@ -8,10 +9,11 @@
|
|||||||
|
|
||||||
export let event
|
export let event
|
||||||
|
|
||||||
const note1 = nip19.noteEncode(event.id)
|
const relays = ctx.app.router.Event(event).getUrls()
|
||||||
|
const nevent1 = nip19.neventEncode({...event, relays})
|
||||||
const npub1 = nip19.npubEncode(event.pubkey)
|
const npub1 = nip19.npubEncode(event.pubkey)
|
||||||
const json = JSON.stringify(event, null, 2)
|
const json = JSON.stringify(event, null, 2)
|
||||||
const copyId = () => clip(note1)
|
const copyLink = () => clip(nevent1)
|
||||||
const copyPubkey = () => clip(npub1)
|
const copyPubkey = () => clip(npub1)
|
||||||
const copyJson = () => clip(json)
|
const copyJson = () => clip(json)
|
||||||
</script>
|
</script>
|
||||||
@@ -22,11 +24,11 @@
|
|||||||
<div slot="info">The full details of this event are shown below.</div>
|
<div slot="info">The full details of this event are shown below.</div>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<FieldInline>
|
<FieldInline>
|
||||||
<p slot="label">Event ID</p>
|
<p slot="label">Event Link</p>
|
||||||
<label class="input input-bordered flex w-full items-center gap-2" slot="input">
|
<label class="input input-bordered flex w-full items-center gap-2" slot="input">
|
||||||
<Icon icon="file" />
|
<Icon icon="file" />
|
||||||
<input type="text" class="ellipsize min-w-0 grow" value={note1} />
|
<input type="text" class="ellipsize min-w-0 grow" value={nevent1} />
|
||||||
<Button on:click={copyId} class="flex items-center">
|
<Button on:click={copyLink} class="flex items-center">
|
||||||
<Icon icon="copy" />
|
<Icon icon="copy" />
|
||||||
</Button>
|
</Button>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {fromPairs} from "@welshman/lib"
|
import {fromPairs} from "@welshman/lib"
|
||||||
import {secondsToDate, getLocale, formatTimestamp, formatTimestampAsDate} from "@welshman/app"
|
import {formatTimestamp, formatTimestampAsDate, formatTimestampAsTime} from "@welshman/app"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
||||||
export let event
|
export let event
|
||||||
|
|
||||||
const timeFmt = new Intl.DateTimeFormat(getLocale(), {timeStyle: "short"})
|
|
||||||
|
|
||||||
$: meta = fromPairs(event.tags) as Record<string, string>
|
$: meta = fromPairs(event.tags) as Record<string, string>
|
||||||
$: end = parseInt(meta.end)
|
$: end = parseInt(meta.end)
|
||||||
$: start = parseInt(meta.start)
|
$: start = parseInt(meta.start)
|
||||||
$: startDate = secondsToDate(start)
|
|
||||||
$: endDate = secondsToDate(end)
|
|
||||||
$: startDateDisplay = formatTimestampAsDate(start)
|
$: startDateDisplay = formatTimestampAsDate(start)
|
||||||
$: endDateDisplay = formatTimestampAsDate(end)
|
$: endDateDisplay = formatTimestampAsDate(end)
|
||||||
$: isSingleDay = startDateDisplay === endDateDisplay
|
$: isSingleDay = startDateDisplay === endDateDisplay
|
||||||
@@ -21,6 +17,8 @@
|
|||||||
<span>{meta.title || meta.name}</span>
|
<span>{meta.title || meta.name}</span>
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="flex items-center gap-2 text-sm">
|
||||||
<Icon icon="clock-circle" size={4} />
|
<Icon icon="clock-circle" size={4} />
|
||||||
{timeFmt.format(startDate)} — {isSingleDay ? timeFmt.format(endDate) : formatTimestamp(end)}
|
{formatTimestampAsTime(start)} — {isSingleDay
|
||||||
|
? formatTimestampAsTime(end)
|
||||||
|
: formatTimestamp(end)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Field from "@lib/components/Field.svelte"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
import {publishReport} from "@app/commands"
|
||||||
|
|
||||||
|
export let url
|
||||||
|
export let event
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const confirm = async () => {
|
||||||
|
if (!reason) {
|
||||||
|
return pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: "Please select a reason for your report.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
await publishReport({event, reason: reason.toLowerCase(), content, relays: [url]})
|
||||||
|
|
||||||
|
loading = false
|
||||||
|
history.back()
|
||||||
|
|
||||||
|
return pushToast({message: "Your report has been sent!"})
|
||||||
|
}
|
||||||
|
|
||||||
|
let reason = ""
|
||||||
|
let content = ""
|
||||||
|
let loading = false
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form class="column gap-4" on:submit|preventDefault={confirm}>
|
||||||
|
<ModalHeader>
|
||||||
|
<div slot="title">Report Content</div>
|
||||||
|
<div slot="info">Flag inappropriate content.</div>
|
||||||
|
</ModalHeader>
|
||||||
|
<Field>
|
||||||
|
<p slot="label">Reason*</p>
|
||||||
|
<select slot="input" class="select select-bordered" bind:value={reason}>
|
||||||
|
<option disabled selected>Choose a reason</option>
|
||||||
|
<option>Nudity</option>
|
||||||
|
<option>Malware</option>
|
||||||
|
<option>Profanity</option>
|
||||||
|
<option>Illegal</option>
|
||||||
|
<option>Spam</option>
|
||||||
|
<option>Impersonation</option>
|
||||||
|
<option>Other</option>
|
||||||
|
</select>
|
||||||
|
<p slot="info">Please select a reason for your report.</p>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<p slot="label">Details</p>
|
||||||
|
<textarea slot="input" class="textarea textarea-bordered" bind:value={content} />
|
||||||
|
<p slot="info">Please provide any additional details relevant to your report.</p>
|
||||||
|
</Field>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="btn btn-link" on:click={back}>
|
||||||
|
<Icon icon="alt-arrow-left" />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||||
|
<Spinner {loading}>Send Report</Spinner>
|
||||||
|
<Icon icon="alt-arrow-right" />
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<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} from "@app/commands"
|
||||||
|
|
||||||
|
export let url
|
||||||
|
export let event
|
||||||
|
|
||||||
|
const reports = deriveEvents(repository, {
|
||||||
|
filters: [{kinds: [REPORT], "#e": [event.id]}],
|
||||||
|
})
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const deleteReport = (report: TrustedEvent) => {
|
||||||
|
publishDelete({event: report, relays: [url]})
|
||||||
|
|
||||||
|
if ($reports.length === 0) {
|
||||||
|
history.back()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getReason = (tags: string[][]) => getTag("e", tags)?.[2] || "other"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="column gap-4">
|
||||||
|
<ModalHeader>
|
||||||
|
<div slot="title">Report Details</div>
|
||||||
|
<div slot="info">All reports for this event are shown below.</div>
|
||||||
|
</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} />
|
||||||
|
<span>Reported this event as "{reason}"</span>
|
||||||
|
</div>
|
||||||
|
{#if report.pubkey === $pubkey}
|
||||||
|
<Button class="btn-default btn" on:click={remove}>Delete Report</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if report.content}
|
||||||
|
<p>"{report.content}"</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<Button class="btn btn-primary" on:click={back}>Got it</Button>
|
||||||
|
</div>
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {session} from "@welshman/app"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import ProfileEject from "@app/components/ProfileEject.svelte"
|
||||||
import {PLATFORM_NAME} from "@app/state"
|
import {PLATFORM_NAME} from "@app/state"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const startEject = () => pushModal(ProfileEject)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="column gap-4">
|
<div class="column gap-4">
|
||||||
@@ -10,21 +19,33 @@
|
|||||||
<div slot="title">What is a private key?</div>
|
<div slot="title">What is a private key?</div>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<p>
|
<p>
|
||||||
Most software keeps track of users by giving them a username and password. This gives the
|
Most online services keep track of users by giving them a username and password. This gives the
|
||||||
service
|
service <strong>total control</strong> over their users, allowing them to ban them at any time, or
|
||||||
<strong>total control</strong> over their users, allowing them to ban them at any time, or sell their
|
sell their activity.
|
||||||
activity.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
On <Link external href="https://nostr.com/">Nostr</Link>, <strong>you</strong> control your own
|
On <Link external href="https://nostr.com/">Nostr</Link>, <strong>you</strong> control your own
|
||||||
identity and social data, through the magic of crytography. The basic idea is that you have a
|
identity and social data, through the magic of crytography. The basic idea is that you have a
|
||||||
<strong>public key</strong>, which acts as your user id, and a <strong>private key</strong> which
|
<strong>public key</strong>, which acts as your user id, and a
|
||||||
allows you to authenticate any message you send.
|
<strong>private key</strong> which allows you to prove your identity.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
{#if $session?.email}
|
||||||
It's very important to keep private keys safe, but this can sometimes be confusing for
|
<p>
|
||||||
newcomers. This is why {PLATFORM_NAME} supports <strong>remote signer</strong> login. These services
|
It's very important to keep private keys safe, but this can sometimes be tricky, which is why {PLATFORM_NAME}
|
||||||
can store your keys securely for you, giving you access using a username and password.
|
supports a traditional account-based login for new users.
|
||||||
</p>
|
</p>
|
||||||
<Button class="btn btn-primary" on:click={() => history.back()}>Got it</Button>
|
<p>If you'd like to switch to self-custody, please click below to get started.</p>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="btn btn-link" on:click={back}>
|
||||||
|
<Icon icon="alt-arrow-left" />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button class="btn btn-primary" on:click={startEject}>
|
||||||
|
<Icon icon="check-circle" />
|
||||||
|
I want to hold my own keys
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
{:else}
|
||||||
|
<Button class="btn btn-primary" on:click={back}>Got it</Button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
import Dialog from "@lib/components/Dialog.svelte"
|
import Dialog from "@lib/components/Dialog.svelte"
|
||||||
import CardButton from "@lib/components/CardButton.svelte"
|
import CardButton from "@lib/components/CardButton.svelte"
|
||||||
import LogIn from "@app/components/LogIn.svelte"
|
import LogIn from "@app/components/LogIn.svelte"
|
||||||
import SignUp from "@app/components/SignUp.svelte"
|
import SignUp from "@app/components/SignUp.svelte"
|
||||||
import {PLATFORM_NAME} from "@app/state"
|
import {PLATFORM_TERMS, PLATFORM_PRIVACY, PLATFORM_NAME} from "@app/state"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
const logIn = () => pushModal(LogIn)
|
const logIn = () => pushModal(LogIn)
|
||||||
@@ -33,5 +34,10 @@
|
|||||||
<div slot="info">Just a few questions and you'll be on your way.</div>
|
<div slot="info">Just a few questions and you'll be on your way.</div>
|
||||||
</CardButton>
|
</CardButton>
|
||||||
</Button>
|
</Button>
|
||||||
|
<p class="text-center text-xs opacity-75">
|
||||||
|
By using {PLATFORM_NAME}, you consent to our
|
||||||
|
<Link external class="link" href={PLATFORM_TERMS}>Terms of Service</Link> and
|
||||||
|
<Link external class="link" href={PLATFORM_PRIVACY}>Privacy Policy</Link>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
import SignUp from "@app/components/SignUp.svelte"
|
import SignUp from "@app/components/SignUp.svelte"
|
||||||
import InfoNostr from "@app/components/InfoNostr.svelte"
|
import InfoNostr from "@app/components/InfoNostr.svelte"
|
||||||
import LogInBunker from "@app/components/LogInBunker.svelte"
|
import LogInBunker from "@app/components/LogInBunker.svelte"
|
||||||
|
import LogInPassword from "@app/components/LogInPassword.svelte"
|
||||||
import {pushModal, clearModals} from "@app/modal"
|
import {pushModal, clearModals} from "@app/modal"
|
||||||
import {PLATFORM_NAME} from "@app/state"
|
import {PLATFORM_NAME, BURROW_URL} from "@app/state"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {loadUserData} from "@app/commands"
|
import {loadUserData} from "@app/commands"
|
||||||
import {setChecked} from "@app/notifications"
|
import {setChecked} from "@app/notifications"
|
||||||
@@ -18,28 +19,27 @@
|
|||||||
const signUp = () => pushModal(SignUp)
|
const signUp = () => pushModal(SignUp)
|
||||||
|
|
||||||
const withLoading =
|
const withLoading =
|
||||||
(cb: (...args: any[]) => any) =>
|
(s: string, cb: (...args: any[]) => any) =>
|
||||||
async (...args: any[]) => {
|
async (...args: any[]) => {
|
||||||
loading = true
|
loading = s
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await cb(...args)
|
await cb(...args)
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSuccess = async (session: Session, relays: string[] = []) => {
|
const onSuccess = async (session: Session, relays: string[] = []) => {
|
||||||
addSession(session)
|
|
||||||
|
|
||||||
await loadUserData(session.pubkey, {relays})
|
await loadUserData(session.pubkey, {relays})
|
||||||
|
|
||||||
|
addSession(session)
|
||||||
pushToast({message: "Successfully logged in!"})
|
pushToast({message: "Successfully logged in!"})
|
||||||
setChecked("*")
|
setChecked("*")
|
||||||
clearModals()
|
clearModals()
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginWithNip07 = withLoading(async () => {
|
const loginWithNip07 = withLoading("nip07", async () => {
|
||||||
const pubkey = await getNip07()?.getPublicKey()
|
const pubkey = await getNip07()?.getPublicKey()
|
||||||
|
|
||||||
if (pubkey) {
|
if (pubkey) {
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const loginWithSigner = withLoading(async (app: any) => {
|
const loginWithNip55 = withLoading("nip55", async (app: any) => {
|
||||||
const signer = new Nip55Signer(app.packageName)
|
const signer = new Nip55Signer(app.packageName)
|
||||||
const pubkey = await signer.getPubkey()
|
const pubkey = await signer.getPubkey()
|
||||||
|
|
||||||
@@ -66,19 +66,18 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const loginWithPassword = () => pushModal(LogInPassword)
|
||||||
|
|
||||||
const loginWithBunker = () => pushModal(LogInBunker)
|
const loginWithBunker = () => pushModal(LogInBunker)
|
||||||
|
|
||||||
let loading = false
|
|
||||||
let signers: any[] = []
|
let signers: any[] = []
|
||||||
let hasNativeSigner = Boolean(getNip07())
|
let loading: string | undefined
|
||||||
|
|
||||||
|
$: hasSigner = getNip07() || signers.length > 0
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (Capacitor.isNativePlatform()) {
|
if (Capacitor.isNativePlatform()) {
|
||||||
signers = await getNip55()
|
signers = await getNip55()
|
||||||
|
|
||||||
if (signers.length > 0) {
|
|
||||||
hasNativeSigner = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -92,7 +91,7 @@
|
|||||||
</p>
|
</p>
|
||||||
{#if getNip07()}
|
{#if getNip07()}
|
||||||
<Button disabled={loading} on:click={loginWithNip07} class="btn btn-primary">
|
<Button disabled={loading} on:click={loginWithNip07} class="btn btn-primary">
|
||||||
{#if loading}
|
{#if loading === "nip07"}
|
||||||
<span class="loading loading-spinner mr-3" />
|
<span class="loading loading-spinner mr-3" />
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon="widget" />
|
<Icon icon="widget" />
|
||||||
@@ -101,8 +100,8 @@
|
|||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{#each signers as app}
|
{#each signers as app}
|
||||||
<Button disabled={loading} class="btn btn-primary" on:click={() => loginWithSigner(app)}>
|
<Button disabled={loading} class="btn btn-primary" on:click={() => loginWithNip55(app)}>
|
||||||
{#if loading}
|
{#if loading === "nip55"}
|
||||||
<span class="loading loading-spinner mr-3" />
|
<span class="loading loading-spinner mr-3" />
|
||||||
{:else}
|
{:else}
|
||||||
<img src={app.iconUrl} alt={app.name} width="20" height="20" />
|
<img src={app.iconUrl} alt={app.name} width="20" height="20" />
|
||||||
@@ -110,21 +109,43 @@
|
|||||||
Log in with {app.name}
|
Log in with {app.name}
|
||||||
</Button>
|
</Button>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if BURROW_URL && !hasSigner}
|
||||||
|
<Button disabled={loading} on:click={loginWithPassword} class="btn btn-primary">
|
||||||
|
{#if loading === "password"}
|
||||||
|
<span class="loading loading-spinner mr-3" />
|
||||||
|
{:else}
|
||||||
|
<Icon icon="key" />
|
||||||
|
{/if}
|
||||||
|
Log in with Password
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
<Button
|
<Button
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
on:click={loginWithBunker}
|
on:click={loginWithBunker}
|
||||||
class="btn {hasNativeSigner ? 'btn-neutral' : 'btn-primary'}">
|
class="btn {hasSigner || BURROW_URL ? 'btn-neutral' : 'btn-primary'}">
|
||||||
<Icon icon="cpu" />
|
<Icon icon="cpu" />
|
||||||
Log in with Remote Signer
|
Log in with Remote Signer
|
||||||
</Button>
|
</Button>
|
||||||
<Link
|
{#if BURROW_URL && hasSigner}
|
||||||
external
|
<Button disabled={loading} on:click={loginWithPassword} class="btn">
|
||||||
disabled={loading}
|
{#if loading === "password"}
|
||||||
href="https://nostrapps.com#signers"
|
<span class="loading loading-spinner mr-3" />
|
||||||
class="btn {hasNativeSigner ? '' : 'btn-neutral'}">
|
{:else}
|
||||||
<Icon icon="compass" />
|
<Icon icon="key" />
|
||||||
Browse Signer Apps
|
{/if}
|
||||||
</Link>
|
Log in with Password
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{#if !hasSigner || !BURROW_URL}
|
||||||
|
<Link
|
||||||
|
external
|
||||||
|
disabled={loading}
|
||||||
|
href="https://nostrapps.com#signers"
|
||||||
|
class="btn {hasSigner || BURROW_URL ? '' : 'btn-neutral'}">
|
||||||
|
<Icon icon="compass" />
|
||||||
|
Browse Signer Apps
|
||||||
|
</Link>
|
||||||
|
{/if}
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
Need an account?
|
Need an account?
|
||||||
<Button class="link" on:click={signUp}>Register instead</Button>
|
<Button class="link" on:click={signUp}>Register instead</Button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onDestroy} from "svelte"
|
import {onMount, onDestroy} from "svelte"
|
||||||
import {Nip46Broker} from "@welshman/signer"
|
import {Nip46Broker, getPubkey, makeSecret} from "@welshman/signer"
|
||||||
import {nip46Perms, addSession} from "@welshman/app"
|
import {addSession} from "@welshman/app"
|
||||||
import {slideAndFade} from "@lib/transition"
|
import {slideAndFade} from "@lib/transition"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
@@ -15,29 +15,24 @@
|
|||||||
import {pushModal, clearModals} from "@app/modal"
|
import {pushModal, clearModals} from "@app/modal"
|
||||||
import {setChecked} from "@app/notifications"
|
import {setChecked} from "@app/notifications"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state"
|
import {NIP46_PERMS, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state"
|
||||||
|
|
||||||
const back = () => history.back()
|
const clientSecret = makeSecret()
|
||||||
|
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
|
|
||||||
const init = Nip46Broker.initiate({
|
const broker = Nip46Broker.get({clientSecret, relays: SIGNER_RELAYS})
|
||||||
perms: nip46Perms,
|
|
||||||
url: PLATFORM_URL,
|
const back = () => history.back()
|
||||||
name: PLATFORM_NAME,
|
|
||||||
relays: SIGNER_RELAYS,
|
|
||||||
image: PLATFORM_LOGO,
|
|
||||||
abortController,
|
|
||||||
})
|
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
const {pubkey, token, relays} = Nip46Broker.parseBunkerLink(bunker)
|
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(input)
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pubkey || relays.length === 0) {
|
if (!signerPubkey || relays.length === 0) {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
message: "Sorry, it looks like that's an invalid bunker link.",
|
message: "Sorry, it looks like that's an invalid bunker link.",
|
||||||
@@ -47,16 +42,16 @@
|
|||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!(await loginWithNip46(token, {pubkey, relays}))) {
|
const success = await loginWithNip46({connectSecret, clientSecret, signerPubkey, relays})
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
abortController.abort()
|
||||||
|
} else {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
message: "Something went wrong, please try again!",
|
message: "Something went wrong, please try again!",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
abortController.abort()
|
|
||||||
|
|
||||||
await loadUserData(pubkey)
|
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
@@ -64,21 +59,57 @@
|
|||||||
clearModals()
|
clearModals()
|
||||||
}
|
}
|
||||||
|
|
||||||
let bunker = ""
|
let url = ""
|
||||||
|
let input = ""
|
||||||
let loading = false
|
let loading = false
|
||||||
|
|
||||||
init.result.then(async pubkey => {
|
$: {
|
||||||
if (pubkey) {
|
// For testing and for play store reviewers
|
||||||
|
if (input === "reviewkey") {
|
||||||
|
const secret = makeSecret()
|
||||||
|
|
||||||
|
addSession({method: "nip01", secret, pubkey: getPubkey(secret)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
url = await broker.makeNostrconnectUrl({
|
||||||
|
perms: NIP46_PERMS,
|
||||||
|
url: PLATFORM_URL,
|
||||||
|
name: PLATFORM_NAME,
|
||||||
|
image: PLATFORM_LOGO,
|
||||||
|
})
|
||||||
|
|
||||||
|
let response
|
||||||
|
try {
|
||||||
|
response = await broker.waitForNostrconnect(url, abortController)
|
||||||
|
} catch (errorResponse: any) {
|
||||||
|
if (errorResponse?.error) {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: `Received error from signer: ${errorResponse.error}`,
|
||||||
|
})
|
||||||
|
} else if (errorResponse) {
|
||||||
|
console.error(errorResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response) {
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
addSession({
|
const userPubkey = await broker.getPublicKey()
|
||||||
pubkey,
|
|
||||||
method: "nip46",
|
|
||||||
secret: init.clientSecret,
|
|
||||||
handler: {pubkey, relays: SIGNER_RELAYS},
|
|
||||||
})
|
|
||||||
|
|
||||||
await loadUserData(pubkey)
|
await loadUserData(userPubkey)
|
||||||
|
|
||||||
|
addSession({
|
||||||
|
method: "nip46",
|
||||||
|
pubkey: userPubkey,
|
||||||
|
secret: clientSecret,
|
||||||
|
handler: {
|
||||||
|
pubkey: response.event.pubkey,
|
||||||
|
relays: SIGNER_RELAYS,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
setChecked("*")
|
setChecked("*")
|
||||||
clearModals()
|
clearModals()
|
||||||
@@ -97,16 +128,16 @@
|
|||||||
Connect your signer by scanning the QR code below or pasting a bunker link.
|
Connect your signer by scanning the QR code below or pasting a bunker link.
|
||||||
</div>
|
</div>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
{#if !loading}
|
{#if !loading && url}
|
||||||
<div class="w-xs m-auto" out:slideAndFade>
|
<div class="w-xs m-auto" out:slideAndFade>
|
||||||
<QRCode code={init.nostrconnect} />
|
<QRCode code={url} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<Field>
|
<Field>
|
||||||
<p slot="label">Bunker Link*</p>
|
<p slot="label">Bunker Link*</p>
|
||||||
<label class="input input-bordered flex w-full items-center gap-2" slot="input">
|
<label class="input input-bordered flex w-full items-center gap-2" slot="input">
|
||||||
<Icon icon="cpu" />
|
<Icon icon="cpu" />
|
||||||
<input disabled={loading} bind:value={bunker} class="grow" placeholder="bunker://" />
|
<input disabled={loading} bind:value={input} class="grow" placeholder="bunker://" />
|
||||||
</label>
|
</label>
|
||||||
<p slot="info">
|
<p slot="info">
|
||||||
A login link provided by a nostr signing app.
|
A login link provided by a nostr signing app.
|
||||||
@@ -118,7 +149,7 @@
|
|||||||
<Icon icon="alt-arrow-left" />
|
<Icon icon="alt-arrow-left" />
|
||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" class="btn btn-primary" disabled={loading || !bunker}>
|
<Button type="submit" class="btn btn-primary" disabled={loading || !input}>
|
||||||
<Spinner {loading}>Next</Spinner>
|
<Spinner {loading}>Next</Spinner>
|
||||||
<Icon icon="alt-arrow-right" />
|
<Icon icon="alt-arrow-right" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||