Compare commits

..

8 Commits

Author SHA1 Message Date
mplorentz 93359011a1 Drop camera settings from join room dialog 2026-04-02 11:34:20 -04:00
mplorentz e88ff822cb Merge feature/98-audio-settings-in-call into video-demo
Resolve VoiceWidget conflict: keep camera, screen share, and call settings.
Extend join flow with optional camera on join and device picker.
Add camera to in-call settings; rename UI to Call settings.

Made-with: Cursor
2026-04-02 11:21:29 -04:00
mplorentz 7afc09809a Change screen sharing icon 2026-04-02 11:14:16 -04:00
mplorentz c1c3cabf89 Improve pinned video layout 2026-04-02 11:14:16 -04:00
mplorentz fe9d9970d7 Add a button to spotlight a video feed 2026-04-02 11:14:16 -04:00
mplorentz ef291006e2 Add basic screen sharing 2026-04-02 11:13:56 -04:00
mplorentz 5792f77fdc add video to livekit calls 2026-04-02 11:13:10 -04:00
mplorentz 462086da53 Add settings button to configure audio devices in call 2026-04-01 14:22:44 -04:00
149 changed files with 2182 additions and 4469 deletions
+1 -1
View File
@@ -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
-1
View File
@@ -19,6 +19,5 @@ 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=
+1 -1
View File
@@ -13,4 +13,4 @@ ios/App/Pods/
android/capacitor-cordova-android-plugins android/capacitor-cordova-android-plugins
android/app/src/androidTest android/app/src/androidTest
android/app/src/test android/app/src/test
node_modules
+1 -4
View File
@@ -1,6 +1,6 @@
# Env # Env
.env
.env.local .env.local
.env.*.local
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
@@ -28,7 +28,6 @@ node_modules/
.pnpm-store/ .pnpm-store/
build/ build/
.svelte-kit/ .svelte-kit/
.next/
# Rust/Tauri # Rust/Tauri
*target/ *target/
@@ -70,9 +69,7 @@ GoogleService-Info.plist
.roo .roo
.idea/ .idea/
.vscode/ .vscode/
.claude/
# OS generated # OS generated
.DS_Store .DS_Store
Thumbs.db Thumbs.db
package-lock.json
-56
View File
@@ -1,56 +0,0 @@
## 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.
+1 -3
View File
@@ -16,13 +16,11 @@ 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](CONTRIBUTING.md). See [CONTRIBUTING.md](AGENTS.md).
## Deployment ## Deployment
-3
View File
@@ -44,7 +44,4 @@
<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>
@@ -7,7 +7,6 @@ import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequest
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequest import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager import androidx.work.WorkManager
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
@@ -77,7 +76,6 @@ class AndroidPushFallbackPlugin : Plugin() {
val immediate = OneTimeWorkRequest.Builder(AndroidPushFallbackWorker::class.java) val immediate = OneTimeWorkRequest.Builder(AndroidPushFallbackWorker::class.java)
.setConstraints(constraints) .setConstraints(constraints)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build() .build()
workManager.enqueueUniquePeriodicWork( workManager.enqueueUniquePeriodicWork(
@@ -43,7 +43,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
private const val TAG = "PushFallback" private const val TAG = "PushFallback"
private const val CHANNEL_ID = "flotilla_fallback" private const val CHANNEL_ID = "flotilla_fallback"
private const val CURSOR_PREFIX = "androidPushFallback.cursor." private const val CURSOR_PREFIX = "androidPushFallback.cursor."
private const val SOCKET_TIMEOUT_SECONDS = 30L private const val SOCKET_TIMEOUT_SECONDS = 20L
private const val REJECTED = "__REJECTED__" private const val REJECTED = "__REJECTED__"
private const val KIND_RELAY_AUTH = 22242 private const val KIND_RELAY_AUTH = 22242
private const val KIND_NIP46_RPC = 24133 private const val KIND_NIP46_RPC = 24133
@@ -72,8 +72,6 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
} }
override fun doWork(): Result { override fun doWork(): Result {
Log.i(TAG, "doWork() started")
if (isAppInForeground()) { if (isAppInForeground()) {
return Result.success() return Result.success()
} }
@@ -90,7 +88,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
val activeSince = state.optLong("activeSince", 0L) val activeSince = state.optLong("activeSince", 0L)
val seen = mutableSetOf<String>() val seen = mutableSetOf<String>()
val newEvents = mutableListOf<Pair<String, JSONObject>>() var latestPair: Pair<String, JSONObject>? = null
for (sub in subscriptions) { for (sub in subscriptions) {
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
@@ -104,19 +102,23 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
for (event in result.events) { for (event in result.events) {
val id = event.optString("id", "") val id = event.optString("id", "")
if (id.isNotEmpty() && seen.add(id)) { if (id.isNotEmpty() && seen.add(id)) {
newEvents.add(Pair(sub.relay, event)) val createdAt = event.optLong("created_at", 0L)
if (latestPair == null || createdAt > latestPair!!.second.optLong("created_at", 0L)) {
latestPair = Pair(sub.relay, event)
}
} }
} }
} }
for ((relay, event) in newEvents) { if (latestPair != null) {
val (relay, event) = latestPair!!
postNotification(relay, event) postNotification(relay, event)
} }
return Result.success() return Result.success()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Worker failed", e) Log.e(TAG, "Worker failed", e)
return Result.retry() return Result.success()
} finally { } finally {
pool.closeAll() pool.closeAll()
client.dispatcher.executorService.shutdown() client.dispatcher.executorService.shutdown()
@@ -212,8 +214,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.build() .build()
val notificationId = id.hashCode().let { if (it == 0) 1 else it } NotificationManagerCompat.from(context).notify(1, notification)
NotificationManagerCompat.from(context).notify(notificationId, notification)
} }
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean { private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
+5 -2
View File
@@ -2,8 +2,11 @@
temp_env=$(declare -p -x) temp_env=$(declare -p -x)
if [ -f .env ]; then if [ -f .env.template ]; then
source .env source .env.template
fi
if [ -f .env.local ]; then
source .env.local
fi fi
# Avoid overwriting env vars provided directly # Avoid overwriting env vars provided directly
+1 -1
View File
@@ -392,7 +392,7 @@
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.2; MARKETING_VERSION = 1.7.5;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
+1 -3
View File
@@ -24,10 +24,8 @@
<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 when you enable it in a voice room.</string> <string>Flotilla uses the microphone for voice chat in rooms.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>remote-notification</string> <string>remote-notification</string>
+4 -7
View File
@@ -22,7 +22,6 @@
"@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",
@@ -36,7 +35,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": "^4.2.2", "tailwindcss": "^3.4.19",
"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"
@@ -48,7 +47,6 @@
"@capacitor/android": "^8.0.1", "@capacitor/android": "^8.0.1",
"@capacitor/app": "^8.0.0", "@capacitor/app": "^8.0.0",
"@capacitor/cli": "^8.0.1", "@capacitor/cli": "^8.0.1",
"@capacitor/clipboard": "^8.0.1",
"@capacitor/core": "^8.0.1", "@capacitor/core": "^8.0.1",
"@capacitor/filesystem": "^8.1.0", "@capacitor/filesystem": "^8.1.0",
"@capacitor/ios": "^8.0.1", "@capacitor/ios": "^8.0.1",
@@ -60,11 +58,10 @@
"@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.3", "@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",
"@tiptap/pm": "^2.27.2",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@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",
@@ -80,7 +77,7 @@
"@welshman/store": "^0.8.12", "@welshman/store": "^0.8.12",
"@welshman/util": "^0.8.12", "@welshman/util": "^0.8.12",
"compressorjs-next": "^1.1.2", "compressorjs-next": "^1.1.2",
"daisyui": "^5.5.19", "daisyui": "^4.12.24",
"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",
@@ -90,7 +87,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.7.2", "prettier-plugin-tailwindcss": "^0.6.14",
"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",
+378 -412
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,5 +1,6 @@
export default { export default {
plugins: { plugins: {
"@tailwindcss/postcss": {}, tailwindcss: {},
autoprefixer: {},
}, },
} }
+1 -1
View File
@@ -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"}) dotenv.config({path: ".env.template"})
export default defineConfig({ export default defineConfig({
preset, preset,
+305 -259
View File
@@ -1,25 +1,45 @@
@import "tailwindcss"; @import "@welshman/editor/index.css";
@config "../tailwind.config.js"; @tailwind base;
@tailwind components;
@tailwind utilities;
@utility pt-sai { /* Fonts */
padding-top: var(--sait);
@font-face {
font-family: "Satoshis";
font-style: normal;
font-weight: 400;
src:
local(""),
url("/fonts/Satoshi Symbol.ttf") format("truetype");
} }
@utility 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");
} }
@utility 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");
} }
@utility pl-sai { @font-face {
padding-left: var(--sail); font-family: "Lato";
} font-style: italic;
font-weight: 400;
@utility px-sai { src:
@apply pl-sai pr-sai; local(""),
url("/fonts/Italic.ttf") format("truetype");
} }
/* root */ /* root */
@@ -32,224 +52,98 @@
--sair: var(--safe-area-inset-right, env(safe-area-inset-right)); --sair: var(--safe-area-inset-right, env(safe-area-inset-right));
} }
@utility py-sai { [data-theme] {
@apply pt-sai pb-sai; @apply bg-base-300;
--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));
} }
@utility p-sai { .mobile [data-tip]::before {
@apply py-sai px-sai; display: none !important;
} }
@utility mt-sai { /* safe area insets */
margin-top: var(--sait);
}
@utility mr-sai { @layer components {
margin-right: var(--sair); .pt-sai {
} 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");
} }
@font-face { .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 { .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 { .pl-sai {
font-family: "Lato"; padding-left: var(--sail);
font-style: italic;
font-weight: 400;
src:
local(""),
url("/fonts/Italic.ttf") format("truetype");
} }
/* root */ .px-sai {
@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));
} }
[data-theme] { .py-sai {
@apply bg-base-300; @apply pt-sai pb-sai;
} }
.mobile [data-tip]::before { .p-sai {
display: none !important; @apply py-sai px-sai;
} }
/* safe area insets */ .mt-sai {
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 */
@@ -271,18 +165,110 @@
@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 text-base-content p-2 sm:p-4; @apply p-2 text-base-content 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 overflow-hidden text-ellipsis; @apply ellipsize;
}
.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,
@@ -292,21 +278,21 @@
} }
.tiptap { .tiptap {
--tiptap-object-bg: var(--color-neutral); --tiptap-object-bg: var(--neutral);
--tiptap-object-fg: var(--color-neutral-content); --tiptap-object-fg: var(--neutral-content);
--tiptap-active-bg: var(--color-primary); --tiptap-active-bg: var(--primary);
--tiptap-active-fg: var(--color-primary-content); --tiptap-active-fg: var(--primary-content);
} }
.tiptap-suggestions { .tiptap-suggestions {
--tiptap-object-bg: var(--color-base-100); --tiptap-object-bg: var(--base-100);
--tiptap-object-fg: var(--color-base-content); --tiptap-object-fg: var(--base-content);
--tiptap-active-bg: var(--color-base-300); --tiptap-active-bg: var(--base-300);
--tiptap-active-fg: var(--color-base-content); --tiptap-active-fg: var(--base-content);
} }
.tiptap-suggestions__item { .tiptap-suggestions__item {
@apply border-base-100 border-l-2 border-solid; @apply border-l-2 border-solid border-base-100;
} }
.tiptap-suggestions__selected { .tiptap-suggestions__selected {
@@ -326,13 +312,13 @@
} }
.note-editor .tiptap { .note-editor .tiptap {
--tiptap-object-bg: var(--color-base-200); --tiptap-object-bg: var(--base-200);
@apply input rounded-box block h-auto min-h-32 w-full p-[.65rem] pb-6; @apply input input-bordered h-auto min-h-32 rounded-box p-[.65rem] pb-6;
} }
.input-editor .tiptap { .input-editor .tiptap {
--tiptap-object-bg: var(--color-base-200); --tiptap-object-bg: var(--base-200);
@apply input h-auto p-[.65rem]; @apply input input-bordered h-auto p-[.65rem];
} }
/* link-content, based on tiptap */ /* link-content, based on tiptap */
@@ -344,8 +330,8 @@
white-space: nowrap; white-space: nowrap;
border-radius: 3px; border-radius: 3px;
padding: 0 0.25rem; padding: 0 0.25rem;
background-color: var(--color-base-100); background-color: var(--base-100);
color: var(--color-base-content); color: var(--base-content);
} }
/* content rendered by welshman/content */ /* content rendered by welshman/content */
@@ -361,31 +347,23 @@
/* date input */ /* date input */
.picker { .picker {
--date-picker-foreground: var(--color-base-content); --date-picker-foreground: var(--base-content);
--date-picker-background: var(--color-base-300); --date-picker-background: var(--base-300);
--date-picker-highlight-border: var(--color-primary); --date-picker-highlight-border: var(--primary);
--date-picker-selected-color: var(--color-primary-content); --date-picker-selected-color: var(--primary-content);
--date-picker-selected-background: var(--color-primary); --date-picker-selected-background: var(--primary);
} }
.date-time-field { .date-time-field {
@apply input rounded-lg px-0; @apply input input-bordered 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;
} }
@@ -393,15 +371,15 @@
/* emoji picker */ /* emoji picker */
emoji-picker { emoji-picker {
--background: var(--color-base-100); --background: var(--base-100);
--border-color: var(--color-base-100); --border-color: var(--base-100);
--border-radius: var(--rounded-box); --border-radius: var(--rounded-box);
--button-active-background: var(--color-base-content); --button-active-background: var(--base-content);
--button-hover-background: var(--color-base-content); --button-hover-background: var(--base-content);
--indicator-color: var(--color-base-content); --indicator-color: var(--base-content);
--input-border-color: var(--color-base-100); --input-border-color: var(--base-100);
--input-font-color: var(--color-base-content); --input-font-color: var(--base-content);
--outline-color: var(--color-base-100); --outline-color: var(--base-100);
} }
/* progress */ /* progress */
@@ -412,12 +390,57 @@ progress[value]::-webkit-progress-value {
/* content width for fixed elements */ /* content width for fixed elements */
.left-content { .cw {
@apply md:left-[calc(18.5rem+var(--sail))]; @apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
}
.cw-video-call-content {
@apply w-full md:left-[calc(18.5rem+18rem)] md:w-[calc(100%-18.5rem-18rem-var(--sair))];
}
/* Voice: desktop split — plain CSS so / in calc is not parsed as Tailwind slash syntax */
.cw-split-video {
width: 100%;
}
.cw-split-chat {
width: 100%;
}
@media (min-width: 768px) {
.cw-split-video {
left: 18.5rem;
right: auto;
width: calc((100vw - 18.5rem - var(--sair)) / 2);
max-width: none;
}
.cw-split-chat {
left: calc(18.5rem + (100vw - 18.5rem - var(--sair)) / 2);
right: auto;
width: calc((100vw - 18.5rem - var(--sair)) / 2);
max-width: none;
}
}
.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;
} }
@@ -425,13 +448,36 @@ body.keyboard-open .hide-on-keyboard {
/* chat view */ /* chat view */
.chat__compose { .chat__compose {
@apply z-compose relative mb-14 shrink-0 md:mb-0; @apply cb cw fixed z-compose;
} }
.chat__compose .chat__compose-inner { .chat__compose-zone {
@apply cb cw fixed z-compose;
}
.chat__compose-zone .chat__compose-inner {
@apply min-w-0; @apply min-w-0;
} }
.chat__scroll-down { .chat__compose-zone.cw-video-call-content {
@apply pb-sai z-feature fixed right-4 bottom-28 md:bottom-16; @apply md:left-[calc(18.5rem+18rem)] md:w-[calc(100%-18.5rem-18rem-var(--sair))];
}
@media (min-width: 768px) {
.chat__compose-zone.cw-split-chat {
left: calc(18.5rem + (100vw - 18.5rem - var(--sair)) / 2);
right: auto;
width: calc((100vw - 18.5rem - var(--sair)) / 2);
max-width: none;
}
}
.chat__scroll-down {
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
}
/* content visibility */
.cv {
content-visibility: auto;
} }
-57
View File
@@ -1,57 +0,0 @@
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))
},
)
-99
View File
@@ -1,99 +0,0 @@
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",
})
}
}
@@ -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 grow flex-wrap justify-end gap-2"> <div class="flex 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} />
@@ -7,13 +7,12 @@
type Props = { type Props = {
url: string url: string
h?: string h?: string
shareToChat?: boolean
} }
const {url, h, shareToChat = false}: Props = $props() const {url, h}: Props = $props()
</script> </script>
<CalendarEventForm {url} {h} {shareToChat}> <CalendarEventForm {url} {h}>
{#snippet header()} {#snippet header()}
<ModalHeader> <ModalHeader>
<ModalTitle>Create an Event</ModalTitle> <ModalTitle>Create an Event</ModalTitle>
+36 -80
View File
@@ -3,7 +3,7 @@
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {randomId, HOUR} from "@welshman/lib" import {randomId, HOUR} from "@welshman/lib"
import {makeEvent, EVENT_TIME} from "@welshman/util" import {makeEvent, EVENT_TIME} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app" import {publishThunk} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {daysBetween} from "@lib/util" import {daysBetween} from "@lib/util"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl" import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
@@ -20,34 +20,24 @@
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, publishRoomQuote} 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
shareToChat?: boolean
header: Snippet header: Snippet
initialValues?: Values initialValues?: {
d: string
title: string
content: string
location: string
start: number
end: number
}
} }
let {url, h, shareToChat = false, header, initialValues}: Props = $props() const {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)
@@ -58,7 +48,7 @@
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run()) const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => { const submit = async () => {
if ($uploading || loading) return if ($uploading) return
if (!title) { if (!title) {
return pushToast({ return pushToast({
@@ -84,68 +74,38 @@
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", d], ["d", initialValues?.d || randomId()],
["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()]),
...ed.storage.nostr.getEditorTags(), ...ed.storage.nostr.getEditorTags(),
] ]
loading = true if (await shouldProtect) {
tags.push(PROTECTED)
try {
const protect = await shouldProtect
if (protect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
const event = makeEvent(EVENT_TIME, {content, tags})
const calendarThunk = publishThunk({event, relays: [url]})
const error = await waitForThunkError(calendarThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: calendarThunk.event, protect})
}
pushToast({message: "Your event has been saved!"})
} finally {
loading = false
} }
if (h) {
tags.push(["h", h])
}
const event = makeEvent(EVENT_TIME, {content, tags})
pushToast({message: "Your event has been saved!"})
publishThunk({event, relays: [url]})
history.back()
} }
let loading = $state(false) const content = initialValues?.content || ""
const editor = makeEditor({url, submit, uploading, content})
const d = $state(initialValues?.d ?? randomId()) let title = $state(initialValues?.title || "")
let title = $state(initialValues?.title ?? "") let location = $state(initialValues?.location || "")
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 = $state(Boolean(initialValues?.end)) let endDirty = 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) {
@@ -176,14 +136,10 @@
{#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 grow overflow-hidden"> <div class="input-editor flex-grow overflow-hidden">
<EditorContent {editor} /> <EditorContent {editor} />
</div> </div>
<Button <Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
data-tip="Add an image"
class="center btn tooltip"
onclick={selectFiles}
disabled={loading}>
{#if $uploading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
{:else} {:else}
@@ -222,12 +178,12 @@
</Field> </Field>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}> <Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}> <Button type="submit" class="btn btn-primary" disabled={$uploading}>
<Spinner loading={$uploading || loading}>Save Event</Spinner> <Spinner loading={$uploading}>Save Event</Spinner>
</Button> </Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
@@ -19,7 +19,7 @@
const end = $derived(parseInt(meta.end)) const end = $derived(parseInt(meta.end))
</script> </script>
<div class="flex grow flex-wrap justify-between gap-2"> <div class="flex 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)}
+1 -1
View File
@@ -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="wrap-break-word">{meta.location}</span> <span class="break-words">{meta.location}</span>
</span> </span>
{/if} {/if}
</div> </div>
+19 -6
View File
@@ -55,7 +55,6 @@
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"
@@ -67,7 +66,6 @@
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)))
@@ -198,6 +196,8 @@
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 = []
@@ -233,6 +233,20 @@
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(() => {
@@ -280,6 +294,7 @@
</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">
@@ -320,10 +335,9 @@
</Spinner> </Spinner>
{@render info?.()} {@render info?.()}
</p> </p>
<div class="h-screen"></div>
</PageContent> </PageContent>
<div class="chat__compose bg-base-200"> <div class="chat__compose bg-base-200" bind:this={chatCompose}>
<div> <div>
{#if parent} {#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" /> <ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
@@ -338,8 +352,7 @@
{onSubmit} {onSubmit}
{onEscape} {onEscape}
{onEditPrevious} {onEditPrevious}
initialValues={eventToEdit} content={eventToEdit?.content}
draftKey={eventToEdit ? undefined : draftKey}
disabled={Boolean(missingRelayLists.length)} /> disabled={Boolean(missingRelayLists.length)} />
{/key} {/key}
</div> </div>
+5 -33
View File
@@ -10,40 +10,23 @@
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
} }
let { const {content, disabled = false, onEscape, onEditPrevious, onSubmit}: Props = $props()
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 grow overflow-hidden", { cx("chat-editor flex-grow overflow-hidden", {
"pointer-events-none opacity-50": disabled, "pointer-events-none opacity-50": disabled,
}), }),
) )
@@ -76,29 +59,18 @@
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)
@@ -123,7 +95,7 @@
{/if} {/if}
</Button> </Button>
<div class={editorClass} aria-disabled={disabled}> <div class={editorClass} aria-disabled={disabled}>
<EditorContent {autofocus} {editor} /> <EditorContent {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"
+1 -1
View File
@@ -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-3 py-2 transition-colors hover:bg-base-100 {props.class}" class="cursor-pointer border-t border-solid border-base-100 px-6 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">
+9
View File
@@ -1,5 +1,6 @@
<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"
@@ -7,9 +8,13 @@
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()
@@ -23,6 +28,10 @@
<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
+1 -1
View File
@@ -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 grow flex-wrap justify-end gap-2"> <div class="flex 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} />
+2 -3
View File
@@ -7,13 +7,12 @@
type Props = { type Props = {
url: string url: string
h?: string h?: string
shareToChat?: boolean
} }
const {url, h, shareToChat = false}: Props = $props() const {url, h}: Props = $props()
</script> </script>
<ClassifiedForm {url} {h} {shareToChat}> <ClassifiedForm {url} {h}>
{#snippet header()} {#snippet header()}
<ModalHeader> <ModalHeader>
<ModalTitle>Create a Classified Listing</ModalTitle> <ModalTitle>Create a Classified Listing</ModalTitle>
+26 -58
View File
@@ -2,7 +2,7 @@
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {removeUndefined, randomId, uniq} from "@welshman/lib" import {removeUndefined, randomId, uniq} from "@welshman/lib"
import {makeEvent, CLASSIFIED} from "@welshman/util" import {makeEvent, CLASSIFIED} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app" import {publishThunk} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import {normalizeTopic} from "@lib/util" import {normalizeTopic} from "@lib/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
@@ -20,35 +20,25 @@
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, publishRoomQuote, 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
shareToChat?: boolean
header: Snippet header: Snippet
initialValues?: Values initialValues?: {
d?: string
title?: string
content?: string
price?: number
currency?: string
images?: string[]
status?: string
topics?: string[]
}
} }
let {url, h, shareToChat = false, header, initialValues}: Props = $props() const {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)
@@ -76,7 +66,7 @@
} }
const tags = [ const tags = [
["d", d], ["d", initialValues?.d || randomId()],
["title", title], ["title", title],
["summary", content], ["summary", content],
["price", String(price), currency], ["price", String(price), currency],
@@ -88,9 +78,7 @@
tags.push(["t", topic]) tags.push(["t", topic])
} }
const protect = await shouldProtect if (await shouldProtect) {
if (protect) {
tags.push(PROTECTED) tags.push(PROTECTED)
} }
@@ -117,47 +105,27 @@
} }
} }
const classifiedThunk = publishThunk({ publishThunk({
relays: [url], relays: [url],
event: makeEvent(CLASSIFIED, {content, tags}), event: makeEvent(CLASSIFIED, {content, tags}),
}) })
const error = await waitForThunkError(classifiedThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back() history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: classifiedThunk.event, protect})
}
} finally { } finally {
loading = false loading = false
} }
} }
const content = initialValues?.content || ""
const editor = makeEditor({url, submit, content})
let loading = $state(false) let loading = $state(false)
const d = $state(initialValues?.d ?? randomId()) let title = $state(initialValues?.title || "")
let title = $state(initialValues?.title ?? "") let status = $state(initialValues?.status || "active")
let status = $state(initialValues?.status ?? "active") let price = $state(Number(initialValues?.price || 0))
let price = $state(initialValues?.price ?? 0) let currency = $state(initialValues?.currency || "SAT")
let currency = $state(initialValues?.currency ?? "SAT") let images = $state<(string | File)[]>(initialValues?.images || [])
let images = $state(initialValues?.images ?? []) let topics = $state(uniq(removeUndefined((initialValues?.topics || []).map(normalizeTopic))))
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)}>
@@ -185,7 +153,7 @@
<p>Description*</p> <p>Description*</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="note-editor grow overflow-hidden"> <div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} /> <EditorContent {editor} />
</div> </div>
{/snippet} {/snippet}
+1 -1
View File
@@ -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 grow flex-wrap justify-end gap-2"> <div class="flex 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 -14
View File
@@ -4,7 +4,6 @@
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"
@@ -12,7 +11,6 @@
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
@@ -22,15 +20,13 @@
const {url, h, onClick}: Props = $props() const {url, h, onClick}: Props = $props()
const createGoal = () => pushModal(GoalCreate, {url, h, shareToChat: true}) const createGoal = () => pushModal(GoalCreate, {url, h})
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h, shareToChat: true}) const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h})
const createThread = () => pushModal(ThreadCreate, {url, h, shareToChat: true}) const createThread = () => pushModal(ThreadCreate, {url, h})
const createClassified = () => pushModal(ClassifiedCreate, {url, h, shareToChat: true}) const createClassified = () => pushModal(ClassifiedCreate, {url, h})
const createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true})
let ul: Element let ul: Element
@@ -64,10 +60,4 @@
Create Thread Create Thread
</Button> </Button>
</li> </li>
<li>
<Button onclick={createPoll}>
<Icon size={4} icon={Revote} />
Ask a Question
</Button>
</li>
</ul> </ul>
+1 -1
View File
@@ -150,7 +150,7 @@
</div> </div>
{:else} {:else}
<div <div
class="overflow-hidden text-ellipsis wrap-break-word" class="overflow-hidden text-ellipsis break-words"
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)}
+41 -69
View File
@@ -1,44 +1,27 @@
<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 ContentLinkUrl from "@app/components/ContentLinkUrl.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 { import {dufflepud, PLATFORM_URL, IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/core/state"
dufflepud, import {makeSpacePath} from "@app/util/routes"
IMAGE_CONTENT_TYPES,
PLATFORM_URL,
VIDEO_CONTENT_TYPES,
THUMBNAIL_URL,
isRoomId,
} from "@app/core/state"
const {value, event} = $props() const {value, event} = $props()
let hideImage = $state(false) let hideImage = $state(false)
const url = value.url.toString() const url = value.url.toString()
const isRoomOrRelay = isRoomId(url) || isRelayUrl(url) const fileType = getTagValue("file-type", event.tags) || ""
const [href, external] = call(() => { const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false] if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true] return [url, true]
}) })
const fileType = getTagValue("file-type", event.tags) || ""
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})
@@ -56,52 +39,41 @@
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true}) const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script> </script>
{#if isRoomOrRelay} <Link {external} {href} class="my-2 block">
<div> <div class="overflow-hidden rounded-box">
<ContentLinkUrl {url} class="link-content whitespace-nowrap" /> {#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">
<track kind="captions" />
</video>
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
</button>
{:else}
{#await loadPreview()}
<div class="center my-12 w-full">
<span class="loading loading-spinner"></span>
</div>
{:then preview}
<div class="bg-alt flex max-w-xl flex-col leading-normal">
{#if preview.image && !hideImage}
<img
alt=""
onerror={onError}
src={preview.image}
class="bg-alt max-h-72 rounded-t-box object-contain object-center" />
{/if}
<div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
>{preview.title || displayUrl(url)}</strong>
<p>{ellipsize(preview.description, 140)}</p>
</div>
</div>
{:catch}
<p class="bg-alt p-12 text-center leading-normal">
Unable to load a preview for {url}
</p>
{/await}
{/if}
</div> </div>
{:else} </Link>
<Link {external} {href} class="my-2 block">
<div class="overflow-hidden rounded-box">
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)}
<video
controls
src={url}
poster={getVideoPoster(url)}
preload="metadata"
class="max-h-96 rounded-box object-contain object-center">
<track kind="captions" />
</video>
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
</button>
{:else}
{#await loadPreview()}
<div class="center my-12 w-full">
<span class="loading loading-spinner"></span>
</div>
{:then preview}
<div class="bg-alt flex max-w-xl flex-col leading-normal">
{#if preview.image && !hideImage}
<img
alt=""
onerror={onError}
src={preview.image}
class="bg-alt max-h-72 rounded-t-box object-contain object-center" />
{/if}
<div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
>{preview.title || displayUrl(url)}</strong>
<p>{ellipsize(preview.description, 140)}</p>
</div>
</div>
{:catch}
<p class="bg-alt p-12 text-center leading-normal">
Unable to load a preview for {url}
</p>
{/await}
{/if}
</div>
</Link>
{/if}
+15 -5
View File
@@ -1,18 +1,25 @@
<script lang="ts"> <script lang="ts">
import {displayUrl} from "@welshman/lib" import {call, displayUrl} from "@welshman/lib"
import {getTagValue} from "@welshman/util" import {isRelayUrl, getTagValue} from "@welshman/util"
import {preventDefault, stopPropagation} from "@lib/html" import {preventDefault, stopPropagation} from "@lib/html"
import LinkRound from "@assets/icons/link-round.svg?dataurl" import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte" import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {IMAGE_CONTENT_TYPES} from "@app/core/state" import {PLATFORM_URL, IMAGE_CONTENT_TYPES} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
const {value, event} = $props() const {value, event} = $props()
const url = value.url.toString() const url = value.url.toString()
const fileType = getTagValue("file-type", event.tags) || "" const fileType = getTagValue("file-type", event.tags) || ""
const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true]
})
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true}) const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script> </script>
@@ -27,5 +34,8 @@
{displayUrl(url)} {displayUrl(url)}
</a> </a>
{:else} {:else}
<ContentLinkUrl {url} class="link-content whitespace-nowrap" /> <Link {external} {href} class="link-content whitespace-nowrap">
<Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
</Link>
{/if} {/if}
-59
View File
@@ -1,59 +0,0 @@
<script lang="ts">
import {call, displayUrl} from "@welshman/lib"
import {displayRelayUrl, isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import {PLATFORM_URL, displayRoom, isRoomId, splitRoomId} from "@app/core/state"
import {makeRoomPath, makeSpacePath} from "@app/util/routes"
const {
url,
class: className = "",
}: {
url: string
class?: string
} = $props()
const roomReference = call(() => {
if (!isRoomId(url)) {
return undefined
}
const [roomUrl, h] = splitRoomId(url)
if (!roomUrl || !h || !isRelayUrl(roomUrl)) {
return undefined
}
return {url: normalizeRelayUrl(roomUrl), h}
})
const relayReference = call(() => {
if (roomReference || !isRelayUrl(url)) {
return undefined
}
return normalizeRelayUrl(url)
})
const [href, external] = call(() => {
if (roomReference) return [makeRoomPath(roomReference.url, roomReference.h), false]
if (relayReference) return [makeSpacePath(relayReference), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true]
})
</script>
<Link {external} {href} class={className}>
{#if roomReference}
~<span class="text-primary">{displayRelayUrl(roomReference.url)}</span> /
{displayRoom(roomReference.url, roomReference.h)}
{:else if relayReference}
<span class="text-primary">{displayRelayUrl(relayReference)}</span>
{:else}
<Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
{/if}
</Link>
+1 -1
View File
@@ -101,7 +101,7 @@
</p> </p>
</div> </div>
{:else} {:else}
<div class="overflow-hidden text-ellipsis wrap-break-word"> <div class="overflow-hidden text-ellipsis break-words">
{#each shortContent as parsed, i} {#each shortContent as parsed, i}
{#if isNewline(parsed)} {#if isNewline(parsed)}
<ContentNewline value={parsed.value} /> <ContentNewline value={parsed.value} />
+2 -2
View File
@@ -45,11 +45,11 @@
{#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(--color-primary) 10%, var(--color-base-300) 90%);"> style="background-color: color-mix(in srgb, var(--primary) 10%, var(--base-300) 90%);">
<NoteContentMinimal trimParent {url} event={$quote} /> <NoteContentMinimal trimParent {url} event={$quote} />
</div> </div>
{:else} {:else}
<NoteCard noShadow event={$quote} {url} class="bg-alt rounded-box p-4"> <NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteContentMinimal {url} event={$quote} /> <NoteContentMinimal {url} event={$quote} />
</NoteCard> </NoteCard>
{/if} {/if}
+2 -2
View File
@@ -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 grow items-center justify-between"> <p class="absolute right-2 top-2 flex 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 grow" onclick={() => history.back()}>Got it</Button> <Button class="btn btn-primary flex-grow" onclick={() => history.back()}>Got it</Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
+8 -28
View File
@@ -10,19 +10,13 @@
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())
@@ -44,23 +38,13 @@
}) })
} }
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(() => {
@@ -68,7 +52,7 @@
}) })
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
spacer!.style.minHeight = `${form!.offsetHeight + 60}px` spacer!.style.minHeight = `${form!.offsetHeight}px`
}) })
observer.observe(form!) observer.observe(form!)
@@ -80,15 +64,11 @@
</script> </script>
<div bind:this={spacer}></div> <div bind:this={spacer}></div>
<form <form in:fly bind:this={form} onsubmit={preventDefault(submit)} class="cb cw fixed z-feature pt-3">
in:fly
bind:this={form}
onsubmit={preventDefault(submit)}
class="left-content bottom-sai right-sai fixed z-feature mb-14 md:mb-0 w-full md:w-auto pr-2">
<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 grow overflow-hidden"> <div class="note-editor flex-grow overflow-hidden">
<EditorContent {autofocus} {editor} /> <EditorContent {editor} />
</div> </div>
<Button <Button
data-tip="Add an image" data-tip="Add an image"
+1 -1
View File
@@ -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 grow flex-wrap justify-end gap-2"> <div class="flex 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} />
+34 -90
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {makeEvent, ZAP_GOAL} from "@welshman/util" import {makeEvent, ZAP_GOAL} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app" import {publishThunk} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl" import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import Bolt from "@assets/icons/bolt.svg?dataurl" import Bolt from "@assets/icons/bolt.svg?dataurl"
@@ -10,7 +10,6 @@
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
@@ -21,29 +20,14 @@
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, publishRoomQuote} 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
shareToChat?: boolean
} }
let {url, h, initialValues, shareToChat = false}: Props = $props() const {url, h}: Props = $props()
const draftKey = new DraftKey<Values>(`goal:${url}:${h ?? ""}`)
if (!initialValues) {
initialValues = draftKey.get()
}
const shouldProtect = canEnforceNip70(url) const shouldProtect = canEnforceNip70(url)
@@ -54,9 +38,9 @@
const selectFiles = () => editor.then(ed => ed.commands.selectFiles()) const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => { const submit = async () => {
if ($uploading || loading) return if ($uploading) return
if (!title) { if (!content) {
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.",
@@ -64,9 +48,9 @@
} }
const ed = await editor const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim() const summary = ed.getText({blockSeparator: "\n"}).trim()
if (!content.trim()) { if (!summary.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.",
@@ -75,68 +59,31 @@
const tags = [ const tags = [
...ed.storage.nostr.getEditorTags(), ...ed.storage.nostr.getEditorTags(),
["summary", content], ["summary", summary],
["amount", String(amount)], ["amount", String(amount)],
["relays", url], ["relays", url],
] ]
loading = true if (await shouldProtect) {
tags.push(PROTECTED)
try {
const protect = await shouldProtect
if (protect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
const goalThunk = publishThunk({
relays: [url],
event: makeEvent(ZAP_GOAL, {content: title, tags}),
})
const error = await waitForThunkError(goalThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: goalThunk.event, protect})
}
} finally {
loading = false
} }
if (h) {
tags.push(["h", h])
}
publishThunk({
relays: [url],
event: makeEvent(ZAP_GOAL, {content, tags}),
})
history.back()
} }
let loading = $state(false) const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
let title = $state(initialValues?.title ?? "") let content = $state("")
let amount = $state(initialValues?.amount ?? 1000) let amount = $state(1000)
let content = $state(initialValues?.content ?? "")
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, amount})
})
</script> </script>
<Modal tag="form" onsubmit={preventDefault(submit)}> <Modal tag="form" onsubmit={preventDefault(submit)}>
@@ -155,7 +102,7 @@
<!-- svelte-ignore a11y_autofocus --> <!-- svelte-ignore a11y_autofocus -->
<input <input
autofocus={!isMobile} autofocus={!isMobile}
bind:value={title} bind:value={content}
class="grow" class="grow"
type="text" type="text"
placeholder="What do funds go towards?" /> placeholder="What do funds go towards?" />
@@ -168,7 +115,7 @@
<p>Details*</p> <p>Details*</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="note-editor grow overflow-hidden"> <div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} /> <EditorContent {editor} />
</div> </div>
{/snippet} {/snippet}
@@ -176,8 +123,7 @@
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2" class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles} onclick={selectFiles}>
disabled={loading}>
{#if $uploading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
{:else} {:else}
@@ -191,17 +137,17 @@
Goal Amount (sats)* Goal Amount (sats)*
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="flex grow justify-end"> <div class="flex flex-grow justify-end">
<label class="input input-bordered flex w-auto 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 grow" /> <input bind:value={amount} type="number" class="w-28" />
<p class="shrink-0 opacity-50">sats</p> <p class="opacity-50">sats</p>
</label> </label>
</div> </div>
{/snippet} {/snippet}
</FieldInline> </FieldInline>
<input <input
class="range range-primary -mt-2 w-full" class="range range-primary -mt-2"
type="range" type="range"
min="1000" min="1000"
max="100000" max="100000"
@@ -211,12 +157,10 @@
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}> <Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}> <Button type="submit" class="btn btn-primary">Create Goal</Button>
<Spinner {loading}>Create Goal</Spinner>
</Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
+1 -1
View File
@@ -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}
-10
View File
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import {Capacitor} from "@capacitor/core"
import {onMount, onDestroy} from "svelte" import {onMount, onDestroy} from "svelte"
import type {Nip46ResponseWithResult} from "@welshman/signer" import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker} from "@welshman/signer" import {Nip46Broker} from "@welshman/signer"
@@ -104,16 +103,10 @@
mode = "connect" mode = "connect"
} }
const openSigner = () => {
controller.launchSigner()
}
const selectBunker = () => { const selectBunker = () => {
mode = "bunker" mode = "bunker"
} }
const isIos = Capacitor.getPlatform() === "ios"
let mode: string = $state("bunker") let mode: string = $state("bunker")
$effect(() => { $effect(() => {
@@ -145,9 +138,6 @@
<BunkerUrl {controller} /> <BunkerUrl {controller} />
<Button class="btn {$bunker ? 'btn-neutral' : 'btn-primary'}" onclick={selectConnect} <Button class="btn {$bunker ? 'btn-neutral' : 'btn-primary'}" onclick={selectConnect}
>Log in with a QR code instead</Button> >Log in with a QR code instead</Button>
{#if isIos}
<Button class="btn btn-neutral" onclick={openSigner}>Open in Signer</Button>
{/if}
{/if} {/if}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
+1 -3
View File
@@ -16,7 +16,6 @@
children, children,
minimal = false, minimal = false,
hideProfile = false, hideProfile = false,
noShadow = false,
url, url,
...restProps ...restProps
}: { }: {
@@ -24,7 +23,6 @@
children: Snippet children: Snippet
minimal?: boolean minimal?: boolean
hideProfile?: boolean hideProfile?: boolean
noShadow?: boolean
url?: string url?: string
class?: string class?: string
} = $props() } = $props()
@@ -36,7 +34,7 @@
let muted = $state($isEventMuted(event)) let muted = $state($isEventMuted(event))
</script> </script>
<div class="flex flex-col gap-2 {restProps.class}" class:shadow-md={!noShadow}> <div class="flex flex-col gap-2 shadow-md {restProps.class}">
{#if muted} {#if muted}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="row-2 relative"> <div class="row-2 relative">
-4
View File
@@ -1,12 +1,10 @@
<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()
@@ -21,8 +19,6 @@
<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 grow flex-col"> <div class="flex 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 grow bg-base-content opacity-25"></div> <div class="h-px flex-grow bg-base-content opacity-25"></div>
</div> </div>
<Content {...props} /> <Content {...props} />
</div> </div>
@@ -1,12 +1,10 @@
<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()
@@ -21,8 +19,6 @@
<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 grow flex-wrap justify-between gap-2"> <div class="flex 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)}
@@ -1,19 +0,0 @@
<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>
-29
View File
@@ -1,29 +0,0 @@
<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>
-281
View File
@@ -1,281 +0,0 @@
<script lang="ts">
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
import {makeEvent} from "@welshman/util"
import {publishThunk, waitForThunkError} 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 Spinner from "@lib/components/Spinner.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, publishRoomQuote} 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
shareToChat?: boolean
}
const {url, h, shareToChat = false}: 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 (loading) return
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])
}
loading = true
try {
const protect = await shouldProtect
if (protect) {
tags.push(PROTECTED)
}
const pollThunk = publishThunk({
relays: [url],
event: makeEvent(Poll, {content: title.trim(), tags}),
})
const error = await waitForThunkError(pollThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: pollThunk.event, protect})
}
} finally {
loading = false
}
}
let loading = $state(false)
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} disabled={loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Create Poll</Spinner>
</Button>
</ModalFooter>
</Modal>
-34
View File
@@ -1,34 +0,0 @@
<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>
-70
View File
@@ -1,70 +0,0 @@
<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>
-127
View File
@@ -1,127 +0,0 @@
<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>
+14 -10
View File
@@ -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, makeSpacePath} from "@app/util/routes" import {goToChat} from "@app/util/routes"
type Props = { type Props = {
children?: Snippet children?: Snippet
@@ -26,20 +26,22 @@
const showSettingsMenu = () => pushModal(MenuSettings) const showSettingsMenu = () => pushModal(MenuSettings)
const anySpaceNotifications = $derived( const anySpaceNotifications = $derived($userSpaceUrls.some(p => $notifications.has(p)))
$userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))),
)
</script> </script>
<div <div
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 shrink-0 bg-base-200 pt-2 md:block"> class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 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 class="flex flex-col"> <div>
<PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings"> <PrimaryNavItem
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}
@@ -49,10 +51,11 @@
<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"> <PrimaryNavItem title="Search" href="/people" class="tooltip-right">
<ImageIcon alt="Search" src={Magnifier} size={8} /> <ImageIcon alt="Search" src={Magnifier} size={8} />
</PrimaryNavItem> </PrimaryNavItem>
</div> </div>
@@ -62,10 +65,11 @@
{@render children?.()} {@render children?.()}
<!-- a little extra something for ios --> <!-- a little extra something for ios -->
<div class="hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-(--saib) bg-base-100 md:hidden"> <div
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="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="bottom-nav hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
<div class="content-padding-x content-sizing flex justify-between px-2"> <div class="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">
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import {deriveRelayDisplay} from "@welshman/app" import {displayRelayUrl} from "@welshman/util"
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,13 +12,11 @@
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={$display} title={displayRelayUrl(url)}
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" />
+3 -2
View File
@@ -12,7 +12,7 @@
const itemHeight = 56 const itemHeight = 56
const navPadding = 8 * itemHeight const navPadding = 8 * itemHeight
const itemLimit = $derived(Math.max(0, (windowHeight - navPadding) / itemHeight)) const itemLimit = $derived((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>
@@ -23,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"> <PrimaryNavItem title="Home" href="/home" class="tooltip-right">
<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 />
@@ -33,6 +33,7 @@
<PrimaryNavItem <PrimaryNavItem
href="/spaces" href="/spaces"
title="All Spaces" title="All Spaces"
class="tooltip-right"
prefix="no-highlight" prefix="no-highlight"
notification={otherSpaceNotifications}> notification={otherSpaceNotifications}>
<ImageIcon alt="All Spaces" src={Widget} size={8} /> <ImageIcon alt="All Spaces" src={Widget} size={8} />
+3 -3
View File
@@ -5,11 +5,11 @@
import type {Filter} from "@welshman/util" import type {Filter} from "@welshman/util"
import {deriveEventsDesc, deriveEventsById} from "@welshman/store" import {deriveEventsDesc, deriveEventsById} from "@welshman/store"
import {formatTimestampRelative} from "@welshman/lib" import {formatTimestampRelative} from "@welshman/lib"
import {NOTE, ROOMS, COMMENT, MESSAGE} from "@welshman/util" import {NOTE, ROOMS, COMMENT} from "@welshman/util"
import {repository, loadRelayList} from "@welshman/app" import {repository, loadRelayList} from "@welshman/app"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileSpaces from "@app/components/ProfileSpaces.svelte" import ProfileSpaces from "@app/components/ProfileSpaces.svelte"
import {deriveGroupList, getSpaceUrlsFromGroupList} from "@app/core/state" import {deriveGroupList, getSpaceUrlsFromGroupList, MESSAGE_KINDS} from "@app/core/state"
import {goToEvent} from "@app/util/routes" import {goToEvent} from "@app/util/routes"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
@@ -36,7 +36,7 @@
load({ load({
filters: [ filters: [
{authors: [pubkey], kinds: [ROOMS]}, {authors: [pubkey], kinds: [ROOMS]},
{authors: [pubkey], limit: 1, kinds: [NOTE, COMMENT, MESSAGE]}, {authors: [pubkey], limit: 1, kinds: [NOTE, COMMENT, ...MESSAGE_KINDS]},
], ],
relays: Router.get().FromPubkeys([pubkey]).getUrls(), relays: Router.get().FromPubkeys([pubkey]).getUrls(),
}) })
+2 -2
View File
@@ -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="shrink-0"> <div class="flex-shrink-0">
<RelayIcon {url} size={12} /> <RelayIcon {url} size={12} />
</div> </div>
<div class="flex grow flex-col"> <div class="flex flex-grow flex-col">
<RelayName {url} /> <RelayName {url} />
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{url} {url}
+5 -21
View File
@@ -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, deriveUserIsSpaceAdmin} from "@app/core/state" import {REACTION_KINDS} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
interface Props { interface Props {
@@ -33,7 +33,6 @@
url?: string url?: string
reactionClass?: string reactionClass?: string
noTooltip?: boolean noTooltip?: boolean
innerEvent?: TrustedEvent
children?: Snippet children?: Snippet
} }
@@ -44,36 +43,23 @@
url = "", url = "",
reactionClass = "", reactionClass = "",
noTooltip = false, noTooltip = false,
innerEvent = undefined,
children, children,
}: Props = $props() }: Props = $props()
const eventIds = innerEvent ? [event.id, innerEvent.id] : [event.id]
const reports = deriveArray( const reports = deriveArray(
deriveEventsById({repository, filters: [{kinds: [REPORT], "#e": [event.id]}]}), deriveEventsById({repository, filters: [{kinds: [REPORT], "#e": [event.id]}]}),
) )
const reactions = deriveArray( const reactions = deriveArray(
deriveEventsById({repository, filters: [{kinds: [REACTION], "#e": eventIds}]}), deriveEventsById({repository, filters: [{kinds: [REACTION], "#e": [event.id]}]}),
) )
const zaps = deriveArray( const zaps = deriveArray(
deriveItemsByKey<Zap>({ deriveItemsByKey<Zap>({
repository, repository,
getKey: zap => zap.response.id, getKey: zap => zap.response.id,
filters: [{kinds: [ZAP_RESPONSE], "#e": eventIds}], filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
eventToItem: (response: TrustedEvent) => { eventToItem: (response: TrustedEvent) => getValidZap(response, event),
const zap = getValidZap(response, event)
if (zap) {
return zap
}
if (innerEvent) {
return getValidZap(response, innerEvent)
}
},
}), }),
) )
@@ -92,8 +78,6 @@
} }
} }
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())))
@@ -134,7 +118,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 && $userIsAdmin} {#if url && $reports.length > 0}
<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)}".`}
+1 -1
View File
@@ -121,6 +121,6 @@
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-primary grow" onclick={back}>Done</Button> <Button class="btn btn-primary flex-grow" onclick={back}>Done</Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
+2 -2
View File
@@ -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 grow flex-row items-start gap-4"> <div class="flex flex-grow flex-row items-start gap-4">
<div class="flex h-7 w-7 shrink-0 items-center justify-center"> <div class="flex h-7 w-7 flex-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">
+2 -2
View File
@@ -23,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>
@@ -35,7 +35,7 @@
</div> </div>
{/if} {/if}
</div> </div>
<div class="min-w-0"> <div>
<h2 class="ellipsize whitespace-nowrap text-xl"> <h2 class="ellipsize whitespace-nowrap text-xl">
<RelayName {url} /> <RelayName {url} />
</h2> </h2>
+6 -34
View File
@@ -12,29 +12,18 @@
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
} }
let {url, h, initialValues, onEscape, onEditPrevious, onSubmit}: Props = $props() const {url, h, content, 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
@@ -72,29 +61,12 @@
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
@@ -132,8 +104,8 @@
</Button> </Button>
</Tippy> </Tippy>
</div> </div>
<div class="chat-editor grow overflow-hidden"> <div class="chat-editor flex-grow overflow-hidden">
<EditorContent {autofocus} {editor} /> <EditorContent {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"
+25 -26
View File
@@ -22,7 +22,6 @@
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"
@@ -207,39 +206,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}
<Tooltip content="Only members can send messages."> <Button
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center"> class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
<Icon size={4} icon={Microphone} /> Restricted data-tip="Only members can send messages.">
</Button> <Icon size={4} icon={Microphone} /> Restricted
</Tooltip> </Button>
{/if} {/if}
{#if $room?.isPrivate} {#if $room?.isPrivate}
<Tooltip content="Only members can view messages."> <Button
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center"> class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
<Icon size={4} icon={Lock} /> Private data-tip="Only members can view messages.">
</Button> <Icon size={4} icon={Lock} /> Private
</Tooltip> </Button>
{/if} {/if}
{#if $room?.isHidden} {#if $room?.isHidden}
<Tooltip content="This room is not visible to non-members."> <Button
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center"> class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
<Icon size={4} icon={EyeClosed} /> Hidden data-tip="This room is not visible to non-members.">
</Button> <Icon size={4} icon={EyeClosed} /> Hidden
</Tooltip> </Button>
{/if} {/if}
{#if $room?.isClosed} {#if $room?.isClosed}
<Tooltip content="Requests to join this room will be ignored."> <Button
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center"> class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
<Icon size={4} icon={MinusCircle} /> Closed data-tip="Requests to join this room will be ignored.">
</Button> <Icon size={4} icon={MinusCircle} /> Closed
</Tooltip> </Button>
{/if} {/if}
{#if !$room?.isRestricted && !$room?.isPrivate && !$room?.isHidden && !$room?.isClosed} {#if !$room?.isRestricted && !$room?.isPrivate && !$room?.isHidden && !$room?.isClosed}
<Tooltip content="This room has no additional access controls."> <Button
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center"> class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
<Icon size={4} icon={Eye} /> Public data-tip="This room has no additional access controls.">
</Button> <Icon size={4} icon={Eye} /> Public
</Tooltip> </Button>
{/if} {/if}
</div> </div>
</div> </div>
+1 -1
View File
@@ -131,7 +131,7 @@
<p>Icon</p> <p>Icon</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="flex grow items-center justify-between gap-4"> <div class="flex 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>
+17 -37
View File
@@ -1,16 +1,8 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import {readable} from "svelte/store" import {hash, now, displayList, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
import {
hash,
gte,
now,
displayList,
formatTimestampAsTime,
formatTimestampAsDate,
} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {MESSAGE, COMMENT, getTag} from "@welshman/util" import {MESSAGE, COMMENT} from "@welshman/util"
import { import {
thunks, thunks,
pubkey, pubkey,
@@ -35,7 +27,7 @@
import RoomItemMenuButton from "@app/components/RoomItemMenuButton.svelte" import RoomItemMenuButton from "@app/components/RoomItemMenuButton.svelte"
import RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte" import RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte"
import RoomItemContent from "@app/components/RoomItemContent.svelte" import RoomItemContent from "@app/components/RoomItemContent.svelte"
import {colors, ENABLE_ZAPS, deriveEventsForUrl, deriveEvent} from "@app/core/state" import {colors, ENABLE_ZAPS, deriveEventsForUrl} from "@app/core/state"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands" import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {getRoomItemPath} from "@app/util/routes" import {getRoomItemPath} from "@app/util/routes"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
@@ -45,7 +37,7 @@
event: TrustedEvent event: TrustedEvent
replyTo?: (event: TrustedEvent) => void replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean showPubkey?: boolean
addSpaceBelow?: boolean inert?: boolean
canEdit: (event: TrustedEvent) => boolean canEdit: (event: TrustedEvent) => boolean
onEdit: (event: TrustedEvent) => void onEdit: (event: TrustedEvent) => void
} }
@@ -55,7 +47,7 @@
event, event,
replyTo = undefined, replyTo = undefined,
showPubkey = false, showPubkey = false,
addSpaceBelow = false, inert = false,
canEdit, canEdit,
onEdit, onEdit,
}: Props = $props() }: Props = $props()
@@ -66,15 +58,7 @@
const profileDisplay = deriveProfileDisplay(event.pubkey, [url]) const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id)) const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
const [_, colorValue] = colors[hash(event.pubkey) % colors.length] const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
const comments = deriveEventsForUrl(url, [{kinds: [COMMENT], "#e": [event.id]}])
const qTag = getTag("q", event.tags)
const isQuoteOnly = Boolean(
gte(qTag?.length, 2) && event.content.trim().match(/^nostr:n(event|addr)1\w+\s*$/),
)
const innerComments = isQuoteOnly
? deriveEventsForUrl(url, [{kinds: [COMMENT], "#e": [qTag![1]]}])
: readable([])
const innerEvent = isQuoteOnly ? deriveEvent(qTag![1], [url]) : readable(undefined)
const reply = () => replyTo!(event) const reply = () => replyTo!(event)
const edit = canEdit(event) ? () => onEdit(event) : undefined const edit = canEdit(event) ? () => onEdit(event) : undefined
@@ -92,23 +76,20 @@
<TapTarget <TapTarget
data-event={event.id} data-event={event.id}
{onTap} onTap={inert ? null : onTap}
class={cx( class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left hover:bg-base-100/50">
"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 pt-1.5 justify-center w-8 shrink-0"> <Button onclick={openProfile} class="flex items-start">
<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 shrink-0"></div> <div class="w-8 min-w-8 max-w-8"></div>
{/if} {/if}
<div class="min-w-0 grow pr-1"> <div class="min-w-0 flex-grow pr-1">
{#if showPubkey} {#if showPubkey}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}"> <Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
@@ -125,7 +106,7 @@
</div> </div>
{/if} {/if}
<div class:mt-2={showPubkey && event.kind !== MESSAGE}> <div class:mt-2={showPubkey && event.kind !== MESSAGE}>
<RoomItemContent {url} event={$innerEvent ?? event} /> <RoomItemContent {url} {event} />
{#if thunk} {#if thunk}
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" /> <ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
{/if} {/if}
@@ -138,10 +119,9 @@
{event} {event}
{deleteReaction} {deleteReaction}
{createReaction} {createReaction}
reactionClass="tooltip-right" reactionClass="tooltip-right" />
innerEvent={$innerEvent} /> {#if path && $comments.length > 0}
{#if path && $innerComments.length > 0} {@const pubkeys = $comments.map(e => e.pubkey)}
{@const pubkeys = $innerComments.map(e => e.pubkey)}
{@const isOwn = $pubkey && pubkeys.includes($pubkey)} {@const isOwn = $pubkey && pubkeys.includes($pubkey)}
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))} {@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
{@const tooltip = `${info} commented`} {@const tooltip = `${info} commented`}
@@ -153,14 +133,14 @@
"btn-primary": isOwn, "btn-primary": isOwn,
})}> })}>
<Icon icon={ReplyAlt} /> <Icon icon={ReplyAlt} />
<span>{$innerComments.length} comment{$innerComments.length === 1 ? "" : "s"}</span> <span>{$comments.length} comment{$comments.length === 1 ? "" : "s"}</span>
</Link> </Link>
</div> </div>
{/if} {/if}
</div> </div>
{#if !isMobile} {#if !isMobile}
<button <button
class="join absolute right-2 top-0.5 border border-solid border-neutral text-xs opacity-0 transition-all pr-2" class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}> class:group-hover:opacity-100={!isMobile}>
{#if ENABLE_ZAPS} {#if ENABLE_ZAPS}
<RoomItemZapButton {url} {event} /> <RoomItemZapButton {url} {event} />
+3 -4
View File
@@ -8,17 +8,16 @@
import {getRoomItemPath} from "@app/util/routes" import {getRoomItemPath} from "@app/util/routes"
const props: ComponentProps<typeof NoteContent> = $props() const props: ComponentProps<typeof NoteContent> = $props()
const path = getRoomItemPath(props.url!, props.event) const path = getRoomItemPath(props.url!, props.event)
const minLength = 5000
const maxLength = 5500
</script> </script>
<div class={cx("text-sm", {"card2 card2-sm bg-alt": props.event.kind !== MESSAGE})}> <div class={cx("text-sm", {"card2 card2-sm bg-alt": props.event.kind !== MESSAGE})}>
{#if path && !isMobile} {#if path && !isMobile}
<Link href={path}> <Link href={path}>
<NoteContent {...props} {minLength} {maxLength} /> <NoteContent {...props} />
</Link> </Link>
{:else} {:else}
<NoteContent {...props} {minLength} {maxLength} /> <NoteContent {...props} />
{/if} {/if}
</div> </div>
@@ -0,0 +1,18 @@
<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}
+2 -2
View File
@@ -11,8 +11,8 @@
const {url, h, ...props}: Props = $props() const {url, h, ...props}: Props = $props()
</script> </script>
<div class="flex grow items-center justify-between gap-4 {props.class}"> <div class="flex flex-grow items-center justify-between gap-4 {props.class}">
<div class="flex items-center gap-2"> <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">
<RoomName {url} {h} /> <RoomName {url} {h} />
+1 -1
View File
@@ -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 grow items-center justify-between gap-4"> <div class="ellipsize whitespace-nowrap flex 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?.()}
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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 grow"> <div class="flex items-center gap-4 justify-between flex-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>
+1 -1
View File
@@ -100,6 +100,6 @@
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-primary grow" onclick={back}>Done</Button> <Button class="btn btn-primary flex-grow" onclick={back}>Done</Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
+8 -19
View File
@@ -1,8 +1,7 @@
<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 {Poll} from "nostr-tools/kinds" import {deriveRelay, createSearch, pubkey} from "@welshman/app"
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"
@@ -18,7 +17,6 @@
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"
@@ -66,13 +64,11 @@
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)
@@ -140,14 +136,12 @@
<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="shrink-0"> <div class="flex-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 <strong class="flex items-center gap-1 relative">
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"
@@ -263,21 +257,16 @@
<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 shrink-0"></div> <div class="h-2 flex-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 shrink-0"></div> <div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader> <SecondaryNavHeader>
{#if $userRooms.length > 0} {#if $userRooms.length > 0}
Other Rooms Other Rooms
@@ -296,7 +285,7 @@
<SpaceMenuRoomItem {url} {h} /> <SpaceMenuRoomItem {url} {h} />
{/each} {/each}
{#if $otherVoiceRooms.length > 0} {#if $otherVoiceRooms.length > 0}
<div class="h-2 shrink-0"></div> <div class="h-2 flex-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} />
@@ -309,11 +298,11 @@
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{/if} {/if}
<div class="h-5 shrink-0"></div> <div class="h-5 flex-shrink-0"></div>
</div> </div>
</SecondaryNavSection> </SecondaryNavSection>
<div <div
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"> class="flex 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} />
+1 -2
View File
@@ -24,13 +24,12 @@
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} title={roomName} {replaceState} {notification}> <SecondaryNavItem href={path} {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" />
+85 -121
View File
@@ -1,16 +1,15 @@
<script lang="ts"> <script lang="ts">
import {tick} from "svelte" import {tick} from "svelte"
import {debounce} from "throttle-debounce" import {createSearch} from "@welshman/app"
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, Filter} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {sortEventsDesc} from "@welshman/util" import {MESSAGE} 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 {CONTENT_KINDS} from "@app/core/state" import {deriveEventsForUrl} from "@app/core/state"
import {goToEvent} from "@app/util/routes" import {goToEvent} from "@app/util/routes"
type Props = { type Props = {
@@ -20,16 +19,14 @@
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
@@ -43,53 +40,21 @@
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 = () => {
void search(term) show = true
} }
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) => {
@@ -130,74 +95,73 @@
} }
</script> </script>
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}> <div>
<Icon size={4} icon={Magnifier} /> <button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
</button> <Icon size={4} icon={Magnifier} />
{#if show} </button>
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button> {#if show}
<div class="fixed top-sai right-sai left-content z-feature p-2"> <button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
<div <div class="fixed cw top-0 right-0 z-feature p-2">
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md" <div
transition:fly={{y: -40, duration: 150}}> class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
<div class="flex justify-between"> transition:fly={{y: -40, duration: 150}}>
<strong>Search</strong> <div class="flex justify-between">
<Button onclick={clear}> <strong>Search</strong>
<Icon icon={CloseCircle} /> <Button onclick={clear}>
</Button> <Icon icon={CloseCircle} />
</div> </Button>
<label class="input input-sm input-bordered flex w-full items-center gap-2"> </div>
<Icon size={4} icon={Magnifier} /> <label class="input input-sm input-bordered flex w-full items-center gap-2">
<input <Icon size={4} icon={Magnifier} />
bind:this={input} <input
bind:value={term} bind:this={input}
class="min-w-0 grow" bind:value={term}
type="text" class="min-w-0 grow"
placeholder={h ? "Search this room..." : "Search this space..."} type="text"
oninput={onInput} /> placeholder={h ? "Search this room..." : "Search this space..."}
</label> oninput={onInput} />
<div class="max-h-[65vh] overflow-y-auto"> </label>
<p class="mb-2 text-xs opacity-70">{relayStatus}</p> <div class="max-h-[65vh] overflow-y-auto">
{#if !term} {#if !term}
<p class="text-sm opacity-70"> <p class="text-sm opacity-70">
{h ? "Search for content in this room." : "Search for content in this space."} {h ? "Search for messages in this room." : "Search for messages across this space."}
</p> </p>
{:else if loading} {:else if eventsByAge.size === 0}
<p class="text-sm opacity-70">Searching...</p> <p class="text-sm opacity-70">No results found.</p>
{:else if eventsByAge.size === 0} {:else}
<p class="text-sm opacity-70">No results found.</p> <div class="col-2">
{:else} {#each eventsByAge as [key, events] (key)}
<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">
{#each events as event (event.id)} <p class="text-xs uppercase tracking-wide opacity-60">
<button {#if key === "day"}
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left" Last 24 Hours
onclick={() => onRoomSearchResultClick(event)}> {:else if key === "week"}
<p class="line-clamp-2 text-sm"> Last 7 Days
{event.content.trim() || "(No text content)"} {:else}
</p> Older
<div class="row-2 text-xs opacity-70"> {/if}
<span>{getAgeLabel(event.created_at)}</span> </p>
<span>{formatTimestampAsDate(event.created_at)}</span> <div class="col-2">
</div> {#each events as event (event.id)}
</button> <button
{/each} class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
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>
</div> {/each}
{/each} </div>
</div> {/if}
{/if} </div>
</div> </div>
</div> </div>
</div> {/if}
{/if} </div>
+1 -1
View File
@@ -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 grow flex-wrap justify-end gap-2"> <div class="flex 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} />
+24 -73
View File
@@ -1,14 +1,13 @@
<script lang="ts"> <script lang="ts">
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {makeEvent, THREAD} from "@welshman/util" import {makeEvent, THREAD} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app" import {publishThunk} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl" import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
@@ -19,23 +18,15 @@
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, publishRoomQuote} from "@app/core/commands"
type Values = {
content?: string | object
title?: string
}
type Props = { type Props = {
url: string url: string
h?: string h?: string
shareToChat?: boolean
} }
const {url, h, shareToChat = false}: 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)
@@ -45,7 +36,7 @@
const selectFiles = () => editor.then(ed => ed.commands.selectFiles()) const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => { const submit = async () => {
if ($uploading || loading) return if ($uploading) return
if (!title) { if (!title) {
return pushToast({ return pushToast({
@@ -66,62 +57,25 @@
const tags = [...ed.storage.nostr.getEditorTags(), ["title", title]] const tags = [...ed.storage.nostr.getEditorTags(), ["title", title]]
loading = true if (await shouldProtect) {
tags.push(PROTECTED)
try {
const protect = await shouldProtect
if (protect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
const threadThunk = publishThunk({
relays: [url],
event: makeEvent(THREAD, {content, tags}),
})
const error = await waitForThunkError(threadThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: threadThunk.event, protect})
}
} finally {
loading = false
} }
if (h) {
tags.push(["h", h])
}
publishThunk({
relays: [url],
event: makeEvent(THREAD, {content, tags}),
})
history.back()
} }
let loading = $state(false) const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
let title = $state(initialValues?.title ?? "") let title: string = $state("")
let content = $state(initialValues?.content ?? "")
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)}>
@@ -152,7 +106,7 @@
<p>Message*</p> <p>Message*</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="note-editor grow overflow-hidden"> <div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} /> <EditorContent {editor} />
</div> </div>
{/snippet} {/snippet}
@@ -160,8 +114,7 @@
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2" class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles} onclick={selectFiles}>
disabled={loading}>
{#if $uploading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
{:else} {:else}
@@ -171,12 +124,10 @@
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}> <Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}> <Button type="submit" class="btn btn-primary">Create Thread</Button>
<Spinner {loading}>Create Thread</Spinner>
</Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
+7 -74
View File
@@ -1,98 +1,28 @@
<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 <div transition:fly class="bottom-sai right-sai toast z-toast">
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 relative flex justify-center whitespace-normal text-left" class="alert 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"}>
<Button <p class:welshman-content-error={theme === "error"}>
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}
@@ -105,6 +35,9 @@
<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>
+97 -128
View File
@@ -6,96 +6,79 @@
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 ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import VideoCallTile from "@app/components/VideoCallTile.svelte" import VideoCallVideo from "@app/components/VideoCallVideo.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import {get} from "svelte/store"
import { import {
VideoCallLayout, currentVoiceSession,
isDesktopLayout, currentVoiceRoom,
toggleVideoPrimaryTile, videoCallContentActive,
videoCallLayout, videoCallLayoutRevision,
videoCallViewportSync,
ViewportSize,
videoPrimaryTileKey, videoPrimaryTileKey,
} from "@app/call/video" toggleVideoPrimaryTile,
import {currentVoiceSession, currentVoiceRoom, pubkeyFromLiveKitIdentity} from "@app/call/stores" pubkeyFromLiveKitIdentity,
} from "@app/voice"
type Variant = "mobile" | "desktop-split" | "desktop-full"
type Props = { type Props = {
layout: VideoCallLayout variant: Variant
mobile?: boolean
url: string url: string
h: string h: string
visible?: boolean
class?: string class?: string
} }
type VideoTileData = { type Tile = {
identity: string identity: string
isLocal: boolean isLocal: boolean
trackSid: string trackSid: string
track: Track | undefined attachable: Track | undefined
source: Track.Source.Camera | Track.Source.ScreenShare source: Track.Source.Camera | Track.Source.ScreenShare
} }
type TileLayout = "spotlight" | "default" | "strip" type TileLayout = "spotlight" | "default" | "strip"
const {layout, mobile = false, url, h, class: className = ""}: Props = $props() const {variant, url, h, visible = true, class: className = ""}: Props = $props()
$effect(() => { const roomMatches = $derived($currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h)
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( const allowEmptyPanel = $derived(variant === "desktop-split" || variant === "desktop-full")
$currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
const showPanel = $derived(
visible &&
roomMatches &&
(variant === "mobile" ? $videoCallContentActive : $videoCallContentActive || allowEmptyPanel),
) )
const showVideoContent = $derived( const tiles = $derived.by(() => {
isViewingCurrentCallRoom && // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- re-run when remote video subscribes
(mobile $videoCallLayoutRevision
? layout === VideoCallLayout.Video
: layout === VideoCallLayout.Split || layout === VideoCallLayout.Video),
)
const videoTiles = $derived.by(() => {
const session = $currentVoiceSession const session = $currentVoiceSession
if (!session || $currentVoiceRoom?.url !== url || $currentVoiceRoom?.h !== h) { if (!session || $currentVoiceRoom?.url !== url || $currentVoiceRoom?.h !== h) {
return [] return []
} }
const room = session.room const room = session.room
const videoTiles: VideoTileData[] = [] const out: Tile[] = []
const user = room.localParticipant const lp = room.localParticipant
if (session.cameraOn) { if (session.cameraOn) {
const localPub = user.getTrackPublication(Track.Source.Camera) const localPub = lp.getTrackPublication(Track.Source.Camera)
videoTiles.push({ out.push({
identity: user.identity, identity: lp.identity,
isLocal: true, isLocal: true,
trackSid: localPub?.trackSid ?? "local-camera", trackSid: localPub?.trackSid ?? "local-camera",
track: localPub?.track, attachable: localPub?.track,
source: Track.Source.Camera, source: Track.Source.Camera,
}) })
} }
if (session.screenShareOn) { if (session.screenShareOn) {
const localPub = user.getTrackPublication(Track.Source.ScreenShare) const localPub = lp.getTrackPublication(Track.Source.ScreenShare)
videoTiles.push({ out.push({
identity: user.identity, identity: lp.identity,
isLocal: true, isLocal: true,
trackSid: localPub?.trackSid ?? "local-screen", trackSid: localPub?.trackSid ?? "local-screen",
track: localPub?.track, attachable: localPub?.track,
source: Track.Source.ScreenShare, source: Track.Source.ScreenShare,
}) })
} }
@@ -103,70 +86,70 @@
for (const rp of room.remoteParticipants.values()) { for (const rp of room.remoteParticipants.values()) {
const camPub = rp.getTrackPublication(Track.Source.Camera) const camPub = rp.getTrackPublication(Track.Source.Camera)
if (camPub?.isSubscribed && camPub.track) { if (camPub?.isSubscribed && camPub.track) {
videoTiles.push({ out.push({
identity: rp.identity, identity: rp.identity,
isLocal: false, isLocal: false,
trackSid: camPub.trackSid, trackSid: camPub.trackSid,
track: camPub.track, attachable: camPub.track,
source: Track.Source.Camera, source: Track.Source.Camera,
}) })
} }
const screenPub = rp.getTrackPublication(Track.Source.ScreenShare) const screenPub = rp.getTrackPublication(Track.Source.ScreenShare)
if (screenPub?.isSubscribed && screenPub.track) { if (screenPub?.isSubscribed && screenPub.track) {
videoTiles.push({ out.push({
identity: rp.identity, identity: rp.identity,
isLocal: false, isLocal: false,
trackSid: screenPub.trackSid, trackSid: screenPub.trackSid,
track: screenPub.track, attachable: screenPub.track,
source: Track.Source.ScreenShare, source: Track.Source.ScreenShare,
}) })
} }
} }
return videoTiles return out
}) })
/** Identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */ /** 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 tileKey = (t: Tile) => `${t.identity}\x1f${t.source}`
const primaryTile = $derived.by(() => { const primaryTile = $derived.by(() => {
const k = $videoPrimaryTileKey const k = $videoPrimaryTileKey
if (k === undefined) return undefined if (k === undefined) return undefined
return videoTiles.find(t => tileKey(t) === k) return tiles.find(t => tileKey(t) === k)
}) })
const secondaryTiles = $derived.by(() => { const secondaryTiles = $derived.by(() => {
const p = primaryTile const p = primaryTile
if (p === undefined) return videoTiles if (p === undefined) return tiles
const pk = tileKey(p) const pk = tileKey(p)
return videoTiles.filter(t => tileKey(t) !== pk) return tiles.filter(t => tileKey(t) !== pk)
}) })
const useSpotlightLayout = $derived(primaryTile !== undefined) const useSpotlightLayout = $derived(primaryTile !== undefined)
const useMultiGrid = $derived(!useSpotlightLayout && videoTiles.length > 2) const useMultiGrid = $derived(!useSpotlightLayout && tiles.length > 2)
$effect(() => { $effect(() => {
const k = $videoPrimaryTileKey const k = $videoPrimaryTileKey
if (k === undefined) return if (k === undefined) return
if (!videoTiles.some(t => tileKey(t) === k)) { if (!tiles.some(t => tileKey(t) === k)) {
videoPrimaryTileKey.set(undefined) videoPrimaryTileKey.set(undefined)
} }
}) })
$effect(() => { $effect(() => {
for (const t of videoTiles) { for (const t of tiles) {
const pk = pubkeyFromLiveKitIdentity(t.identity) const pk = pubkeyFromLiveKitIdentity(t.identity)
if (pk) loadProfile(pk) if (pk) loadProfile(pk)
} }
}) })
const labelFor = (identity: string, source: VideoTileData["source"]) => { const labelFor = (identity: string, source: Tile["source"]) => {
const pk = pubkeyFromLiveKitIdentity(identity) const pk = pubkeyFromLiveKitIdentity(identity)
const name = pk ? displayProfileByPubkey(pk) : "Unknown" const name = pk ? displayProfileByPubkey(pk) : "Unknown"
return source === Track.Source.ScreenShare ? `${name} · screen` : name return source === Track.Source.ScreenShare ? `${name} · screen` : name
} }
const showTileGrid = $derived(videoTiles.length > 0) const showTileGrid = $derived(tiles.length > 0)
const spotlightHandlerFor = (key: string) => () => { const spotlightHandlerFor = (key: string) => () => {
toggleVideoPrimaryTile(key) toggleVideoPrimaryTile(key)
@@ -174,16 +157,18 @@
const panelChrome = $derived( const panelChrome = $derived(
cx( cx(
mobile && variant === "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))]", "cb ct cw z-compose bg-base-300/95 fixed inset-x-0 flex min-h-0 flex-col gap-2 overflow-hidden p-2 md:hidden",
!mobile && variant === "desktop-split" &&
"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", "cb ct cw-split-video z-compose bg-base-300/95 fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex",
variant === "desktop-full" &&
"cb ct cw z-compose bg-base-300/95 fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex",
className, className,
), ),
) )
</script> </script>
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)} {#snippet videoTile(tile: Tile, layout: TileLayout)}
<div <div
class={cx( class={cx(
"relative isolate overflow-hidden rounded-box shadow-sm", "relative isolate overflow-hidden rounded-box shadow-sm",
@@ -191,10 +176,12 @@
layout === "default" && "aspect-video w-full min-h-0", layout === "default" && "aspect-video w-full min-h-0",
layout === "strip" && "aspect-video w-44 shrink-0", layout === "strip" && "aspect-video w-44 shrink-0",
tile.source === Track.Source.ScreenShare ? "bg-black" : "bg-base-100", tile.source === Track.Source.ScreenShare ? "bg-black" : "bg-base-100",
$videoPrimaryTileKey === tileKey(tile) &&
"ring-2 ring-primary ring-offset-2 ring-offset-base-300",
)}> )}>
{#if tile.track} {#if tile.attachable}
<VideoCallTile <VideoCallVideo
track={tile.track} track={tile.attachable}
muted={tile.isLocal} muted={tile.isLocal}
fit={tile.source === Track.Source.ScreenShare ? "contain" : "cover"} fit={tile.source === Track.Source.ScreenShare ? "contain" : "cover"}
class="pointer-events-none absolute inset-0" /> class="pointer-events-none absolute inset-0" />
@@ -207,15 +194,10 @@
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"> 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)" : ""} {labelFor(tile.identity, tile.source)}{tile.isLocal ? " (you)" : ""}
</span> </span>
{#if videoTiles.length > 1} {#if tiles.length > 1}
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
<Button <Button
data-tip={pinned ? "Exit spotlight" : "Spotlight"} data-tip={$videoPrimaryTileKey === tileKey(tile) ? "Exit spotlight" : "Spotlight"}
aria-pressed={pinned} class="absolute right-1 top-1 z-20 btn btn-xs btn-circle btn-ghost bg-base-100/70"
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))}> onclick={spotlightHandlerFor(tileKey(tile))}>
<Icon icon={Pin} size={3} /> <Icon icon={Pin} size={3} />
</Button> </Button>
@@ -223,56 +205,43 @@
</div> </div>
{/snippet} {/snippet}
{#snippet videoPanelBody()} {#if showPanel && (showTileGrid || allowEmptyPanel)}
{#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}> <div class={panelChrome}>
{#if mobile} {#if showTileGrid}
<div class="flex min-h-0 flex-1 flex-col gap-2"> {#if useSpotlightLayout && primaryTile}
<div class="min-h-0 flex-1 overflow-hidden"> <div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
{@render videoPanelBody()} {@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> </div>
<div class="shrink-0 pb-2"> {:else if useMultiGrid}
<VoiceWidget /> <div
class="grid min-h-0 flex-1 grid-cols-1 content-start gap-2 overflow-y-auto sm:grid-cols-2">
{#each tiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}
</div> </div>
</div> {:else}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
{#each tiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}
</div>
{/if}
{:else} {:else}
{@render videoPanelBody()} <div
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-box bg-base-100/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 in the voice widget to share video.
</p>
</div>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -11,21 +11,21 @@
const {track, muted = true, fit = "cover", class: className = ""}: Props = $props() const {track, muted = true, fit = "cover", class: className = ""}: Props = $props()
let videoElement = $state<HTMLVideoElement | undefined>() let el = $state<HTMLVideoElement | undefined>()
$effect(() => { $effect(() => {
const element = videoElement const v = el
const activeTrack = track const t = track
if (!element) return if (!v) return
activeTrack.attach(element) t.attach(v)
return () => { return () => {
activeTrack.detach(element) t.detach(v)
} }
}) })
</script> </script>
<video <video
bind:this={videoElement} bind:this={el}
class={cx("h-full w-full", fit === "contain" ? "object-contain" : "object-cover", className)} class={cx("h-full w-full", fit === "contain" ? "object-contain" : "object-cover", className)}
playsinline playsinline
{muted}></video> {muted}></video>
@@ -5,10 +5,14 @@
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"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import {currentVoiceSession, type VoiceSession} from "@app/call/stores" import {
import {DeviceKind, supportsAudioOutputSelection, switchVoiceActiveDevice} from "@app/call/voice" currentVoiceSession,
DeviceKind,
supportsAudioOutputSelection,
switchVoiceActiveDevice,
type VoiceSession,
} from "@app/voice"
import {popModal} from "@app/util/modal" import {popModal} from "@app/util/modal"
const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => { const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => {
@@ -41,9 +45,16 @@
} }
$effect(() => { $effect(() => {
loadDevices() void loadDevices()
navigator.mediaDevices?.addEventListener?.("devicechange", loadDevices) const md = navigator.mediaDevices
return () => navigator.mediaDevices?.removeEventListener?.("devicechange", loadDevices) if (!md?.addEventListener) return
const onDeviceChange = () => {
void loadDevices()
}
md.addEventListener("devicechange", onDeviceChange)
return () => {
md.removeEventListener("devicechange", onDeviceChange)
}
}) })
$effect(() => { $effect(() => {
@@ -73,7 +84,6 @@
popModal() popModal()
} }
// Output not support in Safari
const canPickOutput = supportsAudioOutputSelection() const canPickOutput = supportsAudioOutputSelection()
</script> </script>
@@ -81,8 +91,8 @@
<ModalBody> <ModalBody>
<ModalHeader> <ModalHeader>
<ModalTitle>Call settings</ModalTitle> <ModalTitle>Call settings</ModalTitle>
<ModalSubtitle>Microphone, speaker, and camera for this call.</ModalSubtitle>
</ModalHeader> </ModalHeader>
<p class="text-sm opacity-80">Microphone, speaker, and camera for this call.</p>
<div class="flex flex-col gap-4 pt-2"> <div class="flex flex-col gap-4 pt-2">
<FieldInline> <FieldInline>
{#snippet label()} {#snippet label()}
@@ -146,6 +156,6 @@
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-primary w-full" onclick={onDone}>Done</Button> <Button class="btn btn-primary" onclick={onDone}>Done</Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
+5 -4
View File
@@ -12,13 +12,14 @@
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/call/stores" } from "@app/voice"
import {cancelJoinVoiceRoom, deriveVoiceParticipants} from "@app/call/voice"
interface Props { interface Props {
url: string url: string
@@ -63,7 +64,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}
+3 -15
View File
@@ -12,11 +12,9 @@
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/call/voice" import {joinVoiceRoom} from "@app/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
@@ -47,16 +45,6 @@
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(
@@ -64,7 +52,7 @@
h, h,
startWithoutMic, startWithoutMic,
startWithoutMic ? undefined : selectedDeviceId || undefined, startWithoutMic ? undefined : selectedDeviceId || undefined,
).catch(handleJoinError) )
} }
</script> </script>
@@ -89,7 +77,7 @@
type="checkbox" type="checkbox"
class="checkbox" class="checkbox"
bind:checked={startWithoutMic} /> bind:checked={startWithoutMic} />
<label for="voice-start-without-mic" class="text-sm cursor-pointer"> <label for="voice-start-without-mic" class="cursor-pointer text-sm">
Join without microphone (you can unmute later) Join without microphone (you can unmute later)
</label> </label>
</div> </div>
+36 -128
View File
@@ -1,20 +1,18 @@
<script lang="ts"> <script lang="ts">
import {readable} from "svelte/store" import {readable} from "svelte/store"
import {fade, fly} from "svelte/transition" import {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 Videocamera from "@assets/icons/videocamera.svg?dataurl"
import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl" import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl"
import Monitor from "@assets/icons/monitor.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 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 VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
@@ -28,23 +26,18 @@
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,
isLocalSpeaking, leaveVoiceRoom,
} from "@app/call/stores" toggleMute,
import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice" toggleCamera,
toggleScreenShare,
cancelJoinVoiceRoom,
} 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)
@@ -53,14 +46,6 @@
) )
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
@@ -86,45 +71,9 @@
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h}) pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
} }
const goToRoom = () => { const openAudioSettings = () => {
if (!targetRoom) return
const path = makeRoomPath(targetRoom.url, targetRoom.h)
if ($page.url.pathname !== path) {
void goto(path)
}
}
const openCallSettings = () => {
pushModal(VoiceCallAudioSettingsDialog) 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}
@@ -132,47 +81,19 @@
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 items-start justify-between gap-2"> <div class="flex flex-col gap-0.5">
<button {#if $voiceState === VoiceState.Joining}
type="button" <span class="text-sm font-semibold text-warning">Joining...</span>
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" {:else if $voiceState === VoiceState.Connected}
onclick={goToRoom} <span class="text-sm font-semibold text-success">Voice Connected</span>
aria-label="Open room {roomName}"> {:else}
<div class="flex flex-col gap-0.5"> <span class="text-sm font-semibold text-neutral-content">Disconnected</span>
{#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 flex-wrap items-center gap-2"> <div class="flex items-center gap-1">
{#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
@@ -184,45 +105,32 @@
{: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={cx( class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
mediaToggleClass, ? 'btn-error'
"overflow-visible", : 'btn-ghost'}"
!$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}>
<span class="relative inline-flex items-center justify-center overflow-visible"> <Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
<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>
<Button <Button
data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"} data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"}
class={cx(mediaToggleClass, $currentVoiceSession.cameraOn && "text-primary")} class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.cameraOn
? 'btn-ghost'
: 'btn-error'}"
onclick={toggleCamera}> onclick={toggleCamera}>
<Icon icon={$currentVoiceSession.cameraOn ? VideocameraRecord : Videocamera} size={4} /> <Icon icon={$currentVoiceSession.cameraOn ? VideocameraRecord : Videocamera} size={4} />
</Button> </Button>
{#if !Capacitor.isNativePlatform()} <Button
<Button data-tip={$currentVoiceSession.screenShareOn ? "Stop sharing" : "Share screen"}
data-tip={$currentVoiceSession.screenShareOn ? "Stop sharing" : "Share screen"} class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.screenShareOn
class={cx(mediaToggleClass, $currentVoiceSession.screenShareOn && "text-primary")} ? 'btn-ghost'
onclick={toggleScreenShare}> : 'btn-error'}"
<Icon icon={Monitor} size={4} /> onclick={toggleScreenShare}>
</Button> <Icon icon={Monitor} size={4} />
{/if} </Button>
<Button <Button
data-tip="Call settings" data-tip="Call settings"
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost" class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
onclick={openCallSettings}> onclick={openAudioSettings}>
<Icon icon={Settings} size={4} /> <Icon icon={Settings} size={4} />
</Button> </Button>
<Button <Button
+1 -1
View File
@@ -70,7 +70,7 @@
Amount (satoshis) Amount (satoshis)
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="flex grow justify-end"> <div class="flex 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
+1 -1
View File
@@ -80,7 +80,7 @@
Amount (satoshis) Amount (satoshis)
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="flex grow justify-end"> <div class="flex 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" />
+2 -2
View File
@@ -1,7 +1,7 @@
<style> <style>
.wot-background { .wot-background {
fill: transparent; fill: transparent;
stroke: var(--color-base-content); stroke: var(--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(--color-primary)" : "var(--color-base-content)") const stroke = $derived(active ? "var(--primary)" : "var(--base-content)")
</script> </script>
<div class="relative h-[14px] w-[14px]"> <div class="relative h-[14px] w-[14px]">
+5 -5
View File
@@ -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 grow items-center justify-end gap-4"> <div class="flex 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 grow justify-end"> <div class="flex 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" />
+6 -6
View File
@@ -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 grow" value={invoice} /> <input readonly class="ellipsize flex-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 grow items-center justify-end gap-4"> <div class="flex 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 grow justify-end"> <div class="flex 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" />
-46
View File
@@ -18,11 +18,9 @@ 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,
MESSAGE,
PROFILE, PROFILE,
MESSAGING_RELAYS, MESSAGING_RELAYS,
RELAYS, RELAYS,
@@ -123,34 +121,6 @@ export const prependParent = (
return {content, tags} return {content, tags}
} }
export const publishRoomQuote = ({
url,
h,
parent,
protect,
delay,
}: {
url: string
h?: string
parent: TrustedEvent
protect: boolean
delay?: number
}) => {
const tags: string[][] = []
if (h) {
tags.push(["h", h])
}
if (protect) {
tags.push(PROTECTED)
}
const event = makeEvent(MESSAGE, prependParent(parent, {content: "", tags}, url))
return publishThunk({relays: [url], event, delay})
}
// Synchronization // Synchronization
export const broadcastUserData = async (relays: string[]) => { export const broadcastUserData = async (relays: string[]) => {
@@ -381,22 +351,6 @@ 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 = {
+69 -106
View File
@@ -1,6 +1,5 @@
import {writable} from "svelte/store" import {get, writable} from "svelte/store"
import { import {
batch,
call, call,
uniq, uniq,
int, int,
@@ -26,8 +25,7 @@ import {
sortEventsDesc, sortEventsDesc,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent, Filter, List} from "@welshman/util" import type {TrustedEvent, Filter, List} from "@welshman/util"
import {load, request, mergeRepositoryUpdates} from "@welshman/net" import {load, request} from "@welshman/net"
import type {RepositoryUpdate} from "@welshman/net"
import {repository, loadRelay, tracker} from "@welshman/app" import {repository, loadRelay, tracker} from "@welshman/app"
import {createScroller} from "@lib/html" import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util" import {daysBetween} from "@lib/util"
@@ -58,75 +56,57 @@ export const makeFeed = ({
let backwardWindow = [at - interval, at] let backwardWindow = [at - interval, at]
let forwardWindow = [at, at + interval] let forwardWindow = [at, at + interval]
const insertIntoBuffer = (event: TrustedEvent) => { const insertEvent = (event: TrustedEvent) => {
for (let i = 0; i < buffer.length; i++) { let handled = false
if (buffer[i].created_at < event.created_at) {
buffer.splice(i, 0, event)
return
}
}
buffer.push(event)
}
// Batch-insert events into the visible store with a single update if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) {
const insertEvents = (newEvents: TrustedEvent[]) => { const $events = get(events)
const visible: TrustedEvent[] = []
for (const event of newEvents) { for (let i = 0; i < $events.length; i++) {
if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) { if ($events[i].created_at > event.created_at) {
visible.push(event) events.set(insertAt(i, event, $events))
} else { handled = true
insertIntoBuffer(event) break
}
}
if (visible.length > 0) {
events.update($events => {
for (const event of visible) {
let inserted = false
for (let i = 0; i < $events.length; i++) {
if ($events[i].created_at > event.created_at) {
$events = insertAt(i, event, $events)
inserted = true
break
}
}
if (!inserted) {
$events = [...$events, event]
}
} }
return $events }
})
if (!handled) {
events.set([...$events, event])
}
} else {
for (let i = 0; i < buffer.length; i++) {
if (buffer[i].created_at > event.created_at) {
buffer.splice(i, 0, event)
handled = true
break
}
}
if (!handled) {
buffer.push(event)
}
} }
} }
const unsubscribers = [ const unsubscribers = [
on( on(repository, "update", ({added, removed}) => {
repository, if (removed.size > 0) {
"update", buffer = buffer.filter(e => !removed.has(e.id))
batch(150, (updates: RepositoryUpdate[]) => { events.update($events => $events.filter(e => !removed.has(e.id)))
const {added, removed} = mergeRepositoryUpdates(updates) }
if (removed.size > 0) { for (const event of added) {
buffer = buffer.filter(e => !removed.has(e.id)) if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) {
events.update($events => $events.filter(e => !removed.has(e.id))) insertEvent(event)
} }
}
const matching = added.filter( }),
event => matchFilters(filters, event) && tracker.getRelays(event.id).has(url),
)
if (matching.length > 0) {
insertEvents(matching)
}
}),
),
on(tracker, "add", (id: string, trackerUrl: string) => { on(tracker, "add", (id: string, trackerUrl: string) => {
if (trackerUrl === url) { if (trackerUrl === url) {
const event = repository.getEvent(id) const event = repository.getEvent(id)
if (event && matchFilters(filters, event)) { if (event && matchFilters(filters, event)) {
insertEvents([event]) insertEvent(event)
} }
} }
}), }),
@@ -152,15 +132,17 @@ export const makeFeed = ({
element, element,
delay: 300, delay: 300,
threshold: 5000, threshold: 5000,
onScroll: async () => { onScroll: () => {
const [since, until] = backwardWindow const [since, until] = backwardWindow
backwardWindow = [since - interval, since] backwardWindow = [since - interval, since]
insertEvents(buffer.splice(0, 30)) for (const event of buffer.splice(0, 30)) {
insertEvent(event)
}
if (until > now() - int(2, YEAR)) { if (until > now() - int(2, YEAR)) {
await loadTimeframe(since, until) loadTimeframe(since, until)
} else if (!buffer.some(e => e.created_at < at)) { } else if (!buffer.some(e => e.created_at < at)) {
backwardScroller.stop() backwardScroller.stop()
onBackwardExhausted?.() onBackwardExhausted?.()
@@ -173,15 +155,17 @@ export const makeFeed = ({
reverse: true, reverse: true,
delay: 300, delay: 300,
threshold: 5000, threshold: 5000,
onScroll: async () => { onScroll: () => {
const [since, until] = forwardWindow const [since, until] = forwardWindow
forwardWindow = [until, until + interval] forwardWindow = [until, until + interval]
insertEvents(buffer.splice(0, 30)) for (const event of buffer.splice(0, 30)) {
insertEvent(event)
}
if (until < now()) { if (until < now()) {
await loadTimeframe(since, until) loadTimeframe(since, until)
} else if (!buffer.some(e => e.created_at > at)) { } else if (!buffer.some(e => e.created_at > at)) {
forwardScroller.stop() forwardScroller.stop()
onForwardExhausted?.() onForwardExhausted?.()
@@ -224,61 +208,40 @@ export const makeCalendarFeed = ({
const events = writable(sortBy(getStart, getEventsForUrl(url, filters))) const events = writable(sortBy(getStart, getEventsForUrl(url, filters)))
// Batch-insert calendar events into the store with a single update const insertEvent = (event: TrustedEvent) => {
const insertEvents = (newEvents: TrustedEvent[]) => { const start = getStart(event)
const valid = newEvents.filter(e => !isNaN(getStart(e)) && !isNaN(getEnd(e))) const address = getAddress(event)
if (valid.length === 0) return
if (isNaN(start) || isNaN(getEnd(event))) return
events.update($events => { events.update($events => {
for (const event of valid) { for (let i = 0; i < $events.length; i++) {
const start = getStart(event) if ($events[i].id === event.id) return $events
const address = getAddress(event) if (getStart($events[i]) > start) return insertAt(i, event, $events)
let handled = false
for (let i = 0; i < $events.length; i++) {
if ($events[i].id === event.id) {
handled = true
break
}
if (getStart($events[i]) > start) {
$events = insertAt(i, event, $events)
handled = true
break
}
}
if (!handled) {
$events = [...$events.filter(e => getAddress(e) !== address), event]
}
} }
return $events
return [...$events.filter(e => getAddress(e) !== address), event]
}) })
} }
const unsubscribers = [ const unsubscribers = [
on( on(repository, "update", ({added, removed}) => {
repository, if (removed.size > 0) {
"update", events.update($events => $events.filter(e => !removed.has(e.id)))
batch(150, (updates: RepositoryUpdate[]) => { }
const {added, removed} = mergeRepositoryUpdates(updates)
if (removed.size > 0) { for (const event of added) {
events.update($events => $events.filter(e => !removed.has(e.id))) if (matchFilters(filters, event)) {
insertEvent(event)
} }
}
const matching = added.filter(event => matchFilters(filters, event)) }),
if (matching.length > 0) {
insertEvents(matching)
}
}),
),
on(tracker, "add", (id: string, trackerUrl: string) => { on(tracker, "add", (id: string, trackerUrl: string) => {
if (trackerUrl === url) { if (trackerUrl === url) {
const event = repository.getEvent(id) const event = repository.getEvent(id)
if (event && matchFilters(filters, event)) { if (event && matchFilters(filters, event)) {
insertEvents([event]) insertEvent(event)
} }
} }
}), }),
+83 -128
View File
@@ -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,9 +191,7 @@ 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 = import.meta.env.PROD export const PLATFORM_LOGO = PLATFORM_URL + "/logo.png"
? 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
@@ -209,8 +207,6 @@ 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," +
[ [
@@ -327,7 +323,9 @@ if (ENABLE_ZAPS) {
REACTION_KINDS.push(ZAP_RESPONSE) REACTION_KINDS.push(ZAP_RESPONSE)
} }
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, Poll] export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED]
export const MESSAGE_KINDS = [...CONTENT_KINDS, MESSAGE]
export const DM_KINDS = [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE] export const DM_KINDS = [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE]
@@ -552,7 +550,7 @@ export const chatsById = call(() => {
setTimeout(() => { setTimeout(() => {
addEvents(added) addEvents(added)
removeEvents(removed) removeEvents(removed)
}, 200) }, 50)
}), }),
] ]
@@ -566,7 +564,7 @@ export const deriveChat = call(() => {
return (pubkeys: string[]) => _deriveChat(makeChatId(pubkeys)) return (pubkeys: string[]) => _deriveChat(makeChatId(pubkeys))
}) })
export const chatSearch = derived(throttled(1500, chatsById), $chatsByPubkey => { export const chatSearch = derived(throttled(800, chatsById), $chatsByPubkey => {
return createSearch( return createSearch(
sortBy(c => -c.last_activity, Array.from($chatsByPubkey.values())), sortBy(c => -c.last_activity, Array.from($chatsByPubkey.values())),
{ {
@@ -593,8 +591,6 @@ export const getRoomType = (room: RoomMeta): RoomType =>
export const makeRoomId = (url: string, h: string) => `${url}'${h}` export const makeRoomId = (url: string, h: string) => `${url}'${h}`
export const isRoomId = (id: string) => id.includes("'")
export const splitRoomId = (id: string) => id.split("'") export const splitRoomId = (id: string) => id.split("'")
export const hasNip29 = (relay?: RelayProfile) => export const hasNip29 = (relay?: RelayProfile) =>
@@ -607,7 +603,7 @@ export const roomMetaEventsByIdByUrl = deriveEventsByIdByUrl({
}) })
export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByUrl => { export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByUrl => {
const result = new Map<string, Room[]>() const metaByIdByUrl = new Map<string, Map<string, Room>>()
for (const [url, events] of roomMetaEventsByIdByUrl.entries()) { for (const [url, events] of roomMetaEventsByIdByUrl.entries()) {
const [metaEvents, deleteEvents] = partition(spec({kind: ROOM_META}), events.values()) const [metaEvents, deleteEvents] = partition(spec({kind: ROOM_META}), events.values())
@@ -619,8 +615,6 @@ export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByU
} }
} }
const metaById = new Map<string, Room>()
for (const event of metaEvents) { for (const event of metaEvents) {
const meta = tryCatch(() => readRoomMeta(event)) const meta = tryCatch(() => readRoomMeta(event))
@@ -628,14 +622,22 @@ export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByU
continue continue
} }
let metaById = metaByIdByUrl.get(url)
if (!metaById) {
metaById = new Map()
metaByIdByUrl.set(url, metaById)
}
const id = makeRoomId(url, meta.h) const id = makeRoomId(url, meta.h)
metaById.set(id, {...meta, url, id}) metaById.set(id, {...meta, url, id})
} }
}
if (metaById.size > 0) { const result = new Map<string, Room[]>()
result.set(url, Array.from(metaById.values()))
} for (const [url, metaById] of metaByIdByUrl.entries()) {
result.set(url, Array.from(metaById.values()))
} }
return result return result
@@ -808,78 +810,36 @@ 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 => getSpaceMembers(url, $events), $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 = {
@@ -906,7 +866,33 @@ 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 => getRoomMembers(url, h, $events)) return derived(deriveEventsForUrl(url, filters), $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) => {
@@ -930,7 +916,7 @@ export const deriveSpaceActionItems = (url: string) =>
derived( derived(
deriveEventsForUrl(url, [ deriveEventsForUrl(url, [
{ {
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS],
}, },
]), ]),
$events => { $events => {
@@ -943,50 +929,19 @@ export const deriveSpaceActionItems = (url: string) =>
for (const [h, roomEvents] of groupBy(getRoomId, $events)) { for (const [h, roomEvents] of groupBy(getRoomId, $events)) {
if (!h) continue if (!h) continue
const roomJoins: TrustedEvent[] = [] const roomJoins = roomEvents.filter(spec({kind: ROOM_JOIN}))
const roomLeaves: TrustedEvent[] = [] const roomLeaves = roomEvents.filter(spec({kind: ROOM_LEAVE}))
const roomMembershipEvents: TrustedEvent[] = [] const roomMembersEvent = roomEvents.find(spec({kind: ROOM_MEMBERS}))
const roomMembers = getTagValues("p", roomMembersEvent?.tags ?? [])
for (const event of roomEvents) {
switch (event.kind) {
case ROOM_JOIN:
roomJoins.push(event)
break
case ROOM_LEAVE:
roomLeaves.push(event)
break
case ROOM_MEMBERS:
case ROOM_ADD_MEMBER:
case ROOM_REMOVE_MEMBER:
roomMembershipEvents.push(event)
break
}
}
const roomMembers = new Set(getRoomMembers(url, h, roomMembershipEvents))
pendingJoins.push( pendingJoins.push(
...removeUndefined( ...removeUndefined(
Array.from(groupBy(e => e.pubkey, roomJoins).values()).map(events => Array.from(groupBy(e => e.pubkey, roomJoins).values())
first(sortEventsDesc(events)), .map(sortEventsDesc)
), .map(first),
).filter(({pubkey, created_at}) => { ).filter(({pubkey, created_at}) => {
if (roomMembers.has(pubkey)) return false if (roomMembers.includes(pubkey)) return false
if ( if (gt(roomMembersEvent?.created_at, created_at)) return false
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
+92 -97
View File
@@ -1,8 +1,7 @@
import {page} from "$app/stores" import {page} from "$app/stores"
import type {Unsubscriber} from "svelte/store" import type {Unsubscriber} from "svelte/store"
import {last, call, ifLet, assoc, chunk, WEEK, ago} from "@welshman/lib" import {derived, get} from "svelte/store"
import {PollResponse} from "nostr-tools/kinds" import {last, call, ifLet, assoc, chunk, sleep, identity, WEEK, ago} from "@welshman/lib"
import {merged} from "@welshman/store"
import { import {
getListTags, getListTags,
getRelayTagValues, getRelayTagValues,
@@ -13,22 +12,20 @@ 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,
RELAY_REMOVE_MEMBER, RELAY_REMOVE_MEMBER,
MESSAGE,
isSignedEvent, isSignedEvent,
unionFilters, unionFilters,
getTagValue, getTagValue,
} from "@welshman/util" } from "@welshman/util"
import type {Filter, List, PublishedList, TrustedEvent} from "@welshman/util" import type {Filter, 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,
@@ -44,12 +41,14 @@ import {
} from "@welshman/app" } from "@welshman/app"
import { import {
REACTION_KINDS, REACTION_KINDS,
MESSAGE_KINDS,
CONTENT_KINDS, CONTENT_KINDS,
INDEXER_RELAYS, INDEXER_RELAYS,
loadSettings, loadSettings,
loadGroupList, loadGroupList,
userSpaceUrls, userSpaceUrls,
userGroupList, userGroupList,
bootstrapPubkeys,
decodeRelay, decodeRelay,
getSpaceUrlsFromGroupList, getSpaceUrlsFromGroupList,
getSpaceRoomsFromGroupList, getSpaceRoomsFromGroupList,
@@ -57,7 +56,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/call/voice" import {LIVEKIT_PARTICIPANTS} from "@app/voice"
// Utils // Utils
@@ -74,8 +73,6 @@ 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
@@ -88,12 +85,6 @@ 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, () => {
@@ -119,7 +110,9 @@ export const pullWithFallback = async ({url, signal, filters, onEvent}: SyncOpts
if (signal.aborted) return if (signal.aborted) return
await Promise.all(filters.map(filter => pullOneWithFallback(url, filter, signal, onEvent))) for (const filter of filters) {
pullOneWithFallback(url, filter, signal, onEvent)
}
} }
const listen = ({url, signal, filters, onEvent}: SyncOpts) => { const listen = ({url, signal, filters, onEvent}: SyncOpts) => {
@@ -129,8 +122,6 @@ 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)
} }
@@ -205,7 +196,7 @@ const syncUserRoomMembership = (url: string, h: string) => {
const syncUserData = () => { const syncUserData = () => {
const unsubscribersByKey = new Map<string, Unsubscriber>() const unsubscribersByKey = new Map<string, Unsubscriber>()
const syncGroupList = ($userGroupList: List | undefined) => { const unsubscribeGroupList = userGroupList.subscribe($userGroupList => {
if ($userGroupList) { if ($userGroupList) {
const keys = new Set<string>() const keys = new Set<string>()
@@ -234,41 +225,49 @@ 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 = merged([userRelayList]).subscribe(([$userRelayList]) => { const unsubscribeRelayList = userRelayList.subscribe($userRelayList => {
syncRelayList($userRelayList) if ($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()
} }
} }
// Spaces // Spaces
const syncSpace = (url: string) => { const syncSpace = (url: string, rooms: string[]) => {
const since = ago(WEEK) const since = ago(WEEK)
const seen = new Set<string>() const seen = new Set<string>()
const controller = new AbortController() const controller = new AbortController()
@@ -280,30 +279,30 @@ const syncSpace = (url: 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, ...CONTENT_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},
], ],
}) })
} }
} }
for (const room of rooms) {
pullRoomContent(room)
}
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_JOIN, ROOM_LEAVE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER] const roomMemberKinds = [ROOM_DELETE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER]
pullAndListen({ pullAndListen({
url, url,
signal: controller.signal, signal: controller.signal,
filters: [ filters: [
{kinds: [...relayKinds, ...roomMetaKinds, ...roomMemberKinds, ...CONTENT_KINDS, MESSAGE]}, {kinds: relayKinds},
{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) {
@@ -315,37 +314,45 @@ const syncSpace = (url: string) => {
listen({ listen({
url, url,
signal: controller.signal, signal: controller.signal,
filters: [{kinds: REACTION_KINDS}, {kinds: [PollResponse]}], filters: [{kinds: REACTION_KINDS}],
}) })
return () => controller.abort() return () => controller.abort()
} }
const syncSpaces = () => { const syncSpaces = () => {
const store = merged([userGroupList, page]) const store = derived([userGroupList, page], identity)
const unsubscribersByUrl = new Map<string, Unsubscriber>() const unsubscribersByUrl = new Map<string, Unsubscriber>()
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 (currentUrl) { if ($page.params.relay) {
urls.add(currentUrl) urls.add(decodeRelay($page.params.relay))
} }
// Stop syncing removed spaces // Stop syncing removed spaces
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) { for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
if (!urls.has(url)) { if (!urls.has(url)) {
unsubscribersByUrl.delete(url) unsubscribersByUrl.delete(url)
roomsByUrl.delete(url)
unsubscribe() unsubscribe()
} }
} }
// Start syncing for new spaces // Start or restart syncing for each space
for (const url of urls) { for (const url of urls) {
if (!unsubscribersByUrl.has(url)) { const rooms = getSpaceRoomsFromGroupList(url, $userGroupList)
unsubscribersByUrl.set(url, syncSpace(url)) const roomsKey = rooms.join(",")
}
if (unsubscribersByUrl.has(url) && roomsByUrl.get(url) === roomsKey) continue
// Tear down existing sync if rooms changed
unsubscribersByUrl.get(url)?.()
roomsByUrl.set(url, roomsKey)
unsubscribersByUrl.set(url, syncSpace(url, rooms))
} }
}) })
@@ -376,7 +383,6 @@ 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()) {
@@ -385,34 +391,6 @@ 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) {
@@ -430,17 +408,34 @@ const syncDMs = () => {
} }
} }
const unsubscribePubkey = merged([pubkey, shouldUnwrap]).subscribe(([$pubkey, $shouldUnwrap]) => { // When pubkey changes, re-sync
syncPubkey($pubkey, $shouldUnwrap) const unsubscribePubkey = derived([pubkey, shouldUnwrap], identity).subscribe(
}) ([$pubkey, $shouldUnwrap]) => {
if ($pubkey !== currentPubkey) {
unsubscribeAll()
}
// When user messaging relays change, update synchronization // If we have a pubkey, refresh our user's relay list then sync our subscriptions
const unsubscribeList = merged([userMessagingRelayList]).subscribe( if ($pubkey && $shouldUnwrap) {
([$userMessagingRelayList]) => { loadRelayList($pubkey)
syncList($userMessagingRelayList) .then(() => loadMessagingRelayList($pubkey))
.then($l => subscribeAll($pubkey, getRelayTagValues(getListTags($l))))
}
currentPubkey = $pubkey
}, },
) )
// When user messaging relays change, update synchronization
const unsubscribeList = userMessagingRelayList.subscribe($userMessagingRelayList => {
const $pubkey = pubkey.get()
const $shouldUnwrap = shouldUnwrap.get()
if ($pubkey && $shouldUnwrap) {
subscribeAll($pubkey, getRelayTagValues(getListTags($userMessagingRelayList)))
}
})
return () => { return () => {
unsubscribeAll() unsubscribeAll()
unsubscribePubkey() unsubscribePubkey()
+6 -11
View File
@@ -4,25 +4,20 @@
type Props = { type Props = {
editor: Promise<Editor> editor: Promise<Editor>
autofocus?: boolean
} }
const {editor, autofocus}: Props = $props() const {editor}: Props = $props()
let element: HTMLElement let element: HTMLElement
onMount(() => { onMount(() => {
editor.then(ed => { editor.then(({options}) => {
if (ed.options.element) { if (options.element) {
element?.append(ed.options.element) element?.append(options.element)
} }
if (autofocus) { if (options.autofocus) {
const hasContent = ed.getText().trim().length > 0 ;(element?.querySelector("[contenteditable]") as HTMLElement)?.focus()
requestAnimationFrame(() => {
ed.commands.focus(hasContent ? "end" : "start")
})
} }
}) })
}) })
-42
View File
@@ -1,42 +0,0 @@
import {mergeAttributes, Node} from "@tiptap/core"
import {RoomReferenceNodeView} from "@app/editor/RoomReferenceNodeView"
export const RoomReferenceExtension = Node.create({
name: "roomref",
atom: true,
inline: true,
group: "inline",
selectable: true,
priority: 1000,
addAttributes() {
return {
url: {default: undefined},
h: {default: undefined},
}
},
parseHTML() {
return [{tag: `span[data-type="${this.name}"]`}]
},
renderHTML({HTMLAttributes}) {
return ["span", mergeAttributes(HTMLAttributes, {"data-type": this.name}), "~"]
},
renderText({node}) {
const url = typeof node.attrs.url === "string" ? node.attrs.url : ""
const h = typeof node.attrs.h === "string" ? node.attrs.h : ""
return `${url}'${h}`
},
addNodeView() {
return RoomReferenceNodeView
},
})

Some files were not shown because too many files have changed in this diff Show More