Compare commits

...

31 Commits

Author SHA1 Message Date
Jon Staab 863d11352f Bump versions 2025-03-04 11:28:41 -08:00
Jon Staab b4cc770cdf Update changelog 2025-03-04 11:20:24 -08:00
Jon Staab 901e56a625 Tweak settings page, hide alerts 2025-03-04 10:58:14 -08:00
Jon Staab 479fed34f7 Fix chat layout on ios 2025-03-04 10:52:27 -08:00
Jon Staab 81d7b08aed Fix profile suggestions 2025-03-04 10:47:58 -08:00
Jon Staab a582b1ea73 Apply layout changes to chat 2025-03-04 10:20:06 -08:00
Jon Staab 1c0b2a09df ellipsize page bar title 2025-03-04 10:00:24 -08:00
Jon Staab 3a42a1b560 Rework css on room view to avoid losing input visibility 2025-03-04 09:49:56 -08:00
Jon Staab db203bf00d Move page bar closer to top of screen 2025-03-03 17:10:17 -08:00
Jon Staab ffb36af734 Make analytics and error reporting optional 2025-03-03 15:09:58 -08:00
Jon Staab b399fa8dcc Replace long press with tap target 2025-03-03 13:59:38 -08:00
Jon Staab 5bba5959f7 Attempt to fix keyboard placement, wait for connection 2025-03-03 13:23:44 -08:00
Jon Staab 2ad65e394e Remember user minute selection 2025-03-03 13:04:12 -08:00
Jon Staab 345b20bf5d Fix nevent hints for url-specific stuff 2025-03-03 12:10:47 -08:00
Jon Staab b9fb251b32 Randomize subscription minute 2025-02-27 09:33:40 -08:00
Jon Staab dd9a9c0df2 Add status to alert items 2025-02-25 13:36:32 -08:00
Jon Staab 115b5f9fbe Extend timeout for setChecked 2025-02-24 13:40:26 -08:00
Jon Staab 3ad7dcfeb4 Ignore some files 2025-02-19 11:13:38 -08:00
Jon Staab 60d107aed2 Fix some state stuff, snapshot things in the right places 2025-02-18 17:15:41 -08:00
Jon Staab 08d8d45ecb Refactor confirm to avoid passing closures 2025-02-18 09:03:10 -08:00
Jon Staab c40e8ce1a7 Fix reactions on mobile 2025-02-17 17:33:21 -08:00
Jon Staab 993bf8d2e6 Bump gradle build number 2025-02-14 16:20:16 -08:00
Jon Staab c3c65c3970 Use in-app onboarding on all native platforms 2025-02-14 16:07:40 -08:00
Jon Staab a5b868cd56 Update changelog 2025-02-14 16:02:11 -08:00
Jon Staab 8fcc56a408 Bump version 2025-02-14 16:00:18 -08:00
Jon Staab c8dfbc936b Spruce up nstart, add profile deletion 2025-02-14 15:59:20 -08:00
Jon Staab f1e76a1ed1 Bump versions, limit key generation to ios 2025-02-14 12:18:52 -08:00
Jon Staab 6ecc3e6770 Improve discover page 2025-02-14 12:14:00 -08:00
Jon Staab b05c408977 Move loadUserData to requests 2025-02-14 11:12:19 -08:00
Jon Staab e484c3cb00 Bump versions 2025-02-14 10:56:00 -08:00
Jon Staab 69d0e11ba4 Add nip 01 login flow to mobile 2025-02-14 10:53:36 -08:00
61 changed files with 1641 additions and 597 deletions
+2
View File
@@ -3,4 +3,6 @@
--ignore-dir=build
--ignore-dir=ios/DerivedData
--ignore-dir=ios/App/App/public
--ignore-file=match:.svg
--ignore-file=match:package-lock.json
+1 -1
View File
@@ -1,4 +1,4 @@
VITE_DEFAULT_PUBKEYS=fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
VITE_BURROW_URL=
VITE_PLATFORM_URL=https://flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms
+2
View File
@@ -56,6 +56,8 @@ out/
.gradle/
local.properties
proguard/
google-services.json
GoogleService-Info.plist
# IDEs and editors
.idea/
+29
View File
@@ -1,5 +1,34 @@
# Changelog
# 1.0.0
* Add alerts via Anchor
# 0.2.12
* Fix keyboard covering chat input
* Fix thread replies
* Make error reporting and analytics optional
* Replace long press with tap target
* Fix time input
* Fix nevent hints for url-specific stuff
* Fix confirm and reactions on mobile
* Add reply to chat on mobile
* Fix profile suggestions
# 0.2.11
* Add in-app signup flow on ios
* Add profile deletion
# 0.2.10
* Improve space discovery
# 0.2.9
* Add NIP 01 signup flow on mobile
# 0.2.8
* Show spinner when joining a room
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "social.flotilla"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 9
versionName "0.2.7"
versionCode 12
versionName "0.2.12"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1 -1
View File
@@ -10,7 +10,7 @@ const config: CapacitorConfig = {
plugins: {
SplashScreen: {
androidSplashResourceName: "splash"
}
},
},
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload
// server: {
+4 -4
View File
@@ -351,14 +351,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.2.8;
MARKETING_VERSION = 0.2.12;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -376,14 +376,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.2.8;
MARKETING_VERSION = 0.2.12;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
+71 -85
View File
@@ -1,12 +1,12 @@
{
"name": "flotilla",
"version": "0.2.8",
"version": "0.2.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flotilla",
"version": "0.2.8",
"version": "0.2.12",
"dependencies": {
"@capacitor/android": "^7.0.0",
"@capacitor/app": "^7.0.0",
@@ -21,16 +21,16 @@
"@types/qrcode": "^1.5.5",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "~0.0.42",
"@welshman/content": "~0.0.18",
"@welshman/dvm": "~0.0.14",
"@welshman/editor": "~0.0.15",
"@welshman/feeds": "~0.0.30",
"@welshman/lib": "~0.0.41",
"@welshman/net": "~0.0.47",
"@welshman/signer": "~0.0.20",
"@welshman/store": "~0.0.16",
"@welshman/util": "~0.0.61",
"@welshman/app": "^0.0.43",
"@welshman/content": "^0.1.0",
"@welshman/dvm": "^0.0.15",
"@welshman/editor": "^0.1.0",
"@welshman/feeds": "^0.1.0",
"@welshman/lib": "^0.1.0",
"@welshman/net": "^0.0.49",
"@welshman/signer": "^0.1.0",
"@welshman/store": "^0.1.0",
"@welshman/util": "^0.1.0",
"daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5",
@@ -4448,9 +4448,9 @@
"peer": true
},
"node_modules/@types/ws": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.14.tgz",
"integrity": "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -4712,19 +4712,19 @@
}
},
"node_modules/@welshman/app": {
"version": "0.0.42",
"resolved": "https://registry.npmjs.org/@welshman/app/-/app-0.0.42.tgz",
"integrity": "sha512-+yV2VZ1r/BVhaLfZyyXe6RNMAbj1Z5jFlvy9K14Z3oTRix9ugvFgs78nLgXbt/lyLvafOit0c3RoGyQcixEb3Q==",
"version": "0.0.43",
"resolved": "https://registry.npmjs.org/@welshman/app/-/app-0.0.43.tgz",
"integrity": "sha512-cW/0h48d18m8eyajfE+ZNUEKyehR2NY5w4jWZjHwEHjUlj6izTTIYLhcLxAGZoge/raEpWhvKkI2CeiX+3PheA==",
"license": "MIT",
"dependencies": {
"@types/throttle-debounce": "^5.0.2",
"@welshman/dvm": "~0.0.13",
"@welshman/feeds": "~0.0.30",
"@welshman/lib": "~0.0.39",
"@welshman/net": "~0.0.46",
"@welshman/signer": "~0.0.19",
"@welshman/store": "~0.0.15",
"@welshman/util": "~0.0.59",
"@welshman/dvm": "^0.0.15",
"@welshman/feeds": "^0.1.0",
"@welshman/lib": "^0.1.0",
"@welshman/net": "^0.0.49",
"@welshman/signer": "^0.1.0",
"@welshman/store": "^0.1.0",
"@welshman/util": "^0.1.0",
"fuse.js": "^7.0.0",
"idb": "^8.0.0",
"svelte": "^4.2.18",
@@ -4766,13 +4766,13 @@
}
},
"node_modules/@welshman/content": {
"version": "0.0.18",
"resolved": "https://registry.npmjs.org/@welshman/content/-/content-0.0.18.tgz",
"integrity": "sha512-7LHs9xKStrkaet9VY1PWSEUWrdIaIThIo+ByN6lF3nRZwPTExrBy4rPXnEa5roVAAwgmlhXw3zTkfGP15V6joQ==",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@welshman/content/-/content-0.1.0.tgz",
"integrity": "sha512-l+r3JgBf6raPcwsAsNiM3N4Ms0X88uKPMuPltQLOMv0whaDCUVpu/w7llQBX6fH7v9RgSq0imgkUCWw9puYNlQ==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.0.2",
"@welshman/lib": "~0.0.40",
"@welshman/lib": "^0.1.0",
"nostr-tools": "^2.7.2"
},
"engines": {
@@ -4780,22 +4780,22 @@
}
},
"node_modules/@welshman/dvm": {
"version": "0.0.14",
"resolved": "https://registry.npmjs.org/@welshman/dvm/-/dvm-0.0.14.tgz",
"integrity": "sha512-C7nJ3Z3QQv5ZRVxH57rqM/z7m9ljDAaAPCjhZdnO/MXzkGdy6AfczSiXK8IXTe9q4dYyEJ7kADo7UVfwES/t5Q==",
"version": "0.0.15",
"resolved": "https://registry.npmjs.org/@welshman/dvm/-/dvm-0.0.15.tgz",
"integrity": "sha512-XYdQBsbMIYX0ympQdq3KiacnoDYqXhQ4m+7zVROOO4rF9swht7az46T12Sga046eLUbFTK2f45qFAPEwcmhDHg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.6.1",
"@welshman/lib": "~0.0.37",
"@welshman/net": "~0.0.46",
"@welshman/util": "~0.0.58",
"@welshman/lib": "^0.1.0",
"@welshman/net": "^0.0.49",
"@welshman/util": "^0.1.0",
"nostr-tools": "^2.7.2"
}
},
"node_modules/@welshman/editor": {
"version": "0.0.15",
"resolved": "https://registry.npmjs.org/@welshman/editor/-/editor-0.0.15.tgz",
"integrity": "sha512-Eg3alzv+cjCXtr6oEItRqoRSD4DTllt3c2JyJTxpF/KNiy8XHHMeUSpVFgph3+pAt5jwyl6b1feKPEwpShgqHw==",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@welshman/editor/-/editor-0.1.0.tgz",
"integrity": "sha512-gqbkjWhyb37tbDwd1gYPc/KWd+kpF6V0YQi7apoBIPGqiprYuzzrkhUe27DWgWDvCik4GB3Ef2Gy9vmQDRXpcg==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^2.11.5",
@@ -4811,41 +4811,27 @@
"@tiptap/extension-text": "^2.11.5",
"@tiptap/pm": "^2.11.5",
"@tiptap/suggestion": "^2.11.5",
"@welshman/lib": "~0.0.40",
"@welshman/util": "^0.0.60",
"@welshman/lib": "^0.1.0",
"@welshman/util": "^0.1.0",
"nostr-editor": "^0.0.4-pre.13",
"nostr-tools": "^2.10.4",
"tippy.js": "^6.3.7"
}
},
"node_modules/@welshman/editor/node_modules/@welshman/util": {
"version": "0.0.60",
"resolved": "https://registry.npmjs.org/@welshman/util/-/util-0.0.60.tgz",
"integrity": "sha512-kqZgYnrwxKx0JTDZnTSaQYc2ev7E9ZjNDy5MclX36d5T/qPUspmwksAOodFJY9kJoJd49bf1omAmBTgnFJfeHw==",
"license": "MIT",
"dependencies": {
"@types/ws": "^8.5.13",
"@welshman/lib": "~0.0.37",
"nostr-tools": "^2.7.2"
},
"engines": {
"node": ">=10.4.0"
}
},
"node_modules/@welshman/feeds": {
"version": "0.0.30",
"resolved": "https://registry.npmjs.org/@welshman/feeds/-/feeds-0.0.30.tgz",
"integrity": "sha512-Zcex2uJVeYM55zDI1Dhb5I41lYGD4BURWl95nbFaWbbMYDwoAFIS2cPXBsaGNrITzsz8qByvRs2RnplrmZwSzA==",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@welshman/feeds/-/feeds-0.1.0.tgz",
"integrity": "sha512-89N1Ibcyzbs7VFljSWe/lpg/hLPq2/EKk5xegiZ7Sn8+X0Mqn7+8V3HUBEbPk+ZICLSIwEsksrFkWgFyeKLoSQ==",
"license": "MIT",
"dependencies": {
"@welshman/lib": "~0.0.37",
"@welshman/util": "~0.0.54"
"@welshman/lib": "^0.1.0",
"@welshman/util": "^0.1.0"
}
},
"node_modules/@welshman/lib": {
"version": "0.0.41",
"resolved": "https://registry.npmjs.org/@welshman/lib/-/lib-0.0.41.tgz",
"integrity": "sha512-FMJVoPZw8Vi1fd2/ulwqlBS1tvjkFAm9lg+Dz5SXItXxrNC06YMRTjGjInCBEkArrvNGPUjchzSFDNmbH0fxHQ==",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@welshman/lib/-/lib-0.1.0.tgz",
"integrity": "sha512-U3hKLigTOP62/jkJbQboZ4P1wSZae16xdFVC3CXT9lk3aRkI7g6pAfzCnungCbeX26X/HgRUs/IqRRMI+tz/Pg==",
"license": "MIT",
"dependencies": {
"@scure/base": "^1.1.6",
@@ -4854,28 +4840,28 @@
}
},
"node_modules/@welshman/net": {
"version": "0.0.47",
"resolved": "https://registry.npmjs.org/@welshman/net/-/net-0.0.47.tgz",
"integrity": "sha512-/mIr+QyLH+RlD16rsPDTIW250lOm5eNaLO6dhZw8dMKznMhVtSWe/X/lJZOXmexzbB2z7WYZVN5x5TggZROyxA==",
"version": "0.0.49",
"resolved": "https://registry.npmjs.org/@welshman/net/-/net-0.0.49.tgz",
"integrity": "sha512-DvsBh+MGIZtRd08itpxM8H40tNB2CuEx1ayydnIjk/bX9J2SgfjoFcMhr0mJ8PdSYvwmfRV95T4gF19ni8ANeQ==",
"license": "MIT",
"dependencies": {
"@welshman/lib": "~0.0.40",
"@welshman/util": "~0.0.59",
"@welshman/lib": "^0.1.0",
"@welshman/util": "^0.1.0",
"isomorphic-ws": "^5.0.0",
"ws": "^8.16.0"
}
},
"node_modules/@welshman/signer": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@welshman/signer/-/signer-0.0.20.tgz",
"integrity": "sha512-t7ulAMtx+b1NedTzs91D/2WTdM0OzTQHsv15qtXQoIyqrdhJ7QQsMHkDdFUkyPK8CBxAWO8kVorbDM8DbVyeCQ==",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@welshman/signer/-/signer-0.1.0.tgz",
"integrity": "sha512-9pLpYsAcriBqQROiJlnnncyCHcneDS2iD+gA5SXv1lHxKRMm3yePx5vjlUj1vJ4+JMvNiutQeTnqOc+gJ31MoA==",
"license": "MIT",
"dependencies": {
"@noble/curves": "^1.7.0",
"@noble/hashes": "^1.6.1",
"@welshman/lib": "~0.0.37",
"@welshman/net": "~0.0.46",
"@welshman/util": "~0.0.58",
"@welshman/lib": "^0.1.0",
"@welshman/net": "^0.0.49",
"@welshman/util": "^0.1.0",
"nostr-tools": "^2.7.2"
},
"engines": {
@@ -4886,13 +4872,13 @@
}
},
"node_modules/@welshman/store": {
"version": "0.0.16",
"resolved": "https://registry.npmjs.org/@welshman/store/-/store-0.0.16.tgz",
"integrity": "sha512-hIcvcBnmE2jbEyl44iw2qRVPq66CQsVevCMK8NlKHN5LrRydbZQDrU1v/1uWV43FoGr2jjIawUiJUsm8+GlP+Q==",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@welshman/store/-/store-0.1.0.tgz",
"integrity": "sha512-i0AD8Y4OuuxdQvxmKgziNbiAZ+WDjheY5Z/DGfBTKPWrITOqA5vmWY601WYUJe0HgleXcgp0dpjFXRdX7bVIvQ==",
"license": "MIT",
"dependencies": {
"@welshman/lib": "~0.0.37",
"@welshman/util": "~0.0.59",
"@welshman/lib": "^0.1.0",
"@welshman/util": "^0.1.0",
"svelte": "^4.2.18"
}
},
@@ -4931,13 +4917,13 @@
}
},
"node_modules/@welshman/util": {
"version": "0.0.61",
"resolved": "https://registry.npmjs.org/@welshman/util/-/util-0.0.61.tgz",
"integrity": "sha512-+l4YX01msAtnyylzpIFIAYubvnBLyr9hGx3iRO5LS3OPv/yUDOeyYJseWDqorkIiN5BRT7PCgnWJdlQP71ZtAw==",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@welshman/util/-/util-0.1.0.tgz",
"integrity": "sha512-acXG0+HoFPT/zDwFO0zBWt9TrCHD0IYNJNN67CNwmv9yicVskDsFs8L65uoSEDgCOjp9eQnIMXf20s8a/XO3Ng==",
"license": "MIT",
"dependencies": {
"@types/ws": "^8.5.13",
"@welshman/lib": "~0.0.40",
"@welshman/lib": "^0.1.0",
"nostr-tools": "^2.7.2"
},
"engines": {
@@ -15240,9 +15226,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "flotilla",
"version": "0.2.8",
"version": "0.2.12",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -50,16 +50,16 @@
"@types/qrcode": "^1.5.5",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "~0.0.42",
"@welshman/content": "~0.0.18",
"@welshman/dvm": "~0.0.14",
"@welshman/editor": "~0.0.15",
"@welshman/feeds": "~0.0.30",
"@welshman/lib": "~0.0.41",
"@welshman/net": "~0.0.47",
"@welshman/signer": "~0.0.20",
"@welshman/store": "~0.0.16",
"@welshman/util": "~0.0.61",
"@welshman/app": "^0.0.43",
"@welshman/content": "^0.1.0",
"@welshman/dvm": "^0.0.15",
"@welshman/editor": "^0.1.0",
"@welshman/feeds": "^0.1.0",
"@welshman/lib": "^0.1.0",
"@welshman/net": "^0.0.49",
"@welshman/signer": "^0.1.0",
"@welshman/store": "^0.1.0",
"@welshman/util": "^0.1.0",
"daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5",
+32
View File
@@ -1,3 +1,5 @@
@import "@welshman/editor/index.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -323,3 +325,33 @@ emoji-picker {
--input-font-color: var(--base-content);
--outline-color: var(--base-100);
}
/* progress */
progress[value]::-webkit-progress-value {
transition: width 0.5s;
}
/* content width for fixed elements */
.cw {
@apply w-full md:w-[calc(100%-18.5rem)];
}
/* chat view */
.chat__page-bar {
@apply sait cw !fixed top-0;
}
.chat__messages {
@apply saib cw fixed top-12 flex h-[calc(100%-10rem)] flex-col-reverse overflow-y-auto overflow-x-hidden md:h-[calc(100%-6rem)];
}
.chat__compose {
@apply saib cw fixed bottom-14 md:bottom-0;
}
.chat__scroll-down {
@apply saib fixed bottom-28 right-4 md:bottom-16;
}
+3 -1
View File
@@ -2,7 +2,9 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="{ACCENT}" />
<meta name="description" content="{DESCRIPTION}" />
<meta name="og:url" content="{URL}" />
+2 -1
View File
@@ -1,6 +1,7 @@
/* eslint prefer-rest-params: 0 */
import {page} from "$app/stores"
import {getSetting} from "@app/state"
const w = window as any
@@ -12,7 +13,7 @@ w.plausible =
export const setupAnalytics = () => {
page.subscribe($page => {
if ($page.route) {
if ($page.route && getSetting("report_usage")) {
w.plausible("pageview", {u: $page.route.id})
}
})
+40 -52
View File
@@ -1,6 +1,6 @@
import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store"
import {ctx, sample, uniq, sleep, chunk, equals} from "@welshman/lib"
import {ctx, randomId, uniq, equals} from "@welshman/lib"
import {
DELETE,
REPORT,
@@ -26,12 +26,11 @@ import {
getTag,
getListTags,
getRelayTags,
isShareableRelayUrl,
getRelayTagValues,
toNostrURI,
unionFilters,
} from "@welshman/util"
import type {TrustedEvent, EventContent, EventTemplate, List} from "@welshman/util"
import type {SubscribeRequestWithHandlers} from "@welshman/net"
import type {TrustedEvent, Filter, EventContent, EventTemplate} from "@welshman/util"
import {PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
import {Nip59, makeSecret, stamp, Nip46Broker} from "@welshman/signer"
import {
@@ -40,13 +39,9 @@ import {
repository,
publishThunk,
publishThunks,
loadProfile,
loadInboxRelaySelections,
profilesByPubkey,
relaySelectionsByPubkey,
getWriteRelayUrls,
loadFollows,
loadMutes,
tagEvent,
tagEventForReaction,
getRelayUrls,
@@ -67,11 +62,12 @@ import {
userMembership,
INDEXER_RELAYS,
NIP46_PERMS,
loadMembership,
loadSettings,
getDefaultPubkeys,
ALERT,
NOTIFIER_PUBKEY,
NOTIFIER_RELAY,
userRoomsByUrl,
} from "@app/state"
import {loadUserData} from "@app/requests"
// Utils
@@ -161,47 +157,6 @@ export const logout = async () => {
localStorage.clear()
}
// Loaders
export const loadUserData = (
pubkey: string,
request: Partial<SubscribeRequestWithHandlers> = {},
) => {
const promise = Promise.race([
sleep(3000),
Promise.all([
loadInboxRelaySelections(pubkey, request),
loadMembership(pubkey, request),
loadSettings(pubkey, request),
loadProfile(pubkey, request),
loadFollows(pubkey, request),
loadMutes(pubkey, request),
]),
])
// 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 () => {
for (const pubkeys of chunk(50, getDefaultPubkeys())) {
const relays = sample(1, INDEXER_RELAYS)
await sleep(1000)
for (const pubkey of pubkeys) {
loadMembership(pubkey, {relays})
loadProfile(pubkey, {relays})
loadFollows(pubkey, {relays})
loadMutes(pubkey, {relays})
}
}
})
return promise
}
export const discoverRelays = (lists: List[]) =>
Promise.all(uniq(lists.flatMap(getRelayUrls)).filter(isShareableRelayUrl).map(loadRelay))
// Synchronization
export const broadcastUserData = async (relays: string[]) => {
@@ -376,6 +331,7 @@ export const checkRelayConnection = async (url: string) => {
const connection = ctx.net.pool.get(url)
await connection.socket.open()
await connection.socket.wait(3000)
if (connection.socket.status !== SocketStatus.Open) {
return `Failed to connect`
@@ -504,3 +460,35 @@ export const makeComment = ({event, content, tags = []}: CommentParams) =>
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
publishThunk({event: makeComment(params), relays})
export type AlertParams = {
cron: string
email: string
relay: string
handler: string
filters: Filter[]
}
export const makeAlert = async ({cron, email, handler, relay, filters}: AlertParams) =>
createEvent(ALERT, {
content: await signer
.get()
.nip44.encrypt(
NOTIFIER_PUBKEY,
JSON.stringify([
["cron", cron],
["email", email],
["relay", relay],
["handler", handler],
["channel", "email"],
...unionFilters(filters).map(filter => ["filter", JSON.stringify(filter)]),
]),
),
tags: [
["d", randomId()],
["p", NOTIFIER_PUBKEY],
],
})
export const publishAlert = async (params: AlertParams) =>
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import {Capacitor} from "@capacitor/core"
import {preventDefault} from "@lib/html"
import {randomInt} from "@welshman/lib"
import {displayRelayUrl, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
import type {Filter} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {getMembershipUrls, getMembershipRoomsByUrl, userMembership} from "@app/state"
import {loadAlertStatuses} from "@app/requests"
import {publishAlert} from "@app/commands"
import {pushToast} from "@app/toast"
const handler = Capacitor.isNativePlatform()
? "https://app.flotilla.social"
: window.location.origin
const timezone = new Date()
.toString()
.match(/GMT[^\s]+/)![0]
.slice(3)
const timezoneOffset = parseInt(timezone) / 100
const minute = randomInt(0, 59)
const hour = (17 - timezoneOffset) % 24
const WEEKLY = `0 ${minute} ${hour} * * 1`
const DAILY = `0 ${minute} ${hour} * * *`
let loading = false
let cron = WEEKLY
let email = ""
let relay = ""
let notifyThreads = true
let notifyCalendar = true
let notifyChat = false
const back = () => history.back()
const submit = async () => {
if (!email.includes("@")) {
return pushToast({
theme: "error",
message: "Please provide an email address",
})
}
if (!relay) {
return pushToast({
theme: "error",
message: "Please select a space",
})
}
if (!notifyThreads && !notifyCalendar && !notifyChat) {
return pushToast({
theme: "error",
message: "Please select something to be notified about",
})
}
const filters: Filter[] = []
if (notifyThreads) {
filters.push({kinds: [THREAD]})
filters.push({kinds: [COMMENT], "#k": [String(THREAD)]})
}
if (notifyCalendar) {
filters.push({kinds: [EVENT_TIME]})
filters.push({kinds: [COMMENT], "#k": [String(EVENT_TIME)]})
}
if (notifyChat) {
filters.push({kinds: [MESSAGE], "#h": getMembershipRoomsByUrl(relay, $userMembership)})
}
loading = true
try {
await publishAlert({cron, email, relay, handler, filters})
await loadAlertStatuses($pubkey!)
pushToast({message: "Your alert has been successfully created!"})
back()
} finally {
loading = false
}
}
</script>
<form class="column gap-4" onsubmit={preventDefault(submit)}>
<ModalHeader>
{#snippet title()}
Add an Alert
{/snippet}
</ModalHeader>
<FieldInline>
{#snippet label()}
<p>Email Address*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input placeholder="email@example.com" bind:value={email} />
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Frequency*</p>
{/snippet}
{#snippet input()}
<select bind:value={cron} class="select select-bordered">
<option value={WEEKLY}>Weekly</option>
<option value={DAILY}>Daily</option>
</select>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Space*</p>
{/snippet}
{#snippet input()}
<select bind:value={relay} class="select select-bordered">
<option value="" disabled selected>Choose a space URL</option>
{#each getMembershipUrls($userMembership) as url (url)}
<option value={url}>{displayRelayUrl(url)}</option>
{/each}
</select>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Notifications*</p>
{/snippet}
{#snippet input()}
<div class="flex items-center justify-end gap-4">
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
Threads
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} />
Calendar
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyChat} />
Chat
</span>
</div>
{/snippet}
</FieldInline>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Confirm</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import Confirm from "@lib/components/Confirm.svelte"
import type {Alert} from "@app/state"
import {NOTIFIER_RELAY} from "@app/state"
import {publishDelete} from "@app/commands"
import {pushToast} from "@app/toast"
type Props = {
alert: Alert
}
const {alert}: Props = $props()
const confirm = () => {
publishDelete({event: alert.event, relays: [NOTIFIER_RELAY]})
pushToast({message: "Your alert has been deleted!"})
history.back()
}
</script>
<Confirm {confirm} message="You'll no longer receive messages for this alert." />
+86
View File
@@ -0,0 +1,86 @@
<script lang="ts">
import {parseJson, nthEq} from "@welshman/lib"
import {
getAddress,
getTagValue,
getTagValues,
displayRelayUrl,
EVENT_TIME,
MESSAGE,
THREAD,
} from "@welshman/util"
import {displayList} from "@lib/util"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import AlertDelete from "@app/components/AlertDelete.svelte"
import type {Alert} from "@app/state"
import {alertStatuses} from "@app/state"
import {makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal"
type Props = {
alert: Alert
}
const {alert}: Props = $props()
const address = $derived(getAddress(alert.event))
const status = $derived($alertStatuses.find(s => s.event.tags.some(nthEq(1, address))))
const cron = $derived(getTagValue("cron", alert.tags))
const channel = $derived(getTagValue("channel", alert.tags))
const relay = $derived(getTagValue("relay", alert.tags)!)
const filters = $derived(getTagValues("filter", alert.tags).map(parseJson))
const types = $derived.by(() => {
const t: string[] = []
if (filters.some(f => f.kinds?.includes(THREAD))) t.push("threads")
if (filters.some(f => f.kinds?.includes(EVENT_TIME))) t.push("calendar events")
if (filters.some(f => f.kinds?.includes(MESSAGE))) t.push("chat")
return t
})
const startDelete = () => pushModal(AlertDelete, {alert})
</script>
<div class="flex items-start justify-between gap-4">
<Button class="py-1" onclick={startDelete}>
<Icon icon="trash-bin-2" />
</Button>
<div class="flex-inline gap-1">
{cron?.endsWith("1") ? "Weekly" : "Daily"} alert for
{displayList(types)} on
<Link class="link" href={makeSpacePath(relay)}>
{displayRelayUrl(relay)}
</Link>, sent via {channel}.
</div>
{#if status}
{@const statusText = getTagValue("status", status.tags) || "error"}
{#if statusText === "ok"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
data-tip={getTagValue("message", status.tags)}>
Active
</span>
{:else if statusText === "pending"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500"
data-tip={getTagValue("message", status.tags)}>
Pending
</span>
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip={getTagValue("message", status.tags)}>
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
</span>
{/if}
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip="The notification server did not respond to your request.">
Inactive
</span>
{/if}
</div>
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte"
import AlertItem from "@app/components/AlertItem.svelte"
import {pushModal} from "@app/modal"
import {alerts} from "@app/state"
const startAlert = () => pushModal(AlertAdd)
</script>
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
<div class="flex items-center justify-between">
<strong class="flex items-center gap-3">
<Icon icon="inbox" />
Alerts
</strong>
<Button class="btn btn-primary btn-sm" onclick={startAlert}>
<Icon icon="add-circle" />
Add Alert
</Button>
</div>
<div class="col-4">
{#each $alerts as alert (alert.event.id)}
<AlertItem {alert} />
{:else}
<p class="text-center opacity-75 py-12">No alerts found</p>
{/each}
</div>
</div>
+18 -16
View File
@@ -11,7 +11,7 @@
formatTimestampAsTime,
} from "@welshman/app"
import {isMobile} from "@lib/html"
import LongPress from "@lib/components/LongPress.svelte"
import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -45,7 +45,7 @@
const reply = () => replyTo(event)
const onLongPress = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
const onTap = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
@@ -60,9 +60,9 @@
}
</script>
<LongPress
<TapTarget
data-event={event.id}
onLongPress={inert ? null : onLongPress}
onTap={inert ? null : onTap}
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left">
<div class="flex w-full gap-3 overflow-auto">
{#if showPubkey}
@@ -99,15 +99,17 @@
<div class="row-2 ml-10 mt-1">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right" />
</div>
<button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}>
<ChannelMessageEmojiButton {url} {room} {event} />
{#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}>
<Icon icon="reply" size={4} />
</Button>
{/if}
<ChannelMessageMenuButton {url} {event} />
</button>
</LongPress>
{#if !isMobile}
<button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}>
<ChannelMessageEmojiButton {url} {room} {event} />
{#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}>
<Icon icon="reply" size={4} />
</Button>
{/if}
<ChannelMessageMenuButton {url} {event} />
</button>
{/if}
</TapTarget>
+3 -3
View File
@@ -4,7 +4,7 @@
import Icon from "@lib/components/Icon.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import EventReport from "@app/components/EventReport.svelte"
import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {pushModal} from "@app/modal"
const {url, event, onClick} = $props()
@@ -16,12 +16,12 @@
const showInfo = () => {
onClick()
pushModal(EventInfo, {event})
pushModal(EventInfo, {url, event})
}
const showDelete = () => {
onClick()
pushModal(ConfirmDelete, {url, event})
pushModal(EventDeleteConfirm, {url, event})
}
</script>
@@ -1,20 +1,27 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {publishReaction} from "@app/commands"
import {pushModal} from "@app/modal"
const {url, event, reply} = $props()
type Props = {
url: string
event: TrustedEvent
reply: () => void
}
const onEmoji = (emoji: NativeEmoji) => {
const {url, event, reply}: Props = $props()
const onEmoji = ((event: TrustedEvent, url: string, emoji: NativeEmoji) => {
history.back()
publishReaction({event, relays: [url], content: emoji.unicode})
}
}).bind(undefined, event, url)
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
@@ -23,9 +30,9 @@
reply()
}
const showInfo = () => pushModal(EventInfo, {event}, {replaceState: true})
const showInfo = () => pushModal(EventInfo, {url, event}, {replaceState: true})
const showDelete = () => pushModal(ConfirmDelete, {url, event})
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
</script>
<div class="col-2">
+117 -100
View File
@@ -42,8 +42,6 @@
const others = remove($pubkey!, pubkeys)
const missingInboxes = $derived(pubkeys.filter(pk => !$inboxRelaySelectionsByPubkey.has(pk)))
const assertEvent = (e: any) => e as TrustedEvent
const showMembers = () =>
pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`})
@@ -72,6 +70,8 @@
let loading = $state(true)
let compose: ChatCompose | undefined = $state()
let parent: TrustedEvent | undefined = $state()
let parentPreview: HTMLElement | undefined = $state()
let dynamicPadding: HTMLElement | undefined = $state()
const elements = $derived.by(() => {
const elements = []
@@ -106,6 +106,16 @@
onMount(() => {
// Don't use loadInboxRelaySelection because we want to force reload
load({filters: [{kinds: [INBOX_RELAYS], authors: others}]})
const observer = new ResizeObserver(() => {
dynamicPadding!.style.minHeight = `${parentPreview!.offsetHeight}px`
})
observer.observe(parentPreview!)
return () => {
observer.unobserve(parentPreview!)
}
})
setTimeout(() => {
@@ -113,106 +123,113 @@
}, 5000)
</script>
<div class="relative flex h-full w-full flex-col">
{#if others.length > 0}
<PageBar>
{#snippet title()}
<div class="flex flex-col gap-1 sm:flex-row sm:gap-2">
{#if others.length === 1}
{@const pubkey = others[0]}
{@const onClick = () => pushModal(ProfileDetail, {pubkey})}
<Button onclick={onClick} class="row-2">
<ProfileCircle {pubkey} size={5} />
<ProfileName {pubkey} />
</Button>
{:else}
<div class="flex items-center gap-2">
<ProfileCircles pubkeys={others} size={5} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ProfileName pubkey={others[0]} />
and
{#if others.length === 2}
<ProfileName pubkey={others[1]} />
{:else}
{others.length - 1}
{others.length > 2 ? "others" : "other"}
{/if}
</p>
</div>
{#if others.length > 2}
<Button onclick={showMembers} class="btn btn-link hidden sm:block"
>Show all members</Button>
{/if}
{/if}
</div>
{/snippet}
{#snippet action()}
<div>
{#if remove($pubkey, missingInboxes).length > 0}
{@const count = remove($pubkey, missingInboxes).length}
{@const label = count > 1 ? "inboxes are" : "inbox is"}
<div
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
data-tip="{count} {label} not configured.">
<Icon icon="danger" />
{count}
</div>
{/if}
</div>
{/snippet}
</PageBar>
{/if}
<div class="-mt-2 flex flex-grow flex-col-reverse overflow-auto py-2">
{#if missingInboxes.includes($pubkey!)}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
Your inbox is not configured.
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit
your <Link class="link" href="/settings/relays">relay settings page</Link> to set up your
inbox.
</p>
</div>
</div>
{:else if missingInboxes.length > 0}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
{missingInboxes.length}
{missingInboxes.length > 1 ? "inboxes are" : "inbox is"} not configured.
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make
sure everyone in this conversation has set up their inbox relays.
</p>
</div>
</div>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "date"}
<Divider>{value}</Divider>
{:else}
<ChatMessage event={assertEvent(value)} {pubkeys} {showPubkey} {replyTo} />
{/if}
{/each}
<p
class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
<Spinner {loading}>
{#if loading}
Looking for messages...
{#if others.length > 0}
<PageBar class="chat__page-bar">
{#snippet title()}
<div class="flex flex-col gap-1 sm:flex-row sm:gap-2">
{#if others.length === 1}
{@const pubkey = others[0]}
{@const onClick = () => pushModal(ProfileDetail, {pubkey})}
<Button onclick={onClick} class="row-2">
<ProfileCircle {pubkey} size={5} />
<ProfileName {pubkey} />
</Button>
{:else}
End of message history
<div class="flex items-center gap-2">
<ProfileCircles pubkeys={others} size={5} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ProfileName pubkey={others[0]} />
and
{#if others.length === 2}
<ProfileName pubkey={others[1]} />
{:else}
{others.length - 1}
{others.length > 2 ? "others" : "other"}
{/if}
</p>
</div>
{#if others.length > 2}
<Button onclick={showMembers} class="btn btn-link hidden sm:block"
>Show all members</Button>
{/if}
{/if}
</Spinner>
{@render info?.()}
</p>
</div>
{#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
</div>
{/snippet}
{#snippet action()}
<div>
{#if remove($pubkey, missingInboxes).length > 0}
{@const count = remove($pubkey, missingInboxes).length}
{@const label = count > 1 ? "inboxes are" : "inbox is"}
<div
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
data-tip="{count} {label} not configured.">
<Icon icon="danger" />
{count}
</div>
{/if}
</div>
{/snippet}
</PageBar>
{/if}
<div class="chat__messages scroll-container">
<div bind:this={dynamicPadding}></div>
{#if missingInboxes.includes($pubkey!)}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
Your inbox is not configured.
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit
your <Link class="link" href="/settings/relays">relay settings page</Link> to set up your inbox.
</p>
</div>
</div>
{:else if missingInboxes.length > 0}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
{missingInboxes.length}
{missingInboxes.length > 1 ? "inboxes are" : "inbox is"} not configured.
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make
sure everyone in this conversation has set up their inbox relays.
</p>
</div>
</div>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "date"}
<Divider>{value}</Divider>
{:else}
<ChatMessage
event={$state.snapshot(value as TrustedEvent)}
{pubkeys}
{showPubkey}
{replyTo} />
{/if}
{/each}
<p class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
<Spinner {loading}>
{#if loading}
Looking for messages...
{:else}
End of message history
{/if}
</Spinner>
{@render info?.()}
</p>
</div>
<div class="chat__compose bg-base-200">
<div bind:this={parentPreview}>
{#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
</div>
<ChatCompose bind:this={compose} {onSubmit} />
</div>
+34 -30
View File
@@ -13,7 +13,7 @@
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import LongPress from "@lib/components/LongPress.svelte"
import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import Content from "@app/components/Content.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
@@ -27,12 +27,12 @@
interface Props {
event: TrustedEvent
replyTo?: any
replyTo: (event: TrustedEvent) => void
pubkeys: string[]
showPubkey?: boolean
}
const {event, replyTo = undefined, pubkeys, showPubkey = false}: Props = $props()
const {event, replyTo, pubkeys, showPubkey = false}: Props = $props()
const thunk = $thunks[event.id]
const isOwn = event.pubkey === $pubkey
@@ -40,6 +40,8 @@
const profileDisplay = deriveProfileDisplay(event.pubkey)
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
const reply = () => replyTo(event)
const onReactionClick = async (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
const template = reaction ? makeDelete({event: reaction}) : makeReaction({event, content})
@@ -49,7 +51,7 @@
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys})
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply})
const togglePopover = () => {
if (popoverIsVisible) {
@@ -72,32 +74,34 @@
class:chat-start={!isOwn}
class:flex-row-reverse={!isOwn}
class:chat-end={isOwn}>
<Tippy
bind:popover
component={ChatMessageMenu}
props={{event, pubkeys, popover, replyTo}}
params={{
interactive: true,
trigger: "manual",
onShow() {
popoverIsVisible = true
},
onHidden() {
popoverIsVisible = false
},
}}>
<button
type="button"
class="opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}
onclick={togglePopover}>
<Icon icon="menu-dots" size={4} />
</button>
</Tippy>
{#if !isMobile}
<Tippy
bind:popover
component={ChatMessageMenu}
props={{event, pubkeys, popover, replyTo}}
params={{
interactive: true,
trigger: "manual",
onShow() {
popoverIsVisible = true
},
onHidden() {
popoverIsVisible = false
},
}}>
<button
type="button"
class="opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}
onclick={togglePopover}>
<Icon icon="menu-dots" size={4} />
</button>
</Tippy>
{/if}
<div class="flex min-w-0 flex-col" class:items-end={isOwn}>
<LongPress
class="bg-alt chat-bubble mx-1 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
onLongPress={showMobileMenu}>
<TapTarget
class="bg-alt chat-bubble mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
onTap={showMobileMenu}>
{#if showPubkey}
<div class="flex items-center gap-2">
{#if !isOwn}
@@ -120,7 +124,7 @@
<div class="text-sm">
<Content showEntire {event} />
</div>
</LongPress>
</TapTarget>
<div class="row-2 z-feature -mt-1 ml-4">
<ReactionSummary {event} {onReactionClick} noTooltip />
</div>
@@ -1,5 +1,6 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
@@ -8,15 +9,26 @@
import {pushModal} from "@app/modal"
import {clip} from "@app/toast"
const {event, pubkeys} = $props()
const onEmoji = (emoji: NativeEmoji) => {
history.back()
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
type Props = {
pubkeys: string[]
event: TrustedEvent
reply: () => void
}
const {event, pubkeys, reply}: Props = $props()
const onEmoji = ((event: TrustedEvent, emoji: NativeEmoji) => {
history.back()
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
}).bind(undefined, event)
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
const sendReply = () => {
history.back()
reply()
}
const copyText = () => {
history.back()
clip(event.content)
@@ -30,6 +42,10 @@
<Icon size={4} icon="smile-circle" />
Send Reaction
</Button>
<Button class="btn btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon="reply" />
Send Reply
</Button>
<Button class="btn btn-neutral w-full" onclick={copyText}>
<Icon size={4} icon="copy" />
Copy Text
@@ -1,14 +1,18 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import Confirm from "@lib/components/Confirm.svelte"
import {publishDelete} from "@app/commands"
import {clearModals} from "@app/modal"
const {url, event} = $props()
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const confirm = async () => {
const snapshot = $state.snapshot(event)
await publishDelete({event: snapshot, relays: [url]})
await publishDelete({event, relays: [url]})
clearModals()
}
+8 -2
View File
@@ -1,15 +1,21 @@
<script lang="ts">
import {nip19} from "nostr-tools"
import {ctx} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import {clip} from "@app/toast"
const {event} = $props()
type Props = {
url?: string
event: TrustedEvent
}
const relays = ctx.app.router.Event(event).getUrls()
const {url, event}: Props = $props()
const relays = url ? [url] : ctx.app.router.Event(event).getUrls()
const nevent1 = nip19.neventEncode({...event, relays})
const npub1 = nip19.npubEncode(event.pubkey)
const json = JSON.stringify(event, null, 2)
+3 -3
View File
@@ -7,7 +7,7 @@
import EventInfo from "@app/components/EventInfo.svelte"
import EventReport from "@app/components/EventReport.svelte"
import EventShare from "@app/components/EventShare.svelte"
import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {pushModal} from "@app/modal"
const {
@@ -31,7 +31,7 @@
const showInfo = () => {
onClick()
pushModal(EventInfo, {event})
pushModal(EventInfo, {url, event})
}
const share = () => {
@@ -41,7 +41,7 @@
const showDelete = () => {
onClick()
pushModal(ConfirmDelete, {url, event})
pushModal(EventDeleteConfirm, {url, event})
}
</script>
+1 -1
View File
@@ -13,7 +13,7 @@
import {pushModal, clearModals} from "@app/modal"
import {PLATFORM_NAME, BURROW_URL} from "@app/state"
import {pushToast} from "@app/toast"
import {loadUserData} from "@app/commands"
import {loadUserData} from "@app/requests"
import {setChecked} from "@app/notifications"
let signers: any[] = $state([])
+2 -1
View File
@@ -12,7 +12,8 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import QRCode from "@app/components/QRCode.svelte"
import InfoBunker from "@app/components/InfoBunker.svelte"
import {loginWithNip46, loadUserData} from "@app/commands"
import {loginWithNip46} from "@app/commands"
import {loadUserData} from "@app/requests"
import {pushModal, clearModals} from "@app/modal"
import {setChecked} from "@app/notifications"
import {pushToast} from "@app/toast"
+1 -1
View File
@@ -12,7 +12,7 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import PasswordResetRequest from "@app/components/PasswordResetRequest.svelte"
import {loadUserData} from "@app/commands"
import {loadUserData} from "@app/requests"
import {clearModals, pushModal} from "@app/modal"
import {setChecked} from "@app/notifications"
import {pushToast} from "@app/toast"
+1 -1
View File
@@ -10,7 +10,7 @@
}
}
let modal: any = $state()
let modal: any = $state.raw()
const hash = $derived($page.url.hash.slice(1))
const hashIsValid = $derived(Boolean($modals[hash]))
+146
View File
@@ -0,0 +1,146 @@
<script lang="ts">
import {chunk, sleep, uniq} from "@welshman/lib"
import {
createEvent,
createProfile,
PROFILE,
DELETE,
isReplaceable,
getAddress,
} from "@welshman/util"
import {pubkey, userRelaySelections, publishThunk, getRelayUrls, repository} from "@welshman/app"
import {preventDefault} from "@lib/html"
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 {pushToast} from "@app/toast"
import {logout} from "@app/commands"
import {INDEXER_RELAYS, PLATFORM_NAME, userMembership, getMembershipUrls} from "@app/state"
let progress: number | undefined = $state(undefined)
let confirmText = $state("")
const CONFIRM_TEXT = "permanently delete my nostr account"
const confirmOk = $derived(confirmText.toLowerCase().trim() === CONFIRM_TEXT)
const showProgress = $derived(progress !== undefined)
const deleteProfile = async () => {
if (!confirmOk) {
return pushToast({
theme: "error",
message: "Please type your confirmation into the text box.",
})
}
const chunks = chunk(500, repository.query([{authors: [$pubkey!]}]))
const profileEvent = createEvent(PROFILE, createProfile({name: "[deleted]"}))
const vanishEvent = createEvent(62, {tags: [["relay", "ALL_RELAYS"]]})
const denominator = chunks.length + 2
const relays = uniq([
...INDEXER_RELAYS,
...getRelayUrls($userRelaySelections),
...getMembershipUrls($userMembership),
])
let step = 0
const incrementProgress = async () => {
progress = ++step / denominator
return sleep(800)
}
// First, blank out their profile in case relays don't support deletion by address
await publishThunk({relays, event: profileEvent})
await incrementProgress()
// Next, send a "right to vanish" event to all relays
await publishThunk({relays, event: vanishEvent})
await incrementProgress()
// Finally, send deletion requests for all known events in case relays don't support right to vanish
for (const events of chunks) {
const tags: string[][] = []
for (const event of events) {
tags.push(["e", event.id])
if (isReplaceable(event)) {
tags.push(["a", getAddress(event)])
}
}
await publishThunk({relays, event: createEvent(DELETE, {tags})})
await incrementProgress()
}
// Let them see that progress is complete
await sleep(2000)
// Goodbye forever!
await logout()
window.location.href = "/"
}
const confirm = async () => {
progress = 0
try {
await deleteProfile()
} catch (e) {
progress = undefined
throw e
}
}
const back = () => history.back()
</script>
<form class="column gap-4" onsubmit={preventDefault(confirm)}>
<ModalHeader>
{#snippet title()}
Delete your account
{/snippet}
{#snippet info()}
From the Nostr network
{/snippet}
</ModalHeader>
{#if showProgress}
<p>
We are currently sending deletion requests to your relay selections and space hosts. Please
wait while we complete this process. Once we're done, you'll be automatically logged out.
</p>
<progress class="progress progress-primary w-full" value={progress! * 100} max="100"></progress>
{:else}
<p>
This will delete your nostr account everywhere, not just on {PLATFORM_NAME}.
</p>
<p>
To confirm, please type "{CONFIRM_TEXT}" into the text box below. This action can't be undone.
</p>
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={confirmText} class="grow" type="text" />
</label>
<p>
<strong>Note:</strong> not all relays may honor your request for deletion. If you find that your
content continues to be available, please contact the offending relays directly.
</p>
{/if}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-error" disabled={showProgress || !confirmOk}>
<Spinner loading={progress !== undefined}>Confirm</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
+14 -58
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import {ctx} from "@welshman/lib"
import type {Profile} from "@welshman/util"
import {
createEvent,
makeProfile,
@@ -8,76 +9,31 @@
isPublishedProfile,
} from "@welshman/util"
import {pubkey, profilesByPubkey, publishThunk} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import InputProfilePicture from "@lib/components/InputProfilePicture.svelte"
import InfoHandle from "@app/components/InfoHandle.svelte"
import {pushModal, clearModals} from "@app/modal"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import {clearModals} from "@app/modal"
import {pushToast} from "@app/toast"
const values = $state({...($profilesByPubkey.get($pubkey!) || makeProfile())})
const initialValues = {...($profilesByPubkey.get($pubkey!) || makeProfile())}
const back = () => history.back()
const saveEdit = () => {
const onsubmit = (profile: Profile) => {
const relays = ctx.app.router.FromUser().getUrls()
const template = isPublishedProfile(values)
? editProfile($state.snapshot(values))
: createProfile($state.snapshot(values))
const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
const event = createEvent(template.kind, template)
publishThunk({event, relays})
pushToast({message: "Your profile has been updated!"})
clearModals()
}
let file: File | undefined = $state()
</script>
<form class="col-4" onsubmit={preventDefault(saveEdit)}>
<div class="flex justify-center py-2">
<InputProfilePicture bind:file bind:url={values.picture} />
</div>
<Field>
{#snippet label()}
<p>Username</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="user-circle" />
<input bind:value={values.name} class="grow" type="text" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>About You</p>
{/snippet}
{#snippet input()}
<textarea class="textarea textarea-bordered leading-4" rows="3" bind:value={values.about}>
</textarea>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Nostr Address</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="map-point" />
<input bind:value={values.nip05} class="grow" type="text" />
</label>
{/snippet}
{#snippet info()}
<p>
<Button class="link" onclick={() => pushModal(InfoHandle)}>What is a nostr address?</Button>
</p>
{/snippet}
</Field>
<div class="mt-4 flex flex-row items-center justify-between gap-4">
<Button class="btn btn-neutral" onclick={back}>Discard Changes</Button>
<Button type="submit" class="btn btn-primary">Save Changes</Button>
</div>
</form>
<ProfileEditForm {initialValues} {onsubmit}>
{#snippet footer()}
<div class="mt-4 flex flex-row items-center justify-between gap-4">
<Button class="btn btn-neutral" onclick={back}>Discard Changes</Button>
<Button type="submit" class="btn btn-primary">Save Changes</Button>
</div>
{/snippet}
</ProfileEditForm>
+79
View File
@@ -0,0 +1,79 @@
<script lang="ts">
import type {Snippet} from "svelte"
import type {Profile} from "@welshman/util"
import {makeProfile} from "@welshman/util"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import InputProfilePicture from "@lib/components/InputProfilePicture.svelte"
import InfoHandle from "@app/components/InfoHandle.svelte"
import {pushModal} from "@app/modal"
type Props = {
initialValues?: Profile
onsubmit: (profile: Profile) => void
hideAddress?: boolean
footer: Snippet
}
const {initialValues = makeProfile(), hideAddress, onsubmit, footer}: Props = $props()
const values = $state(initialValues)
const submit = () => onsubmit($state.snapshot(values))
let file: File | undefined = $state()
</script>
<form class="col-4" onsubmit={preventDefault(submit)}>
<div class="flex justify-center py-2">
<InputProfilePicture bind:file bind:url={values.picture} />
</div>
<Field>
{#snippet label()}
<p>Username</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="user-circle" />
<input bind:value={values.name} class="grow" type="text" />
</label>
{/snippet}
{#snippet info()}
What would you like people to call you?
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>About You</p>
{/snippet}
{#snippet input()}
<textarea class="textarea textarea-bordered leading-4" rows="3" bind:value={values.about}
></textarea>
{/snippet}
{#snippet info()}
Give a brief introduction to why you're here.
{/snippet}
</Field>
{#if !hideAddress}
<Field>
{#snippet label()}
<p>Nostr Address</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="map-point" />
<input bind:value={values.nip05} class="grow" type="text" />
</label>
{/snippet}
{#snippet info()}
<p>
<Button class="link" onclick={() => pushModal(InfoHandle)}
>What is a nostr address?</Button>
</p>
{/snippet}
</Field>
{/if}
{@render footer()}
</form>
+27 -11
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import {Capacitor} from "@capacitor/core"
import {postJson} from "@welshman/lib"
import {isMobile, preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
@@ -8,16 +9,22 @@
import Spinner from "@lib/components/Spinner.svelte"
import LogIn from "@app/components/LogIn.svelte"
import InfoNostr from "@app/components/InfoNostr.svelte"
import SignUpKey from "@app/components/SignUpKey.svelte"
import SignUpSuccess from "@app/components/SignUpSuccess.svelte"
import {pushModal} from "@app/modal"
import {BURROW_URL, PLATFORM_NAME} from "@app/state"
import {BURROW_URL, PLATFORM_NAME, PLATFORM_ACCENT} from "@app/state"
import {pushToast} from "@app/toast"
const ac = window.location.origin
const params = new URLSearchParams({
an: PLATFORM_NAME,
ac: window.location.origin,
at: isMobile ? "android" : "web",
aa: PLATFORM_ACCENT.slice(1),
am: "dark",
asf: "yes",
})
const at = isMobile ? "android" : "web"
const nstart = `https://start.njump.me/?an=Flotilla&at=${at}&ac=${ac}`
const nstart = `https://start.njump.me/?${params.toString()}`
const login = () => pushModal(LogIn)
@@ -37,18 +44,20 @@
}
}
const signup = () => {
const usePassword = () => {
if (BURROW_URL) {
signupPassword()
}
}
const useKey = () => pushModal(SignUpKey)
let email = $state("")
let password = $state("")
let loading = $state(false)
</script>
<form class="column gap-4" onsubmit={preventDefault(signup)}>
<form class="column gap-4" onsubmit={preventDefault(usePassword)}>
<h1 class="heading">Sign up with Nostr</h1>
<p class="m-auto max-w-sm text-center">
{PLATFORM_NAME} is built using the
@@ -89,10 +98,17 @@
</p>
<Divider>Or</Divider>
{/if}
<a href={nstart} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="square-share-line" />
Get going on nstart
</a>
{#if Capacitor.isNativePlatform()}
<Button onclick={useKey} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="key" />
Generate a key
</Button>
{:else}
<a href={nstart} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="square-share-line" />
Create an account on Nstart
</a>
{/if}
<div class="text-sm">
Already have an account?
<Button class="link" onclick={login}>Log in instead</Button>
+84
View File
@@ -0,0 +1,84 @@
<script lang="ts">
import {encrypt} from "nostr-tools/nip49"
import {hexToBytes} from "@noble/hashes/utils"
import {makeSecret, getPubkey} from "@welshman/signer"
import {preventDefault, downloadText} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import SignUpKeyConfirm from "@app/components/SignUpKeyConfirm.svelte"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
const secret = makeSecret()
const pubkey = getPubkey(secret)
const back = () => history.back()
const next = () => {
if (password.length < 12) {
return pushToast({
theme: "error",
message: "Passwords must be at least 12 characters long.",
})
}
const ncryptsec = encrypt(hexToBytes(secret), password)
downloadText("Nostr Secret Key.txt", ncryptsec)
pushModal(SignUpKeyConfirm, {secret, pubkey, ncryptsec})
}
let password = ""
</script>
<form class="column gap-4" onsubmit={preventDefault(next)}>
<ModalHeader>
{#snippet title()}
<div>Welcome to Nostr!</div>
{/snippet}
</ModalHeader>
<p>
<Link external href="https://nostr.com/" class="link">Nostr</Link> is way to build social apps that
talk to each other. Users own their social identity instead of renting it from a tech company, and
can take it with them.
</p>
<p>
This means that instead of using a password to log in, you generate a <strong
>secret key</strong>
which gives you full control over your account.
</p>
<p>
Keeping this key safe is very important, so we encourage you to download an encrypted copy. To
do this, go ahead and fill in the password you'd like to use to secure your key below.
</p>
<Field>
{#snippet label()}
Password*
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="key" />
<input bind:value={password} class="grow" type="password" />
</label>
{/snippet}
{#snippet info()}
<p>Passwords should be at least 12 characters long. Write this down!</p>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button class="btn btn-primary" type="submit">
Download my key
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
@@ -0,0 +1,66 @@
<script lang="ts">
import {preventDefault, copyToClipboard} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import SignUpProfile from "@app/components/SignUpProfile.svelte"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
type Props = {
secret: string
pubkey: string
ncryptsec: string
}
const {secret, pubkey, ncryptsec}: Props = $props()
const back = () => history.back()
const copy = () => {
copyToClipboard(ncryptsec)
pushToast({message: "Your secret key has been copied to your clipboard!"})
}
const next = () => {
pushModal(SignUpProfile, {secret, pubkey})
}
</script>
<form class="column gap-4" onsubmit={preventDefault(next)}>
<ModalHeader>
{#snippet title()}
<div>Download your key</div>
{/snippet}
</ModalHeader>
<p>
Great! We've encrypted your secret key and saved it to your device. If that didn't work, or if
you'd rather save your key somewhere else, you can find the encrypted version below:
</p>
<Field>
{#snippet label()}
Encrypted Secret Key
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="key" />
<input value={ncryptsec} class="ellipsize grow" />
<Button onclick={copy} class="flex items-center">
<Icon icon="copy" />
</Button>
</label>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button class="btn btn-primary" type="submit">
Fill out your profile
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
import type {Profile} from "@welshman/util"
import {PROFILE, createProfile, createEvent} from "@welshman/util"
import {addSession, publishThunk} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import {INDEXER_RELAYS} from "@app/state"
type Props = {
secret: string
pubkey: string
}
const {secret, pubkey}: Props = $props()
const onsubmit = (profile: Profile) => {
const event = createEvent(PROFILE, createProfile(profile))
addSession({method: "nip01", secret, pubkey})
publishThunk({event, relays: INDEXER_RELAYS})
}
</script>
<ProfileEditForm hideAddress {onsubmit}>
{#snippet footer()}
<Button type="submit" class="btn btn-primary">Create Account</Button>
{/snippet}
</ProfileEditForm>
+3 -13
View File
@@ -1,36 +1,26 @@
<script lang="ts">
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {ctx, sleep} from "@welshman/lib"
import {displayRelayUrl} from "@welshman/util"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import SpaceVisitConfirm, {confirmSpaceVisit} from "@app/components/SpaceVisitConfirm.svelte"
import {attemptRelayAccess} from "@app/commands"
import {makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal"
const {url} = $props()
const path = makeSpacePath(url)
const back = () => history.back()
const confirm = () => goto(path, {replaceState: true})
const next = () => {
if (!error && ctx.net.pool.get(url).stats.lastAuth === 0) {
pushModal(Confirm, {
confirm,
message: `This space does not appear to limit who can post to it. This can result
in a large amount of spam or other objectionable content. Continue?`,
})
pushModal(SpaceVisitConfirm, {url}, {replaceState: true})
} else {
confirm()
confirmSpaceVisit(url)
}
}
+4 -20
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {ctx, tryCatch} from "@welshman/lib"
import {isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {preventDefault} from "@lib/html"
@@ -7,27 +6,16 @@
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import InfoRelay from "@app/components/InfoRelay.svelte"
import SpaceJoinConfirm, {confirmSpaceJoin} from "@app/components/SpaceJoinConfirm.svelte"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
import {addSpaceMembership, attemptRelayAccess} from "@app/commands"
import {makeSpacePath} from "@app/routes"
import {attemptRelayAccess} from "@app/commands"
const back = () => history.back()
const confirm = async (url: string) => {
await addSpaceMembership(url)
goto(makeSpacePath(url), {replaceState: true})
pushToast({
message: "Welcome to the space!",
})
}
const joinRelay = async (invite: string) => {
const [raw, claim] = invite.split("|")
const url = normalizeRelayUrl(raw)
@@ -40,13 +28,9 @@
const connection = ctx.net.pool.get(url)
if (connection.stats.lastAuth === 0) {
pushModal(Confirm, {
confirm: () => confirm(url),
message: `This space does not appear to limit who can post to it. This can result
in a large amount of spam or other objectionable content. Continue?`,
})
pushModal(SpaceJoinConfirm, {url}, {replaceState: true})
} else {
await confirm(url)
await confirmSpaceJoin(url)
}
}
@@ -0,0 +1,32 @@
<script module lang="ts">
import {goto} from "$app/navigation"
import {makeSpacePath} from "@app/routes"
import {addSpaceMembership} from "@app/commands"
import {pushToast} from "@app/toast"
export const confirmSpaceJoin = async (url: string) => {
await addSpaceMembership(url)
goto(makeSpacePath(url), {replaceState: true})
pushToast({
message: "Welcome to the space!",
})
}
</script>
<script lang="ts">
import Confirm from "@lib/components/Confirm.svelte"
type Props = {
url: string
}
const {url}: Props = $props()
const confirm = () => confirmSpaceJoin(url)
</script>
<Confirm
{confirm}
message="This space does not appear to limit who can post to it. This can result in a large amount of spam or other objectionable content. Continue?" />
@@ -0,0 +1,24 @@
<script module lang="ts">
import {goto} from "$app/navigation"
import {makeSpacePath} from "@app/routes"
export const confirmSpaceVisit = (url: string) => {
goto(makeSpacePath(url), {replaceState: true})
}
</script>
<script lang="ts">
import Confirm from "@lib/components/Confirm.svelte"
type Props = {
url: string
}
const {url}: Props = $props()
const confirm = () => confirmSpaceVisit(url)
</script>
<Confirm
{confirm}
message="This space does not appear to limit who can post to it. This can result in a large amount of spam or other objectionable content. Continue?" />
-2
View File
@@ -1,5 +1,3 @@
import "@welshman/editor/index.css"
import {mount} from "svelte"
import type {Writable} from "svelte/store"
import {get} from "svelte/store"
+94 -6
View File
@@ -1,5 +1,19 @@
import {get, writable} from "svelte/store"
import {partition, shuffle, int, YEAR, MONTH, insert, sortBy, assoc, now} from "@welshman/lib"
import {
partition,
chunk,
sample,
sleep,
shuffle,
uniq,
int,
YEAR,
MONTH,
insert,
sortBy,
assoc,
now,
} from "@welshman/lib"
import {
MESSAGE,
DELETE,
@@ -9,10 +23,11 @@ import {
matchFilters,
getTagValues,
getTagValue,
isShareableRelayUrl,
} from "@welshman/util"
import type {TrustedEvent, Filter} from "@welshman/util"
import type {TrustedEvent, Filter, List} from "@welshman/util"
import {feedFromFilters, makeRelayFeed, makeIntersectionFeed} from "@welshman/feeds"
import type {Subscription} from "@welshman/net"
import type {Subscription, SubscribeRequestWithHandlers} from "@welshman/net"
import type {AppSyncOpts, Thunk} from "@welshman/app"
import {
subscribe,
@@ -22,10 +37,26 @@ import {
hasNegentropy,
thunkWorker,
createFeedController,
loadRelay,
loadMutes,
loadFollows,
loadProfile,
loadInboxRelaySelections,
getRelayUrls,
} from "@welshman/app"
import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util"
import {userRoomsByUrl, getUrlsForEvent} from "@app/state"
import {
ALERT,
ALERT_STATUS,
NOTIFIER_RELAY,
INDEXER_RELAYS,
getDefaultPubkeys,
userRoomsByUrl,
getUrlsForEvent,
loadMembership,
loadSettings,
} from "@app/state"
// Utils
@@ -280,6 +311,20 @@ export const makeCalendarFeed = ({
}
}
// Domain specific
export const loadAlerts = (pubkey: string) =>
load({
relays: [NOTIFIER_RELAY],
filters: [{kinds: [ALERT], authors: [pubkey]}],
})
export const loadAlertStatuses = (pubkey: string) =>
load({
relays: [NOTIFIER_RELAY],
filters: [{kinds: [ALERT_STATUS], "#p": [pubkey]}],
})
// Application requests
export const listenForNotifications = () => {
@@ -294,7 +339,9 @@ export const listenForNotifications = () => {
relays: [url],
filters: [
{kinds: [THREAD], limit: 1},
{kinds: [EVENT_TIME], limit: 1},
{kinds: [COMMENT], "#K": [String(THREAD)], limit: 1},
{kinds: [COMMENT], "#K": [String(EVENT_TIME)], limit: 1},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})),
],
})
@@ -303,8 +350,8 @@ export const listenForNotifications = () => {
subscribe({
relays: [url],
filters: [
{kinds: [THREAD], since: now()},
{kinds: [COMMENT], "#K": [String(THREAD)], since: now()},
{kinds: [THREAD, EVENT_TIME], since: now()},
{kinds: [COMMENT], "#K": [String(THREAD), String(EVENT_TIME)], since: now()},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})),
],
}),
@@ -317,3 +364,44 @@ export const listenForNotifications = () => {
}
}
}
export const loadUserData = (
pubkey: string,
request: Partial<SubscribeRequestWithHandlers> = {},
) => {
const promise = Promise.race([
sleep(3000),
Promise.all([
loadInboxRelaySelections(pubkey, request),
loadMembership(pubkey, request),
loadSettings(pubkey, request),
loadProfile(pubkey, request),
loadFollows(pubkey, request),
loadMutes(pubkey, request),
loadAlertStatuses(pubkey),
loadAlerts(pubkey),
]),
])
// 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 () => {
for (const pubkeys of chunk(50, getDefaultPubkeys())) {
const relays = sample(1, INDEXER_RELAYS)
await sleep(1000)
for (const pubkey of pubkeys) {
loadMembership(pubkey, {relays})
loadProfile(pubkey, {relays})
loadFollows(pubkey, {relays})
loadMutes(pubkey, {relays})
}
}
})
return promise
}
export const discoverRelays = (lists: List[]) =>
Promise.all(uniq(lists.flatMap(getRelayUrls)).filter(isShareableRelayUrl).map(loadRelay))
+55 -7
View File
@@ -41,7 +41,7 @@ import {
normalizeRelayUrl,
} from "@welshman/util"
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util"
import {Nip59} from "@welshman/signer"
import {Nip59, decrypt} from "@welshman/signer"
import {
pubkey,
repository,
@@ -62,6 +62,7 @@ import {
ensurePlaintext,
thunks,
walkThunks,
signer,
} from "@welshman/app"
import type {Thunk, Relay} from "@welshman/app"
import type {SubscribeRequestWithHandlers} from "@welshman/net"
@@ -73,6 +74,15 @@ export const GENERAL = "_"
export const PROTECTED = ["-"]
export const ALERT = 32830
export const ALERT_STATUS = 32831
export const NOTIFIER_PUBKEY = "27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df"
// export const NOTIFIER_RELAY = 'wss://notifier.flotilla.social/'
export const NOTIFIER_RELAY = "ws://localhost:4738/"
export const INDEXER_RELAYS = [
"wss://purplepag.es/",
"wss://relay.damus.io/",
@@ -295,6 +305,8 @@ export type Settings = {
values: {
show_media: boolean
hide_sensitive: boolean
report_usage: boolean
report_errors: boolean
send_delay: number
upload_type: "nip96" | "blossom"
nip96_urls: string[]
@@ -305,6 +317,8 @@ export type Settings = {
export const defaultSettings = {
show_media: true,
hide_sensitive: true,
report_usage: true,
report_errors: false,
send_delay: 3000,
upload_type: "nip96",
nip96_urls: ["https://nostr.build"],
@@ -332,6 +346,40 @@ export const {
load({...request, filters: [{kinds: [SETTINGS], authors: [pubkey]}]}),
})
// Alerts
export type Alert = {
event: TrustedEvent
tags: string[][]
}
export const alerts = deriveEventsMapped<Alert>(repository, {
filters: [{kinds: [ALERT]}],
itemToEvent: item => item.event,
eventToItem: async event => {
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))
return {event, tags}
},
})
// Alert Statuses
export type AlertStatus = {
event: TrustedEvent
tags: string[][]
}
export const alertStatuses = deriveEventsMapped<AlertStatus>(repository, {
filters: [{kinds: [ALERT_STATUS]}],
itemToEvent: item => item.event,
eventToItem: async event => {
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))
return {event, tags}
},
})
// Membership
export const hasMembershipUrl = (list: List | undefined, url: string) =>
@@ -345,18 +393,18 @@ export const hasMembershipUrl = (list: List | undefined, url: string) =>
export const getMembershipUrls = (list?: List) => {
const tags = getListTags(list)
return sort(uniq([...getRelayTagValues(tags), ...getGroupTags(tags).map(nth(2))]))
return sort(
uniq([...getRelayTagValues(tags), ...getGroupTags(tags).map(nth(2))]).map(url =>
normalizeRelayUrl(url),
),
)
}
export const getMembershipRooms = (list?: List) =>
getGroupTags(getListTags(list)).map(([_, room, url, name = ""]) => ({url, room, name}))
export const getMembershipRoomsByUrl = (url: string, list?: List) =>
sort(
getGroupTags(getListTags(list))
.filter(t => t[2] === url)
.map(nth(1)),
)
sort(getGroupTags(getListTags(list)).filter(nthEq(2, url)).map(nth(1)))
export const memberships = deriveEventsMapped<PublishedList>(repository, {
filters: [{kinds: [GROUPS]}],
+8 -1
View File
@@ -1,10 +1,17 @@
import * as Sentry from "@sentry/browser"
import {getSetting} from "@app/state"
export const setupTracking = () => {
if (import.meta.env.VITE_GLITCHTIP_API_KEY) {
Sentry.init({
dsn: import.meta.env.VITE_GLITCHTIP_API_KEY,
tracesSampleRate: 0.01,
beforeSend(event: any) {
if (!getSetting("report_errors")) {
return null
}
return event
},
integrations(integrations) {
return integrations.filter(integration => integration.name !== "Breadcrumbs")
},
+1 -1
View File
@@ -39,7 +39,7 @@
<div>{subtitle}</div>
{/snippet}
</ModalHeader>
<p>{message}</p>
<p class="text-center">{message}</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
+5 -7
View File
@@ -12,11 +12,7 @@
const pad = (n: number) => ("00" + String(n)).slice(-2)
const getTime = (d: Date, inheritMinutes: boolean) => {
const minutes = inheritMinutes ? pad(d.getMinutes()) : "00"
return `${pad(d.getHours())}:${minutes}`
}
const getTime = (d: Date) => `${pad(d.getHours())}:${minutes}`
const setTime = (d: Date, time: string) => {
const [hours, minutes] = time.split(":").map(x => parseInt(x))
@@ -29,6 +25,7 @@
const onTimeChange = () => {
if (time) {
minutes = time.slice(-2)
date = setTime(date || new Date(), time)
}
}
@@ -42,12 +39,13 @@
let date: Date | undefined = $state()
let time: string | undefined = $state()
let minutes: string = $state("00")
let element: HTMLElement
// Sync date to time and value
$effect(() => {
if (date) {
time = getTime(date, false)
time = getTime(date)
value = dateToSeconds(date)
} else {
value = undefined
@@ -57,7 +55,7 @@
// Sync updates to value to date/time
$effect(() => {
const derivedDate = value ? secondsToDate(value) : undefined
const derivedTime = derivedDate ? getTime(derivedDate, true) : undefined
const derivedTime = derivedDate ? getTime(derivedDate) : undefined
date = derivedDate
time = derivedTime
-29
View File
@@ -1,29 +0,0 @@
<script lang="ts">
const {children, onLongPress, ...restProps} = $props()
const ontouchstart = (event: any) => {
touch = event.touches[0]
timeout = setTimeout(onLongPress, 500)
}
const ontouchmove = (event: any) => {
const curTouch = event.touches[0]
if (Math.abs(curTouch.clientX - touch.clientX) > 30) {
clearTimeout(timeout)
}
if (Math.abs(curTouch.clientY - touch.clientY) > 30) {
clearTimeout(timeout)
}
}
const ontouchend = () => clearTimeout(timeout)
let touch: Touch
let timeout: any
</script>
<div role="button" tabindex="0" {ontouchstart} {ontouchmove} {ontouchend} {...restProps}>
{@render children()}
</div>
+2 -2
View File
@@ -9,10 +9,10 @@
const {...props}: Props = $props()
</script>
<div class="relative z-feature mx-2 rounded-xl pt-4 {props.class}">
<div class="relative z-feature rounded-xl px-2 pt-2 {props.class}">
<div
class="flex min-h-12 items-center justify-between gap-4 rounded-xl bg-base-100 px-4 shadow-xl">
<div class="flex items-center gap-4">
<div class="ellipsize flex items-center gap-4 whitespace-nowrap">
{@render props.icon?.()}
{@render props.title?.()}
</div>
+1 -1
View File
@@ -58,7 +58,7 @@
</script>
<div transition:fly|local={{duration: 200}} class="tiptap-suggestions">
<div class="tiptap-suggestions__content">
<div class="tiptap-suggestions__content max-h-[40vh]">
{#if $term && allowCreate && !items.includes($term)}
<button
class="tiptap-suggestions__create"
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import {isMobile} from "@lib/html"
const {children, onTap, ...restProps} = $props()
const onclick = (event: MouseEvent) => {
if (isMobile) {
onTap(event)
}
}
</script>
<div role="button" tabindex="0" {onclick} {...restProps}>
{@render children()}
</div>
+13
View File
@@ -76,3 +76,16 @@ export const createScroller = ({
}
export const isMobile = "ontouchstart" in document.documentElement
export const downloadText = (filename: string, text: string) => {
const blob = new Blob([text], {type: "text/plain"})
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
+2 -2
View File
@@ -67,8 +67,8 @@
import {nsecDecode} from "@lib/util"
import {theme} from "@app/theme"
import {INDEXER_RELAYS, userMembership, ensureUnwrapped, canDecrypt} from "@app/state"
import {loadUserData, loginWithNip46} from "@app/commands"
import {listenForNotifications} from "@app/requests"
import {loadUserData, listenForNotifications} from "@app/requests"
import {loginWithNip46} from "@app/commands"
import * as commands from "@app/commands"
import * as requests from "@app/requests"
import * as notifications from "@app/notifications"
+32 -17
View File
@@ -2,8 +2,9 @@
import {onMount} from "svelte"
import {addToMapKey, dec, gt} from "@welshman/lib"
import type {Relay} from "@welshman/app"
import {relays, createSearch} from "@welshman/app"
import {relays, createSearch, loadRelay, loadRelaySelections} from "@welshman/app"
import {createScroller} from "@lib/html"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Page from "@lib/components/Page.svelte"
import Spinner from "@lib/components/Spinner.svelte"
@@ -14,15 +15,26 @@
import SpaceCheck from "@app/components/SpaceCheck.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {
memberships,
membershipByPubkey,
getMembershipUrls,
loadMembership,
userRoomsByUrl,
getDefaultPubkeys,
} from "@app/state"
import {discoverRelays} from "@app/commands"
import {pushModal} from "@app/modal"
const discoverRelays = () =>
Promise.all(
getDefaultPubkeys().map(async pubkey => {
await loadRelaySelections(pubkey)
const membership = await loadMembership(pubkey)
const urls = getMembershipUrls(membership)
await Promise.all(urls.map(url => loadRelay(url)))
}),
)
const wotGraph = $derived.by(() => {
const scores = new Map<string, Set<string>>()
@@ -36,20 +48,23 @@
})
const relaySearch = $derived(
createSearch($relays, {
getValue: (relay: Relay) => relay.url,
sortFn: ({score, item}) => {
if (score && score > 0.1) return -score!
createSearch(
$relays.filter(r => wotGraph.has(r.url)),
{
getValue: (relay: Relay) => relay.url,
sortFn: ({score, item}) => {
if (score && score > 0.1) return -score!
const wotScore = wotGraph.get(item.url)?.size || 0
const wotScore = wotGraph.get(item.url)?.size || 0
return score ? dec(score) * wotScore : -wotScore
return score ? dec(score) * wotScore : -wotScore
},
fuseOptions: {
keys: ["url", "name", {name: "description", weight: 0.3}],
shouldSort: false,
},
},
fuseOptions: {
keys: ["url", "name", {name: "description", weight: 0.3}],
shouldSort: false,
},
}),
),
)
const openSpace = (url: string) => pushModal(SpaceCheck, {url})
@@ -128,9 +143,9 @@
{/if}
</Button>
{/each}
{#await discoverRelays($memberships)}
<div class="flex justify-center">
<Spinner loading>Loading more relays...</Spinner>
{#await discoverRelays()}
<div class="flex justify-center py-20" out:fly>
<Spinner loading>Looking for spaces...</Spinner>
</div>
{/await}
</div>
+1 -1
View File
@@ -23,7 +23,7 @@
</script>
{#if !PLATFORM_RELAY}
<div class="hero min-h-screen">
<div class="hero min-h-screen overflow-auto pb-8">
<div class="hero-content">
<div class="column content gap-4">
<h1 class="text-center text-5xl">Welcome to</h1>
+33 -3
View File
@@ -39,7 +39,7 @@
<form class="content column gap-4" {onsubmit}>
<div class="card2 bg-alt col-4 shadow-xl">
<p class="text-lg">Content Settings</p>
<strong class="text-lg">Content Settings</strong>
<FieldInline>
{#snippet label()}
<p>Hide sensitive content?</p>
@@ -77,7 +77,37 @@
</div>
{/snippet}
</Field>
<p class="text-lg">Editor Settings</p>
<strong class="text-lg">Privacy Settings</strong>
<FieldInline>
{#snippet label()}
<p>Report errors?</p>
{/snippet}
{#snippet input()}
<input
type="checkbox"
class="toggle toggle-primary"
bind:checked={settings.report_errors} />
{/snippet}
{#snippet info()}
<p>
Allow {PLATFORM_NAME} to send error reports to help improve the app.
</p>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Report usage?</p>
{/snippet}
{#snippet input()}
<input type="checkbox" class="toggle toggle-primary" bind:checked={settings.report_usage} />
{/snippet}
{#snippet info()}
<p>
Allow {PLATFORM_NAME} to collect anonymous usage data.
</p>
{/snippet}
</FieldInline>
<strong class="text-lg">Editor Settings</strong>
<FieldInline>
{#snippet label()}
<p>Send Delay</p>
@@ -119,7 +149,7 @@
</div>
{/snippet}
{#snippet info()}
<p>Choose a media server type and url for files you upload to flotilla.</p>
<p>Choose a media server type and url for files you upload to {PLATFORM_NAME}.</p>
{/snippet}
</Field>
<div class="mt-4 flex flex-row items-center justify-between gap-4">
+37 -2
View File
@@ -3,12 +3,14 @@
import {hexToBytes} from "@noble/hashes/utils"
import {displayPubkey, displayProfile} from "@welshman/util"
import {pubkey, session, displayNip05, deriveProfile} from "@welshman/app"
import {slideAndFade} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import Content from "@app/components/Content.svelte"
import ProfileEdit from "@app/components/ProfileEdit.svelte"
import ProfileDelete from "@app/components/ProfileDelete.svelte"
import InfoKeys from "@app/components/InfoKeys.svelte"
import {PLATFORM_NAME} from "@app/state"
import {pushModal} from "@app/modal"
@@ -25,6 +27,10 @@
const startEdit = () => pushModal(ProfileEdit)
const startEject = () => pushModal(InfoKeys)
const startDelete = () => pushModal(ProfileDelete)
let showAdvanced = false
</script>
<div class="content column gap-4">
@@ -77,7 +83,10 @@
<div class="card2 bg-alt col-4 shadow-xl">
<FieldInline>
{#snippet label()}
<p>Public Key</p>
<p class="flex items-center gap-3">
<Icon icon="key" />
Public Key
</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center justify-between gap-2">
@@ -100,7 +109,10 @@
{#if $session?.method === "nip01"}
<FieldInline>
{#snippet label()}
<p>Private Key</p>
<p class="flex items-center gap-3">
<Icon icon="key" />
Private Key
</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
@@ -117,4 +129,27 @@
</FieldInline>
{/if}
</div>
<div class="card2 bg-alt shadow-xl">
<div class="flex items-center justify-between">
<strong class="flex items-center gap-3">
<Icon icon="settings" />
Advanced
</strong>
<Button onclick={() => (showAdvanced = !showAdvanced)}>
{#if showAdvanced}
<Icon icon="alt-arrow-down" />
{:else}
<Icon icon="alt-arrow-up" />
{/if}
</Button>
</div>
{#if showAdvanced}
<div transition:slideAndFade class="flex flex-col gap-2 pt-4">
<Button class="btn btn-outline btn-error" onclick={startDelete}>
<Icon icon="trash-bin-2" />
Delete your profile
</Button>
</div>
{/if}
</div>
</div>
+2 -1
View File
@@ -16,7 +16,8 @@
import RelayItem from "@app/components/RelayItem.svelte"
import RelayAdd from "@app/components/RelayAdd.svelte"
import {pushModal} from "@app/modal"
import {setRelayPolicy, discoverRelays, setInboxRelayPolicy} from "@app/commands"
import {discoverRelays} from "@app/requests"
import {setRelayPolicy, setInboxRelayPolicy} from "@app/commands"
const readRelayUrls = derived(userRelaySelections, getReadRelayUrls)
const writeRelayUrls = derived(userRelaySelections, getWriteRelayUrls)
+4
View File
@@ -125,6 +125,7 @@
</div>
{/if}
</div>
<Divider>Your Rooms</Divider>
<div class="grid grid-cols-3 gap-2">
<Link href={threadsPath} class="btn btn-primary">
<div class="relative flex items-center gap-2">
@@ -167,6 +168,9 @@
{/if}
</Link>
{/each}
</div>
<Divider>Other Rooms</Divider>
<div class="grid grid-cols-3 gap-2">
{#each $otherRooms as room (room)}
<Link href={makeRoomPath(url, room)} class="btn btn-neutral">
<div class="relative flex min-w-0 items-center gap-2 overflow-hidden text-nowrap">
+95 -79
View File
@@ -46,8 +46,6 @@
const filter = {kinds: [MESSAGE], "#h": [room]}
const relay = deriveRelay(url)
const assertEvent = (e: any) => e as TrustedEvent
const joinRoom = async () => {
if (hasNip29($relay)) {
joiningRoom = true
@@ -136,6 +134,8 @@
let parent: TrustedEvent | undefined = $state()
let element: HTMLElement | undefined = $state()
let newMessages: HTMLElement | undefined = $state()
let parentPreview: HTMLElement | undefined = $state()
let dynamicPadding: HTMLElement | undefined = $state()
let newMessagesSeen = false
let showFixedNewMessages = $state(false)
let showScrollButton = $state(false)
@@ -210,6 +210,16 @@
loadingEvents = false
},
}))
const observer = new ResizeObserver(() => {
dynamicPadding!.style.minHeight = `${parentPreview!.offsetHeight}px`
})
observer.observe(parentPreview!)
return () => {
observer.unobserve(parentPreview!)
}
})
onDestroy(() => {
@@ -218,99 +228,105 @@
// Sveltekit calls onDestroy at the beginning of the page load for some reason
setTimeout(() => {
setChecked($page.url.pathname)
}, 300)
}, 800)
})
</script>
<div class="relative flex h-full flex-col">
<PageBar>
{#snippet icon()}
<div class="center">
<Icon icon="hashtag" />
</div>
{/snippet}
{#snippet title()}
<strong>
<ChannelName {url} {room} />
</strong>
{/snippet}
{#snippet action()}
<div class="row-2">
{#if room !== GENERAL}
{#if $userRoomsByUrl.get(url)?.has(room)}
<Button class="btn btn-neutral btn-sm" onclick={leaveRoom}>
<Icon icon="arrows-a-logout-2" />
Leave Room
</Button>
{:else}
<Button class="btn btn-neutral btn-sm" disabled={joiningRoom} onclick={joinRoom}>
{#if joiningRoom}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon="login-2" />
{/if}
Join Room
</Button>
{/if}
{/if}
<MenuSpaceButton {url} />
</div>
{/snippet}
</PageBar>
<div
class="scroll-container -mt-2 flex flex-grow flex-col-reverse overflow-y-auto overflow-x-hidden py-2"
onscroll={onScroll}
bind:this={element}>
{#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"></div>
<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>
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
<div in:slide class:-mt-1={!showPubkey}>
<ChannelMessage {url} {room} {replyTo} event={assertEvent(value)} {showPubkey} />
</div>
{/if}
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loadingEvents}
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
{:else}
<Spinner>End of message history</Spinner>
{/if}
</p>
</div>
{#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" onclick={scrollToNewMessages}>
New Messages
</Button>
</div>
<PageBar class="chat__page-bar">
{#snippet icon()}
<div class="center">
<Icon icon="hashtag" />
</div>
{/if}
<div>
{/snippet}
{#snippet title()}
<strong class="ellipsize">
<ChannelName {url} {room} />
</strong>
{/snippet}
{#snippet action()}
<div class="row-2">
{#if room !== GENERAL}
{#if $userRoomsByUrl.get(url)?.has(room)}
<Button class="btn btn-neutral btn-sm" onclick={leaveRoom}>
<Icon icon="arrows-a-logout-2" />
Leave Room
</Button>
{:else}
<Button class="btn btn-neutral btn-sm" disabled={joiningRoom} onclick={joinRoom}>
{#if joiningRoom}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon="login-2" />
{/if}
Join Room
</Button>
{/if}
{/if}
<MenuSpaceButton {url} />
</div>
{/snippet}
</PageBar>
<div class="chat__messages scroll-container" onscroll={onScroll} bind:this={element}>
<div bind:this={dynamicPadding}></div>
{#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"></div>
<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>
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
<div in:slide class:-mt-1={!showPubkey}>
<ChannelMessage
{url}
{room}
{replyTo}
event={$state.snapshot(value as TrustedEvent)}
{showPubkey} />
</div>
{/if}
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loadingEvents}
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
{:else}
<Spinner>End of message history</Spinner>
{/if}
</p>
</div>
<div class="chat__compose bg-base-200">
<div bind:this={parentPreview}>
{#if parent}
<ChannelComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if share}
<ChannelComposeParent event={share} clear={clearShare} verb="Sharing" />
{/if}
<ChannelCompose bind:this={compose} {onSubmit} />
</div>
<ChannelCompose bind:this={compose} {onSubmit} />
</div>
{#if showScrollButton}
<div in:fade class="fixed bottom-14 right-4">
<div in:fade class="chat__scroll-down">
<Button class="btn btn-circle btn-neutral" onclick={scrollToBottom}>
<Icon icon="alt-arrow-down" />
</Button>
</div>
{/if}
{#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" onclick={scrollToNewMessages}>
New Messages
</Button>
</div>
</div>
{/if}
@@ -42,8 +42,6 @@
return sortBy(e => -max([scores.get(e.id), e.created_at]), threads)
})
$inspect({threads, comments, events})
onMount(() => {
const {cleanup} = makeFeed({
element: element!,
@@ -98,7 +96,7 @@
<div class="flex flex-grow flex-col gap-2 overflow-auto p-2">
{#each events as event (event.id)}
<div in:fly>
<ThreadItem {url} {event} />
<ThreadItem {url} event={$state.snapshot(event)} />
</div>
{/each}
<p class="flex h-10 items-center justify-center py-20">