forked from coracle/flotilla
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53954aae89 | |||
| 24aa62a503 | |||
| 2618bb9c63 | |||
| 32a31045ef | |||
| 56edad77a8 | |||
| fdb604e350 | |||
| 3c66dfd83c | |||
| 81633b0a1e | |||
| 4a967de184 | |||
| 59961cbdb5 | |||
| 95d9d8bf23 | |||
| 2fd9741a2b | |||
| fe9c325580 | |||
| 61e93d4071 | |||
| 1e4a4e43dc | |||
| e1a7b051bd | |||
| 7a7af58f5c | |||
| 016ae86d50 | |||
| 2bff060a5e | |||
| 68231504d0 | |||
| 0658a8ee44 | |||
| 43fb3d35e6 | |||
| 4cc1cc95ca | |||
| 964ef441ec | |||
| 796f37d320 | |||
| b46fd94578 | |||
| bdc8e75640 | |||
| ef08821796 | |||
| 9f386f6968 | |||
| ec0b6a99e2 | |||
| f6d9e52c6e | |||
| 90f86b833d | |||
| 29bb33c26c | |||
| c740bd21d4 | |||
| 1d92709c76 | |||
| a42e1df1a7 | |||
| e33beee17d | |||
| b10ea04cb3 | |||
| e8c94177ca | |||
| f1f2083c88 | |||
| f42889c3c2 | |||
| a75e1f96eb | |||
| 85c5293082 | |||
| 37efa6a62c | |||
| 1d5f91fb6c | |||
| ef18655776 | |||
| b786e858d9 | |||
| f4ebc4e99e | |||
| 65ca8a7fd8 | |||
| 7f1e98dcb2 | |||
| 4c19ee823b | |||
| 8e2dd8b278 | |||
| 8d35b3aad2 | |||
| 613cad31c0 | |||
| 3779a90f26 | |||
| 7470f28f31 | |||
| 17fb4e780b | |||
| 30c2a6ef79 | |||
| 0547e9513f | |||
| 70e5172f1b | |||
| 61c568a112 | |||
| ae2ba6f44d | |||
| f84006fbe4 | |||
| fed34a2747 | |||
| 80df16f97b | |||
| 18cb245599 | |||
| fd6cc84be6 | |||
| 9311cab3b2 | |||
| fceccf47be | |||
| fe20fbfd28 | |||
| 4f3a2a1660 | |||
| 1c8457a4bf | |||
| 8710043a02 | |||
| dc46b42cb6 | |||
| 2f1972e70a | |||
| c5fcf12165 | |||
| 61ed632579 | |||
| 86f4b75c52 | |||
| b26ab916d5 | |||
| c882198206 | |||
| 4aef27ffd5 |
+1
-1
@@ -9,4 +9,4 @@ build
|
|||||||
|
|
||||||
# Env files (keep .env for build; exclude local overrides)
|
# Env files (keep .env for build; exclude local overrides)
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|||||||
@@ -19,5 +19,6 @@ 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=https://vthumbs.coracle.social
|
||||||
VITE_GLITCHTIP_API_KEY=
|
VITE_GLITCHTIP_API_KEY=
|
||||||
GLITCHTIP_AUTH_TOKEN=
|
GLITCHTIP_AUTH_TOKEN=
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
src/assets
|
src/assets
|
||||||
|
.claude
|
||||||
target
|
target
|
||||||
build
|
build
|
||||||
.idea
|
.idea
|
||||||
@@ -13,4 +14,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
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
# Env
|
# Env
|
||||||
.env
|
|
||||||
.env.local
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
@@ -28,6 +28,7 @@ node_modules/
|
|||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
build/
|
build/
|
||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
|
.next/
|
||||||
|
|
||||||
# Rust/Tauri
|
# Rust/Tauri
|
||||||
*target/
|
*target/
|
||||||
@@ -69,7 +70,9 @@ 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
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
# 1.7.4
|
||||||
|
|
||||||
|
* Fix safe area inset for FAB
|
||||||
|
|
||||||
|
# 1.7.3
|
||||||
|
|
||||||
|
* Add native share support for space invites
|
||||||
|
* Stop sending duplicate requests per room
|
||||||
|
* Add more robust thumbnail url generation
|
||||||
|
* Make space reordering discoverable with smoother drag animation
|
||||||
|
* Improve relay member list
|
||||||
|
* Add room mentions and clickable room/relay refs
|
||||||
|
* Support native clipboard image paste on mobile
|
||||||
|
* publish kind 9 quote after room content creation for cross-client interoperability
|
||||||
|
* Improve feed pagination logic and performance
|
||||||
|
* Support Aegis URL scheme for NIP-46 login
|
||||||
|
* Various UI and bug fixes
|
||||||
|
* Raise message size limit in chat
|
||||||
|
* Fix realtime updates for room members and admins
|
||||||
|
* Add video to calls
|
||||||
|
* Remove follow graph building
|
||||||
|
* Add start chat FAB
|
||||||
|
* Add drafts
|
||||||
|
* Redesign toast notifications
|
||||||
|
* Remove room/space leave indications
|
||||||
|
* Hide report badge for non-admin users
|
||||||
|
* Add polls
|
||||||
|
* Add search to recent activity page
|
||||||
|
* Fix notification badge on mobile nav
|
||||||
|
* Change audio devices in call
|
||||||
|
|
||||||
# 1.7.2
|
# 1.7.2
|
||||||
|
|
||||||
* Fix race condition in nip 46
|
* Fix race condition in nip 46
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Flotilla is a Nostr "relays as groups" community chat client. It implements NIP-29 (relay-based groups) to create Discord-like spaces (servers) and rooms (channels).
|
||||||
|
|
||||||
|
Please visit our [issue tracker](https://gitea.coracle.social/coracle/flotilla/issues) to contribute. Any new issues should be opened without a milestone, label, or project and the project owners will triage them.
|
||||||
|
|
||||||
|
### Milestones
|
||||||
|
|
||||||
|
Milestones indicate how soon a given task should be tackled.
|
||||||
|
|
||||||
|
- [Current](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=1) issues are immediately actionable.
|
||||||
|
- [Next](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=2) means an issue is blocked.
|
||||||
|
- [Future](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=3) means we're deferring work until a later date.
|
||||||
|
|
||||||
|
### Labels
|
||||||
|
|
||||||
|
- [Design](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=15) issues need design work before being implemented. This might take the form of a high-quality mockup, wireframes, user flows, or just a couple notes about where things go, depending on the nature of the task.
|
||||||
|
- [Dev](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=16) issues are ready to be implemented. Most of the work will be related to architecting and writing code.
|
||||||
|
- [Easy](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=14) issues have no dependencies, and are scoped quite narrowly.
|
||||||
|
- [Priority](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=6) issues include bugs and urgent feature requests. These should get attention first if possible, although sometimes long-standing performance issues or subtle bugs might end up here for a while.
|
||||||
|
- [Ideas](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=13) are for things that aren't scoped out yet, or which need protocol work before getting designed or implemented.
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
|
||||||
|
Issues may or may not have a project. Projects are used to group issues thematically just for organization.
|
||||||
|
|
||||||
|
## Coding conventions
|
||||||
|
|
||||||
|
There are a few conventions that are helpful to know right out of the gate.
|
||||||
|
|
||||||
|
- Most nostr protocol functionality is implemented via the [welshman library](https://welshman.coracle.social/)
|
||||||
|
- Use Svelte 4 **stores** rather than runes for all state outside UI components
|
||||||
|
- Most global state flows through Welshman's `repository` (unidirectional)
|
||||||
|
- Query state using `deriveEventsMapped` or `deriveProfile` etc
|
||||||
|
- Events are published via `publishThunk`, which allows for optimistic UI updates during signing/pow generation.
|
||||||
|
- Components should have minimal props - e.g. instead of passing a whole `relay` through, pass its `url`.
|
||||||
|
- Use `AbortController` when possible instead of request ids
|
||||||
|
- Use `undefined` or optional properties instead of `null`
|
||||||
|
- Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect.
|
||||||
|
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
|
||||||
|
- When dynamically building classes, use `cx` from `classnames`.
|
||||||
|
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates.
|
||||||
|
|
||||||
|
## Contributing Workflow
|
||||||
|
|
||||||
|
To contribute, do the following:
|
||||||
|
|
||||||
|
- Find or create an issue and assign yourself (comment instead if you're not able to self-assign)
|
||||||
|
- If the issue is a design task, attach or link out to any mockups/wireframes/flowcharts
|
||||||
|
- Once a design task is completed, a maintainer will remove the `design` label and add the `dev` label
|
||||||
|
- If the issue is a development task, fork the repository and create a branch prefixed by the issue number, e.g. `105-deep-links`
|
||||||
|
- Before requesting a review, be sure to review any agent-generated code, run the pre-commit hooks, and test the changes.
|
||||||
|
- Open a PR and request a review. A maintainer will get back to you with requested changes, or will merge the PR.
|
||||||
|
- Keep your PR up-to-date by rebasing frequently on `dev`. Avoid force-pushing to `dev`.
|
||||||
|
- PRs are rebased, squashed, and merged to keep commit history simple.
|
||||||
|
- An issue may have multiple PRs. Once complete, it can be closed.
|
||||||
@@ -16,11 +16,13 @@ You can also optionally create an `.env.local` file and populate it with the fol
|
|||||||
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
||||||
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
|
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
|
||||||
|
|
||||||
|
These values **won't** be used for a built version. Instead, env variables should be provided to `build.sh` directly or to the built container.
|
||||||
|
|
||||||
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
|
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
See [CONTRIBUTING.md](AGENTS.md).
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ android {
|
|||||||
applicationId "social.flotilla"
|
applicationId "social.flotilla"
|
||||||
minSdk rootProject.ext.minSdkVersion
|
minSdk rootProject.ext.minSdkVersion
|
||||||
targetSdk rootProject.ext.targetSdkVersion
|
targetSdk rootProject.ext.targetSdkVersion
|
||||||
versionCode 44
|
versionCode 46
|
||||||
versionName "1.7.2"
|
versionName "1.7.4"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ dependencies {
|
|||||||
implementation project(':aparajita-capacitor-secure-storage')
|
implementation project(':aparajita-capacitor-secure-storage')
|
||||||
implementation project(':capacitor-community-safe-area')
|
implementation project(':capacitor-community-safe-area')
|
||||||
implementation project(':capacitor-app')
|
implementation project(':capacitor-app')
|
||||||
|
implementation project(':capacitor-clipboard')
|
||||||
implementation project(':capacitor-filesystem')
|
implementation project(':capacitor-filesystem')
|
||||||
implementation project(':capacitor-keyboard')
|
implementation project(':capacitor-keyboard')
|
||||||
implementation project(':capacitor-preferences')
|
implementation project(':capacitor-preferences')
|
||||||
implementation project(':capacitor-push-notifications')
|
implementation project(':capacitor-push-notifications')
|
||||||
|
implementation project(':capacitor-share')
|
||||||
implementation project(':capawesome-capacitor-android-dark-mode-support')
|
implementation project(':capawesome-capacitor-android-dark-mode-support')
|
||||||
implementation project(':capawesome-capacitor-badge')
|
implementation project(':capawesome-capacitor-badge')
|
||||||
implementation project(':nostr-signer-capacitor-plugin')
|
implementation project(':nostr-signer-capacitor-plugin')
|
||||||
|
|||||||
@@ -44,4 +44,7 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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
|
||||||
@@ -76,6 +77,7 @@ 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(
|
||||||
|
|||||||
+9
-10
@@ -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 = 20L
|
private const val SOCKET_TIMEOUT_SECONDS = 30L
|
||||||
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,6 +72,8 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -88,7 +90,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>()
|
||||||
var latestPair: Pair<String, JSONObject>? = null
|
val newEvents = mutableListOf<Pair<String, JSONObject>>()
|
||||||
|
|
||||||
for (sub in subscriptions) {
|
for (sub in subscriptions) {
|
||||||
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
|
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
|
||||||
@@ -102,23 +104,19 @@ 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)) {
|
||||||
val createdAt = event.optLong("created_at", 0L)
|
newEvents.add(Pair(sub.relay, event))
|
||||||
if (latestPair == null || createdAt > latestPair!!.second.optLong("created_at", 0L)) {
|
|
||||||
latestPair = Pair(sub.relay, event)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (latestPair != null) {
|
for ((relay, event) in newEvents) {
|
||||||
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.success()
|
return Result.retry()
|
||||||
} finally {
|
} finally {
|
||||||
pool.closeAll()
|
pool.closeAll()
|
||||||
client.dispatcher.executorService.shutdown()
|
client.dispatcher.executorService.shutdown()
|
||||||
@@ -214,7 +212,8 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
|||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
NotificationManagerCompat.from(context).notify(1, notification)
|
val notificationId = id.hashCode().let { if (it == 0) 1 else it }
|
||||||
|
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
|
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ project(':capacitor-community-safe-area').projectDir = new File('../node_modules
|
|||||||
include ':capacitor-app'
|
include ':capacitor-app'
|
||||||
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app/android')
|
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app/android')
|
||||||
|
|
||||||
|
include ':capacitor-clipboard'
|
||||||
|
project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/clipboard/android')
|
||||||
|
|
||||||
include ':capacitor-filesystem'
|
include ':capacitor-filesystem'
|
||||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem/android')
|
project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem/android')
|
||||||
|
|
||||||
@@ -23,6 +26,9 @@ project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@
|
|||||||
include ':capacitor-push-notifications'
|
include ':capacitor-push-notifications'
|
||||||
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications/android')
|
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications/android')
|
||||||
|
|
||||||
|
include ':capacitor-share'
|
||||||
|
project(':capacitor-share').projectDir = new File('../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share/android')
|
||||||
|
|
||||||
include ':capawesome-capacitor-android-dark-mode-support'
|
include ':capawesome-capacitor-android-dark-mode-support'
|
||||||
project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-android-dark-mode-support/android')
|
project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-android-dark-mode-support/android')
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
temp_env=$(declare -p -x)
|
temp_env=$(declare -p -x)
|
||||||
|
|
||||||
if [ -f .env.template ]; then
|
if [ -f .env ]; then
|
||||||
source .env.template
|
source .env
|
||||||
fi
|
|
||||||
if [ -f .env.local ]; then
|
|
||||||
source .env.local
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Avoid overwriting env vars provided directly
|
# Avoid overwriting env vars provided directly
|
||||||
|
|||||||
@@ -358,14 +358,14 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 35;
|
CURRENT_PROJECT_VERSION = 37;
|
||||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.7.2;
|
MARKETING_VERSION = 1.7.4;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -385,14 +385,14 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 35;
|
CURRENT_PROJECT_VERSION = 37;
|
||||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.7.5;
|
MARKETING_VERSION = 1.7.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|||||||
@@ -24,8 +24,10 @@
|
|||||||
<false/>
|
<false/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Flotilla uses the camera when you enable it in a voice room.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Flotilla uses the microphone for voice chat in rooms.</string>
|
<string>Flotilla uses the microphone when you enable it in a voice room.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ def capacitor_pods
|
|||||||
pod 'AparajitaCapacitorSecureStorage', :path => '../../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage'
|
pod 'AparajitaCapacitorSecureStorage', :path => '../../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage'
|
||||||
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area'
|
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area'
|
||||||
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app'
|
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app'
|
||||||
|
pod 'CapacitorClipboard', :path => '../../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/clipboard'
|
||||||
pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem'
|
pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem'
|
||||||
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/keyboard'
|
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/keyboard'
|
||||||
pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/preferences'
|
pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/preferences'
|
||||||
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications'
|
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications'
|
||||||
|
pod 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share'
|
||||||
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge'
|
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge'
|
||||||
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin'
|
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin'
|
||||||
end
|
end
|
||||||
|
|||||||
+21
-16
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flotilla",
|
"name": "flotilla",
|
||||||
"version": "1.7.2",
|
"version": "1.7.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@sveltejs/kit": "^2.50.1",
|
"@sveltejs/kit": "^2.50.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||||
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
"@tauri-apps/cli": "^2.9.6",
|
"@tauri-apps/cli": "^2.9.6",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
"prettier-plugin-svelte": "^3.4.1",
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"svelte": "^5.48.0",
|
"svelte": "^5.48.0",
|
||||||
"svelte-check": "^4.3.5",
|
"svelte-check": "^4.3.5",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.53.1",
|
"typescript-eslint": "^8.53.1",
|
||||||
"vite": "^5.4.21"
|
"vite": "^5.4.21"
|
||||||
@@ -47,37 +48,40 @@
|
|||||||
"@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",
|
||||||
"@capacitor/keyboard": "^8.0.0",
|
"@capacitor/keyboard": "^8.0.0",
|
||||||
"@capacitor/preferences": "^8.0.0",
|
"@capacitor/preferences": "^8.0.0",
|
||||||
"@capacitor/push-notifications": "^8.0.0",
|
"@capacitor/push-notifications": "^8.0.0",
|
||||||
|
"@capacitor/share": "^8.0.1",
|
||||||
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
|
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
|
||||||
"@capawesome/capacitor-badge": "^8.0.0",
|
"@capawesome/capacitor-badge": "^8.0.0",
|
||||||
"@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.2",
|
"@pomade/core": "^0.2.3",
|
||||||
"@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",
|
||||||
"@vite-pwa/sveltekit": "^0.6.8",
|
"@vite-pwa/sveltekit": "^0.6.8",
|
||||||
"@welshman/app": "^0.8.12",
|
"@welshman/app": "^0.8.13",
|
||||||
"@welshman/content": "^0.8.12",
|
"@welshman/content": "^0.8.13",
|
||||||
"@welshman/editor": "^0.8.12",
|
"@welshman/editor": "^0.8.13",
|
||||||
"@welshman/feeds": "^0.8.12",
|
"@welshman/feeds": "^0.8.13",
|
||||||
"@welshman/lib": "^0.8.12",
|
"@welshman/lib": "^0.8.13",
|
||||||
"@welshman/net": "^0.8.12",
|
"@welshman/net": "^0.8.13",
|
||||||
"@welshman/router": "^0.8.12",
|
"@welshman/router": "^0.8.13",
|
||||||
"@welshman/signer": "^0.8.12",
|
"@welshman/signer": "^0.8.13",
|
||||||
"@welshman/store": "^0.8.12",
|
"@welshman/store": "^0.8.13",
|
||||||
"@welshman/util": "^0.8.12",
|
"@welshman/util": "^0.8.13",
|
||||||
"compressorjs-next": "^1.1.2",
|
"compressorjs-next": "^1.1.2",
|
||||||
"daisyui": "^4.12.24",
|
"daisyui": "^5.5.19",
|
||||||
"date-picker-svelte": "^2.17.0",
|
"date-picker-svelte": "^2.17.0",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"emoji-picker-element": "^1.28.1",
|
"emoji-picker-element": "^1.28.1",
|
||||||
@@ -87,7 +91,7 @@
|
|||||||
"livekit-client": "^2.17.2",
|
"livekit-client": "^2.17.2",
|
||||||
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
|
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
|
||||||
"nostr-tools": "^2.19.4",
|
"nostr-tools": "^2.19.4",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"throttle-debounce": "^5.0.2",
|
"throttle-debounce": "^5.0.2",
|
||||||
@@ -104,5 +108,6 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"sharp": "0.35.0-rc.0"
|
"sharp": "0.35.0-rc.0"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+529
-483
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -1,6 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
"@tailwindcss/postcss": {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import dotenv from "dotenv"
|
|||||||
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
|
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
|
||||||
|
|
||||||
dotenv.config({path: ".env.local"})
|
dotenv.config({path: ".env.local"})
|
||||||
dotenv.config({path: ".env.template"})
|
dotenv.config({path: ".env"})
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
preset,
|
preset,
|
||||||
|
|||||||
+266
-270
@@ -1,46 +1,6 @@
|
|||||||
@import "@welshman/editor/index.css";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@tailwind base;
|
@config "../tailwind.config.js";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
/* Fonts */
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Satoshis";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src:
|
|
||||||
local(""),
|
|
||||||
url("/fonts/Satoshi Symbol.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Lato";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src:
|
|
||||||
local(""),
|
|
||||||
url("/fonts/Lato-Regular.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Lato";
|
|
||||||
font-style: bold;
|
|
||||||
font-weight: 600;
|
|
||||||
src:
|
|
||||||
local(""),
|
|
||||||
url("/fonts/Lato-Bold.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Lato";
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 400;
|
|
||||||
src:
|
|
||||||
local(""),
|
|
||||||
url("/fonts/Italic.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* root */
|
/* root */
|
||||||
|
|
||||||
@@ -52,98 +12,244 @@
|
|||||||
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme] {
|
@utility pt-sai {
|
||||||
@apply bg-base-300;
|
padding-top: var(--sait);
|
||||||
--base-100: oklch(var(--b1));
|
|
||||||
--base-200: oklch(var(--b2));
|
|
||||||
--base-300: oklch(var(--b3));
|
|
||||||
--base-content: oklch(var(--bc));
|
|
||||||
--primary: oklch(var(--p));
|
|
||||||
--primary-content: oklch(var(--pc));
|
|
||||||
--secondary: oklch(var(--s));
|
|
||||||
--secondary-content: oklch(var(--sc));
|
|
||||||
--neutral: oklch(var(--n));
|
|
||||||
--neutral-content: oklch(var(--nc));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile [data-tip]::before {
|
@utility pr-sai {
|
||||||
display: none !important;
|
padding-right: var(--sair);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* safe area insets */
|
@utility pb-sai {
|
||||||
|
padding-bottom: var(--saib);
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@utility pl-sai {
|
||||||
.pt-sai {
|
padding-left: var(--sail);
|
||||||
padding-top: var(--sait);
|
}
|
||||||
|
|
||||||
|
@utility px-sai {
|
||||||
|
@apply pl-sai pr-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility py-sai {
|
||||||
|
@apply pt-sai pb-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility p-sai {
|
||||||
|
@apply py-sai px-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility mt-sai {
|
||||||
|
margin-top: var(--sait);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility mr-sai {
|
||||||
|
margin-right: var(--sair);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility mb-sai {
|
||||||
|
margin-bottom: var(--saib);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility ml-sai {
|
||||||
|
margin-left: var(--sail);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility mx-sai {
|
||||||
|
@apply ml-sai mr-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility my-sai {
|
||||||
|
@apply mt-sai mb-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility m-sai {
|
||||||
|
@apply my-sai mx-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility top-sai {
|
||||||
|
top: var(--sait);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility right-sai {
|
||||||
|
right: var(--sair);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bottom-sai {
|
||||||
|
bottom: var(--saib);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility left-sai {
|
||||||
|
left: var(--sail);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility card2 {
|
||||||
|
@apply rounded-box text-base-content p-4 sm:p-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility column {
|
||||||
|
@apply flex flex-col;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility center {
|
||||||
|
@apply flex items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility row-2 {
|
||||||
|
@apply flex items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility row-3 {
|
||||||
|
@apply flex items-center gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility row-4 {
|
||||||
|
@apply flex items-center gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility col-2 {
|
||||||
|
@apply flex flex-col gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility col-3 {
|
||||||
|
@apply flex flex-col gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility col-4 {
|
||||||
|
@apply flex flex-col gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility col-8 {
|
||||||
|
@apply flex flex-col gap-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility ellipsize {
|
||||||
|
@apply overflow-hidden text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-padding-x {
|
||||||
|
@apply px-4 sm:px-8 md:px-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-padding-t {
|
||||||
|
@apply pt-4 sm:pt-8 md:pt-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-padding-b {
|
||||||
|
@apply pb-4 sm:pb-8 md:pb-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-padding-y {
|
||||||
|
@apply pt-4 pb-4 sm:pt-8 sm:pb-8 md:pt-12 md:pb-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-sizing {
|
||||||
|
@apply m-auto w-full max-w-3xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content {
|
||||||
|
@apply m-auto w-full max-w-3xl px-4 pt-4 pb-4 sm:px-8 sm:pt-8 sm:pb-8 md:px-12 md:pt-12 md:pb-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility heading {
|
||||||
|
@apply text-center text-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility subheading {
|
||||||
|
@apply text-center text-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility superheading {
|
||||||
|
@apply text-center text-4xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility link {
|
||||||
|
@apply text-primary cursor-pointer underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* content visibility */
|
||||||
|
|
||||||
|
@utility cv {
|
||||||
|
content-visibility: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
|
so we've added these compatibility styles to make sure everything still
|
||||||
|
looks the same as it did with Tailwind CSS v3.
|
||||||
|
|
||||||
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
|
color utility to any element that depends on these defaults.
|
||||||
|
*/
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Fonts */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Satoshis";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src:
|
||||||
|
local(""),
|
||||||
|
url("/fonts/Satoshi Symbol.ttf") format("truetype");
|
||||||
}
|
}
|
||||||
|
|
||||||
.pr-sai {
|
@font-face {
|
||||||
padding-right: var(--sair);
|
font-family: "Lato";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src:
|
||||||
|
local(""),
|
||||||
|
url("/fonts/Lato-Regular.ttf") format("truetype");
|
||||||
}
|
}
|
||||||
|
|
||||||
.pb-sai {
|
@font-face {
|
||||||
padding-bottom: var(--saib);
|
font-family: "Lato";
|
||||||
|
font-style: bold;
|
||||||
|
font-weight: 600;
|
||||||
|
src:
|
||||||
|
local(""),
|
||||||
|
url("/fonts/Lato-Bold.ttf") format("truetype");
|
||||||
}
|
}
|
||||||
|
|
||||||
.pl-sai {
|
@font-face {
|
||||||
padding-left: var(--sail);
|
font-family: "Lato";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
src:
|
||||||
|
local(""),
|
||||||
|
url("/fonts/Italic.ttf") format("truetype");
|
||||||
}
|
}
|
||||||
|
|
||||||
.px-sai {
|
/* root */
|
||||||
@apply pl-sai pr-sai;
|
|
||||||
|
:root {
|
||||||
|
font-family: Lato;
|
||||||
|
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
|
||||||
|
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
|
||||||
|
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
|
||||||
|
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
||||||
}
|
}
|
||||||
|
|
||||||
.py-sai {
|
[data-theme] {
|
||||||
@apply pt-sai pb-sai;
|
@apply bg-base-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.p-sai {
|
.mobile [data-tip]::before {
|
||||||
@apply py-sai px-sai;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mt-sai {
|
/* safe area insets */
|
||||||
margin-top: var(--sait);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mr-sai {
|
|
||||||
margin-right: var(--sair);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-sai {
|
|
||||||
margin-bottom: var(--saib);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ml-sai {
|
|
||||||
margin-left: var(--sail);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx-sai {
|
|
||||||
@apply ml-sai mr-sai;
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-sai {
|
|
||||||
@apply mt-sai mb-sai;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-sai {
|
|
||||||
@apply my-sai mx-sai;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-sai {
|
|
||||||
top: var(--sait);
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-sai {
|
|
||||||
right: var(--sair);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-sai {
|
|
||||||
bottom: var(--saib);
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-sai {
|
|
||||||
left: var(--sail);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* utilities */
|
/* utilities */
|
||||||
@@ -165,110 +271,18 @@
|
|||||||
@apply bg-base-300 text-base-content transition-colors;
|
@apply bg-base-300 text-base-content transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card2 {
|
|
||||||
@apply rounded-box p-4 text-base-content sm:p-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card2.card2-sm {
|
.card2.card2-sm {
|
||||||
@apply p-2 text-base-content sm:p-4;
|
@apply text-base-content p-2 sm:p-4;
|
||||||
}
|
|
||||||
|
|
||||||
.column {
|
|
||||||
@apply flex flex-col;
|
|
||||||
}
|
|
||||||
|
|
||||||
.center {
|
|
||||||
@apply flex items-center justify-center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-2 {
|
|
||||||
@apply flex items-center gap-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-3 {
|
|
||||||
@apply flex items-center gap-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-4 {
|
|
||||||
@apply flex items-center gap-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-2 {
|
|
||||||
@apply flex flex-col gap-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-3 {
|
|
||||||
@apply flex flex-col gap-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-4 {
|
|
||||||
@apply flex flex-col gap-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-8 {
|
|
||||||
@apply flex flex-col gap-8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
@apply justify-start overflow-hidden text-ellipsis whitespace-nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ellipsize {
|
|
||||||
@apply overflow-hidden text-ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-tip]::before {
|
[data-tip]::before {
|
||||||
@apply ellipsize;
|
@apply overflow-hidden text-ellipsis;
|
||||||
}
|
|
||||||
|
|
||||||
.content-padding-x {
|
|
||||||
@apply px-4 sm:px-8 md:px-12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-padding-t {
|
|
||||||
@apply pt-4 sm:pt-8 md:pt-12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-padding-b {
|
|
||||||
@apply pb-4 sm:pb-8 md:pb-12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-padding-y {
|
|
||||||
@apply content-padding-t content-padding-b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-sizing {
|
|
||||||
@apply m-auto w-full max-w-3xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
@apply content-sizing content-padding-x content-padding-y;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
@apply text-center text-2xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subheading {
|
|
||||||
@apply text-center text-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.superheading {
|
|
||||||
@apply text-center text-4xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link {
|
|
||||||
@apply cursor-pointer text-primary underline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input input::placeholder {
|
.input input::placeholder {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shadow-top-xl {
|
|
||||||
@apply shadow-[0_20px_25px_-5px_rgb(0,0,0,0.1)_0_8px_10px_-6px_rgb(0,0,0,0.1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
/* tiptap */
|
/* tiptap */
|
||||||
|
|
||||||
.input-editor,
|
.input-editor,
|
||||||
@@ -278,21 +292,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tiptap {
|
.tiptap {
|
||||||
--tiptap-object-bg: var(--neutral);
|
--tiptap-object-bg: var(--color-neutral);
|
||||||
--tiptap-object-fg: var(--neutral-content);
|
--tiptap-object-fg: var(--color-neutral-content);
|
||||||
--tiptap-active-bg: var(--primary);
|
--tiptap-active-bg: var(--color-primary);
|
||||||
--tiptap-active-fg: var(--primary-content);
|
--tiptap-active-fg: var(--color-primary-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-suggestions {
|
.tiptap-suggestions {
|
||||||
--tiptap-object-bg: var(--base-100);
|
--tiptap-object-bg: var(--color-base-100);
|
||||||
--tiptap-object-fg: var(--base-content);
|
--tiptap-object-fg: var(--color-base-content);
|
||||||
--tiptap-active-bg: var(--base-300);
|
--tiptap-active-bg: var(--color-base-300);
|
||||||
--tiptap-active-fg: var(--base-content);
|
--tiptap-active-fg: var(--color-base-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-suggestions__item {
|
.tiptap-suggestions__item {
|
||||||
@apply border-l-2 border-solid border-base-100;
|
@apply border-base-100 border-l-2 border-solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-suggestions__selected {
|
.tiptap-suggestions__selected {
|
||||||
@@ -312,13 +326,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.note-editor .tiptap {
|
.note-editor .tiptap {
|
||||||
--tiptap-object-bg: var(--base-200);
|
--tiptap-object-bg: var(--color-base-200);
|
||||||
@apply input input-bordered h-auto min-h-32 rounded-box p-[.65rem] pb-6;
|
@apply input rounded-box block h-auto min-h-32 w-full p-[.65rem] pb-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-editor .tiptap {
|
.input-editor .tiptap {
|
||||||
--tiptap-object-bg: var(--base-200);
|
--tiptap-object-bg: var(--color-base-200);
|
||||||
@apply input input-bordered h-auto p-[.65rem];
|
@apply input h-auto p-[.65rem];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* link-content, based on tiptap */
|
/* link-content, based on tiptap */
|
||||||
@@ -330,8 +344,8 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
background-color: var(--base-100);
|
background-color: var(--color-base-100);
|
||||||
color: var(--base-content);
|
color: var(--color-base-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* content rendered by welshman/content */
|
/* content rendered by welshman/content */
|
||||||
@@ -347,23 +361,31 @@
|
|||||||
/* date input */
|
/* date input */
|
||||||
|
|
||||||
.picker {
|
.picker {
|
||||||
--date-picker-foreground: var(--base-content);
|
--date-picker-foreground: var(--color-base-content);
|
||||||
--date-picker-background: var(--base-300);
|
--date-picker-background: var(--color-base-300);
|
||||||
--date-picker-highlight-border: var(--primary);
|
--date-picker-highlight-border: var(--color-primary);
|
||||||
--date-picker-selected-color: var(--primary-content);
|
--date-picker-selected-color: var(--color-primary-content);
|
||||||
--date-picker-selected-background: var(--primary);
|
--date-picker-selected-background: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-time-field {
|
.date-time-field {
|
||||||
@apply input input-bordered rounded-lg px-0;
|
@apply input rounded-lg px-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-time-field input {
|
.date-time-field input {
|
||||||
@apply !h-full !w-full !rounded-lg !border-none !bg-inherit !px-4 !text-inherit;
|
@apply h-full! w-full! rounded-lg! border-none! bg-inherit! px-4! text-inherit!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* tippy popover */
|
/* tippy popover */
|
||||||
|
|
||||||
|
.tippy-target {
|
||||||
|
@apply z-tooltip pointer-events-none fixed inset-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippy-target > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.tippy-box {
|
.tippy-box {
|
||||||
@apply rounded-box shadow-xl;
|
@apply rounded-box shadow-xl;
|
||||||
}
|
}
|
||||||
@@ -371,15 +393,15 @@
|
|||||||
/* emoji picker */
|
/* emoji picker */
|
||||||
|
|
||||||
emoji-picker {
|
emoji-picker {
|
||||||
--background: var(--base-100);
|
--background: var(--color-base-100);
|
||||||
--border-color: var(--base-100);
|
--border-color: var(--color-base-100);
|
||||||
--border-radius: var(--rounded-box);
|
--border-radius: var(--rounded-box);
|
||||||
--button-active-background: var(--base-content);
|
--button-active-background: var(--color-base-content);
|
||||||
--button-hover-background: var(--base-content);
|
--button-hover-background: var(--color-base-content);
|
||||||
--indicator-color: var(--base-content);
|
--indicator-color: var(--color-base-content);
|
||||||
--input-border-color: var(--base-100);
|
--input-border-color: var(--color-base-100);
|
||||||
--input-font-color: var(--base-content);
|
--input-font-color: var(--color-base-content);
|
||||||
--outline-color: var(--base-100);
|
--outline-color: var(--color-base-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* progress */
|
/* progress */
|
||||||
@@ -390,28 +412,12 @@ progress[value]::-webkit-progress-value {
|
|||||||
|
|
||||||
/* content width for fixed elements */
|
/* content width for fixed elements */
|
||||||
|
|
||||||
.cw {
|
.left-content {
|
||||||
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
|
@apply md:left-[calc(18.5rem+var(--sail))];
|
||||||
}
|
|
||||||
|
|
||||||
.cw-full {
|
|
||||||
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
|
|
||||||
}
|
|
||||||
|
|
||||||
.cb {
|
|
||||||
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
|
|
||||||
}
|
|
||||||
|
|
||||||
.ct {
|
|
||||||
@apply top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keyboard open state adjustments */
|
/* Keyboard open state adjustments */
|
||||||
|
|
||||||
body.keyboard-open .cb {
|
|
||||||
@apply bottom-sai;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.keyboard-open .hide-on-keyboard {
|
body.keyboard-open .hide-on-keyboard {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -419,23 +425,13 @@ body.keyboard-open .hide-on-keyboard {
|
|||||||
/* chat view */
|
/* chat view */
|
||||||
|
|
||||||
.chat__compose {
|
.chat__compose {
|
||||||
@apply cb cw fixed z-compose;
|
@apply z-compose relative mb-14 shrink-0 md:mb-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat__compose-zone {
|
.chat__compose .chat__compose-inner {
|
||||||
@apply cb cw fixed z-compose;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat__compose-zone .chat__compose-inner {
|
|
||||||
@apply min-w-0;
|
@apply min-w-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat__scroll-down {
|
.chat__scroll-down {
|
||||||
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
|
@apply pb-sai z-feature fixed right-4 bottom-28 md:bottom-16;
|
||||||
}
|
|
||||||
|
|
||||||
/* content visibility */
|
|
||||||
|
|
||||||
.cv {
|
|
||||||
content-visibility: auto;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {Room as LiveKitRoom} from "livekit-client"
|
||||||
|
import {derived, writable} from "svelte/store"
|
||||||
|
import {type Room} from "@app/core/state"
|
||||||
|
|
||||||
|
export type VoiceSession = {
|
||||||
|
url: string
|
||||||
|
h: string
|
||||||
|
room: LiveKitRoom
|
||||||
|
muted: boolean
|
||||||
|
cameraOn: boolean
|
||||||
|
screenShareOn: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Pubkey = string
|
||||||
|
|
||||||
|
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
||||||
|
|
||||||
|
export enum VoiceState {
|
||||||
|
Joining = "joining",
|
||||||
|
Connected = "connected",
|
||||||
|
Disconnected = "disconnected",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
||||||
|
|
||||||
|
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
||||||
|
|
||||||
|
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
||||||
|
|
||||||
|
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
||||||
|
|
||||||
|
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
||||||
|
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
|
||||||
|
|
||||||
|
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
|
||||||
|
const pk = pubkeyFromLiveKitIdentity(identity)
|
||||||
|
return pk ? {pubkey: pk, identity} : {identity}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
||||||
|
|
||||||
|
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
||||||
|
|
||||||
|
export const isParticipantSpeaking = derived(
|
||||||
|
speakingParticipants,
|
||||||
|
$participants => (p: VoiceParticipant) =>
|
||||||
|
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const isLocalSpeaking = derived(
|
||||||
|
[currentVoiceSession, speakingParticipants],
|
||||||
|
([$session, $speaking]) => {
|
||||||
|
if (!$session?.room) return false
|
||||||
|
const local = participantFromLiveKitIdentity($session.room.localParticipant.identity)
|
||||||
|
return $speaking.some(sp => participantKey(sp) === participantKey(local))
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {Track} from "livekit-client"
|
||||||
|
import {MediaQuery} from "svelte/reactivity"
|
||||||
|
import {derived, get, writable} from "svelte/store"
|
||||||
|
import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores"
|
||||||
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
|
export enum VideoCallLayout {
|
||||||
|
Chat = "chat",
|
||||||
|
Video = "video",
|
||||||
|
Split = "split",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
|
||||||
|
|
||||||
|
export enum ViewportSize {
|
||||||
|
Desktop = "desktop",
|
||||||
|
Mobile = "mobile",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoCallViewportSync = {
|
||||||
|
previousLayout: undefined as ViewportSize | undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
|
||||||
|
|
||||||
|
export const resetVideoCallLayout = () => {
|
||||||
|
videoCallViewportSync.previousLayout = undefined
|
||||||
|
videoCallLayout.set(VideoCallLayout.Chat)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
|
||||||
|
|
||||||
|
export const toggleVideoPrimaryTile = (key: string) => {
|
||||||
|
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
|
||||||
|
}
|
||||||
|
|
||||||
|
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
|
||||||
|
|
||||||
|
const countLiveVisualFeeds = (session: VoiceSession): number => {
|
||||||
|
const room = session.room
|
||||||
|
let n = 0
|
||||||
|
const lp = room.localParticipant
|
||||||
|
if (session.cameraOn) {
|
||||||
|
const pub = lp.getTrackPublication(Track.Source.Camera)
|
||||||
|
if (pub?.track) n += 1
|
||||||
|
}
|
||||||
|
if (session.screenShareOn) {
|
||||||
|
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
|
||||||
|
if (pub?.track) n += 1
|
||||||
|
}
|
||||||
|
for (const rp of room.remoteParticipants.values()) {
|
||||||
|
for (const source of VISUAL_SOURCES) {
|
||||||
|
const pub = rp.getTrackPublication(source)
|
||||||
|
if (pub?.isSubscribed && pub.track) n += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
export const triggerVideoFeedCount = () => {
|
||||||
|
currentVoiceSession.update(s => (s ? {...s} : s))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoTileCount = derived([currentVoiceSession, voiceState], ([$session, $state]) => {
|
||||||
|
if ($state !== VoiceState.Connected || !$session) return 0
|
||||||
|
return countLiveVisualFeeds($session)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const toggleCamera = async () => {
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const cameraOn = !session.cameraOn
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setCameraEnabled(cameraOn)
|
||||||
|
currentVoiceSession.set({...session, cameraOn})
|
||||||
|
} catch {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: cameraOn ? "Could not access camera" : "Could not turn off camera",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toggleScreenShare = async () => {
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const screenShareOn = !session.screenShareOn
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setScreenShareEnabled(screenShareOn)
|
||||||
|
currentVoiceSession.set({...session, screenShareOn})
|
||||||
|
} catch {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,51 +4,77 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
|
LocalParticipant,
|
||||||
|
LocalTrackPublication,
|
||||||
Room as LiveKitRoom,
|
Room as LiveKitRoom,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
Track,
|
Track,
|
||||||
|
supportsAudioOutputSelection,
|
||||||
type AudioCaptureOptions,
|
type AudioCaptureOptions,
|
||||||
type LocalParticipant,
|
|
||||||
} from "livekit-client"
|
} from "livekit-client"
|
||||||
import {derived, get, writable} from "svelte/store"
|
import {derived, get} from "svelte/store"
|
||||||
import {map, removeUndefined, uniqBy} from "@welshman/lib"
|
import {map, removeUndefined, uniqBy} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
|
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
|
||||||
import {signer} from "@welshman/app"
|
import {signer} from "@welshman/app"
|
||||||
import {getLivekitEndpoint} from "$lib/livekit"
|
import {getLivekitEndpoint} from "$lib/livekit"
|
||||||
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
|
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
|
||||||
import {deriveLatestEventForUrl, deriveRoom, makeRoomId, type Room} from "@app/core/state"
|
import {
|
||||||
|
currentVoiceRoom,
|
||||||
|
currentVoiceSession,
|
||||||
|
participantFromLiveKitIdentity,
|
||||||
|
participantKey,
|
||||||
|
participantPubkeyMap,
|
||||||
|
pubkeyFromLiveKitIdentity,
|
||||||
|
speakingParticipants,
|
||||||
|
VoiceState,
|
||||||
|
type VoiceParticipant,
|
||||||
|
voiceState,
|
||||||
|
} from "@app/call/stores"
|
||||||
|
import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video"
|
||||||
|
import {deriveLatestEventForUrl, deriveRoom, makeRoomId} from "@app/core/state"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
export const LIVEKIT_PARTICIPANTS = 39004
|
export const LIVEKIT_PARTICIPANTS = 39004
|
||||||
|
|
||||||
export {checkRelayHasLivekit} from "$lib/livekit"
|
export {checkRelayHasLivekit} from "$lib/livekit"
|
||||||
|
|
||||||
export type VoiceSession = {
|
export {supportsAudioOutputSelection}
|
||||||
url: string
|
|
||||||
h: string
|
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
|
||||||
room: LiveKitRoom
|
|
||||||
muted: boolean
|
export enum DeviceKind {
|
||||||
|
AudioInput = "audioinput",
|
||||||
|
AudioOutput = "audiooutput",
|
||||||
|
VideoInput = "videoinput",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Pubkey = string
|
export const switchVoiceActiveDevice = async (
|
||||||
|
kind: DeviceKind,
|
||||||
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
targetDeviceId: string,
|
||||||
|
): Promise<void> => {
|
||||||
export enum VoiceState {
|
const session = get(currentVoiceSession)
|
||||||
Joining = "joining",
|
if (!session) return
|
||||||
Connected = "connected",
|
const id = targetDeviceId === "" ? LIVEKIT_DEFAULT_DEVICE_ID : targetDeviceId
|
||||||
Disconnected = "disconnected",
|
try {
|
||||||
|
await session.room.switchActiveDevice(kind, id)
|
||||||
|
} catch {
|
||||||
|
let label: string
|
||||||
|
switch (kind) {
|
||||||
|
case DeviceKind.AudioInput:
|
||||||
|
label = "microphone"
|
||||||
|
break
|
||||||
|
case DeviceKind.AudioOutput:
|
||||||
|
label = "speaker"
|
||||||
|
break
|
||||||
|
case DeviceKind.VideoInput:
|
||||||
|
label = "camera"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pushToast({theme: "error", message: `Error changing ${label}`})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
|
||||||
|
|
||||||
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
|
||||||
|
|
||||||
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
|
||||||
|
|
||||||
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
|
||||||
|
|
||||||
const addParticipant = (identity: string) => {
|
const addParticipant = (identity: string) => {
|
||||||
participantPubkeyMap.update(m => {
|
participantPubkeyMap.update(m => {
|
||||||
const next = new Map(m)
|
const next = new Map(m)
|
||||||
@@ -65,24 +91,6 @@ const deleteParticipant = (identity: string) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
|
||||||
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
|
|
||||||
|
|
||||||
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
|
|
||||||
const pk = pubkeyFromLiveKitIdentity(identity)
|
|
||||||
return pk ? {pubkey: pk, identity} : {identity}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
|
||||||
|
|
||||||
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
|
||||||
|
|
||||||
export const isParticipantSpeaking = derived(
|
|
||||||
speakingParticipants,
|
|
||||||
$participants => (p: VoiceParticipant) =>
|
|
||||||
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const fetchLivekitToken = async (
|
const fetchLivekitToken = async (
|
||||||
url: string,
|
url: string,
|
||||||
groupId: string,
|
groupId: string,
|
||||||
@@ -164,7 +172,9 @@ const setUpMicrophone = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
||||||
|
videoPrimaryTileKey.set(undefined)
|
||||||
currentVoiceSession.set(undefined)
|
currentVoiceSession.set(undefined)
|
||||||
|
resetVideoCallLayout()
|
||||||
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||||
voiceState.set(VoiceState.Disconnected)
|
voiceState.set(VoiceState.Disconnected)
|
||||||
const message =
|
const message =
|
||||||
@@ -183,11 +193,16 @@ const onTrackSubscribed = (track: Track) => {
|
|||||||
element.style.display = "none"
|
element.style.display = "none"
|
||||||
document.body.appendChild(element)
|
document.body.appendChild(element)
|
||||||
element.play().catch(() => {})
|
element.play().catch(() => {})
|
||||||
|
} else if (track.kind === Track.Kind.Video) {
|
||||||
|
triggerVideoFeedCount()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onTrackUnsubscribed = (track: Track) => {
|
const onTrackUnsubscribed = (track: Track) => {
|
||||||
track.detach().forEach(el => el.remove())
|
track.detach().forEach(el => el.remove())
|
||||||
|
if (track.kind === Track.Kind.Video) {
|
||||||
|
triggerVideoFeedCount()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
||||||
@@ -208,6 +223,17 @@ const onParticipantDisconnected = (participant: {identity: string}) => {
|
|||||||
deleteParticipant(participant.identity)
|
deleteParticipant(participant.identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onLocalTrackUnpublished = (
|
||||||
|
publication: LocalTrackPublication,
|
||||||
|
participant: LocalParticipant,
|
||||||
|
) => {
|
||||||
|
if (publication.source !== Track.Source.ScreenShare) return
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session || participant.identity !== session.room.localParticipant.identity) return
|
||||||
|
if (!session.screenShareOn) return
|
||||||
|
currentVoiceSession.set({...session, screenShareOn: false})
|
||||||
|
}
|
||||||
|
|
||||||
let joinAbortController: AbortController | undefined
|
let joinAbortController: AbortController | undefined
|
||||||
|
|
||||||
export const cancelJoinVoiceRoom = () => {
|
export const cancelJoinVoiceRoom = () => {
|
||||||
@@ -245,12 +271,13 @@ export const joinVoiceRoom = async (
|
|||||||
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
||||||
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||||
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||||
|
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
|
||||||
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}),
|
liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}),
|
||||||
whenTimeout(5_000, {
|
whenTimeout(15_000, {
|
||||||
message: "Connection timed out. Please check your network and try again.",
|
message: "Connection timed out. Please check your network and try again.",
|
||||||
}),
|
}),
|
||||||
whenAborted(signal),
|
whenAborted(signal),
|
||||||
@@ -268,7 +295,14 @@ export const joinVoiceRoom = async (
|
|||||||
|
|
||||||
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
||||||
|
|
||||||
currentVoiceSession.set({url, h, room: liveKitRoom, muted})
|
currentVoiceSession.set({
|
||||||
|
url,
|
||||||
|
h,
|
||||||
|
room: liveKitRoom,
|
||||||
|
muted,
|
||||||
|
cameraOn: false,
|
||||||
|
screenShareOn: false,
|
||||||
|
})
|
||||||
voiceState.set(VoiceState.Connected)
|
voiceState.set(VoiceState.Connected)
|
||||||
playJoinSound()
|
playJoinSound()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -287,8 +321,26 @@ export const leaveVoiceRoom = async () => {
|
|||||||
const audio = new Audio("/leave-voice-room.mp3")
|
const audio = new Audio("/leave-voice-room.mp3")
|
||||||
audio.play().catch(() => {})
|
audio.play().catch(() => {})
|
||||||
|
|
||||||
|
if (session.cameraOn) {
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setCameraEnabled(false)
|
||||||
|
} catch {
|
||||||
|
pushToast({theme: "error", message: "Error turning off camera."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.screenShareOn) {
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setScreenShareEnabled(false)
|
||||||
|
} catch {
|
||||||
|
pushToast({theme: "error", message: "Error turning off screen sharing."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
voiceState.set(VoiceState.Disconnected)
|
voiceState.set(VoiceState.Disconnected)
|
||||||
|
videoPrimaryTileKey.set(undefined)
|
||||||
currentVoiceSession.set(undefined)
|
currentVoiceSession.set(undefined)
|
||||||
|
resetVideoCallLayout()
|
||||||
session.room.disconnect()
|
session.room.disconnect()
|
||||||
speakingParticipants.set([])
|
speakingParticipants.set([])
|
||||||
participantPubkeyMap.set(new Map())
|
participantPubkeyMap.set(new Map())
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
<div class="flex grow flex-wrap justify-end gap-2">
|
||||||
{#if h && showRoom}
|
{#if h && showRoom}
|
||||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||||
Posted in #<RoomName {h} {url} />
|
Posted in #<RoomName {h} {url} />
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
h?: string
|
h?: string
|
||||||
|
shareToChat?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, h}: Props = $props()
|
const {url, h, shareToChat = false}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarEventForm {url} {h}>
|
<CalendarEventForm {url} {h} {shareToChat}>
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Create an Event</ModalTitle>
|
<ModalTitle>Create an Event</ModalTitle>
|
||||||
|
|||||||
@@ -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} from "@welshman/app"
|
import {publishThunk, waitForThunkError} 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,24 +20,34 @@
|
|||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {PROTECTED} from "@app/core/state"
|
import {PROTECTED} from "@app/core/state"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
|
import {DraftKey} from "@app/util/drafts"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
import {canEnforceNip70} from "@app/core/commands"
|
import {canEnforceNip70, publishRoomQuote} 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?: {
|
initialValues?: Values
|
||||||
d: string
|
|
||||||
title: string
|
|
||||||
content: string
|
|
||||||
location: string
|
|
||||||
start: number
|
|
||||||
end: number
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, h, header, initialValues}: Props = $props()
|
let {url, h, shareToChat = false, 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)
|
||||||
|
|
||||||
@@ -48,7 +58,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) return
|
if ($uploading || loading) return
|
||||||
|
|
||||||
if (!title) {
|
if (!title) {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
@@ -74,38 +84,68 @@
|
|||||||
const ed = await editor
|
const ed = await editor
|
||||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||||
const tags = [
|
const tags = [
|
||||||
["d", initialValues?.d || randomId()],
|
["d", d],
|
||||||
["title", title],
|
["title", title],
|
||||||
["location", location || ""],
|
["location", location],
|
||||||
["start", start.toString()],
|
["start", start.toString()],
|
||||||
["end", end.toString()],
|
["end", end.toString()],
|
||||||
...daysBetween(start, end).map(D => ["D", D.toString()]),
|
...daysBetween(start, end).map(D => ["D", D.toString()]),
|
||||||
...ed.storage.nostr.getEditorTags(),
|
...ed.storage.nostr.getEditorTags(),
|
||||||
]
|
]
|
||||||
|
|
||||||
if (await shouldProtect) {
|
loading = true
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = initialValues?.content || ""
|
let loading = $state(false)
|
||||||
const editor = makeEditor({url, submit, uploading, content})
|
|
||||||
|
|
||||||
let title = $state(initialValues?.title || "")
|
const d = $state(initialValues?.d ?? randomId())
|
||||||
let location = $state(initialValues?.location || "")
|
let title = $state(initialValues?.title ?? "")
|
||||||
|
let location = $state(initialValues?.location ?? "")
|
||||||
let start: number | undefined = $state(initialValues?.start)
|
let start: number | undefined = $state(initialValues?.start)
|
||||||
let end: number | undefined = $state(initialValues?.end)
|
let end: number | undefined = $state(initialValues?.end)
|
||||||
let endDirty = Boolean(initialValues?.end)
|
let endDirty = $state(Boolean(initialValues?.end))
|
||||||
|
let content = $state(initialValues?.content ?? "")
|
||||||
|
|
||||||
|
const onChange = (json: object) => {
|
||||||
|
content = json
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = makeEditor({url, submit, uploading, onChange, content})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
draftKey.set({d, title, location, start, end, content})
|
||||||
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!endDirty && start) {
|
if (!endDirty && start) {
|
||||||
@@ -136,10 +176,14 @@
|
|||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div
|
<div
|
||||||
class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
|
class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
|
||||||
<div class="input-editor flex-grow overflow-hidden">
|
<div class="input-editor grow overflow-hidden">
|
||||||
<EditorContent {editor} />
|
<EditorContent {editor} />
|
||||||
</div>
|
</div>
|
||||||
<Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
|
<Button
|
||||||
|
data-tip="Add an image"
|
||||||
|
class="center btn tooltip"
|
||||||
|
onclick={selectFiles}
|
||||||
|
disabled={loading}>
|
||||||
{#if $uploading}
|
{#if $uploading}
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -178,12 +222,12 @@
|
|||||||
</Field>
|
</Field>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-link" onclick={back}>
|
<Button class="btn btn-link" onclick={back} disabled={loading}>
|
||||||
<Icon icon={AltArrowLeft} />
|
<Icon icon={AltArrowLeft} />
|
||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
|
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
|
||||||
<Spinner loading={$uploading}>Save Event</Spinner>
|
<Spinner loading={$uploading || loading}>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 flex-grow flex-wrap justify-between gap-2">
|
<div class="flex grow flex-wrap justify-between gap-2">
|
||||||
<p class="text-xl">{meta.title || meta.name}</p>
|
<p class="text-xl">{meta.title || meta.name}</p>
|
||||||
{#if !isNaN(start) && !isNaN(end)}
|
{#if !isNaN(start) && !isNaN(end)}
|
||||||
{@const startDateDisplay = formatTimestampAsDate(start)}
|
{@const startDateDisplay = formatTimestampAsDate(start)}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
{#if meta.location}
|
{#if meta.location}
|
||||||
<span class="flex items-start gap-1">
|
<span class="flex items-start gap-1">
|
||||||
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
|
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
|
||||||
<span class="break-words">{meta.location}</span>
|
<span class="wrap-break-word">{meta.location}</span>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||||
import {userSettingsValues, deriveChat} from "@app/core/state"
|
import {userSettingsValues, deriveChat} from "@app/core/state"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
import {DraftKey} from "@app/util/drafts"
|
||||||
import {makeDelete, prependParent} from "@app/core/commands"
|
import {makeDelete, prependParent} from "@app/core/commands"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
const {pubkeys, info}: Props = $props()
|
const {pubkeys, info}: Props = $props()
|
||||||
|
|
||||||
const chat = deriveChat(pubkeys)
|
const chat = deriveChat(pubkeys)
|
||||||
|
const draftKey = new DraftKey<{content?: string | object}>(`dm:${$chat?.id}`)
|
||||||
const others = remove($pubkey!, pubkeys)
|
const others = remove($pubkey!, pubkeys)
|
||||||
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
|
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
|
||||||
|
|
||||||
@@ -196,8 +198,6 @@
|
|||||||
let compose: ChatCompose | undefined = $state()
|
let compose: ChatCompose | undefined = $state()
|
||||||
let parent: TrustedEvent | undefined = $state()
|
let parent: TrustedEvent | undefined = $state()
|
||||||
let eventToEdit: TrustedEvent | undefined = $state()
|
let eventToEdit: TrustedEvent | undefined = $state()
|
||||||
let chatCompose: HTMLElement | undefined = $state()
|
|
||||||
let dynamicPadding: HTMLElement | undefined = $state()
|
|
||||||
|
|
||||||
const elements = $derived.by(() => {
|
const elements = $derived.by(() => {
|
||||||
const elements = []
|
const elements = []
|
||||||
@@ -233,20 +233,6 @@
|
|||||||
for (const pubkey of others) {
|
for (const pubkey of others) {
|
||||||
loadMessagingRelayList(pubkey)
|
loadMessagingRelayList(pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
|
||||||
if (dynamicPadding && chatCompose) {
|
|
||||||
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
observer.observe(chatCompose!)
|
|
||||||
observer.observe(dynamicPadding!)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.unobserve(chatCompose!)
|
|
||||||
observer.unobserve(dynamicPadding!)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -293,8 +279,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</PageBar>
|
</PageBar>
|
||||||
|
|
||||||
<PageContent class="flex flex-col-reverse gap-2 pt-4">
|
<PageContent class="flex flex-col-reverse gap-2 py-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">
|
||||||
@@ -335,9 +320,10 @@
|
|||||||
</Spinner>
|
</Spinner>
|
||||||
{@render info?.()}
|
{@render info?.()}
|
||||||
</p>
|
</p>
|
||||||
|
<div class="h-screen"></div>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|
||||||
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
|
<div class="chat__compose bg-base-200">
|
||||||
<div>
|
<div>
|
||||||
{#if parent}
|
{#if parent}
|
||||||
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
||||||
@@ -352,7 +338,8 @@
|
|||||||
{onSubmit}
|
{onSubmit}
|
||||||
{onEscape}
|
{onEscape}
|
||||||
{onEditPrevious}
|
{onEditPrevious}
|
||||||
content={eventToEdit?.content}
|
initialValues={eventToEdit}
|
||||||
|
draftKey={eventToEdit ? undefined : draftKey}
|
||||||
disabled={Boolean(missingRelayLists.length)} />
|
disabled={Boolean(missingRelayLists.length)} />
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,23 +10,40 @@
|
|||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
|
import {type DraftKey} from "@app/util/drafts"
|
||||||
|
|
||||||
|
type Values = {
|
||||||
|
content?: string | object
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
content?: string
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
draftKey?: DraftKey<Values>
|
||||||
onEscape?: () => void
|
onEscape?: () => void
|
||||||
onEditPrevious?: () => void
|
onEditPrevious?: () => void
|
||||||
onSubmit: (event: EventContent) => void
|
onSubmit: (event: EventContent) => void
|
||||||
|
initialValues?: Values
|
||||||
}
|
}
|
||||||
|
|
||||||
const {content, disabled = false, onEscape, onEditPrevious, onSubmit}: Props = $props()
|
let {
|
||||||
|
initialValues,
|
||||||
|
disabled = false,
|
||||||
|
draftKey,
|
||||||
|
onEscape,
|
||||||
|
onEditPrevious,
|
||||||
|
onSubmit,
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
if (!initialValues) {
|
||||||
|
initialValues = draftKey?.get()
|
||||||
|
}
|
||||||
|
|
||||||
const autofocus = !isMobile && !disabled
|
const autofocus = !isMobile && !disabled
|
||||||
|
|
||||||
const uploading = writable(false)
|
const uploading = writable(false)
|
||||||
|
|
||||||
const editorClass = $derived(
|
const editorClass = $derived(
|
||||||
cx("chat-editor flex-grow overflow-hidden", {
|
cx("chat-editor grow overflow-hidden", {
|
||||||
"pointer-events-none opacity-50": disabled,
|
"pointer-events-none opacity-50": disabled,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -59,18 +76,29 @@
|
|||||||
|
|
||||||
onSubmit({content, tags})
|
onSubmit({content, tags})
|
||||||
|
|
||||||
|
draftKey?.clear()
|
||||||
ed.chain().clearContent().run()
|
ed.chain().clearContent().run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let content = $state(initialValues?.content ?? "")
|
||||||
|
|
||||||
|
const onChange = (json: object) => {
|
||||||
|
content = json
|
||||||
|
}
|
||||||
|
|
||||||
const editor = makeEditor({
|
const editor = makeEditor({
|
||||||
content,
|
content,
|
||||||
autofocus,
|
|
||||||
submit,
|
submit,
|
||||||
uploading,
|
uploading,
|
||||||
|
onChange,
|
||||||
aggressive: true,
|
aggressive: true,
|
||||||
encryptFiles: true,
|
encryptFiles: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
draftKey?.set({content})
|
||||||
|
})
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const ed = await editor
|
const ed = await editor
|
||||||
ed.view.dom.addEventListener("keydown", handleKeyDown)
|
ed.view.dom.addEventListener("keydown", handleKeyDown)
|
||||||
@@ -95,7 +123,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
<div class={editorClass} aria-disabled={disabled}>
|
<div class={editorClass} aria-disabled={disabled}>
|
||||||
<EditorContent {editor} />
|
<EditorContent {autofocus} {editor} />
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
<Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}>
|
<Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}>
|
||||||
<div
|
<div
|
||||||
class="cursor-pointer border-t border-solid border-base-100 px-6 py-2 transition-colors hover:bg-base-100 {props.class}"
|
class="cursor-pointer border-t border-solid border-base-100 px-3 py-2 transition-colors hover:bg-base-100 {props.class}"
|
||||||
class:bg-base-100={active}>
|
class:bg-base-100={active}>
|
||||||
<div class="flex flex-col justify-start gap-1">
|
<div class="flex flex-col justify-start gap-1">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {assoc} from "@welshman/lib"
|
import {assoc} from "@welshman/lib"
|
||||||
import ChatSquare from "@assets/icons/chat-square.svg?dataurl"
|
|
||||||
import Check from "@assets/icons/check.svg?dataurl"
|
import Check from "@assets/icons/check.svg?dataurl"
|
||||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||||
import BellOff from "@assets/icons/bell-off.svg?dataurl"
|
import BellOff from "@assets/icons/bell-off.svg?dataurl"
|
||||||
@@ -8,13 +7,9 @@
|
|||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Modal from "@lib/components/Modal.svelte"
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
import ChatStart from "@app/components/ChatStart.svelte"
|
|
||||||
import {setChecked} from "@app/util/notifications"
|
import {setChecked} from "@app/util/notifications"
|
||||||
import {pushModal} from "@app/util/modal"
|
|
||||||
import {notificationSettings} from "@app/core/state"
|
import {notificationSettings} from "@app/core/state"
|
||||||
|
|
||||||
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
|
|
||||||
|
|
||||||
const markAsRead = () => {
|
const markAsRead = () => {
|
||||||
setChecked("/chat/*")
|
setChecked("/chat/*")
|
||||||
history.back()
|
history.back()
|
||||||
@@ -28,10 +23,6 @@
|
|||||||
<Modal>
|
<Modal>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<Button class="btn btn-primary" onclick={startChat}>
|
|
||||||
<Icon size={5} icon={ChatSquare} />
|
|
||||||
Start chat
|
|
||||||
</Button>
|
|
||||||
<Button class="btn btn-neutral" onclick={markAsRead}>
|
<Button class="btn btn-neutral" onclick={markAsRead}>
|
||||||
<Icon size={5} icon={Check} />
|
<Icon size={5} icon={Check} />
|
||||||
Mark all read
|
Mark all read
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
<div class="flex grow flex-wrap justify-end gap-2">
|
||||||
{#if h && showRoom}
|
{#if h && showRoom}
|
||||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||||
Posted in #<RoomName {h} {url} />
|
Posted in #<RoomName {h} {url} />
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
h?: string
|
h?: string
|
||||||
|
shareToChat?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, h}: Props = $props()
|
const {url, h, shareToChat = false}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ClassifiedForm {url} {h}>
|
<ClassifiedForm {url} {h} {shareToChat}>
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Create a Classified Listing</ModalTitle>
|
<ModalTitle>Create a Classified Listing</ModalTitle>
|
||||||
|
|||||||
@@ -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} from "@welshman/app"
|
import {publishThunk, waitForThunkError} 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,25 +20,35 @@
|
|||||||
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 {canEnforceNip70, uploadFile} from "@app/core/commands"
|
import {DraftKey} from "@app/util/drafts"
|
||||||
|
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?: {
|
initialValues?: Values
|
||||||
d?: string
|
|
||||||
title?: string
|
|
||||||
content?: string
|
|
||||||
price?: number
|
|
||||||
currency?: string
|
|
||||||
images?: string[]
|
|
||||||
status?: string
|
|
||||||
topics?: string[]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, h, header, initialValues}: Props = $props()
|
let {url, h, shareToChat = false, header, initialValues}: Props = $props()
|
||||||
|
|
||||||
|
const draftKey = new DraftKey<Values>(`classified:${url}:${h ?? ""}`)
|
||||||
|
|
||||||
|
if (!initialValues) {
|
||||||
|
initialValues = draftKey.get()
|
||||||
|
}
|
||||||
|
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
@@ -66,7 +76,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tags = [
|
const tags = [
|
||||||
["d", initialValues?.d || randomId()],
|
["d", d],
|
||||||
["title", title],
|
["title", title],
|
||||||
["summary", content],
|
["summary", content],
|
||||||
["price", String(price), currency],
|
["price", String(price), currency],
|
||||||
@@ -78,7 +88,9 @@
|
|||||||
tags.push(["t", topic])
|
tags.push(["t", topic])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await shouldProtect) {
|
const protect = await shouldProtect
|
||||||
|
|
||||||
|
if (protect) {
|
||||||
tags.push(PROTECTED)
|
tags.push(PROTECTED)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,27 +117,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
publishThunk({
|
const classifiedThunk = 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)
|
||||||
let title = $state(initialValues?.title || "")
|
const d = $state(initialValues?.d ?? randomId())
|
||||||
let status = $state(initialValues?.status || "active")
|
let title = $state(initialValues?.title ?? "")
|
||||||
let price = $state(Number(initialValues?.price || 0))
|
let status = $state(initialValues?.status ?? "active")
|
||||||
let currency = $state(initialValues?.currency || "SAT")
|
let price = $state(initialValues?.price ?? 0)
|
||||||
let images = $state<(string | File)[]>(initialValues?.images || [])
|
let currency = $state(initialValues?.currency ?? "SAT")
|
||||||
let topics = $state(uniq(removeUndefined((initialValues?.topics || []).map(normalizeTopic))))
|
let images = $state(initialValues?.images ?? [])
|
||||||
|
let topics = $state(uniq(removeUndefined(initialValues?.topics?.map(normalizeTopic) ?? [])))
|
||||||
|
let content = $state(initialValues?.content ?? "")
|
||||||
|
|
||||||
|
const onChange = (json: object) => {
|
||||||
|
content = json
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = makeEditor({url, submit, onChange, content})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
draftKey.set({d, title, status, price, currency, images, topics, content})
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||||
@@ -153,7 +185,7 @@
|
|||||||
<p>Description*</p>
|
<p>Description*</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="note-editor flex-grow overflow-hidden">
|
<div class="note-editor grow overflow-hidden">
|
||||||
<EditorContent {editor} />
|
<EditorContent {editor} />
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
<div class="flex grow flex-wrap justify-end gap-2">
|
||||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||||
<ThunkStatusOrDeleted {event} />
|
<ThunkStatusOrDeleted {event} />
|
||||||
{#if showActivity}
|
{#if showActivity}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
|
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
|
||||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||||
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
||||||
|
import Revote from "@assets/icons/revote.svg?dataurl"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
import ThreadCreate from "@app/components/ThreadCreate.svelte"
|
import ThreadCreate from "@app/components/ThreadCreate.svelte"
|
||||||
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
|
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
|
||||||
import GoalCreate from "@app/components/GoalCreate.svelte"
|
import GoalCreate from "@app/components/GoalCreate.svelte"
|
||||||
|
import PollCreate from "@app/components/PollCreate.svelte"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
@@ -20,13 +22,15 @@
|
|||||||
|
|
||||||
const {url, h, onClick}: Props = $props()
|
const {url, h, onClick}: Props = $props()
|
||||||
|
|
||||||
const createGoal = () => pushModal(GoalCreate, {url, h})
|
const createGoal = () => pushModal(GoalCreate, {url, h, shareToChat: true})
|
||||||
|
|
||||||
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h})
|
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h, shareToChat: true})
|
||||||
|
|
||||||
const createThread = () => pushModal(ThreadCreate, {url, h})
|
const createThread = () => pushModal(ThreadCreate, {url, h, shareToChat: true})
|
||||||
|
|
||||||
const createClassified = () => pushModal(ClassifiedCreate, {url, h})
|
const createClassified = () => pushModal(ClassifiedCreate, {url, h, shareToChat: true})
|
||||||
|
|
||||||
|
const createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true})
|
||||||
|
|
||||||
let ul: Element
|
let ul: Element
|
||||||
|
|
||||||
@@ -60,4 +64,10 @@
|
|||||||
Create Thread
|
Create Thread
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Button onclick={createPoll}>
|
||||||
|
<Icon size={4} icon={Revote} />
|
||||||
|
Ask a Question
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -150,7 +150,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="overflow-hidden text-ellipsis break-words"
|
class="overflow-hidden text-ellipsis wrap-break-word"
|
||||||
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
|
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
|
||||||
{#each shortContent as parsed, i}
|
{#each shortContent as parsed, i}
|
||||||
{#if isNewline(parsed) && !isBlock(i - 1)}
|
{#if isNewline(parsed) && !isBlock(i - 1)}
|
||||||
|
|||||||
@@ -1,27 +1,44 @@
|
|||||||
<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 {dufflepud, PLATFORM_URL, IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/core/state"
|
import {
|
||||||
import {makeSpacePath} from "@app/util/routes"
|
dufflepud,
|
||||||
|
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 fileType = getTagValue("file-type", event.tags) || ""
|
const isRoomOrRelay = isRoomId(url) || isRelayUrl(url)
|
||||||
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})
|
||||||
|
|
||||||
@@ -39,41 +56,52 @@
|
|||||||
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
|
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Link {external} {href} class="my-2 block">
|
{#if isRoomOrRelay}
|
||||||
<div class="overflow-hidden rounded-box">
|
<div>
|
||||||
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)}
|
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
|
||||||
<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>
|
||||||
</Link>
|
{:else}
|
||||||
|
<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}
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {call, displayUrl} from "@welshman/lib"
|
import {displayUrl} from "@welshman/lib"
|
||||||
import {isRelayUrl, getTagValue} from "@welshman/util"
|
import {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 {PLATFORM_URL, IMAGE_CONTENT_TYPES} from "@app/core/state"
|
import {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>
|
||||||
@@ -34,8 +27,5 @@
|
|||||||
{displayUrl(url)}
|
{displayUrl(url)}
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<Link {external} {href} class="link-content whitespace-nowrap">
|
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
|
||||||
<Icon icon={LinkRound} size={3} class="inline-block" />
|
|
||||||
{displayUrl(url)}
|
|
||||||
</Link>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<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>
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-hidden text-ellipsis break-words">
|
<div class="overflow-hidden text-ellipsis wrap-break-word">
|
||||||
{#each shortContent as parsed, i}
|
{#each shortContent as parsed, i}
|
||||||
{#if isNewline(parsed)}
|
{#if isNewline(parsed)}
|
||||||
<ContentNewline value={parsed.value} />
|
<ContentNewline value={parsed.value} />
|
||||||
|
|||||||
@@ -45,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(--primary) 10%, var(--base-300) 90%);">
|
style="background-color: color-mix(in srgb, var(--color-primary) 10%, var(--color-base-300) 90%);">
|
||||||
<NoteContentMinimal trimParent {url} event={$quote} />
|
<NoteContentMinimal trimParent {url} event={$quote} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
|
<NoteCard noShadow event={$quote} {url} class="bg-alt rounded-box p-4">
|
||||||
<NoteContentMinimal {url} event={$quote} />
|
<NoteContentMinimal {url} event={$quote} />
|
||||||
</NoteCard>
|
</NoteCard>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
let popover: Instance | undefined = $state()
|
let popover: Instance | undefined = $state()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button class="join rounded-full">
|
<div class="join items-center rounded-full">
|
||||||
{#if ENABLE_ZAPS && !hideZap}
|
{#if ENABLE_ZAPS && !hideZap}
|
||||||
<ZapButton {url} {event} class="btn join-item btn-neutral btn-xs">
|
<ZapButton {url} {event} class="btn join-item btn-neutral btn-xs">
|
||||||
<Icon icon={Bolt} size={4} />
|
<Icon icon={Bolt} size={4} />
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
<Icon icon={SmileCircle} size={4} />
|
<Icon icon={SmileCircle} size={4} />
|
||||||
</EmojiButton>
|
</EmojiButton>
|
||||||
<Tippy
|
<Tippy
|
||||||
|
class="flex"
|
||||||
bind:popover
|
bind:popover
|
||||||
component={EventMenu}
|
component={EventMenu}
|
||||||
props={{url, noun, event, customActions, onClick: hidePopover}}
|
props={{url, noun, event, customActions, onClick: hidePopover}}
|
||||||
@@ -60,4 +61,4 @@
|
|||||||
<Icon icon={MenuDots} size={4} />
|
<Icon icon={MenuDots} size={4} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tippy>
|
</Tippy>
|
||||||
</Button>
|
</div>
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<pre class="card2 card2-sm bg-alt overflow-auto text-xs"><code>{json}</code></pre>
|
<pre class="card2 card2-sm bg-alt overflow-auto text-xs"><code>{json}</code></pre>
|
||||||
<p class="absolute right-2 top-2 flex flex-grow items-center justify-between">
|
<p class="absolute right-2 top-2 flex grow items-center justify-between">
|
||||||
<Button onclick={copyJson} class="btn btn-neutral btn-sm flex items-center">
|
<Button onclick={copyJson} class="btn btn-neutral btn-sm flex items-center">
|
||||||
<Icon icon={Copy} /> Copy
|
<Icon icon={Copy} /> Copy
|
||||||
</Button>
|
</Button>
|
||||||
@@ -109,6 +109,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-primary flex-grow" onclick={() => history.back()}>Got it</Button>
|
<Button class="btn btn-primary grow" onclick={() => history.back()}>Got it</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -10,13 +10,19 @@
|
|||||||
import {publishComment, canEnforceNip70} from "@app/core/commands"
|
import {publishComment, canEnforceNip70} from "@app/core/commands"
|
||||||
import {PROTECTED} from "@app/core/state"
|
import {PROTECTED} from "@app/core/state"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
|
import {DraftKey} from "@app/util/drafts"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
|
type Values = {
|
||||||
|
content?: string | object
|
||||||
|
}
|
||||||
|
|
||||||
const {url, event, onClose, onSubmit} = $props()
|
const {url, event, onClose, onSubmit} = $props()
|
||||||
|
const draftKey = new DraftKey<Values>(`reply:${event.id}`)
|
||||||
|
const initialValues = draftKey.get()
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
const uploading = writable(false)
|
const uploading = writable(false)
|
||||||
|
const autofocus = !isMobile
|
||||||
|
|
||||||
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
|
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
|
||||||
|
|
||||||
@@ -38,13 +44,23 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
draftKey.clear()
|
||||||
onSubmit(publishComment({event, content, tags, relays: [url]}))
|
onSubmit(publishComment({event, content, tags, relays: [url]}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const editor = makeEditor({url, submit, uploading, autofocus: !isMobile})
|
|
||||||
|
|
||||||
let form: HTMLElement
|
let form: HTMLElement
|
||||||
let spacer: HTMLElement
|
let spacer: HTMLElement
|
||||||
|
let content = $state(initialValues?.content ?? "")
|
||||||
|
|
||||||
|
const onChange = (json: object) => {
|
||||||
|
content = json
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = makeEditor({url, submit, uploading, content, onChange})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
draftKey.set({content})
|
||||||
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -52,7 +68,7 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
spacer!.style.minHeight = `${form!.offsetHeight}px`
|
spacer!.style.minHeight = `${form!.offsetHeight + 60}px`
|
||||||
})
|
})
|
||||||
|
|
||||||
observer.observe(form!)
|
observer.observe(form!)
|
||||||
@@ -64,11 +80,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={spacer}></div>
|
<div bind:this={spacer}></div>
|
||||||
<form in:fly bind:this={form} onsubmit={preventDefault(submit)} class="cb cw fixed z-feature pt-3">
|
<form
|
||||||
|
in:fly
|
||||||
|
bind:this={form}
|
||||||
|
onsubmit={preventDefault(submit)}
|
||||||
|
class="left-content bottom-sai right-sai 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 flex-grow overflow-hidden">
|
<div class="note-editor grow overflow-hidden">
|
||||||
<EditorContent {editor} />
|
<EditorContent {autofocus} {editor} />
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
data-tip="Add an image"
|
data-tip="Add an image"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
<div class="flex grow flex-wrap justify-end gap-2">
|
||||||
{#if h && showRoom}
|
{#if h && showRoom}
|
||||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||||
Posted in #<RoomName {h} {url} />
|
Posted in #<RoomName {h} {url} />
|
||||||
|
|||||||
@@ -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} from "@welshman/app"
|
import {publishThunk, waitForThunkError} 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,6 +10,7 @@
|
|||||||
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"
|
||||||
@@ -20,14 +21,29 @@
|
|||||||
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 {canEnforceNip70} from "@app/core/commands"
|
import {DraftKey} from "@app/util/drafts"
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, h}: Props = $props()
|
let {url, h, initialValues, shareToChat = false}: Props = $props()
|
||||||
|
|
||||||
|
const draftKey = new DraftKey<Values>(`goal:${url}:${h ?? ""}`)
|
||||||
|
|
||||||
|
if (!initialValues) {
|
||||||
|
initialValues = draftKey.get()
|
||||||
|
}
|
||||||
|
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
@@ -38,9 +54,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) return
|
if ($uploading || loading) return
|
||||||
|
|
||||||
if (!content) {
|
if (!title) {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
message: "Please provide a title for your funding goal.",
|
message: "Please provide a title for your funding goal.",
|
||||||
@@ -48,9 +64,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ed = await editor
|
const ed = await editor
|
||||||
const summary = ed.getText({blockSeparator: "\n"}).trim()
|
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||||
|
|
||||||
if (!summary.trim()) {
|
if (!content.trim()) {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
message: "Please provide details about your funding goal.",
|
message: "Please provide details about your funding goal.",
|
||||||
@@ -59,31 +75,68 @@
|
|||||||
|
|
||||||
const tags = [
|
const tags = [
|
||||||
...ed.storage.nostr.getEditorTags(),
|
...ed.storage.nostr.getEditorTags(),
|
||||||
["summary", summary],
|
["summary", content],
|
||||||
["amount", String(amount)],
|
["amount", String(amount)],
|
||||||
["relays", url],
|
["relays", url],
|
||||||
]
|
]
|
||||||
|
|
||||||
if (await shouldProtect) {
|
loading = true
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
|
let loading = $state(false)
|
||||||
|
|
||||||
let content = $state("")
|
let title = $state(initialValues?.title ?? "")
|
||||||
let amount = $state(1000)
|
let amount = $state(initialValues?.amount ?? 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)}>
|
||||||
@@ -102,7 +155,7 @@
|
|||||||
<!-- svelte-ignore a11y_autofocus -->
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
autofocus={!isMobile}
|
autofocus={!isMobile}
|
||||||
bind:value={content}
|
bind:value={title}
|
||||||
class="grow"
|
class="grow"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="What do funds go towards?" />
|
placeholder="What do funds go towards?" />
|
||||||
@@ -115,7 +168,7 @@
|
|||||||
<p>Details*</p>
|
<p>Details*</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="note-editor flex-grow overflow-hidden">
|
<div class="note-editor grow overflow-hidden">
|
||||||
<EditorContent {editor} />
|
<EditorContent {editor} />
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -123,7 +176,8 @@
|
|||||||
<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}
|
||||||
@@ -137,17 +191,17 @@
|
|||||||
Goal Amount (sats)*
|
Goal Amount (sats)*
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="flex flex-grow justify-end">
|
<div class="flex grow justify-end">
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<label class="input input-bordered flex w-auto items-center gap-2">
|
||||||
<Icon icon={Bolt} />
|
<Icon icon={Bolt} />
|
||||||
<input bind:value={amount} type="number" class="w-28" />
|
<input bind:value={amount} type="number" class="w-28 grow" />
|
||||||
<p class="opacity-50">sats</p>
|
<p class="shrink-0 opacity-50">sats</p>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
<input
|
<input
|
||||||
class="range range-primary -mt-2"
|
class="range range-primary -mt-2 w-full"
|
||||||
type="range"
|
type="range"
|
||||||
min="1000"
|
min="1000"
|
||||||
max="100000"
|
max="100000"
|
||||||
@@ -157,10 +211,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-link" onclick={back}>
|
<Button class="btn btn-link" onclick={back} disabled={loading}>
|
||||||
<Icon icon={AltArrowLeft} />
|
<Icon icon={AltArrowLeft} />
|
||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" class="btn btn-primary">Create Goal</Button>
|
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
|
||||||
|
<Spinner {loading}>Create Goal</Spinner>
|
||||||
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<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"
|
||||||
@@ -103,10 +104,16 @@
|
|||||||
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(() => {
|
||||||
@@ -138,6 +145,9 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
import LogInOTP from "@app/components/LogInOTP.svelte"
|
import LogInOTP from "@app/components/LogInOTP.svelte"
|
||||||
import LogInSelect from "@app/components/LogInSelect.svelte"
|
import LogInSelect from "@app/components/LogInSelect.svelte"
|
||||||
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
|
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
|
||||||
|
import {getPomadeLoginFailureMessage, POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
|
||||||
import {pushModal, clearModals} from "@app/util/modal"
|
import {pushModal, clearModals} from "@app/util/modal"
|
||||||
import {setChecked} from "@app/util/notifications"
|
import {setChecked} from "@app/util/notifications"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
|
|
||||||
return pushToast({
|
return pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
message: "Sorry, we were unable to log you in.",
|
message: getPomadeLoginFailureMessage(messages),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +65,17 @@
|
|||||||
|
|
||||||
pushToast({
|
pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
message: "Sorry, we were unable to log you in.",
|
message: getPomadeLoginFailureMessage(res.messages),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Login error:", error)
|
||||||
|
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: POMADE_NETWORK_ERROR_MESSAGE,
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
@@ -90,7 +98,7 @@
|
|||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
<Icon icon={Letter} />
|
<Icon icon={Letter} />
|
||||||
<input bind:value={email} />
|
<input type="email" bind:value={email} />
|
||||||
</label>
|
</label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import LogInOTPConfirm from "@app/components/LogInOTPConfirm.svelte"
|
import LogInOTPConfirm from "@app/components/LogInOTPConfirm.svelte"
|
||||||
|
import {POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
@@ -35,11 +36,20 @@
|
|||||||
if (ok) {
|
if (ok) {
|
||||||
pushModal(LogInOTPConfirm, {email, peersByPrefix})
|
pushModal(LogInOTPConfirm, {email, peersByPrefix})
|
||||||
} else {
|
} else {
|
||||||
|
console.error("Pomade challenge request failed during OTP login")
|
||||||
|
|
||||||
pushToast({
|
pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
message: "Sorry, we were unable to request a login code.",
|
message: POMADE_NETWORK_ERROR_MESSAGE,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: POMADE_NETWORK_ERROR_MESSAGE,
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
@@ -61,7 +71,7 @@
|
|||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
<Icon icon={Letter} />
|
<Icon icon={Letter} />
|
||||||
<input bind:value={email} />
|
<input type="email" bind:value={email} />
|
||||||
</label>
|
</label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
|
|||||||
@@ -15,10 +15,11 @@
|
|||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import StringMultiInput from "@lib/components/StringMultiInput.svelte"
|
import StringMultiInput from "@lib/components/StringMultiInput.svelte"
|
||||||
import LogInSelect from "@app/components/LogInSelect.svelte"
|
import LogInSelect from "@app/components/LogInSelect.svelte"
|
||||||
import {pushToast} from "@app/util/toast"
|
|
||||||
import {setChecked} from "@app/util/notifications"
|
|
||||||
import {pushModal, clearModals} from "@app/util/modal"
|
import {pushModal, clearModals} from "@app/util/modal"
|
||||||
|
import {setChecked} from "@app/util/notifications"
|
||||||
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
|
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
|
||||||
|
import {getPomadeLoginFailureMessage, POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
|
||||||
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
email: string
|
email: string
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
|
|
||||||
return pushToast({
|
return pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
message: "Sorry, we were unable to log you in.",
|
message: getPomadeLoginFailureMessage(messages),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +65,17 @@
|
|||||||
|
|
||||||
pushToast({
|
pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
message: "Sorry, we were unable to log you in.",
|
message: getPomadeLoginFailureMessage(res.messages),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Login error:", error)
|
||||||
|
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: POMADE_NETWORK_ERROR_MESSAGE,
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import Profile from "@app/components/Profile.svelte"
|
import Profile from "@app/components/Profile.svelte"
|
||||||
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
|
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
|
||||||
|
import {getPomadeLoginFailureMessage, POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
|
||||||
import {setChecked} from "@app/util/notifications"
|
import {setChecked} from "@app/util/notifications"
|
||||||
import {clearModals} from "@app/util/modal"
|
import {clearModals} from "@app/util/modal"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
@@ -46,9 +47,16 @@
|
|||||||
|
|
||||||
pushToast({
|
pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
message: "Sorry, we were unable to log you in.",
|
message: getPomadeLoginFailureMessage(res.messages),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Login error:", error)
|
||||||
|
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: POMADE_NETWORK_ERROR_MESSAGE,
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
children,
|
children,
|
||||||
minimal = false,
|
minimal = false,
|
||||||
hideProfile = false,
|
hideProfile = false,
|
||||||
|
noShadow = false,
|
||||||
url,
|
url,
|
||||||
...restProps
|
...restProps
|
||||||
}: {
|
}: {
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
children: Snippet
|
children: Snippet
|
||||||
minimal?: boolean
|
minimal?: boolean
|
||||||
hideProfile?: boolean
|
hideProfile?: boolean
|
||||||
|
noShadow?: boolean
|
||||||
url?: string
|
url?: string
|
||||||
class?: string
|
class?: string
|
||||||
} = $props()
|
} = $props()
|
||||||
@@ -34,7 +36,7 @@
|
|||||||
let muted = $state($isEventMuted(event))
|
let muted = $state($isEventMuted(event))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 shadow-md {restProps.class}">
|
<div class="flex flex-col gap-2 {restProps.class}" class:shadow-md={!noShadow}>
|
||||||
{#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">
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<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, POLL} from "@welshman/util"
|
||||||
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
|
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
|
||||||
import NoteContentThread from "@app/components/NoteContentThread.svelte"
|
import NoteContentThread from "@app/components/NoteContentThread.svelte"
|
||||||
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
|
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
|
||||||
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
|
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
|
||||||
|
import NoteContentPoll from "@app/components/NoteContentPoll.svelte"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
|
|
||||||
const props: ComponentProps<typeof Content> = $props()
|
const props: ComponentProps<typeof Content> = $props()
|
||||||
@@ -19,6 +20,8 @@
|
|||||||
<NoteContentClassified {...props} />
|
<NoteContentClassified {...props} />
|
||||||
{:else if props.event.kind === ZAP_GOAL}
|
{:else if props.event.kind === ZAP_GOAL}
|
||||||
<NoteContentGoal {...props} />
|
<NoteContentGoal {...props} />
|
||||||
|
{:else if props.event.kind === POLL}
|
||||||
|
<NoteContentPoll {...props} />
|
||||||
{:else}
|
{:else}
|
||||||
<Content {...props} />
|
<Content {...props} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
|
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<CalendarEventDate event={props.event} />
|
<CalendarEventDate event={props.event} />
|
||||||
<div class="flex flex-grow flex-col">
|
<div class="flex grow flex-col">
|
||||||
<CalendarEventHeader event={props.event} />
|
<CalendarEventHeader event={props.event} />
|
||||||
<div class="flex py-2 opacity-50">
|
<div class="flex py-2 opacity-50">
|
||||||
<div class="h-px flex-grow bg-base-content opacity-25"></div>
|
<div class="h-px grow bg-base-content opacity-25"></div>
|
||||||
</div>
|
</div>
|
||||||
<Content {...props} />
|
<Content {...props} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<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, POLL} from "@welshman/util"
|
||||||
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
|
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
|
||||||
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
|
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
|
||||||
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
|
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
|
||||||
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
|
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
|
||||||
|
import NoteContentMinimalPoll from "@app/components/NoteContentMinimalPoll.svelte"
|
||||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||||
|
|
||||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||||
@@ -19,6 +20,8 @@
|
|||||||
<NoteContentMinimalClassified {...props} />
|
<NoteContentMinimalClassified {...props} />
|
||||||
{:else if props.event.kind === ZAP_GOAL}
|
{:else if props.event.kind === ZAP_GOAL}
|
||||||
<NoteContentMinimalGoal {...props} />
|
<NoteContentMinimalGoal {...props} />
|
||||||
|
{:else if props.event.kind === POLL}
|
||||||
|
<NoteContentMinimalPoll {...props} />
|
||||||
{:else}
|
{:else}
|
||||||
<ContentMinimal {...props} />
|
<ContentMinimal {...props} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="flex flex-grow flex-wrap justify-between gap-2">
|
<div class="flex grow flex-wrap justify-between gap-2">
|
||||||
<p class="text-sm">{meta.title || meta.name}</p>
|
<p class="text-sm">{meta.title || meta.name}</p>
|
||||||
{#if !isNaN(start) && !isNaN(end)}
|
{#if !isNaN(start) && !isNaN(end)}
|
||||||
{@const startDateDisplay = formatTimestampAsDate(start)}
|
{@const startDateDisplay = formatTimestampAsDate(start)}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {ComponentProps} from "svelte"
|
||||||
|
import {derived} from "svelte/store"
|
||||||
|
import {POLL_RESPONSE} from "@welshman/util"
|
||||||
|
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: [POLL_RESPONSE], "#e": [props.event.id]}])
|
||||||
|
|
||||||
|
const results = derived(responses, $responses => getPollResults(props.event, $responses))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-0">
|
||||||
|
<ContentMinimal {...props} />
|
||||||
|
<span class="text-xs opacity-50">{$results.voters} voter{$results.voters === 1 ? "" : "s"}</span>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {ComponentProps} from "svelte"
|
||||||
|
import {onMount} from "svelte"
|
||||||
|
import {request} from "@welshman/net"
|
||||||
|
import {POLL_RESPONSE} from "@welshman/util"
|
||||||
|
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: [POLL_RESPONSE], "#e": [props.event.id]}],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<Content event={props.event} showEntire url={props.url} />
|
||||||
|
|
||||||
|
{#if props.url}
|
||||||
|
<PollVotes url={props.url} event={props.event} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
|
||||||
|
import {makeEvent, POLL} from "@welshman/util"
|
||||||
|
import {publishThunk, waitForThunkError} from "@welshman/app"
|
||||||
|
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>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {getTagValue} from "@welshman/util"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import NoteContent from "@app/components/NoteContent.svelte"
|
||||||
|
import CommentActions from "@app/components/CommentActions.svelte"
|
||||||
|
import RoomLink from "@app/components/RoomLink.svelte"
|
||||||
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
|
import {makePollPath} from "@app/util/routes"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
|
const h = getTagValue("h", event.tags)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-md"
|
||||||
|
href={makePollPath(url, event.id)}>
|
||||||
|
<NoteContent {event} {url} />
|
||||||
|
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||||
|
<span class="whitespace-nowrap py-1 text-sm opacity-75">
|
||||||
|
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
||||||
|
{#if h}
|
||||||
|
in <RoomLink {url} {h} />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<CommentActions segment="polls" showActivity {url} {event} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {tweened} from "svelte/motion"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {noop} from "@welshman/lib"
|
||||||
|
import {stopPropagation} from "@lib/html"
|
||||||
|
import {getPollType, isPollClosed} from "@app/util/polls"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
event: TrustedEvent
|
||||||
|
option: {id: string; label: string}
|
||||||
|
results: {voters: number; options: {id: string; votes: number}[]}
|
||||||
|
selectedIds: string[]
|
||||||
|
setSingleChoice: (id: string) => void
|
||||||
|
toggleMultipleChoice: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const {event, option, results, selectedIds, setSingleChoice, toggleMultipleChoice}: Props =
|
||||||
|
$props()
|
||||||
|
|
||||||
|
const pollType = getPollType(event)
|
||||||
|
const closed = isPollClosed(event)
|
||||||
|
|
||||||
|
const selected = $derived(
|
||||||
|
pollType === "singlechoice" ? selectedIds[0] === option.id : selectedIds.includes(option.id),
|
||||||
|
)
|
||||||
|
const onselect = () =>
|
||||||
|
pollType === "singlechoice" ? setSingleChoice(option.id) : toggleMultipleChoice(option.id)
|
||||||
|
|
||||||
|
const votes = $derived(results.options.find(r => r.id === option.id)?.votes || 0)
|
||||||
|
const maxVotes = $derived(Math.max(...results.options.map(r => r.votes), 1))
|
||||||
|
|
||||||
|
const tweenedVotes = tweened(votes, {duration: 300})
|
||||||
|
const tweenedMax = tweened(maxVotes, {duration: 300})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
tweenedVotes.set(votes)
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
tweenedMax.set(maxVotes)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 card2 card2-sm bg-alt">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<label class="flex min-w-0 grow items-center gap-2">
|
||||||
|
{#if !closed}
|
||||||
|
{#if pollType === "singlechoice"}
|
||||||
|
<input
|
||||||
|
name={event.id}
|
||||||
|
type="radio"
|
||||||
|
class="radio radio-primary radio-sm"
|
||||||
|
checked={selected}
|
||||||
|
onclick={stopPropagation(noop)}
|
||||||
|
onchange={onselect} />
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-primary checkbox-sm"
|
||||||
|
checked={selected}
|
||||||
|
onclick={stopPropagation(noop)}
|
||||||
|
onchange={onselect} />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<span class="truncate">{option.label}</span>
|
||||||
|
</label>
|
||||||
|
<span class="whitespace-nowrap text-xs opacity-75">{votes} vote{votes === 1 ? "" : "s"}</span>
|
||||||
|
</div>
|
||||||
|
<progress class="progress progress-primary" value={$tweenedVotes} max={$tweenedMax}></progress>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onDestroy} from "svelte"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {POLL_RESPONSE} from "@welshman/util"
|
||||||
|
import {pubkey, publishThunk, abortThunk} from "@welshman/app"
|
||||||
|
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: [POLL_RESPONSE], "#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,7 +14,7 @@
|
|||||||
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
|
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {notifications} from "@app/util/notifications"
|
import {notifications} from "@app/util/notifications"
|
||||||
import {goToChat} from "@app/util/routes"
|
import {goToChat, makeSpacePath} from "@app/util/routes"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children?: Snippet
|
children?: Snippet
|
||||||
@@ -26,22 +26,20 @@
|
|||||||
|
|
||||||
const showSettingsMenu = () => pushModal(MenuSettings)
|
const showSettingsMenu = () => pushModal(MenuSettings)
|
||||||
|
|
||||||
const anySpaceNotifications = $derived($userSpaceUrls.some(p => $notifications.has(p)))
|
const anySpaceNotifications = $derived(
|
||||||
|
$userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
|
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 shrink-0 bg-base-200 pt-2 md:block">
|
||||||
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
|
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
|
||||||
<PrimaryNavSpaces />
|
<PrimaryNavSpaces />
|
||||||
{#if PLATFORM_RELAYS.length > 0}
|
{#if PLATFORM_RELAYS.length > 0}
|
||||||
<Divider />
|
<Divider />
|
||||||
{/if}
|
{/if}
|
||||||
<div>
|
<div class="flex flex-col">
|
||||||
<PrimaryNavItem
|
<PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings">
|
||||||
title="Settings"
|
|
||||||
href="/settings/profile"
|
|
||||||
prefix="/settings"
|
|
||||||
class="tooltip-right">
|
|
||||||
{#if $userProfile?.picture}
|
{#if $userProfile?.picture}
|
||||||
<ImageIcon alt="Settings" src={$userProfile?.picture} class="rounded-full" size={10} />
|
<ImageIcon alt="Settings" src={$userProfile?.picture} class="rounded-full" size={10} />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -51,11 +49,10 @@
|
|||||||
<PrimaryNavItem
|
<PrimaryNavItem
|
||||||
title="Messages"
|
title="Messages"
|
||||||
onclick={chatHandler}
|
onclick={chatHandler}
|
||||||
class="tooltip-right"
|
|
||||||
notification={$notifications.has("/chat")}>
|
notification={$notifications.has("/chat")}>
|
||||||
<ImageIcon alt="Messages" src={Letter} size={8} />
|
<ImageIcon alt="Messages" src={Letter} size={8} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
<PrimaryNavItem title="Search" href="/people" class="tooltip-right">
|
<PrimaryNavItem title="Search" href="/people">
|
||||||
<ImageIcon alt="Search" src={Magnifier} size={8} />
|
<ImageIcon alt="Search" src={Magnifier} size={8} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,11 +62,10 @@
|
|||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|
||||||
<!-- a little extra something for ios -->
|
<!-- a little extra something for ios -->
|
||||||
<div
|
<div class="hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-(--saib) bg-base-100 md:hidden">
|
||||||
class="bottom-nav hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden">
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="bottom-nav hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
|
class="hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
|
||||||
<div class="content-padding-x content-sizing flex justify-between px-2">
|
<div class="content-padding-x content-sizing flex justify-between px-2">
|
||||||
<div class="flex gap-2 sm:gap-6">
|
<div class="flex gap-2 sm:gap-6">
|
||||||
<PrimaryNavItem title="Search" href="/people">
|
<PrimaryNavItem title="Search" href="/people">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
import {deriveRelayDisplay} from "@welshman/app"
|
||||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||||
import RelayIcon from "@app/components/RelayIcon.svelte"
|
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||||
import {makeSpacePath, goToSpace} from "@app/util/routes"
|
import {makeSpacePath, goToSpace} from "@app/util/routes"
|
||||||
@@ -12,11 +12,13 @@
|
|||||||
const {url}: Props = $props()
|
const {url}: Props = $props()
|
||||||
|
|
||||||
const onClick = () => goToSpace(url)
|
const onClick = () => goToSpace(url)
|
||||||
|
|
||||||
|
const display = $derived(deriveRelayDisplay(url))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PrimaryNavItem
|
<PrimaryNavItem
|
||||||
onclick={onClick}
|
onclick={onClick}
|
||||||
title={displayRelayUrl(url)}
|
title={$display}
|
||||||
class="tooltip-right"
|
class="tooltip-right"
|
||||||
notification={$notifications.has(makeSpacePath(url))}>
|
notification={$notifications.has(makeSpacePath(url))}>
|
||||||
<RelayIcon {url} size={10} class="rounded-full" />
|
<RelayIcon {url} size={10} class="rounded-full" />
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
const itemHeight = 56
|
const itemHeight = 56
|
||||||
const navPadding = 8 * itemHeight
|
const navPadding = 8 * itemHeight
|
||||||
const itemLimit = $derived((windowHeight - navPadding) / itemHeight)
|
const itemLimit = $derived(Math.max(0, (windowHeight - navPadding) / itemHeight))
|
||||||
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
|
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
|
||||||
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
|
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
|
||||||
</script>
|
</script>
|
||||||
@@ -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" class="tooltip-right">
|
<PrimaryNavItem title="Home" href="/home">
|
||||||
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
|
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -33,7 +33,6 @@
|
|||||||
<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} />
|
||||||
|
|||||||
@@ -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} from "@welshman/util"
|
import {NOTE, ROOMS, COMMENT, MESSAGE} 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, MESSAGE_KINDS} from "@app/core/state"
|
import {deriveGroupList, getSpaceUrlsFromGroupList} 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_KINDS]},
|
{authors: [pubkey], limit: 1, kinds: [NOTE, COMMENT, MESSAGE]},
|
||||||
],
|
],
|
||||||
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
|
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {Profile} from "@welshman/util"
|
import type {Profile} from "@welshman/util"
|
||||||
import {getTag, makeProfile} from "@welshman/util"
|
import {makeProfile} from "@welshman/util"
|
||||||
import {pubkey, profilesByPubkey, waitForThunkError} from "@welshman/app"
|
import {pubkey, profilesByPubkey, waitForThunkError} from "@welshman/app"
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
import {errorMessage} from "@lib/util"
|
import {errorMessage} from "@lib/util"
|
||||||
@@ -10,26 +10,18 @@
|
|||||||
import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
|
import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
|
||||||
import {clearModals} from "@app/util/modal"
|
import {clearModals} from "@app/util/modal"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
import {PROTECTED} from "@app/core/state"
|
|
||||||
import {updateProfile} from "@app/core/commands"
|
import {updateProfile} from "@app/core/commands"
|
||||||
|
|
||||||
const profile = $profilesByPubkey.get($pubkey!) || makeProfile()
|
const profile = $profilesByPubkey.get($pubkey!) || makeProfile()
|
||||||
const shouldBroadcast = !getTag(PROTECTED, profile.event?.tags || [])
|
const initialValues = {profile}
|
||||||
const initialValues = {profile, shouldBroadcast}
|
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
const onsubmit = async ({
|
const onsubmit = async ({profile}: {profile: Profile}) => {
|
||||||
profile,
|
|
||||||
shouldBroadcast,
|
|
||||||
}: {
|
|
||||||
profile: Profile
|
|
||||||
shouldBroadcast: boolean
|
|
||||||
}) => {
|
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const error = await waitForThunkError(updateProfile({profile, shouldBroadcast}))
|
const error = await waitForThunkError(updateProfile({profile}))
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
pushToast({
|
pushToast({
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
import MapPoint from "@assets/icons/map-point.svg?dataurl"
|
import MapPoint from "@assets/icons/map-point.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 FieldInline from "@lib/components/FieldInline.svelte"
|
|
||||||
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"
|
||||||
@@ -17,7 +16,6 @@
|
|||||||
|
|
||||||
type Values = {
|
type Values = {
|
||||||
profile: Profile
|
profile: Profile
|
||||||
shouldBroadcast: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -77,7 +75,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<textarea
|
<textarea
|
||||||
class="textarea textarea-bordered leading-4"
|
class="textarea textarea-bordered leading-4 w-full"
|
||||||
rows="5"
|
rows="5"
|
||||||
bind:value={values.profile.about}></textarea>
|
bind:value={values.profile.about}></textarea>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -104,25 +102,6 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Field>
|
</Field>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !isSignup}
|
|
||||||
<FieldInline>
|
|
||||||
{#snippet label()}
|
|
||||||
<p>Broadcast Profile</p>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet input()}
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="toggle toggle-primary"
|
|
||||||
bind:checked={values.shouldBroadcast} />
|
|
||||||
{/snippet}
|
|
||||||
{#snippet info()}
|
|
||||||
<p>
|
|
||||||
If enabled, changes will be published to the broader nostr network in addition to spaces
|
|
||||||
you are a member of.
|
|
||||||
</p>
|
|
||||||
{/snippet}
|
|
||||||
</FieldInline>
|
|
||||||
{/if}
|
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
{@render footer()}
|
{@render footer()}
|
||||||
|
|||||||
@@ -25,10 +25,10 @@
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{#each spaceUrls as url (url)}
|
{#each spaceUrls as url (url)}
|
||||||
<div class="card2 bg-alt flex flex-row items-center gap-2">
|
<div class="card2 bg-alt flex flex-row items-center gap-2">
|
||||||
<div class="flex-shrink-0">
|
<div class="shrink-0">
|
||||||
<RelayIcon {url} size={12} />
|
<RelayIcon {url} size={12} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-grow flex-col">
|
<div class="flex grow flex-col">
|
||||||
<RelayName {url} />
|
<RelayName {url} />
|
||||||
<div class="text-sm opacity-75">
|
<div class="text-sm opacity-75">
|
||||||
{url}
|
{url}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Reaction from "@app/components/Reaction.svelte"
|
import Reaction from "@app/components/Reaction.svelte"
|
||||||
import ReportDetails from "@app/components/ReportDetails.svelte"
|
import ReportDetails from "@app/components/ReportDetails.svelte"
|
||||||
import {REACTION_KINDS} from "@app/core/state"
|
import {REACTION_KINDS, deriveUserIsSpaceAdmin} from "@app/core/state"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
url?: string
|
url?: string
|
||||||
reactionClass?: string
|
reactionClass?: string
|
||||||
noTooltip?: boolean
|
noTooltip?: boolean
|
||||||
|
innerEvent?: TrustedEvent
|
||||||
children?: Snippet
|
children?: Snippet
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,23 +44,36 @@
|
|||||||
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": [event.id]}]}),
|
deriveEventsById({repository, filters: [{kinds: [REACTION], "#e": eventIds}]}),
|
||||||
)
|
)
|
||||||
|
|
||||||
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": [event.id]}],
|
filters: [{kinds: [ZAP_RESPONSE], "#e": eventIds}],
|
||||||
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
|
eventToItem: (response: TrustedEvent) => {
|
||||||
|
const zap = getValidZap(response, event)
|
||||||
|
|
||||||
|
if (zap) {
|
||||||
|
return zap
|
||||||
|
}
|
||||||
|
|
||||||
|
if (innerEvent) {
|
||||||
|
return getValidZap(response, innerEvent)
|
||||||
|
}
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -78,6 +92,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||||
|
|
||||||
const onReportClick = () => pushModal(ReportDetails, {url, event})
|
const onReportClick = () => pushModal(ReportDetails, {url, event})
|
||||||
|
|
||||||
const reportReasons = $derived(uniq(map(e => getTag("e", e.tags)?.[2], $reports.values())))
|
const reportReasons = $derived(uniq(map(e => getTag("e", e.tags)?.[2], $reports.values())))
|
||||||
@@ -118,7 +134,7 @@
|
|||||||
|
|
||||||
{#if $reactions.length > 0 || $zaps.length || $reports.length > 0 || children}
|
{#if $reactions.length > 0 || $zaps.length || $reports.length > 0 || children}
|
||||||
<div class="flex min-w-0 flex-wrap gap-2">
|
<div class="flex min-w-0 flex-wrap gap-2">
|
||||||
{#if url && $reports.length > 0}
|
{#if url && $reports.length > 0 && $userIsAdmin}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-tip={`This content has been reported as "${displayList(reportReasons)}".`}
|
data-tip={`This content has been reported as "${displayList(reportReasons)}".`}
|
||||||
|
|||||||
@@ -121,6 +121,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-primary flex-grow" onclick={back}>Done</Button>
|
<Button class="btn btn-primary grow" onclick={back}>Done</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn font-normal flex h-[unset] w-full flex-nowrap py-4 text-left items-start justify-between"
|
class="btn font-normal flex h-[unset] w-full flex-nowrap py-4 text-left items-start justify-between"
|
||||||
{onclick}>
|
{onclick}>
|
||||||
<div class="flex flex-grow flex-row items-start gap-4">
|
<div class="flex grow flex-row items-start gap-4">
|
||||||
<div class="flex h-7 w-7 flex-shrink-0 items-center justify-center">
|
<div class="flex h-7 w-7 shrink-0 items-center justify-center">
|
||||||
<Icon {icon} />
|
<Icon {icon} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -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>
|
<div class="min-w-0">
|
||||||
<h2 class="ellipsize whitespace-nowrap text-xl">
|
<h2 class="ellipsize whitespace-nowrap text-xl">
|
||||||
<RelayName {url} />
|
<RelayName {url} />
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -12,18 +12,29 @@
|
|||||||
import ComposeMenu from "@app/components/ComposeMenu.svelte"
|
import ComposeMenu from "@app/components/ComposeMenu.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
|
import {DraftKey} from "@app/util/drafts"
|
||||||
import {onDestroy, onMount} from "svelte"
|
import {onDestroy, onMount} from "svelte"
|
||||||
|
|
||||||
|
type Values = {
|
||||||
|
content?: string | object
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url?: string
|
url?: string
|
||||||
h?: string
|
h?: string
|
||||||
content?: string
|
|
||||||
onEscape?: () => void
|
onEscape?: () => void
|
||||||
onEditPrevious?: () => void
|
onEditPrevious?: () => void
|
||||||
onSubmit: (event: EventContent) => void
|
onSubmit: (event: EventContent) => void
|
||||||
|
initialValues?: Values
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, h, content, onEscape, onEditPrevious, onSubmit}: Props = $props()
|
let {url, h, initialValues, onEscape, onEditPrevious, onSubmit}: Props = $props()
|
||||||
|
|
||||||
|
const draftKey = url || h ? new DraftKey<Values>(`room:${url ?? ""}:${h ?? ""}`) : undefined
|
||||||
|
|
||||||
|
if (!initialValues) {
|
||||||
|
initialValues = draftKey?.get()
|
||||||
|
}
|
||||||
|
|
||||||
const autofocus = !isMobile
|
const autofocus = !isMobile
|
||||||
|
|
||||||
@@ -61,12 +72,29 @@
|
|||||||
|
|
||||||
onSubmit({content, tags})
|
onSubmit({content, tags})
|
||||||
|
|
||||||
|
draftKey?.clear()
|
||||||
ed.chain().clearContent().run()
|
ed.chain().clearContent().run()
|
||||||
}
|
}
|
||||||
|
|
||||||
const editor = makeEditor({url, content, autofocus, submit, uploading, aggressive: true})
|
|
||||||
|
|
||||||
let popover: Instance | undefined = $state()
|
let popover: Instance | undefined = $state()
|
||||||
|
let content = $state(initialValues?.content ?? "")
|
||||||
|
|
||||||
|
const onChange = (json: object) => {
|
||||||
|
content = json
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = makeEditor({
|
||||||
|
url,
|
||||||
|
content,
|
||||||
|
submit,
|
||||||
|
uploading,
|
||||||
|
onChange,
|
||||||
|
aggressive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
draftKey?.set({content})
|
||||||
|
})
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const ed = await editor
|
const ed = await editor
|
||||||
@@ -104,8 +132,8 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</Tippy>
|
</Tippy>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-editor flex-grow overflow-hidden">
|
<div class="chat-editor grow overflow-hidden">
|
||||||
<EditorContent {editor} />
|
<EditorContent {autofocus} {editor} />
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Popover from "@lib/components/Popover.svelte"
|
import Popover from "@lib/components/Popover.svelte"
|
||||||
import Confirm from "@lib/components/Confirm.svelte"
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
|
import Tooltip from "@lib/components/Tooltip.svelte"
|
||||||
import Modal from "@lib/components/Modal.svelte"
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
@@ -206,43 +207,43 @@
|
|||||||
<strong class="text-lg">Room Permissions</strong>
|
<strong class="text-lg">Room Permissions</strong>
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
{#if $room?.isRestricted}
|
{#if $room?.isRestricted}
|
||||||
<Button
|
<Tooltip content="Only members can send messages.">
|
||||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||||
data-tip="Only members can send messages.">
|
<Icon size={4} icon={Microphone} /> Restricted
|
||||||
<Icon size={4} icon={Microphone} /> Restricted
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $room?.isPrivate}
|
{#if $room?.isPrivate}
|
||||||
<Button
|
<Tooltip content="Only members can view messages.">
|
||||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||||
data-tip="Only members can view messages.">
|
<Icon size={4} icon={Lock} /> Private
|
||||||
<Icon size={4} icon={Lock} /> Private
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $room?.isHidden}
|
{#if $room?.isHidden}
|
||||||
<Button
|
<Tooltip content="This room is not visible to non-members.">
|
||||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||||
data-tip="This room is not visible to non-members.">
|
<Icon size={4} icon={EyeClosed} /> Hidden
|
||||||
<Icon size={4} icon={EyeClosed} /> Hidden
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $room?.isClosed}
|
{#if $room?.isClosed}
|
||||||
<Button
|
<Tooltip content="Requests to join this room will be ignored.">
|
||||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||||
data-tip="Requests to join this room will be ignored.">
|
<Icon size={4} icon={MinusCircle} /> Closed
|
||||||
<Icon size={4} icon={MinusCircle} /> Closed
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !$room?.isRestricted && !$room?.isPrivate && !$room?.isHidden && !$room?.isClosed}
|
{#if !$room?.isRestricted && !$room?.isPrivate && !$room?.isHidden && !$room?.isClosed}
|
||||||
<Button
|
<Tooltip content="This room has no additional access controls.">
|
||||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||||
data-tip="This room has no additional access controls.">
|
<Icon size={4} icon={Eye} /> Public
|
||||||
<Icon size={4} icon={Eye} /> Public
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if $members.length > 0}
|
{#if $members !== undefined && $members.length > 0}
|
||||||
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-4">
|
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-4">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span>Members:</span>
|
<span>Members:</span>
|
||||||
@@ -250,6 +251,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<Button class="btn btn-neutral btn-sm" onclick={showMembers}>View All</Button>
|
<Button class="btn btn-neutral btn-sm" onclick={showMembers}>View All</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if $members === undefined}
|
||||||
|
<div class="card2 card2-sm bg-base-200 flex items-center gap-4">
|
||||||
|
<span class="text-error">Member list not available from this relay</span>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="card2 card2-sm bg-alt col-4">
|
<div class="card2 card2-sm bg-alt col-4">
|
||||||
<strong class="text-lg">Room Settings</strong>
|
<strong class="text-lg">Room Settings</strong>
|
||||||
|
|||||||
@@ -131,7 +131,7 @@
|
|||||||
<p>Icon</p>
|
<p>Icon</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="flex flex-grow items-center justify-between gap-4">
|
<div class="flex grow items-center justify-between gap-4">
|
||||||
{#if imagePreview}
|
{#if imagePreview}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm opacity-75">Selected:</span>
|
<span class="text-sm opacity-75">Selected:</span>
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import {hash, now, displayList, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
|
import {readable} from "svelte/store"
|
||||||
|
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} from "@welshman/util"
|
import {MESSAGE, COMMENT, getTag} from "@welshman/util"
|
||||||
import {
|
import {
|
||||||
thunks,
|
thunks,
|
||||||
pubkey,
|
pubkey,
|
||||||
@@ -27,7 +35,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} from "@app/core/state"
|
import {colors, ENABLE_ZAPS, deriveEventsForUrl, deriveEvent} 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"
|
||||||
@@ -37,7 +45,7 @@
|
|||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
replyTo?: (event: TrustedEvent) => void
|
replyTo?: (event: TrustedEvent) => void
|
||||||
showPubkey?: boolean
|
showPubkey?: boolean
|
||||||
inert?: boolean
|
addSpaceBelow?: boolean
|
||||||
canEdit: (event: TrustedEvent) => boolean
|
canEdit: (event: TrustedEvent) => boolean
|
||||||
onEdit: (event: TrustedEvent) => void
|
onEdit: (event: TrustedEvent) => void
|
||||||
}
|
}
|
||||||
@@ -47,7 +55,7 @@
|
|||||||
event,
|
event,
|
||||||
replyTo = undefined,
|
replyTo = undefined,
|
||||||
showPubkey = false,
|
showPubkey = false,
|
||||||
inert = false,
|
addSpaceBelow = false,
|
||||||
canEdit,
|
canEdit,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
@@ -58,7 +66,15 @@
|
|||||||
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
|
||||||
@@ -76,20 +92,23 @@
|
|||||||
|
|
||||||
<TapTarget
|
<TapTarget
|
||||||
data-event={event.id}
|
data-event={event.id}
|
||||||
onTap={inert ? null : onTap}
|
{onTap}
|
||||||
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left hover:bg-base-100/50">
|
class={cx(
|
||||||
|
"group relative flex w-full cursor-default flex-col px-2 py-0.5 text-left hover:bg-base-100/50",
|
||||||
|
{"mt-1.5": showPubkey, "mb-1.5": addSpaceBelow},
|
||||||
|
)}>
|
||||||
<div class="flex w-full gap-3 overflow-auto">
|
<div class="flex w-full gap-3 overflow-auto">
|
||||||
{#if showPubkey}
|
{#if showPubkey}
|
||||||
<Button onclick={openProfile} class="flex items-start">
|
<Button onclick={openProfile} class="flex items-start pt-1.5 justify-center w-8 shrink-0">
|
||||||
<ProfileCircle
|
<ProfileCircle
|
||||||
pubkey={event.pubkey}
|
pubkey={event.pubkey}
|
||||||
class="border border-solid border-base-content"
|
class="border border-solid border-base-content"
|
||||||
size={8} />
|
size={8} />
|
||||||
</Button>
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="w-8 min-w-8 max-w-8"></div>
|
<div class="w-8 shrink-0"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="min-w-0 flex-grow pr-1">
|
<div class="min-w-0 grow pr-1">
|
||||||
{#if showPubkey}
|
{#if showPubkey}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
|
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
|
||||||
@@ -106,7 +125,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class:mt-2={showPubkey && event.kind !== MESSAGE}>
|
<div class:mt-2={showPubkey && event.kind !== MESSAGE}>
|
||||||
<RoomItemContent {url} {event} />
|
<RoomItemContent {url} event={$innerEvent ?? event} />
|
||||||
{#if thunk}
|
{#if thunk}
|
||||||
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
|
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -119,9 +138,10 @@
|
|||||||
{event}
|
{event}
|
||||||
{deleteReaction}
|
{deleteReaction}
|
||||||
{createReaction}
|
{createReaction}
|
||||||
reactionClass="tooltip-right" />
|
reactionClass="tooltip-right"
|
||||||
{#if path && $comments.length > 0}
|
innerEvent={$innerEvent} />
|
||||||
{@const pubkeys = $comments.map(e => e.pubkey)}
|
{#if path && $innerComments.length > 0}
|
||||||
|
{@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`}
|
||||||
@@ -133,14 +153,14 @@
|
|||||||
"btn-primary": isOwn,
|
"btn-primary": isOwn,
|
||||||
})}>
|
})}>
|
||||||
<Icon icon={ReplyAlt} />
|
<Icon icon={ReplyAlt} />
|
||||||
<span>{$comments.length} comment{$comments.length === 1 ? "" : "s"}</span>
|
<span>{$innerComments.length} comment{$innerComments.length === 1 ? "" : "s"}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if !isMobile}
|
{#if !isMobile}
|
||||||
<button
|
<button
|
||||||
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
|
class="join absolute right-2 top-0.5 border border-solid border-neutral text-xs opacity-0 transition-all pr-2"
|
||||||
class:group-hover:opacity-100={!isMobile}>
|
class:group-hover:opacity-100={!isMobile}>
|
||||||
{#if ENABLE_ZAPS}
|
{#if ENABLE_ZAPS}
|
||||||
<RoomItemZapButton {url} {event} />
|
<RoomItemZapButton {url} {event} />
|
||||||
|
|||||||
@@ -8,16 +8,17 @@
|
|||||||
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} />
|
<NoteContent {...props} {minLength} {maxLength} />
|
||||||
</Link>
|
</Link>
|
||||||
{:else}
|
{:else}
|
||||||
<NoteContent {...props} />
|
<NoteContent {...props} {minLength} {maxLength} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {getPubkeyTagValues} from "@welshman/util"
|
|
||||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
url: string
|
|
||||||
event: TrustedEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
const {url, event}: Props = $props()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#each getPubkeyTagValues(event.tags) as pubkey}
|
|
||||||
<div class="py-1 text-center text-xs opacity-75">
|
|
||||||
<ProfileLink unstyled class="text-primary" {url} {pubkey} /> left the room
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
@@ -73,34 +73,44 @@
|
|||||||
</ModalSubtitle>
|
</ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{#each $members as pubkey (pubkey)}
|
{#if $members === undefined}
|
||||||
<div class="card2 bg-alt relative">
|
<div class="card2 bg-base-200 p-4">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<span class="text-error">Member list not available from this relay</span>
|
||||||
<div class="min-w-0 flex-1">
|
</div>
|
||||||
<Profile {pubkey} {url} />
|
{:else if $members.length === 0}
|
||||||
</div>
|
<div class="card2 bg-base-200 p-4">
|
||||||
<div class="relative">
|
<span class="text-base-content/70">No members yet</span>
|
||||||
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
|
</div>
|
||||||
<Icon icon={MenuDots} />
|
{:else}
|
||||||
</Button>
|
{#each $members as pubkey (pubkey)}
|
||||||
{#if menuPubkey === pubkey}
|
<div class="card2 bg-alt relative">
|
||||||
<Popover hideOnClick onClose={closeMenu}>
|
<div class="flex items-center justify-between gap-2">
|
||||||
<ul
|
<div class="min-w-0 flex-1">
|
||||||
transition:fly
|
<Profile {pubkey} {url} />
|
||||||
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
|
</div>
|
||||||
<li>
|
<div class="relative">
|
||||||
<Button class="text-error" onclick={() => removeMember(pubkey)}>
|
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
|
||||||
<Icon icon={MinusCircle} />
|
<Icon icon={MenuDots} />
|
||||||
Remove Member
|
</Button>
|
||||||
</Button>
|
{#if menuPubkey === pubkey}
|
||||||
</li>
|
<Popover hideOnClick onClose={closeMenu}>
|
||||||
</ul>
|
<ul
|
||||||
</Popover>
|
transition:fly
|
||||||
{/if}
|
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
|
||||||
|
<li>
|
||||||
|
<Button class="text-error" onclick={() => removeMember(pubkey)}>
|
||||||
|
<Icon icon={MinusCircle} />
|
||||||
|
Remove Member
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Popover>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
{/each}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -56,6 +56,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
|
if (!$spaceMembers) {
|
||||||
|
addMembers()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const pubkeysSnapshot = $state.snapshot(pubkeys)
|
const pubkeysSnapshot = $state.snapshot(pubkeys)
|
||||||
const nonSpaceMembers = pubkeysSnapshot.filter(pubkey => !$spaceMembers.includes(pubkey))
|
const nonSpaceMembers = pubkeysSnapshot.filter(pubkey => !$spaceMembers.includes(pubkey))
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
const {url, h, ...props}: Props = $props()
|
const {url, h, ...props}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-grow items-center justify-between gap-4 {props.class}">
|
<div class="flex grow items-center justify-between gap-4 {props.class}">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-2">
|
||||||
<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} />
|
||||||
|
|||||||
@@ -120,7 +120,7 @@
|
|||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
<Icon icon={Letter} />
|
<Icon icon={Letter} />
|
||||||
<input bind:value={email} />
|
<input type="email" bind:value={email} />
|
||||||
</label>
|
</label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
@@ -134,6 +134,9 @@
|
|||||||
<input type="password" bind:value={password} />
|
<input type="password" bind:value={password} />
|
||||||
</label>
|
</label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
{#snippet info()}
|
||||||
|
Must be at least 12 characters long.
|
||||||
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
const profile = getKey<Profile>("signup.profile")!
|
const profile = getKey<Profile>("signup.profile")!
|
||||||
|
|
||||||
const initialValues = {profile, shouldBroadcast: false}
|
const initialValues = {profile}
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<Button onclick={back} class="place-self-start pr-3 md:hidden">
|
<Button onclick={back} class="place-self-start pr-3 md:hidden">
|
||||||
<Icon icon={ArrowLeft} size={7} />
|
<Icon icon={ArrowLeft} size={7} />
|
||||||
</Button>
|
</Button>
|
||||||
<div class="ellipsize whitespace-nowrap flex flex-grow items-center justify-between gap-4">
|
<div class="ellipsize whitespace-nowrap flex grow items-center justify-between gap-4">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
{@render title?.()}
|
{@render title?.()}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="avatar relative">
|
<div class="avatar relative">
|
||||||
<div
|
<div
|
||||||
class="center !flex h-16 w-16 min-w-16 rounded-full border-2 border-solid border-base-300 bg-base-300">
|
class="center flex! h-16 w-16 min-w-16 rounded-full border-2 border-solid border-base-300 bg-base-300">
|
||||||
<RelayIcon {url} size={10} />
|
<RelayIcon {url} size={10} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
<p>Icon</p>
|
<p>Icon</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="flex items-center gap-4 justify-between flex-grow">
|
<div class="flex items-center gap-4 justify-between grow">
|
||||||
{#if imagePreview}
|
{#if imagePreview}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm opacity-75">Selected:</span>
|
<span class="text-sm opacity-75">Selected:</span>
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import {sleep} from "@welshman/lib"
|
import {sleep} from "@welshman/lib"
|
||||||
import {request} from "@welshman/net"
|
import {request} from "@welshman/net"
|
||||||
import {displayRelayUrl, getTagValue, RELAY_INVITE} from "@welshman/util"
|
import {displayRelayUrl, getTagValue, RELAY_INVITE} from "@welshman/util"
|
||||||
|
import {Share} from "@capacitor/share"
|
||||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
|
import Upload from "@assets/icons/upload.svg?dataurl"
|
||||||
import Copy from "@assets/icons/copy.svg?dataurl"
|
import Copy from "@assets/icons/copy.svg?dataurl"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import Field from "@lib/components/Field.svelte"
|
import Field from "@lib/components/Field.svelte"
|
||||||
@@ -23,36 +25,72 @@
|
|||||||
const {url} = $props()
|
const {url} = $props()
|
||||||
|
|
||||||
const authError = deriveRelayAuthError(url)
|
const authError = deriveRelayAuthError(url)
|
||||||
|
let networkError = $state(false)
|
||||||
|
const isExplicitAuthError = $derived(
|
||||||
|
$authError &&
|
||||||
|
!(
|
||||||
|
$authError.toLowerCase().includes("failed") ||
|
||||||
|
$authError.toLowerCase().includes("timeout") ||
|
||||||
|
$authError.toLowerCase().includes("network")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const isGenericError = $derived(networkError || ($authError && !isExplicitAuthError))
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
const copyInvite = () => clip(invite)
|
const copyInvite = () => clip(invite)
|
||||||
|
|
||||||
|
const shareInvite = async () => {
|
||||||
|
if (!canShare) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Share.share({url: invite})
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let canShare = $state(false)
|
||||||
let claim = $state("")
|
let claim = $state("")
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
|
|
||||||
let invite = $state("")
|
let invite = $state("")
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const relay = displayRelayUrl(url)
|
const relay = displayRelayUrl(url)
|
||||||
const params = new URLSearchParams({r: relay, c: claim}).toString()
|
const params = new URLSearchParams({r: relay, c: claim}).toString()
|
||||||
|
|
||||||
invite = PLATFORM_URL + "/join?" + params
|
invite = PLATFORM_URL + "/join?" + params
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const [[event]] = await Promise.all([
|
try {
|
||||||
request({
|
const {value} = await Share.canShare()
|
||||||
relays: [url],
|
canShare = value
|
||||||
autoClose: true,
|
} catch {
|
||||||
signal: AbortSignal.timeout(3000),
|
canShare = false
|
||||||
filters: [{kinds: [RELAY_INVITE]}],
|
}
|
||||||
}),
|
|
||||||
sleep(2000),
|
|
||||||
])
|
|
||||||
|
|
||||||
claim = getTagValue("claim", event?.tags || []) || ""
|
try {
|
||||||
loading = false
|
const [[event]] = await Promise.all([
|
||||||
|
request({
|
||||||
|
relays: [url],
|
||||||
|
autoClose: true,
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
filters: [{kinds: [RELAY_INVITE]}],
|
||||||
|
}),
|
||||||
|
sleep(2000),
|
||||||
|
])
|
||||||
|
|
||||||
|
claim = getTagValue("claim", event?.tags || []) || ""
|
||||||
|
} catch (err) {
|
||||||
|
claim = ""
|
||||||
|
if (
|
||||||
|
(err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError")) ||
|
||||||
|
!navigator.onLine
|
||||||
|
) {
|
||||||
|
networkError = true
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -70,20 +108,36 @@
|
|||||||
<p class="center">
|
<p class="center">
|
||||||
<Spinner {loading}>Requesting an invite link...</Spinner>
|
<Spinner {loading}>Requesting an invite link...</Spinner>
|
||||||
</p>
|
</p>
|
||||||
{:else if $authError}
|
{:else if isGenericError}
|
||||||
|
<p class="center text-center">
|
||||||
|
Unable to reach the relay. Please check your connection and try again.
|
||||||
|
</p>
|
||||||
|
{:else if isExplicitAuthError}
|
||||||
<p class="center">Oops! It looks like you're not a member of this relay.</p>
|
<p class="center">Oops! It looks like you're not a member of this relay.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col items-center gap-6">
|
<div class="flex flex-col items-center gap-6">
|
||||||
<QRCode code={invite} />
|
<div class="w-48">
|
||||||
|
<QRCode code={invite} />
|
||||||
|
</div>
|
||||||
<Field>
|
<Field>
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
<div class="flex w-full gap-2">
|
||||||
<Icon icon={LinkRound} />
|
{#if canShare}
|
||||||
<input bind:value={invite} class="grow" type="text" />
|
<Button
|
||||||
<Button onclick={copyInvite}>
|
class="input input-bordered flex shrink-0 w-12 items-center justify-center p-0"
|
||||||
<Icon icon={Copy} />
|
onclick={shareInvite}>
|
||||||
</Button>
|
<Icon icon={Upload} />
|
||||||
</label>
|
</Button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<label class="input input-bordered flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<Icon icon={LinkRound} class="shrink-0" />
|
||||||
|
<input bind:value={invite} class="min-w-0 flex-1 truncate" type="text" readonly />
|
||||||
|
<Button class="shrink-0" onclick={copyInvite}>
|
||||||
|
<Icon icon={Copy} />
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet info()}
|
{#snippet info()}
|
||||||
<p>
|
<p>
|
||||||
@@ -100,6 +154,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-primary flex-grow" onclick={back}>Done</Button>
|
<Button class="btn btn-primary grow" onclick={back}>Done</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -112,46 +112,58 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{#each $members as pubkey (pubkey)}
|
{#if $members === undefined}
|
||||||
<div class="card2 card2-sm bg-alt relative">
|
<div class="card2 bg-base-200 p-4">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<span class="text-error">Member list not available from this space</span>
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<Profile {pubkey} {url} />
|
|
||||||
</div>
|
|
||||||
{#if canBan || canUnallow}
|
|
||||||
<div class="relative">
|
|
||||||
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
|
|
||||||
<Icon icon={MenuDots} />
|
|
||||||
</Button>
|
|
||||||
{#if menuPubkey === pubkey}
|
|
||||||
<Popover hideOnClick onClose={closeMenu}>
|
|
||||||
<ul
|
|
||||||
transition:fly
|
|
||||||
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
|
|
||||||
{#if canUnallow}
|
|
||||||
<li>
|
|
||||||
<Button onclick={() => unallowMember(pubkey)}>
|
|
||||||
<Icon icon={UserMinus} />
|
|
||||||
Remove User
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
{#if canBan}
|
|
||||||
<li>
|
|
||||||
<Button class="text-error" onclick={() => banMember(pubkey)}>
|
|
||||||
<Icon icon={MinusCircle} />
|
|
||||||
Ban User
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
</ul>
|
|
||||||
</Popover>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{:else if $members.length === 0}
|
||||||
|
<div class="card2 bg-base-200 p-4">
|
||||||
|
<span class="text-base-content/70">No members yet</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each $members as pubkey (pubkey)}
|
||||||
|
<div class="card2 card2-sm bg-alt relative">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<Profile {pubkey} {url} />
|
||||||
|
</div>
|
||||||
|
{#if canBan || canUnallow}
|
||||||
|
<div class="relative">
|
||||||
|
<Button
|
||||||
|
class="btn btn-circle btn-ghost btn-sm"
|
||||||
|
onclick={() => toggleMenu(pubkey)}>
|
||||||
|
<Icon icon={MenuDots} />
|
||||||
|
</Button>
|
||||||
|
{#if menuPubkey === pubkey}
|
||||||
|
<Popover hideOnClick onClose={closeMenu}>
|
||||||
|
<ul
|
||||||
|
transition:fly
|
||||||
|
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
|
||||||
|
{#if canUnallow}
|
||||||
|
<li>
|
||||||
|
<Button onclick={() => unallowMember(pubkey)}>
|
||||||
|
<Icon icon={UserMinus} />
|
||||||
|
Remove User
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{#if canBan}
|
||||||
|
<li>
|
||||||
|
<Button class="text-error" onclick={() => banMember(pubkey)}>
|
||||||
|
<Icon icon={MinusCircle} />
|
||||||
|
Ban User
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
</Popover>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -1,7 +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, POLL} from "@welshman/util"
|
||||||
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"
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||||
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
||||||
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
||||||
|
import Revote from "@assets/icons/revote.svg?dataurl"
|
||||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||||
@@ -64,11 +65,13 @@
|
|||||||
const {url} = $props()
|
const {url} = $props()
|
||||||
|
|
||||||
const relay = deriveRelay(url)
|
const relay = deriveRelay(url)
|
||||||
|
const display = deriveRelayDisplay(url)
|
||||||
const chatPath = makeSpacePath(url, "chat")
|
const chatPath = makeSpacePath(url, "chat")
|
||||||
const goalsPath = makeSpacePath(url, "goals")
|
const goalsPath = makeSpacePath(url, "goals")
|
||||||
const threadsPath = makeSpacePath(url, "threads")
|
const threadsPath = makeSpacePath(url, "threads")
|
||||||
const classifiedsPath = makeSpacePath(url, "classifieds")
|
const classifiedsPath = makeSpacePath(url, "classifieds")
|
||||||
const calendarPath = makeSpacePath(url, "calendar")
|
const calendarPath = makeSpacePath(url, "calendar")
|
||||||
|
const pollsPath = makeSpacePath(url, "polls")
|
||||||
const userRooms = deriveUserRooms(url)
|
const userRooms = deriveUserRooms(url)
|
||||||
const otherRooms = deriveOtherRooms(url)
|
const otherRooms = deriveOtherRooms(url)
|
||||||
const otherVoiceRooms = deriveOtherVoiceRooms(url)
|
const otherVoiceRooms = deriveOtherVoiceRooms(url)
|
||||||
@@ -136,12 +139,14 @@
|
|||||||
|
|
||||||
<div bind:this={element} class="flex min-h-0 flex-1 flex-col">
|
<div bind:this={element} class="flex min-h-0 flex-1 flex-col">
|
||||||
<SecondaryNavSection class="min-h-0 flex-1 flex flex-col overflow-hidden pb-0">
|
<SecondaryNavSection class="min-h-0 flex-1 flex flex-col overflow-hidden pb-0">
|
||||||
<div class="flex-shrink-0">
|
<div class="shrink-0">
|
||||||
<Button
|
<Button
|
||||||
class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
|
class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
|
||||||
onclick={openMenu}>
|
onclick={openMenu}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<strong class="flex items-center gap-1 relative">
|
<strong
|
||||||
|
class="flex items-center gap-1 relative tooltip tooltip-right"
|
||||||
|
data-tip={$display}>
|
||||||
<RelayName {url} class="ellipsize" />
|
<RelayName {url} class="ellipsize" />
|
||||||
<div
|
<div
|
||||||
class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0"
|
class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0"
|
||||||
@@ -175,7 +180,11 @@
|
|||||||
<li>
|
<li>
|
||||||
<Button onclick={showMembers}>
|
<Button onclick={showMembers}>
|
||||||
<Icon icon={UserRounded} />
|
<Icon icon={UserRounded} />
|
||||||
View Members ({$members.length})
|
{#if $members === undefined}
|
||||||
|
View Members
|
||||||
|
{:else}
|
||||||
|
View Members ({$members.length})
|
||||||
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
{#if $userIsAdmin}
|
{#if $userIsAdmin}
|
||||||
@@ -257,16 +266,21 @@
|
|||||||
<Icon icon={CalendarMinimalistic} /> Calendar
|
<Icon icon={CalendarMinimalistic} /> Calendar
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if $spaceKinds.has(POLL)}
|
||||||
|
<SecondaryNavItem href={pollsPath}>
|
||||||
|
<Icon icon={Revote} /> Polls
|
||||||
|
</SecondaryNavItem>
|
||||||
|
{/if}
|
||||||
{#if hasNip29($relay)}
|
{#if hasNip29($relay)}
|
||||||
{#if $userRooms.length > 0}
|
{#if $userRooms.length > 0}
|
||||||
<div class="h-2 flex-shrink-0"></div>
|
<div class="h-2 shrink-0"></div>
|
||||||
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
|
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
|
||||||
{/if}
|
{/if}
|
||||||
{#each $userRooms as h (h)}
|
{#each $userRooms as h (h)}
|
||||||
<SpaceMenuRoomItem {url} {h} />
|
<SpaceMenuRoomItem {url} {h} />
|
||||||
{/each}
|
{/each}
|
||||||
{#if $otherRooms.length > 0}
|
{#if $otherRooms.length > 0}
|
||||||
<div class="h-2 flex-shrink-0"></div>
|
<div class="h-2 shrink-0"></div>
|
||||||
<SecondaryNavHeader>
|
<SecondaryNavHeader>
|
||||||
{#if $userRooms.length > 0}
|
{#if $userRooms.length > 0}
|
||||||
Other Rooms
|
Other Rooms
|
||||||
@@ -285,7 +299,7 @@
|
|||||||
<SpaceMenuRoomItem {url} {h} />
|
<SpaceMenuRoomItem {url} {h} />
|
||||||
{/each}
|
{/each}
|
||||||
{#if $otherVoiceRooms.length > 0}
|
{#if $otherVoiceRooms.length > 0}
|
||||||
<div class="h-2 flex-shrink-0"></div>
|
<div class="h-2 shrink-0"></div>
|
||||||
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
|
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
|
||||||
{#each $otherVoiceRooms as h (h)}
|
{#each $otherVoiceRooms as h (h)}
|
||||||
<SpaceMenuRoomItem {url} {h} />
|
<SpaceMenuRoomItem {url} {h} />
|
||||||
@@ -298,11 +312,11 @@
|
|||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<div class="h-5 flex-shrink-0"></div>
|
<div class="h-5 shrink-0"></div>
|
||||||
</div>
|
</div>
|
||||||
</SecondaryNavSection>
|
</SecondaryNavSection>
|
||||||
<div
|
<div
|
||||||
class="flex flex-shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+0.25rem)] md:pb-2 z-nav">
|
class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+0.25rem)] md:pb-2 z-nav">
|
||||||
<VoiceWidget />
|
<VoiceWidget />
|
||||||
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
|
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
|
||||||
<SocketStatusIndicator {url} />
|
<SocketStatusIndicator {url} />
|
||||||
|
|||||||
@@ -24,12 +24,13 @@
|
|||||||
const shouldNotifyForRoom = deriveShouldNotify(url, h)
|
const shouldNotifyForRoom = deriveShouldNotify(url, h)
|
||||||
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
|
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
|
||||||
const notification = $derived($shouldNotifyForRoom ? $notifications.has(path) : false)
|
const notification = $derived($shouldNotifyForRoom ? $notifications.has(path) : false)
|
||||||
|
const roomName = $derived($room?.name || h)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if roomType === RoomType.Voice}
|
{#if roomType === RoomType.Voice}
|
||||||
<VoiceRoomItem {url} {h} {replaceState} {notification} />
|
<VoiceRoomItem {url} {h} {replaceState} {notification} />
|
||||||
{:else}
|
{:else}
|
||||||
<SecondaryNavItem href={path} {replaceState} {notification}>
|
<SecondaryNavItem href={path} title={roomName} {replaceState} {notification}>
|
||||||
<RoomNameWithImage {url} {h} />
|
<RoomNameWithImage {url} {h} />
|
||||||
{#if showDifferenceIcon}
|
{#if showDifferenceIcon}
|
||||||
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
|
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if Array.isArray(supported_nips)}
|
{#if Array.isArray(supported_nips)}
|
||||||
<p class="badge badge-neutral">
|
<p class="badge badge-neutral text-wrap h-auto">
|
||||||
<span class="ellipsize">Supported NIPs: {supported_nips.join(", ")}</span>
|
<span class="ellipsize">Supported NIPs: {supported_nips.join(", ")}</span>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {tick} from "svelte"
|
import {tick} from "svelte"
|
||||||
import {createSearch} from "@welshman/app"
|
import {debounce} from "throttle-debounce"
|
||||||
|
import {request} from "@welshman/net"
|
||||||
import {formatTimestampAsDate, groupBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
|
import {formatTimestampAsDate, groupBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent, Filter} from "@welshman/util"
|
||||||
import {MESSAGE} from "@welshman/util"
|
import {sortEventsDesc} from "@welshman/util"
|
||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import {deriveEventsForUrl} from "@app/core/state"
|
import {CONTENT_KINDS} from "@app/core/state"
|
||||||
import {goToEvent} from "@app/util/routes"
|
import {goToEvent} from "@app/util/routes"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -19,14 +20,16 @@
|
|||||||
|
|
||||||
const {url, h}: Props = $props()
|
const {url, h}: Props = $props()
|
||||||
|
|
||||||
const spaceMessages = deriveEventsForUrl(
|
|
||||||
url,
|
|
||||||
h ? [{kinds: [MESSAGE], "#h": [h]}] : [{kinds: [MESSAGE]}],
|
|
||||||
)
|
|
||||||
|
|
||||||
let term = $state("")
|
let term = $state("")
|
||||||
let show = $state(false)
|
let show = $state(false)
|
||||||
|
let results = $state<TrustedEvent[]>([])
|
||||||
|
let loading = $state(false)
|
||||||
let input: HTMLInputElement | undefined = $state()
|
let input: HTMLInputElement | undefined = $state()
|
||||||
|
let controller: AbortController | undefined
|
||||||
|
|
||||||
|
const relayStatus = $derived(
|
||||||
|
h ? `Searching this room on relay: ${url}.` : `Searching this space on relay: ${url}.`,
|
||||||
|
)
|
||||||
|
|
||||||
const open = () => {
|
const open = () => {
|
||||||
show = true
|
show = true
|
||||||
@@ -40,21 +43,53 @@
|
|||||||
const clear = () => {
|
const clear = () => {
|
||||||
term = ""
|
term = ""
|
||||||
show = false
|
show = false
|
||||||
|
loading = false
|
||||||
|
results = []
|
||||||
|
controller?.abort()
|
||||||
|
controller = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRelayUrls = () => [url]
|
||||||
|
|
||||||
|
const getFilter = (searchTerm: string): Filter =>
|
||||||
|
h
|
||||||
|
? {kinds: CONTENT_KINDS, "#h": [h], search: searchTerm}
|
||||||
|
: {kinds: CONTENT_KINDS, search: searchTerm}
|
||||||
|
|
||||||
|
const search = debounce(300, async (searchTerm: string) => {
|
||||||
|
controller?.abort()
|
||||||
|
|
||||||
|
if (!searchTerm.trim()) {
|
||||||
|
loading = false
|
||||||
|
results = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
controller = new AbortController()
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await request({
|
||||||
|
relays: getRelayUrls(),
|
||||||
|
autoClose: true,
|
||||||
|
signal: controller.signal,
|
||||||
|
filters: [getFilter(searchTerm.trim())],
|
||||||
|
})
|
||||||
|
|
||||||
|
results = sortEventsDesc(events)
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
||||||
|
results = []
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const onInput = () => {
|
const onInput = () => {
|
||||||
show = true
|
void search(term)
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchIndex = $derived.by(() =>
|
|
||||||
createSearch($spaceMessages, {
|
|
||||||
getValue: event => event.id,
|
|
||||||
fuseOptions: {keys: ["content"]},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const results = $derived(term ? searchIndex.searchOptions(term) : [])
|
|
||||||
|
|
||||||
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
|
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
|
||||||
|
|
||||||
const getAgeSection = (createdAt: number) => {
|
const getAgeSection = (createdAt: number) => {
|
||||||
@@ -95,73 +130,74 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
|
||||||
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
|
<Icon size={4} icon={Magnifier} />
|
||||||
<Icon size={4} icon={Magnifier} />
|
</button>
|
||||||
</button>
|
{#if show}
|
||||||
{#if show}
|
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
|
||||||
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
|
<div class="fixed top-sai right-sai left-content z-feature p-2">
|
||||||
<div class="fixed cw top-0 right-0 z-feature p-2">
|
<div
|
||||||
<div
|
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
|
||||||
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
|
transition:fly={{y: -40, duration: 150}}>
|
||||||
transition:fly={{y: -40, duration: 150}}>
|
<div class="flex justify-between">
|
||||||
<div class="flex justify-between">
|
<strong>Search</strong>
|
||||||
<strong>Search</strong>
|
<Button onclick={clear}>
|
||||||
<Button onclick={clear}>
|
<Icon icon={CloseCircle} />
|
||||||
<Icon icon={CloseCircle} />
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
<label class="input input-sm input-bordered flex w-full items-center gap-2">
|
||||||
<label class="input input-sm input-bordered flex w-full items-center gap-2">
|
<Icon size={4} icon={Magnifier} />
|
||||||
<Icon size={4} icon={Magnifier} />
|
<input
|
||||||
<input
|
bind:this={input}
|
||||||
bind:this={input}
|
bind:value={term}
|
||||||
bind:value={term}
|
class="min-w-0 grow"
|
||||||
class="min-w-0 grow"
|
type="text"
|
||||||
type="text"
|
placeholder={h ? "Search this room..." : "Search this space..."}
|
||||||
placeholder={h ? "Search this room..." : "Search this space..."}
|
oninput={onInput} />
|
||||||
oninput={onInput} />
|
</label>
|
||||||
</label>
|
<div class="max-h-[65vh] overflow-y-auto">
|
||||||
<div class="max-h-[65vh] overflow-y-auto">
|
<p class="mb-2 text-xs opacity-70">{relayStatus}</p>
|
||||||
{#if !term}
|
{#if !term}
|
||||||
<p class="text-sm opacity-70">
|
<p class="text-sm opacity-70">
|
||||||
{h ? "Search for messages in this room." : "Search for messages across this space."}
|
{h ? "Search for content in this room." : "Search for content in this space."}
|
||||||
</p>
|
</p>
|
||||||
{:else if eventsByAge.size === 0}
|
{:else if loading}
|
||||||
<p class="text-sm opacity-70">No results found.</p>
|
<p class="text-sm opacity-70">Searching...</p>
|
||||||
{:else}
|
{:else if eventsByAge.size === 0}
|
||||||
<div class="col-2">
|
<p class="text-sm opacity-70">No results found.</p>
|
||||||
{#each eventsByAge as [key, events] (key)}
|
{:else}
|
||||||
|
<div class="col-2">
|
||||||
|
{#each eventsByAge as [key, events] (key)}
|
||||||
|
<div class="col-2">
|
||||||
|
<p class="text-xs uppercase tracking-wide opacity-60">
|
||||||
|
{#if key === "day"}
|
||||||
|
Last 24 Hours
|
||||||
|
{:else if key === "week"}
|
||||||
|
Last 7 Days
|
||||||
|
{:else}
|
||||||
|
Older
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
<p class="text-xs uppercase tracking-wide opacity-60">
|
{#each events as event (event.id)}
|
||||||
{#if key === "day"}
|
<button
|
||||||
Last 24 Hours
|
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
|
||||||
{:else if key === "week"}
|
onclick={() => onRoomSearchResultClick(event)}>
|
||||||
Last 7 Days
|
<p class="line-clamp-2 text-sm">
|
||||||
{:else}
|
{event.content.trim() || "(No text content)"}
|
||||||
Older
|
</p>
|
||||||
{/if}
|
<div class="row-2 text-xs opacity-70">
|
||||||
</p>
|
<span>{getAgeLabel(event.created_at)}</span>
|
||||||
<div class="col-2">
|
<span>{formatTimestampAsDate(event.created_at)}</span>
|
||||||
{#each events as event (event.id)}
|
</div>
|
||||||
<button
|
</button>
|
||||||
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
|
{/each}
|
||||||
onclick={() => onRoomSearchResultClick(event)}>
|
|
||||||
<p class="line-clamp-2 text-sm">
|
|
||||||
{event.content.trim() || "(No text content)"}
|
|
||||||
</p>
|
|
||||||
<div class="row-2 text-xs opacity-70">
|
|
||||||
<span>{getAgeLabel(event.created_at)}</span>
|
|
||||||
<span>{formatTimestampAsDate(event.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
<div class="flex grow flex-wrap justify-end gap-2">
|
||||||
{#if h && showRoom}
|
{#if h && showRoom}
|
||||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||||
Posted in #<RoomName {h} {url} />
|
Posted in #<RoomName {h} {url} />
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user