forked from coracle/flotilla
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d5f91fb6c | |||
| ef18655776 | |||
| b786e858d9 | |||
| f4ebc4e99e | |||
| 65ca8a7fd8 | |||
| 7f1e98dcb2 | |||
| 4c19ee823b | |||
| 8e2dd8b278 | |||
| 8d35b3aad2 | |||
| 613cad31c0 | |||
| 3779a90f26 | |||
| 7470f28f31 | |||
| 17fb4e780b | |||
| 30c2a6ef79 | |||
| 0547e9513f | |||
| 70e5172f1b | |||
| 61c568a112 | |||
| ae2ba6f44d | |||
| f84006fbe4 | |||
| fed34a2747 | |||
| 80df16f97b | |||
| 18cb245599 | |||
| fd6cc84be6 | |||
| 9311cab3b2 | |||
| fceccf47be | |||
| fe20fbfd28 | |||
| 4f3a2a1660 | |||
| 1c8457a4bf | |||
| 8710043a02 | |||
| dc46b42cb6 | |||
| 2f1972e70a | |||
| c5fcf12165 | |||
| 61ed632579 | |||
| 86f4b75c52 | |||
| b26ab916d5 | |||
| c882198206 | |||
| 4aef27ffd5 | |||
| cf4e3f5fc6 | |||
| 57eb919c83 | |||
| 85cfaf2bc9 | |||
| 25a69a8191 | |||
| 6feeb23b1f | |||
| 4b92ffe3c5 | |||
| 823a9c3271 | |||
| fe89df2aa3 | |||
| 97ff8ff802 | |||
| a10a9e7043 |
+1
-1
@@ -9,4 +9,4 @@ build
|
|||||||
|
|
||||||
# Env files (keep .env for build; exclude local overrides)
|
# Env files (keep .env for build; exclude local overrides)
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ VITE_PUSH_BRIDGE=wss://npb.coracle.social/
|
|||||||
VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi
|
VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi
|
||||||
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
|
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
|
||||||
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
|
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
|
||||||
|
VITE_DEFAULT_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io
|
||||||
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
|
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
|
||||||
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
|
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
|
||||||
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
|
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
|
||||||
|
VITE_THUMBNAIL_URL=
|
||||||
VITE_GLITCHTIP_API_KEY=
|
VITE_GLITCHTIP_API_KEY=
|
||||||
GLITCHTIP_AUTH_TOKEN=
|
GLITCHTIP_AUTH_TOKEN=
|
||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
# Env
|
# Env
|
||||||
.env
|
|
||||||
.env.local
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
@@ -28,6 +28,7 @@ node_modules/
|
|||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
build/
|
build/
|
||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
|
.next/
|
||||||
|
|
||||||
# Rust/Tauri
|
# Rust/Tauri
|
||||||
*target/
|
*target/
|
||||||
@@ -73,3 +74,4 @@ GoogleService-Info.plist
|
|||||||
# OS generated
|
# OS generated
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
package-lock.json
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
# 1.7.2
|
||||||
|
|
||||||
|
* Fix race condition in nip 46
|
||||||
|
* Remove duplicate spaces button
|
||||||
|
* Combine discover and space list pages
|
||||||
|
* Fix some chat related bugs
|
||||||
|
* Fix bug with joining spaces
|
||||||
|
|
||||||
|
# 1.7.1
|
||||||
|
|
||||||
|
* Fix pomade registration fallback in case of offline signer
|
||||||
|
|
||||||
# 1.7.0
|
# 1.7.0
|
||||||
|
|
||||||
* Enable email/password login
|
* Enable email/password login
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Flotilla is a Nostr "relays as groups" community chat client. It implements NIP-29 (relay-based groups) to create Discord-like spaces (servers) and rooms (channels).
|
||||||
|
|
||||||
|
Please visit our [issue tracker](https://gitea.coracle.social/coracle/flotilla/issues) to contribute. Any new issues should be opened without a milestone, label, or project and the project owners will triage them.
|
||||||
|
|
||||||
|
### Milestones
|
||||||
|
|
||||||
|
Milestones indicate how soon a given task should be tackled.
|
||||||
|
|
||||||
|
- [Current](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=1) issues are immediately actionable.
|
||||||
|
- [Next](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=2) means an issue is blocked.
|
||||||
|
- [Future](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=3) means we're deferring work until a later date.
|
||||||
|
|
||||||
|
### Labels
|
||||||
|
|
||||||
|
- [Design](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=15) issues need design work before being implemented. This might take the form of a high-quality mockup, wireframes, user flows, or just a couple notes about where things go, depending on the nature of the task.
|
||||||
|
- [Dev](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=16) issues are ready to be implemented. Most of the work will be related to architecting and writing code.
|
||||||
|
- [Easy](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=14) issues have no dependencies, and are scoped quite narrowly.
|
||||||
|
- [Priority](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=6) issues include bugs and urgent feature requests. These should get attention first if possible, although sometimes long-standing performance issues or subtle bugs might end up here for a while.
|
||||||
|
- [Ideas](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=13) are for things that aren't scoped out yet, or which need protocol work before getting designed or implemented.
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
|
||||||
|
Issues may or may not have a project. Projects are used to group issues thematically just for organization.
|
||||||
|
|
||||||
|
## Coding conventions
|
||||||
|
|
||||||
|
There are a few conventions that are helpful to know right out of the gate.
|
||||||
|
|
||||||
|
- Most nostr protocol functionality is implemented via the [welshman library](https://welshman.coracle.social/)
|
||||||
|
- Use Svelte 4 **stores** rather than runes for all state outside UI components
|
||||||
|
- Most global state flows through Welshman's `repository` (unidirectional)
|
||||||
|
- Query state using `deriveEventsMapped` or `deriveProfile` etc
|
||||||
|
- Events are published via `publishThunk`, which allows for optimistic UI updates during signing/pow generation.
|
||||||
|
- Components should have minimal props - e.g. instead of passing a whole `relay` through, pass its `url`.
|
||||||
|
- Use `AbortController` when possible instead of request ids
|
||||||
|
- Use `undefined` or optional properties instead of `null`
|
||||||
|
- Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect.
|
||||||
|
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
|
||||||
|
- When dynamically building classes, use `cx` from `classnames`.
|
||||||
|
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates.
|
||||||
|
|
||||||
|
## Contributing Workflow
|
||||||
|
|
||||||
|
To contribute, do the following:
|
||||||
|
|
||||||
|
- Find or create an issue and assign yourself (comment instead if you're not able to self-assign)
|
||||||
|
- If the issue is a design task, attach or link out to any mockups/wireframes/flowcharts
|
||||||
|
- Once a design task is completed, a maintainer will remove the `design` label and add the `dev` label
|
||||||
|
- If the issue is a development task, fork the repository and create a branch prefixed by the issue number, e.g. `105-deep-links`
|
||||||
|
- Before requesting a review, be sure to review any agent-generated code, run the pre-commit hooks, and test the changes.
|
||||||
|
- Open a PR and request a review. A maintainer will get back to you with requested changes, or will merge the PR.
|
||||||
|
- Keep your PR up-to-date by rebasing frequently on `dev`. Avoid force-pushing to `dev`.
|
||||||
|
- PRs are rebased, squashed, and merged to keep commit history simple.
|
||||||
|
- An issue may have multiple PRs. Once complete, it can be closed.
|
||||||
@@ -16,11 +16,13 @@ You can also optionally create an `.env.local` file and populate it with the fol
|
|||||||
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
||||||
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
|
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
|
||||||
|
|
||||||
|
These values **won't** be used for a built version. Instead, env variables should be provided to `build.sh` directly or to the built container.
|
||||||
|
|
||||||
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
|
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
See [CONTRIBUTING.md](AGENTS.md).
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ android {
|
|||||||
applicationId "social.flotilla"
|
applicationId "social.flotilla"
|
||||||
minSdk rootProject.ext.minSdkVersion
|
minSdk rootProject.ext.minSdkVersion
|
||||||
targetSdk rootProject.ext.targetSdkVersion
|
targetSdk rootProject.ext.targetSdkVersion
|
||||||
versionCode 42
|
versionCode 44
|
||||||
versionName "1.7.0"
|
versionName "1.7.2"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -44,4 +44,7 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
temp_env=$(declare -p -x)
|
temp_env=$(declare -p -x)
|
||||||
|
|
||||||
if [ -f .env.template ]; then
|
if [ -f .env ]; then
|
||||||
source .env.template
|
source .env
|
||||||
fi
|
|
||||||
if [ -f .env.local ]; then
|
|
||||||
source .env.local
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Avoid overwriting env vars provided directly
|
# Avoid overwriting env vars provided directly
|
||||||
|
|||||||
@@ -358,14 +358,14 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 33;
|
CURRENT_PROJECT_VERSION = 35;
|
||||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.7.0;
|
MARKETING_VERSION = 1.7.2;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -385,14 +385,14 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 33;
|
CURRENT_PROJECT_VERSION = 35;
|
||||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.7.0;
|
MARKETING_VERSION = 1.7.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|||||||
@@ -24,8 +24,10 @@
|
|||||||
<false/>
|
<false/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Flotilla uses the camera when you enable it in a voice room.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Flotilla uses the microphone for voice chat in rooms.</string>
|
<string>Flotilla uses the microphone when you enable it in a voice room.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
|
|||||||
+16
-15
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flotilla",
|
"name": "flotilla",
|
||||||
"version": "1.7.0",
|
"version": "1.7.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@sveltejs/kit": "^2.50.1",
|
"@sveltejs/kit": "^2.50.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||||
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
"@tauri-apps/cli": "^2.9.6",
|
"@tauri-apps/cli": "^2.9.6",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
"prettier-plugin-svelte": "^3.4.1",
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"svelte": "^5.48.0",
|
"svelte": "^5.48.0",
|
||||||
"svelte-check": "^4.3.5",
|
"svelte-check": "^4.3.5",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.53.1",
|
"typescript-eslint": "^8.53.1",
|
||||||
"vite": "^5.4.21"
|
"vite": "^5.4.21"
|
||||||
@@ -58,7 +59,7 @@
|
|||||||
"@getalby/lightning-tools": "^6.1.0",
|
"@getalby/lightning-tools": "^6.1.0",
|
||||||
"@getalby/sdk": "^5.1.2",
|
"@getalby/sdk": "^5.1.2",
|
||||||
"@noble/curves": "^1.9.7",
|
"@noble/curves": "^1.9.7",
|
||||||
"@pomade/core": "^0.2.1",
|
"@pomade/core": "^0.2.2",
|
||||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
"@poppanator/sveltekit-svg": "^4.2.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@tiptap/core": "^2.27.2",
|
"@tiptap/core": "^2.27.2",
|
||||||
@@ -66,18 +67,18 @@
|
|||||||
"@types/throttle-debounce": "^5.0.2",
|
"@types/throttle-debounce": "^5.0.2",
|
||||||
"@vite-pwa/assets-generator": "^0.2.6",
|
"@vite-pwa/assets-generator": "^0.2.6",
|
||||||
"@vite-pwa/sveltekit": "^0.6.8",
|
"@vite-pwa/sveltekit": "^0.6.8",
|
||||||
"@welshman/app": "^0.8.10",
|
"@welshman/app": "^0.8.12",
|
||||||
"@welshman/content": "^0.8.10",
|
"@welshman/content": "^0.8.12",
|
||||||
"@welshman/editor": "^0.8.10",
|
"@welshman/editor": "^0.8.12",
|
||||||
"@welshman/feeds": "^0.8.10",
|
"@welshman/feeds": "^0.8.12",
|
||||||
"@welshman/lib": "^0.8.10",
|
"@welshman/lib": "^0.8.12",
|
||||||
"@welshman/net": "^0.8.10",
|
"@welshman/net": "^0.8.12",
|
||||||
"@welshman/router": "^0.8.10",
|
"@welshman/router": "^0.8.12",
|
||||||
"@welshman/signer": "^0.8.10",
|
"@welshman/signer": "^0.8.12",
|
||||||
"@welshman/store": "^0.8.10",
|
"@welshman/store": "^0.8.12",
|
||||||
"@welshman/util": "^0.8.10",
|
"@welshman/util": "^0.8.12",
|
||||||
"compressorjs-next": "^1.1.2",
|
"compressorjs-next": "^1.1.2",
|
||||||
"daisyui": "^4.12.24",
|
"daisyui": "^5.5.19",
|
||||||
"date-picker-svelte": "^2.17.0",
|
"date-picker-svelte": "^2.17.0",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"emoji-picker-element": "^1.28.1",
|
"emoji-picker-element": "^1.28.1",
|
||||||
@@ -87,7 +88,7 @@
|
|||||||
"livekit-client": "^2.17.2",
|
"livekit-client": "^2.17.2",
|
||||||
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
|
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
|
||||||
"nostr-tools": "^2.19.4",
|
"nostr-tools": "^2.19.4",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"throttle-debounce": "^5.0.2",
|
"throttle-debounce": "^5.0.2",
|
||||||
|
|||||||
Generated
+536
-483
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -1,6 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
"@tailwindcss/postcss": {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import dotenv from "dotenv"
|
|||||||
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
|
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
|
||||||
|
|
||||||
dotenv.config({path: ".env.local"})
|
dotenv.config({path: ".env.local"})
|
||||||
dotenv.config({path: ".env.template"})
|
dotenv.config({path: ".env"})
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
preset,
|
preset,
|
||||||
|
|||||||
+258
-262
@@ -1,45 +1,25 @@
|
|||||||
@import "@welshman/editor/index.css";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@tailwind base;
|
@config "../tailwind.config.js";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
/* Fonts */
|
@utility pt-sai {
|
||||||
|
padding-top: var(--sait);
|
||||||
@font-face {
|
|
||||||
font-family: "Satoshis";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src:
|
|
||||||
local(""),
|
|
||||||
url("/fonts/Satoshi Symbol.ttf") format("truetype");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@utility pr-sai {
|
||||||
font-family: "Lato";
|
padding-right: var(--sair);
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src:
|
|
||||||
local(""),
|
|
||||||
url("/fonts/Lato-Regular.ttf") format("truetype");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@utility pb-sai {
|
||||||
font-family: "Lato";
|
padding-bottom: var(--saib);
|
||||||
font-style: bold;
|
|
||||||
font-weight: 600;
|
|
||||||
src:
|
|
||||||
local(""),
|
|
||||||
url("/fonts/Lato-Bold.ttf") format("truetype");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@utility pl-sai {
|
||||||
font-family: "Lato";
|
padding-left: var(--sail);
|
||||||
font-style: italic;
|
}
|
||||||
font-weight: 400;
|
|
||||||
src:
|
@utility px-sai {
|
||||||
local(""),
|
@apply pl-sai pr-sai;
|
||||||
url("/fonts/Italic.ttf") format("truetype");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* root */
|
/* root */
|
||||||
@@ -52,98 +32,224 @@
|
|||||||
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme] {
|
@utility py-sai {
|
||||||
@apply bg-base-300;
|
@apply pt-sai pb-sai;
|
||||||
--base-100: oklch(var(--b1));
|
|
||||||
--base-200: oklch(var(--b2));
|
|
||||||
--base-300: oklch(var(--b3));
|
|
||||||
--base-content: oklch(var(--bc));
|
|
||||||
--primary: oklch(var(--p));
|
|
||||||
--primary-content: oklch(var(--pc));
|
|
||||||
--secondary: oklch(var(--s));
|
|
||||||
--secondary-content: oklch(var(--sc));
|
|
||||||
--neutral: oklch(var(--n));
|
|
||||||
--neutral-content: oklch(var(--nc));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile [data-tip]::before {
|
@utility p-sai {
|
||||||
display: none !important;
|
@apply py-sai px-sai;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* safe area insets */
|
@utility mt-sai {
|
||||||
|
margin-top: var(--sait);
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@utility mr-sai {
|
||||||
.pt-sai {
|
margin-right: var(--sair);
|
||||||
padding-top: var(--sait);
|
}
|
||||||
|
|
||||||
|
@utility mb-sai {
|
||||||
|
margin-bottom: var(--saib);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility ml-sai {
|
||||||
|
margin-left: var(--sail);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility mx-sai {
|
||||||
|
@apply ml-sai mr-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility my-sai {
|
||||||
|
@apply mt-sai mb-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility m-sai {
|
||||||
|
@apply my-sai mx-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility top-sai {
|
||||||
|
top: var(--sait);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility right-sai {
|
||||||
|
right: var(--sair);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bottom-sai {
|
||||||
|
bottom: var(--saib);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility left-sai {
|
||||||
|
left: var(--sail);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility card2 {
|
||||||
|
@apply rounded-box text-base-content p-4 sm:p-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility column {
|
||||||
|
@apply flex flex-col;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility center {
|
||||||
|
@apply flex items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility row-2 {
|
||||||
|
@apply flex items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility row-3 {
|
||||||
|
@apply flex items-center gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility row-4 {
|
||||||
|
@apply flex items-center gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility col-2 {
|
||||||
|
@apply flex flex-col gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility col-3 {
|
||||||
|
@apply flex flex-col gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility col-4 {
|
||||||
|
@apply flex flex-col gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility col-8 {
|
||||||
|
@apply flex flex-col gap-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility ellipsize {
|
||||||
|
@apply overflow-hidden text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-padding-x {
|
||||||
|
@apply px-4 sm:px-8 md:px-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-padding-t {
|
||||||
|
@apply pt-4 sm:pt-8 md:pt-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-padding-b {
|
||||||
|
@apply pb-4 sm:pb-8 md:pb-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-padding-y {
|
||||||
|
@apply pt-4 pb-4 sm:pt-8 sm:pb-8 md:pt-12 md:pb-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-sizing {
|
||||||
|
@apply m-auto w-full max-w-3xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content {
|
||||||
|
@apply m-auto w-full max-w-3xl px-4 pt-4 pb-4 sm:px-8 sm:pt-8 sm:pb-8 md:px-12 md:pt-12 md:pb-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility heading {
|
||||||
|
@apply text-center text-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility subheading {
|
||||||
|
@apply text-center text-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility superheading {
|
||||||
|
@apply text-center text-4xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility link {
|
||||||
|
@apply text-primary cursor-pointer underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* content visibility */
|
||||||
|
|
||||||
|
@utility cv {
|
||||||
|
content-visibility: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
|
so we've added these compatibility styles to make sure everything still
|
||||||
|
looks the same as it did with Tailwind CSS v3.
|
||||||
|
|
||||||
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
|
color utility to any element that depends on these defaults.
|
||||||
|
*/
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Fonts */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Satoshis";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src:
|
||||||
|
local(""),
|
||||||
|
url("/fonts/Satoshi Symbol.ttf") format("truetype");
|
||||||
}
|
}
|
||||||
|
|
||||||
.pr-sai {
|
@font-face {
|
||||||
padding-right: var(--sair);
|
font-family: "Lato";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src:
|
||||||
|
local(""),
|
||||||
|
url("/fonts/Lato-Regular.ttf") format("truetype");
|
||||||
}
|
}
|
||||||
|
|
||||||
.pb-sai {
|
@font-face {
|
||||||
padding-bottom: var(--saib);
|
font-family: "Lato";
|
||||||
|
font-style: bold;
|
||||||
|
font-weight: 600;
|
||||||
|
src:
|
||||||
|
local(""),
|
||||||
|
url("/fonts/Lato-Bold.ttf") format("truetype");
|
||||||
}
|
}
|
||||||
|
|
||||||
.pl-sai {
|
@font-face {
|
||||||
padding-left: var(--sail);
|
font-family: "Lato";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
src:
|
||||||
|
local(""),
|
||||||
|
url("/fonts/Italic.ttf") format("truetype");
|
||||||
}
|
}
|
||||||
|
|
||||||
.px-sai {
|
/* root */
|
||||||
@apply pl-sai pr-sai;
|
|
||||||
|
:root {
|
||||||
|
font-family: Lato;
|
||||||
|
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
|
||||||
|
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
|
||||||
|
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
|
||||||
|
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
||||||
}
|
}
|
||||||
|
|
||||||
.py-sai {
|
[data-theme] {
|
||||||
@apply pt-sai pb-sai;
|
@apply bg-base-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.p-sai {
|
.mobile [data-tip]::before {
|
||||||
@apply py-sai px-sai;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mt-sai {
|
/* safe area insets */
|
||||||
margin-top: var(--sait);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mr-sai {
|
|
||||||
margin-right: var(--sair);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-sai {
|
|
||||||
margin-bottom: var(--saib);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ml-sai {
|
|
||||||
margin-left: var(--sail);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx-sai {
|
|
||||||
@apply ml-sai mr-sai;
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-sai {
|
|
||||||
@apply mt-sai mb-sai;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-sai {
|
|
||||||
@apply my-sai mx-sai;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-sai {
|
|
||||||
top: var(--sait);
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-sai {
|
|
||||||
right: var(--sair);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-sai {
|
|
||||||
bottom: var(--saib);
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-sai {
|
|
||||||
left: var(--sail);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* utilities */
|
/* utilities */
|
||||||
@@ -165,110 +271,18 @@
|
|||||||
@apply bg-base-300 text-base-content transition-colors;
|
@apply bg-base-300 text-base-content transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card2 {
|
|
||||||
@apply rounded-box p-4 text-base-content sm:p-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card2.card2-sm {
|
.card2.card2-sm {
|
||||||
@apply p-2 text-base-content sm:p-4;
|
@apply text-base-content p-2 sm:p-4;
|
||||||
}
|
|
||||||
|
|
||||||
.column {
|
|
||||||
@apply flex flex-col;
|
|
||||||
}
|
|
||||||
|
|
||||||
.center {
|
|
||||||
@apply flex items-center justify-center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-2 {
|
|
||||||
@apply flex items-center gap-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-3 {
|
|
||||||
@apply flex items-center gap-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-4 {
|
|
||||||
@apply flex items-center gap-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-2 {
|
|
||||||
@apply flex flex-col gap-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-3 {
|
|
||||||
@apply flex flex-col gap-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-4 {
|
|
||||||
@apply flex flex-col gap-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-8 {
|
|
||||||
@apply flex flex-col gap-8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
@apply justify-start overflow-hidden text-ellipsis whitespace-nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ellipsize {
|
|
||||||
@apply overflow-hidden text-ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-tip]::before {
|
[data-tip]::before {
|
||||||
@apply ellipsize;
|
@apply overflow-hidden text-ellipsis;
|
||||||
}
|
|
||||||
|
|
||||||
.content-padding-x {
|
|
||||||
@apply px-4 sm:px-8 md:px-12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-padding-t {
|
|
||||||
@apply pt-4 sm:pt-8 md:pt-12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-padding-b {
|
|
||||||
@apply pb-4 sm:pb-8 md:pb-12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-padding-y {
|
|
||||||
@apply content-padding-t content-padding-b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-sizing {
|
|
||||||
@apply m-auto w-full max-w-3xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
@apply content-sizing content-padding-x content-padding-y;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
@apply text-center text-2xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subheading {
|
|
||||||
@apply text-center text-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.superheading {
|
|
||||||
@apply text-center text-4xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link {
|
|
||||||
@apply cursor-pointer text-primary underline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input input::placeholder {
|
.input input::placeholder {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shadow-top-xl {
|
|
||||||
@apply shadow-[0_20px_25px_-5px_rgb(0,0,0,0.1)_0_8px_10px_-6px_rgb(0,0,0,0.1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
/* tiptap */
|
/* tiptap */
|
||||||
|
|
||||||
.input-editor,
|
.input-editor,
|
||||||
@@ -278,21 +292,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tiptap {
|
.tiptap {
|
||||||
--tiptap-object-bg: var(--neutral);
|
--tiptap-object-bg: var(--color-neutral);
|
||||||
--tiptap-object-fg: var(--neutral-content);
|
--tiptap-object-fg: var(--color-neutral-content);
|
||||||
--tiptap-active-bg: var(--primary);
|
--tiptap-active-bg: var(--color-primary);
|
||||||
--tiptap-active-fg: var(--primary-content);
|
--tiptap-active-fg: var(--color-primary-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-suggestions {
|
.tiptap-suggestions {
|
||||||
--tiptap-object-bg: var(--base-100);
|
--tiptap-object-bg: var(--color-base-100);
|
||||||
--tiptap-object-fg: var(--base-content);
|
--tiptap-object-fg: var(--color-base-content);
|
||||||
--tiptap-active-bg: var(--base-300);
|
--tiptap-active-bg: var(--color-base-300);
|
||||||
--tiptap-active-fg: var(--base-content);
|
--tiptap-active-fg: var(--color-base-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-suggestions__item {
|
.tiptap-suggestions__item {
|
||||||
@apply border-l-2 border-solid border-base-100;
|
@apply border-base-100 border-l-2 border-solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-suggestions__selected {
|
.tiptap-suggestions__selected {
|
||||||
@@ -312,13 +326,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.note-editor .tiptap {
|
.note-editor .tiptap {
|
||||||
--tiptap-object-bg: var(--base-200);
|
--tiptap-object-bg: var(--color-base-200);
|
||||||
@apply input input-bordered h-auto min-h-32 rounded-box p-[.65rem] pb-6;
|
@apply input rounded-box h-auto min-h-32 p-[.65rem] pb-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-editor .tiptap {
|
.input-editor .tiptap {
|
||||||
--tiptap-object-bg: var(--base-200);
|
--tiptap-object-bg: var(--color-base-200);
|
||||||
@apply input input-bordered h-auto p-[.65rem];
|
@apply input h-auto p-[.65rem];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* link-content, based on tiptap */
|
/* link-content, based on tiptap */
|
||||||
@@ -330,8 +344,8 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
background-color: var(--base-100);
|
background-color: var(--color-base-100);
|
||||||
color: var(--base-content);
|
color: var(--color-base-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* content rendered by welshman/content */
|
/* content rendered by welshman/content */
|
||||||
@@ -347,23 +361,31 @@
|
|||||||
/* date input */
|
/* date input */
|
||||||
|
|
||||||
.picker {
|
.picker {
|
||||||
--date-picker-foreground: var(--base-content);
|
--date-picker-foreground: var(--color-base-content);
|
||||||
--date-picker-background: var(--base-300);
|
--date-picker-background: var(--color-base-300);
|
||||||
--date-picker-highlight-border: var(--primary);
|
--date-picker-highlight-border: var(--color-primary);
|
||||||
--date-picker-selected-color: var(--primary-content);
|
--date-picker-selected-color: var(--color-primary-content);
|
||||||
--date-picker-selected-background: var(--primary);
|
--date-picker-selected-background: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-time-field {
|
.date-time-field {
|
||||||
@apply input input-bordered rounded-lg px-0;
|
@apply input rounded-lg px-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-time-field input {
|
.date-time-field input {
|
||||||
@apply !h-full !w-full !rounded-lg !border-none !bg-inherit !px-4 !text-inherit;
|
@apply h-full! w-full! rounded-lg! border-none! bg-inherit! px-4! text-inherit!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* tippy popover */
|
/* tippy popover */
|
||||||
|
|
||||||
|
.tippy-target {
|
||||||
|
@apply z-tooltip pointer-events-none fixed inset-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippy-target > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.tippy-box {
|
.tippy-box {
|
||||||
@apply rounded-box shadow-xl;
|
@apply rounded-box shadow-xl;
|
||||||
}
|
}
|
||||||
@@ -371,15 +393,15 @@
|
|||||||
/* emoji picker */
|
/* emoji picker */
|
||||||
|
|
||||||
emoji-picker {
|
emoji-picker {
|
||||||
--background: var(--base-100);
|
--background: var(--color-base-100);
|
||||||
--border-color: var(--base-100);
|
--border-color: var(--color-base-100);
|
||||||
--border-radius: var(--rounded-box);
|
--border-radius: var(--rounded-box);
|
||||||
--button-active-background: var(--base-content);
|
--button-active-background: var(--color-base-content);
|
||||||
--button-hover-background: var(--base-content);
|
--button-hover-background: var(--color-base-content);
|
||||||
--indicator-color: var(--base-content);
|
--indicator-color: var(--color-base-content);
|
||||||
--input-border-color: var(--base-100);
|
--input-border-color: var(--color-base-100);
|
||||||
--input-font-color: var(--base-content);
|
--input-font-color: var(--color-base-content);
|
||||||
--outline-color: var(--base-100);
|
--outline-color: var(--color-base-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* progress */
|
/* progress */
|
||||||
@@ -390,28 +412,12 @@ progress[value]::-webkit-progress-value {
|
|||||||
|
|
||||||
/* content width for fixed elements */
|
/* content width for fixed elements */
|
||||||
|
|
||||||
.cw {
|
.left-content {
|
||||||
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
|
@apply md:left-[calc(18.5rem+var(--sail))];
|
||||||
}
|
|
||||||
|
|
||||||
.cw-full {
|
|
||||||
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
|
|
||||||
}
|
|
||||||
|
|
||||||
.cb {
|
|
||||||
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
|
|
||||||
}
|
|
||||||
|
|
||||||
.ct {
|
|
||||||
@apply top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keyboard open state adjustments */
|
/* Keyboard open state adjustments */
|
||||||
|
|
||||||
body.keyboard-open .cb {
|
|
||||||
@apply bottom-sai;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.keyboard-open .hide-on-keyboard {
|
body.keyboard-open .hide-on-keyboard {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -419,23 +425,13 @@ body.keyboard-open .hide-on-keyboard {
|
|||||||
/* chat view */
|
/* chat view */
|
||||||
|
|
||||||
.chat__compose {
|
.chat__compose {
|
||||||
@apply cb cw fixed z-compose;
|
@apply relative z-compose mb-14 shrink-0 md:mb-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat__compose-zone {
|
.chat__compose .chat__compose-inner {
|
||||||
@apply cb cw fixed z-compose;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat__compose-zone .chat__compose-inner {
|
|
||||||
@apply min-w-0;
|
@apply min-w-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat__scroll-down {
|
.chat__scroll-down {
|
||||||
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
|
@apply pb-sai z-feature fixed right-4 bottom-28 md:bottom-16;
|
||||||
}
|
|
||||||
|
|
||||||
/* content visibility */
|
|
||||||
|
|
||||||
.cv {
|
|
||||||
content-visibility: auto;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {Room as LiveKitRoom} from "livekit-client"
|
||||||
|
import {derived, writable} from "svelte/store"
|
||||||
|
import {type Room} from "@app/core/state"
|
||||||
|
|
||||||
|
export type VoiceSession = {
|
||||||
|
url: string
|
||||||
|
h: string
|
||||||
|
room: LiveKitRoom
|
||||||
|
muted: boolean
|
||||||
|
cameraOn: boolean
|
||||||
|
screenShareOn: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Pubkey = string
|
||||||
|
|
||||||
|
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
||||||
|
|
||||||
|
export enum VoiceState {
|
||||||
|
Joining = "joining",
|
||||||
|
Connected = "connected",
|
||||||
|
Disconnected = "disconnected",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
||||||
|
|
||||||
|
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
||||||
|
|
||||||
|
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
||||||
|
|
||||||
|
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
||||||
|
|
||||||
|
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
||||||
|
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
|
||||||
|
|
||||||
|
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
|
||||||
|
const pk = pubkeyFromLiveKitIdentity(identity)
|
||||||
|
return pk ? {pubkey: pk, identity} : {identity}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
||||||
|
|
||||||
|
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
||||||
|
|
||||||
|
export const isParticipantSpeaking = derived(
|
||||||
|
speakingParticipants,
|
||||||
|
$participants => (p: VoiceParticipant) =>
|
||||||
|
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const isLocalSpeaking = derived(
|
||||||
|
[currentVoiceSession, speakingParticipants],
|
||||||
|
([$session, $speaking]) => {
|
||||||
|
if (!$session?.room) return false
|
||||||
|
const local = participantFromLiveKitIdentity($session.room.localParticipant.identity)
|
||||||
|
return $speaking.some(sp => participantKey(sp) === participantKey(local))
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {Track} from "livekit-client"
|
||||||
|
import {MediaQuery} from "svelte/reactivity"
|
||||||
|
import {derived, get, writable} from "svelte/store"
|
||||||
|
import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores"
|
||||||
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
|
export enum VideoCallLayout {
|
||||||
|
Chat = "chat",
|
||||||
|
Video = "video",
|
||||||
|
Split = "split",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
|
||||||
|
|
||||||
|
export enum ViewportSize {
|
||||||
|
Desktop = "desktop",
|
||||||
|
Mobile = "mobile",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoCallViewportSync = {
|
||||||
|
previousLayout: undefined as ViewportSize | undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
|
||||||
|
|
||||||
|
export const resetVideoCallLayout = () => {
|
||||||
|
videoCallViewportSync.previousLayout = undefined
|
||||||
|
videoCallLayout.set(VideoCallLayout.Chat)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
|
||||||
|
|
||||||
|
export const toggleVideoPrimaryTile = (key: string) => {
|
||||||
|
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
|
||||||
|
}
|
||||||
|
|
||||||
|
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
|
||||||
|
|
||||||
|
const countLiveVisualFeeds = (session: VoiceSession): number => {
|
||||||
|
const room = session.room
|
||||||
|
let n = 0
|
||||||
|
const lp = room.localParticipant
|
||||||
|
if (session.cameraOn) {
|
||||||
|
const pub = lp.getTrackPublication(Track.Source.Camera)
|
||||||
|
if (pub?.track) n += 1
|
||||||
|
}
|
||||||
|
if (session.screenShareOn) {
|
||||||
|
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
|
||||||
|
if (pub?.track) n += 1
|
||||||
|
}
|
||||||
|
for (const rp of room.remoteParticipants.values()) {
|
||||||
|
for (const source of VISUAL_SOURCES) {
|
||||||
|
const pub = rp.getTrackPublication(source)
|
||||||
|
if (pub?.isSubscribed && pub.track) n += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
export const triggerVideoFeedCount = () => {
|
||||||
|
currentVoiceSession.update(s => (s ? {...s} : s))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoTileCount = derived([currentVoiceSession, voiceState], ([$session, $state]) => {
|
||||||
|
if ($state !== VoiceState.Connected || !$session) return 0
|
||||||
|
return countLiveVisualFeeds($session)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const toggleCamera = async () => {
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const cameraOn = !session.cameraOn
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setCameraEnabled(cameraOn)
|
||||||
|
currentVoiceSession.set({...session, cameraOn})
|
||||||
|
} catch {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: cameraOn ? "Could not access camera" : "Could not turn off camera",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toggleScreenShare = async () => {
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const screenShareOn = !session.screenShareOn
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setScreenShareEnabled(screenShareOn)
|
||||||
|
currentVoiceSession.set({...session, screenShareOn})
|
||||||
|
} catch {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,51 +4,77 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
|
LocalParticipant,
|
||||||
|
LocalTrackPublication,
|
||||||
Room as LiveKitRoom,
|
Room as LiveKitRoom,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
Track,
|
Track,
|
||||||
|
supportsAudioOutputSelection,
|
||||||
type AudioCaptureOptions,
|
type AudioCaptureOptions,
|
||||||
type LocalParticipant,
|
|
||||||
} from "livekit-client"
|
} from "livekit-client"
|
||||||
import {derived, get, writable} from "svelte/store"
|
import {derived, get} from "svelte/store"
|
||||||
import {map, removeUndefined, uniqBy} from "@welshman/lib"
|
import {map, removeUndefined, uniqBy} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
|
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
|
||||||
import {signer} from "@welshman/app"
|
import {signer} from "@welshman/app"
|
||||||
import {getLivekitEndpoint} from "$lib/livekit"
|
import {getLivekitEndpoint} from "$lib/livekit"
|
||||||
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
|
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
|
||||||
import {deriveLatestEventForUrl, deriveRoom, makeRoomId, type Room} from "@app/core/state"
|
import {
|
||||||
|
currentVoiceRoom,
|
||||||
|
currentVoiceSession,
|
||||||
|
participantFromLiveKitIdentity,
|
||||||
|
participantKey,
|
||||||
|
participantPubkeyMap,
|
||||||
|
pubkeyFromLiveKitIdentity,
|
||||||
|
speakingParticipants,
|
||||||
|
VoiceState,
|
||||||
|
type VoiceParticipant,
|
||||||
|
voiceState,
|
||||||
|
} from "@app/call/stores"
|
||||||
|
import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video"
|
||||||
|
import {deriveLatestEventForUrl, deriveRoom, makeRoomId} from "@app/core/state"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
export const LIVEKIT_PARTICIPANTS = 39004
|
export const LIVEKIT_PARTICIPANTS = 39004
|
||||||
|
|
||||||
export {checkRelayHasLivekit} from "$lib/livekit"
|
export {checkRelayHasLivekit} from "$lib/livekit"
|
||||||
|
|
||||||
export type VoiceSession = {
|
export {supportsAudioOutputSelection}
|
||||||
url: string
|
|
||||||
h: string
|
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
|
||||||
room: LiveKitRoom
|
|
||||||
muted: boolean
|
export enum DeviceKind {
|
||||||
|
AudioInput = "audioinput",
|
||||||
|
AudioOutput = "audiooutput",
|
||||||
|
VideoInput = "videoinput",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Pubkey = string
|
export const switchVoiceActiveDevice = async (
|
||||||
|
kind: DeviceKind,
|
||||||
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
targetDeviceId: string,
|
||||||
|
): Promise<void> => {
|
||||||
export enum VoiceState {
|
const session = get(currentVoiceSession)
|
||||||
Joining = "joining",
|
if (!session) return
|
||||||
Connected = "connected",
|
const id = targetDeviceId === "" ? LIVEKIT_DEFAULT_DEVICE_ID : targetDeviceId
|
||||||
Disconnected = "disconnected",
|
try {
|
||||||
|
await session.room.switchActiveDevice(kind, id)
|
||||||
|
} catch {
|
||||||
|
let label: string
|
||||||
|
switch (kind) {
|
||||||
|
case DeviceKind.AudioInput:
|
||||||
|
label = "microphone"
|
||||||
|
break
|
||||||
|
case DeviceKind.AudioOutput:
|
||||||
|
label = "speaker"
|
||||||
|
break
|
||||||
|
case DeviceKind.VideoInput:
|
||||||
|
label = "camera"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pushToast({theme: "error", message: `Error changing ${label}`})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
|
||||||
|
|
||||||
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
|
||||||
|
|
||||||
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
|
||||||
|
|
||||||
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
|
||||||
|
|
||||||
const addParticipant = (identity: string) => {
|
const addParticipant = (identity: string) => {
|
||||||
participantPubkeyMap.update(m => {
|
participantPubkeyMap.update(m => {
|
||||||
const next = new Map(m)
|
const next = new Map(m)
|
||||||
@@ -65,24 +91,6 @@ const deleteParticipant = (identity: string) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
|
||||||
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
|
|
||||||
|
|
||||||
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
|
|
||||||
const pk = pubkeyFromLiveKitIdentity(identity)
|
|
||||||
return pk ? {pubkey: pk, identity} : {identity}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
|
||||||
|
|
||||||
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
|
||||||
|
|
||||||
export const isParticipantSpeaking = derived(
|
|
||||||
speakingParticipants,
|
|
||||||
$participants => (p: VoiceParticipant) =>
|
|
||||||
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const fetchLivekitToken = async (
|
const fetchLivekitToken = async (
|
||||||
url: string,
|
url: string,
|
||||||
groupId: string,
|
groupId: string,
|
||||||
@@ -164,7 +172,9 @@ const setUpMicrophone = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
||||||
|
videoPrimaryTileKey.set(undefined)
|
||||||
currentVoiceSession.set(undefined)
|
currentVoiceSession.set(undefined)
|
||||||
|
resetVideoCallLayout()
|
||||||
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||||
voiceState.set(VoiceState.Disconnected)
|
voiceState.set(VoiceState.Disconnected)
|
||||||
const message =
|
const message =
|
||||||
@@ -183,11 +193,16 @@ const onTrackSubscribed = (track: Track) => {
|
|||||||
element.style.display = "none"
|
element.style.display = "none"
|
||||||
document.body.appendChild(element)
|
document.body.appendChild(element)
|
||||||
element.play().catch(() => {})
|
element.play().catch(() => {})
|
||||||
|
} else if (track.kind === Track.Kind.Video) {
|
||||||
|
triggerVideoFeedCount()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onTrackUnsubscribed = (track: Track) => {
|
const onTrackUnsubscribed = (track: Track) => {
|
||||||
track.detach().forEach(el => el.remove())
|
track.detach().forEach(el => el.remove())
|
||||||
|
if (track.kind === Track.Kind.Video) {
|
||||||
|
triggerVideoFeedCount()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
||||||
@@ -208,6 +223,17 @@ const onParticipantDisconnected = (participant: {identity: string}) => {
|
|||||||
deleteParticipant(participant.identity)
|
deleteParticipant(participant.identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onLocalTrackUnpublished = (
|
||||||
|
publication: LocalTrackPublication,
|
||||||
|
participant: LocalParticipant,
|
||||||
|
) => {
|
||||||
|
if (publication.source !== Track.Source.ScreenShare) return
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session || participant.identity !== session.room.localParticipant.identity) return
|
||||||
|
if (!session.screenShareOn) return
|
||||||
|
currentVoiceSession.set({...session, screenShareOn: false})
|
||||||
|
}
|
||||||
|
|
||||||
let joinAbortController: AbortController | undefined
|
let joinAbortController: AbortController | undefined
|
||||||
|
|
||||||
export const cancelJoinVoiceRoom = () => {
|
export const cancelJoinVoiceRoom = () => {
|
||||||
@@ -245,6 +271,7 @@ export const joinVoiceRoom = async (
|
|||||||
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
||||||
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||||
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||||
|
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
|
||||||
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -268,7 +295,14 @@ export const joinVoiceRoom = async (
|
|||||||
|
|
||||||
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
||||||
|
|
||||||
currentVoiceSession.set({url, h, room: liveKitRoom, muted})
|
currentVoiceSession.set({
|
||||||
|
url,
|
||||||
|
h,
|
||||||
|
room: liveKitRoom,
|
||||||
|
muted,
|
||||||
|
cameraOn: false,
|
||||||
|
screenShareOn: false,
|
||||||
|
})
|
||||||
voiceState.set(VoiceState.Connected)
|
voiceState.set(VoiceState.Connected)
|
||||||
playJoinSound()
|
playJoinSound()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -287,8 +321,26 @@ export const leaveVoiceRoom = async () => {
|
|||||||
const audio = new Audio("/leave-voice-room.mp3")
|
const audio = new Audio("/leave-voice-room.mp3")
|
||||||
audio.play().catch(() => {})
|
audio.play().catch(() => {})
|
||||||
|
|
||||||
|
if (session.cameraOn) {
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setCameraEnabled(false)
|
||||||
|
} catch {
|
||||||
|
pushToast({theme: "error", message: "Error turning off camera."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.screenShareOn) {
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setScreenShareEnabled(false)
|
||||||
|
} catch {
|
||||||
|
pushToast({theme: "error", message: "Error turning off screen sharing."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
voiceState.set(VoiceState.Disconnected)
|
voiceState.set(VoiceState.Disconnected)
|
||||||
|
videoPrimaryTileKey.set(undefined)
|
||||||
currentVoiceSession.set(undefined)
|
currentVoiceSession.set(undefined)
|
||||||
|
resetVideoCallLayout()
|
||||||
session.room.disconnect()
|
session.room.disconnect()
|
||||||
speakingParticipants.set([])
|
speakingParticipants.set([])
|
||||||
participantPubkeyMap.set(new Map())
|
participantPubkeyMap.set(new Map())
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
<div class="flex grow flex-wrap justify-end gap-2">
|
||||||
{#if h && showRoom}
|
{#if h && showRoom}
|
||||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||||
Posted in #<RoomName {h} {url} />
|
Posted in #<RoomName {h} {url} />
|
||||||
|
|||||||
@@ -20,24 +20,33 @@
|
|||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {PROTECTED} from "@app/core/state"
|
import {PROTECTED} from "@app/core/state"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
|
import {DraftKey} from "@app/util/drafts"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
import {canEnforceNip70} from "@app/core/commands"
|
import {canEnforceNip70} from "@app/core/commands"
|
||||||
|
|
||||||
|
type Values = {
|
||||||
|
d: string
|
||||||
|
title: string
|
||||||
|
content: string | object
|
||||||
|
location: string
|
||||||
|
start?: number
|
||||||
|
end?: number
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
h?: string
|
h?: string
|
||||||
header: Snippet
|
header: Snippet
|
||||||
initialValues?: {
|
initialValues?: Values
|
||||||
d: string
|
|
||||||
title: string
|
|
||||||
content: string
|
|
||||||
location: string
|
|
||||||
start: number
|
|
||||||
end: number
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, h, header, initialValues}: Props = $props()
|
let {url, h, header, initialValues}: Props = $props()
|
||||||
|
|
||||||
|
const draftKey = new DraftKey<Values>(`calendar:${url}:${h ?? ""}`)
|
||||||
|
|
||||||
|
if (!initialValues) {
|
||||||
|
initialValues = draftKey.get()
|
||||||
|
}
|
||||||
|
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
@@ -74,9 +83,9 @@
|
|||||||
const ed = await editor
|
const ed = await editor
|
||||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||||
const tags = [
|
const tags = [
|
||||||
["d", initialValues?.d || randomId()],
|
["d", d],
|
||||||
["title", title],
|
["title", title],
|
||||||
["location", location || ""],
|
["location", location],
|
||||||
["start", start.toString()],
|
["start", start.toString()],
|
||||||
["end", end.toString()],
|
["end", end.toString()],
|
||||||
...daysBetween(start, end).map(D => ["D", D.toString()]),
|
...daysBetween(start, end).map(D => ["D", D.toString()]),
|
||||||
@@ -95,17 +104,27 @@
|
|||||||
|
|
||||||
pushToast({message: "Your event has been saved!"})
|
pushToast({message: "Your event has been saved!"})
|
||||||
publishThunk({event, relays: [url]})
|
publishThunk({event, relays: [url]})
|
||||||
|
draftKey.clear()
|
||||||
history.back()
|
history.back()
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = initialValues?.content || ""
|
const d = $state(initialValues?.d ?? randomId())
|
||||||
const editor = makeEditor({url, submit, uploading, content})
|
let title = $state(initialValues?.title ?? "")
|
||||||
|
let location = $state(initialValues?.location ?? "")
|
||||||
let title = $state(initialValues?.title || "")
|
|
||||||
let location = $state(initialValues?.location || "")
|
|
||||||
let start: number | undefined = $state(initialValues?.start)
|
let start: number | undefined = $state(initialValues?.start)
|
||||||
let end: number | undefined = $state(initialValues?.end)
|
let end: number | undefined = $state(initialValues?.end)
|
||||||
let endDirty = Boolean(initialValues?.end)
|
let endDirty = $state(Boolean(initialValues?.end))
|
||||||
|
let content = $state(initialValues?.content ?? "")
|
||||||
|
|
||||||
|
const onChange = (json: object) => {
|
||||||
|
content = json
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = makeEditor({url, submit, uploading, onChange, content})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
draftKey.set({d, title, location, start, end, content})
|
||||||
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!endDirty && start) {
|
if (!endDirty && start) {
|
||||||
@@ -136,7 +155,7 @@
|
|||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div
|
<div
|
||||||
class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
|
class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
|
||||||
<div class="input-editor flex-grow overflow-hidden">
|
<div class="input-editor grow overflow-hidden">
|
||||||
<EditorContent {editor} />
|
<EditorContent {editor} />
|
||||||
</div>
|
</div>
|
||||||
<Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
|
<Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
const end = $derived(parseInt(meta.end))
|
const end = $derived(parseInt(meta.end))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-grow flex-wrap justify-between gap-2">
|
<div class="flex grow flex-wrap justify-between gap-2">
|
||||||
<p class="text-xl">{meta.title || meta.name}</p>
|
<p class="text-xl">{meta.title || meta.name}</p>
|
||||||
{#if !isNaN(start) && !isNaN(end)}
|
{#if !isNaN(start) && !isNaN(end)}
|
||||||
{@const startDateDisplay = formatTimestampAsDate(start)}
|
{@const startDateDisplay = formatTimestampAsDate(start)}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
{#if meta.location}
|
{#if meta.location}
|
||||||
<span class="flex items-start gap-1">
|
<span class="flex items-start gap-1">
|
||||||
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
|
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
|
||||||
<span class="break-words">{meta.location}</span>
|
<span class="wrap-break-word">{meta.location}</span>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
|
import {goto} from "$app/navigation"
|
||||||
import {
|
import {
|
||||||
ago,
|
ago,
|
||||||
int,
|
int,
|
||||||
@@ -54,6 +55,7 @@
|
|||||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||||
import {userSettingsValues, deriveChat} from "@app/core/state"
|
import {userSettingsValues, deriveChat} from "@app/core/state"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
import {DraftKey} from "@app/util/drafts"
|
||||||
import {makeDelete, prependParent} from "@app/core/commands"
|
import {makeDelete, prependParent} from "@app/core/commands"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
@@ -65,6 +67,7 @@
|
|||||||
const {pubkeys, info}: Props = $props()
|
const {pubkeys, info}: Props = $props()
|
||||||
|
|
||||||
const chat = deriveChat(pubkeys)
|
const chat = deriveChat(pubkeys)
|
||||||
|
const draftKey = new DraftKey<{content?: string | object}>(`dm:${$chat?.id}`)
|
||||||
const others = remove($pubkey!, pubkeys)
|
const others = remove($pubkey!, pubkeys)
|
||||||
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
|
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
|
||||||
|
|
||||||
@@ -73,7 +76,7 @@
|
|||||||
? pushModal(ProfileDetail, {pubkey: others[0]})
|
? pushModal(ProfileDetail, {pubkey: others[0]})
|
||||||
: pushModal(ChatMembers, {pubkeys: others})
|
: pushModal(ChatMembers, {pubkeys: others})
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => goto("/chat")
|
||||||
|
|
||||||
const replyTo = (event: TrustedEvent) => {
|
const replyTo = (event: TrustedEvent) => {
|
||||||
parent = event
|
parent = event
|
||||||
@@ -195,8 +198,6 @@
|
|||||||
let compose: ChatCompose | undefined = $state()
|
let compose: ChatCompose | undefined = $state()
|
||||||
let parent: TrustedEvent | undefined = $state()
|
let parent: TrustedEvent | undefined = $state()
|
||||||
let eventToEdit: TrustedEvent | undefined = $state()
|
let eventToEdit: TrustedEvent | undefined = $state()
|
||||||
let chatCompose: HTMLElement | undefined = $state()
|
|
||||||
let dynamicPadding: HTMLElement | undefined = $state()
|
|
||||||
|
|
||||||
const elements = $derived.by(() => {
|
const elements = $derived.by(() => {
|
||||||
const elements = []
|
const elements = []
|
||||||
@@ -232,20 +233,6 @@
|
|||||||
for (const pubkey of others) {
|
for (const pubkey of others) {
|
||||||
loadMessagingRelayList(pubkey)
|
loadMessagingRelayList(pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
|
||||||
if (dynamicPadding && chatCompose) {
|
|
||||||
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
observer.observe(chatCompose!)
|
|
||||||
observer.observe(dynamicPadding!)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.unobserve(chatCompose!)
|
|
||||||
observer.unobserve(dynamicPadding!)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -293,7 +280,6 @@
|
|||||||
</PageBar>
|
</PageBar>
|
||||||
|
|
||||||
<PageContent class="flex flex-col-reverse gap-2 pt-4">
|
<PageContent class="flex flex-col-reverse gap-2 pt-4">
|
||||||
<div bind:this={dynamicPadding}></div>
|
|
||||||
{#if missingRelayLists.length > 0}
|
{#if missingRelayLists.length > 0}
|
||||||
<div class="py-12">
|
<div class="py-12">
|
||||||
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
||||||
@@ -334,9 +320,10 @@
|
|||||||
</Spinner>
|
</Spinner>
|
||||||
{@render info?.()}
|
{@render info?.()}
|
||||||
</p>
|
</p>
|
||||||
|
<div class="h-screen"></div>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|
||||||
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
|
<div class="chat__compose bg-base-200">
|
||||||
<div>
|
<div>
|
||||||
{#if parent}
|
{#if parent}
|
||||||
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
||||||
@@ -351,7 +338,8 @@
|
|||||||
{onSubmit}
|
{onSubmit}
|
||||||
{onEscape}
|
{onEscape}
|
||||||
{onEditPrevious}
|
{onEditPrevious}
|
||||||
content={eventToEdit?.content}
|
initialValues={eventToEdit}
|
||||||
|
draftKey={eventToEdit ? undefined : draftKey}
|
||||||
disabled={Boolean(missingRelayLists.length)} />
|
disabled={Boolean(missingRelayLists.length)} />
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,23 +10,40 @@
|
|||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
|
import {type DraftKey} from "@app/util/drafts"
|
||||||
|
|
||||||
|
type Values = {
|
||||||
|
content?: string | object
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
content?: string
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
draftKey?: DraftKey<Values>
|
||||||
onEscape?: () => void
|
onEscape?: () => void
|
||||||
onEditPrevious?: () => void
|
onEditPrevious?: () => void
|
||||||
onSubmit: (event: EventContent) => void
|
onSubmit: (event: EventContent) => void
|
||||||
|
initialValues?: Values
|
||||||
}
|
}
|
||||||
|
|
||||||
const {content, disabled = false, onEscape, onEditPrevious, onSubmit}: Props = $props()
|
let {
|
||||||
|
initialValues,
|
||||||
|
disabled = false,
|
||||||
|
draftKey,
|
||||||
|
onEscape,
|
||||||
|
onEditPrevious,
|
||||||
|
onSubmit,
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
if (!initialValues) {
|
||||||
|
initialValues = draftKey?.get()
|
||||||
|
}
|
||||||
|
|
||||||
const autofocus = !isMobile && !disabled
|
const autofocus = !isMobile && !disabled
|
||||||
|
|
||||||
const uploading = writable(false)
|
const uploading = writable(false)
|
||||||
|
|
||||||
const editorClass = $derived(
|
const editorClass = $derived(
|
||||||
cx("chat-editor flex-grow overflow-hidden", {
|
cx("chat-editor grow overflow-hidden", {
|
||||||
"pointer-events-none opacity-50": disabled,
|
"pointer-events-none opacity-50": disabled,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -59,18 +76,29 @@
|
|||||||
|
|
||||||
onSubmit({content, tags})
|
onSubmit({content, tags})
|
||||||
|
|
||||||
|
draftKey?.clear()
|
||||||
ed.chain().clearContent().run()
|
ed.chain().clearContent().run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let content = $state(initialValues?.content ?? "")
|
||||||
|
|
||||||
|
const onChange = (json: object) => {
|
||||||
|
content = json
|
||||||
|
}
|
||||||
|
|
||||||
const editor = makeEditor({
|
const editor = makeEditor({
|
||||||
content,
|
content,
|
||||||
autofocus,
|
|
||||||
submit,
|
submit,
|
||||||
uploading,
|
uploading,
|
||||||
|
onChange,
|
||||||
aggressive: true,
|
aggressive: true,
|
||||||
encryptFiles: true,
|
encryptFiles: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
draftKey?.set({content})
|
||||||
|
})
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const ed = await editor
|
const ed = await editor
|
||||||
ed.view.dom.addEventListener("keydown", handleKeyDown)
|
ed.view.dom.addEventListener("keydown", handleKeyDown)
|
||||||
@@ -95,7 +123,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
<div class={editorClass} aria-disabled={disabled}>
|
<div class={editorClass} aria-disabled={disabled}>
|
||||||
<EditorContent {editor} />
|
<EditorContent {autofocus} {editor} />
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
<Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}>
|
<Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}>
|
||||||
<div
|
<div
|
||||||
class="cursor-pointer border-t border-solid border-base-100 px-6 py-2 transition-colors hover:bg-base-100 {props.class}"
|
class="cursor-pointer border-t border-solid border-base-100 px-3 py-2 transition-colors hover:bg-base-100 {props.class}"
|
||||||
class:bg-base-100={active}>
|
class:bg-base-100={active}>
|
||||||
<div class="flex flex-col justify-start gap-1">
|
<div class="flex flex-col justify-start gap-1">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {assoc} from "@welshman/lib"
|
import {assoc} from "@welshman/lib"
|
||||||
import ChatSquare from "@assets/icons/chat-square.svg?dataurl"
|
|
||||||
import Check from "@assets/icons/check.svg?dataurl"
|
import Check from "@assets/icons/check.svg?dataurl"
|
||||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||||
import BellOff from "@assets/icons/bell-off.svg?dataurl"
|
import BellOff from "@assets/icons/bell-off.svg?dataurl"
|
||||||
@@ -8,13 +7,9 @@
|
|||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Modal from "@lib/components/Modal.svelte"
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
import ChatStart from "@app/components/ChatStart.svelte"
|
|
||||||
import {setChecked} from "@app/util/notifications"
|
import {setChecked} from "@app/util/notifications"
|
||||||
import {pushModal} from "@app/util/modal"
|
|
||||||
import {notificationSettings} from "@app/core/state"
|
import {notificationSettings} from "@app/core/state"
|
||||||
|
|
||||||
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
|
|
||||||
|
|
||||||
const markAsRead = () => {
|
const markAsRead = () => {
|
||||||
setChecked("/chat/*")
|
setChecked("/chat/*")
|
||||||
history.back()
|
history.back()
|
||||||
@@ -28,10 +23,6 @@
|
|||||||
<Modal>
|
<Modal>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<Button class="btn btn-primary" onclick={startChat}>
|
|
||||||
<Icon size={5} icon={ChatSquare} />
|
|
||||||
Start chat
|
|
||||||
</Button>
|
|
||||||
<Button class="btn btn-neutral" onclick={markAsRead}>
|
<Button class="btn btn-neutral" onclick={markAsRead}>
|
||||||
<Icon size={5} icon={Check} />
|
<Icon size={5} icon={Check} />
|
||||||
Mark all read
|
Mark all read
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
<div class="flex grow flex-wrap justify-end gap-2">
|
||||||
{#if h && showRoom}
|
{#if h && showRoom}
|
||||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||||
Posted in #<RoomName {h} {url} />
|
Posted in #<RoomName {h} {url} />
|
||||||
|
|||||||
@@ -20,25 +20,34 @@
|
|||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
import {PROTECTED} from "@app/core/state"
|
import {PROTECTED} from "@app/core/state"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
|
import {DraftKey} from "@app/util/drafts"
|
||||||
import {canEnforceNip70, uploadFile} from "@app/core/commands"
|
import {canEnforceNip70, uploadFile} from "@app/core/commands"
|
||||||
|
|
||||||
|
type Values = {
|
||||||
|
d: string
|
||||||
|
title: string
|
||||||
|
content: string | object
|
||||||
|
price: number
|
||||||
|
currency: string
|
||||||
|
images: (string | File)[]
|
||||||
|
status: string
|
||||||
|
topics: string[]
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
h?: string
|
h?: string
|
||||||
header: Snippet
|
header: Snippet
|
||||||
initialValues?: {
|
initialValues?: Values
|
||||||
d?: string
|
|
||||||
title?: string
|
|
||||||
content?: string
|
|
||||||
price?: number
|
|
||||||
currency?: string
|
|
||||||
images?: string[]
|
|
||||||
status?: string
|
|
||||||
topics?: string[]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, h, header, initialValues}: Props = $props()
|
let {url, h, header, initialValues}: Props = $props()
|
||||||
|
|
||||||
|
const draftKey = new DraftKey<Values>(`classified:${url}:${h ?? ""}`)
|
||||||
|
|
||||||
|
if (!initialValues) {
|
||||||
|
initialValues = draftKey.get()
|
||||||
|
}
|
||||||
|
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
@@ -66,7 +75,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tags = [
|
const tags = [
|
||||||
["d", initialValues?.d || randomId()],
|
["d", d],
|
||||||
["title", title],
|
["title", title],
|
||||||
["summary", content],
|
["summary", content],
|
||||||
["price", String(price), currency],
|
["price", String(price), currency],
|
||||||
@@ -110,22 +119,32 @@
|
|||||||
event: makeEvent(CLASSIFIED, {content, tags}),
|
event: makeEvent(CLASSIFIED, {content, tags}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
draftKey.clear()
|
||||||
history.back()
|
history.back()
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = initialValues?.content || ""
|
|
||||||
const editor = makeEditor({url, submit, content})
|
|
||||||
|
|
||||||
let loading = $state(false)
|
let loading = $state(false)
|
||||||
let title = $state(initialValues?.title || "")
|
const d = $state(initialValues?.d ?? randomId())
|
||||||
let status = $state(initialValues?.status || "active")
|
let title = $state(initialValues?.title ?? "")
|
||||||
let price = $state(Number(initialValues?.price || 0))
|
let status = $state(initialValues?.status ?? "active")
|
||||||
let currency = $state(initialValues?.currency || "SAT")
|
let price = $state(initialValues?.price ?? 0)
|
||||||
let images = $state<(string | File)[]>(initialValues?.images || [])
|
let currency = $state(initialValues?.currency ?? "SAT")
|
||||||
let topics = $state(uniq(removeUndefined((initialValues?.topics || []).map(normalizeTopic))))
|
let images = $state(initialValues?.images ?? [])
|
||||||
|
let topics = $state(uniq(removeUndefined(initialValues?.topics?.map(normalizeTopic) ?? [])))
|
||||||
|
let content = $state(initialValues?.content ?? "")
|
||||||
|
|
||||||
|
const onChange = (json: object) => {
|
||||||
|
content = json
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = makeEditor({url, submit, onChange, content})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
draftKey.set({d, title, status, price, currency, images, topics, content})
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||||
@@ -153,7 +172,7 @@
|
|||||||
<p>Description*</p>
|
<p>Description*</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="note-editor flex-grow overflow-hidden">
|
<div class="note-editor grow overflow-hidden">
|
||||||
<EditorContent {editor} />
|
<EditorContent {editor} />
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
<div class="flex grow flex-wrap justify-end gap-2">
|
||||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||||
<ThunkStatusOrDeleted {event} />
|
<ThunkStatusOrDeleted {event} />
|
||||||
{#if showActivity}
|
{#if showActivity}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
|
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
|
||||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||||
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
||||||
|
import Revote from "@assets/icons/revote.svg?dataurl"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
import ThreadCreate from "@app/components/ThreadCreate.svelte"
|
import ThreadCreate from "@app/components/ThreadCreate.svelte"
|
||||||
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
|
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
|
||||||
import GoalCreate from "@app/components/GoalCreate.svelte"
|
import GoalCreate from "@app/components/GoalCreate.svelte"
|
||||||
|
import PollCreate from "@app/components/PollCreate.svelte"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
@@ -28,6 +30,8 @@
|
|||||||
|
|
||||||
const createClassified = () => pushModal(ClassifiedCreate, {url, h})
|
const createClassified = () => pushModal(ClassifiedCreate, {url, h})
|
||||||
|
|
||||||
|
const createPoll = () => pushModal(PollCreate, {url, h})
|
||||||
|
|
||||||
let ul: Element
|
let ul: Element
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -60,4 +64,10 @@
|
|||||||
Create Thread
|
Create Thread
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Button onclick={createPoll}>
|
||||||
|
<Icon size={4} icon={Revote} />
|
||||||
|
Ask a Question
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -150,7 +150,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="overflow-hidden text-ellipsis break-words"
|
class="overflow-hidden text-ellipsis wrap-break-word"
|
||||||
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
|
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
|
||||||
{#each shortContent as parsed, i}
|
{#each shortContent as parsed, i}
|
||||||
{#if isNewline(parsed) && !isBlock(i - 1)}
|
{#if isNewline(parsed) && !isBlock(i - 1)}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
|
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
|
||||||
import {isRelayUrl, getTagValue} from "@welshman/util"
|
import {isRelayUrl, getTagValue} from "@welshman/util"
|
||||||
|
import {Capacitor} from "@capacitor/core"
|
||||||
import {preventDefault, stopPropagation} from "@lib/html"
|
import {preventDefault, stopPropagation} from "@lib/html"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
||||||
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {dufflepud, PLATFORM_URL, IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/core/state"
|
import {
|
||||||
|
dufflepud,
|
||||||
|
PLATFORM_URL,
|
||||||
|
IMAGE_CONTENT_TYPES,
|
||||||
|
VIDEO_CONTENT_TYPES,
|
||||||
|
THUMBNAIL_URL,
|
||||||
|
} from "@app/core/state"
|
||||||
import {makeSpacePath} from "@app/util/routes"
|
import {makeSpacePath} from "@app/util/routes"
|
||||||
|
|
||||||
const {value, event} = $props()
|
const {value, event} = $props()
|
||||||
@@ -22,6 +29,14 @@
|
|||||||
return [url, true]
|
return [url, true]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getVideoPoster = (videoUrl: string): string | undefined => {
|
||||||
|
if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) {
|
||||||
|
return `${THUMBNAIL_URL}/thumbnail?url=${encodeURIComponent(videoUrl)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
const loadPreview = async () => {
|
const loadPreview = async () => {
|
||||||
const json = await postJson(dufflepud("link/preview"), {url})
|
const json = await postJson(dufflepud("link/preview"), {url})
|
||||||
|
|
||||||
@@ -42,7 +57,12 @@
|
|||||||
<Link {external} {href} class="my-2 block">
|
<Link {external} {href} class="my-2 block">
|
||||||
<div class="overflow-hidden rounded-box">
|
<div class="overflow-hidden rounded-box">
|
||||||
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)}
|
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)}
|
||||||
<video controls src={url} class="max-h-96 rounded-box object-contain object-center">
|
<video
|
||||||
|
controls
|
||||||
|
src={url}
|
||||||
|
poster={getVideoPoster(url)}
|
||||||
|
preload="metadata"
|
||||||
|
class="max-h-96 rounded-box object-contain object-center">
|
||||||
<track kind="captions" />
|
<track kind="captions" />
|
||||||
</video>
|
</video>
|
||||||
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
|
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-hidden text-ellipsis break-words">
|
<div class="overflow-hidden text-ellipsis wrap-break-word">
|
||||||
{#each shortContent as parsed, i}
|
{#each shortContent as parsed, i}
|
||||||
{#if isNewline(parsed)}
|
{#if isNewline(parsed)}
|
||||||
<ContentNewline value={parsed.value} />
|
<ContentNewline value={parsed.value} />
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
{#if $quote.kind === MESSAGE}
|
{#if $quote.kind === MESSAGE}
|
||||||
<div
|
<div
|
||||||
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
|
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
|
||||||
style="background-color: color-mix(in srgb, var(--primary) 10%, var(--base-300) 90%);">
|
style="background-color: color-mix(in srgb, var(--color-primary) 10%, var(--color-base-300) 90%);">
|
||||||
<NoteContentMinimal trimParent {url} event={$quote} />
|
<NoteContentMinimal trimParent {url} event={$quote} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<pre class="card2 card2-sm bg-alt overflow-auto text-xs"><code>{json}</code></pre>
|
<pre class="card2 card2-sm bg-alt overflow-auto text-xs"><code>{json}</code></pre>
|
||||||
<p class="absolute right-2 top-2 flex flex-grow items-center justify-between">
|
<p class="absolute right-2 top-2 flex grow items-center justify-between">
|
||||||
<Button onclick={copyJson} class="btn btn-neutral btn-sm flex items-center">
|
<Button onclick={copyJson} class="btn btn-neutral btn-sm flex items-center">
|
||||||
<Icon icon={Copy} /> Copy
|
<Icon icon={Copy} /> Copy
|
||||||
</Button>
|
</Button>
|
||||||
@@ -109,6 +109,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-primary flex-grow" onclick={() => history.back()}>Got it</Button>
|
<Button class="btn btn-primary grow" onclick={() => history.back()}>Got it</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -10,13 +10,19 @@
|
|||||||
import {publishComment, canEnforceNip70} from "@app/core/commands"
|
import {publishComment, canEnforceNip70} from "@app/core/commands"
|
||||||
import {PROTECTED} from "@app/core/state"
|
import {PROTECTED} from "@app/core/state"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
|
import {DraftKey} from "@app/util/drafts"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
|
type Values = {
|
||||||
|
content?: string | object
|
||||||
|
}
|
||||||
|
|
||||||
const {url, event, onClose, onSubmit} = $props()
|
const {url, event, onClose, onSubmit} = $props()
|
||||||
|
const draftKey = new DraftKey<Values>(`reply:${event.id}`)
|
||||||
|
const initialValues = draftKey.get()
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
const uploading = writable(false)
|
const uploading = writable(false)
|
||||||
|
const autofocus = !isMobile
|
||||||
|
|
||||||
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
|
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
|
||||||
|
|
||||||
@@ -38,13 +44,23 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
draftKey.clear()
|
||||||
onSubmit(publishComment({event, content, tags, relays: [url]}))
|
onSubmit(publishComment({event, content, tags, relays: [url]}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const editor = makeEditor({url, submit, uploading, autofocus: !isMobile})
|
|
||||||
|
|
||||||
let form: HTMLElement
|
let form: HTMLElement
|
||||||
let spacer: HTMLElement
|
let spacer: HTMLElement
|
||||||
|
let content = $state(initialValues?.content ?? "")
|
||||||
|
|
||||||
|
const onChange = (json: object) => {
|
||||||
|
content = json
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = makeEditor({url, submit, uploading, content, onChange})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
draftKey.set({content})
|
||||||
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -64,11 +80,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={spacer}></div>
|
<div bind:this={spacer}></div>
|
||||||
<form in:fly bind:this={form} onsubmit={preventDefault(submit)} class="cb cw fixed z-feature pt-3">
|
<form
|
||||||
|
in:fly
|
||||||
|
bind:this={form}
|
||||||
|
onsubmit={preventDefault(submit)}
|
||||||
|
class="left-content bottom-sai right-sai ml-2 pl-2 fixed z-feature">
|
||||||
<div class="card2 mx-2 my-2 bg-alt shadow-md">
|
<div class="card2 mx-2 my-2 bg-alt shadow-md">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="note-editor flex-grow overflow-hidden">
|
<div class="note-editor grow overflow-hidden">
|
||||||
<EditorContent {editor} />
|
<EditorContent {autofocus} {editor} />
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
data-tip="Add an image"
|
data-tip="Add an image"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
<div class="flex grow flex-wrap justify-end gap-2">
|
||||||
{#if h && showRoom}
|
{#if h && showRoom}
|
||||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||||
Posted in #<RoomName {h} {url} />
|
Posted in #<RoomName {h} {url} />
|
||||||
|
|||||||
@@ -20,14 +20,28 @@
|
|||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
import {PROTECTED} from "@app/core/state"
|
import {PROTECTED} from "@app/core/state"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
|
import {DraftKey} from "@app/util/drafts"
|
||||||
import {canEnforceNip70} from "@app/core/commands"
|
import {canEnforceNip70} from "@app/core/commands"
|
||||||
|
|
||||||
|
type Values = {
|
||||||
|
title: string
|
||||||
|
content: string | object
|
||||||
|
amount: number
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
h?: string
|
h?: string
|
||||||
|
initialValues?: Values
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, h}: Props = $props()
|
let {url, h, initialValues}: Props = $props()
|
||||||
|
|
||||||
|
const draftKey = new DraftKey<Values>(`goal:${url}:${h ?? ""}`)
|
||||||
|
|
||||||
|
if (!initialValues) {
|
||||||
|
initialValues = draftKey.get()
|
||||||
|
}
|
||||||
|
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
@@ -40,7 +54,7 @@
|
|||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if ($uploading) return
|
if ($uploading) return
|
||||||
|
|
||||||
if (!content) {
|
if (!title) {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
message: "Please provide a title for your funding goal.",
|
message: "Please provide a title for your funding goal.",
|
||||||
@@ -48,9 +62,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ed = await editor
|
const ed = await editor
|
||||||
const summary = ed.getText({blockSeparator: "\n"}).trim()
|
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||||
|
|
||||||
if (!summary.trim()) {
|
if (!content.trim()) {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
message: "Please provide details about your funding goal.",
|
message: "Please provide details about your funding goal.",
|
||||||
@@ -59,7 +73,7 @@
|
|||||||
|
|
||||||
const tags = [
|
const tags = [
|
||||||
...ed.storage.nostr.getEditorTags(),
|
...ed.storage.nostr.getEditorTags(),
|
||||||
["summary", summary],
|
["summary", content],
|
||||||
["amount", String(amount)],
|
["amount", String(amount)],
|
||||||
["relays", url],
|
["relays", url],
|
||||||
]
|
]
|
||||||
@@ -74,16 +88,33 @@
|
|||||||
|
|
||||||
publishThunk({
|
publishThunk({
|
||||||
relays: [url],
|
relays: [url],
|
||||||
event: makeEvent(ZAP_GOAL, {content, tags}),
|
event: makeEvent(ZAP_GOAL, {content: title, tags}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
draftKey.clear()
|
||||||
history.back()
|
history.back()
|
||||||
}
|
}
|
||||||
|
|
||||||
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
|
let title = $state(initialValues?.title ?? "")
|
||||||
|
let amount = $state(initialValues?.amount ?? 1000)
|
||||||
|
let content = $state(initialValues?.content ?? "")
|
||||||
|
|
||||||
let content = $state("")
|
const onChange = (json: object) => {
|
||||||
let amount = $state(1000)
|
content = json
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = makeEditor({
|
||||||
|
url,
|
||||||
|
submit,
|
||||||
|
uploading,
|
||||||
|
onChange,
|
||||||
|
placeholder: "What's on your mind?",
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
draftKey.update({title, content, amount})
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||||
@@ -102,7 +133,7 @@
|
|||||||
<!-- svelte-ignore a11y_autofocus -->
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
autofocus={!isMobile}
|
autofocus={!isMobile}
|
||||||
bind:value={content}
|
bind:value={title}
|
||||||
class="grow"
|
class="grow"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="What do funds go towards?" />
|
placeholder="What do funds go towards?" />
|
||||||
@@ -115,7 +146,7 @@
|
|||||||
<p>Details*</p>
|
<p>Details*</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="note-editor flex-grow overflow-hidden">
|
<div class="note-editor grow overflow-hidden">
|
||||||
<EditorContent {editor} />
|
<EditorContent {editor} />
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -137,7 +168,7 @@
|
|||||||
Goal Amount (sats)*
|
Goal Amount (sats)*
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="flex flex-grow justify-end">
|
<div class="flex grow justify-end">
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
<Icon icon={Bolt} />
|
<Icon icon={Bolt} />
|
||||||
<input bind:value={amount} type="number" class="w-28" />
|
<input bind:value={amount} type="number" class="w-28" />
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<ModalTitle>Unable to Zap</ModalTitle>
|
<ModalTitle>Unable to Zap</ModalTitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<p>
|
<p>
|
||||||
Zapping <ProfileLink {pubkey} class="!text-primary" /> isn't possible right now because
|
Zapping <ProfileLink {pubkey} class="text-primary!" /> isn't possible right now because
|
||||||
{#if $zapper}
|
{#if $zapper}
|
||||||
their zap receiver isn't correctly set up.
|
their zap receiver isn't correctly set up.
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -97,10 +97,10 @@
|
|||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
onmousedown={stopPropagation(onClear)}
|
onmousedown={stopPropagation(onClear)}
|
||||||
ontouchstart={stopPropagation(onClear)}>
|
ontouchstart={stopPropagation(onClear)}>
|
||||||
<Icon icon={CloseCircle} class="scale-150 !bg-base-300" />
|
<Icon icon={CloseCircle} class="scale-150 bg-base-300!" />
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon={AddCircle} class="scale-150 !bg-base-300" />
|
<Icon icon={AddCircle} class="scale-150 bg-base-300!" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if !url}
|
{#if !url}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import {Capacitor} from "@capacitor/core"
|
import {Capacitor} from "@capacitor/core"
|
||||||
import {getNip07, getNip55, Nip55Signer} from "@welshman/signer"
|
import {getNip07, getNip55, Nip55Signer} from "@welshman/signer"
|
||||||
import {addSession, type Session, makeNip07Session, makeNip55Session} from "@welshman/app"
|
import {addSession, type Session, makeNip07Session, makeNip55Session} from "@welshman/app"
|
||||||
import Widget from "@assets/icons/widget-2.svg?dataurl"
|
import Widget from "@assets/icons/widget-4.svg?dataurl"
|
||||||
import Letter from "@assets/icons/letter.svg?dataurl"
|
import Letter from "@assets/icons/letter.svg?dataurl"
|
||||||
import Cpu from "@assets/icons/cpu-bolt.svg?dataurl"
|
import Cpu from "@assets/icons/cpu-bolt.svg?dataurl"
|
||||||
import Compass from "@assets/icons/compass-big.svg?dataurl"
|
import Compass from "@assets/icons/compass-big.svg?dataurl"
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
controller.stop()
|
controller.stop()
|
||||||
|
|
||||||
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
|
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
|
||||||
|
setChecked("*")
|
||||||
} else {
|
} else {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Link from "@lib/components/Link.svelte"
|
|
||||||
import CardButton from "@lib/components/CardButton.svelte"
|
|
||||||
import RelayIcon from "@app/components/RelayIcon.svelte"
|
|
||||||
import RelayName from "@app/components/RelayName.svelte"
|
|
||||||
import RelayDescription from "@app/components/RelayDescription.svelte"
|
|
||||||
import {makeSpacePath} from "@app/util/routes"
|
|
||||||
import {notifications} from "@app/util/notifications"
|
|
||||||
|
|
||||||
const {url} = $props()
|
|
||||||
|
|
||||||
const path = makeSpacePath(url)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Link replaceState href={path}>
|
|
||||||
<CardButton class="btn-neutral shadow-md bg-alt rounded-box border-none">
|
|
||||||
{#snippet icon()}
|
|
||||||
<RelayIcon {url} size={12} class="rounded-full" />
|
|
||||||
{/snippet}
|
|
||||||
{#snippet title()}
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<RelayName {url} />
|
|
||||||
{#if $notifications.has(path)}
|
|
||||||
<div class="relative top-1 h-2 w-2 rounded-full bg-primary"></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet info()}
|
|
||||||
<div><RelayDescription {url} /></div>
|
|
||||||
{/snippet}
|
|
||||||
</CardButton>
|
|
||||||
</Link>
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {ComponentProps} from "svelte"
|
import type {ComponentProps} from "svelte"
|
||||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
||||||
|
import {Poll} from "nostr-tools/kinds"
|
||||||
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
|
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
|
||||||
import NoteContentThread from "@app/components/NoteContentThread.svelte"
|
import NoteContentThread from "@app/components/NoteContentThread.svelte"
|
||||||
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
|
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
|
||||||
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
|
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
|
||||||
|
import NoteContentPoll from "@app/components/NoteContentPoll.svelte"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
|
|
||||||
const props: ComponentProps<typeof Content> = $props()
|
const props: ComponentProps<typeof Content> = $props()
|
||||||
@@ -19,6 +21,8 @@
|
|||||||
<NoteContentClassified {...props} />
|
<NoteContentClassified {...props} />
|
||||||
{:else if props.event.kind === ZAP_GOAL}
|
{:else if props.event.kind === ZAP_GOAL}
|
||||||
<NoteContentGoal {...props} />
|
<NoteContentGoal {...props} />
|
||||||
|
{:else if props.event.kind === Poll}
|
||||||
|
<NoteContentPoll {...props} />
|
||||||
{:else}
|
{:else}
|
||||||
<Content {...props} />
|
<Content {...props} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
|
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<CalendarEventDate event={props.event} />
|
<CalendarEventDate event={props.event} />
|
||||||
<div class="flex flex-grow flex-col">
|
<div class="flex grow flex-col">
|
||||||
<CalendarEventHeader event={props.event} />
|
<CalendarEventHeader event={props.event} />
|
||||||
<div class="flex py-2 opacity-50">
|
<div class="flex py-2 opacity-50">
|
||||||
<div class="h-px flex-grow bg-base-content opacity-25"></div>
|
<div class="h-px grow bg-base-content opacity-25"></div>
|
||||||
</div>
|
</div>
|
||||||
<Content {...props} />
|
<Content {...props} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {ComponentProps} from "svelte"
|
import type {ComponentProps} from "svelte"
|
||||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
||||||
|
import {Poll} from "nostr-tools/kinds"
|
||||||
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
|
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
|
||||||
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
|
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
|
||||||
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
|
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
|
||||||
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
|
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
|
||||||
|
import NoteContentMinimalPoll from "@app/components/NoteContentMinimalPoll.svelte"
|
||||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||||
|
|
||||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||||
@@ -19,6 +21,8 @@
|
|||||||
<NoteContentMinimalClassified {...props} />
|
<NoteContentMinimalClassified {...props} />
|
||||||
{:else if props.event.kind === ZAP_GOAL}
|
{:else if props.event.kind === ZAP_GOAL}
|
||||||
<NoteContentMinimalGoal {...props} />
|
<NoteContentMinimalGoal {...props} />
|
||||||
|
{:else if props.event.kind === Poll}
|
||||||
|
<NoteContentMinimalPoll {...props} />
|
||||||
{:else}
|
{:else}
|
||||||
<ContentMinimal {...props} />
|
<ContentMinimal {...props} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="flex flex-grow flex-wrap justify-between gap-2">
|
<div class="flex grow flex-wrap justify-between gap-2">
|
||||||
<p class="text-sm">{meta.title || meta.name}</p>
|
<p class="text-sm">{meta.title || meta.name}</p>
|
||||||
{#if !isNaN(start) && !isNaN(end)}
|
{#if !isNaN(start) && !isNaN(end)}
|
||||||
{@const startDateDisplay = formatTimestampAsDate(start)}
|
{@const startDateDisplay = formatTimestampAsDate(start)}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {ComponentProps} from "svelte"
|
||||||
|
import {derived} from "svelte/store"
|
||||||
|
import {PollResponse} from "nostr-tools/kinds"
|
||||||
|
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||||
|
import {deriveEvents} from "@app/core/state"
|
||||||
|
import {getPollResults} from "@app/util/polls"
|
||||||
|
|
||||||
|
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||||
|
|
||||||
|
const responses = deriveEvents([{kinds: [PollResponse], "#e": [props.event.id]}])
|
||||||
|
|
||||||
|
const results = derived(responses, $responses => getPollResults(props.event, $responses))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-0">
|
||||||
|
<ContentMinimal {...props} />
|
||||||
|
<span class="text-xs opacity-50">{$results.voters} voter{$results.voters === 1 ? "" : "s"}</span>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {ComponentProps} from "svelte"
|
||||||
|
import {onMount} from "svelte"
|
||||||
|
import {request} from "@welshman/net"
|
||||||
|
import {PollResponse} from "nostr-tools/kinds"
|
||||||
|
import PollVotes from "@app/components/PollVotes.svelte"
|
||||||
|
import Content from "@app/components/Content.svelte"
|
||||||
|
|
||||||
|
const props: ComponentProps<typeof Content> = $props()
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!props.url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
request({
|
||||||
|
relays: [props.url],
|
||||||
|
filters: [{kinds: [PollResponse], "#e": [props.event.id]}],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<Content event={props.event} showEntire url={props.url} />
|
||||||
|
|
||||||
|
{#if props.url}
|
||||||
|
<PollVotes url={props.url} event={props.event} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
|
||||||
|
import {makeEvent} from "@welshman/util"
|
||||||
|
import {publishThunk} from "@welshman/app"
|
||||||
|
import {Poll} from "nostr-tools/kinds"
|
||||||
|
import {isMobile, preventDefault} from "@lib/html"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import HamburgerMenu from "@assets/icons/hamburger-menu.svg?dataurl"
|
||||||
|
import PlusCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
|
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Field from "@lib/components/Field.svelte"
|
||||||
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
|
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import {pushToast} from "@app/util/toast"
|
||||||
|
import {PROTECTED} from "@app/core/state"
|
||||||
|
import {canEnforceNip70} from "@app/core/commands"
|
||||||
|
import {DraftKey} from "@app/util/drafts"
|
||||||
|
import type {PollType} from "@app/util/polls"
|
||||||
|
|
||||||
|
type Option = {
|
||||||
|
id: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Values = {
|
||||||
|
title: string
|
||||||
|
pollType: PollType
|
||||||
|
endsAt?: number
|
||||||
|
options: Option[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
h?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, h}: Props = $props()
|
||||||
|
const draftKey = new DraftKey<Values>(`poll:${url}:${h ?? ""}`)
|
||||||
|
const initialValues = draftKey.get()
|
||||||
|
|
||||||
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const addOption = () => {
|
||||||
|
options = [...options, {id: randomId(), value: ""}]
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeOption = (id: string) => {
|
||||||
|
options = options.filter(option => option.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateOption = (id: string, value: string) => {
|
||||||
|
options = options.map(option => (option.id === id ? {...option, value} : option))
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorderOptions = (targetId: string) => {
|
||||||
|
if (!draggedOptionId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceIndex = options.findIndex(option => option.id === draggedOptionId)
|
||||||
|
const targetIndex = options.findIndex(option => option.id === targetId)
|
||||||
|
|
||||||
|
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options = insertAt(targetIndex, options[sourceIndex], removeAt(sourceIndex, options))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragStart = (e: DragEvent, id: string) => {
|
||||||
|
draggedOptionId = id
|
||||||
|
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.effectAllowed = "move"
|
||||||
|
e.dataTransfer.setData("text/plain", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragOver = (e: DragEvent, targetId: string) => {
|
||||||
|
e.preventDefault()
|
||||||
|
reorderOptions(targetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrop = (e: DragEvent, targetId: string) => {
|
||||||
|
e.preventDefault()
|
||||||
|
reorderOptions(targetId)
|
||||||
|
draggedOptionId = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragEnd = () => {
|
||||||
|
draggedOptionId = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
return pushToast({theme: "error", message: "Please provide a title for your poll."})
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonEmptyOptions = removeUndefined(options.map(option => option.value.trim() || undefined))
|
||||||
|
|
||||||
|
if (nonEmptyOptions.length < 2) {
|
||||||
|
return pushToast({theme: "error", message: "Please provide at least two options."})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endsAt && endsAt <= now()) {
|
||||||
|
return pushToast({theme: "error", message: "End time must be in the future."})
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags: string[][] = [
|
||||||
|
...nonEmptyOptions.map(option => ["option", randomId(), option]),
|
||||||
|
["polltype", pollType],
|
||||||
|
["relay", url],
|
||||||
|
]
|
||||||
|
|
||||||
|
if (endsAt) {
|
||||||
|
tags.push(["endsAt", String(endsAt)])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (h) {
|
||||||
|
tags.push(["h", h])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await shouldProtect) {
|
||||||
|
tags.push(PROTECTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
publishThunk({
|
||||||
|
relays: [url],
|
||||||
|
event: makeEvent(Poll, {content: title.trim(), tags}),
|
||||||
|
})
|
||||||
|
|
||||||
|
draftKey.clear()
|
||||||
|
history.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
let draggedOptionId = $state<string | undefined>()
|
||||||
|
let title = $state(initialValues?.title ?? "")
|
||||||
|
let pollType = $state<PollType>(initialValues?.pollType ?? "singlechoice")
|
||||||
|
let endsAt = $state<number | undefined>(initialValues?.endsAt)
|
||||||
|
let options = $state<Option[]>(
|
||||||
|
initialValues?.options ?? [
|
||||||
|
{id: randomId(), value: "Yes"},
|
||||||
|
{id: randomId(), value: "No"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
draftKey.set({title, pollType, endsAt, options})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||||
|
<ModalBody>
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>Create a Poll</ModalTitle>
|
||||||
|
<ModalSubtitle>Ask a question and collect votes right in the feed.</ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
<div class="col-8 relative">
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Question*</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input
|
||||||
|
autofocus={!isMobile}
|
||||||
|
bind:value={title}
|
||||||
|
class="grow"
|
||||||
|
type="text"
|
||||||
|
placeholder="What would you like to ask?" />
|
||||||
|
</label>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Options*</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<div class="flex flex-col gap-2" role="list">
|
||||||
|
{#each options as option, index (option.id)}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
draggable="true"
|
||||||
|
role="listitem"
|
||||||
|
ondragstart={e => onDragStart(e, option.id)}
|
||||||
|
ondragover={e => onDragOver(e, option.id)}
|
||||||
|
ondrop={e => onDrop(e, option.id)}
|
||||||
|
ondragend={onDragEnd}>
|
||||||
|
<div class="cursor-move opacity-70" aria-label="Drag handle">
|
||||||
|
<Icon icon={HamburgerMenu} size={4} />
|
||||||
|
</div>
|
||||||
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={option.value}
|
||||||
|
class="grow"
|
||||||
|
type="text"
|
||||||
|
placeholder={`Option ${index + 1}`}
|
||||||
|
oninput={e => updateOption(option.id, e.currentTarget.value)} />
|
||||||
|
</label>
|
||||||
|
<Button class="btn btn-ghost btn-sm" onclick={() => removeOption(option.id)}>
|
||||||
|
<Icon icon={MinusCircle} size={4} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<Button class="btn btn-outline btn-sm self-end" onclick={addOption}>
|
||||||
|
<Icon icon={PlusCircle} size={4} />
|
||||||
|
Add option
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
Poll type
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<select class="select select-bordered w-full max-w-xs" bind:value={pollType}>
|
||||||
|
<option value="singlechoice">Single choice</option>
|
||||||
|
<option value="multiplechoice">Multiple choice</option>
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
Ends at
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<DateTimeInput bind:value={endsAt} />
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="btn btn-link" onclick={back}>
|
||||||
|
<Icon icon={AltArrowLeft} />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" class="btn btn-primary">Create Poll</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {getTagValue} from "@welshman/util"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import NoteContent from "@app/components/NoteContent.svelte"
|
||||||
|
import CommentActions from "@app/components/CommentActions.svelte"
|
||||||
|
import RoomLink from "@app/components/RoomLink.svelte"
|
||||||
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
|
import {makePollPath} from "@app/util/routes"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
|
const h = getTagValue("h", event.tags)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-md"
|
||||||
|
href={makePollPath(url, event.id)}>
|
||||||
|
<NoteContent {event} {url} />
|
||||||
|
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||||
|
<span class="whitespace-nowrap py-1 text-sm opacity-75">
|
||||||
|
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
||||||
|
{#if h}
|
||||||
|
in <RoomLink {url} {h} />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<CommentActions segment="polls" showActivity {url} {event} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {tweened} from "svelte/motion"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {noop} from "@welshman/lib"
|
||||||
|
import {stopPropagation} from "@lib/html"
|
||||||
|
import {getPollType, isPollClosed} from "@app/util/polls"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
event: TrustedEvent
|
||||||
|
option: {id: string; label: string}
|
||||||
|
results: {voters: number; options: {id: string; votes: number}[]}
|
||||||
|
selectedIds: string[]
|
||||||
|
setSingleChoice: (id: string) => void
|
||||||
|
toggleMultipleChoice: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const {event, option, results, selectedIds, setSingleChoice, toggleMultipleChoice}: Props =
|
||||||
|
$props()
|
||||||
|
|
||||||
|
const pollType = getPollType(event)
|
||||||
|
const closed = isPollClosed(event)
|
||||||
|
|
||||||
|
const selected = $derived(
|
||||||
|
pollType === "singlechoice" ? selectedIds[0] === option.id : selectedIds.includes(option.id),
|
||||||
|
)
|
||||||
|
const onselect = () =>
|
||||||
|
pollType === "singlechoice" ? setSingleChoice(option.id) : toggleMultipleChoice(option.id)
|
||||||
|
|
||||||
|
const votes = $derived(results.options.find(r => r.id === option.id)?.votes || 0)
|
||||||
|
const maxVotes = $derived(Math.max(...results.options.map(r => r.votes), 1))
|
||||||
|
|
||||||
|
const tweenedVotes = tweened(votes, {duration: 300})
|
||||||
|
const tweenedMax = tweened(maxVotes, {duration: 300})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
tweenedVotes.set(votes)
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
tweenedMax.set(maxVotes)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 card2 card2-sm bg-alt">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<label class="flex min-w-0 grow items-center gap-2">
|
||||||
|
{#if !closed}
|
||||||
|
{#if pollType === "singlechoice"}
|
||||||
|
<input
|
||||||
|
name={event.id}
|
||||||
|
type="radio"
|
||||||
|
class="radio radio-primary radio-sm"
|
||||||
|
checked={selected}
|
||||||
|
onclick={stopPropagation(noop)}
|
||||||
|
onchange={onselect} />
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-primary checkbox-sm"
|
||||||
|
checked={selected}
|
||||||
|
onclick={stopPropagation(noop)}
|
||||||
|
onchange={onselect} />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<span class="truncate">{option.label}</span>
|
||||||
|
</label>
|
||||||
|
<span class="whitespace-nowrap text-xs opacity-75">{votes} vote{votes === 1 ? "" : "s"}</span>
|
||||||
|
</div>
|
||||||
|
<progress class="progress progress-primary" value={$tweenedVotes} max={$tweenedMax}></progress>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onDestroy} from "svelte"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {pubkey, publishThunk, abortThunk} from "@welshman/app"
|
||||||
|
import {PollResponse} from "nostr-tools/kinds"
|
||||||
|
import {formatTimestampRelative} from "@welshman/lib"
|
||||||
|
import {deriveEvents} from "@app/core/state"
|
||||||
|
import {pushToast} from "@app/util/toast"
|
||||||
|
import {makePollResponse} from "@app/core/commands"
|
||||||
|
import PollOption from "@app/components/PollOption.svelte"
|
||||||
|
import {
|
||||||
|
getPollEndsAt,
|
||||||
|
getPollOptions,
|
||||||
|
getPollResponseSelections,
|
||||||
|
getPollResults,
|
||||||
|
getPollType,
|
||||||
|
isPollClosed,
|
||||||
|
} from "@app/util/polls"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
|
const responses = deriveEvents([{kinds: [PollResponse], "#e": [event.id]}])
|
||||||
|
|
||||||
|
const pollType = getPollType(event)
|
||||||
|
const options = getPollOptions(event)
|
||||||
|
const closed = isPollClosed(event)
|
||||||
|
const endsAt = getPollEndsAt(event)
|
||||||
|
const publishDelay = pollType === "multiplechoice" ? 10_000 : undefined
|
||||||
|
|
||||||
|
const getOwnResponse = (responses: TrustedEvent[]) => {
|
||||||
|
let latest: TrustedEvent | undefined
|
||||||
|
|
||||||
|
for (const response of responses) {
|
||||||
|
if (response.pubkey !== $pubkey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!latest || response.created_at > latest.created_at) {
|
||||||
|
latest = response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishSelection = (selection: string[]) => {
|
||||||
|
if (activeThunk) {
|
||||||
|
abortThunk(activeThunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.length === 0) {
|
||||||
|
activeThunk = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeThunk = publishThunk({
|
||||||
|
relays: [url],
|
||||||
|
event: makePollResponse({event, selectedIds: selection}),
|
||||||
|
delay: publishDelay,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishCurrentSelection = () => {
|
||||||
|
const selection = pollType === "singlechoice" ? selectedIds.slice(0, 1) : selectedIds
|
||||||
|
|
||||||
|
if (selection.length === 0) {
|
||||||
|
return pushToast({theme: "error", message: "Please select at least one option."})
|
||||||
|
}
|
||||||
|
|
||||||
|
publishSelection(selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = $derived(getPollResults(event, $responses))
|
||||||
|
const ownResponse = $derived(getOwnResponse($responses))
|
||||||
|
|
||||||
|
const setSingleChoice = (id: string) => {
|
||||||
|
selectedIds = [id]
|
||||||
|
publishCurrentSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMultipleChoice = (id: string) => {
|
||||||
|
selectedIds = selectedIds.includes(id)
|
||||||
|
? selectedIds.filter(selectedId => selectedId !== id)
|
||||||
|
: [...selectedIds, id]
|
||||||
|
|
||||||
|
publishCurrentSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedIds = $state<string[]>([])
|
||||||
|
let activeThunk: ReturnType<typeof publishThunk> | undefined
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (ownResponse) {
|
||||||
|
selectedIds = getPollResponseSelections(ownResponse, pollType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (activeThunk) {
|
||||||
|
abortThunk(activeThunk)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each options as option (option.id)}
|
||||||
|
<PollOption {event} {option} {results} {selectedIds} {setSingleChoice} {toggleMultipleChoice} />
|
||||||
|
{/each}
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div class="text-sm opacity-75">
|
||||||
|
{pollType === "multiplechoice" ? "Multiple choice" : "Single choice"}
|
||||||
|
{#if endsAt}
|
||||||
|
{#if closed}
|
||||||
|
• Ended {formatTimestampRelative(endsAt)}
|
||||||
|
{:else}
|
||||||
|
• Ends {formatTimestampRelative(endsAt)}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm opacity-75">{results.voters} vote{results.voters === 1 ? "" : "s"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import {userProfile} from "@welshman/app"
|
import {userProfile} from "@welshman/app"
|
||||||
import Letter from "@assets/icons/letter.svg?dataurl"
|
import Letter from "@assets/icons/letter.svg?dataurl"
|
||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
import Planet from "@assets/icons/planet-3.svg?dataurl"
|
import Widget from "@assets/icons/widget-4.svg?dataurl"
|
||||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||||
import Settings from "@assets/icons/settings.svg?dataurl"
|
import Settings from "@assets/icons/settings.svg?dataurl"
|
||||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
|
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {notifications} from "@app/util/notifications"
|
import {notifications} from "@app/util/notifications"
|
||||||
import {goToChat} from "@app/util/routes"
|
import {goToChat, makeSpacePath} from "@app/util/routes"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children?: Snippet
|
children?: Snippet
|
||||||
@@ -26,22 +26,20 @@
|
|||||||
|
|
||||||
const showSettingsMenu = () => pushModal(MenuSettings)
|
const showSettingsMenu = () => pushModal(MenuSettings)
|
||||||
|
|
||||||
const anySpaceNotifications = $derived($userSpaceUrls.some(p => $notifications.has(p)))
|
const anySpaceNotifications = $derived(
|
||||||
|
$userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
|
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 shrink-0 bg-base-200 pt-2 md:block">
|
||||||
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
|
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
|
||||||
<PrimaryNavSpaces />
|
<PrimaryNavSpaces />
|
||||||
{#if PLATFORM_RELAYS.length > 0}
|
{#if PLATFORM_RELAYS.length > 0}
|
||||||
<Divider />
|
<Divider />
|
||||||
{/if}
|
{/if}
|
||||||
<div>
|
<div class="flex flex-col">
|
||||||
<PrimaryNavItem
|
<PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings">
|
||||||
title="Settings"
|
|
||||||
href="/settings/profile"
|
|
||||||
prefix="/settings"
|
|
||||||
class="tooltip-right">
|
|
||||||
{#if $userProfile?.picture}
|
{#if $userProfile?.picture}
|
||||||
<ImageIcon alt="Settings" src={$userProfile?.picture} class="rounded-full" size={10} />
|
<ImageIcon alt="Settings" src={$userProfile?.picture} class="rounded-full" size={10} />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -51,11 +49,10 @@
|
|||||||
<PrimaryNavItem
|
<PrimaryNavItem
|
||||||
title="Messages"
|
title="Messages"
|
||||||
onclick={chatHandler}
|
onclick={chatHandler}
|
||||||
class="tooltip-right"
|
|
||||||
notification={$notifications.has("/chat")}>
|
notification={$notifications.has("/chat")}>
|
||||||
<ImageIcon alt="Messages" src={Letter} size={8} />
|
<ImageIcon alt="Messages" src={Letter} size={8} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
<PrimaryNavItem title="Search" href="/people" class="tooltip-right">
|
<PrimaryNavItem title="Search" href="/people">
|
||||||
<ImageIcon alt="Search" src={Magnifier} size={8} />
|
<ImageIcon alt="Search" src={Magnifier} size={8} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,11 +62,10 @@
|
|||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|
||||||
<!-- a little extra something for ios -->
|
<!-- a little extra something for ios -->
|
||||||
<div
|
<div class="hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-(--saib) bg-base-100 md:hidden">
|
||||||
class="bottom-nav hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden">
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="bottom-nav hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
|
class="hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
|
||||||
<div class="content-padding-x content-sizing flex justify-between px-2">
|
<div class="content-padding-x content-sizing flex justify-between px-2">
|
||||||
<div class="flex gap-2 sm:gap-6">
|
<div class="flex gap-2 sm:gap-6">
|
||||||
<PrimaryNavItem title="Search" href="/people">
|
<PrimaryNavItem title="Search" href="/people">
|
||||||
@@ -84,7 +80,7 @@
|
|||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
{#if PLATFORM_RELAYS.length !== 1}
|
{#if PLATFORM_RELAYS.length !== 1}
|
||||||
<PrimaryNavItem title="Spaces" href="/spaces" notification={anySpaceNotifications}>
|
<PrimaryNavItem title="Spaces" href="/spaces" notification={anySpaceNotifications}>
|
||||||
<ImageIcon alt="Spaces" src={Planet} size={8} />
|
<ImageIcon alt="Spaces" src={Widget} size={8} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
import {deriveRelayDisplay} from "@welshman/app"
|
||||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||||
import RelayIcon from "@app/components/RelayIcon.svelte"
|
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||||
import {makeSpacePath, goToSpace} from "@app/util/routes"
|
import {makeSpacePath, goToSpace} from "@app/util/routes"
|
||||||
@@ -12,11 +12,13 @@
|
|||||||
const {url}: Props = $props()
|
const {url}: Props = $props()
|
||||||
|
|
||||||
const onClick = () => goToSpace(url)
|
const onClick = () => goToSpace(url)
|
||||||
|
|
||||||
|
const display = $derived(deriveRelayDisplay(url))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PrimaryNavItem
|
<PrimaryNavItem
|
||||||
onclick={onClick}
|
onclick={onClick}
|
||||||
title={displayRelayUrl(url)}
|
title={$display}
|
||||||
class="tooltip-right"
|
class="tooltip-right"
|
||||||
notification={$notifications.has(makeSpacePath(url))}>
|
notification={$notifications.has(makeSpacePath(url))}>
|
||||||
<RelayIcon {url} size={10} class="rounded-full" />
|
<RelayIcon {url} size={10} class="rounded-full" />
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {splitAt} from "@welshman/lib"
|
import {splitAt} from "@welshman/lib"
|
||||||
import Widget from "@assets/icons/widget.svg?dataurl"
|
import Widget from "@assets/icons/widget-4.svg?dataurl"
|
||||||
import Compass from "@assets/icons/compass.svg?dataurl"
|
|
||||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||||
import Divider from "@lib/components/Divider.svelte"
|
import Divider from "@lib/components/Divider.svelte"
|
||||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||||
@@ -13,7 +12,7 @@
|
|||||||
|
|
||||||
const itemHeight = 56
|
const itemHeight = 56
|
||||||
const navPadding = 8 * itemHeight
|
const navPadding = 8 * itemHeight
|
||||||
const itemLimit = $derived((windowHeight - navPadding) / itemHeight)
|
const itemLimit = $derived(Math.max(0, (windowHeight - navPadding) / itemHeight))
|
||||||
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
|
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
|
||||||
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
|
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
|
||||||
</script>
|
</script>
|
||||||
@@ -24,7 +23,7 @@
|
|||||||
{#each PLATFORM_RELAYS as url (url)}
|
{#each PLATFORM_RELAYS as url (url)}
|
||||||
<PrimaryNavItemSpace {url} />
|
<PrimaryNavItemSpace {url} />
|
||||||
{:else}
|
{:else}
|
||||||
<PrimaryNavItem title="Home" href="/home" class="tooltip-right">
|
<PrimaryNavItem title="Home" href="/home">
|
||||||
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
|
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -34,12 +33,9 @@
|
|||||||
<PrimaryNavItem
|
<PrimaryNavItem
|
||||||
href="/spaces"
|
href="/spaces"
|
||||||
title="All Spaces"
|
title="All Spaces"
|
||||||
class="tooltip-right"
|
prefix="no-highlight"
|
||||||
notification={otherSpaceNotifications}>
|
notification={otherSpaceNotifications}>
|
||||||
<ImageIcon alt="All Spaces" src={Widget} size={8} />
|
<ImageIcon alt="All Spaces" src={Widget} size={8} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
<PrimaryNavItem title="Add a Space" href="/discover" class="tooltip-right">
|
|
||||||
<ImageIcon alt="Add a Space" src={Compass} size={8} />
|
|
||||||
</PrimaryNavItem>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,10 +25,10 @@
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{#each spaceUrls as url (url)}
|
{#each spaceUrls as url (url)}
|
||||||
<div class="card2 bg-alt flex flex-row items-center gap-2">
|
<div class="card2 bg-alt flex flex-row items-center gap-2">
|
||||||
<div class="flex-shrink-0">
|
<div class="shrink-0">
|
||||||
<RelayIcon {url} size={12} />
|
<RelayIcon {url} size={12} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-grow flex-col">
|
<div class="flex grow flex-col">
|
||||||
<RelayName {url} />
|
<RelayName {url} />
|
||||||
<div class="text-sm opacity-75">
|
<div class="text-sm opacity-75">
|
||||||
{url}
|
{url}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Reaction from "@app/components/Reaction.svelte"
|
import Reaction from "@app/components/Reaction.svelte"
|
||||||
import ReportDetails from "@app/components/ReportDetails.svelte"
|
import ReportDetails from "@app/components/ReportDetails.svelte"
|
||||||
import {REACTION_KINDS} from "@app/core/state"
|
import {REACTION_KINDS, deriveUserIsSpaceAdmin} from "@app/core/state"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -78,6 +78,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||||
|
|
||||||
const onReportClick = () => pushModal(ReportDetails, {url, event})
|
const onReportClick = () => pushModal(ReportDetails, {url, event})
|
||||||
|
|
||||||
const reportReasons = $derived(uniq(map(e => getTag("e", e.tags)?.[2], $reports.values())))
|
const reportReasons = $derived(uniq(map(e => getTag("e", e.tags)?.[2], $reports.values())))
|
||||||
@@ -118,7 +120,7 @@
|
|||||||
|
|
||||||
{#if $reactions.length > 0 || $zaps.length || $reports.length > 0 || children}
|
{#if $reactions.length > 0 || $zaps.length || $reports.length > 0 || children}
|
||||||
<div class="flex min-w-0 flex-wrap gap-2">
|
<div class="flex min-w-0 flex-wrap gap-2">
|
||||||
{#if url && $reports.length > 0}
|
{#if url && $reports.length > 0 && $userIsAdmin}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-tip={`This content has been reported as "${displayList(reportReasons)}".`}
|
data-tip={`This content has been reported as "${displayList(reportReasons)}".`}
|
||||||
|
|||||||
@@ -121,6 +121,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-primary flex-grow" onclick={back}>Done</Button>
|
<Button class="btn btn-primary grow" onclick={back}>Done</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn font-normal flex h-[unset] w-full flex-nowrap py-4 text-left items-start justify-between"
|
class="btn font-normal flex h-[unset] w-full flex-nowrap py-4 text-left items-start justify-between"
|
||||||
{onclick}>
|
{onclick}>
|
||||||
<div class="flex flex-grow flex-row items-start gap-4">
|
<div class="flex grow flex-row items-start gap-4">
|
||||||
<div class="flex h-7 w-7 flex-shrink-0 items-center justify-center">
|
<div class="flex h-7 w-7 shrink-0 items-center justify-center">
|
||||||
<Icon {icon} />
|
<Icon {icon} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
|
hideFavorites?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url}: Props = $props()
|
const {url, hideFavorites}: Props = $props()
|
||||||
const rooms = deriveUserRooms(url)
|
const rooms = deriveUserRooms(url)
|
||||||
const favorited = deriveGroupListPubkeys(url)
|
const favorited = deriveGroupListPubkeys(url)
|
||||||
</script>
|
</script>
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="avatar relative">
|
<div class="avatar relative">
|
||||||
<div
|
<div
|
||||||
class="center !flex h-12 w-12 min-w-12 rounded-full border-2 border-solid border-base-300 bg-base-300">
|
class="center flex! h-12 w-12 min-w-12 rounded-full border-2 border-solid border-base-300 bg-base-300">
|
||||||
<RelayIcon {url} />
|
<RelayIcon {url} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<h2 class="ellipsize whitespace-nowrap text-xl">
|
<h2 class="ellipsize whitespace-nowrap text-xl">
|
||||||
<RelayName {url} />
|
<RelayName {url} />
|
||||||
</h2>
|
</h2>
|
||||||
@@ -43,7 +44,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<RelayDescription {url} />
|
<RelayDescription {url} />
|
||||||
</div>
|
</div>
|
||||||
{#if $favorited.size > 0}
|
{#if !hideFavorites && $favorited.size > 0}
|
||||||
<div class="row-2 card2 card2-sm bg-alt">
|
<div class="row-2 card2 card2-sm bg-alt">
|
||||||
Favorited By:
|
Favorited By:
|
||||||
<ProfileCircles pubkeys={Array.from($favorited)} />
|
<ProfileCircles pubkeys={Array.from($favorited)} />
|
||||||
|
|||||||
@@ -12,18 +12,29 @@
|
|||||||
import ComposeMenu from "@app/components/ComposeMenu.svelte"
|
import ComposeMenu from "@app/components/ComposeMenu.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
|
import {DraftKey} from "@app/util/drafts"
|
||||||
import {onDestroy, onMount} from "svelte"
|
import {onDestroy, onMount} from "svelte"
|
||||||
|
|
||||||
|
type Values = {
|
||||||
|
content?: string | object
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url?: string
|
url?: string
|
||||||
h?: string
|
h?: string
|
||||||
content?: string
|
|
||||||
onEscape?: () => void
|
onEscape?: () => void
|
||||||
onEditPrevious?: () => void
|
onEditPrevious?: () => void
|
||||||
onSubmit: (event: EventContent) => void
|
onSubmit: (event: EventContent) => void
|
||||||
|
initialValues?: Values
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, h, content, onEscape, onEditPrevious, onSubmit}: Props = $props()
|
let {url, h, initialValues, onEscape, onEditPrevious, onSubmit}: Props = $props()
|
||||||
|
|
||||||
|
const draftKey = url || h ? new DraftKey<Values>(`room:${url ?? ""}:${h ?? ""}`) : undefined
|
||||||
|
|
||||||
|
if (!initialValues) {
|
||||||
|
initialValues = draftKey?.get()
|
||||||
|
}
|
||||||
|
|
||||||
const autofocus = !isMobile
|
const autofocus = !isMobile
|
||||||
|
|
||||||
@@ -61,12 +72,29 @@
|
|||||||
|
|
||||||
onSubmit({content, tags})
|
onSubmit({content, tags})
|
||||||
|
|
||||||
|
draftKey?.clear()
|
||||||
ed.chain().clearContent().run()
|
ed.chain().clearContent().run()
|
||||||
}
|
}
|
||||||
|
|
||||||
const editor = makeEditor({url, content, autofocus, submit, uploading, aggressive: true})
|
|
||||||
|
|
||||||
let popover: Instance | undefined = $state()
|
let popover: Instance | undefined = $state()
|
||||||
|
let content = $state(initialValues?.content ?? "")
|
||||||
|
|
||||||
|
const onChange = (json: object) => {
|
||||||
|
content = json
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = makeEditor({
|
||||||
|
url,
|
||||||
|
content,
|
||||||
|
submit,
|
||||||
|
uploading,
|
||||||
|
onChange,
|
||||||
|
aggressive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
draftKey?.set({content})
|
||||||
|
})
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const ed = await editor
|
const ed = await editor
|
||||||
@@ -104,8 +132,8 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</Tippy>
|
</Tippy>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-editor flex-grow overflow-hidden">
|
<div class="chat-editor grow overflow-hidden">
|
||||||
<EditorContent {editor} />
|
<EditorContent {autofocus} {editor} />
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Popover from "@lib/components/Popover.svelte"
|
import Popover from "@lib/components/Popover.svelte"
|
||||||
import Confirm from "@lib/components/Confirm.svelte"
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
|
import Tooltip from "@lib/components/Tooltip.svelte"
|
||||||
import Modal from "@lib/components/Modal.svelte"
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
@@ -206,39 +207,39 @@
|
|||||||
<strong class="text-lg">Room Permissions</strong>
|
<strong class="text-lg">Room Permissions</strong>
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
{#if $room?.isRestricted}
|
{#if $room?.isRestricted}
|
||||||
<Button
|
<Tooltip content="Only members can send messages.">
|
||||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||||
data-tip="Only members can send messages.">
|
<Icon size={4} icon={Microphone} /> Restricted
|
||||||
<Icon size={4} icon={Microphone} /> Restricted
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $room?.isPrivate}
|
{#if $room?.isPrivate}
|
||||||
<Button
|
<Tooltip content="Only members can view messages.">
|
||||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||||
data-tip="Only members can view messages.">
|
<Icon size={4} icon={Lock} /> Private
|
||||||
<Icon size={4} icon={Lock} /> Private
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $room?.isHidden}
|
{#if $room?.isHidden}
|
||||||
<Button
|
<Tooltip content="This room is not visible to non-members.">
|
||||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||||
data-tip="This room is not visible to non-members.">
|
<Icon size={4} icon={EyeClosed} /> Hidden
|
||||||
<Icon size={4} icon={EyeClosed} /> Hidden
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $room?.isClosed}
|
{#if $room?.isClosed}
|
||||||
<Button
|
<Tooltip content="Requests to join this room will be ignored.">
|
||||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||||
data-tip="Requests to join this room will be ignored.">
|
<Icon size={4} icon={MinusCircle} /> Closed
|
||||||
<Icon size={4} icon={MinusCircle} /> Closed
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !$room?.isRestricted && !$room?.isPrivate && !$room?.isHidden && !$room?.isClosed}
|
{#if !$room?.isRestricted && !$room?.isPrivate && !$room?.isHidden && !$room?.isClosed}
|
||||||
<Button
|
<Tooltip content="This room has no additional access controls.">
|
||||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||||
data-tip="This room has no additional access controls.">
|
<Icon size={4} icon={Eye} /> Public
|
||||||
<Icon size={4} icon={Eye} /> Public
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -131,7 +131,7 @@
|
|||||||
<p>Icon</p>
|
<p>Icon</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="flex flex-grow items-center justify-between gap-4">
|
<div class="flex grow items-center justify-between gap-4">
|
||||||
{#if imagePreview}
|
{#if imagePreview}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm opacity-75">Selected:</span>
|
<span class="text-sm opacity-75">Selected:</span>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
replyTo?: (event: TrustedEvent) => void
|
replyTo?: (event: TrustedEvent) => void
|
||||||
showPubkey?: boolean
|
showPubkey?: boolean
|
||||||
|
addSpaceBelow?: boolean
|
||||||
inert?: boolean
|
inert?: boolean
|
||||||
canEdit: (event: TrustedEvent) => boolean
|
canEdit: (event: TrustedEvent) => boolean
|
||||||
onEdit: (event: TrustedEvent) => void
|
onEdit: (event: TrustedEvent) => void
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
event,
|
event,
|
||||||
replyTo = undefined,
|
replyTo = undefined,
|
||||||
showPubkey = false,
|
showPubkey = false,
|
||||||
|
addSpaceBelow = false,
|
||||||
inert = false,
|
inert = false,
|
||||||
canEdit,
|
canEdit,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -77,19 +79,22 @@
|
|||||||
<TapTarget
|
<TapTarget
|
||||||
data-event={event.id}
|
data-event={event.id}
|
||||||
onTap={inert ? null : onTap}
|
onTap={inert ? null : onTap}
|
||||||
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left hover:bg-base-100/50">
|
class={cx(
|
||||||
|
"group relative flex w-full cursor-default flex-col px-2 py-0.5 text-left hover:bg-base-100/50",
|
||||||
|
{"mt-1.5": showPubkey, "mb-1.5": addSpaceBelow},
|
||||||
|
)}>
|
||||||
<div class="flex w-full gap-3 overflow-auto">
|
<div class="flex w-full gap-3 overflow-auto">
|
||||||
{#if showPubkey}
|
{#if showPubkey}
|
||||||
<Button onclick={openProfile} class="flex items-start">
|
<Button onclick={openProfile} class="flex items-start pt-1.5 justify-center w-8 shrink-0">
|
||||||
<ProfileCircle
|
<ProfileCircle
|
||||||
pubkey={event.pubkey}
|
pubkey={event.pubkey}
|
||||||
class="border border-solid border-base-content"
|
class="border border-solid border-base-content"
|
||||||
size={8} />
|
size={8} />
|
||||||
</Button>
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="w-8 min-w-8 max-w-8"></div>
|
<div class="w-8 shrink-0"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="min-w-0 flex-grow pr-1">
|
<div class="min-w-0 grow pr-1">
|
||||||
{#if showPubkey}
|
{#if showPubkey}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
|
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
|
||||||
@@ -140,7 +145,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if !isMobile}
|
{#if !isMobile}
|
||||||
<button
|
<button
|
||||||
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
|
class="join absolute right-2 top-0.5 border border-solid border-neutral text-xs opacity-0 transition-all pr-2"
|
||||||
class:group-hover:opacity-100={!isMobile}>
|
class:group-hover:opacity-100={!isMobile}>
|
||||||
{#if ENABLE_ZAPS}
|
{#if ENABLE_ZAPS}
|
||||||
<RoomItemZapButton {url} {event} />
|
<RoomItemZapButton {url} {event} />
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {getPubkeyTagValues} from "@welshman/util"
|
|
||||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
url: string
|
|
||||||
event: TrustedEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
const {url, event}: Props = $props()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#each getPubkeyTagValues(event.tags) as pubkey}
|
|
||||||
<div class="py-1 text-center text-xs opacity-75">
|
|
||||||
<ProfileLink unstyled class="text-primary" {url} {pubkey} /> left the room
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
const {url, h, ...props}: Props = $props()
|
const {url, h, ...props}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-grow items-center justify-between gap-4 {props.class}">
|
<div class="flex grow items-center justify-between gap-4 {props.class}">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<RoomImage {url} {h} />
|
<RoomImage {url} {h} />
|
||||||
<div class="min-w-0 overflow-hidden text-ellipsis">
|
<div class="min-w-0 overflow-hidden text-ellipsis">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Login from "@assets/icons/login-3.svg?dataurl"
|
import Login from "@assets/icons/login-3.svg?dataurl"
|
||||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
import Compass from "@assets/icons/compass.svg?dataurl"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
@@ -14,12 +13,6 @@
|
|||||||
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
|
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
type Props = {
|
|
||||||
hideDiscover?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const {hideDiscover}: Props = $props()
|
|
||||||
|
|
||||||
const startJoin = () => pushModal(SpaceInviteAccept)
|
const startJoin = () => pushModal(SpaceInviteAccept)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -30,23 +23,8 @@
|
|||||||
<ModalSubtitle
|
<ModalSubtitle
|
||||||
>Spaces are places where communities come together to work, play, and hang out.</ModalSubtitle>
|
>Spaces are places where communities come together to work, play, and hang out.</ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
{#if !hideDiscover}
|
|
||||||
<Link href="/discover">
|
|
||||||
<CardButton class="btn-primary">
|
|
||||||
{#snippet icon()}
|
|
||||||
<div><Icon icon={Compass} size={7} /></div>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet title()}
|
|
||||||
<div>Explore Spaces</div>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet info()}
|
|
||||||
<div>Join create, or browse spaces</div>
|
|
||||||
{/snippet}
|
|
||||||
</CardButton>
|
|
||||||
</Link>
|
|
||||||
{/if}
|
|
||||||
<Button onclick={startJoin}>
|
<Button onclick={startJoin}>
|
||||||
<CardButton class={hideDiscover ? "btn-primary" : "btn-neutral"}>
|
<CardButton class="btn-primary">
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<div><Icon icon={Login} size={7} /></div>
|
<div><Icon icon={Login} size={7} /></div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<Button onclick={back} class="place-self-start pr-3 md:hidden">
|
<Button onclick={back} class="place-self-start pr-3 md:hidden">
|
||||||
<Icon icon={ArrowLeft} size={7} />
|
<Icon icon={ArrowLeft} size={7} />
|
||||||
</Button>
|
</Button>
|
||||||
<div class="ellipsize whitespace-nowrap flex flex-grow items-center justify-between gap-4">
|
<div class="ellipsize whitespace-nowrap flex grow items-center justify-between gap-4">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
{@render title?.()}
|
{@render title?.()}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="avatar relative">
|
<div class="avatar relative">
|
||||||
<div
|
<div
|
||||||
class="center !flex h-16 w-16 min-w-16 rounded-full border-2 border-solid border-base-300 bg-base-300">
|
class="center flex! h-16 w-16 min-w-16 rounded-full border-2 border-solid border-base-300 bg-base-300">
|
||||||
<RelayIcon {url} size={10} />
|
<RelayIcon {url} size={10} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import {displayRelayUrl, ManagementMethod} from "@welshman/util"
|
import {displayRelayUrl, ManagementMethod} from "@welshman/util"
|
||||||
import {manageRelay, forceLoadRelay} from "@welshman/app"
|
import {manageRelay, forceLoadRelay} from "@welshman/app"
|
||||||
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
|
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
|
||||||
import Planet from "@assets/icons/planet-3.svg?dataurl"
|
import Widget from "@assets/icons/widget-4.svg?dataurl"
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
|
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
<p>Icon</p>
|
<p>Icon</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="flex items-center gap-4 justify-between flex-grow">
|
<div class="flex items-center gap-4 justify-between grow">
|
||||||
{#if imagePreview}
|
{#if imagePreview}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm opacity-75">Selected:</span>
|
<span class="text-sm opacity-75">Selected:</span>
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
{#if imagePreview}
|
{#if imagePreview}
|
||||||
<ImageIcon src={imagePreview} alt="" />
|
<ImageIcon src={imagePreview} alt="" />
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon={Planet} />
|
<Icon icon={Widget} />
|
||||||
{/if}
|
{/if}
|
||||||
<input bind:value={values.name} class="grow" type="text" />
|
<input bind:value={values.name} class="grow" type="text" />
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -100,6 +100,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-primary flex-grow" onclick={back}>Done</Button>
|
<Button class="btn btn-primary grow" onclick={back}>Done</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {derived} from "svelte/store"
|
import {derived} from "svelte/store"
|
||||||
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
||||||
import {deriveRelay, createSearch, pubkey} from "@welshman/app"
|
import {Poll} from "nostr-tools/kinds"
|
||||||
|
import {deriveRelay, deriveRelayDisplay, createSearch, pubkey} from "@welshman/app"
|
||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||||
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
||||||
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
||||||
|
import Revote from "@assets/icons/revote.svg?dataurl"
|
||||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||||
@@ -64,11 +66,13 @@
|
|||||||
const {url} = $props()
|
const {url} = $props()
|
||||||
|
|
||||||
const relay = deriveRelay(url)
|
const relay = deriveRelay(url)
|
||||||
|
const display = deriveRelayDisplay(url)
|
||||||
const chatPath = makeSpacePath(url, "chat")
|
const chatPath = makeSpacePath(url, "chat")
|
||||||
const goalsPath = makeSpacePath(url, "goals")
|
const goalsPath = makeSpacePath(url, "goals")
|
||||||
const threadsPath = makeSpacePath(url, "threads")
|
const threadsPath = makeSpacePath(url, "threads")
|
||||||
const classifiedsPath = makeSpacePath(url, "classifieds")
|
const classifiedsPath = makeSpacePath(url, "classifieds")
|
||||||
const calendarPath = makeSpacePath(url, "calendar")
|
const calendarPath = makeSpacePath(url, "calendar")
|
||||||
|
const pollsPath = makeSpacePath(url, "polls")
|
||||||
const userRooms = deriveUserRooms(url)
|
const userRooms = deriveUserRooms(url)
|
||||||
const otherRooms = deriveOtherRooms(url)
|
const otherRooms = deriveOtherRooms(url)
|
||||||
const otherVoiceRooms = deriveOtherVoiceRooms(url)
|
const otherVoiceRooms = deriveOtherVoiceRooms(url)
|
||||||
@@ -136,12 +140,14 @@
|
|||||||
|
|
||||||
<div bind:this={element} class="flex min-h-0 flex-1 flex-col">
|
<div bind:this={element} class="flex min-h-0 flex-1 flex-col">
|
||||||
<SecondaryNavSection class="min-h-0 flex-1 flex flex-col overflow-hidden pb-0">
|
<SecondaryNavSection class="min-h-0 flex-1 flex flex-col overflow-hidden pb-0">
|
||||||
<div class="flex-shrink-0">
|
<div class="shrink-0">
|
||||||
<Button
|
<Button
|
||||||
class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
|
class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
|
||||||
onclick={openMenu}>
|
onclick={openMenu}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<strong class="flex items-center gap-1 relative">
|
<strong
|
||||||
|
class="flex items-center gap-1 relative tooltip tooltip-right"
|
||||||
|
data-tip={$display}>
|
||||||
<RelayName {url} class="ellipsize" />
|
<RelayName {url} class="ellipsize" />
|
||||||
<div
|
<div
|
||||||
class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0"
|
class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0"
|
||||||
@@ -257,16 +263,21 @@
|
|||||||
<Icon icon={CalendarMinimalistic} /> Calendar
|
<Icon icon={CalendarMinimalistic} /> Calendar
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if $spaceKinds.has(Poll)}
|
||||||
|
<SecondaryNavItem href={pollsPath}>
|
||||||
|
<Icon icon={Revote} /> Polls
|
||||||
|
</SecondaryNavItem>
|
||||||
|
{/if}
|
||||||
{#if hasNip29($relay)}
|
{#if hasNip29($relay)}
|
||||||
{#if $userRooms.length > 0}
|
{#if $userRooms.length > 0}
|
||||||
<div class="h-2 flex-shrink-0"></div>
|
<div class="h-2 shrink-0"></div>
|
||||||
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
|
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
|
||||||
{/if}
|
{/if}
|
||||||
{#each $userRooms as h (h)}
|
{#each $userRooms as h (h)}
|
||||||
<SpaceMenuRoomItem {url} {h} />
|
<SpaceMenuRoomItem {url} {h} />
|
||||||
{/each}
|
{/each}
|
||||||
{#if $otherRooms.length > 0}
|
{#if $otherRooms.length > 0}
|
||||||
<div class="h-2 flex-shrink-0"></div>
|
<div class="h-2 shrink-0"></div>
|
||||||
<SecondaryNavHeader>
|
<SecondaryNavHeader>
|
||||||
{#if $userRooms.length > 0}
|
{#if $userRooms.length > 0}
|
||||||
Other Rooms
|
Other Rooms
|
||||||
@@ -285,7 +296,7 @@
|
|||||||
<SpaceMenuRoomItem {url} {h} />
|
<SpaceMenuRoomItem {url} {h} />
|
||||||
{/each}
|
{/each}
|
||||||
{#if $otherVoiceRooms.length > 0}
|
{#if $otherVoiceRooms.length > 0}
|
||||||
<div class="h-2 flex-shrink-0"></div>
|
<div class="h-2 shrink-0"></div>
|
||||||
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
|
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
|
||||||
{#each $otherVoiceRooms as h (h)}
|
{#each $otherVoiceRooms as h (h)}
|
||||||
<SpaceMenuRoomItem {url} {h} />
|
<SpaceMenuRoomItem {url} {h} />
|
||||||
@@ -298,11 +309,11 @@
|
|||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<div class="h-5 flex-shrink-0"></div>
|
<div class="h-5 shrink-0"></div>
|
||||||
</div>
|
</div>
|
||||||
</SecondaryNavSection>
|
</SecondaryNavSection>
|
||||||
<div
|
<div
|
||||||
class="flex flex-shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+3rem)] md:pb-2 z-nav">
|
class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+0.25rem)] md:pb-2 z-nav">
|
||||||
<VoiceWidget />
|
<VoiceWidget />
|
||||||
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
|
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
|
||||||
<SocketStatusIndicator {url} />
|
<SocketStatusIndicator {url} />
|
||||||
|
|||||||
@@ -24,12 +24,13 @@
|
|||||||
const shouldNotifyForRoom = deriveShouldNotify(url, h)
|
const shouldNotifyForRoom = deriveShouldNotify(url, h)
|
||||||
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
|
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
|
||||||
const notification = $derived($shouldNotifyForRoom ? $notifications.has(path) : false)
|
const notification = $derived($shouldNotifyForRoom ? $notifications.has(path) : false)
|
||||||
|
const roomName = $derived($room?.name || h)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if roomType === RoomType.Voice}
|
{#if roomType === RoomType.Voice}
|
||||||
<VoiceRoomItem {url} {h} {replaceState} {notification} />
|
<VoiceRoomItem {url} {h} {replaceState} {notification} />
|
||||||
{:else}
|
{:else}
|
||||||
<SecondaryNavItem href={path} {replaceState} {notification}>
|
<SecondaryNavItem href={path} title={roomName} {replaceState} {notification}>
|
||||||
<RoomNameWithImage {url} {h} />
|
<RoomNameWithImage {url} {h} />
|
||||||
{#if showDifferenceIcon}
|
{#if showDifferenceIcon}
|
||||||
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
|
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {tick} from "svelte"
|
import {tick} from "svelte"
|
||||||
import {createSearch} from "@welshman/app"
|
import {debounce} from "throttle-debounce"
|
||||||
|
import {request} from "@welshman/net"
|
||||||
import {formatTimestampAsDate, groupBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
|
import {formatTimestampAsDate, groupBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent, Filter} from "@welshman/util"
|
||||||
import {MESSAGE} from "@welshman/util"
|
import {sortEventsDesc} from "@welshman/util"
|
||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import {deriveEventsForUrl} from "@app/core/state"
|
import {CONTENT_KINDS} from "@app/core/state"
|
||||||
import {goToEvent} from "@app/util/routes"
|
import {goToEvent} from "@app/util/routes"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -19,14 +20,16 @@
|
|||||||
|
|
||||||
const {url, h}: Props = $props()
|
const {url, h}: Props = $props()
|
||||||
|
|
||||||
const spaceMessages = deriveEventsForUrl(
|
|
||||||
url,
|
|
||||||
h ? [{kinds: [MESSAGE], "#h": [h]}] : [{kinds: [MESSAGE]}],
|
|
||||||
)
|
|
||||||
|
|
||||||
let term = $state("")
|
let term = $state("")
|
||||||
let show = $state(false)
|
let show = $state(false)
|
||||||
|
let results = $state<TrustedEvent[]>([])
|
||||||
|
let loading = $state(false)
|
||||||
let input: HTMLInputElement | undefined = $state()
|
let input: HTMLInputElement | undefined = $state()
|
||||||
|
let controller: AbortController | undefined
|
||||||
|
|
||||||
|
const relayStatus = $derived(
|
||||||
|
h ? `Searching this room on relay: ${url}.` : `Searching this space on relay: ${url}.`,
|
||||||
|
)
|
||||||
|
|
||||||
const open = () => {
|
const open = () => {
|
||||||
show = true
|
show = true
|
||||||
@@ -40,21 +43,53 @@
|
|||||||
const clear = () => {
|
const clear = () => {
|
||||||
term = ""
|
term = ""
|
||||||
show = false
|
show = false
|
||||||
|
loading = false
|
||||||
|
results = []
|
||||||
|
controller?.abort()
|
||||||
|
controller = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRelayUrls = () => [url]
|
||||||
|
|
||||||
|
const getFilter = (searchTerm: string): Filter =>
|
||||||
|
h
|
||||||
|
? {kinds: CONTENT_KINDS, "#h": [h], search: searchTerm}
|
||||||
|
: {kinds: CONTENT_KINDS, search: searchTerm}
|
||||||
|
|
||||||
|
const search = debounce(300, async (searchTerm: string) => {
|
||||||
|
controller?.abort()
|
||||||
|
|
||||||
|
if (!searchTerm.trim()) {
|
||||||
|
loading = false
|
||||||
|
results = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
controller = new AbortController()
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await request({
|
||||||
|
relays: getRelayUrls(),
|
||||||
|
autoClose: true,
|
||||||
|
signal: controller.signal,
|
||||||
|
filters: [getFilter(searchTerm.trim())],
|
||||||
|
})
|
||||||
|
|
||||||
|
results = sortEventsDesc(events)
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
||||||
|
results = []
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const onInput = () => {
|
const onInput = () => {
|
||||||
show = true
|
void search(term)
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchIndex = $derived.by(() =>
|
|
||||||
createSearch($spaceMessages, {
|
|
||||||
getValue: event => event.id,
|
|
||||||
fuseOptions: {keys: ["content"]},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const results = $derived(term ? searchIndex.searchOptions(term) : [])
|
|
||||||
|
|
||||||
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
|
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
|
||||||
|
|
||||||
const getAgeSection = (createdAt: number) => {
|
const getAgeSection = (createdAt: number) => {
|
||||||
@@ -95,73 +130,74 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
|
||||||
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
|
<Icon size={4} icon={Magnifier} />
|
||||||
<Icon size={4} icon={Magnifier} />
|
</button>
|
||||||
</button>
|
{#if show}
|
||||||
{#if show}
|
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
|
||||||
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
|
<div class="fixed top-sai right-sai left-content z-feature p-2">
|
||||||
<div class="fixed cw top-0 right-0 z-feature p-2">
|
<div
|
||||||
<div
|
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
|
||||||
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
|
transition:fly={{y: -40, duration: 150}}>
|
||||||
transition:fly={{y: -40, duration: 150}}>
|
<div class="flex justify-between">
|
||||||
<div class="flex justify-between">
|
<strong>Search</strong>
|
||||||
<strong>Search</strong>
|
<Button onclick={clear}>
|
||||||
<Button onclick={clear}>
|
<Icon icon={CloseCircle} />
|
||||||
<Icon icon={CloseCircle} />
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
<label class="input input-sm input-bordered flex w-full items-center gap-2">
|
||||||
<label class="input input-sm input-bordered flex w-full items-center gap-2">
|
<Icon size={4} icon={Magnifier} />
|
||||||
<Icon size={4} icon={Magnifier} />
|
<input
|
||||||
<input
|
bind:this={input}
|
||||||
bind:this={input}
|
bind:value={term}
|
||||||
bind:value={term}
|
class="min-w-0 grow"
|
||||||
class="min-w-0 grow"
|
type="text"
|
||||||
type="text"
|
placeholder={h ? "Search this room..." : "Search this space..."}
|
||||||
placeholder={h ? "Search this room..." : "Search this space..."}
|
oninput={onInput} />
|
||||||
oninput={onInput} />
|
</label>
|
||||||
</label>
|
<div class="max-h-[65vh] overflow-y-auto">
|
||||||
<div class="max-h-[65vh] overflow-y-auto">
|
<p class="mb-2 text-xs opacity-70">{relayStatus}</p>
|
||||||
{#if !term}
|
{#if !term}
|
||||||
<p class="text-sm opacity-70">
|
<p class="text-sm opacity-70">
|
||||||
{h ? "Search for messages in this room." : "Search for messages across this space."}
|
{h ? "Search for content in this room." : "Search for content in this space."}
|
||||||
</p>
|
</p>
|
||||||
{:else if eventsByAge.size === 0}
|
{:else if loading}
|
||||||
<p class="text-sm opacity-70">No results found.</p>
|
<p class="text-sm opacity-70">Searching...</p>
|
||||||
{:else}
|
{:else if eventsByAge.size === 0}
|
||||||
<div class="col-2">
|
<p class="text-sm opacity-70">No results found.</p>
|
||||||
{#each eventsByAge as [key, events] (key)}
|
{:else}
|
||||||
|
<div class="col-2">
|
||||||
|
{#each eventsByAge as [key, events] (key)}
|
||||||
|
<div class="col-2">
|
||||||
|
<p class="text-xs uppercase tracking-wide opacity-60">
|
||||||
|
{#if key === "day"}
|
||||||
|
Last 24 Hours
|
||||||
|
{:else if key === "week"}
|
||||||
|
Last 7 Days
|
||||||
|
{:else}
|
||||||
|
Older
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
<p class="text-xs uppercase tracking-wide opacity-60">
|
{#each events as event (event.id)}
|
||||||
{#if key === "day"}
|
<button
|
||||||
Last 24 Hours
|
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
|
||||||
{:else if key === "week"}
|
onclick={() => onRoomSearchResultClick(event)}>
|
||||||
Last 7 Days
|
<p class="line-clamp-2 text-sm">
|
||||||
{:else}
|
{event.content.trim() || "(No text content)"}
|
||||||
Older
|
</p>
|
||||||
{/if}
|
<div class="row-2 text-xs opacity-70">
|
||||||
</p>
|
<span>{getAgeLabel(event.created_at)}</span>
|
||||||
<div class="col-2">
|
<span>{formatTimestampAsDate(event.created_at)}</span>
|
||||||
{#each events as event (event.id)}
|
</div>
|
||||||
<button
|
</button>
|
||||||
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
|
{/each}
|
||||||
onclick={() => onRoomSearchResultClick(event)}>
|
|
||||||
<p class="line-clamp-2 text-sm">
|
|
||||||
{event.content.trim() || "(No text content)"}
|
|
||||||
</p>
|
|
||||||
<div class="row-2 text-xs opacity-70">
|
|
||||||
<span>{getAgeLabel(event.created_at)}</span>
|
|
||||||
<span>{formatTimestampAsDate(event.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
<div class="flex grow flex-wrap justify-end gap-2">
|
||||||
{#if h && showRoom}
|
{#if h && showRoom}
|
||||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||||
Posted in #<RoomName {h} {url} />
|
Posted in #<RoomName {h} {url} />
|
||||||
|
|||||||
@@ -18,15 +18,22 @@
|
|||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
import {PROTECTED} from "@app/core/state"
|
import {PROTECTED} from "@app/core/state"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
|
import {DraftKey} from "@app/util/drafts"
|
||||||
import {canEnforceNip70} from "@app/core/commands"
|
import {canEnforceNip70} from "@app/core/commands"
|
||||||
|
|
||||||
|
type Values = {
|
||||||
|
content?: string | object
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
h?: string
|
h?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, h}: Props = $props()
|
const {url, h}: Props = $props()
|
||||||
|
const draftKey = new DraftKey<Values>(`thread:${url}:${h ?? ""}`)
|
||||||
|
const initialValues = draftKey.get()
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
const uploading = writable(false)
|
const uploading = writable(false)
|
||||||
@@ -70,12 +77,29 @@
|
|||||||
event: makeEvent(THREAD, {content, tags}),
|
event: makeEvent(THREAD, {content, tags}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
draftKey.clear()
|
||||||
history.back()
|
history.back()
|
||||||
}
|
}
|
||||||
|
|
||||||
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
|
let title = $state(initialValues?.title ?? "")
|
||||||
|
let content = $state(initialValues?.content ?? "")
|
||||||
|
|
||||||
let title: string = $state("")
|
const onChange = (json: object) => {
|
||||||
|
content = json
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = makeEditor({
|
||||||
|
url,
|
||||||
|
submit,
|
||||||
|
uploading,
|
||||||
|
onChange,
|
||||||
|
placeholder: "What's on your mind?",
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
draftKey.update({title, content})
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||||
@@ -106,7 +130,7 @@
|
|||||||
<p>Message*</p>
|
<p>Message*</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="note-editor flex-grow overflow-hidden">
|
<div class="note-editor grow overflow-hidden">
|
||||||
<EditorContent {editor} />
|
<EditorContent {editor} />
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -1,28 +1,98 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {parse, renderAsHtml} from "@welshman/content"
|
import {parse, renderAsHtml} from "@welshman/content"
|
||||||
|
import Close from "@assets/icons/close.svg?dataurl"
|
||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import {toast, popToast} from "@app/util/toast"
|
import {toast, popToast} from "@app/util/toast"
|
||||||
|
|
||||||
|
let touchStartY = 0
|
||||||
|
let touchStartTime = 0
|
||||||
|
let dragY = $state(0)
|
||||||
|
let isSettling = $state(false)
|
||||||
|
let containerEl = $state<HTMLDivElement | undefined>(undefined)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($toast) {
|
||||||
|
dragY = 0
|
||||||
|
isSettling = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!containerEl) return
|
||||||
|
containerEl.addEventListener("touchmove", onTouchMove, {passive: false})
|
||||||
|
return () => containerEl!.removeEventListener("touchmove", onTouchMove)
|
||||||
|
})
|
||||||
|
|
||||||
const onActionClick = () => {
|
const onActionClick = () => {
|
||||||
$toast!.action!.onclick()
|
$toast!.action!.onclick()
|
||||||
popToast($toast!.id)
|
popToast($toast!.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onClose = () => popToast($toast!.id)
|
||||||
|
|
||||||
|
const onTouchStart = (e: TouchEvent) => {
|
||||||
|
touchStartY = e.touches[0].clientY
|
||||||
|
touchStartTime = Date.now()
|
||||||
|
dragY = 0
|
||||||
|
isSettling = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTouchMove = (e: TouchEvent) => {
|
||||||
|
const delta = e.touches[0].clientY - touchStartY
|
||||||
|
if (delta < 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
isSettling = false
|
||||||
|
dragY = delta
|
||||||
|
} else {
|
||||||
|
dragY = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTouchEnd = (e: TouchEvent) => {
|
||||||
|
const delta = e.changedTouches[0].clientY - touchStartY
|
||||||
|
const duration = Date.now() - touchStartTime
|
||||||
|
const isQuickFlick = duration < 400 && delta < 0
|
||||||
|
const isSlowDismiss = delta < -40
|
||||||
|
|
||||||
|
if (isQuickFlick || isSlowDismiss) {
|
||||||
|
dragY = 0
|
||||||
|
popToast($toast!.id)
|
||||||
|
} else {
|
||||||
|
isSettling = true
|
||||||
|
dragY = 0
|
||||||
|
setTimeout(() => {
|
||||||
|
isSettling = false
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $toast}
|
{#if $toast}
|
||||||
{@const theme = $toast.theme || "info"}
|
{@const theme = $toast.theme || "info"}
|
||||||
<div transition:fly class="bottom-sai right-sai toast z-toast">
|
<div
|
||||||
|
bind:this={containerEl}
|
||||||
|
transition:fly={{y: -20}}
|
||||||
|
class="fixed z-toast top-[calc(var(--sait)+0.5rem)] left-[calc(var(--sail)+0.5rem)] right-[calc(var(--sair)+0.5rem)] flex flex-col gap-2 md:right-4 md:bottom-4 md:top-auto md:left-auto md:w-80"
|
||||||
|
style={dragY !== 0 || isSettling
|
||||||
|
? `transform: translateY(${dragY}px)${isSettling ? "; transition: transform 200ms ease-out" : ""}`
|
||||||
|
: ""}
|
||||||
|
ontouchstart={onTouchStart}
|
||||||
|
ontouchend={onTouchEnd}>
|
||||||
{#key $toast.id}
|
{#key $toast.id}
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
class="alert flex justify-center whitespace-normal text-left"
|
class="alert relative flex justify-center whitespace-normal text-left"
|
||||||
class:bg-base-100={theme === "info"}
|
class:bg-base-100={theme === "info"}
|
||||||
class:text-base-content={theme === "info"}
|
class:text-base-content={theme === "info"}
|
||||||
class:alert-error={theme === "error"}>
|
class:alert-error={theme === "error"}>
|
||||||
<p class:welshman-content-error={theme === "error"}>
|
<Button
|
||||||
|
class="absolute -top-2 -right-2 btn btn-circle btn-neutral btn-xs hidden md:inline-flex flex justify-center items-center"
|
||||||
|
onclick={onClose}>
|
||||||
|
<Icon icon={Close} size={4} />
|
||||||
|
</Button>
|
||||||
|
<p class="md:pr-6" class:welshman-content-error={theme === "error"}>
|
||||||
{#if $toast.message}
|
{#if $toast.message}
|
||||||
{@html renderAsHtml(parse({content: $toast.message}))}
|
{@html renderAsHtml(parse({content: $toast.message}))}
|
||||||
{#if $toast.action}
|
{#if $toast.action}
|
||||||
@@ -35,9 +105,6 @@
|
|||||||
<Component toast={$toast} {...props} />
|
<Component toast={$toast} {...props} />
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
<Button class="flex items-center opacity-75" onclick={() => popToast($toast.id)}>
|
|
||||||
<Icon icon={CloseCircle} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
|
import {Track} from "livekit-client"
|
||||||
|
import {displayProfileByPubkey, loadProfile} from "@welshman/app"
|
||||||
|
import Pin from "@assets/icons/pin.svg?dataurl"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
|
import VideoCallTile from "@app/components/VideoCallTile.svelte"
|
||||||
|
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
||||||
|
import {get} from "svelte/store"
|
||||||
|
import {
|
||||||
|
VideoCallLayout,
|
||||||
|
isDesktopLayout,
|
||||||
|
toggleVideoPrimaryTile,
|
||||||
|
videoCallLayout,
|
||||||
|
videoCallViewportSync,
|
||||||
|
ViewportSize,
|
||||||
|
videoPrimaryTileKey,
|
||||||
|
} from "@app/call/video"
|
||||||
|
import {currentVoiceSession, currentVoiceRoom, pubkeyFromLiveKitIdentity} from "@app/call/stores"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
layout: VideoCallLayout
|
||||||
|
mobile?: boolean
|
||||||
|
url: string
|
||||||
|
h: string
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoTileData = {
|
||||||
|
identity: string
|
||||||
|
isLocal: boolean
|
||||||
|
trackSid: string
|
||||||
|
track: Track | undefined
|
||||||
|
source: Track.Source.Camera | Track.Source.ScreenShare
|
||||||
|
}
|
||||||
|
|
||||||
|
type TileLayout = "spotlight" | "default" | "strip"
|
||||||
|
|
||||||
|
const {layout, mobile = false, url, h, class: className = ""}: Props = $props()
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const currentLayout = isDesktopLayout.current ? ViewportSize.Desktop : ViewportSize.Mobile
|
||||||
|
const {previousLayout} = videoCallViewportSync
|
||||||
|
if (previousLayout === undefined) {
|
||||||
|
videoCallViewportSync.previousLayout = currentLayout
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (previousLayout === currentLayout) return
|
||||||
|
const p = get(videoCallLayout)
|
||||||
|
if (previousLayout === ViewportSize.Desktop && currentLayout === ViewportSize.Mobile) {
|
||||||
|
if (p === VideoCallLayout.Split) videoCallLayout.set(VideoCallLayout.Video)
|
||||||
|
} else if (previousLayout === ViewportSize.Mobile && currentLayout === ViewportSize.Desktop) {
|
||||||
|
if (p === VideoCallLayout.Chat) videoCallLayout.set(VideoCallLayout.Split)
|
||||||
|
}
|
||||||
|
videoCallViewportSync.previousLayout = currentLayout
|
||||||
|
})
|
||||||
|
|
||||||
|
const isViewingCurrentCallRoom = $derived(
|
||||||
|
$currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
|
||||||
|
)
|
||||||
|
|
||||||
|
const showVideoContent = $derived(
|
||||||
|
isViewingCurrentCallRoom &&
|
||||||
|
(mobile
|
||||||
|
? layout === VideoCallLayout.Video
|
||||||
|
: layout === VideoCallLayout.Split || layout === VideoCallLayout.Video),
|
||||||
|
)
|
||||||
|
|
||||||
|
const videoTiles = $derived.by(() => {
|
||||||
|
const session = $currentVoiceSession
|
||||||
|
if (!session || $currentVoiceRoom?.url !== url || $currentVoiceRoom?.h !== h) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = session.room
|
||||||
|
const videoTiles: VideoTileData[] = []
|
||||||
|
const user = room.localParticipant
|
||||||
|
|
||||||
|
if (session.cameraOn) {
|
||||||
|
const localPub = user.getTrackPublication(Track.Source.Camera)
|
||||||
|
videoTiles.push({
|
||||||
|
identity: user.identity,
|
||||||
|
isLocal: true,
|
||||||
|
trackSid: localPub?.trackSid ?? "local-camera",
|
||||||
|
track: localPub?.track,
|
||||||
|
source: Track.Source.Camera,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.screenShareOn) {
|
||||||
|
const localPub = user.getTrackPublication(Track.Source.ScreenShare)
|
||||||
|
videoTiles.push({
|
||||||
|
identity: user.identity,
|
||||||
|
isLocal: true,
|
||||||
|
trackSid: localPub?.trackSid ?? "local-screen",
|
||||||
|
track: localPub?.track,
|
||||||
|
source: Track.Source.ScreenShare,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rp of room.remoteParticipants.values()) {
|
||||||
|
const camPub = rp.getTrackPublication(Track.Source.Camera)
|
||||||
|
if (camPub?.isSubscribed && camPub.track) {
|
||||||
|
videoTiles.push({
|
||||||
|
identity: rp.identity,
|
||||||
|
isLocal: false,
|
||||||
|
trackSid: camPub.trackSid,
|
||||||
|
track: camPub.track,
|
||||||
|
source: Track.Source.Camera,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const screenPub = rp.getTrackPublication(Track.Source.ScreenShare)
|
||||||
|
if (screenPub?.isSubscribed && screenPub.track) {
|
||||||
|
videoTiles.push({
|
||||||
|
identity: rp.identity,
|
||||||
|
isLocal: false,
|
||||||
|
trackSid: screenPub.trackSid,
|
||||||
|
track: screenPub.track,
|
||||||
|
source: Track.Source.ScreenShare,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return videoTiles
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */
|
||||||
|
const tileKey = (t: VideoTileData) => `${t.identity}\x1f${t.source}`
|
||||||
|
|
||||||
|
const primaryTile = $derived.by(() => {
|
||||||
|
const k = $videoPrimaryTileKey
|
||||||
|
if (k === undefined) return undefined
|
||||||
|
return videoTiles.find(t => tileKey(t) === k)
|
||||||
|
})
|
||||||
|
|
||||||
|
const secondaryTiles = $derived.by(() => {
|
||||||
|
const p = primaryTile
|
||||||
|
if (p === undefined) return videoTiles
|
||||||
|
const pk = tileKey(p)
|
||||||
|
return videoTiles.filter(t => tileKey(t) !== pk)
|
||||||
|
})
|
||||||
|
|
||||||
|
const useSpotlightLayout = $derived(primaryTile !== undefined)
|
||||||
|
const useMultiGrid = $derived(!useSpotlightLayout && videoTiles.length > 2)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const k = $videoPrimaryTileKey
|
||||||
|
if (k === undefined) return
|
||||||
|
if (!videoTiles.some(t => tileKey(t) === k)) {
|
||||||
|
videoPrimaryTileKey.set(undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
for (const t of videoTiles) {
|
||||||
|
const pk = pubkeyFromLiveKitIdentity(t.identity)
|
||||||
|
if (pk) loadProfile(pk)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const labelFor = (identity: string, source: VideoTileData["source"]) => {
|
||||||
|
const pk = pubkeyFromLiveKitIdentity(identity)
|
||||||
|
const name = pk ? displayProfileByPubkey(pk) : "Unknown"
|
||||||
|
return source === Track.Source.ScreenShare ? `${name} · screen` : name
|
||||||
|
}
|
||||||
|
|
||||||
|
const showTileGrid = $derived(videoTiles.length > 0)
|
||||||
|
|
||||||
|
const spotlightHandlerFor = (key: string) => () => {
|
||||||
|
toggleVideoPrimaryTile(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelChrome = $derived(
|
||||||
|
cx(
|
||||||
|
mobile &&
|
||||||
|
"flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden bg-base-200 px-2 pt-4 md:hidden pb-[calc(3.5rem+var(--saib))]",
|
||||||
|
!mobile &&
|
||||||
|
"flex min-h-0 w-full min-w-0 flex-1 flex-col gap-2 overflow-hidden bg-base-200 px-2 pb-2 pt-4",
|
||||||
|
className,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
|
||||||
|
<div
|
||||||
|
class={cx(
|
||||||
|
"relative isolate overflow-hidden rounded-box shadow-sm",
|
||||||
|
layout === "spotlight" && "min-h-0 flex-1",
|
||||||
|
layout === "default" && "aspect-video w-full min-h-0",
|
||||||
|
layout === "strip" && "aspect-video w-44 shrink-0",
|
||||||
|
tile.source === Track.Source.ScreenShare ? "bg-black" : "bg-base-100",
|
||||||
|
)}>
|
||||||
|
{#if tile.track}
|
||||||
|
<VideoCallTile
|
||||||
|
track={tile.track}
|
||||||
|
muted={tile.isLocal}
|
||||||
|
fit={tile.source === Track.Source.ScreenShare ? "contain" : "cover"}
|
||||||
|
class="pointer-events-none absolute inset-0" />
|
||||||
|
{:else}
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<ProfileCircle pubkey={pubkeyFromLiveKitIdentity(tile.identity)} {url} size={14} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-base-100/80 px-1.5 py-0.5 text-xs">
|
||||||
|
{labelFor(tile.identity, tile.source)}{tile.isLocal ? " (you)" : ""}
|
||||||
|
</span>
|
||||||
|
{#if videoTiles.length > 1}
|
||||||
|
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
|
||||||
|
<Button
|
||||||
|
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
|
||||||
|
aria-pressed={pinned}
|
||||||
|
class={cx(
|
||||||
|
"absolute right-1 top-1 z-20 btn btn-xs btn-square btn-ghost",
|
||||||
|
pinned ? "btn-active bg-primary/25 text-primary" : "bg-base-100/70",
|
||||||
|
)}
|
||||||
|
onclick={spotlightHandlerFor(tileKey(tile))}>
|
||||||
|
<Icon icon={Pin} size={3} />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet videoPanelBody()}
|
||||||
|
{#if showTileGrid}
|
||||||
|
{#if useSpotlightLayout && primaryTile}
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||||
|
{@render videoTile(primaryTile, "spotlight")}
|
||||||
|
{#if secondaryTiles.length > 0}
|
||||||
|
<div
|
||||||
|
class="flex max-h-40 shrink-0 flex-row gap-2 overflow-x-auto overflow-y-hidden py-0.5">
|
||||||
|
{#each secondaryTiles as tile (tileKey(tile))}
|
||||||
|
{@render videoTile(tile, "strip")}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if useMultiGrid}
|
||||||
|
<div
|
||||||
|
class="grid min-h-0 flex-1 grid-cols-1 content-start gap-2 overflow-y-auto sm:grid-cols-2">
|
||||||
|
{#each videoTiles as tile (tileKey(tile))}
|
||||||
|
{@render videoTile(tile, "default")}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||||
|
{#each videoTiles as tile (tileKey(tile))}
|
||||||
|
{@render videoTile(tile, "default")}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-box bg-base-200/50 p-4 text-center text-sm opacity-80">
|
||||||
|
<p>No camera or screen share yet.</p>
|
||||||
|
<p class="text-xs">Use the camera or screen share control to share video.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if showVideoContent}
|
||||||
|
<div class={panelChrome}>
|
||||||
|
{#if mobile}
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col gap-2">
|
||||||
|
<div class="min-h-0 flex-1 overflow-hidden">
|
||||||
|
{@render videoPanelBody()}
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0 pb-2">
|
||||||
|
<VoiceWidget />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@render videoPanelBody()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {Track} from "livekit-client"
|
||||||
|
import cx from "classnames"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
track: Track
|
||||||
|
muted?: boolean
|
||||||
|
fit?: "cover" | "contain"
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {track, muted = true, fit = "cover", class: className = ""}: Props = $props()
|
||||||
|
|
||||||
|
let videoElement = $state<HTMLVideoElement | undefined>()
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const element = videoElement
|
||||||
|
const activeTrack = track
|
||||||
|
if (!element) return
|
||||||
|
activeTrack.attach(element)
|
||||||
|
return () => {
|
||||||
|
activeTrack.detach(element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<video
|
||||||
|
bind:this={videoElement}
|
||||||
|
class={cx("h-full w-full", fit === "contain" ? "object-contain" : "object-cover", className)}
|
||||||
|
playsinline
|
||||||
|
{muted}></video>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import {currentVoiceSession, type VoiceSession} from "@app/call/stores"
|
||||||
|
import {DeviceKind, supportsAudioOutputSelection, switchVoiceActiveDevice} from "@app/call/voice"
|
||||||
|
import {popModal} from "@app/util/modal"
|
||||||
|
|
||||||
|
const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => {
|
||||||
|
const livekitDeviceId = session.room.getActiveDevice(kind)
|
||||||
|
if (livekitDeviceId === undefined || livekitDeviceId === "" || livekitDeviceId === "default") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return livekitDeviceId
|
||||||
|
}
|
||||||
|
|
||||||
|
let audioInputs = $state<MediaDeviceInfo[]>([])
|
||||||
|
let audioOutputs = $state<MediaDeviceInfo[]>([])
|
||||||
|
let videoInputs = $state<MediaDeviceInfo[]>([])
|
||||||
|
let selectedInput = $state("")
|
||||||
|
let selectedOutput = $state("")
|
||||||
|
let selectedVideo = $state("")
|
||||||
|
|
||||||
|
const loadDevices = async () => {
|
||||||
|
if (!navigator.mediaDevices?.enumerateDevices) return
|
||||||
|
try {
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
audioInputs = devices.filter(d => d.kind === "audioinput")
|
||||||
|
audioOutputs = devices.filter(d => d.kind === "audiooutput")
|
||||||
|
videoInputs = devices.filter(d => d.kind === "videoinput")
|
||||||
|
} catch {
|
||||||
|
audioInputs = []
|
||||||
|
audioOutputs = []
|
||||||
|
videoInputs = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadDevices()
|
||||||
|
navigator.mediaDevices?.addEventListener?.("devicechange", loadDevices)
|
||||||
|
return () => navigator.mediaDevices?.removeEventListener?.("devicechange", loadDevices)
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const session = $currentVoiceSession
|
||||||
|
if (!session) {
|
||||||
|
popModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
|
||||||
|
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
|
||||||
|
selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput)
|
||||||
|
})
|
||||||
|
|
||||||
|
const onInputChange = () => {
|
||||||
|
void switchVoiceActiveDevice(DeviceKind.AudioInput, selectedInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onOutputChange = () => {
|
||||||
|
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVideoChange = () => {
|
||||||
|
void switchVoiceActiveDevice(DeviceKind.VideoInput, selectedVideo)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDone = () => {
|
||||||
|
popModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output not support in Safari
|
||||||
|
const canPickOutput = supportsAudioOutputSelection()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal>
|
||||||
|
<ModalBody>
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>Call settings</ModalTitle>
|
||||||
|
<ModalSubtitle>Microphone, speaker, and camera for this call.</ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
<div class="flex flex-col gap-4 pt-2">
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Microphone</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<select
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
bind:value={selectedInput}
|
||||||
|
onchange={onInputChange}
|
||||||
|
aria-label="Microphone">
|
||||||
|
<option value="">Default microphone</option>
|
||||||
|
{#each audioInputs as d (d.deviceId)}
|
||||||
|
<option value={d.deviceId}>
|
||||||
|
{d.label || `Microphone ${d.deviceId.slice(0, 8)}…`}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
{#if canPickOutput}
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Speaker</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<select
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
bind:value={selectedOutput}
|
||||||
|
onchange={onOutputChange}
|
||||||
|
aria-label="Speaker">
|
||||||
|
<option value="">Default speaker</option>
|
||||||
|
{#each audioOutputs as d (d.deviceId)}
|
||||||
|
<option value={d.deviceId}>
|
||||||
|
{d.label || `Speaker ${d.deviceId.slice(0, 8)}…`}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
{/if}
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Camera</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<select
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
bind:value={selectedVideo}
|
||||||
|
onchange={onVideoChange}
|
||||||
|
aria-label="Camera">
|
||||||
|
<option value="">Default camera</option>
|
||||||
|
{#each videoInputs as d (d.deviceId)}
|
||||||
|
<option value={d.deviceId}>
|
||||||
|
{d.label || `Camera ${d.deviceId.slice(0, 8)}…`}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="btn btn-primary w-full" onclick={onDone}>Done</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
@@ -12,14 +12,13 @@
|
|||||||
import {makeRoomId} from "@app/core/state"
|
import {makeRoomId} from "@app/core/state"
|
||||||
import {
|
import {
|
||||||
VoiceState,
|
VoiceState,
|
||||||
deriveVoiceParticipants,
|
|
||||||
cancelJoinVoiceRoom,
|
|
||||||
currentVoiceRoom,
|
currentVoiceRoom,
|
||||||
voiceState,
|
|
||||||
isParticipantSpeaking,
|
isParticipantSpeaking,
|
||||||
participantKey,
|
participantKey,
|
||||||
|
voiceState,
|
||||||
type VoiceParticipant,
|
type VoiceParticipant,
|
||||||
} from "@app/voice"
|
} from "@app/call/stores"
|
||||||
|
import {cancelJoinVoiceRoom, deriveVoiceParticipants} from "@app/call/voice"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string
|
url: string
|
||||||
@@ -64,7 +63,7 @@
|
|||||||
{replaceState}
|
{replaceState}
|
||||||
{notification}
|
{notification}
|
||||||
onclick={handleClick}
|
onclick={handleClick}
|
||||||
class={cx("!items-start", isActive && "!bg-base-100 !text-base-content")}>
|
class={cx("items-start!", isActive && "bg-base-100! text-base-content!")}>
|
||||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
{#if isJoining}
|
{#if isJoining}
|
||||||
|
|||||||
@@ -12,9 +12,11 @@
|
|||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import {AbortError, TimeoutError} from "$lib/util"
|
||||||
import {displayRoom} from "@app/core/state"
|
import {displayRoom} from "@app/core/state"
|
||||||
import {joinVoiceRoom} from "@app/voice"
|
import {joinVoiceRoom} from "@app/call/voice"
|
||||||
import {popModal} from "@app/util/modal"
|
import {popModal} from "@app/util/modal"
|
||||||
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
@@ -45,6 +47,16 @@
|
|||||||
|
|
||||||
const goBack = () => history.back()
|
const goBack = () => history.back()
|
||||||
|
|
||||||
|
const handleJoinError = (e: unknown) => {
|
||||||
|
if (e instanceof AbortError) return
|
||||||
|
console.error("Failed to join voice room", e)
|
||||||
|
let message = "Failed to join voice room"
|
||||||
|
if (e instanceof TimeoutError)
|
||||||
|
message = "Connection timed out. Please check your network and try again."
|
||||||
|
else if (e instanceof Error) message = e.message
|
||||||
|
pushToast({theme: "error", message})
|
||||||
|
}
|
||||||
|
|
||||||
const joinVoice = async () => {
|
const joinVoice = async () => {
|
||||||
popModal()
|
popModal()
|
||||||
await joinVoiceRoom(
|
await joinVoiceRoom(
|
||||||
@@ -52,7 +64,7 @@
|
|||||||
h,
|
h,
|
||||||
startWithoutMic,
|
startWithoutMic,
|
||||||
startWithoutMic ? undefined : selectedDeviceId || undefined,
|
startWithoutMic ? undefined : selectedDeviceId || undefined,
|
||||||
)
|
).catch(handleJoinError)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {readable} from "svelte/store"
|
import {readable} from "svelte/store"
|
||||||
import {fly} from "svelte/transition"
|
import {fade, fly} from "svelte/transition"
|
||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
|
import cx from "classnames"
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
||||||
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
|
import Videocamera from "@assets/icons/videocamera.svg?dataurl"
|
||||||
|
import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl"
|
||||||
|
import Monitor from "@assets/icons/monitor.svg?dataurl"
|
||||||
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
|
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
|
||||||
import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl"
|
import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl"
|
||||||
|
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
|
import Settings from "@assets/icons/settings.svg?dataurl"
|
||||||
|
import {Capacitor} from "@capacitor/core"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
|
||||||
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
|
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
|
||||||
import {
|
import {
|
||||||
decodeRelay,
|
decodeRelay,
|
||||||
@@ -21,16 +28,23 @@
|
|||||||
type Room,
|
type Room,
|
||||||
} from "@app/core/state"
|
} from "@app/core/state"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
import {notifications} from "@app/util/notifications"
|
||||||
import {makeRoomPath} from "@app/util/routes"
|
import {makeRoomPath} from "@app/util/routes"
|
||||||
|
import {
|
||||||
|
VideoCallLayout,
|
||||||
|
isDesktopLayout,
|
||||||
|
toggleCamera,
|
||||||
|
toggleScreenShare,
|
||||||
|
videoCallLayout,
|
||||||
|
} from "@app/call/video"
|
||||||
import {
|
import {
|
||||||
VoiceState,
|
VoiceState,
|
||||||
currentVoiceSession,
|
currentVoiceSession,
|
||||||
currentVoiceRoom,
|
currentVoiceRoom,
|
||||||
voiceState,
|
voiceState,
|
||||||
leaveVoiceRoom,
|
isLocalSpeaking,
|
||||||
toggleMute,
|
} from "@app/call/stores"
|
||||||
cancelJoinVoiceRoom,
|
import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice"
|
||||||
} from "@app/voice"
|
|
||||||
|
|
||||||
const {relay, h} = $derived($page.params)
|
const {relay, h} = $derived($page.params)
|
||||||
const url = $derived(relay ? decodeRelay(relay) : undefined)
|
const url = $derived(relay ? decodeRelay(relay) : undefined)
|
||||||
@@ -39,6 +53,14 @@
|
|||||||
)
|
)
|
||||||
const routeDisplayedRoom = $derived($displayedRoomStore)
|
const routeDisplayedRoom = $derived($displayedRoomStore)
|
||||||
|
|
||||||
|
const isViewingCurrentVoiceRoom = $derived(
|
||||||
|
$currentVoiceRoom !== undefined &&
|
||||||
|
url !== undefined &&
|
||||||
|
typeof h === "string" &&
|
||||||
|
$currentVoiceRoom.url === url &&
|
||||||
|
$currentVoiceRoom.h === h,
|
||||||
|
)
|
||||||
|
|
||||||
const targetRoom = $derived.by((): Room | undefined => {
|
const targetRoom = $derived.by((): Room | undefined => {
|
||||||
if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) {
|
if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) {
|
||||||
return $currentVoiceRoom
|
return $currentVoiceRoom
|
||||||
@@ -63,6 +85,46 @@
|
|||||||
await goto(makeRoomPath(targetRoom.url, targetRoom.h))
|
await goto(makeRoomPath(targetRoom.url, targetRoom.h))
|
||||||
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
|
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goToRoom = () => {
|
||||||
|
if (!targetRoom) return
|
||||||
|
const path = makeRoomPath(targetRoom.url, targetRoom.h)
|
||||||
|
if ($page.url.pathname !== path) {
|
||||||
|
void goto(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCallSettings = () => {
|
||||||
|
pushModal(VoiceCallAudioSettingsDialog)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showChatButton = $derived($voiceState === VoiceState.Connected && isViewingCurrentVoiceRoom)
|
||||||
|
|
||||||
|
const isChatPanelActive = $derived(
|
||||||
|
showChatButton &&
|
||||||
|
(isDesktopLayout.current
|
||||||
|
? $videoCallLayout === VideoCallLayout.Split
|
||||||
|
: $videoCallLayout === VideoCallLayout.Chat),
|
||||||
|
)
|
||||||
|
|
||||||
|
const onChatToggle = () => {
|
||||||
|
if (!showChatButton) return
|
||||||
|
if (isDesktopLayout.current) {
|
||||||
|
videoCallLayout.update(p =>
|
||||||
|
p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
videoCallLayout.update(p =>
|
||||||
|
p === VideoCallLayout.Video ? VideoCallLayout.Chat : VideoCallLayout.Video,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatUnread = $derived(
|
||||||
|
targetRoom !== undefined && $notifications.has(makeRoomPath(targetRoom.url, targetRoom.h)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const mediaToggleClass = "center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if targetRoom}
|
{#if targetRoom}
|
||||||
@@ -70,19 +132,47 @@
|
|||||||
in:fly={{y: 60, duration: 350}}
|
in:fly={{y: 60, duration: 350}}
|
||||||
out:fly={{y: 60, duration: 250}}
|
out:fly={{y: 60, duration: 250}}
|
||||||
class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
|
class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex items-start justify-between gap-2">
|
||||||
{#if $voiceState === VoiceState.Joining}
|
<button
|
||||||
<span class="text-sm font-semibold text-warning">Joining...</span>
|
type="button"
|
||||||
{:else if $voiceState === VoiceState.Connected}
|
class="min-w-0 flex-1 rounded-lg px-1 py-0.5 text-left outline-none hover:bg-base-200/60 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-base-100"
|
||||||
<span class="text-sm font-semibold text-success">Voice Connected</span>
|
onclick={goToRoom}
|
||||||
{:else}
|
aria-label="Open room {roomName}">
|
||||||
<span class="text-sm font-semibold text-neutral-content">Disconnected</span>
|
<div class="flex flex-col gap-0.5">
|
||||||
|
{#if $voiceState === VoiceState.Joining}
|
||||||
|
<span class="text-sm font-semibold text-warning">Joining...</span>
|
||||||
|
{:else if $voiceState === VoiceState.Connected}
|
||||||
|
<span class="text-sm font-semibold text-success">Voice Connected</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-sm font-semibold text-neutral-content">Disconnected</span>
|
||||||
|
{/if}
|
||||||
|
<span class="ellipsize text-xs opacity-70">
|
||||||
|
{roomName} / {spaceName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{#if showChatButton}
|
||||||
|
<Button
|
||||||
|
data-tip="Toggle Chat"
|
||||||
|
class={cx(
|
||||||
|
mediaToggleClass,
|
||||||
|
"relative shrink-0 overflow-visible",
|
||||||
|
isChatPanelActive && "text-primary",
|
||||||
|
)}
|
||||||
|
onclick={onChatToggle}>
|
||||||
|
<span class="relative inline-flex">
|
||||||
|
<Icon icon={ChatRound} size={4} />
|
||||||
|
{#if chatUnread}
|
||||||
|
<span
|
||||||
|
transition:fade={{duration: 150}}
|
||||||
|
class="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary ring-2 ring-base-100"
|
||||||
|
aria-hidden="true"></span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="ellipsize text-xs opacity-70">
|
|
||||||
{roomName} / {spaceName}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
{#if $voiceState === VoiceState.Joining}
|
{#if $voiceState === VoiceState.Joining}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
<Button
|
<Button
|
||||||
@@ -94,11 +184,46 @@
|
|||||||
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
|
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
|
||||||
<Button
|
<Button
|
||||||
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
|
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
|
||||||
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
|
class={cx(
|
||||||
? 'btn-error'
|
mediaToggleClass,
|
||||||
: 'btn-ghost'}"
|
"overflow-visible",
|
||||||
|
!$currentVoiceSession.muted && $isLocalSpeaking && "text-primary",
|
||||||
|
$currentVoiceSession.muted &&
|
||||||
|
"text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
|
||||||
|
)}
|
||||||
onclick={toggleMute}>
|
onclick={toggleMute}>
|
||||||
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
|
<span class="relative inline-flex items-center justify-center overflow-visible">
|
||||||
|
<Icon icon={Microphone} size={4} />
|
||||||
|
{#if $currentVoiceSession.muted}
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute inset-0 flex items-center justify-center overflow-visible"
|
||||||
|
aria-hidden="true">
|
||||||
|
<span
|
||||||
|
class="h-[1.3px] w-[150%] max-w-none shrink-0 -rotate-45 rounded-full bg-current"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"}
|
||||||
|
class={cx(mediaToggleClass, $currentVoiceSession.cameraOn && "text-primary")}
|
||||||
|
onclick={toggleCamera}>
|
||||||
|
<Icon icon={$currentVoiceSession.cameraOn ? VideocameraRecord : Videocamera} size={4} />
|
||||||
|
</Button>
|
||||||
|
{#if !Capacitor.isNativePlatform()}
|
||||||
|
<Button
|
||||||
|
data-tip={$currentVoiceSession.screenShareOn ? "Stop sharing" : "Share screen"}
|
||||||
|
class={cx(mediaToggleClass, $currentVoiceSession.screenShareOn && "text-primary")}
|
||||||
|
onclick={toggleScreenShare}>
|
||||||
|
<Icon icon={Monitor} size={4} />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<Button
|
||||||
|
data-tip="Call settings"
|
||||||
|
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
|
||||||
|
onclick={openCallSettings}>
|
||||||
|
<Icon icon={Settings} size={4} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
data-tip="Leave room"
|
data-tip="Leave room"
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
Amount (satoshis)
|
Amount (satoshis)
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="flex flex-grow justify-end">
|
<div class="flex grow justify-end">
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
<Icon icon={Bolt} />
|
<Icon icon={Bolt} />
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
Amount (satoshis)
|
Amount (satoshis)
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="flex flex-grow justify-end">
|
<div class="flex grow justify-end">
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
<Icon icon={Bolt} />
|
<Icon icon={Bolt} />
|
||||||
<input bind:value={sats} type="number" class="w-14" placeholder="0" />
|
<input bind:value={sats} type="number" class="w-14" placeholder="0" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.wot-background {
|
.wot-background {
|
||||||
fill: transparent;
|
fill: transparent;
|
||||||
stroke: var(--base-content);
|
stroke: var(--color-base-content);
|
||||||
opacity: 30%;
|
opacity: 30%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
const normalizedScore = $derived(clamp([0, max], $score) / max)
|
const normalizedScore = $derived(clamp([0, max], $score) / max)
|
||||||
const dashOffset = $derived(100 - 44 * normalizedScore)
|
const dashOffset = $derived(100 - 44 * normalizedScore)
|
||||||
const style = $derived(`transform: rotate(${135 - normalizedScore * 180}deg)`)
|
const style = $derived(`transform: rotate(${135 - normalizedScore * 180}deg)`)
|
||||||
const stroke = $derived(active ? "var(--primary)" : "var(--base-content)")
|
const stroke = $derived(active ? "var(--color-primary)" : "var(--color-base-content)")
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative h-[14px] w-[14px]">
|
<div class="relative h-[14px] w-[14px]">
|
||||||
|
|||||||
@@ -118,26 +118,26 @@
|
|||||||
<ModalBody>
|
<ModalBody>
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Send a Zap</ModalTitle>
|
<ModalTitle>Send a Zap</ModalTitle>
|
||||||
<ModalSubtitle>To <ProfileLink {pubkey} class="!text-primary" /></ModalSubtitle>
|
<ModalSubtitle>To <ProfileLink {pubkey} class="text-primary!" /></ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<FieldInline class="!grid-cols-3">
|
<FieldInline class="grid-cols-3!">
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
Emoji Reaction
|
Emoji Reaction
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="flex flex-grow items-center justify-end gap-4">
|
<div class="flex grow items-center justify-end gap-4">
|
||||||
<EmojiButton {onEmoji} class="btn btn-neutral">
|
<EmojiButton {onEmoji} class="btn btn-neutral">
|
||||||
{content}
|
{content}
|
||||||
</EmojiButton>
|
</EmojiButton>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
<FieldInline class="!grid-cols-3">
|
<FieldInline class="grid-cols-3!">
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
Amount
|
Amount
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="flex flex-grow justify-end">
|
<div class="flex grow justify-end">
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
<Icon icon={Bolt} />
|
<Icon icon={Bolt} />
|
||||||
<input bind:value={amount} type="number" class="w-24" />
|
<input bind:value={amount} type="number" class="w-24" />
|
||||||
|
|||||||
@@ -147,7 +147,7 @@
|
|||||||
<ModalBody>
|
<ModalBody>
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Send a Zap</ModalTitle>
|
<ModalTitle>Send a Zap</ModalTitle>
|
||||||
<ModalSubtitle>To <ProfileLink {pubkey} class="!text-primary" /></ModalSubtitle>
|
<ModalSubtitle>To <ProfileLink {pubkey} class="text-primary!" /></ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
|
|
||||||
{#if invoice}
|
{#if invoice}
|
||||||
@@ -158,30 +158,30 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label class="input input-bordered flex w-full items-center justify-between gap-2">
|
<label class="input input-bordered flex w-full items-center justify-between gap-2">
|
||||||
<input readonly class="ellipsize flex-grow" value={invoice} />
|
<input readonly class="ellipsize grow" value={invoice} />
|
||||||
<Button class="flex items-center" onclick={copyInvoice}>
|
<Button class="flex items-center" onclick={copyInvoice}>
|
||||||
<Icon icon={Copy} />
|
<Icon icon={Copy} />
|
||||||
</Button>
|
</Button>
|
||||||
</label>
|
</label>
|
||||||
{:else}
|
{:else}
|
||||||
<FieldInline class="!grid-cols-3">
|
<FieldInline class="grid-cols-3!">
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
Emoji Reaction
|
Emoji Reaction
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="flex flex-grow items-center justify-end gap-4">
|
<div class="flex grow items-center justify-end gap-4">
|
||||||
<EmojiButton {onEmoji} class="btn btn-neutral">
|
<EmojiButton {onEmoji} class="btn btn-neutral">
|
||||||
{content}
|
{content}
|
||||||
</EmojiButton>
|
</EmojiButton>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
<FieldInline class="!grid-cols-3">
|
<FieldInline class="grid-cols-3!">
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
Amount
|
Amount
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="flex flex-grow justify-end">
|
<div class="flex grow justify-end">
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
<Icon icon={Bolt} />
|
<Icon icon={Bolt} />
|
||||||
<input bind:value={amount} type="number" class="w-24" />
|
<input bind:value={amount} type="number" class="w-24" />
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import {Nip01Signer} from "@welshman/signer"
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
import type {UploadTask} from "@welshman/editor"
|
import type {UploadTask} from "@welshman/editor"
|
||||||
import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util"
|
import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util"
|
||||||
|
import {PollResponse} from "nostr-tools/kinds"
|
||||||
import {
|
import {
|
||||||
DELETE,
|
DELETE,
|
||||||
REPORT,
|
REPORT,
|
||||||
@@ -351,6 +352,22 @@ export const publishReaction = ({relays, ...params}: ReactionParams & {relays: s
|
|||||||
publishThunk({event: makeReaction({url: relays[0], ...params}), relays})
|
publishThunk({event: makeReaction({url: relays[0], ...params}), relays})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Polls
|
||||||
|
|
||||||
|
export type PollResponseParams = {
|
||||||
|
event: TrustedEvent
|
||||||
|
selectedIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makePollResponse = ({event, selectedIds}: PollResponseParams) =>
|
||||||
|
makeEvent(PollResponse, {
|
||||||
|
content: "",
|
||||||
|
tags: [["e", event.id], ...selectedIds.map(selectedId => ["response", selectedId])],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const publishPollResponse = ({relays, ...params}: PollResponseParams & {relays: string[]}) =>
|
||||||
|
publishThunk({event: makePollResponse(params), relays})
|
||||||
|
|
||||||
// Comments
|
// Comments
|
||||||
|
|
||||||
export type CommentParams = {
|
export type CommentParams = {
|
||||||
@@ -412,12 +429,9 @@ export const toggleRoomNotifications = async (url: string, h: string) => {
|
|||||||
let updated: typeof alerts
|
let updated: typeof alerts
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
// No space settings yet, create one with this room as an exception (default is notify: true)
|
|
||||||
updated = [...alerts, {url, notify: true, exceptions: [h]}]
|
updated = [...alerts, {url, notify: true, exceptions: [h]}]
|
||||||
} else {
|
} else {
|
||||||
// Toggle exception status
|
const exceptions = existing.exceptions.includes(h)
|
||||||
const hasException = existing.exceptions.includes(h)
|
|
||||||
const exceptions = hasException
|
|
||||||
? remove(h, existing.exceptions)
|
? remove(h, existing.exceptions)
|
||||||
: append(h, existing.exceptions)
|
: append(h, existing.exceptions)
|
||||||
|
|
||||||
|
|||||||
+103
-64
@@ -3,11 +3,11 @@ import {context as pomadeContext} from "@pomade/core"
|
|||||||
import {Capacitor} from "@capacitor/core"
|
import {Capacitor} from "@capacitor/core"
|
||||||
import {derived, readable, writable} from "svelte/store"
|
import {derived, readable, writable} from "svelte/store"
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
|
import {Poll} from "nostr-tools/kinds"
|
||||||
import {
|
import {
|
||||||
on,
|
on,
|
||||||
gt,
|
gt,
|
||||||
max,
|
max,
|
||||||
find,
|
|
||||||
spec,
|
spec,
|
||||||
call,
|
call,
|
||||||
first,
|
first,
|
||||||
@@ -191,7 +191,9 @@ export const PLATFORM_TERMS = import.meta.env.VITE_PLATFORM_TERMS
|
|||||||
|
|
||||||
export const PLATFORM_PRIVACY = import.meta.env.VITE_PLATFORM_PRIVACY
|
export const PLATFORM_PRIVACY = import.meta.env.VITE_PLATFORM_PRIVACY
|
||||||
|
|
||||||
export const PLATFORM_LOGO = PLATFORM_URL + "/logo.png"
|
export const PLATFORM_LOGO = import.meta.env.PROD
|
||||||
|
? PLATFORM_URL + "/logo.png"
|
||||||
|
: import.meta.env.VITE_PLATFORM_LOGO.replace(/^static/, "") || PLATFORM_URL + "/logo.png"
|
||||||
|
|
||||||
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
|
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
|
||||||
|
|
||||||
@@ -207,6 +209,8 @@ export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS
|
|||||||
|
|
||||||
export const DUFFLEPUD_URL = "https://dufflepud.onrender.com"
|
export const DUFFLEPUD_URL = "https://dufflepud.onrender.com"
|
||||||
|
|
||||||
|
export const THUMBNAIL_URL = import.meta.env.VITE_THUMBNAIL_URL
|
||||||
|
|
||||||
export const NIP46_PERMS =
|
export const NIP46_PERMS =
|
||||||
"nip44_encrypt,nip44_decrypt," +
|
"nip44_encrypt,nip44_decrypt," +
|
||||||
[
|
[
|
||||||
@@ -323,7 +327,7 @@ if (ENABLE_ZAPS) {
|
|||||||
REACTION_KINDS.push(ZAP_RESPONSE)
|
REACTION_KINDS.push(ZAP_RESPONSE)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED]
|
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, Poll]
|
||||||
|
|
||||||
export const MESSAGE_KINDS = [...CONTENT_KINDS, MESSAGE]
|
export const MESSAGE_KINDS = [...CONTENT_KINDS, MESSAGE]
|
||||||
|
|
||||||
@@ -546,8 +550,11 @@ export const chatsById = call(() => {
|
|||||||
|
|
||||||
const unsubscribers = [
|
const unsubscribers = [
|
||||||
on(repository, "update", ({added, removed}: RepositoryUpdate) => {
|
on(repository, "update", ({added, removed}: RepositoryUpdate) => {
|
||||||
addEvents(added)
|
// Do this async so that profiles are populated
|
||||||
removeEvents(removed)
|
setTimeout(() => {
|
||||||
|
addEvents(added)
|
||||||
|
removeEvents(removed)
|
||||||
|
}, 50)
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -807,36 +814,78 @@ export const deriveOtherRooms = (url: string) =>
|
|||||||
|
|
||||||
// Space/room memberships
|
// Space/room memberships
|
||||||
|
|
||||||
|
const getSpaceMembers = (_url: string, events: TrustedEvent[]) => {
|
||||||
|
const members = new Set<string>()
|
||||||
|
|
||||||
|
for (const event of sortEventsAsc(events)) {
|
||||||
|
if (event.kind === RELAY_MEMBERS) {
|
||||||
|
members.clear()
|
||||||
|
|
||||||
|
for (const pubkey of uniq(getTagValues("member", event.tags))) {
|
||||||
|
members.add(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const pubkeys = getPubkeyTagValues(event.tags)
|
||||||
|
|
||||||
|
if (event.kind === RELAY_ADD_MEMBER) {
|
||||||
|
for (const pubkey of pubkeys) {
|
||||||
|
members.add(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === RELAY_REMOVE_MEMBER) {
|
||||||
|
for (const pubkey of pubkeys) {
|
||||||
|
members.delete(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(members)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => {
|
||||||
|
const members = new Set<string>()
|
||||||
|
|
||||||
|
for (const event of sortEventsAsc(events)) {
|
||||||
|
if (event.kind === ROOM_MEMBERS && getTagValue("d", event.tags) === h) {
|
||||||
|
members.clear()
|
||||||
|
|
||||||
|
for (const pubkey of uniq(getPubkeyTagValues(event.tags))) {
|
||||||
|
members.add(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getTagValue("h", event.tags) !== h) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const pubkeys = getPubkeyTagValues(event.tags)
|
||||||
|
|
||||||
|
if (event.kind === ROOM_ADD_MEMBER) {
|
||||||
|
for (const pubkey of pubkeys) {
|
||||||
|
members.add(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === ROOM_REMOVE_MEMBER) {
|
||||||
|
for (const pubkey of pubkeys) {
|
||||||
|
members.delete(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(members)
|
||||||
|
}
|
||||||
|
|
||||||
export const deriveSpaceMembers = (url: string) =>
|
export const deriveSpaceMembers = (url: string) =>
|
||||||
derived(
|
derived(
|
||||||
deriveRelaySignedEvents(url, [{kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS]}]),
|
deriveRelaySignedEvents(url, [{kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS]}]),
|
||||||
$events => {
|
$events => getSpaceMembers(url, $events),
|
||||||
const membersEvent = $events.find(spec({kind: RELAY_MEMBERS}))
|
|
||||||
|
|
||||||
if (membersEvent) {
|
|
||||||
return uniq(getTagValues("member", membersEvent.tags))
|
|
||||||
}
|
|
||||||
|
|
||||||
const members = new Set<string>()
|
|
||||||
|
|
||||||
for (const event of sortBy(e => e.created_at, $events)) {
|
|
||||||
const pubkeys = getPubkeyTagValues(event.tags)
|
|
||||||
|
|
||||||
if (event.kind === RELAY_ADD_MEMBER) {
|
|
||||||
for (const pubkey of pubkeys) {
|
|
||||||
members.add(pubkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.kind === RELAY_REMOVE_MEMBER) {
|
|
||||||
for (const pubkey of pubkeys) {
|
|
||||||
members.delete(pubkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(members)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export type BannedPubkeyItem = {
|
export type BannedPubkeyItem = {
|
||||||
@@ -863,33 +912,7 @@ export const deriveRoomMembers = (url: string, h: string) => {
|
|||||||
{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]},
|
{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]},
|
||||||
]
|
]
|
||||||
|
|
||||||
return derived(deriveEventsForUrl(url, filters), $events => {
|
return derived(deriveEventsForUrl(url, filters), $events => getRoomMembers(url, h, $events))
|
||||||
const membersEvent = find(spec({kind: ROOM_MEMBERS}), $events)
|
|
||||||
|
|
||||||
if (membersEvent) {
|
|
||||||
return uniq(getPubkeyTagValues(membersEvent.tags))
|
|
||||||
}
|
|
||||||
|
|
||||||
const members = new Set<string>()
|
|
||||||
|
|
||||||
for (const event of sortEventsAsc($events)) {
|
|
||||||
const pubkeys = getPubkeyTagValues(event.tags)
|
|
||||||
|
|
||||||
if (event.kind === ROOM_ADD_MEMBER) {
|
|
||||||
for (const pubkey of pubkeys) {
|
|
||||||
members.add(pubkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.kind === ROOM_REMOVE_MEMBER) {
|
|
||||||
for (const pubkey of pubkeys) {
|
|
||||||
members.delete(pubkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(members)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deriveRoomAdmins = (url: string, h: string) => {
|
export const deriveRoomAdmins = (url: string, h: string) => {
|
||||||
@@ -913,7 +936,7 @@ export const deriveSpaceActionItems = (url: string) =>
|
|||||||
derived(
|
derived(
|
||||||
deriveEventsForUrl(url, [
|
deriveEventsForUrl(url, [
|
||||||
{
|
{
|
||||||
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS],
|
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER],
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
$events => {
|
$events => {
|
||||||
@@ -928,8 +951,10 @@ export const deriveSpaceActionItems = (url: string) =>
|
|||||||
|
|
||||||
const roomJoins = roomEvents.filter(spec({kind: ROOM_JOIN}))
|
const roomJoins = roomEvents.filter(spec({kind: ROOM_JOIN}))
|
||||||
const roomLeaves = roomEvents.filter(spec({kind: ROOM_LEAVE}))
|
const roomLeaves = roomEvents.filter(spec({kind: ROOM_LEAVE}))
|
||||||
const roomMembersEvent = roomEvents.find(spec({kind: ROOM_MEMBERS}))
|
const roomMembershipEvents = roomEvents.filter(event =>
|
||||||
const roomMembers = getTagValues("p", roomMembersEvent?.tags ?? [])
|
[ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER].includes(event.kind),
|
||||||
|
)
|
||||||
|
const roomMembers = new Set(getRoomMembers(url, h, roomMembershipEvents))
|
||||||
|
|
||||||
pendingJoins.push(
|
pendingJoins.push(
|
||||||
...removeUndefined(
|
...removeUndefined(
|
||||||
@@ -937,8 +962,22 @@ export const deriveSpaceActionItems = (url: string) =>
|
|||||||
.map(sortEventsDesc)
|
.map(sortEventsDesc)
|
||||||
.map(first),
|
.map(first),
|
||||||
).filter(({pubkey, created_at}) => {
|
).filter(({pubkey, created_at}) => {
|
||||||
if (roomMembers.includes(pubkey)) return false
|
if (roomMembers.has(pubkey)) return false
|
||||||
if (gt(roomMembersEvent?.created_at, created_at)) return false
|
if (
|
||||||
|
roomMembershipEvents.some(event => {
|
||||||
|
if (event.created_at <= created_at) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === ROOM_MEMBERS) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return getPubkeyTagValues(event.tags).includes(pubkey)
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (roomLeaves.some(e => e.pubkey === pubkey && e.created_at > created_at)) return false
|
if (roomLeaves.some(e => e.pubkey === pubkey && e.created_at > created_at)) return false
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|||||||
+96
-73
@@ -1,7 +1,8 @@
|
|||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import type {Unsubscriber} from "svelte/store"
|
import type {Unsubscriber} from "svelte/store"
|
||||||
import {derived, get} from "svelte/store"
|
import {last, call, ifLet, assoc, chunk, WEEK, ago} from "@welshman/lib"
|
||||||
import {last, call, ifLet, assoc, chunk, sleep, identity, WEEK, ago} from "@welshman/lib"
|
import {PollResponse} from "nostr-tools/kinds"
|
||||||
|
import {merged} from "@welshman/store"
|
||||||
import {
|
import {
|
||||||
getListTags,
|
getListTags,
|
||||||
getRelayTagValues,
|
getRelayTagValues,
|
||||||
@@ -12,6 +13,8 @@ import {
|
|||||||
ROOM_MEMBERS,
|
ROOM_MEMBERS,
|
||||||
ROOM_ADD_MEMBER,
|
ROOM_ADD_MEMBER,
|
||||||
ROOM_REMOVE_MEMBER,
|
ROOM_REMOVE_MEMBER,
|
||||||
|
ROOM_JOIN,
|
||||||
|
ROOM_LEAVE,
|
||||||
ROOM_CREATE_PERMISSION,
|
ROOM_CREATE_PERMISSION,
|
||||||
RELAY_MEMBERS,
|
RELAY_MEMBERS,
|
||||||
RELAY_ADD_MEMBER,
|
RELAY_ADD_MEMBER,
|
||||||
@@ -20,12 +23,11 @@ import {
|
|||||||
unionFilters,
|
unionFilters,
|
||||||
getTagValue,
|
getTagValue,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import type {Filter, TrustedEvent} from "@welshman/util"
|
import type {Filter, List, PublishedList, TrustedEvent} from "@welshman/util"
|
||||||
import {request, requestOne, Difference, DifferenceEvent} from "@welshman/net"
|
import {request, requestOne, Difference, DifferenceEvent} from "@welshman/net"
|
||||||
import {
|
import {
|
||||||
pubkey,
|
pubkey,
|
||||||
loadRelay,
|
loadRelay,
|
||||||
userFollowList,
|
|
||||||
userRelayList,
|
userRelayList,
|
||||||
userMessagingRelayList,
|
userMessagingRelayList,
|
||||||
loadRelayList,
|
loadRelayList,
|
||||||
@@ -48,7 +50,6 @@ import {
|
|||||||
loadGroupList,
|
loadGroupList,
|
||||||
userSpaceUrls,
|
userSpaceUrls,
|
||||||
userGroupList,
|
userGroupList,
|
||||||
bootstrapPubkeys,
|
|
||||||
decodeRelay,
|
decodeRelay,
|
||||||
getSpaceUrlsFromGroupList,
|
getSpaceUrlsFromGroupList,
|
||||||
getSpaceRoomsFromGroupList,
|
getSpaceRoomsFromGroupList,
|
||||||
@@ -56,7 +57,7 @@ import {
|
|||||||
loadFeedsForPubkey,
|
loadFeedsForPubkey,
|
||||||
} from "@app/core/state"
|
} from "@app/core/state"
|
||||||
import {hasBlossomSupport} from "@app/core/commands"
|
import {hasBlossomSupport} from "@app/core/commands"
|
||||||
import {LIVEKIT_PARTICIPANTS} from "@app/voice"
|
import {LIVEKIT_PARTICIPANTS} from "@app/call/voice"
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
|
|
||||||
@@ -73,6 +74,8 @@ const pullOneWithFallback = async (
|
|||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
onEvent?: (event: TrustedEvent) => void,
|
onEvent?: (event: TrustedEvent) => void,
|
||||||
) => {
|
) => {
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
const cachedEvents = repository.query([filter]).filter(isSignedEvent)
|
const cachedEvents = repository.query([filter]).filter(isSignedEvent)
|
||||||
const since = last(cachedEvents.slice(10))?.created_at || 0
|
const since = last(cachedEvents.slice(10))?.created_at || 0
|
||||||
|
|
||||||
@@ -85,6 +88,12 @@ const pullOneWithFallback = async (
|
|||||||
const shouldFallback =
|
const shouldFallback =
|
||||||
!hasNegentropy(url) ||
|
!hasNegentropy(url) ||
|
||||||
(await new Promise(resolve => {
|
(await new Promise(resolve => {
|
||||||
|
if (signal.aborted) {
|
||||||
|
resolve(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If teardown wins while the diff is opening, skip the fallback path and let cleanup stay in control.
|
||||||
const diff = new Difference({relay: url, filter, events: cachedEvents, signal})
|
const diff = new Difference({relay: url, filter, events: cachedEvents, signal})
|
||||||
|
|
||||||
diff.on(DifferenceEvent.Error, () => {
|
diff.on(DifferenceEvent.Error, () => {
|
||||||
@@ -110,9 +119,7 @@ export const pullWithFallback = async ({url, signal, filters, onEvent}: SyncOpts
|
|||||||
|
|
||||||
if (signal.aborted) return
|
if (signal.aborted) return
|
||||||
|
|
||||||
for (const filter of filters) {
|
await Promise.all(filters.map(filter => pullOneWithFallback(url, filter, signal, onEvent)))
|
||||||
pullOneWithFallback(url, filter, signal, onEvent)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const listen = ({url, signal, filters, onEvent}: SyncOpts) => {
|
const listen = ({url, signal, filters, onEvent}: SyncOpts) => {
|
||||||
@@ -122,6 +129,8 @@ const listen = ({url, signal, filters, onEvent}: SyncOpts) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pullAndListen = (options: SyncOpts) => {
|
const pullAndListen = (options: SyncOpts) => {
|
||||||
|
if (options.signal.aborted) return
|
||||||
|
|
||||||
pullWithFallback(options)
|
pullWithFallback(options)
|
||||||
listen(options)
|
listen(options)
|
||||||
}
|
}
|
||||||
@@ -196,7 +205,7 @@ const syncUserRoomMembership = (url: string, h: string) => {
|
|||||||
const syncUserData = () => {
|
const syncUserData = () => {
|
||||||
const unsubscribersByKey = new Map<string, Unsubscriber>()
|
const unsubscribersByKey = new Map<string, Unsubscriber>()
|
||||||
|
|
||||||
const unsubscribeGroupList = userGroupList.subscribe($userGroupList => {
|
const syncGroupList = ($userGroupList: List | undefined) => {
|
||||||
if ($userGroupList) {
|
if ($userGroupList) {
|
||||||
const keys = new Set<string>()
|
const keys = new Set<string>()
|
||||||
|
|
||||||
@@ -225,43 +234,35 @@ const syncUserData = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncRelayList = ($userRelayList: PublishedList | undefined) => {
|
||||||
|
const pubkey = $userRelayList?.event?.pubkey
|
||||||
|
|
||||||
|
if (!pubkey) return
|
||||||
|
|
||||||
|
loadBlossomServerList(pubkey)
|
||||||
|
loadBlockedRelayList(pubkey)
|
||||||
|
loadFollowList(pubkey)
|
||||||
|
loadGroupList(pubkey)
|
||||||
|
loadMuteList(pubkey)
|
||||||
|
loadProfile(pubkey)
|
||||||
|
loadSettings(pubkey)
|
||||||
|
loadFeedsForPubkey(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribeGroupList = merged([userGroupList]).subscribe(([$userGroupList]) => {
|
||||||
|
syncGroupList($userGroupList)
|
||||||
})
|
})
|
||||||
|
|
||||||
const unsubscribeRelayList = userRelayList.subscribe($userRelayList => {
|
const unsubscribeRelayList = merged([userRelayList]).subscribe(([$userRelayList]) => {
|
||||||
if ($userRelayList) {
|
syncRelayList($userRelayList)
|
||||||
loadBlossomServerList($userRelayList.event.pubkey)
|
|
||||||
loadBlockedRelayList($userRelayList.event.pubkey)
|
|
||||||
loadFollowList($userRelayList.event.pubkey)
|
|
||||||
loadGroupList($userRelayList.event.pubkey)
|
|
||||||
loadMuteList($userRelayList.event.pubkey)
|
|
||||||
loadProfile($userRelayList.event.pubkey)
|
|
||||||
loadSettings($userRelayList.event.pubkey)
|
|
||||||
loadFeedsForPubkey($userRelayList.event.pubkey)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const unsubscribeFollows = userFollowList.subscribe(async $userFollowList => {
|
|
||||||
for (const pubkeys of chunk(10, get(bootstrapPubkeys))) {
|
|
||||||
// This isn't urgent, avoid clogging other stuff up
|
|
||||||
await sleep(1000)
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
pubkeys.flatMap(pk => [
|
|
||||||
loadRelayList(pk),
|
|
||||||
loadGroupList(pk),
|
|
||||||
loadProfile(pk),
|
|
||||||
loadFollowList(pk),
|
|
||||||
loadMuteList(pk),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribersByKey.forEach(call)
|
unsubscribersByKey.forEach(call)
|
||||||
unsubscribeGroupList()
|
unsubscribeGroupList()
|
||||||
unsubscribeRelayList()
|
unsubscribeRelayList()
|
||||||
unsubscribeFollows()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,8 +280,14 @@ const syncSpace = (url: string, rooms: string[]) => {
|
|||||||
url,
|
url,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
filters: [
|
filters: [
|
||||||
|
{kinds: [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS], "#d": [room]},
|
||||||
{kinds: MESSAGE_KINDS, since, "#h": [room]},
|
{kinds: MESSAGE_KINDS, since, "#h": [room]},
|
||||||
makeCommentFilter(CONTENT_KINDS, {since, "#h": [room]}),
|
makeCommentFilter(CONTENT_KINDS, {since, "#h": [room]}),
|
||||||
|
{
|
||||||
|
kinds: [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER],
|
||||||
|
"#h": [room],
|
||||||
|
},
|
||||||
|
{kinds: [PollResponse], since},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -292,17 +299,15 @@ const syncSpace = (url: string, rooms: string[]) => {
|
|||||||
|
|
||||||
const relayKinds = [RELAY_MEMBERS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]
|
const relayKinds = [RELAY_MEMBERS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]
|
||||||
const roomMetaKinds = [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS]
|
const roomMetaKinds = [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS]
|
||||||
const roomMemberKinds = [ROOM_DELETE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER]
|
const roomMemberKinds = [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER]
|
||||||
|
|
||||||
pullAndListen({
|
pullAndListen({
|
||||||
url,
|
url,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
filters: [
|
filters: [
|
||||||
{kinds: relayKinds},
|
{kinds: [...relayKinds, ...roomMetaKinds, ...roomMemberKinds, ...MESSAGE_KINDS]},
|
||||||
{kinds: roomMetaKinds},
|
|
||||||
{kinds: roomMemberKinds},
|
|
||||||
{kinds: MESSAGE_KINDS, since},
|
|
||||||
makeCommentFilter(CONTENT_KINDS, {since}),
|
makeCommentFilter(CONTENT_KINDS, {since}),
|
||||||
|
{kinds: [PollResponse], since},
|
||||||
],
|
],
|
||||||
onEvent: event => {
|
onEvent: event => {
|
||||||
if (event.kind === ROOM_META) {
|
if (event.kind === ROOM_META) {
|
||||||
@@ -314,22 +319,23 @@ const syncSpace = (url: string, rooms: string[]) => {
|
|||||||
listen({
|
listen({
|
||||||
url,
|
url,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
filters: [{kinds: REACTION_KINDS}],
|
filters: [{kinds: REACTION_KINDS}, {kinds: [PollResponse]}],
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncSpaces = () => {
|
const syncSpaces = () => {
|
||||||
const store = derived([userGroupList, page], identity)
|
const store = merged([userGroupList, page])
|
||||||
const unsubscribersByUrl = new Map<string, Unsubscriber>()
|
const unsubscribersByUrl = new Map<string, Unsubscriber>()
|
||||||
const roomsByUrl = new Map<string, string>()
|
const roomsByUrl = new Map<string, string>()
|
||||||
|
|
||||||
const unsubscribe = store.subscribe(([$userGroupList, $page]) => {
|
const unsubscribe = store.subscribe(([$userGroupList, $page]) => {
|
||||||
const urls = new Set(getSpaceUrlsFromGroupList($userGroupList))
|
const urls = new Set(getSpaceUrlsFromGroupList($userGroupList))
|
||||||
|
const currentUrl = $page.params.relay ? decodeRelay($page.params.relay) : undefined
|
||||||
|
|
||||||
if ($page.params.relay) {
|
if (currentUrl) {
|
||||||
urls.add(decodeRelay($page.params.relay))
|
urls.add(currentUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop syncing removed spaces
|
// Stop syncing removed spaces
|
||||||
@@ -344,6 +350,11 @@ const syncSpaces = () => {
|
|||||||
// Start or restart syncing for each space
|
// Start or restart syncing for each space
|
||||||
for (const url of urls) {
|
for (const url of urls) {
|
||||||
const rooms = getSpaceRoomsFromGroupList(url, $userGroupList)
|
const rooms = getSpaceRoomsFromGroupList(url, $userGroupList)
|
||||||
|
|
||||||
|
if (currentUrl === url && $page.params.h && !rooms.includes($page.params.h)) {
|
||||||
|
rooms.push($page.params.h)
|
||||||
|
}
|
||||||
|
|
||||||
const roomsKey = rooms.join(",")
|
const roomsKey = rooms.join(",")
|
||||||
|
|
||||||
if (unsubscribersByUrl.has(url) && roomsByUrl.get(url) === roomsKey) continue
|
if (unsubscribersByUrl.has(url) && roomsByUrl.get(url) === roomsKey) continue
|
||||||
@@ -383,6 +394,7 @@ const syncDMs = () => {
|
|||||||
const unsubscribersByUrl = new Map<string, Unsubscriber>()
|
const unsubscribersByUrl = new Map<string, Unsubscriber>()
|
||||||
|
|
||||||
let currentPubkey: string | undefined
|
let currentPubkey: string | undefined
|
||||||
|
let currentShouldUnwrap = false
|
||||||
|
|
||||||
const unsubscribeAll = () => {
|
const unsubscribeAll = () => {
|
||||||
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
|
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
|
||||||
@@ -391,6 +403,34 @@ const syncDMs = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const syncPubkey = ($pubkey: string | undefined, $shouldUnwrap: boolean) => {
|
||||||
|
if ($pubkey !== currentPubkey) {
|
||||||
|
unsubscribeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pubkey && $shouldUnwrap) {
|
||||||
|
loadRelayList($pubkey)
|
||||||
|
.then(() => loadMessagingRelayList($pubkey))
|
||||||
|
.then($l => {
|
||||||
|
if ($l && currentPubkey === $pubkey && currentShouldUnwrap === $shouldUnwrap) {
|
||||||
|
subscribeAll($pubkey, getRelayTagValues(getListTags($l)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPubkey = $pubkey
|
||||||
|
currentShouldUnwrap = $shouldUnwrap
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncList = ($userMessagingRelayList: List | undefined) => {
|
||||||
|
const $pubkey = pubkey.get()
|
||||||
|
const $shouldUnwrap = shouldUnwrap.get()
|
||||||
|
|
||||||
|
if ($pubkey && $shouldUnwrap) {
|
||||||
|
subscribeAll($pubkey, getRelayTagValues(getListTags($userMessagingRelayList)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const subscribeAll = (pubkey: string, urls: string[]) => {
|
const subscribeAll = (pubkey: string, urls: string[]) => {
|
||||||
// Start syncing newly added relays
|
// Start syncing newly added relays
|
||||||
for (const url of urls) {
|
for (const url of urls) {
|
||||||
@@ -408,33 +448,16 @@ const syncDMs = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// When pubkey changes, re-sync
|
const unsubscribePubkey = merged([pubkey, shouldUnwrap]).subscribe(([$pubkey, $shouldUnwrap]) => {
|
||||||
const unsubscribePubkey = derived([pubkey, shouldUnwrap], identity).subscribe(
|
syncPubkey($pubkey, $shouldUnwrap)
|
||||||
([$pubkey, $shouldUnwrap]) => {
|
})
|
||||||
if ($pubkey !== currentPubkey) {
|
|
||||||
unsubscribeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have a pubkey, refresh our user's relay list then sync our subscriptions
|
|
||||||
if ($pubkey && $shouldUnwrap) {
|
|
||||||
loadRelayList($pubkey)
|
|
||||||
.then(() => loadMessagingRelayList($pubkey))
|
|
||||||
.then($l => subscribeAll($pubkey, getRelayTagValues(getListTags($l))))
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPubkey = $pubkey
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// When user messaging relays change, update synchronization
|
// When user messaging relays change, update synchronization
|
||||||
const unsubscribeList = userMessagingRelayList.subscribe($userMessagingRelayList => {
|
const unsubscribeList = merged([userMessagingRelayList]).subscribe(
|
||||||
const $pubkey = pubkey.get()
|
([$userMessagingRelayList]) => {
|
||||||
const $shouldUnwrap = shouldUnwrap.get()
|
syncList($userMessagingRelayList)
|
||||||
|
},
|
||||||
if ($pubkey && $shouldUnwrap) {
|
)
|
||||||
subscribeAll($pubkey, getRelayTagValues(getListTags($userMessagingRelayList)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribeAll()
|
unsubscribeAll()
|
||||||
|
|||||||
@@ -4,20 +4,25 @@
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
editor: Promise<Editor>
|
editor: Promise<Editor>
|
||||||
|
autofocus?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {editor}: Props = $props()
|
const {editor, autofocus}: Props = $props()
|
||||||
|
|
||||||
let element: HTMLElement
|
let element: HTMLElement
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
editor.then(({options}) => {
|
editor.then(ed => {
|
||||||
if (options.element) {
|
if (ed.options.element) {
|
||||||
element?.append(options.element)
|
element?.append(ed.options.element)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.autofocus) {
|
if (autofocus) {
|
||||||
;(element?.querySelector("[contenteditable]") as HTMLElement)?.focus()
|
const hasContent = ed.getText().trim().length > 0
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ed.commands.focus(hasContent ? "end" : "start")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ import {pushToast} from "@app/util/toast"
|
|||||||
export const makeEditor = async ({
|
export const makeEditor = async ({
|
||||||
encryptFiles = false,
|
encryptFiles = false,
|
||||||
aggressive = false,
|
aggressive = false,
|
||||||
autofocus = false,
|
|
||||||
charCount,
|
charCount,
|
||||||
content = "",
|
content = "",
|
||||||
|
onChange,
|
||||||
placeholder = "",
|
placeholder = "",
|
||||||
url,
|
url,
|
||||||
submit,
|
submit,
|
||||||
@@ -36,9 +36,9 @@ export const makeEditor = async ({
|
|||||||
}: {
|
}: {
|
||||||
encryptFiles?: boolean
|
encryptFiles?: boolean
|
||||||
aggressive?: boolean
|
aggressive?: boolean
|
||||||
autofocus?: boolean
|
|
||||||
charCount?: Writable<number>
|
charCount?: Writable<number>
|
||||||
content?: string
|
content?: string | object
|
||||||
|
onChange?: (json: object) => void
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
url?: string
|
url?: string
|
||||||
submit: () => void
|
submit: () => void
|
||||||
@@ -82,9 +82,8 @@ export const makeEditor = async ({
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return new Editor({
|
const ed = new Editor({
|
||||||
content: escapeHtml(content),
|
content: typeof content === "string" ? escapeHtml(content) : content,
|
||||||
autofocus,
|
|
||||||
editorProps,
|
editorProps,
|
||||||
element: document.createElement("div"),
|
element: document.createElement("div"),
|
||||||
extensions: [
|
extensions: [
|
||||||
@@ -142,6 +141,9 @@ export const makeEditor = async ({
|
|||||||
onUpdate({editor}) {
|
onUpdate({editor}) {
|
||||||
wordCount?.set(editor.storage.wordCount.words)
|
wordCount?.set(editor.storage.wordCount.words)
|
||||||
charCount?.set(editor.storage.wordCount.chars)
|
charCount?.set(editor.storage.wordCount.chars)
|
||||||
|
onChange?.(editor.getJSON())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return ed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const store = new Map<string, unknown>()
|
||||||
|
|
||||||
|
export class DraftKey<T> {
|
||||||
|
constructor(private key: string) {}
|
||||||
|
|
||||||
|
get(): T | undefined {
|
||||||
|
return store.get(this.key) as T | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
set(value: T): void {
|
||||||
|
store.set(this.key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
update(value: Partial<T>): void {
|
||||||
|
this.set({...this.get(), ...value} as T)
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
store.delete(this.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {now, removeUndefined, uniq} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {getTagValue, getTags, getTagValues} from "@welshman/util"
|
||||||
|
|
||||||
|
export type PollType = "singlechoice" | "multiplechoice"
|
||||||
|
|
||||||
|
export type PollOption = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
votes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPollType = (event: TrustedEvent): PollType =>
|
||||||
|
getTagValue("polltype", event.tags) === "multiplechoice" ? "multiplechoice" : "singlechoice"
|
||||||
|
|
||||||
|
export const getPollOptions = (event: TrustedEvent) =>
|
||||||
|
removeUndefined(
|
||||||
|
getTags("option", event.tags).map(tag => {
|
||||||
|
const [, id, label = id] = tag
|
||||||
|
|
||||||
|
if (!id) return undefined
|
||||||
|
|
||||||
|
return {id, label}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const getPollEndsAt = (event: TrustedEvent) => {
|
||||||
|
const endsAt = getTagValue("endsAt", event.tags)
|
||||||
|
|
||||||
|
if (!endsAt) return undefined
|
||||||
|
|
||||||
|
const timestamp = parseInt(endsAt)
|
||||||
|
|
||||||
|
return Number.isNaN(timestamp) ? undefined : timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isPollClosed = (event: TrustedEvent) => {
|
||||||
|
const endsAt = getPollEndsAt(event)
|
||||||
|
|
||||||
|
return typeof endsAt === "number" ? endsAt <= now() : false
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPollResponseSelections = (event: TrustedEvent, pollType = getPollType(event)) => {
|
||||||
|
const selections = getTagValues("response", event.tags)
|
||||||
|
|
||||||
|
return pollType === "singlechoice" ? selections.slice(0, 1) : uniq(selections)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPollResults = (event: TrustedEvent, responses: TrustedEvent[]) => {
|
||||||
|
const options = getPollOptions(event).map(option => ({...option, votes: 0}))
|
||||||
|
const counts = new Map(options.map(option => [option.id, option]))
|
||||||
|
const latestByPubkey = new Map<string, TrustedEvent>()
|
||||||
|
|
||||||
|
for (const response of responses) {
|
||||||
|
const current = latestByPubkey.get(response.pubkey)
|
||||||
|
|
||||||
|
if (!current || response.created_at > current.created_at) {
|
||||||
|
latestByPubkey.set(response.pubkey, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const response of latestByPubkey.values()) {
|
||||||
|
for (const optionId of getPollResponseSelections(response, getPollType(event))) {
|
||||||
|
const option = counts.get(optionId)
|
||||||
|
|
||||||
|
if (option) {
|
||||||
|
option.votes += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
voters: latestByPubkey.size,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import {throttle} from "throttle-debounce"
|
import {throttle} from "throttle-debounce"
|
||||||
import {App} from "@capacitor/app"
|
import {App} from "@capacitor/app"
|
||||||
import {registerPlugin} from "@capacitor/core"
|
import {registerPlugin} from "@capacitor/core"
|
||||||
import {pubkey, getSession} from "@welshman/app"
|
import {session} from "@welshman/app"
|
||||||
import type {Session} from "@welshman/app"
|
import type {Session} from "@welshman/app"
|
||||||
import {maybe, now} from "@welshman/lib"
|
import {maybe, now} from "@welshman/lib"
|
||||||
import type {Filter} from "@welshman/util"
|
import type {Filter} from "@welshman/util"
|
||||||
@@ -44,7 +44,7 @@ export class AndroidFallbackNotifications implements IPushAdapter {
|
|||||||
const doSync = throttle(1000, () => {
|
const doSync = throttle(1000, () => {
|
||||||
AndroidPushFallback.syncState({
|
AndroidPushFallback.syncState({
|
||||||
state: {
|
state: {
|
||||||
session: pubkey.get() ? getSession(pubkey.get()!) : undefined,
|
session: session.get(),
|
||||||
activeSince: this._activeSince,
|
activeSince: this._activeSince,
|
||||||
subscriptions: Array.from(this._subscriptions.values()),
|
subscriptions: Array.from(this._subscriptions.values()),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {page} from "$app/stores"
|
|||||||
import {nthEq} from "@welshman/lib"
|
import {nthEq} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {getAddress} from "@welshman/util"
|
import {getAddress} from "@welshman/util"
|
||||||
|
import {Poll} from "nostr-tools/kinds"
|
||||||
import {tracker, userMessagingRelayList} from "@welshman/app"
|
import {tracker, userMessagingRelayList} from "@welshman/app"
|
||||||
import {identity} from "@welshman/lib"
|
import {identity} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
@@ -90,6 +91,8 @@ export const makeClassifiedPath = (url: string, address?: string) =>
|
|||||||
export const makeCalendarPath = (url: string, address?: string) =>
|
export const makeCalendarPath = (url: string, address?: string) =>
|
||||||
makeSpacePath(url, "calendar", address)
|
makeSpacePath(url, "calendar", address)
|
||||||
|
|
||||||
|
export const makePollPath = (url: string, id?: string) => makeSpacePath(url, "polls", id)
|
||||||
|
|
||||||
export const scrollToEvent = (id: string) => {
|
export const scrollToEvent = (id: string) => {
|
||||||
const element = document.querySelector(`[data-event="${id}"]`) as any
|
const element = document.querySelector(`[data-event="${id}"]`) as any
|
||||||
|
|
||||||
@@ -146,6 +149,10 @@ export const getEventPath = (event: TrustedEvent, urls: string[]) => {
|
|||||||
return makeCalendarPath(url, getAddress(event))
|
return makeCalendarPath(url, getAddress(event))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.kind === Poll) {
|
||||||
|
return makePollPath(url, event.id)
|
||||||
|
}
|
||||||
|
|
||||||
if (event.kind === MESSAGE) {
|
if (event.kind === MESSAGE) {
|
||||||
return makeMessagePath(url, event)
|
return makeMessagePath(url, event)
|
||||||
}
|
}
|
||||||
@@ -192,5 +199,7 @@ export const getRoomItemPath = (url: string, event: TrustedEvent) => {
|
|||||||
return makeGoalPath(url, event.id)
|
return makeGoalPath(url, event.id)
|
||||||
case EVENT_TIME:
|
case EVENT_TIME:
|
||||||
return makeCalendarPath(url, getAddress(event))
|
return makeCalendarPath(url, getAddress(event))
|
||||||
|
case Poll:
|
||||||
|
return makePollPath(url, event.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ const FALLBACK_APP_NAME = "Flotilla"
|
|||||||
const staticTitles = new Map<string, string>([
|
const staticTitles = new Map<string, string>([
|
||||||
["/", "Redirecting"],
|
["/", "Redirecting"],
|
||||||
["/home", "Home"],
|
["/home", "Home"],
|
||||||
["/discover", "Join a Space"],
|
["/spaces", "Spaces"],
|
||||||
["/spaces", "Your Spaces"],
|
|
||||||
["/spaces/create", "Create a Space"],
|
["/spaces/create", "Create a Space"],
|
||||||
["/spaces/[relay]", "Space"],
|
["/spaces/[relay]", "Space"],
|
||||||
["/spaces/[relay]/chat", "Space Chat"],
|
["/spaces/[relay]/chat", "Space Chat"],
|
||||||
@@ -18,6 +17,7 @@ const staticTitles = new Map<string, string>([
|
|||||||
["/spaces/[relay]/classifieds", "Classifieds"],
|
["/spaces/[relay]/classifieds", "Classifieds"],
|
||||||
["/spaces/[relay]/calendar", "Calendar"],
|
["/spaces/[relay]/calendar", "Calendar"],
|
||||||
["/spaces/[relay]/goals", "Goals"],
|
["/spaces/[relay]/goals", "Goals"],
|
||||||
|
["/spaces/[relay]/polls", "Polls"],
|
||||||
["/chat", "Messages"],
|
["/chat", "Messages"],
|
||||||
["/join", "Join Space"],
|
["/join", "Join Space"],
|
||||||
["/people", "Find People"],
|
["/people", "Find People"],
|
||||||
@@ -36,6 +36,7 @@ const eventRoutes = new Set([
|
|||||||
"/spaces/[relay]/goals/[id]",
|
"/spaces/[relay]/goals/[id]",
|
||||||
"/spaces/[relay]/calendar/[address]",
|
"/spaces/[relay]/calendar/[address]",
|
||||||
"/spaces/[relay]/classifieds/[address]",
|
"/spaces/[relay]/classifieds/[address]",
|
||||||
|
"/spaces/[relay]/polls/[id]",
|
||||||
])
|
])
|
||||||
|
|
||||||
type RouteParams = Record<string, string | undefined>
|
type RouteParams = Record<string, string | undefined>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user