Compare commits

...

54 Commits

Author SHA1 Message Date
Jon Staab 95d9d8bf23 Bump version 2026-04-16 14:10:50 -07:00
Jon Staab 2fd9741a2b Fix safe area inset for chat fab 2026-04-16 14:08:25 -07:00
Jon Staab fe9c325580 Update universal links 2026-04-16 13:50:13 -07:00
Jon Staab 61e93d4071 Update changelog, bump version 2026-04-16 11:40:24 -07:00
Jon Staab 1e4a4e43dc remove dead virtualization code 2026-04-16 11:39:11 -07:00
Jon Staab e1a7b051bd Use welshman kinds 2026-04-16 11:34:59 -07:00
sakshamjain 7a7af58f5c feat: add native share support for space invites 2026-04-16 10:16:12 -07:00
Jon Staab 016ae86d50 Stop sending duplicate requests per room 2026-04-16 10:03:01 -07:00
Jon Staab 2bff060a5e Add thumbnail url 2026-04-16 10:03:01 -07:00
userAdityaa 68231504d0 fix: modal close button stacking above emoji picker on mobile (#211)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-16 15:38:25 +00:00
DeveshSingh 0658a8ee44 bug: fixed calender modal stacking issue (#209)
Co-authored-by: deveshanim3 <deveshsingh6986@gmail.com>
Co-committed-by: deveshanim3 <deveshsingh6986@gmail.com>
2026-04-16 14:55:37 +00:00
priyanshu_bharti 43fb3d35e6 Fix #202 slow-network invite timeout handling (#207)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-15 22:01:00 +00:00
Khushvendra 4cc1cc95ca Fix voice call cold-start timeout and preserve custom timeout message (#174) (#203)
Co-authored-by: 1amKhush <khushvendras99@gmail.com>
Co-committed-by: 1amKhush <khushvendras99@gmail.com>
2026-04-15 20:37:03 +00:00
Prat_09 964ef441ec Update relay description (#195) (#197)
Co-authored-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
Co-committed-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
2026-04-14 15:09:46 +00:00
priyanshu_bharti 796f37d320 Make space reordering discoverable with smoother drag animation (#171)
Co-authored-by: Priyanshu Bharti <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshu Bharti <bhartipriyanshustm@gmail.com>
2026-04-13 22:38:02 +00:00
nayan9617 b46fd94578 Use relay-provided member lists as source of truth (#191)
Co-authored-by: Nayan Patidar <nayan9617@noreply.coracle.social>
Co-committed-by: Nayan Patidar <nayan9617@noreply.coracle.social>
2026-04-13 21:12:49 +00:00
Jon Staab bdc8e75640 Fix search input width 2026-04-13 12:08:11 -07:00
Jon Staab ef08821796 remove VirtualItem 2026-04-13 10:35:26 -07:00
Jon Staab 9f386f6968 remove redundant room syncing logic 2026-04-11 10:20:50 -07:00
Khushvendra ec0b6a99e2 add room mentions and clickable room/relay refs 2026-04-11 10:13:23 -07:00
Khushvendra f6d9e52c6e fix: support native clipboard image paste on mobile (#181)
Co-authored-by: Khushvendra <khushvendras99@gmail.com>
Co-committed-by: Khushvendra <khushvendras99@gmail.com>
2026-04-11 16:38:06 +00:00
Jon Staab 90f86b833d Handle quotes in RoomItem. Fixes #188 2026-04-10 15:27:36 -07:00
userAdityaa 29bb33c26c publish kind 9 quote after room content creation for cross-client interoperability 2026-04-10 14:20:24 -07:00
Jon Staab c740bd21d4 Fix space page layout on Android by adding visible prop to SecondaryNav 2026-04-10 13:26:14 -07:00
Jon Staab 1d92709c76 perf: task-fix-list-virtualization changes 2026-04-10 12:44:01 -07:00
Jon Staab a42e1df1a7 Fix feed pagination logic 2026-04-10 12:40:28 -07:00
Jon Staab e33beee17d perf: task-fix-raf-derived-to-effect changes 2026-04-10 12:30:25 -07:00
Jon Staab b10ea04cb3 Fix Android push fallback: show all notifications, retry on failure 2026-04-10 12:23:32 -07:00
priyanshu_bharti e8c94177ca Support Aegis URL scheme for NIP-46 login (#161)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-10 19:04:34 +00:00
Jon Staab f1f2083c88 Remove unnecessary snapshots, format 2026-04-10 11:09:26 -07:00
Jon Staab f42889c3c2 Improve performance #182:
increase profile timer and chat search throttle delays
reduce GC pressure in derived stores
use requestIdleCallback for non-critical storage writes
batch repository update processing in feeds
2026-04-10 10:39:38 -07:00
Jon Staab a75e1f96eb Add .claude to gitignore 2026-04-10 10:14:01 -07:00
priyanshu_bharti 85c5293082 Raise message size limit in chat (#186)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-10 16:37:23 +00:00
Jon Staab 37efa6a62c Bump pomade 2026-04-10 09:24:22 -07:00
userAdityaa 1d5f91fb6c fix: realtime updates for room members and admins (#178)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-09 21:05:30 +00:00
userAdityaa ef18655776 make close button / backdrop work on direct invite link page (#177)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-09 20:03:08 +00:00
sakshamjain b786e858d9 correct inverted arrow icon in advanced section toggle (#180)
Co-authored-by: Saksham Jain <reach2saksham2004@gmail.com>
Co-committed-by: Saksham Jain <reach2saksham2004@gmail.com>
2026-04-09 19:57:15 +00:00
mplorentz f4ebc4e99e Video in calls (#135)
#135

This PR adds basic video functionality to our voice rooms. Again I followed the Discord UX for inspiration, so all video calls start as voice-only calls that gracefully upgrade (and downgrade) when someone turns on a video or starts screen sharing.

When a video feed is detected the Room page will change to display a grid of feeds. The grid logic is very basic, that's definitely an area to improve in the future. You can open the chat part of the room with a new button on the VoiceWidget - on the desktop layout this creates a split view with video on the left and chat on the right, but on mobile it switches to chat fullscreen. I also added a little pin icon you can use to focus on a single video feed (useful for screen sharing). There is a lot of tailwind I don't understand here, but it seems to work well enough.

I moved voice.ts into a new `call` folder and moved some of its stores into `call/stores.ts` which allowed me to keep most of the video logic in `call/video.ts`. It's not a perfect encapsulation as voice.ts does subscribe to some of the hooks for the livekit calls and passes some of the signals onto `video.ts`. This could probably be broken up better but for this PR I'd rather not focus on making it perfect if that's ok. Partly for the sake of time but also because I envision another PR that renames/reorganizes things and I think a larger UX evaluation is necessary and should include real user feedback. I'm not confident tha""t the Voice Room concept as a whole will stick going forward. Maybe all rooms in a livekit enabled server should be able to host a call (like a slack huddle), maybe users want to be able to schedule calls as events, or even have them start with an ad-hoc set of participants completely outside of a NIP-29 group, etc.

Co-authored-by: mplorentz <mplorentz@noreply.gitea.coracle.social>
Reviewed-on: coracle/flotilla#135
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-04-08 17:10:20 +00:00
Jon Staab 65ca8a7fd8 Remove follow graph building 2026-04-08 09:46:56 -07:00
nayan9617 7f1e98dcb2 Fix fallback pull race after abort (#167)
Co-authored-by: nayan9617 <nayanp4925@gmail.com>
Co-committed-by: nayan9617 <nayanp4925@gmail.com>
2026-04-08 16:43:04 +00:00
priyanshu_bharti 4c19ee823b 73-video-thumbnails (#142)
This PR implements video thumbnails for `.mov`, `.webm`, and `.mp4` files in the `ContentLinkBlock` component.

Changes:
- Added the `poster` attribute to the `<video>` tag.
- Set the poster source to `{url}#t=1` to capture a clear preview frame at the 1-second mark.
- Verified locally that thumbnails are now correctly displayed instead of a black/empty box.

Closes #73

Reviewed-on: coracle/flotilla#142
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-08 16:07:11 +00:00
Jon Staab 8e2dd8b278 Upgrade daisyui/tailwind 2026-04-07 15:31:35 -07:00
Jon Staab 8d35b3aad2 Chat tweaks 2026-04-07 10:40:45 -07:00
Prat_09 613cad31c0 add start chat FAB (#152)
Co-authored-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
Co-committed-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
2026-04-07 17:02:40 +00:00
Jon Staab 3779a90f26 Tweak to chat item menu buttons 2026-04-07 09:51:25 -07:00
theAnuragMishra 7470f28f31 fix spacing around messages (#159)
Co-authored-by: theAnuragMishra <theanuragmishra@noreply.coracle.social>
Co-committed-by: theAnuragMishra <theanuragmishra@noreply.coracle.social>
2026-04-07 16:50:53 +00:00
hodlbod 17fb4e780b Clean up drafts implementation (#164) 2026-04-07 13:06:29 +00:00
userAdityaa 30c2a6ef79 persist drafts in memory (#155)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-07 12:06:29 +00:00
Jon Staab 0547e9513f Small css tweak 2026-04-06 09:21:59 -07:00
nayan9617 70e5172f1b fix/tooltip-clipping (#156)
Co-authored-by: nayan9617 <nayanp4925@gmail.com>
Co-committed-by: nayan9617 <nayanp4925@gmail.com>
2026-04-06 16:14:08 +00:00
Jon Staab 61c568a112 Formatting 2026-04-06 09:09:01 -07:00
Jon Staab ae2ba6f44d Tweak toast close button 2026-04-06 09:08:26 -07:00
Jon Staab f84006fbe4 Tweak button on profile page 2026-04-06 09:08:26 -07:00
priyanshu_bharti fed34a2747 show space name on hover in primary nav (#129) (#136)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-06 16:08:02 +00:00
152 changed files with 3825 additions and 1985 deletions
+1
View File
@@ -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=
+2 -1
View File
@@ -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
+3
View File
@@ -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
+31
View File
@@ -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
+2 -2
View File
@@ -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.
+2
View File
@@ -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')
+3
View File
@@ -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(
@@ -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 {
+6
View File
@@ -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')
+4 -4
View File
@@ -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.2; 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 = "";
+3 -1
View File
@@ -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>
+2
View File
@@ -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
View File
@@ -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"
} }
+529 -483
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -1,6 +1,5 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {}, "@tailwindcss/postcss": {},
autoprefixer: {},
}, },
} }
+263 -247
View File
@@ -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 */
@@ -403,7 +425,7 @@ body.keyboard-open .hide-on-keyboard {
/* chat view */ /* chat view */
.chat__compose { .chat__compose {
@apply relative z-compose mb-14 flex-grow md:mb-0; @apply z-compose relative mb-14 shrink-0 md:mb-0;
} }
.chat__compose .chat__compose-inner { .chat__compose .chat__compose-inner {
@@ -411,11 +433,5 @@ body.keyboard-open .hide-on-keyboard {
} }
.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;
} }
+57
View File
@@ -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))
},
)
+99
View File
@@ -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",
})
}
}
+67 -48
View File
@@ -4,21 +4,35 @@
*/ */
import { import {
DisconnectReason, DisconnectReason,
LocalParticipant,
LocalTrackPublication,
Room as LiveKitRoom, Room as LiveKitRoom,
RoomEvent, RoomEvent,
Track, Track,
supportsAudioOutputSelection, 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
@@ -27,30 +41,12 @@ export {checkRelayHasLivekit} from "$lib/livekit"
export {supportsAudioOutputSelection} export {supportsAudioOutputSelection}
export type VoiceSession = {
url: string
h: string
room: LiveKitRoom
muted: 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)
const LIVEKIT_DEFAULT_DEVICE_ID = "default" const LIVEKIT_DEFAULT_DEVICE_ID = "default"
export enum DeviceKind { export enum DeviceKind {
AudioInput = "audioinput", AudioInput = "audioinput",
AudioOutput = "audiooutput", AudioOutput = "audiooutput",
VideoInput = "videoinput",
} }
export const switchVoiceActiveDevice = async ( export const switchVoiceActiveDevice = async (
@@ -71,17 +67,14 @@ export const switchVoiceActiveDevice = async (
case DeviceKind.AudioOutput: case DeviceKind.AudioOutput:
label = "speaker" label = "speaker"
break break
case DeviceKind.VideoInput:
label = "camera"
break
} }
pushToast({theme: "error", message: `Error changing ${label}`}) pushToast({theme: "error", message: `Error changing ${label}`})
} }
} }
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)
@@ -98,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,
@@ -197,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 =
@@ -216,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}[]) => {
@@ -241,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 = () => {
@@ -278,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),
@@ -301,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) {
@@ -320,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>
+80 -36
View File
@@ -3,7 +3,7 @@
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {randomId, HOUR} from "@welshman/lib" import {randomId, HOUR} from "@welshman/lib"
import {makeEvent, EVENT_TIME} from "@welshman/util" import {makeEvent, EVENT_TIME} from "@welshman/util"
import {publishThunk} 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)}
+1 -1
View File
@@ -23,7 +23,7 @@
{#if meta.location} {#if meta.location}
<span class="flex items-start gap-1"> <span class="flex items-start gap-1">
<Icon icon={MapPoint} class="mt-[2px]" size={4} /> <Icon icon={MapPoint} class="mt-[2px]" size={4} />
<span class="break-words">{meta.location}</span> <span class="wrap-break-word">{meta.location}</span>
</span> </span>
{/if} {/if}
</div> </div>
+5 -2
View File
@@ -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)))
@@ -277,7 +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">
{#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">
@@ -336,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>
+33 -5
View File
@@ -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"
+1 -1
View File
@@ -35,7 +35,7 @@
<Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}> <Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}>
<div <div
class="cursor-pointer border-t border-solid border-base-100 px-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">
-9
View File
@@ -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
+1 -1
View File
@@ -42,7 +42,7 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect}) publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script> </script>
<div class="flex 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} />
+3 -2
View File
@@ -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>
+58 -26
View File
@@ -2,7 +2,7 @@
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {removeUndefined, randomId, uniq} from "@welshman/lib" import {removeUndefined, randomId, uniq} from "@welshman/lib"
import {makeEvent, CLASSIFIED} from "@welshman/util" import {makeEvent, CLASSIFIED} from "@welshman/util"
import {publishThunk} 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}
+1 -1
View File
@@ -28,7 +28,7 @@
</script> </script>
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex 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}
+5 -5
View File
@@ -22,15 +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}) const createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true})
let ul: Element let ul: Element
+1 -1
View File
@@ -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)}
+69 -41
View File
@@ -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}
+5 -15
View File
@@ -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}
+59
View File
@@ -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>
+1 -1
View File
@@ -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} />
+2 -2
View File
@@ -45,11 +45,11 @@
{#if $quote.kind === MESSAGE} {#if $quote.kind === MESSAGE}
<div <div
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90" class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
style="background-color: color-mix(in srgb, var(--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}
+2 -2
View File
@@ -101,7 +101,7 @@
{/if} {/if}
<div class="relative"> <div class="relative">
<pre class="card2 card2-sm bg-alt overflow-auto text-xs"><code>{json}</code></pre> <pre class="card2 card2-sm bg-alt overflow-auto text-xs"><code>{json}</code></pre>
<p class="absolute right-2 top-2 flex 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>
+24 -8
View File
@@ -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!)
@@ -68,11 +84,11 @@
in:fly in:fly
bind:this={form} bind:this={form}
onsubmit={preventDefault(submit)} onsubmit={preventDefault(submit)}
class="left-content bottom-sai right-sai ml-2 pl-2 fixed z-feature"> 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"
+1 -1
View File
@@ -30,7 +30,7 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect}) publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script> </script>
<div class="flex 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} />
+90 -34
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {makeEvent, ZAP_GOAL} from "@welshman/util" import {makeEvent, ZAP_GOAL} from "@welshman/util"
import {publishThunk} 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>
+1 -1
View File
@@ -23,7 +23,7 @@
<ModalTitle>Unable to Zap</ModalTitle> <ModalTitle>Unable to Zap</ModalTitle>
</ModalHeader> </ModalHeader>
<p> <p>
Zapping <ProfileLink {pubkey} class="!text-primary" /> isn't possible right now because Zapping <ProfileLink {pubkey} class="text-primary!" /> isn't possible right now because
{#if $zapper} {#if $zapper}
their zap receiver isn't correctly set up. their zap receiver isn't correctly set up.
{:else} {:else}
@@ -97,10 +97,10 @@
tabindex="-1" tabindex="-1"
onmousedown={stopPropagation(onClear)} onmousedown={stopPropagation(onClear)}
ontouchstart={stopPropagation(onClear)}> ontouchstart={stopPropagation(onClear)}>
<Icon icon={CloseCircle} class="scale-150 !bg-base-300" /> <Icon icon={CloseCircle} class="scale-150 bg-base-300!" />
</span> </span>
{:else} {:else}
<Icon icon={AddCircle} class="scale-150 !bg-base-300" /> <Icon icon={AddCircle} class="scale-150 bg-base-300!" />
{/if} {/if}
</div> </div>
{#if !url} {#if !url}
+10
View File
@@ -1,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>
+3 -1
View File
@@ -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">
+2 -3
View File
@@ -1,7 +1,6 @@
<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 {Poll} from "nostr-tools/kinds"
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte" import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
import NoteContentThread from "@app/components/NoteContentThread.svelte" import NoteContentThread from "@app/components/NoteContentThread.svelte"
import NoteContentClassified from "@app/components/NoteContentClassified.svelte" import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
@@ -21,7 +20,7 @@
<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} {:else if props.event.kind === POLL}
<NoteContentPoll {...props} /> <NoteContentPoll {...props} />
{:else} {:else}
<Content {...props} /> <Content {...props} />
@@ -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>
+2 -3
View File
@@ -1,7 +1,6 @@
<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 {Poll} from "nostr-tools/kinds"
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte" import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte" import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte" import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
@@ -21,7 +20,7 @@
<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} {:else if props.event.kind === POLL}
<NoteContentMinimalPoll {...props} /> <NoteContentMinimalPoll {...props} />
{:else} {:else}
<ContentMinimal {...props} /> <ContentMinimal {...props} />
@@ -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)}
@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import type {ComponentProps} from "svelte" import type {ComponentProps} from "svelte"
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {PollResponse} from "nostr-tools/kinds" import {POLL_RESPONSE} from "@welshman/util"
import ContentMinimal from "@app/components/ContentMinimal.svelte" import ContentMinimal from "@app/components/ContentMinimal.svelte"
import {deriveEvents} from "@app/core/state" import {deriveEvents} from "@app/core/state"
import {getPollResults} from "@app/util/polls" import {getPollResults} from "@app/util/polls"
const props: ComponentProps<typeof ContentMinimal> = $props() const props: ComponentProps<typeof ContentMinimal> = $props()
const responses = deriveEvents([{kinds: [PollResponse], "#e": [props.event.id]}]) const responses = deriveEvents([{kinds: [POLL_RESPONSE], "#e": [props.event.id]}])
const results = derived(responses, $responses => getPollResults(props.event, $responses)) const results = derived(responses, $responses => getPollResults(props.event, $responses))
</script> </script>
+2 -2
View File
@@ -2,7 +2,7 @@
import type {ComponentProps} from "svelte" import type {ComponentProps} from "svelte"
import {onMount} from "svelte" import {onMount} from "svelte"
import {request} from "@welshman/net" import {request} from "@welshman/net"
import {PollResponse} from "nostr-tools/kinds" import {POLL_RESPONSE} from "@welshman/util"
import PollVotes from "@app/components/PollVotes.svelte" import PollVotes from "@app/components/PollVotes.svelte"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
@@ -15,7 +15,7 @@
request({ request({
relays: [props.url], relays: [props.url],
filters: [{kinds: [PollResponse], "#e": [props.event.id]}], filters: [{kinds: [POLL_RESPONSE], "#e": [props.event.id]}],
}) })
}) })
</script> </script>
+70 -28
View File
@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib" import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
import {makeEvent} from "@welshman/util" import {makeEvent, POLL} from "@welshman/util"
import {publishThunk} from "@welshman/app" import {publishThunk, waitForThunkError} from "@welshman/app"
import {Poll} from "nostr-tools/kinds"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import HamburgerMenu from "@assets/icons/hamburger-menu.svg?dataurl" import HamburgerMenu from "@assets/icons/hamburger-menu.svg?dataurl"
@@ -13,6 +12,7 @@
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte" import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
@@ -21,23 +21,34 @@
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
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 {canEnforceNip70} from "@app/core/commands" import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
import {DraftKey} from "@app/util/drafts"
import type {PollType} from "@app/util/polls" 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 = { type Props = {
url: string url: string
h?: string h?: string
shareToChat?: boolean
} }
const {url, h}: Props = $props() const {url, h, shareToChat = false}: Props = $props()
const draftKey = new DraftKey<Values>(`poll:${url}:${h ?? ""}`)
const initialValues = draftKey.get()
const shouldProtect = canEnforceNip70(url) const shouldProtect = canEnforceNip70(url)
type DraftOption = {
id: string
value: string
}
const back = () => history.back() const back = () => history.back()
const addOption = () => { const addOption = () => {
@@ -92,6 +103,8 @@
} }
const submit = async () => { const submit = async () => {
if (loading) return
if (!title.trim()) { if (!title.trim()) {
return pushToast({theme: "error", message: "Please provide a title for your poll."}) return pushToast({theme: "error", message: "Please provide a title for your poll."})
} }
@@ -120,26 +133,53 @@
tags.push(["h", h]) tags.push(["h", h])
} }
if (await shouldProtect) { loading = true
tags.push(PROTECTED)
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
} }
publishThunk({
relays: [url],
event: makeEvent(Poll, {content: title.trim(), tags}),
})
history.back()
} }
let title = $state("") let loading = $state(false)
let pollType = $state<PollType>("singlechoice")
let endsAt = $state<number | undefined>()
let options = $state<DraftOption[]>([
{id: randomId(), value: "Yes"},
{id: randomId(), value: "No"},
])
let draggedOptionId = $state<string | undefined>() 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> </script>
<Modal tag="form" onsubmit={preventDefault(submit)}> <Modal tag="form" onsubmit={preventDefault(submit)}>
@@ -229,10 +269,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 Poll</Button> <Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Create Poll</Spinner>
</Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
+1 -1
View File
@@ -43,7 +43,7 @@
<div class="flex flex-col gap-2 card2 card2-sm bg-alt"> <div class="flex flex-col gap-2 card2 card2-sm bg-alt">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<label class="flex min-w-0 flex-grow items-center gap-2"> <label class="flex min-w-0 grow items-center gap-2">
{#if !closed} {#if !closed}
{#if pollType === "singlechoice"} {#if pollType === "singlechoice"}
<input <input
+2 -2
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import {onDestroy} from "svelte" import {onDestroy} from "svelte"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {POLL_RESPONSE} from "@welshman/util"
import {pubkey, publishThunk, abortThunk} from "@welshman/app" import {pubkey, publishThunk, abortThunk} from "@welshman/app"
import {PollResponse} from "nostr-tools/kinds"
import {formatTimestampRelative} from "@welshman/lib" import {formatTimestampRelative} from "@welshman/lib"
import {deriveEvents} from "@app/core/state" import {deriveEvents} from "@app/core/state"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
@@ -24,7 +24,7 @@
const {url, event}: Props = $props() const {url, event}: Props = $props()
const responses = deriveEvents([{kinds: [PollResponse], "#e": [event.id]}]) const responses = deriveEvents([{kinds: [POLL_RESPONSE], "#e": [event.id]}])
const pollType = getPollType(event) const pollType = getPollType(event)
const options = getPollOptions(event) const options = getPollOptions(event)
+5 -11
View File
@@ -32,18 +32,14 @@
</script> </script>
<div <div
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-2 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}
@@ -53,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>
@@ -67,8 +62,7 @@
{@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="hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden">
</div> </div>
<div <div
class="hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden"> class="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">
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import {displayRelayUrl} from "@welshman/util" import {deriveRelayDisplay} from "@welshman/app"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte" import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte" import RelayIcon from "@app/components/RelayIcon.svelte"
import {makeSpacePath, goToSpace} from "@app/util/routes" import {makeSpacePath, goToSpace} from "@app/util/routes"
@@ -12,11 +12,13 @@
const {url}: Props = $props() const {url}: Props = $props()
const onClick = () => goToSpace(url) const onClick = () => goToSpace(url)
const display = $derived(deriveRelayDisplay(url))
</script> </script>
<PrimaryNavItem <PrimaryNavItem
onclick={onClick} onclick={onClick}
title={displayRelayUrl(url)} title={$display}
class="tooltip-right" class="tooltip-right"
notification={$notifications.has(makeSpacePath(url))}> notification={$notifications.has(makeSpacePath(url))}>
<RelayIcon {url} size={10} class="rounded-full" /> <RelayIcon {url} size={10} class="rounded-full" />
+1 -2
View File
@@ -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} />
+3 -3
View File
@@ -5,11 +5,11 @@
import type {Filter} from "@welshman/util" import type {Filter} from "@welshman/util"
import {deriveEventsDesc, deriveEventsById} from "@welshman/store" import {deriveEventsDesc, deriveEventsById} from "@welshman/store"
import {formatTimestampRelative} from "@welshman/lib" import {formatTimestampRelative} from "@welshman/lib"
import {NOTE, ROOMS, COMMENT} 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(),
}) })
+4 -12
View File
@@ -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({
+1 -22
View File
@@ -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()}
+2 -2
View File
@@ -25,10 +25,10 @@
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each spaceUrls as url (url)} {#each spaceUrls as url (url)}
<div class="card2 bg-alt flex flex-row items-center gap-2"> <div class="card2 bg-alt flex flex-row items-center gap-2">
<div class="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}
+17 -3
View File
@@ -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)
}
},
}), }),
) )
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -26,8 +26,8 @@
type="button" type="button"
class="btn font-normal flex h-[unset] w-full flex-nowrap py-4 text-left items-start justify-between" class="btn font-normal flex h-[unset] w-full flex-nowrap py-4 text-left items-start justify-between"
{onclick}> {onclick}>
<div class="flex 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">
+1 -1
View File
@@ -23,7 +23,7 @@
<div class="relative"> <div class="relative">
<div class="avatar relative"> <div class="avatar relative">
<div <div
class="center !flex h-12 w-12 min-w-12 rounded-full border-2 border-solid border-base-300 bg-base-300"> class="center flex! h-12 w-12 min-w-12 rounded-full border-2 border-solid border-base-300 bg-base-300">
<RelayIcon {url} /> <RelayIcon {url} />
</div> </div>
</div> </div>
+34 -6
View File
@@ -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"
+31 -26
View File
@@ -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>
+1 -1
View File
@@ -131,7 +131,7 @@
<p>Icon</p> <p>Icon</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="flex flex-grow items-center justify-between gap-4"> <div class="flex grow items-center justify-between gap-4">
{#if imagePreview} {#if imagePreview}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm opacity-75">Selected:</span> <span class="text-sm opacity-75">Selected:</span>
+37 -17
View File
@@ -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} />
+4 -3
View File
@@ -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>
+36 -26
View File
@@ -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>
+5
View File
@@ -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))
+2 -2
View File
@@ -11,8 +11,8 @@
const {url, h, ...props}: Props = $props() const {url, h, ...props}: Props = $props()
</script> </script>
<div class="flex 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} />
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -27,7 +27,7 @@
<Button onclick={back} class="place-self-start pr-3 md:hidden"> <Button onclick={back} class="place-self-start pr-3 md:hidden">
<Icon icon={ArrowLeft} size={7} /> <Icon icon={ArrowLeft} size={7} />
</Button> </Button>
<div class="ellipsize whitespace-nowrap flex 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?.()}
+1 -1
View File
@@ -42,7 +42,7 @@
<div class="relative"> <div class="relative">
<div class="avatar relative"> <div class="avatar relative">
<div <div
class="center !flex h-16 w-16 min-w-16 rounded-full border-2 border-solid border-base-300 bg-base-300"> class="center flex! h-16 w-16 min-w-16 rounded-full border-2 border-solid border-base-300 bg-base-300">
<RelayIcon {url} size={10} /> <RelayIcon {url} size={10} />
</div> </div>
</div> </div>
+1 -1
View File
@@ -134,7 +134,7 @@
<p>Icon</p> <p>Icon</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="flex items-center gap-4 justify-between 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>
+57 -23
View File
@@ -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"
@@ -25,34 +27,54 @@
const authError = deriveRelayAuthError(url) const authError = deriveRelayAuthError(url)
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 {
claim = ""
} finally {
loading = false
}
}) })
</script> </script>
@@ -74,16 +96,28 @@
<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 +134,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>
+51 -39
View File
@@ -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>
+18 -12
View File
@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util" import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL} from "@welshman/util"
import {Poll} from "nostr-tools/kinds" import {deriveRelay, deriveRelayDisplay, createSearch, pubkey} from "@welshman/app"
import {deriveRelay, 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"
@@ -66,6 +65,7 @@
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")
@@ -139,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"
@@ -178,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}
@@ -260,21 +266,21 @@
<Icon icon={CalendarMinimalistic} /> Calendar <Icon icon={CalendarMinimalistic} /> Calendar
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{#if $spaceKinds.has(Poll)} {#if $spaceKinds.has(POLL)}
<SecondaryNavItem href={pollsPath}> <SecondaryNavItem href={pollsPath}>
<Icon icon={Revote} /> Polls <Icon icon={Revote} /> Polls
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/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
@@ -293,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} />
@@ -306,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} />
+2 -1
View File
@@ -24,12 +24,13 @@
const shouldNotifyForRoom = deriveShouldNotify(url, h) const shouldNotifyForRoom = deriveShouldNotify(url, h)
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace) const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
const notification = $derived($shouldNotifyForRoom ? $notifications.has(path) : false) const notification = $derived($shouldNotifyForRoom ? $notifications.has(path) : false)
const roomName = $derived($room?.name || h)
</script> </script>
{#if roomType === RoomType.Voice} {#if roomType === RoomType.Voice}
<VoiceRoomItem {url} {h} {replaceState} {notification} /> <VoiceRoomItem {url} {h} {replaceState} {notification} />
{:else} {:else}
<SecondaryNavItem href={path} {replaceState} {notification}> <SecondaryNavItem href={path} title={roomName} {replaceState} {notification}>
<RoomNameWithImage {url} {h} /> <RoomNameWithImage {url} {h} />
{#if showDifferenceIcon} {#if showDifferenceIcon}
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" /> <Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
+1 -1
View File
@@ -30,7 +30,7 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect}) publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script> </script>
<div class="flex 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} />
+73 -24
View File
@@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {makeEvent, THREAD} from "@welshman/util" import {makeEvent, THREAD} from "@welshman/util"
import {publishThunk} 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 AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
@@ -18,15 +19,23 @@
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 = {
content?: string | object
title?: string
}
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()
const draftKey = new DraftKey<Values>(`thread:${url}:${h ?? ""}`)
const initialValues = draftKey.get()
const shouldProtect = canEnforceNip70(url) const shouldProtect = canEnforceNip70(url)
const uploading = writable(false) const uploading = writable(false)
@@ -36,7 +45,7 @@
const selectFiles = () => editor.then(ed => ed.commands.selectFiles()) const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => { const submit = async () => {
if ($uploading) return if ($uploading || loading) return
if (!title) { if (!title) {
return pushToast({ return pushToast({
@@ -57,25 +66,62 @@
const tags = [...ed.storage.nostr.getEditorTags(), ["title", title]] const tags = [...ed.storage.nostr.getEditorTags(), ["title", title]]
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 threadThunk = publishThunk({
relays: [url],
event: makeEvent(THREAD, {content, tags}),
})
const error = await waitForThunkError(threadThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: threadThunk.event, protect})
}
} finally {
loading = false
} }
if (h) {
tags.push(["h", h])
}
publishThunk({
relays: [url],
event: makeEvent(THREAD, {content, tags}),
})
history.back()
} }
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"}) let loading = $state(false)
let title: string = $state("") let title = $state(initialValues?.title ?? "")
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
url,
submit,
uploading,
onChange,
placeholder: "What's on your mind?",
content,
})
$effect(() => {
draftKey.update({title, content})
})
</script> </script>
<Modal tag="form" onsubmit={preventDefault(submit)}> <Modal tag="form" onsubmit={preventDefault(submit)}>
@@ -106,7 +152,7 @@
<p>Message*</p> <p>Message*</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="note-editor flex-grow overflow-hidden"> <div class="note-editor grow overflow-hidden">
<EditorContent {editor} /> <EditorContent {editor} />
</div> </div>
{/snippet} {/snippet}
@@ -114,7 +160,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}
@@ -124,10 +171,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 Thread</Button> <Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
<Spinner {loading}>Create Thread</Spinner>
</Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
+2 -2
View File
@@ -88,9 +88,9 @@
class:text-base-content={theme === "info"} class:text-base-content={theme === "info"}
class:alert-error={theme === "error"}> class:alert-error={theme === "error"}>
<Button <Button
class="absolute -top-2 -right-2 btn btn-circle btn-neutral btn-xs hidden md:inline-flex" class="absolute -top-2 -right-2 btn btn-circle btn-neutral btn-xs hidden md:inline-flex flex justify-center items-center"
onclick={onClose}> onclick={onClose}>
<Icon icon={Close} size={3} /> <Icon icon={Close} size={4} />
</Button> </Button>
<p class="md:pr-6" class:welshman-content-error={theme === "error"}> <p class="md:pr-6" class:welshman-content-error={theme === "error"}>
{#if $toast.message} {#if $toast.message}
+278
View File
@@ -0,0 +1,278 @@
<script lang="ts">
import cx from "classnames"
import {Track} from "livekit-client"
import {displayProfileByPubkey, loadProfile} from "@welshman/app"
import Pin from "@assets/icons/pin.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import VideoCallTile from "@app/components/VideoCallTile.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import {get} from "svelte/store"
import {
VideoCallLayout,
isDesktopLayout,
toggleVideoPrimaryTile,
videoCallLayout,
videoCallViewportSync,
ViewportSize,
videoPrimaryTileKey,
} from "@app/call/video"
import {currentVoiceSession, currentVoiceRoom, pubkeyFromLiveKitIdentity} from "@app/call/stores"
type Props = {
layout: VideoCallLayout
mobile?: boolean
url: string
h: string
class?: string
}
type VideoTileData = {
identity: string
isLocal: boolean
trackSid: string
track: Track | undefined
source: Track.Source.Camera | Track.Source.ScreenShare
}
type TileLayout = "spotlight" | "default" | "strip"
const {layout, mobile = false, url, h, class: className = ""}: Props = $props()
$effect(() => {
const currentLayout = isDesktopLayout.current ? ViewportSize.Desktop : ViewportSize.Mobile
const {previousLayout} = videoCallViewportSync
if (previousLayout === undefined) {
videoCallViewportSync.previousLayout = currentLayout
return
}
if (previousLayout === currentLayout) return
const p = get(videoCallLayout)
if (previousLayout === ViewportSize.Desktop && currentLayout === ViewportSize.Mobile) {
if (p === VideoCallLayout.Split) videoCallLayout.set(VideoCallLayout.Video)
} else if (previousLayout === ViewportSize.Mobile && currentLayout === ViewportSize.Desktop) {
if (p === VideoCallLayout.Chat) videoCallLayout.set(VideoCallLayout.Split)
}
videoCallViewportSync.previousLayout = currentLayout
})
const isViewingCurrentCallRoom = $derived(
$currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
)
const showVideoContent = $derived(
isViewingCurrentCallRoom &&
(mobile
? layout === VideoCallLayout.Video
: layout === VideoCallLayout.Split || layout === VideoCallLayout.Video),
)
const videoTiles = $derived.by(() => {
const session = $currentVoiceSession
if (!session || $currentVoiceRoom?.url !== url || $currentVoiceRoom?.h !== h) {
return []
}
const room = session.room
const videoTiles: VideoTileData[] = []
const user = room.localParticipant
if (session.cameraOn) {
const localPub = user.getTrackPublication(Track.Source.Camera)
videoTiles.push({
identity: user.identity,
isLocal: true,
trackSid: localPub?.trackSid ?? "local-camera",
track: localPub?.track,
source: Track.Source.Camera,
})
}
if (session.screenShareOn) {
const localPub = user.getTrackPublication(Track.Source.ScreenShare)
videoTiles.push({
identity: user.identity,
isLocal: true,
trackSid: localPub?.trackSid ?? "local-screen",
track: localPub?.track,
source: Track.Source.ScreenShare,
})
}
for (const rp of room.remoteParticipants.values()) {
const camPub = rp.getTrackPublication(Track.Source.Camera)
if (camPub?.isSubscribed && camPub.track) {
videoTiles.push({
identity: rp.identity,
isLocal: false,
trackSid: camPub.trackSid,
track: camPub.track,
source: Track.Source.Camera,
})
}
const screenPub = rp.getTrackPublication(Track.Source.ScreenShare)
if (screenPub?.isSubscribed && screenPub.track) {
videoTiles.push({
identity: rp.identity,
isLocal: false,
trackSid: screenPub.trackSid,
track: screenPub.track,
source: Track.Source.ScreenShare,
})
}
}
return videoTiles
})
/** Identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */
const tileKey = (t: VideoTileData) => `${t.identity}\x1f${t.source}`
const primaryTile = $derived.by(() => {
const k = $videoPrimaryTileKey
if (k === undefined) return undefined
return videoTiles.find(t => tileKey(t) === k)
})
const secondaryTiles = $derived.by(() => {
const p = primaryTile
if (p === undefined) return videoTiles
const pk = tileKey(p)
return videoTiles.filter(t => tileKey(t) !== pk)
})
const useSpotlightLayout = $derived(primaryTile !== undefined)
const useMultiGrid = $derived(!useSpotlightLayout && videoTiles.length > 2)
$effect(() => {
const k = $videoPrimaryTileKey
if (k === undefined) return
if (!videoTiles.some(t => tileKey(t) === k)) {
videoPrimaryTileKey.set(undefined)
}
})
$effect(() => {
for (const t of videoTiles) {
const pk = pubkeyFromLiveKitIdentity(t.identity)
if (pk) loadProfile(pk)
}
})
const labelFor = (identity: string, source: VideoTileData["source"]) => {
const pk = pubkeyFromLiveKitIdentity(identity)
const name = pk ? displayProfileByPubkey(pk) : "Unknown"
return source === Track.Source.ScreenShare ? `${name} · screen` : name
}
const showTileGrid = $derived(videoTiles.length > 0)
const spotlightHandlerFor = (key: string) => () => {
toggleVideoPrimaryTile(key)
}
const panelChrome = $derived(
cx(
mobile &&
"flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden bg-base-200 px-2 pt-4 md:hidden pb-[calc(3.5rem+var(--saib))]",
!mobile &&
"flex min-h-0 w-full min-w-0 flex-1 flex-col gap-2 overflow-hidden bg-base-200 px-2 pb-2 pt-4",
className,
),
)
</script>
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
<div
class={cx(
"relative isolate overflow-hidden rounded-box shadow-sm",
layout === "spotlight" && "min-h-0 flex-1",
layout === "default" && "aspect-video w-full min-h-0",
layout === "strip" && "aspect-video w-44 shrink-0",
tile.source === Track.Source.ScreenShare ? "bg-black" : "bg-base-100",
)}>
{#if tile.track}
<VideoCallTile
track={tile.track}
muted={tile.isLocal}
fit={tile.source === Track.Source.ScreenShare ? "contain" : "cover"}
class="pointer-events-none absolute inset-0" />
{:else}
<div class="absolute inset-0 flex items-center justify-center">
<ProfileCircle pubkey={pubkeyFromLiveKitIdentity(tile.identity)} {url} size={14} />
</div>
{/if}
<span
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-base-100/80 px-1.5 py-0.5 text-xs">
{labelFor(tile.identity, tile.source)}{tile.isLocal ? " (you)" : ""}
</span>
{#if videoTiles.length > 1}
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
<Button
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
aria-pressed={pinned}
class={cx(
"absolute right-1 top-1 z-20 btn btn-xs btn-square btn-ghost",
pinned ? "btn-active bg-primary/25 text-primary" : "bg-base-100/70",
)}
onclick={spotlightHandlerFor(tileKey(tile))}>
<Icon icon={Pin} size={3} />
</Button>
{/if}
</div>
{/snippet}
{#snippet videoPanelBody()}
{#if showTileGrid}
{#if useSpotlightLayout && primaryTile}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
{@render videoTile(primaryTile, "spotlight")}
{#if secondaryTiles.length > 0}
<div
class="flex max-h-40 shrink-0 flex-row gap-2 overflow-x-auto overflow-y-hidden py-0.5">
{#each secondaryTiles as tile (tileKey(tile))}
{@render videoTile(tile, "strip")}
{/each}
</div>
{/if}
</div>
{:else if useMultiGrid}
<div
class="grid min-h-0 flex-1 grid-cols-1 content-start gap-2 overflow-y-auto sm:grid-cols-2">
{#each videoTiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}
</div>
{:else}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
{#each videoTiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}
</div>
{/if}
{:else}
<div
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-box bg-base-200/50 p-4 text-center text-sm opacity-80">
<p>No camera or screen share yet.</p>
<p class="text-xs">Use the camera or screen share control to share video.</p>
</div>
{/if}
{/snippet}
{#if showVideoContent}
<div class={panelChrome}>
{#if mobile}
<div class="flex min-h-0 flex-1 flex-col gap-2">
<div class="min-h-0 flex-1 overflow-hidden">
{@render videoPanelBody()}
</div>
<div class="shrink-0 pb-2">
<VoiceWidget />
</div>
</div>
{:else}
{@render videoPanelBody()}
{/if}
</div>
{/if}
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
import type {Track} from "livekit-client"
import cx from "classnames"
type Props = {
track: Track
muted?: boolean
fit?: "cover" | "contain"
class?: string
}
const {track, muted = true, fit = "cover", class: className = ""}: Props = $props()
let videoElement = $state<HTMLVideoElement | undefined>()
$effect(() => {
const element = videoElement
const activeTrack = track
if (!element) return
activeTrack.attach(element)
return () => {
activeTrack.detach(element)
}
})
</script>
<video
bind:this={videoElement}
class={cx("h-full w-full", fit === "contain" ? "object-contain" : "object-cover", className)}
playsinline
{muted}></video>
@@ -7,13 +7,8 @@
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import { import {currentVoiceSession, type VoiceSession} from "@app/call/stores"
currentVoiceSession, import {DeviceKind, supportsAudioOutputSelection, switchVoiceActiveDevice} from "@app/call/voice"
DeviceKind,
supportsAudioOutputSelection,
switchVoiceActiveDevice,
type VoiceSession,
} from "@app/voice"
import {popModal} from "@app/util/modal" import {popModal} from "@app/util/modal"
const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => { const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => {
@@ -26,8 +21,10 @@
let audioInputs = $state<MediaDeviceInfo[]>([]) let audioInputs = $state<MediaDeviceInfo[]>([])
let audioOutputs = $state<MediaDeviceInfo[]>([]) let audioOutputs = $state<MediaDeviceInfo[]>([])
let videoInputs = $state<MediaDeviceInfo[]>([])
let selectedInput = $state("") let selectedInput = $state("")
let selectedOutput = $state("") let selectedOutput = $state("")
let selectedVideo = $state("")
const loadDevices = async () => { const loadDevices = async () => {
if (!navigator.mediaDevices?.enumerateDevices) return if (!navigator.mediaDevices?.enumerateDevices) return
@@ -35,9 +32,11 @@
const devices = await navigator.mediaDevices.enumerateDevices() const devices = await navigator.mediaDevices.enumerateDevices()
audioInputs = devices.filter(d => d.kind === "audioinput") audioInputs = devices.filter(d => d.kind === "audioinput")
audioOutputs = devices.filter(d => d.kind === "audiooutput") audioOutputs = devices.filter(d => d.kind === "audiooutput")
videoInputs = devices.filter(d => d.kind === "videoinput")
} catch { } catch {
audioInputs = [] audioInputs = []
audioOutputs = [] audioOutputs = []
videoInputs = []
} }
} }
@@ -55,6 +54,7 @@
} }
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput) selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput) selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput)
}) })
const onInputChange = () => { const onInputChange = () => {
@@ -65,6 +65,10 @@
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput) void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput)
} }
const onVideoChange = () => {
void switchVoiceActiveDevice(DeviceKind.VideoInput, selectedVideo)
}
const onDone = () => { const onDone = () => {
popModal() popModal()
} }
@@ -76,8 +80,8 @@
<Modal> <Modal>
<ModalBody> <ModalBody>
<ModalHeader> <ModalHeader>
<ModalTitle>Audio settings</ModalTitle> <ModalTitle>Call settings</ModalTitle>
<ModalSubtitle>Choose microphone and speaker for this call.</ModalSubtitle> <ModalSubtitle>Microphone, speaker, and camera for this call.</ModalSubtitle>
</ModalHeader> </ModalHeader>
<div class="flex flex-col gap-4 pt-2"> <div class="flex flex-col gap-4 pt-2">
<FieldInline> <FieldInline>
@@ -120,6 +124,25 @@
{/snippet} {/snippet}
</FieldInline> </FieldInline>
{/if} {/if}
<FieldInline>
{#snippet label()}
<p>Camera</p>
{/snippet}
{#snippet input()}
<select
class="select select-bordered w-full"
bind:value={selectedVideo}
onchange={onVideoChange}
aria-label="Camera">
<option value="">Default camera</option>
{#each videoInputs as d (d.deviceId)}
<option value={d.deviceId}>
{d.label || `Camera ${d.deviceId.slice(0, 8)}`}
</option>
{/each}
</select>
{/snippet}
</FieldInline>
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
+4 -5
View File
@@ -12,14 +12,13 @@
import {makeRoomId} from "@app/core/state" import {makeRoomId} from "@app/core/state"
import { import {
VoiceState, VoiceState,
deriveVoiceParticipants,
cancelJoinVoiceRoom,
currentVoiceRoom, currentVoiceRoom,
voiceState,
isParticipantSpeaking, isParticipantSpeaking,
participantKey, participantKey,
voiceState,
type VoiceParticipant, type VoiceParticipant,
} from "@app/voice" } from "@app/call/stores"
import {cancelJoinVoiceRoom, deriveVoiceParticipants} from "@app/call/voice"
interface Props { interface Props {
url: string url: string
@@ -64,7 +63,7 @@
{replaceState} {replaceState}
{notification} {notification}
onclick={handleClick} onclick={handleClick}
class={cx("!items-start", isActive && "!bg-base-100 !text-base-content")}> class={cx("items-start!", isActive && "bg-base-100! text-base-content!")}>
<div class="flex w-full min-w-0 flex-col gap-2"> <div class="flex w-full min-w-0 flex-col gap-2">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
{#if isJoining} {#if isJoining}
@@ -14,7 +14,7 @@
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import {AbortError, TimeoutError} from "$lib/util" import {AbortError, TimeoutError} from "$lib/util"
import {displayRoom} from "@app/core/state" import {displayRoom} from "@app/core/state"
import {joinVoiceRoom} from "@app/voice" import {joinVoiceRoom} from "@app/call/voice"
import {popModal} from "@app/util/modal" import {popModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
+137 -24
View File
@@ -1,15 +1,20 @@
<script lang="ts"> <script lang="ts">
import {readable} from "svelte/store" import {readable} from "svelte/store"
import {fly} from "svelte/transition" import {fade, fly} from "svelte/transition"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {page} from "$app/stores" import {page} from "$app/stores"
import cx from "classnames"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import Microphone from "@assets/icons/microphone.svg?dataurl" import Microphone from "@assets/icons/microphone.svg?dataurl"
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl" import Videocamera from "@assets/icons/videocamera.svg?dataurl"
import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl"
import Monitor from "@assets/icons/monitor.svg?dataurl"
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl" import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl" import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl" import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Settings from "@assets/icons/settings.svg?dataurl" import Settings from "@assets/icons/settings.svg?dataurl"
import {Capacitor} from "@capacitor/core"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte" import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
@@ -23,16 +28,23 @@
type Room, type Room,
} from "@app/core/state" } from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {notifications} from "@app/util/notifications"
import {makeRoomPath} from "@app/util/routes" import {makeRoomPath} from "@app/util/routes"
import {
VideoCallLayout,
isDesktopLayout,
toggleCamera,
toggleScreenShare,
videoCallLayout,
} from "@app/call/video"
import { import {
VoiceState, VoiceState,
currentVoiceSession, currentVoiceSession,
currentVoiceRoom, currentVoiceRoom,
voiceState, voiceState,
leaveVoiceRoom, isLocalSpeaking,
toggleMute, } from "@app/call/stores"
cancelJoinVoiceRoom, import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice"
} from "@app/voice"
const {relay, h} = $derived($page.params) const {relay, h} = $derived($page.params)
const url = $derived(relay ? decodeRelay(relay) : undefined) const url = $derived(relay ? decodeRelay(relay) : undefined)
@@ -41,6 +53,14 @@
) )
const routeDisplayedRoom = $derived($displayedRoomStore) const routeDisplayedRoom = $derived($displayedRoomStore)
const isViewingCurrentVoiceRoom = $derived(
$currentVoiceRoom !== undefined &&
url !== undefined &&
typeof h === "string" &&
$currentVoiceRoom.url === url &&
$currentVoiceRoom.h === h,
)
const targetRoom = $derived.by((): Room | undefined => { const targetRoom = $derived.by((): Room | undefined => {
if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) { if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) {
return $currentVoiceRoom return $currentVoiceRoom
@@ -66,9 +86,45 @@
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h}) pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
} }
const openAudioSettings = () => { const goToRoom = () => {
if (!targetRoom) return
const path = makeRoomPath(targetRoom.url, targetRoom.h)
if ($page.url.pathname !== path) {
void goto(path)
}
}
const openCallSettings = () => {
pushModal(VoiceCallAudioSettingsDialog) pushModal(VoiceCallAudioSettingsDialog)
} }
const showChatButton = $derived($voiceState === VoiceState.Connected && isViewingCurrentVoiceRoom)
const isChatPanelActive = $derived(
showChatButton &&
(isDesktopLayout.current
? $videoCallLayout === VideoCallLayout.Split
: $videoCallLayout === VideoCallLayout.Chat),
)
const onChatToggle = () => {
if (!showChatButton) return
if (isDesktopLayout.current) {
videoCallLayout.update(p =>
p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split,
)
} else {
videoCallLayout.update(p =>
p === VideoCallLayout.Video ? VideoCallLayout.Chat : VideoCallLayout.Video,
)
}
}
const chatUnread = $derived(
targetRoom !== undefined && $notifications.has(makeRoomPath(targetRoom.url, targetRoom.h)),
)
const mediaToggleClass = "center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
</script> </script>
{#if targetRoom} {#if targetRoom}
@@ -76,19 +132,47 @@
in:fly={{y: 60, duration: 350}} in:fly={{y: 60, duration: 350}}
out:fly={{y: 60, duration: 250}} out:fly={{y: 60, duration: 250}}
class="flex flex-col gap-2 rounded-box bg-base-100 p-3"> class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
<div class="flex flex-col gap-0.5"> <div class="flex items-start justify-between gap-2">
{#if $voiceState === VoiceState.Joining} <button
<span class="text-sm font-semibold text-warning">Joining...</span> type="button"
{:else if $voiceState === VoiceState.Connected} class="min-w-0 flex-1 rounded-lg px-1 py-0.5 text-left outline-none hover:bg-base-200/60 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-base-100"
<span class="text-sm font-semibold text-success">Voice Connected</span> onclick={goToRoom}
{:else} aria-label="Open room {roomName}">
<span class="text-sm font-semibold text-neutral-content">Disconnected</span> <div class="flex flex-col gap-0.5">
{#if $voiceState === VoiceState.Joining}
<span class="text-sm font-semibold text-warning">Joining...</span>
{:else if $voiceState === VoiceState.Connected}
<span class="text-sm font-semibold text-success">Voice Connected</span>
{:else}
<span class="text-sm font-semibold text-neutral-content">Disconnected</span>
{/if}
<span class="ellipsize text-xs opacity-70">
{roomName} / {spaceName}
</span>
</div>
</button>
{#if showChatButton}
<Button
data-tip="Toggle Chat"
class={cx(
mediaToggleClass,
"relative shrink-0 overflow-visible",
isChatPanelActive && "text-primary",
)}
onclick={onChatToggle}>
<span class="relative inline-flex">
<Icon icon={ChatRound} size={4} />
{#if chatUnread}
<span
transition:fade={{duration: 150}}
class="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary ring-2 ring-base-100"
aria-hidden="true"></span>
{/if}
</span>
</Button>
{/if} {/if}
<span class="ellipsize text-xs opacity-70">
{roomName} / {spaceName}
</span>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex flex-wrap items-center gap-2">
{#if $voiceState === VoiceState.Joining} {#if $voiceState === VoiceState.Joining}
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm"></span>
<Button <Button
@@ -100,16 +184,45 @@
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession} {:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
<Button <Button
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"} data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted class={cx(
? 'btn-error' mediaToggleClass,
: 'btn-ghost'}" "overflow-visible",
!$currentVoiceSession.muted && $isLocalSpeaking && "text-primary",
$currentVoiceSession.muted &&
"text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
)}
onclick={toggleMute}> onclick={toggleMute}>
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} /> <span class="relative inline-flex items-center justify-center overflow-visible">
<Icon icon={Microphone} size={4} />
{#if $currentVoiceSession.muted}
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center overflow-visible"
aria-hidden="true">
<span
class="h-[1.3px] w-[150%] max-w-none shrink-0 -rotate-45 rounded-full bg-current"
></span>
</span>
{/if}
</span>
</Button> </Button>
<Button <Button
data-tip="Audio settings" data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"}
class={cx(mediaToggleClass, $currentVoiceSession.cameraOn && "text-primary")}
onclick={toggleCamera}>
<Icon icon={$currentVoiceSession.cameraOn ? VideocameraRecord : Videocamera} size={4} />
</Button>
{#if !Capacitor.isNativePlatform()}
<Button
data-tip={$currentVoiceSession.screenShareOn ? "Stop sharing" : "Share screen"}
class={cx(mediaToggleClass, $currentVoiceSession.screenShareOn && "text-primary")}
onclick={toggleScreenShare}>
<Icon icon={Monitor} size={4} />
</Button>
{/if}
<Button
data-tip="Call settings"
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost" class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
onclick={openAudioSettings}> onclick={openCallSettings}>
<Icon icon={Settings} size={4} /> <Icon icon={Settings} size={4} />
</Button> </Button>
<Button <Button
+1 -1
View File
@@ -70,7 +70,7 @@
Amount (satoshis) Amount (satoshis)
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="flex flex-grow justify-end"> <div class="flex grow justify-end">
<label class="input input-bordered flex items-center gap-2"> <label class="input input-bordered flex items-center gap-2">
<Icon icon={Bolt} /> <Icon icon={Bolt} />
<input <input
+1 -1
View File
@@ -80,7 +80,7 @@
Amount (satoshis) Amount (satoshis)
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="flex flex-grow justify-end"> <div class="flex grow justify-end">
<label class="input input-bordered flex items-center gap-2"> <label class="input input-bordered flex items-center gap-2">
<Icon icon={Bolt} /> <Icon icon={Bolt} />
<input bind:value={sats} type="number" class="w-14" placeholder="0" /> <input bind:value={sats} type="number" class="w-14" placeholder="0" />
+2 -2
View File
@@ -1,7 +1,7 @@
<style> <style>
.wot-background { .wot-background {
fill: transparent; fill: transparent;
stroke: var(--base-content); stroke: var(--color-base-content);
opacity: 30%; opacity: 30%;
} }
@@ -32,7 +32,7 @@
const normalizedScore = $derived(clamp([0, max], $score) / max) const normalizedScore = $derived(clamp([0, max], $score) / max)
const dashOffset = $derived(100 - 44 * normalizedScore) const dashOffset = $derived(100 - 44 * normalizedScore)
const style = $derived(`transform: rotate(${135 - normalizedScore * 180}deg)`) const style = $derived(`transform: rotate(${135 - normalizedScore * 180}deg)`)
const stroke = $derived(active ? "var(--primary)" : "var(--base-content)") const stroke = $derived(active ? "var(--color-primary)" : "var(--color-base-content)")
</script> </script>
<div class="relative h-[14px] w-[14px]"> <div class="relative h-[14px] w-[14px]">
+5 -5
View File
@@ -118,26 +118,26 @@
<ModalBody> <ModalBody>
<ModalHeader> <ModalHeader>
<ModalTitle>Send a Zap</ModalTitle> <ModalTitle>Send a Zap</ModalTitle>
<ModalSubtitle>To <ProfileLink {pubkey} class="!text-primary" /></ModalSubtitle> <ModalSubtitle>To <ProfileLink {pubkey} class="text-primary!" /></ModalSubtitle>
</ModalHeader> </ModalHeader>
<FieldInline class="!grid-cols-3"> <FieldInline class="grid-cols-3!">
{#snippet label()} {#snippet label()}
Emoji Reaction Emoji Reaction
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="flex flex-grow items-center justify-end gap-4"> <div class="flex grow items-center justify-end gap-4">
<EmojiButton {onEmoji} class="btn btn-neutral"> <EmojiButton {onEmoji} class="btn btn-neutral">
{content} {content}
</EmojiButton> </EmojiButton>
</div> </div>
{/snippet} {/snippet}
</FieldInline> </FieldInline>
<FieldInline class="!grid-cols-3"> <FieldInline class="grid-cols-3!">
{#snippet label()} {#snippet label()}
Amount Amount
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="flex flex-grow justify-end"> <div class="flex grow justify-end">
<label class="input input-bordered flex items-center gap-2"> <label class="input input-bordered flex items-center gap-2">
<Icon icon={Bolt} /> <Icon icon={Bolt} />
<input bind:value={amount} type="number" class="w-24" /> <input bind:value={amount} type="number" class="w-24" />
+6 -6
View File
@@ -147,7 +147,7 @@
<ModalBody> <ModalBody>
<ModalHeader> <ModalHeader>
<ModalTitle>Send a Zap</ModalTitle> <ModalTitle>Send a Zap</ModalTitle>
<ModalSubtitle>To <ProfileLink {pubkey} class="!text-primary" /></ModalSubtitle> <ModalSubtitle>To <ProfileLink {pubkey} class="text-primary!" /></ModalSubtitle>
</ModalHeader> </ModalHeader>
{#if invoice} {#if invoice}
@@ -158,30 +158,30 @@
</p> </p>
</div> </div>
<label class="input input-bordered flex w-full items-center justify-between gap-2"> <label class="input input-bordered flex w-full items-center justify-between gap-2">
<input readonly class="ellipsize flex-grow" value={invoice} /> <input readonly class="ellipsize grow" value={invoice} />
<Button class="flex items-center" onclick={copyInvoice}> <Button class="flex items-center" onclick={copyInvoice}>
<Icon icon={Copy} /> <Icon icon={Copy} />
</Button> </Button>
</label> </label>
{:else} {:else}
<FieldInline class="!grid-cols-3"> <FieldInline class="grid-cols-3!">
{#snippet label()} {#snippet label()}
Emoji Reaction Emoji Reaction
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="flex flex-grow items-center justify-end gap-4"> <div class="flex grow items-center justify-end gap-4">
<EmojiButton {onEmoji} class="btn btn-neutral"> <EmojiButton {onEmoji} class="btn btn-neutral">
{content} {content}
</EmojiButton> </EmojiButton>
</div> </div>
{/snippet} {/snippet}
</FieldInline> </FieldInline>
<FieldInline class="!grid-cols-3"> <FieldInline class="grid-cols-3!">
{#snippet label()} {#snippet label()}
Amount Amount
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="flex flex-grow justify-end"> <div class="flex grow justify-end">
<label class="input input-bordered flex items-center gap-2"> <label class="input input-bordered flex items-center gap-2">
<Icon icon={Bolt} /> <Icon icon={Bolt} />
<input bind:value={amount} type="number" class="w-24" /> <input bind:value={amount} type="number" class="w-24" />
+40 -26
View File
@@ -18,10 +18,10 @@ import {
import {Nip01Signer} from "@welshman/signer" import {Nip01Signer} from "@welshman/signer"
import type {UploadTask} from "@welshman/editor" import type {UploadTask} from "@welshman/editor"
import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util" import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util"
import {PollResponse} from "nostr-tools/kinds"
import { import {
DELETE, DELETE,
REPORT, REPORT,
MESSAGE,
PROFILE, PROFILE,
MESSAGING_RELAYS, MESSAGING_RELAYS,
RELAYS, RELAYS,
@@ -32,6 +32,7 @@ import {
ROOMS, ROOMS,
COMMENT, COMMENT,
APP_DATA, APP_DATA,
POLL_RESPONSE,
isSignedEvent, isSignedEvent,
makeEvent, makeEvent,
normalizeRelayUrl, normalizeRelayUrl,
@@ -52,7 +53,6 @@ import {
isPublishedProfile, isPublishedProfile,
editProfile, editProfile,
createProfile, createProfile,
uniqTags,
ManagementMethod, ManagementMethod,
} from "@welshman/util" } from "@welshman/util"
import {Pool, AuthStatus, SocketStatus} from "@welshman/net" import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
@@ -84,6 +84,7 @@ import {
SETTINGS, SETTINGS,
PROTECTED, PROTECTED,
INDEXER_RELAYS, INDEXER_RELAYS,
DEFAULT_RELAYS,
DEFAULT_BLOSSOM_SERVERS, DEFAULT_BLOSSOM_SERVERS,
userSpaceUrls, userSpaceUrls,
userSettingsValues, userSettingsValues,
@@ -122,6 +123,34 @@ export const prependParent = (
return {content, tags} return {content, tags}
} }
export const publishRoomQuote = ({
url,
h,
parent,
protect,
delay,
}: {
url: string
h?: string
parent: TrustedEvent
protect: boolean
delay?: number
}) => {
const tags: string[][] = []
if (h) {
tags.push(["h", h])
}
if (protect) {
tags.push(PROTECTED)
}
const event = makeEvent(MESSAGE, prependParent(parent, {content: "", tags}, url))
return publishThunk({relays: [url], event, delay})
}
// Synchronization // Synchronization
export const broadcastUserData = async (relays: string[]) => { export const broadcastUserData = async (relays: string[]) => {
@@ -360,7 +389,7 @@ export type PollResponseParams = {
} }
export const makePollResponse = ({event, selectedIds}: PollResponseParams) => export const makePollResponse = ({event, selectedIds}: PollResponseParams) =>
makeEvent(PollResponse, { makeEvent(POLL_RESPONSE, {
content: "", content: "",
tags: [["e", event.id], ...selectedIds.map(selectedId => ["response", selectedId])], tags: [["e", event.id], ...selectedIds.map(selectedId => ["response", selectedId])],
}) })
@@ -695,34 +724,18 @@ export const uploadFile = async (file: File, options: UploadFileOptions = {}) =>
// Update Profile // Update Profile
export const initProfile = (profile: Profile) => { export const initProfile = (profile: Profile) => {
const template = createProfile(profile) const event = makeEvent(PROFILE, createProfile(profile))
// Start out protected by default return publishThunk({event, relays: DEFAULT_RELAYS})
template.tags.push(PROTECTED)
const event = makeEvent(PROFILE, template)
// Don't publish anywhere yet, wait until they join a space
return publishThunk({event, relays: []})
} }
export const updateProfile = ({ export const updateProfile = ({profile}: {profile: Profile}) => {
profile,
shouldBroadcast = !getTag(PROTECTED, profile.event?.tags || []),
}: {
profile: Profile
shouldBroadcast?: boolean
}) => {
const router = Router.get() const router = Router.get()
const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile) const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
const scenarios = [router.FromRelays(get(userSpaceUrls))] const scenarios = [router.FromRelays(get(userSpaceUrls)), router.FromUser(), router.Index()]
if (shouldBroadcast) { // Remove protected tag, we used to add it
scenarios.push(router.FromUser(), router.Index()) template.tags = template.tags.filter(nthNe(0, "-"))
template.tags = template.tags.filter(nthNe(0, "-"))
} else {
template.tags = uniqTags([...template.tags, PROTECTED])
}
const event = makeEvent(template.kind, template) const event = makeEvent(template.kind, template)
const relays = router.merge(scenarios).getUrls() const relays = router.merge(scenarios).getUrls()
@@ -737,9 +750,10 @@ export const addSpaceMembers = async (
pubkeys: string[], pubkeys: string[],
): Promise<string | undefined> => { ): Promise<string | undefined> => {
const spaceMembers = get(deriveSpaceMembers(url)) const spaceMembers = get(deriveSpaceMembers(url))
const results = await Promise.all( const results = await Promise.all(
pubkeys pubkeys
.filter(pubkey => !spaceMembers.includes(pubkey)) .filter(pubkey => !spaceMembers || !spaceMembers.includes(pubkey))
.map(pubkey => .map(pubkey =>
manageRelay(url, { manageRelay(url, {
method: ManagementMethod.AllowPubkey, method: ManagementMethod.AllowPubkey,
+106 -69
View File
@@ -1,5 +1,6 @@
import {get, writable} from "svelte/store" import {writable} from "svelte/store"
import { import {
batch,
call, call,
uniq, uniq,
int, int,
@@ -25,7 +26,8 @@ import {
sortEventsDesc, sortEventsDesc,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent, Filter, List} from "@welshman/util" import type {TrustedEvent, Filter, List} from "@welshman/util"
import {load, request} from "@welshman/net" import {load, request, mergeRepositoryUpdates} from "@welshman/net"
import type {RepositoryUpdate} from "@welshman/net"
import {repository, loadRelay, tracker} from "@welshman/app" import {repository, loadRelay, tracker} from "@welshman/app"
import {createScroller} from "@lib/html" import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util" import {daysBetween} from "@lib/util"
@@ -56,57 +58,75 @@ export const makeFeed = ({
let backwardWindow = [at - interval, at] let backwardWindow = [at - interval, at]
let forwardWindow = [at, at + interval] let forwardWindow = [at, at + interval]
const insertEvent = (event: TrustedEvent) => { const insertIntoBuffer = (event: TrustedEvent) => {
let handled = false for (let i = 0; i < buffer.length; i++) {
if (buffer[i].created_at < event.created_at) {
buffer.splice(i, 0, event)
return
}
}
buffer.push(event)
}
if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) { // Batch-insert events into the visible store with a single update
const $events = get(events) const insertEvents = (newEvents: TrustedEvent[]) => {
const visible: TrustedEvent[] = []
for (let i = 0; i < $events.length; i++) { for (const event of newEvents) {
if ($events[i].created_at > event.created_at) { if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) {
events.set(insertAt(i, event, $events)) visible.push(event)
handled = true } else {
break insertIntoBuffer(event)
}
}
if (visible.length > 0) {
events.update($events => {
for (const event of visible) {
let inserted = false
for (let i = 0; i < $events.length; i++) {
if ($events[i].created_at > event.created_at) {
$events = insertAt(i, event, $events)
inserted = true
break
}
}
if (!inserted) {
$events = [...$events, event]
}
} }
} return $events
})
if (!handled) {
events.set([...$events, event])
}
} else {
for (let i = 0; i < buffer.length; i++) {
if (buffer[i].created_at > event.created_at) {
buffer.splice(i, 0, event)
handled = true
break
}
}
if (!handled) {
buffer.push(event)
}
} }
} }
const unsubscribers = [ const unsubscribers = [
on(repository, "update", ({added, removed}) => { on(
if (removed.size > 0) { repository,
buffer = buffer.filter(e => !removed.has(e.id)) "update",
events.update($events => $events.filter(e => !removed.has(e.id))) batch(150, (updates: RepositoryUpdate[]) => {
} const {added, removed} = mergeRepositoryUpdates(updates)
for (const event of added) { if (removed.size > 0) {
if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) { buffer = buffer.filter(e => !removed.has(e.id))
insertEvent(event) events.update($events => $events.filter(e => !removed.has(e.id)))
} }
}
}), const matching = added.filter(
event => matchFilters(filters, event) && tracker.getRelays(event.id).has(url),
)
if (matching.length > 0) {
insertEvents(matching)
}
}),
),
on(tracker, "add", (id: string, trackerUrl: string) => { on(tracker, "add", (id: string, trackerUrl: string) => {
if (trackerUrl === url) { if (trackerUrl === url) {
const event = repository.getEvent(id) const event = repository.getEvent(id)
if (event && matchFilters(filters, event)) { if (event && matchFilters(filters, event)) {
insertEvent(event) insertEvents([event])
} }
} }
}), }),
@@ -132,17 +152,15 @@ export const makeFeed = ({
element, element,
delay: 300, delay: 300,
threshold: 5000, threshold: 5000,
onScroll: () => { onScroll: async () => {
const [since, until] = backwardWindow const [since, until] = backwardWindow
backwardWindow = [since - interval, since] backwardWindow = [since - interval, since]
for (const event of buffer.splice(0, 30)) { insertEvents(buffer.splice(0, 30))
insertEvent(event)
}
if (until > now() - int(2, YEAR)) { if (until > now() - int(2, YEAR)) {
loadTimeframe(since, until) await loadTimeframe(since, until)
} else if (!buffer.some(e => e.created_at < at)) { } else if (!buffer.some(e => e.created_at < at)) {
backwardScroller.stop() backwardScroller.stop()
onBackwardExhausted?.() onBackwardExhausted?.()
@@ -155,17 +173,15 @@ export const makeFeed = ({
reverse: true, reverse: true,
delay: 300, delay: 300,
threshold: 5000, threshold: 5000,
onScroll: () => { onScroll: async () => {
const [since, until] = forwardWindow const [since, until] = forwardWindow
forwardWindow = [until, until + interval] forwardWindow = [until, until + interval]
for (const event of buffer.splice(0, 30)) { insertEvents(buffer.splice(0, 30))
insertEvent(event)
}
if (until < now()) { if (until < now()) {
loadTimeframe(since, until) await loadTimeframe(since, until)
} else if (!buffer.some(e => e.created_at > at)) { } else if (!buffer.some(e => e.created_at > at)) {
forwardScroller.stop() forwardScroller.stop()
onForwardExhausted?.() onForwardExhausted?.()
@@ -208,40 +224,61 @@ export const makeCalendarFeed = ({
const events = writable(sortBy(getStart, getEventsForUrl(url, filters))) const events = writable(sortBy(getStart, getEventsForUrl(url, filters)))
const insertEvent = (event: TrustedEvent) => { // Batch-insert calendar events into the store with a single update
const start = getStart(event) const insertEvents = (newEvents: TrustedEvent[]) => {
const address = getAddress(event) const valid = newEvents.filter(e => !isNaN(getStart(e)) && !isNaN(getEnd(e)))
if (valid.length === 0) return
if (isNaN(start) || isNaN(getEnd(event))) return
events.update($events => { events.update($events => {
for (let i = 0; i < $events.length; i++) { for (const event of valid) {
if ($events[i].id === event.id) return $events const start = getStart(event)
if (getStart($events[i]) > start) return insertAt(i, event, $events) const address = getAddress(event)
}
return [...$events.filter(e => getAddress(e) !== address), event] let handled = false
for (let i = 0; i < $events.length; i++) {
if ($events[i].id === event.id) {
handled = true
break
}
if (getStart($events[i]) > start) {
$events = insertAt(i, event, $events)
handled = true
break
}
}
if (!handled) {
$events = [...$events.filter(e => getAddress(e) !== address), event]
}
}
return $events
}) })
} }
const unsubscribers = [ const unsubscribers = [
on(repository, "update", ({added, removed}) => { on(
if (removed.size > 0) { repository,
events.update($events => $events.filter(e => !removed.has(e.id))) "update",
} batch(150, (updates: RepositoryUpdate[]) => {
const {added, removed} = mergeRepositoryUpdates(updates)
for (const event of added) { if (removed.size > 0) {
if (matchFilters(filters, event)) { events.update($events => $events.filter(e => !removed.has(e.id)))
insertEvent(event)
} }
}
}), const matching = added.filter(event => matchFilters(filters, event))
if (matching.length > 0) {
insertEvents(matching)
}
}),
),
on(tracker, "add", (id: string, trackerUrl: string) => { on(tracker, "add", (id: string, trackerUrl: string) => {
if (trackerUrl === url) { if (trackerUrl === url) {
const event = repository.getEvent(id) const event = repository.getEvent(id)
if (event && matchFilters(filters, event)) { if (event && matchFilters(filters, event)) {
insertEvent(event) insertEvents([event])
} }
} }
}), }),

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