Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cee6c3c164 | |||
| 06d0ae2798 | |||
| b129ef4242 | |||
| 48a45f3a3a | |||
| ce1fb396e3 | |||
| e95c57bcb7 | |||
| 414f5a5ace | |||
| a331d24bb1 | |||
| fb53e53411 | |||
| 1e7e439e3f | |||
| 3368cba1be | |||
| 4f0579bb7f | |||
| 08e80262a4 | |||
| e10b83bed8 | |||
| fa17c398ca | |||
| e0840f24dd | |||
| 8e38271534 | |||
| 86928fc12c | |||
| e15fb3ce9c | |||
| bf1ab5f0ee | |||
| 59568f95f1 | |||
| 75d52e7e17 | |||
| bdb5d3dfaa |
@@ -1,6 +1,8 @@
|
||||
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_TERMS=https://flotilla.social/terms
|
||||
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
|
||||
VITE_PLATFORM_NAME=Flotilla
|
||||
VITE_PLATFORM_LOGO=static/flotilla.png
|
||||
VITE_PLATFORM_RELAY=
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
src/assets
|
||||
android
|
||||
build
|
||||
.idea
|
||||
.gradle
|
||||
*.png
|
||||
*.ttf
|
||||
gradlew*
|
||||
_app
|
||||
release
|
||||
android/capacitor-cordova-android-plugins
|
||||
android/app/src/androidTest
|
||||
android/app/src/test
|
||||
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
# 0.2.6
|
||||
|
||||
* Add reply to long-press menu
|
||||
* Fix @-mentions
|
||||
* Replace nsec.app signup with njump.me
|
||||
* Add new messages button in rooms
|
||||
* Add media server settings
|
||||
* Add build hash to about page
|
||||
|
||||
# 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
|
||||
|
||||
@@ -6,7 +6,11 @@ If you would like to be interoperable with Flotilla, please check out this draft
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "social.flotilla"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2
|
||||
versionName "0.2.3"
|
||||
versionCode 6
|
||||
versionName "0.2.6"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// 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"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
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"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
@@ -19,4 +17,4 @@
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -7,7 +7,7 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
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'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
|
||||
@@ -2,5 +2,8 @@
|
||||
include ':capacitor-android'
|
||||
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'
|
||||
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/nostr-signer-capacitor-plugin/android')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
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
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
|
Before Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -14,9 +14,12 @@ fi
|
||||
# https://stackoverflow.com/a/69127685/1467342
|
||||
eval "$temp_env"
|
||||
|
||||
if [[ -z $VITE_BUILD_HASH ]]; then
|
||||
export VITE_BUILD_HASH=$(git rev-parse --short HEAD)
|
||||
fi
|
||||
|
||||
if [[ $VITE_PLATFORM_LOGO =~ ^https://* ]]; then
|
||||
curl $VITE_PLATFORM_LOGO > static/logo.png
|
||||
cp static/logo.png assets/logo.png
|
||||
export VITE_PLATFORM_LOGO=static/logo.png
|
||||
fi
|
||||
|
||||
@@ -28,3 +31,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|{NAME}|$VITE_PLATFORM_NAME|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',
|
||||
appName: 'Flotilla',
|
||||
webDir: 'build'
|
||||
server: {
|
||||
androidScheme: "https"
|
||||
},
|
||||
plugins: {
|
||||
SplashScreen: {
|
||||
androidSplashResourceName: "splash"
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
{
|
||||
"name": "flotilla",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"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",
|
||||
"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:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check src && eslint src",
|
||||
"format": "prettier --write src",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"overrides": {
|
||||
"@capacitor/core": "^7.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@sentry/cli": "^2.40.0",
|
||||
@@ -37,38 +40,28 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^6.1.2",
|
||||
"@capacitor/cli": "^6.1.2",
|
||||
"@capacitor/core": "^6.1.2",
|
||||
"@capacitor/android": "^7.0.1",
|
||||
"@capacitor/app": "^7.0.0",
|
||||
"@capacitor/cli": "^6.2.0",
|
||||
"@capacitor/core": "^7.0.1",
|
||||
"@noble/curves": "^1.5.0",
|
||||
"@noble/hashes": "^1.4.0",
|
||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
||||
"@sentry/browser": "^8.35.0",
|
||||
"@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",
|
||||
"@vite-pwa/assets-generator": "^0.2.6",
|
||||
"@vite-pwa/sveltekit": "^0.6.6",
|
||||
"@welshman/app": "~0.0.37",
|
||||
"@welshman/content": "~0.0.15",
|
||||
"@welshman/dvm": "~0.0.13",
|
||||
"@welshman/editor": "~0.0.6",
|
||||
"@welshman/app": "~0.0.41",
|
||||
"@welshman/content": "~0.0.16",
|
||||
"@welshman/dvm": "~0.0.14",
|
||||
"@welshman/editor": "~0.0.10",
|
||||
"@welshman/feeds": "~0.0.30",
|
||||
"@welshman/lib": "~0.0.37",
|
||||
"@welshman/net": "~0.0.45",
|
||||
"@welshman/signer": "~0.0.19",
|
||||
"@welshman/lib": "~0.0.38",
|
||||
"@welshman/net": "~0.0.46",
|
||||
"@welshman/signer": "~0.0.20",
|
||||
"@welshman/store": "~0.0.15",
|
||||
"@welshman/util": "~0.0.57",
|
||||
"@welshman/util": "~0.0.59",
|
||||
"daisyui": "^4.12.10",
|
||||
"date-picker-svelte": "^2.13.0",
|
||||
"dotenv": "^16.4.5",
|
||||
@@ -76,11 +69,9 @@
|
||||
"fuse.js": "^7.0.0",
|
||||
"husky": "^9.1.6",
|
||||
"idb": "^8.0.0",
|
||||
"nostr-editor": "^0.0.3",
|
||||
"nostr-signer-capacitor-plugin": "^0.0.3",
|
||||
"nostr-tools": "^2.7.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"svelte-tiptap": "^1.1.3"
|
||||
"qrcode": "^1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,45 +185,53 @@
|
||||
@apply -m-1 min-h-12 p-1;
|
||||
}
|
||||
|
||||
.tiptap[contenteditable="true"] {
|
||||
.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);
|
||||
}
|
||||
|
||||
.tiptap {
|
||||
@apply max-h-[350px] overflow-y-auto p-2 px-4;
|
||||
}
|
||||
|
||||
.chat-editor .tiptap[contenteditable="true"] {
|
||||
@apply rounded-box bg-base-300;
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
opacity: 40%;
|
||||
}
|
||||
|
||||
.input-editor .tiptap[contenteditable="true"] {
|
||||
@apply input input-bordered h-auto p-[.65rem];
|
||||
.chat-editor .tiptap {
|
||||
@apply rounded-box bg-base-300 pr-12;
|
||||
}
|
||||
|
||||
.note-editor .tiptap[contenteditable="true"] {
|
||||
.note-editor .tiptap {
|
||||
--tiptap-object-bg: var(--base-200);
|
||||
@apply input input-bordered h-auto min-h-32 rounded-box p-[.65rem] pb-6;
|
||||
}
|
||||
|
||||
.tiptap pre code {
|
||||
@apply link-content block w-full;
|
||||
.input-editor .tiptap {
|
||||
--tiptap-object-bg: var(--base-200);
|
||||
@apply input input-bordered h-auto p-[.65rem];
|
||||
}
|
||||
|
||||
.tiptap p code {
|
||||
@apply link-content;
|
||||
}
|
||||
/* link-content, based on tiptap */
|
||||
|
||||
.link-content,
|
||||
.tiptap [tag] {
|
||||
@apply max-w-full overflow-hidden text-ellipsis whitespace-nowrap rounded bg-neutral px-1 text-neutral-content no-underline;
|
||||
}
|
||||
|
||||
.link-content.link-content-selected {
|
||||
@apply bg-primary text-primary-content;
|
||||
}
|
||||
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
opacity: 50%;
|
||||
.link-content {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border-radius: 3px;
|
||||
padding: 0 0.25rem;
|
||||
background-color: var(--base-100);
|
||||
color: var(--base-content);
|
||||
}
|
||||
|
||||
/* date input */
|
||||
@@ -248,19 +256,3 @@ emoji-picker {
|
||||
--input-font-color: var(--base-content);
|
||||
--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,3 +1,4 @@
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {get} from "svelte/store"
|
||||
import {ctx, sample, uniq, sleep, chunk, equals} from "@welshman/lib"
|
||||
import {
|
||||
@@ -27,8 +28,9 @@ import {
|
||||
getRelayTags,
|
||||
isShareableRelayUrl,
|
||||
getRelayTagValues,
|
||||
toNostrURI,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent, EventTemplate, List} from "@welshman/util"
|
||||
import type {TrustedEvent, EventContent, EventTemplate, List} from "@welshman/util"
|
||||
import type {SubscribeRequestWithHandlers} from "@welshman/net"
|
||||
import {PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
|
||||
import {Nip59, makeSecret, stamp, Nip46Broker} from "@welshman/signer"
|
||||
@@ -46,7 +48,7 @@ import {
|
||||
loadFollows,
|
||||
loadMutes,
|
||||
tagEvent,
|
||||
tagReactionTo,
|
||||
tagEventForReaction,
|
||||
getRelayUrls,
|
||||
userRelaySelections,
|
||||
userInboxRelaySelections,
|
||||
@@ -55,6 +57,8 @@ import {
|
||||
addSession,
|
||||
clearStorage,
|
||||
dropSession,
|
||||
tagEventForComment,
|
||||
tagEventForQuote,
|
||||
} from "@welshman/app"
|
||||
import type {Thunk} from "@welshman/app"
|
||||
import {
|
||||
@@ -95,6 +99,22 @@ export const getThunkError = async (thunk: Thunk) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
|
||||
if (parent) {
|
||||
const nevent = nip19.neventEncode({
|
||||
id: parent.id,
|
||||
kind: parent.kind,
|
||||
author: parent.pubkey,
|
||||
relays: ctx.app.router.Event(parent).limit(3).getUrls(),
|
||||
})
|
||||
|
||||
tags = [...tags, tagEventForQuote(parent)]
|
||||
content = toNostrURI(nevent) + "\n\n" + content
|
||||
}
|
||||
|
||||
return {content, tags}
|
||||
}
|
||||
|
||||
// Log in
|
||||
|
||||
export const loginWithNip46 = async ({
|
||||
@@ -459,7 +479,7 @@ export type ReactionParams = {
|
||||
}
|
||||
|
||||
export const makeReaction = ({event, content}: ReactionParams) => {
|
||||
const tags = [["k", String(event.kind)], ...tagReactionTo(event)]
|
||||
const tags = tagEventForReaction(event)
|
||||
const groupTag = getTag("h", event.tags)
|
||||
|
||||
if (groupTag) {
|
||||
@@ -473,37 +493,14 @@ export const makeReaction = ({event, content}: ReactionParams) => {
|
||||
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
|
||||
publishThunk({event: makeReaction(params), relays})
|
||||
|
||||
export type ReplyParams = {
|
||||
export type CommentParams = {
|
||||
event: TrustedEvent
|
||||
content: string
|
||||
tags?: string[][]
|
||||
}
|
||||
|
||||
export const makeComment = ({event, content, tags = []}: ReplyParams) => {
|
||||
const seenRoots = new Set<string>()
|
||||
export const makeComment = ({event, content, tags = []}: CommentParams) =>
|
||||
createEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event)]})
|
||||
|
||||
for (const [raw, ...tag] of event.tags.filter(t => t[0].match(/^(k|e|a|i)$/i))) {
|
||||
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])
|
||||
}
|
||||
|
||||
tags.push(["k", String(event.kind)])
|
||||
tags.push(["e", event.id])
|
||||
|
||||
return createEvent(COMMENT, {content, tags})
|
||||
}
|
||||
|
||||
export const publishComment = ({relays, ...params}: ReplyParams & {relays: string[]}) =>
|
||||
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
|
||||
publishThunk({event: makeComment(params), relays})
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {writable} from "svelte/store"
|
||||
import {EditorContent} from "svelte-tiptap"
|
||||
import {isMobile} from "@lib/html"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import {getEditor} from "@app/editor"
|
||||
import {makeEditor} from "@app/editor"
|
||||
|
||||
export let onSubmit: any
|
||||
export let content = ""
|
||||
export let editor: ReturnType<typeof getEditor> | undefined = undefined
|
||||
|
||||
export const focus = () => $editor.chain().focus().run()
|
||||
|
||||
const uploading = writable(false)
|
||||
|
||||
let element: HTMLElement
|
||||
|
||||
const uploadFiles = () => $editor!.chain().selectFiles().run()
|
||||
|
||||
const submit = () => {
|
||||
@@ -29,9 +29,9 @@
|
||||
$editor!.chain().clearContent().run()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
editor = getEditor({autofocus: !isMobile, element, submit, uploading})
|
||||
const editor = makeEditor({autofocus: !isMobile, submit, uploading, aggressive: true})
|
||||
|
||||
onMount(() => {
|
||||
$editor!.chain().setContent(content).run()
|
||||
})
|
||||
</script>
|
||||
@@ -51,7 +51,7 @@
|
||||
{/if}
|
||||
</Button>
|
||||
<div class="chat-editor flex-grow overflow-hidden">
|
||||
<div bind:this={element} />
|
||||
<EditorContent editor={$editor} />
|
||||
</div>
|
||||
<Button
|
||||
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
||||
|
||||
@@ -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} hideMedia 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,12 +1,14 @@
|
||||
<script lang="ts">
|
||||
import {hash} from "@welshman/lib"
|
||||
import {now} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {
|
||||
thunks,
|
||||
pubkey,
|
||||
deriveProfile,
|
||||
deriveProfileDisplay,
|
||||
formatTimestampAsDate,
|
||||
formatTimestampAsTime,
|
||||
pubkey,
|
||||
} from "@welshman/app"
|
||||
import {isMobile} from "@lib/html"
|
||||
import LongPress from "@lib/components/LongPress.svelte"
|
||||
@@ -31,13 +33,14 @@
|
||||
export let inert = false
|
||||
|
||||
const thunk = $thunks[event.id]
|
||||
const today = formatTimestampAsDate(now())
|
||||
const profile = deriveProfile(event.pubkey)
|
||||
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
||||
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
|
||||
|
||||
const reply = () => replyTo(event)
|
||||
|
||||
const onLongPress = () => pushModal(ChannelMessageMenuMobile, {url, event})
|
||||
const onLongPress = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
|
||||
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
|
||||
|
||||
@@ -70,7 +73,14 @@
|
||||
<Button on:click={openProfile} class="text-sm font-bold" style="color: {colorValue}">
|
||||
{$profileDisplay}
|
||||
</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>
|
||||
{/if}
|
||||
<div class="text-sm">
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
export let url
|
||||
export let event
|
||||
export let reply
|
||||
|
||||
const onEmoji = (emoji: NativeEmoji) => {
|
||||
history.back()
|
||||
@@ -19,6 +20,11 @@
|
||||
|
||||
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
|
||||
|
||||
const sendReply = () => {
|
||||
history.back()
|
||||
reply()
|
||||
}
|
||||
|
||||
const showInfo = () => pushModal(EventInfo, {event}, {replaceState: true})
|
||||
|
||||
const showDelete = () => pushModal(ConfirmDelete, {url, event})
|
||||
@@ -29,6 +35,10 @@
|
||||
<Icon size={4} icon="smile-circle" />
|
||||
Send Reaction
|
||||
</Button>
|
||||
<Button class="btn btn-neutral w-full" on:click={sendReply}>
|
||||
<Icon size={4} icon="reply" />
|
||||
Send Reply
|
||||
</Button>
|
||||
<Button class="btn btn-neutral" on:click={showInfo}>
|
||||
<Icon size={4} icon="code-2" />
|
||||
Message Details
|
||||
|
||||
@@ -10,19 +10,10 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {derived} from "svelte/store"
|
||||
import type {Readable} from "svelte/store"
|
||||
import type {Editor} from "svelte-tiptap"
|
||||
import {nip19} from "nostr-tools"
|
||||
import {int, nthNe, MINUTE, sortBy, remove, ctx} from "@welshman/lib"
|
||||
import {int, nthNe, MINUTE, sortBy, remove} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util"
|
||||
import {
|
||||
pubkey,
|
||||
formatTimestampAsDate,
|
||||
inboxRelaySelectionsByPubkey,
|
||||
load,
|
||||
tagPubkey,
|
||||
} from "@welshman/app"
|
||||
import {pubkey, formatTimestampAsDate, inboxRelaySelectionsByPubkey, load} from "@welshman/app"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
@@ -36,9 +27,10 @@
|
||||
import ProfileList from "@app/components/ProfileList.svelte"
|
||||
import ChatMessage from "@app/components/ChatMessage.svelte"
|
||||
import ChatCompose from "@app/components/ChannelCompose.svelte"
|
||||
import ChatComposeParent from "@app/components/ChannelComposeParent.svelte"
|
||||
import {userSettingValues, deriveChat, splitChatId, PLATFORM_NAME} from "@app/state"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {sendWrapped} from "@app/commands"
|
||||
import {sendWrapped, prependParent} from "@app/commands"
|
||||
|
||||
export let id
|
||||
|
||||
@@ -57,28 +49,31 @@
|
||||
pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`})
|
||||
|
||||
const replyTo = (event: TrustedEvent) => {
|
||||
const relays = ctx.app.router.Event(event).getUrls()
|
||||
const nevent = nip19.neventEncode({...event, relays})
|
||||
|
||||
$editor.commands.insertNEvent({nevent})
|
||||
$editor.commands.insertContent("\n")
|
||||
$editor.commands.focus()
|
||||
parent = event
|
||||
compose.focus()
|
||||
}
|
||||
|
||||
const onSubmit = async ({content, ...params}: EventContent) => {
|
||||
// Remove p tags since they result in forking the conversation
|
||||
const tags = [...params.tags.filter(nthNe(0, "p")), ...remove($pubkey!, pubkeys).map(tagPubkey)]
|
||||
const clearParent = () => {
|
||||
parent = undefined
|
||||
}
|
||||
|
||||
const onSubmit = async ({content, tags}: EventContent) => {
|
||||
await sendWrapped({
|
||||
pubkeys,
|
||||
template: createEvent(DIRECT_MESSAGE, {content, tags}),
|
||||
template: createEvent(
|
||||
DIRECT_MESSAGE,
|
||||
prependParent(parent, {content, tags: tags.filter(nthNe(0, "p"))}),
|
||||
),
|
||||
delay: $userSettingValues.send_delay,
|
||||
})
|
||||
|
||||
clearParent()
|
||||
}
|
||||
|
||||
let loading = true
|
||||
let editor: Readable<Editor>
|
||||
let parent: TrustedEvent | undefined
|
||||
let elements: Element[] = []
|
||||
let compose: ChatCompose
|
||||
|
||||
$: {
|
||||
elements = []
|
||||
@@ -200,5 +195,8 @@
|
||||
<slot name="info" />
|
||||
</p>
|
||||
</div>
|
||||
<ChatCompose bind:editor {onSubmit} />
|
||||
{#if parent}
|
||||
<ChatComposeParent event={parent} clear={clearParent} />
|
||||
{/if}
|
||||
<ChatCompose bind:this={compose} {onSubmit} />
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,10 @@
|
||||
<div class="flex flex-col justify-start gap-1">
|
||||
<div class="flex items-center justify-between 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} />
|
||||
<ProfileName pubkey={others[0]} />
|
||||
{:else}
|
||||
|
||||
@@ -112,7 +112,8 @@
|
||||
</div>
|
||||
</Button>
|
||||
{/if}
|
||||
<span class="text-xs opacity-50">{formatTimestampAsTime(event.created_at)}</span>
|
||||
<span class="whitespace-nowrap text-xs opacity-50"
|
||||
>{formatTimestampAsTime(event.created_at)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-sm">
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
mediaLength: hideMedia ? 20 : 200,
|
||||
})
|
||||
|
||||
$: hasEllipsis = shortContent.find(isEllipsis)
|
||||
$: hasEllipsis = shortContent.some(isEllipsis)
|
||||
$: expandInline = hasEllipsis && expandMode === "inline"
|
||||
$: expandBlock = hasEllipsis && expandMode === "block"
|
||||
</script>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
</video>
|
||||
{:else if url.match(/\.(jpe?g|png|gif|webp)$/)}
|
||||
<button type="button" on:click|stopPropagation|preventDefault={expand}>
|
||||
<img alt="Link preview" src={imgproxy(url)} class="m-auto max-h-96" />
|
||||
<img alt="Link preview" src={imgproxy(url)} class="m-auto max-h-96 rounded-box" />
|
||||
</button>
|
||||
{:else}
|
||||
{#await loadPreview()}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {EditorContent} from "svelte-tiptap"
|
||||
import {writable} from "svelte/store"
|
||||
import {randomId} from "@welshman/lib"
|
||||
import {createEvent, EVENT_TIME} from "@welshman/util"
|
||||
@@ -11,7 +11,7 @@
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
||||
import {PROTECTED} from "@app/state"
|
||||
import {getEditor} from "@app/editor"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
export let url
|
||||
@@ -54,16 +54,12 @@
|
||||
history.back()
|
||||
}
|
||||
|
||||
let element: HTMLElement
|
||||
let editor: ReturnType<typeof getEditor>
|
||||
const editor = makeEditor({submit, uploading})
|
||||
|
||||
let title = ""
|
||||
let location = ""
|
||||
let start: Date
|
||||
let end: Date
|
||||
|
||||
onMount(() => {
|
||||
editor = getEditor({submit, element, uploading})
|
||||
})
|
||||
</script>
|
||||
|
||||
<form class="column gap-4" on:submit|preventDefault={submit}>
|
||||
@@ -83,7 +79,7 @@
|
||||
slot="input"
|
||||
class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
|
||||
<div class="input-editor flex-grow overflow-hidden">
|
||||
<div bind:this={element} />
|
||||
<EditorContent editor={$editor} />
|
||||
</div>
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import CardButton from "@lib/components/CardButton.svelte"
|
||||
import LogIn from "@app/components/LogIn.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"
|
||||
|
||||
const logIn = () => pushModal(LogIn)
|
||||
@@ -36,8 +36,8 @@
|
||||
</Button>
|
||||
<p class="text-center text-xs opacity-75">
|
||||
By using {PLATFORM_NAME}, you consent to our
|
||||
<Link external class="link" href="/terms.html">Terms of Service</Link> and
|
||||
<Link external class="link" href="/privacy.html">Privacy Policy</Link>.
|
||||
<Link external class="link" href={PLATFORM_TERMS}>Terms of Service</Link> and
|
||||
<Link external class="link" href={PLATFORM_PRIVACY}>Privacy Policy</Link>.
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
const back = () => history.back()
|
||||
|
||||
const onSubmit = async () => {
|
||||
const {signerPubkey, connectSecret, relays} = broker.parseBunkerUrl(input)
|
||||
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(input)
|
||||
|
||||
if (loading) {
|
||||
return
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {first, sortBy, ctx} from "@welshman/lib"
|
||||
import {getAncestorTags} from "@welshman/util"
|
||||
import {ctx} from "@welshman/lib"
|
||||
import type {Filter} from "@welshman/util"
|
||||
import {deriveEvents} from "@welshman/store"
|
||||
import {repository, load, loadRelaySelections, formatTimestampRelative} from "@welshman/app"
|
||||
@@ -13,11 +12,9 @@
|
||||
|
||||
export let pubkey
|
||||
|
||||
const filters: Filter[] = [{authors: [pubkey]}]
|
||||
const filters: Filter[] = [{authors: [pubkey], limit: 1}]
|
||||
const events = deriveEvents(repository, {filters})
|
||||
|
||||
$: roots = $events.filter(e => getAncestorTags(e.tags).replies.length === 0)
|
||||
|
||||
onMount(async () => {
|
||||
// Make sure we have their relay selections before we load their posts
|
||||
await loadRelaySelections(pubkey)
|
||||
@@ -39,10 +36,9 @@
|
||||
</Link>
|
||||
</div>
|
||||
<ProfileInfo {pubkey} />
|
||||
{#if roots.length > 0}
|
||||
{@const event = first(sortBy(e => -e.created_at, roots))}
|
||||
{#if $events.length > 0}
|
||||
<div class="bg-alt badge badge-neutral border-none">
|
||||
Last active {formatTimestampRelative(event.created_at)}
|
||||
Last active {formatTimestampRelative($events[0].created_at)}
|
||||
</div>
|
||||
{/if}
|
||||
<Link class="btn btn-primary sm:hidden" href={makeChatPath([pubkey])}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import {onMount} from "svelte"
|
||||
import {sortBy, uniqBy} from "@welshman/lib"
|
||||
import {feedFromFilter, makeIntersectionFeed, makeRelayFeed} from "@welshman/feeds"
|
||||
import {NOTE, getAncestorTags} from "@welshman/util"
|
||||
import {NOTE, getReplyTags} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {createFeedController} from "@welshman/app"
|
||||
import {createScroller} from "@lib/html"
|
||||
@@ -22,7 +22,7 @@
|
||||
feedFromFilter({kinds: [NOTE], authors: [pubkey]}),
|
||||
),
|
||||
onEvent: (event: TrustedEvent) => {
|
||||
if (getAncestorTags(event.tags).replies.length === 0) {
|
||||
if (getReplyTags(event.tags).replies.length === 0) {
|
||||
buffer.push(event)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -36,16 +36,18 @@
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
load({
|
||||
relays: [url],
|
||||
filters: [{kinds: [REACTION, REPORT, DELETE], "#e": [event.id]}],
|
||||
onEvent: batch(300, (events: TrustedEvent[]) => {
|
||||
load({
|
||||
relays: [url],
|
||||
filters: [{kinds: [DELETE], "#e": events.map(e => e.id)}],
|
||||
})
|
||||
}),
|
||||
})
|
||||
if (url) {
|
||||
load({
|
||||
relays: [url],
|
||||
filters: [{kinds: [REACTION, REPORT, DELETE], "#e": [event.id]}],
|
||||
onEvent: batch(300, (events: TrustedEvent[]) => {
|
||||
load({
|
||||
relays: [url],
|
||||
filters: [{kinds: [DELETE], "#e": events.map(e => e.id)}],
|
||||
})
|
||||
}),
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,107 +1,50 @@
|
||||
<script lang="ts">
|
||||
import {postJson, assoc} from "@welshman/lib"
|
||||
import {makeSecret, Nip46Broker} from "@welshman/signer"
|
||||
import {pubkey, loadHandle, updateSession} from "@welshman/app"
|
||||
import {postJson} from "@welshman/lib"
|
||||
import {isMobile} from "@lib/html"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import LogIn from "@app/components/LogIn.svelte"
|
||||
import InfoNostr from "@app/components/InfoNostr.svelte"
|
||||
import SignUpSuccess from "@app/components/SignUpSuccess.svelte"
|
||||
import {pushModal, clearModals} from "@app/modal"
|
||||
import {setChecked} from "@app/notifications"
|
||||
import {BURROW_URL, PLATFORM_NAME, NIP46_PERMS} from "@app/state"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {BURROW_URL, PLATFORM_NAME} from "@app/state"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {loginWithNip46} from "@app/commands"
|
||||
|
||||
const relays = ["wss://relay.nsec.app"]
|
||||
const ac = window.location.origin
|
||||
|
||||
const signerDomain = "nsec.app"
|
||||
const at = isMobile ? "android" : "web"
|
||||
|
||||
const signerPubkey = "e24a86943d37a91ab485d6f9a7c66097c25ddd67e8bd1b75ed252a3c266cf9bb"
|
||||
const nstart = `https://start.njump.me/?an=Flotilla&at=${at}&ac=${ac}`
|
||||
|
||||
const login = () => pushModal(LogIn)
|
||||
|
||||
const withLoading =
|
||||
(cb: (...args: any[]) => any) =>
|
||||
async (...args: any[]) => {
|
||||
loading = true
|
||||
const signupPassword = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
await cb(...args)
|
||||
} finally {
|
||||
loading = false
|
||||
try {
|
||||
const res = await postJson(BURROW_URL + "/user", {email, password})
|
||||
|
||||
if (res.error) {
|
||||
pushToast({message: res.error, theme: "error"})
|
||||
} else {
|
||||
pushModal(SignUpSuccess, {email}, {replaceState: true})
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
|
||||
const signupPassword = withLoading(async () => {
|
||||
const res = await postJson(BURROW_URL + "/user", {email, password})
|
||||
|
||||
if (res.error) {
|
||||
return pushToast({message: res.error, theme: "error"})
|
||||
}
|
||||
|
||||
pushModal(SignUpSuccess, {email}, {replaceState: true})
|
||||
})
|
||||
|
||||
const signupNsecApp = withLoading(async () => {
|
||||
const handle = await loadHandle(`${username}@${signerDomain}`)
|
||||
|
||||
if (handle?.pubkey) {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: "Sorry, it looks like that account already exists. Try logging in instead.",
|
||||
})
|
||||
}
|
||||
|
||||
const clientSecret = makeSecret()
|
||||
const broker = Nip46Broker.get({
|
||||
relays,
|
||||
clientSecret,
|
||||
signerPubkey,
|
||||
algorithm: "nip04",
|
||||
})
|
||||
|
||||
const userPubkey = await broker.createAccount(username, signerDomain, NIP46_PERMS)
|
||||
|
||||
if (!userPubkey) {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: "Sorry, it looks like something went wrong. Please try again.",
|
||||
})
|
||||
}
|
||||
|
||||
// Now we can log in. Use the user's pubkey for the handler (legacy stuff)
|
||||
const success = await loginWithNip46({relays, signerPubkey: userPubkey, clientSecret})
|
||||
|
||||
if (!success) {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: "Sorry, it looks like something went wrong. Please try again.",
|
||||
})
|
||||
}
|
||||
|
||||
updateSession($pubkey!, assoc("email", email))
|
||||
pushToast({message: "Successfully logged in!"})
|
||||
setChecked("*")
|
||||
clearModals()
|
||||
})
|
||||
}
|
||||
|
||||
const signup = () => {
|
||||
if (BURROW_URL) {
|
||||
signupPassword()
|
||||
} else {
|
||||
signupNsecApp()
|
||||
}
|
||||
}
|
||||
|
||||
let email = ""
|
||||
let password = ""
|
||||
let username = ""
|
||||
let loading = false
|
||||
</script>
|
||||
|
||||
@@ -136,29 +79,12 @@
|
||||
on other nostr applications, you can create a nostr key yourself, or export your key from {PLATFORM_NAME}
|
||||
later.
|
||||
</p>
|
||||
{:else}
|
||||
<Field>
|
||||
<div class="flex items-center gap-2" slot="input">
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon="user-rounded" />
|
||||
<input bind:value={username} class="grow" type="text" placeholder="username" />
|
||||
</label>
|
||||
@{signerDomain}
|
||||
</div>
|
||||
</Field>
|
||||
<Button type="submit" class="btn btn-primary" disabled={loading || !username}>
|
||||
<Spinner {loading}>Sign Up</Spinner>
|
||||
<Icon icon="alt-arrow-right" />
|
||||
</Button>
|
||||
<Divider>Or</Divider>
|
||||
{/if}
|
||||
<Divider>Or</Divider>
|
||||
<Link
|
||||
external
|
||||
href="https://nosta.me"
|
||||
class="btn {username || email || password ? 'btn-neutral' : 'btn-primary'}">
|
||||
<a href={nstart} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
|
||||
<Icon icon="square-share-line" />
|
||||
Get started on Nosta.me
|
||||
</Link>
|
||||
Get started on njump
|
||||
</a>
|
||||
<div class="text-sm">
|
||||
Already have an account?
|
||||
<Button class="link" on:click={login}>Log in instead</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {writable} from "svelte/store"
|
||||
import {EditorContent} from "svelte-tiptap"
|
||||
import {createEvent, THREAD} from "@welshman/util"
|
||||
import {publishThunk} from "@welshman/app"
|
||||
import {isMobile} from "@lib/html"
|
||||
@@ -11,7 +11,7 @@
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {GENERAL, tagRoom, PROTECTED} from "@app/state"
|
||||
import {getEditor} from "@app/editor"
|
||||
import {makeEditor} from "@app/editor"
|
||||
|
||||
export let url
|
||||
|
||||
@@ -53,13 +53,9 @@
|
||||
history.back()
|
||||
}
|
||||
|
||||
let title: string
|
||||
let element: HTMLElement
|
||||
let editor: ReturnType<typeof getEditor>
|
||||
const editor = makeEditor({submit, uploading, placeholder: "What's on your mind?"})
|
||||
|
||||
onMount(() => {
|
||||
editor = getEditor({submit, element, uploading, placeholder: "What's on your mind?"})
|
||||
})
|
||||
let title: string
|
||||
</script>
|
||||
|
||||
<form class="column gap-4" on:submit|preventDefault={submit}>
|
||||
@@ -83,7 +79,7 @@
|
||||
<Field>
|
||||
<p slot="label">Message*</p>
|
||||
<div slot="input" class="note-editor flex-grow overflow-hidden">
|
||||
<div bind:this={element} />
|
||||
<EditorContent editor={$editor} />
|
||||
</div>
|
||||
</Field>
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {writable} from "svelte/store"
|
||||
import {EditorContent} from "svelte-tiptap"
|
||||
import {isMobile} from "@lib/html"
|
||||
import {fly, slideAndFade} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
@@ -8,7 +8,7 @@
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {publishComment} from "@app/commands"
|
||||
import {tagRoom, GENERAL, PROTECTED} from "@app/state"
|
||||
import {getEditor} from "@app/editor"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
export let url
|
||||
@@ -34,12 +34,7 @@
|
||||
onSubmit(publishComment({event, content, tags, relays: [url]}))
|
||||
}
|
||||
|
||||
let editor: ReturnType<typeof getEditor>
|
||||
let element: HTMLElement
|
||||
|
||||
onMount(() => {
|
||||
editor = getEditor({element, submit, uploading, autofocus: !isMobile})
|
||||
})
|
||||
const editor = makeEditor({submit, uploading, autofocus: !isMobile})
|
||||
</script>
|
||||
|
||||
<form
|
||||
@@ -49,7 +44,7 @@
|
||||
class="card2 sticky bottom-2 z-feature mx-2 mt-4 bg-neutral">
|
||||
<div class="relative">
|
||||
<div class="note-editor flex-grow overflow-hidden">
|
||||
<div bind:this={element} />
|
||||
<EditorContent editor={$editor} />
|
||||
</div>
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
|
||||
@@ -25,27 +25,26 @@ export const signWithAssert = async (template: StampedEvent) => {
|
||||
return event!
|
||||
}
|
||||
|
||||
export const getEditor = ({
|
||||
export const makeEditor = ({
|
||||
aggressive = false,
|
||||
autofocus = false,
|
||||
charCount,
|
||||
content = "",
|
||||
element,
|
||||
placeholder = "",
|
||||
submit,
|
||||
uploading,
|
||||
wordCount,
|
||||
}: {
|
||||
aggressive?: boolean
|
||||
autofocus?: boolean
|
||||
charCount?: Writable<number>
|
||||
content?: string
|
||||
element: HTMLElement
|
||||
placeholder?: string
|
||||
submit: () => void
|
||||
uploading?: Writable<boolean>
|
||||
wordCount?: Writable<number>
|
||||
}) =>
|
||||
createEditor({
|
||||
element,
|
||||
content,
|
||||
autofocus,
|
||||
extensions: [
|
||||
@@ -60,6 +59,11 @@ export const getEditor = ({
|
||||
placeholder,
|
||||
},
|
||||
},
|
||||
breakOrSubmit: {
|
||||
config: {
|
||||
aggressive,
|
||||
},
|
||||
},
|
||||
fileUpload: {
|
||||
config: {
|
||||
onDrop() {
|
||||
|
||||
@@ -3,16 +3,9 @@ import {synced, throttled} from "@welshman/store"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import {prop, spec, identity, now, groupBy} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {MESSAGE, COMMENT, getTagValue} from "@welshman/util"
|
||||
import {MESSAGE, THREAD, COMMENT, getTagValue} from "@welshman/util"
|
||||
import {makeSpacePath, makeChatPath, makeThreadPath, makeRoomPath} from "@app/routes"
|
||||
import {
|
||||
THREAD_FILTER,
|
||||
COMMENT_FILTER,
|
||||
chats,
|
||||
getUrlsForEvent,
|
||||
userRoomsByUrl,
|
||||
repositoryStore,
|
||||
} from "@app/state"
|
||||
import {chats, getUrlsForEvent, userRoomsByUrl, repositoryStore} from "@app/state"
|
||||
|
||||
// Checked state
|
||||
|
||||
@@ -60,7 +53,10 @@ export const notifications = derived(
|
||||
}
|
||||
}
|
||||
|
||||
const allThreadEvents = $repository.query([THREAD_FILTER, COMMENT_FILTER])
|
||||
const allThreadEvents = $repository.query([
|
||||
{kinds: [THREAD]},
|
||||
{kinds: [COMMENT], "#K": [String(THREAD)]},
|
||||
])
|
||||
const allMessageEvents = $repository.query([{kinds: [MESSAGE]}])
|
||||
|
||||
for (const [url, rooms] of $userRoomsByUrl.entries()) {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {get} from "svelte/store"
|
||||
import {partition, assoc, now} from "@welshman/lib"
|
||||
import {MESSAGE, THREAD, COMMENT} from "@welshman/util"
|
||||
import {get, writable} from "svelte/store"
|
||||
import {partition, insert, sortBy, assoc, now} from "@welshman/lib"
|
||||
import {MESSAGE, DELETE, THREAD, COMMENT, matchFilters, getTagValues} from "@welshman/util"
|
||||
import type {TrustedEvent, Filter} from "@welshman/util"
|
||||
import {feedFromFilters, makeRelayFeed, makeIntersectionFeed} from "@welshman/feeds"
|
||||
import type {Subscription} from "@welshman/net"
|
||||
import type {AppSyncOpts} from "@welshman/app"
|
||||
import {subscribe, load, repository, pull, hasNegentropy} from "@welshman/app"
|
||||
import {subscribe, load, repository, pull, hasNegentropy, createFeedController} from "@welshman/app"
|
||||
import {createScroller} from "@lib/html"
|
||||
import {userRoomsByUrl, getUrlsForEvent} from "@app/state"
|
||||
|
||||
// Utils
|
||||
@@ -29,6 +32,82 @@ export const pullConservatively = ({relays, filters}: AppSyncOpts) => {
|
||||
return Promise.all(promises)
|
||||
}
|
||||
|
||||
export const makeFeed = ({
|
||||
relays,
|
||||
feedFilters,
|
||||
subscriptionFilters,
|
||||
element,
|
||||
onExhausted,
|
||||
initialEvents = [],
|
||||
}: {
|
||||
relays: string[]
|
||||
feedFilters: Filter[]
|
||||
subscriptionFilters: Filter[]
|
||||
element: HTMLElement
|
||||
onExhausted?: () => void
|
||||
initialEvents?: TrustedEvent[]
|
||||
}) => {
|
||||
const buffer = writable<TrustedEvent[]>([])
|
||||
const events = writable(initialEvents)
|
||||
|
||||
const onEvent = (event: TrustedEvent) => {
|
||||
buffer.update($buffer => {
|
||||
for (let i = 0; i < $buffer.length; i++) {
|
||||
if ($buffer[i].id === event.id) return $buffer
|
||||
if ($buffer[i].created_at < event.created_at) return insert(i, event, $buffer)
|
||||
}
|
||||
|
||||
return [...$buffer, event]
|
||||
})
|
||||
}
|
||||
|
||||
const deleteEvent = (e: TrustedEvent) => {
|
||||
const ids = getTagValues(["e", "a"], e.tags)
|
||||
|
||||
buffer.update($buffer => $buffer.filter(e => !ids.includes(e.id)))
|
||||
events.update($events => $events.filter(e => !ids.includes(e.id)))
|
||||
}
|
||||
|
||||
const ctrl = createFeedController({
|
||||
useWindowing: true,
|
||||
feed: makeIntersectionFeed(makeRelayFeed(...relays), feedFromFilters(feedFilters)),
|
||||
onEvent,
|
||||
onExhausted,
|
||||
})
|
||||
|
||||
const sub = subscribe({
|
||||
relays,
|
||||
filters: subscriptionFilters,
|
||||
onEvent: (e: TrustedEvent) => {
|
||||
if (matchFilters(feedFilters, e)) onEvent(e)
|
||||
if (e.kind === DELETE) deleteEvent(e)
|
||||
},
|
||||
})
|
||||
|
||||
const scroller = createScroller({
|
||||
element,
|
||||
delay: 300,
|
||||
threshold: 10_000,
|
||||
onScroll: async () => {
|
||||
const $buffer = get(buffer)
|
||||
|
||||
events.update($events => sortBy(e => -e.created_at, [...$events, ...$buffer.splice(0, 100)]))
|
||||
|
||||
if ($buffer.length < 100) {
|
||||
ctrl.load(100)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
events,
|
||||
cleanup: () => {
|
||||
scroller.stop()
|
||||
sub.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Application requests
|
||||
|
||||
export const listenForNotifications = () => {
|
||||
|
||||
@@ -80,10 +80,6 @@ export const GENERAL = "_"
|
||||
|
||||
export const PROTECTED = ["-"]
|
||||
|
||||
export const LEGACY_MESSAGE = 209
|
||||
|
||||
export const LEGACY_THREAD = 309
|
||||
|
||||
export const INDEXER_RELAYS = [
|
||||
"wss://purplepag.es/",
|
||||
"wss://relay.damus.io/",
|
||||
@@ -94,6 +90,10 @@ export const SIGNER_RELAYS = ["wss://relay.nsec.app/", "wss://bucket.coracle.soc
|
||||
|
||||
export const PLATFORM_URL = window.location.origin
|
||||
|
||||
export const PLATFORM_TERMS = import.meta.env.VITE_PLATFORM_TERMS
|
||||
|
||||
export const PLATFORM_PRIVACY = import.meta.env.VITE_PLATFORM_PRIVACY
|
||||
|
||||
export const PLATFORM_LOGO = PLATFORM_URL + "/pwa-192x192.png"
|
||||
|
||||
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
|
||||
@@ -114,13 +114,6 @@ export const IMGPROXY_URL = "https://imgproxy.coracle.social"
|
||||
|
||||
export const REACTION_KINDS = [REACTION, ZAP_RESPONSE]
|
||||
|
||||
export const THREAD_FILTER: Filter = {kinds: [THREAD, LEGACY_THREAD]}
|
||||
|
||||
export const COMMENT_FILTER: Filter = {
|
||||
kinds: [COMMENT],
|
||||
"#K": [String(THREAD), String(LEGACY_THREAD)],
|
||||
}
|
||||
|
||||
export const NIP46_PERMS =
|
||||
"nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt," +
|
||||
[CLIENT_AUTH, AUTH_JOIN, MESSAGE, THREAD, COMMENT, GROUPS, WRAP, REACTION]
|
||||
@@ -417,19 +410,22 @@ export const chats = derived(
|
||||
pushToMapKey(messagesByChatId, chatId, message)
|
||||
}
|
||||
|
||||
const displayPubkey = (pubkey: string) => {
|
||||
const profile = $profilesByPubkey.get(pubkey)
|
||||
|
||||
return profile ? displayProfile(profile) : ""
|
||||
}
|
||||
|
||||
return sortBy(
|
||||
c => -c.last_activity,
|
||||
Array.from(messagesByChatId.entries()).map(([id, events]): Chat => {
|
||||
const pubkeys = splitChatId(id)
|
||||
const pubkeys = remove($pubkey!, splitChatId(id))
|
||||
const messages = sortBy(e => -e.created_at, events)
|
||||
const last_activity = messages[0].created_at
|
||||
const search_text = remove($pubkey as string, pubkeys)
|
||||
.map(pubkey => {
|
||||
const profile = $profilesByPubkey.get(pubkey)
|
||||
|
||||
return profile ? displayProfile(profile) : ""
|
||||
})
|
||||
.join(" ")
|
||||
const search_text =
|
||||
pubkeys.length === 0
|
||||
? displayPubkey($pubkey!) + " note to self"
|
||||
: pubkeys.map(displayPubkey).join(" ")
|
||||
|
||||
return {id, pubkeys, messages, last_activity, search_text}
|
||||
}),
|
||||
@@ -456,24 +452,9 @@ export const chatSearch = derived(chats, $chats =>
|
||||
|
||||
// Messages
|
||||
|
||||
// TODO: remove support for legacy messages
|
||||
export const adaptLegacyMessage = (event: TrustedEvent) => {
|
||||
if (event.kind === LEGACY_MESSAGE) {
|
||||
let room = event.tags.find(nthEq(0, "~"))?.[1] || GENERAL
|
||||
|
||||
if (room === "general") {
|
||||
room = GENERAL
|
||||
}
|
||||
|
||||
return {...event, kind: MESSAGE, tags: [...event.tags, tagRoom(room, "")]}
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
export const messages = derived(
|
||||
deriveEvents(repository, {filters: [{kinds: [MESSAGE, LEGACY_MESSAGE]}]}),
|
||||
$events => $events.map(adaptLegacyMessage),
|
||||
deriveEvents(repository, {filters: [{kinds: [MESSAGE]}]}),
|
||||
$events => $events,
|
||||
)
|
||||
|
||||
// Nip29
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import Tippy from "@lib/components/Tippy.svelte"
|
||||
|
||||
export let value: string
|
||||
export let options: string[]
|
||||
export let options: string[] = []
|
||||
export let allowCreate = false
|
||||
|
||||
let input: Element
|
||||
@@ -20,7 +20,7 @@
|
||||
createSearch(options, {
|
||||
getValue: identity,
|
||||
fuseOptions: {keys: [""]},
|
||||
}),
|
||||
}).searchValues,
|
||||
)
|
||||
|
||||
const select = (newValue: string) => {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import {hexToBytes, bytesToHex} from "@noble/hashes/utils"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
|
||||
export const displayList = <T>(xs: T[], conj = "and", n = 6, locale = "en-US") => {
|
||||
const stringItems = xs.map(String)
|
||||
|
||||
@@ -11,3 +14,13 @@ export const displayList = <T>(xs: T[], conj = "and", n = 6, locale = "en-US") =
|
||||
|
||||
return new Intl.ListFormat(locale, {style: "long", type: "conjunction"}).format(stringItems)
|
||||
}
|
||||
|
||||
export const nsecEncode = (secret: string) => nip19.nsecEncode(hexToBytes(secret))
|
||||
|
||||
export const nsecDecode = (nsec: string) => {
|
||||
const {type, data} = nip19.decode(nsec)
|
||||
|
||||
if (type !== "nsec") throw new Error(`Invalid nsec: ${nsec}`)
|
||||
|
||||
return bytesToHex(data)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import "@src/app.css"
|
||||
import {onMount} from "svelte"
|
||||
import {nip19} from "nostr-tools"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {get, derived} from "svelte/store"
|
||||
import {App} from "@capacitor/app"
|
||||
import {dev} from "$app/environment"
|
||||
import {goto} from "$app/navigation"
|
||||
import {bytesToHex, hexToBytes} from "@noble/hashes/utils"
|
||||
import {identity, sleep, take, sortBy, ago, now, HOUR, WEEK, MONTH, Worker} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
@@ -20,6 +22,7 @@
|
||||
getPubkeyTagValues,
|
||||
getListTags,
|
||||
} from "@welshman/util"
|
||||
import {Nip46Broker, getPubkey, makeSecret} from "@welshman/signer"
|
||||
import {
|
||||
relays,
|
||||
handles,
|
||||
@@ -38,6 +41,7 @@
|
||||
getRelayUrls,
|
||||
subscribe,
|
||||
userInboxRelaySelections,
|
||||
addSession,
|
||||
} from "@welshman/app"
|
||||
import * as lib from "@welshman/lib"
|
||||
import * as util from "@welshman/util"
|
||||
@@ -48,9 +52,10 @@
|
||||
import ModalContainer from "@app/components/ModalContainer.svelte"
|
||||
import {setupTracking} from "@app/tracking"
|
||||
import {setupAnalytics} from "@app/analytics"
|
||||
import {nsecDecode} from "@lib/util"
|
||||
import {theme} from "@app/theme"
|
||||
import {INDEXER_RELAYS, userMembership, ensureUnwrapped, canDecrypt} from "@app/state"
|
||||
import {loadUserData} from "@app/commands"
|
||||
import {loadUserData, loginWithNip46} from "@app/commands"
|
||||
import {listenForNotifications} from "@app/requests"
|
||||
import * as commands from "@app/commands"
|
||||
import * as requests from "@app/requests"
|
||||
@@ -81,10 +86,46 @@
|
||||
...notifications,
|
||||
})
|
||||
|
||||
// Nstart login
|
||||
if (window.location.hash?.startsWith("#nostr-login")) {
|
||||
const params = new URLSearchParams(window.location.hash.slice(1))
|
||||
const login = params.get("nostr-login")
|
||||
|
||||
let success = false
|
||||
|
||||
try {
|
||||
if (login?.startsWith("bunker://")) {
|
||||
success = await loginWithNip46({
|
||||
clientSecret: makeSecret(),
|
||||
...Nip46Broker.parseBunkerUrl(login),
|
||||
})
|
||||
} else if (login) {
|
||||
const secret = nsecDecode(login)
|
||||
|
||||
addSession({method: "nip01", secret, pubkey: getPubkey(secret)})
|
||||
success = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
goto("/home")
|
||||
}
|
||||
}
|
||||
|
||||
if (!db) {
|
||||
setupTracking()
|
||||
setupAnalytics()
|
||||
|
||||
App.addListener("backButton", () => {
|
||||
if (window.history.length > 1) {
|
||||
window.history.back()
|
||||
} else {
|
||||
App.exitApp()
|
||||
}
|
||||
})
|
||||
|
||||
ready = initStorage("flotilla", 5, {
|
||||
relays: storageAdapters.fromCollectionStore("url", relays, {throttle: 3000}),
|
||||
handles: storageAdapters.fromCollectionStore("nip05", handles, {throttle: 3000}),
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
let term = ""
|
||||
|
||||
$: chats = $chatSearch.searchOptions(term).filter(c => c.pubkeys.length > 1)
|
||||
$: chats = $chatSearch.searchOptions(term)
|
||||
</script>
|
||||
|
||||
<SecondaryNav>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
const openMenu = () => pushModal(ChatMenuMobile)
|
||||
|
||||
$: chats = $chatSearch.searchOptions(term).filter(c => c.pubkeys.length > 1)
|
||||
$: chats = $chatSearch.searchOptions(term)
|
||||
|
||||
onDestroy(() => {
|
||||
setChecked($page.url.pathname)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import {pubkey, signer, userMutes, tagPubkey, publishThunk} from "@welshman/app"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
|
||||
import {pushToast} from "@app/toast"
|
||||
@@ -79,6 +80,24 @@
|
||||
{settings.send_delay === 1000 ? "second" : "seconds"}.
|
||||
</p>
|
||||
</FieldInline>
|
||||
<Field>
|
||||
<p slot="label">Media Server</p>
|
||||
<div slot="input" class="flex gap-2">
|
||||
<select bind:value={settings.upload_type} class="select select-bordered">
|
||||
<option value="nip96">NIP 96 (default)</option>
|
||||
<option value="blossom">Blossom</option>
|
||||
</select>
|
||||
<label class="input input-bordered flex flex-grow items-center gap-2">
|
||||
<Icon icon="link-round" />
|
||||
{#if settings.upload_type === "nip96"}
|
||||
<input class="grow" bind:value={settings.nip96_urls[0]} />
|
||||
{:else}
|
||||
<input class="grow" bind:value={settings.blossom_urls[0]} />
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
<p slot="info">Choose a media server type and url for files you upload to flotilla.</p>
|
||||
</Field>
|
||||
<div class="mt-4 flex flex-row items-center justify-between gap-4">
|
||||
<Button class="btn btn-neutral" on:click={reset}>Discard Changes</Button>
|
||||
<Button type="submit" class="btn btn-primary">Save Changes</Button>
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import {PLATFORM_NAME} from "@app/state"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
const hash = import.meta.env.VITE_BUILD_HASH
|
||||
|
||||
const pubkey = "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"
|
||||
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey})
|
||||
@@ -48,6 +50,9 @@
|
||||
class="link"
|
||||
href="https://www.figma.com/community/file/1166831539721848736">480 Design</Link>
|
||||
</p>
|
||||
{#if hash}
|
||||
<p class="text-xs">Running build {hash.slice(0, 8)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex justify-center gap-4">
|
||||
<div class="tooltip" data-tip="Source Code">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {page} from "$app/stores"
|
||||
import {ago, WEEK} from "@welshman/lib"
|
||||
import {GROUPS, MESSAGE, DELETE} from "@welshman/util"
|
||||
import {subscribe} from "@welshman/app"
|
||||
import {ago, MONTH} from "@welshman/lib"
|
||||
import {GROUPS, THREAD, COMMENT, MESSAGE, DELETE} from "@welshman/util"
|
||||
import {subscribe, load} from "@welshman/app"
|
||||
import Page from "@lib/components/Page.svelte"
|
||||
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
|
||||
import MenuSpace from "@app/components/MenuSpace.svelte"
|
||||
@@ -12,7 +12,7 @@
|
||||
import {pushModal} from "@app/modal"
|
||||
import {setChecked} from "@app/notifications"
|
||||
import {checkRelayConnection, checkRelayAuth, checkRelayAccess} from "@app/commands"
|
||||
import {decodeRelay, userRoomsByUrl, THREAD_FILTER, COMMENT_FILTER} from "@app/state"
|
||||
import {decodeRelay, userRoomsByUrl} from "@app/state"
|
||||
import {pullConservatively} from "@app/requests"
|
||||
import {notifications} from "@app/notifications"
|
||||
|
||||
@@ -47,23 +47,22 @@
|
||||
checkConnection()
|
||||
|
||||
const relays = [url]
|
||||
const since = ago(WEEK)
|
||||
const since = ago(MONTH)
|
||||
|
||||
// Load all groups for this space to populate navigation
|
||||
pullConservatively({relays, filters: [{kinds: [GROUPS]}]})
|
||||
// Load all groups for this space to populate navigation. It would be nice to sync, but relay29
|
||||
// is too picky about how requests are built.
|
||||
load({relays, filters: [{kinds: [GROUPS]}], delay: 0})
|
||||
|
||||
// Load threads and comments
|
||||
// Load threads, comments, and recent messages for user rooms to help with a quick page transition
|
||||
pullConservatively({
|
||||
relays,
|
||||
filters: [
|
||||
{...THREAD_FILTER, since},
|
||||
{...COMMENT_FILTER, since},
|
||||
{kinds: [THREAD], since},
|
||||
{kinds: [COMMENT], "#K": [String(THREAD)], since},
|
||||
...rooms.map(r => ({kinds: [MESSAGE], "#h": [r], since})),
|
||||
],
|
||||
})
|
||||
|
||||
// Load recent messages for user rooms to help with a quick page transition
|
||||
pullConservatively({relays, filters: rooms.map(r => ({kinds: [MESSAGE], "#h": [r], since}))})
|
||||
|
||||
// Listen for deletes that would apply to messages we already have, and new groups
|
||||
const sub = subscribe({relays, filters: [{kinds: [DELETE, GROUPS], since}]})
|
||||
|
||||
|
||||
@@ -1,70 +1,50 @@
|
||||
<script lang="ts">
|
||||
import {nip19} from "nostr-tools"
|
||||
import {onMount} from "svelte"
|
||||
import {derived} from "svelte/store"
|
||||
import {page} from "$app/stores"
|
||||
import {sleep, now, ctx} from "@welshman/lib"
|
||||
import type {Readable} from "svelte/store"
|
||||
import {now} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {throttled} from "@welshman/store"
|
||||
import {feedsFromFilter, makeIntersectionFeed, makeRelayFeed} from "@welshman/feeds"
|
||||
import {createEvent, MESSAGE, DELETE, REACTION} from "@welshman/util"
|
||||
import {
|
||||
formatTimestampAsDate,
|
||||
createFeedController,
|
||||
subscribe,
|
||||
publishThunk,
|
||||
deriveRelay,
|
||||
} from "@welshman/app"
|
||||
import {slide} from "@lib/transition"
|
||||
import {createScroller, type Scroller} from "@lib/html"
|
||||
import {formatTimestampAsDate, pubkey, publishThunk, deriveRelay, repository} from "@welshman/app"
|
||||
import {slide, fade, fly} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import PageBar from "@lib/components/PageBar.svelte"
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import type {getEditor} from "@app/editor"
|
||||
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
|
||||
import ChannelName from "@app/components/ChannelName.svelte"
|
||||
import ChannelMessage from "@app/components/ChannelMessage.svelte"
|
||||
import ChannelCompose from "@app/components/ChannelCompose.svelte"
|
||||
import ChannelComposeParent from "@app/components/ChannelComposeParent.svelte"
|
||||
import {
|
||||
userSettingValues,
|
||||
decodeRelay,
|
||||
deriveEventsForUrl,
|
||||
GENERAL,
|
||||
tagRoom,
|
||||
LEGACY_MESSAGE,
|
||||
userRoomsByUrl,
|
||||
displayChannel,
|
||||
getEventsForUrl,
|
||||
} from "@app/state"
|
||||
import {setChecked} from "@app/notifications"
|
||||
import {nip29, addRoomMembership, removeRoomMembership, getThunkError} from "@app/commands"
|
||||
import {setChecked, checked} from "@app/notifications"
|
||||
import {
|
||||
nip29,
|
||||
addRoomMembership,
|
||||
removeRoomMembership,
|
||||
prependParent,
|
||||
getThunkError,
|
||||
} from "@app/commands"
|
||||
import {PROTECTED, hasNip29} from "@app/state"
|
||||
import {makeFeed} from "@app/requests"
|
||||
import {popKey} from "@app/implicit"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
const {room = GENERAL} = $page.params
|
||||
const lastChecked = $checked[$page.url.pathname]
|
||||
const content = popKey<string>("content") || ""
|
||||
const url = decodeRelay($page.params.relay)
|
||||
const filter = {kinds: [MESSAGE], "#h": [room]}
|
||||
const relay = deriveRelay(url)
|
||||
const legacyRoom = room === GENERAL ? "general" : room
|
||||
const feeds = feedsFromFilter({kinds: [MESSAGE], "#h": [room]})
|
||||
|
||||
const events = throttled(
|
||||
300,
|
||||
deriveEventsForUrl(url, [
|
||||
{kinds: [MESSAGE], "#h": [room]},
|
||||
{kinds: [LEGACY_MESSAGE], "#~": [legacyRoom]},
|
||||
]),
|
||||
)
|
||||
|
||||
const ctrl = createFeedController({
|
||||
useWindowing: true,
|
||||
feed: makeIntersectionFeed(makeRelayFeed(url), ...feeds),
|
||||
onExhausted: () => {
|
||||
loading = false
|
||||
},
|
||||
})
|
||||
|
||||
const assertEvent = (e: any) => e as TrustedEvent
|
||||
|
||||
@@ -89,85 +69,130 @@
|
||||
}
|
||||
|
||||
const replyTo = (event: TrustedEvent) => {
|
||||
const relays = ctx.app.router.Event(event).getUrls()
|
||||
const nevent = nip19.neventEncode({...event, relays})
|
||||
|
||||
$editor.commands.insertNEvent({nevent})
|
||||
$editor.commands.insertContent("\n")
|
||||
$editor.commands.focus()
|
||||
parent = event
|
||||
compose.focus()
|
||||
}
|
||||
|
||||
const onSubmit = ({content, tags}: EventContent) =>
|
||||
const clearParent = () => {
|
||||
parent = undefined
|
||||
}
|
||||
|
||||
const onSubmit = ({content, tags}: EventContent) => {
|
||||
tags.push(tagRoom(room, url))
|
||||
tags.push(PROTECTED)
|
||||
|
||||
publishThunk({
|
||||
relays: [url],
|
||||
event: createEvent(MESSAGE, {content, tags: [...tags, tagRoom(room, url), PROTECTED]}),
|
||||
event: createEvent(MESSAGE, prependParent(parent, {content, tags})),
|
||||
delay: $userSettingValues.send_delay,
|
||||
})
|
||||
|
||||
let limit = 100
|
||||
let loading = true
|
||||
let unmounted = false
|
||||
let element: HTMLElement
|
||||
let scroller: Scroller
|
||||
let editor: ReturnType<typeof getEditor>
|
||||
clearParent()
|
||||
}
|
||||
|
||||
const elements = derived(events, $events => {
|
||||
const $elements = []
|
||||
const onScroll = () => {
|
||||
showScrollButton = Math.abs(element?.scrollTop || 0) > 1500
|
||||
|
||||
if (!newMessages || newMessagesSeen) {
|
||||
showFixedNewMessages = false
|
||||
} else {
|
||||
const {y} = newMessages.getBoundingClientRect()
|
||||
|
||||
if (y > 300) {
|
||||
newMessagesSeen = true
|
||||
} else {
|
||||
showFixedNewMessages = y < 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToNewMessages = () =>
|
||||
newMessages.scrollIntoView({behavior: "smooth", block: "center"})
|
||||
|
||||
const scrollToBottom = () => element.scrollTo({top: 0, behavior: "smooth"})
|
||||
|
||||
let parent: TrustedEvent | undefined
|
||||
let loading = true
|
||||
let element: HTMLElement
|
||||
let newMessages: HTMLElement
|
||||
let newMessagesSeen = false
|
||||
let showFixedNewMessages = false
|
||||
let showScrollButton = false
|
||||
let cleanup: () => void
|
||||
let events: Readable<TrustedEvent[]>
|
||||
let compose: ChannelCompose
|
||||
let elements: any[] = []
|
||||
|
||||
$: {
|
||||
elements = []
|
||||
|
||||
const seen = new Set()
|
||||
|
||||
let previousDate
|
||||
let previousPubkey
|
||||
let newMessagesSeen = false
|
||||
|
||||
for (const event of $events.toReversed()) {
|
||||
const {id, pubkey, created_at} = event
|
||||
const date = formatTimestampAsDate(created_at)
|
||||
if (events) {
|
||||
for (const event of $events.toReversed()) {
|
||||
const {id, pubkey, created_at} = event
|
||||
|
||||
if (date !== previousDate) {
|
||||
$elements.push({type: "date", value: date, id: date, showPubkey: false})
|
||||
if (seen.has(id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const date = formatTimestampAsDate(created_at)
|
||||
|
||||
if (
|
||||
!newMessagesSeen &&
|
||||
event.pubkey !== $pubkey &&
|
||||
lastChecked &&
|
||||
created_at > lastChecked
|
||||
) {
|
||||
elements.push({type: "new-messages", id: "new-messages"})
|
||||
newMessagesSeen = true
|
||||
}
|
||||
|
||||
if (date !== previousDate) {
|
||||
elements.push({type: "date", value: date, id: date, showPubkey: false})
|
||||
}
|
||||
|
||||
elements.push({
|
||||
id,
|
||||
type: "note",
|
||||
value: event,
|
||||
showPubkey: date !== previousDate || previousPubkey !== pubkey,
|
||||
})
|
||||
|
||||
previousDate = date
|
||||
previousPubkey = pubkey
|
||||
seen.add(id)
|
||||
}
|
||||
|
||||
$elements.push({
|
||||
id,
|
||||
type: "note",
|
||||
value: event,
|
||||
showPubkey: date !== previousDate || previousPubkey !== pubkey,
|
||||
})
|
||||
|
||||
previousDate = date
|
||||
previousPubkey = pubkey
|
||||
}
|
||||
|
||||
return $elements.reverse()
|
||||
})
|
||||
elements.reverse()
|
||||
|
||||
setTimeout(onScroll, 100)
|
||||
}
|
||||
|
||||
$: {
|
||||
if (element) {
|
||||
;({events, cleanup} = makeFeed({
|
||||
element,
|
||||
relays: [url],
|
||||
feedFilters: [filter],
|
||||
subscriptionFilters: [{kinds: [DELETE, REACTION, MESSAGE], "#h": [room], since: now()}],
|
||||
initialEvents: getEventsForUrl(repository, url, [{...filter, limit: 20}]),
|
||||
onExhausted: () => {
|
||||
loading = false
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Element is frequently not defined. I don't know why
|
||||
sleep(1000).then(() => {
|
||||
if (!unmounted) {
|
||||
scroller = createScroller({
|
||||
element,
|
||||
delay: 300,
|
||||
threshold: 10_000,
|
||||
onScroll: () => {
|
||||
limit += 100
|
||||
|
||||
if ($events.length - limit < 100) {
|
||||
ctrl.load(200)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const sub = subscribe({
|
||||
relays: [url],
|
||||
filters: [{kinds: [DELETE, REACTION, MESSAGE], "#h": [room], since: now()}],
|
||||
})
|
||||
|
||||
return () => {
|
||||
unmounted = true
|
||||
setChecked($page.url.pathname)
|
||||
scroller?.stop()
|
||||
sub.close()
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -198,13 +223,23 @@
|
||||
</div>
|
||||
</PageBar>
|
||||
<div
|
||||
class="scroll-container -mt-2 flex flex-grow flex-col-reverse overflow-auto py-2"
|
||||
class="scroll-container -mt-2 flex flex-grow flex-col-reverse overflow-y-auto overflow-x-hidden py-2"
|
||||
on:scroll={onScroll}
|
||||
bind:this={element}>
|
||||
{#each $elements.slice(0, limit) as { type, id, value, showPubkey } (id)}
|
||||
{#if type === "date"}
|
||||
{#each elements as { type, id, value, showPubkey } (id)}
|
||||
{#if type === "new-messages"}
|
||||
<div
|
||||
bind:this={newMessages}
|
||||
class="flex items-center py-2 text-xs transition-colors"
|
||||
class:opacity-0={showFixedNewMessages}>
|
||||
<div class="h-px flex-grow bg-primary" />
|
||||
<p class="rounded-full bg-primary px-2 py-1 text-primary-content">New Messages</p>
|
||||
<div class="h-px flex-grow bg-primary" />
|
||||
</div>
|
||||
{:else if type === "date"}
|
||||
<Divider>{value}</Divider>
|
||||
{:else}
|
||||
<div in:slide class:-mt-4={!showPubkey}>
|
||||
<div in:slide class:-mt-1={!showPubkey}>
|
||||
<ChannelMessage {url} {room} {replyTo} event={assertEvent(value)} {showPubkey} />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -217,5 +252,27 @@
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<ChannelCompose bind:editor {content} {onSubmit} />
|
||||
{#if showFixedNewMessages}
|
||||
<div class="relative z-feature flex justify-center">
|
||||
<div transition:fly={{duration: 200}} class="fixed top-12">
|
||||
<Button class="btn btn-primary btn-xs rounded-full" on:click={scrollToNewMessages}>
|
||||
New Messages
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
{#if parent}
|
||||
<ChannelComposeParent event={parent} clear={clearParent} />
|
||||
{/if}
|
||||
<ChannelCompose bind:this={compose} {content} {onSubmit} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showScrollButton}
|
||||
<div in:fade class="fixed bottom-14 right-4">
|
||||
<Button class="btn btn-circle btn-neutral" on:click={scrollToBottom}>
|
||||
<Icon icon="alt-arrow-down" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||