Compare commits

..

1 Commits

115 changed files with 1470 additions and 3153 deletions
-1
View File
@@ -19,6 +19,5 @@ VITE_DEFAULT_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_THUMBNAIL_URL=
VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN=
+1 -1
View File
@@ -13,4 +13,4 @@ ios/App/Pods/
android/capacitor-cordova-android-plugins
android/app/src/androidTest
android/app/src/test
node_modules
-3
View File
@@ -28,7 +28,6 @@ node_modules/
.pnpm-store/
build/
.svelte-kit/
.next/
# Rust/Tauri
*target/
@@ -70,9 +69,7 @@ GoogleService-Info.plist
.roo
.idea/
.vscode/
.claude/
# OS generated
.DS_Store
Thumbs.db
package-lock.json
-3
View File
@@ -44,7 +44,4 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<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>
@@ -7,7 +7,6 @@ import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import com.getcapacitor.JSObject
@@ -77,7 +76,6 @@ class AndroidPushFallbackPlugin : Plugin() {
val immediate = OneTimeWorkRequest.Builder(AndroidPushFallbackWorker::class.java)
.setConstraints(constraints)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
workManager.enqueueUniquePeriodicWork(
@@ -43,7 +43,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
private const val TAG = "PushFallback"
private const val CHANNEL_ID = "flotilla_fallback"
private const val CURSOR_PREFIX = "androidPushFallback.cursor."
private const val SOCKET_TIMEOUT_SECONDS = 30L
private const val SOCKET_TIMEOUT_SECONDS = 20L
private const val REJECTED = "__REJECTED__"
private const val KIND_RELAY_AUTH = 22242
private const val KIND_NIP46_RPC = 24133
@@ -72,8 +72,6 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
}
override fun doWork(): Result {
Log.i(TAG, "doWork() started")
if (isAppInForeground()) {
return Result.success()
}
@@ -90,7 +88,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
val activeSince = state.optLong("activeSince", 0L)
val seen = mutableSetOf<String>()
val newEvents = mutableListOf<Pair<String, JSONObject>>()
var latestPair: Pair<String, JSONObject>? = null
for (sub in subscriptions) {
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
@@ -104,19 +102,23 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
for (event in result.events) {
val id = event.optString("id", "")
if (id.isNotEmpty() && seen.add(id)) {
newEvents.add(Pair(sub.relay, event))
val createdAt = event.optLong("created_at", 0L)
if (latestPair == null || createdAt > latestPair!!.second.optLong("created_at", 0L)) {
latestPair = Pair(sub.relay, event)
}
}
}
}
for ((relay, event) in newEvents) {
if (latestPair != null) {
val (relay, event) = latestPair!!
postNotification(relay, event)
}
return Result.success()
} catch (e: Exception) {
Log.e(TAG, "Worker failed", e)
return Result.retry()
return Result.success()
} finally {
pool.closeAll()
client.dispatcher.executorService.shutdown()
@@ -212,8 +214,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
.setContentIntent(pendingIntent)
.build()
val notificationId = id.hashCode().let { if (it == 0) 1 else it }
NotificationManagerCompat.from(context).notify(notificationId, notification)
NotificationManagerCompat.from(context).notify(1, notification)
}
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
+1 -3
View File
@@ -24,10 +24,8 @@
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Flotilla uses the camera when you enable it in a voice room.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Flotilla uses the microphone when you enable it in a voice room.</string>
<string>Flotilla uses the microphone for voice chat in rooms.</string>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
+4 -5
View File
@@ -22,7 +22,6 @@
"@eslint/js": "^9.39.2",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@tailwindcss/postcss": "^4.2.2",
"@tauri-apps/cli": "^2.9.6",
"@types/eslint": "^9.6.1",
"autoprefixer": "^10.4.23",
@@ -36,7 +35,7 @@
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.48.0",
"svelte-check": "^4.3.5",
"tailwindcss": "^4.2.2",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1",
"vite": "^5.4.21"
@@ -59,7 +58,7 @@
"@getalby/lightning-tools": "^6.1.0",
"@getalby/sdk": "^5.1.2",
"@noble/curves": "^1.9.7",
"@pomade/core": "^0.2.3",
"@pomade/core": "^0.2.2",
"@poppanator/sveltekit-svg": "^4.2.1",
"@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.27.2",
@@ -78,7 +77,7 @@
"@welshman/store": "^0.8.12",
"@welshman/util": "^0.8.12",
"compressorjs-next": "^1.1.2",
"daisyui": "^5.5.19",
"daisyui": "^4.12.24",
"date-picker-svelte": "^2.17.0",
"dotenv": "^16.6.1",
"emoji-picker-element": "^1.28.1",
@@ -88,7 +87,7 @@
"livekit-client": "^2.17.2",
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
"nostr-tools": "^2.19.4",
"prettier-plugin-tailwindcss": "^0.7.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"throttle-debounce": "^5.0.2",
+378 -397
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,5 +1,6 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
tailwindcss: {},
autoprefixer: {},
},
}
+240 -248
View File
@@ -1,25 +1,45 @@
@import "tailwindcss";
@import "@welshman/editor/index.css";
@config "../tailwind.config.js";
@tailwind base;
@tailwind components;
@tailwind utilities;
@utility pt-sai {
padding-top: var(--sait);
/* Fonts */
@font-face {
font-family: "Satoshis";
font-style: normal;
font-weight: 400;
src:
local(""),
url("/fonts/Satoshi Symbol.ttf") format("truetype");
}
@utility pr-sai {
padding-right: var(--sair);
@font-face {
font-family: "Lato";
font-style: normal;
font-weight: 400;
src:
local(""),
url("/fonts/Lato-Regular.ttf") format("truetype");
}
@utility pb-sai {
padding-bottom: var(--saib);
@font-face {
font-family: "Lato";
font-style: bold;
font-weight: 600;
src:
local(""),
url("/fonts/Lato-Bold.ttf") format("truetype");
}
@utility pl-sai {
padding-left: var(--sail);
}
@utility px-sai {
@apply pl-sai pr-sai;
@font-face {
font-family: "Lato";
font-style: italic;
font-weight: 400;
src:
local(""),
url("/fonts/Italic.ttf") format("truetype");
}
/* root */
@@ -32,224 +52,98 @@
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
}
@utility py-sai {
@apply pt-sai pb-sai;
[data-theme] {
@apply bg-base-300;
--base-100: oklch(var(--b1));
--base-200: oklch(var(--b2));
--base-300: oklch(var(--b3));
--base-content: oklch(var(--bc));
--primary: oklch(var(--p));
--primary-content: oklch(var(--pc));
--secondary: oklch(var(--s));
--secondary-content: oklch(var(--sc));
--neutral: oklch(var(--n));
--neutral-content: oklch(var(--nc));
}
@utility p-sai {
@apply py-sai px-sai;
.mobile [data-tip]::before {
display: none !important;
}
@utility mt-sai {
margin-top: var(--sait);
}
/* safe area insets */
@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");
@layer components {
.pt-sai {
padding-top: var(--sait);
}
@font-face {
font-family: "Lato";
font-style: normal;
font-weight: 400;
src:
local(""),
url("/fonts/Lato-Regular.ttf") format("truetype");
.pr-sai {
padding-right: var(--sair);
}
@font-face {
font-family: "Lato";
font-style: bold;
font-weight: 600;
src:
local(""),
url("/fonts/Lato-Bold.ttf") format("truetype");
.pb-sai {
padding-bottom: var(--saib);
}
@font-face {
font-family: "Lato";
font-style: italic;
font-weight: 400;
src:
local(""),
url("/fonts/Italic.ttf") format("truetype");
.pl-sai {
padding-left: var(--sail);
}
/* root */
: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));
.px-sai {
@apply pl-sai pr-sai;
}
[data-theme] {
@apply bg-base-300;
.py-sai {
@apply pt-sai pb-sai;
}
.mobile [data-tip]::before {
display: none !important;
.p-sai {
@apply py-sai px-sai;
}
/* safe area insets */
.mt-sai {
margin-top: var(--sait);
}
.mr-sai {
margin-right: var(--sair);
}
.mb-sai {
margin-bottom: var(--saib);
}
.ml-sai {
margin-left: var(--sail);
}
.mx-sai {
@apply ml-sai mr-sai;
}
.my-sai {
@apply mt-sai mb-sai;
}
.m-sai {
@apply my-sai mx-sai;
}
.top-sai {
top: var(--sait);
}
.right-sai {
right: var(--sair);
}
.bottom-sai {
bottom: var(--saib);
}
.left-sai {
left: var(--sail);
}
}
/* utilities */
@@ -271,18 +165,110 @@
@apply bg-base-300 text-base-content transition-colors;
}
.card2 {
@apply rounded-box p-4 text-base-content sm:p-6;
}
.card2.card2-sm {
@apply text-base-content p-2 sm:p-4;
@apply p-2 text-base-content sm:p-4;
}
.column {
@apply flex flex-col;
}
.center {
@apply flex items-center justify-center;
}
.row-2 {
@apply flex items-center gap-2;
}
.row-3 {
@apply flex items-center gap-3;
}
.row-4 {
@apply flex items-center gap-4;
}
.col-2 {
@apply flex flex-col gap-2;
}
.col-3 {
@apply flex flex-col gap-3;
}
.col-4 {
@apply flex flex-col gap-4;
}
.col-8 {
@apply flex flex-col gap-8;
}
.badge {
@apply justify-start overflow-hidden text-ellipsis whitespace-nowrap;
}
.ellipsize {
@apply overflow-hidden text-ellipsis;
}
[data-tip]::before {
@apply overflow-hidden text-ellipsis;
@apply ellipsize;
}
.content-padding-x {
@apply px-4 sm:px-8 md:px-12;
}
.content-padding-t {
@apply pt-4 sm:pt-8 md:pt-12;
}
.content-padding-b {
@apply pb-4 sm:pb-8 md:pb-12;
}
.content-padding-y {
@apply content-padding-t content-padding-b;
}
.content-sizing {
@apply m-auto w-full max-w-3xl;
}
.content {
@apply content-sizing content-padding-x content-padding-y;
}
.heading {
@apply text-center text-2xl;
}
.subheading {
@apply text-center text-xl;
}
.superheading {
@apply text-center text-4xl;
}
.link {
@apply cursor-pointer text-primary underline;
}
.input input::placeholder {
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 */
.input-editor,
@@ -292,21 +278,21 @@
}
.tiptap {
--tiptap-object-bg: var(--color-neutral);
--tiptap-object-fg: var(--color-neutral-content);
--tiptap-active-bg: var(--color-primary);
--tiptap-active-fg: var(--color-primary-content);
--tiptap-object-bg: var(--neutral);
--tiptap-object-fg: var(--neutral-content);
--tiptap-active-bg: var(--primary);
--tiptap-active-fg: var(--primary-content);
}
.tiptap-suggestions {
--tiptap-object-bg: var(--color-base-100);
--tiptap-object-fg: var(--color-base-content);
--tiptap-active-bg: var(--color-base-300);
--tiptap-active-fg: var(--color-base-content);
--tiptap-object-bg: var(--base-100);
--tiptap-object-fg: var(--base-content);
--tiptap-active-bg: var(--base-300);
--tiptap-active-fg: var(--base-content);
}
.tiptap-suggestions__item {
@apply border-base-100 border-l-2 border-solid;
@apply border-l-2 border-solid border-base-100;
}
.tiptap-suggestions__selected {
@@ -326,13 +312,13 @@
}
.note-editor .tiptap {
--tiptap-object-bg: var(--color-base-200);
@apply input rounded-box block h-auto min-h-32 w-full p-[.65rem] pb-6;
--tiptap-object-bg: var(--base-200);
@apply input input-bordered h-auto min-h-32 rounded-box p-[.65rem] pb-6;
}
.input-editor .tiptap {
--tiptap-object-bg: var(--color-base-200);
@apply input h-auto p-[.65rem];
--tiptap-object-bg: var(--base-200);
@apply input input-bordered h-auto p-[.65rem];
}
/* link-content, based on tiptap */
@@ -344,8 +330,8 @@
white-space: nowrap;
border-radius: 3px;
padding: 0 0.25rem;
background-color: var(--color-base-100);
color: var(--color-base-content);
background-color: var(--base-100);
color: var(--base-content);
}
/* content rendered by welshman/content */
@@ -361,25 +347,25 @@
/* date input */
.picker {
--date-picker-foreground: var(--color-base-content);
--date-picker-background: var(--color-base-300);
--date-picker-highlight-border: var(--color-primary);
--date-picker-selected-color: var(--color-primary-content);
--date-picker-selected-background: var(--color-primary);
--date-picker-foreground: var(--base-content);
--date-picker-background: var(--base-300);
--date-picker-highlight-border: var(--primary);
--date-picker-selected-color: var(--primary-content);
--date-picker-selected-background: var(--primary);
}
.date-time-field {
@apply input rounded-lg px-0;
@apply input input-bordered rounded-lg px-0;
}
.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-target {
@apply z-tooltip pointer-events-none fixed inset-0;
@apply pointer-events-none fixed inset-0 z-tooltip;
}
.tippy-target > * {
@@ -393,15 +379,15 @@
/* emoji picker */
emoji-picker {
--background: var(--color-base-100);
--border-color: var(--color-base-100);
--background: var(--base-100);
--border-color: var(--base-100);
--border-radius: var(--rounded-box);
--button-active-background: var(--color-base-content);
--button-hover-background: var(--color-base-content);
--indicator-color: var(--color-base-content);
--input-border-color: var(--color-base-100);
--input-font-color: var(--color-base-content);
--outline-color: var(--color-base-100);
--button-active-background: var(--base-content);
--button-hover-background: var(--base-content);
--indicator-color: var(--base-content);
--input-border-color: var(--base-100);
--input-font-color: var(--base-content);
--outline-color: var(--base-100);
}
/* progress */
@@ -425,7 +411,7 @@ body.keyboard-open .hide-on-keyboard {
/* chat view */
.chat__compose {
@apply z-compose relative mb-14 shrink-0 md:mb-0;
@apply relative z-compose mb-14 flex-grow md:mb-0;
}
.chat__compose .chat__compose-inner {
@@ -433,5 +419,11 @@ body.keyboard-open .hide-on-keyboard {
}
.chat__scroll-down {
@apply pb-sai z-feature fixed right-4 bottom-28 md:bottom-16;
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
}
/* content visibility */
.cv {
content-visibility: auto;
}
-57
View File
@@ -1,57 +0,0 @@
import {Room as LiveKitRoom} from "livekit-client"
import {derived, writable} from "svelte/store"
import {type Room} from "@app/core/state"
export type VoiceSession = {
url: string
h: string
room: LiveKitRoom
muted: boolean
cameraOn: boolean
screenShareOn: boolean
}
export type Pubkey = string
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
export enum VoiceState {
Joining = "joining",
Connected = "connected",
Disconnected = "disconnected",
}
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
export const currentVoiceRoom = writable<Room | undefined>(undefined)
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
const pk = pubkeyFromLiveKitIdentity(identity)
return pk ? {pubkey: pk, identity} : {identity}
}
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
export const speakingParticipants = writable<VoiceParticipant[]>([])
export const isParticipantSpeaking = derived(
speakingParticipants,
$participants => (p: VoiceParticipant) =>
$participants.some(sp => participantKey(sp) === participantKey(p)),
)
export const isLocalSpeaking = derived(
[currentVoiceSession, speakingParticipants],
([$session, $speaking]) => {
if (!$session?.room) return false
const local = participantFromLiveKitIdentity($session.room.localParticipant.identity)
return $speaking.some(sp => participantKey(sp) === participantKey(local))
},
)
-99
View File
@@ -1,99 +0,0 @@
import {Track} from "livekit-client"
import {MediaQuery} from "svelte/reactivity"
import {derived, get, writable} from "svelte/store"
import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores"
import {pushToast} from "@app/util/toast"
export enum VideoCallLayout {
Chat = "chat",
Video = "video",
Split = "split",
}
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
export enum ViewportSize {
Desktop = "desktop",
Mobile = "mobile",
}
export const videoCallViewportSync = {
previousLayout: undefined as ViewportSize | undefined,
}
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
export const resetVideoCallLayout = () => {
videoCallViewportSync.previousLayout = undefined
videoCallLayout.set(VideoCallLayout.Chat)
}
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
export const toggleVideoPrimaryTile = (key: string) => {
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
}
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
const countLiveVisualFeeds = (session: VoiceSession): number => {
const room = session.room
let n = 0
const lp = room.localParticipant
if (session.cameraOn) {
const pub = lp.getTrackPublication(Track.Source.Camera)
if (pub?.track) n += 1
}
if (session.screenShareOn) {
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
if (pub?.track) n += 1
}
for (const rp of room.remoteParticipants.values()) {
for (const source of VISUAL_SOURCES) {
const pub = rp.getTrackPublication(source)
if (pub?.isSubscribed && pub.track) n += 1
}
}
return n
}
export const triggerVideoFeedCount = () => {
currentVoiceSession.update(s => (s ? {...s} : s))
}
export const videoTileCount = derived([currentVoiceSession, voiceState], ([$session, $state]) => {
if ($state !== VoiceState.Connected || !$session) return 0
return countLiveVisualFeeds($session)
})
export const toggleCamera = async () => {
const session = get(currentVoiceSession)
if (!session) return
const cameraOn = !session.cameraOn
try {
await session.room.localParticipant.setCameraEnabled(cameraOn)
currentVoiceSession.set({...session, cameraOn})
} catch {
pushToast({
theme: "error",
message: cameraOn ? "Could not access camera" : "Could not turn off camera",
})
}
}
export const toggleScreenShare = async () => {
const session = get(currentVoiceSession)
if (!session) return
const screenShareOn = !session.screenShareOn
try {
await session.room.localParticipant.setScreenShareEnabled(screenShareOn)
currentVoiceSession.set({...session, screenShareOn})
} catch {
pushToast({
theme: "error",
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
})
}
}
@@ -38,7 +38,7 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex grow flex-wrap justify-end gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} />
@@ -7,13 +7,12 @@
type Props = {
url: string
h?: string
shareToChat?: boolean
}
const {url, h, shareToChat = false}: Props = $props()
const {url, h}: Props = $props()
</script>
<CalendarEventForm {url} {h} {shareToChat}>
<CalendarEventForm {url} {h}>
{#snippet header()}
<ModalHeader>
<ModalTitle>Create an Event</ModalTitle>
+36 -80
View File
@@ -3,7 +3,7 @@
import {writable} from "svelte/store"
import {randomId, HOUR} from "@welshman/lib"
import {makeEvent, EVENT_TIME} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app"
import {publishThunk} from "@welshman/app"
import {preventDefault} from "@lib/html"
import {daysBetween} from "@lib/util"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
@@ -20,34 +20,24 @@
import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {pushToast} from "@app/util/toast"
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
type Values = {
d: string
title: string
content: string | object
location: string
start?: number
end?: number
}
import {canEnforceNip70} from "@app/core/commands"
type Props = {
url: string
h?: string
shareToChat?: boolean
header: Snippet
initialValues?: Values
initialValues?: {
d: string
title: string
content: string
location: string
start: number
end: number
}
}
let {url, h, shareToChat = false, header, initialValues}: Props = $props()
const draftKey = new DraftKey<Values>(`calendar:${url}:${h ?? ""}`)
if (!initialValues) {
initialValues = draftKey.get()
}
const {url, h, header, initialValues}: Props = $props()
const shouldProtect = canEnforceNip70(url)
@@ -58,7 +48,7 @@
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => {
if ($uploading || loading) return
if ($uploading) return
if (!title) {
return pushToast({
@@ -84,68 +74,38 @@
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = [
["d", d],
["d", initialValues?.d || randomId()],
["title", title],
["location", location],
["location", location || ""],
["start", start.toString()],
["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]),
...ed.storage.nostr.getEditorTags(),
]
loading = true
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 (await shouldProtect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
const event = makeEvent(EVENT_TIME, {content, tags})
pushToast({message: "Your event has been saved!"})
publishThunk({event, relays: [url]})
history.back()
}
let loading = $state(false)
const content = initialValues?.content || ""
const editor = makeEditor({url, submit, uploading, content})
const d = $state(initialValues?.d ?? randomId())
let title = $state(initialValues?.title ?? "")
let location = $state(initialValues?.location ?? "")
let title = $state(initialValues?.title || "")
let location = $state(initialValues?.location || "")
let start: number | undefined = $state(initialValues?.start)
let end: number | undefined = $state(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})
})
let endDirty = Boolean(initialValues?.end)
$effect(() => {
if (!endDirty && start) {
@@ -176,14 +136,10 @@
{#snippet input()}
<div
class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
<div class="input-editor grow overflow-hidden">
<div class="input-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="Add an image"
class="center btn tooltip"
onclick={selectFiles}
disabled={loading}>
<Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
@@ -222,12 +178,12 @@
</Field>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
<Spinner loading={$uploading || loading}>Save Event</Spinner>
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
<Spinner loading={$uploading}>Save Event</Spinner>
</Button>
</ModalFooter>
</Modal>
@@ -19,7 +19,7 @@
const end = $derived(parseInt(meta.end))
</script>
<div class="flex grow flex-wrap justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-between gap-2">
<p class="text-xl">{meta.title || meta.name}</p>
{#if !isNaN(start) && !isNaN(end)}
{@const startDateDisplay = formatTimestampAsDate(start)}
+1 -1
View File
@@ -23,7 +23,7 @@
{#if meta.location}
<span class="flex items-start gap-1">
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
<span class="wrap-break-word">{meta.location}</span>
<span class="break-words">{meta.location}</span>
</span>
{/if}
</div>
+1 -4
View File
@@ -55,7 +55,6 @@
import ThunkToast from "@app/components/ThunkToast.svelte"
import {userSettingsValues, deriveChat} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {DraftKey} from "@app/util/drafts"
import {makeDelete, prependParent} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
@@ -67,7 +66,6 @@
const {pubkeys, info}: Props = $props()
const chat = deriveChat(pubkeys)
const draftKey = new DraftKey<{content?: string | object}>(`dm:${$chat?.id}`)
const others = remove($pubkey!, pubkeys)
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
@@ -338,8 +336,7 @@
{onSubmit}
{onEscape}
{onEditPrevious}
initialValues={eventToEdit}
draftKey={eventToEdit ? undefined : draftKey}
content={eventToEdit?.content}
disabled={Boolean(missingRelayLists.length)} />
{/key}
</div>
+5 -33
View File
@@ -10,40 +10,23 @@
import Button from "@lib/components/Button.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {type DraftKey} from "@app/util/drafts"
type Values = {
content?: string | object
}
type Props = {
content?: string
disabled?: boolean
draftKey?: DraftKey<Values>
onEscape?: () => void
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void
initialValues?: Values
}
let {
initialValues,
disabled = false,
draftKey,
onEscape,
onEditPrevious,
onSubmit,
}: Props = $props()
if (!initialValues) {
initialValues = draftKey?.get()
}
const {content, disabled = false, onEscape, onEditPrevious, onSubmit}: Props = $props()
const autofocus = !isMobile && !disabled
const uploading = writable(false)
const editorClass = $derived(
cx("chat-editor grow overflow-hidden", {
cx("chat-editor flex-grow overflow-hidden", {
"pointer-events-none opacity-50": disabled,
}),
)
@@ -76,29 +59,18 @@
onSubmit({content, tags})
draftKey?.clear()
ed.chain().clearContent().run()
}
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
content,
autofocus,
submit,
uploading,
onChange,
aggressive: true,
encryptFiles: true,
})
$effect(() => {
draftKey?.set({content})
})
onMount(async () => {
const ed = await editor
ed.view.dom.addEventListener("keydown", handleKeyDown)
@@ -123,7 +95,7 @@
{/if}
</Button>
<div class={editorClass} aria-disabled={disabled}>
<EditorContent {autofocus} {editor} />
<EditorContent {editor} />
</div>
<Button
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}>
<div
class="cursor-pointer border-t border-solid border-base-100 px-3 py-2 transition-colors hover:bg-base-100 {props.class}"
class="cursor-pointer border-t border-solid border-base-100 px-6 py-2 transition-colors hover:bg-base-100 {props.class}"
class:bg-base-100={active}>
<div class="flex flex-col justify-start gap-1">
<div class="flex items-center justify-between gap-2">
+9
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import {assoc} from "@welshman/lib"
import ChatSquare from "@assets/icons/chat-square.svg?dataurl"
import Check from "@assets/icons/check.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import BellOff from "@assets/icons/bell-off.svg?dataurl"
@@ -7,9 +8,13 @@
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ChatStart from "@app/components/ChatStart.svelte"
import {setChecked} from "@app/util/notifications"
import {pushModal} from "@app/util/modal"
import {notificationSettings} from "@app/core/state"
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
const markAsRead = () => {
setChecked("/chat/*")
history.back()
@@ -23,6 +28,10 @@
<Modal>
<ModalBody>
<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}>
<Icon size={5} icon={Check} />
Mark all read
+1 -1
View File
@@ -42,7 +42,7 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex grow flex-wrap justify-end gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} />
+2 -3
View File
@@ -7,13 +7,12 @@
type Props = {
url: string
h?: string
shareToChat?: boolean
}
const {url, h, shareToChat = false}: Props = $props()
const {url, h}: Props = $props()
</script>
<ClassifiedForm {url} {h} {shareToChat}>
<ClassifiedForm {url} {h}>
{#snippet header()}
<ModalHeader>
<ModalTitle>Create a Classified Listing</ModalTitle>
+26 -58
View File
@@ -2,7 +2,7 @@
import type {Snippet} from "svelte"
import {removeUndefined, randomId, uniq} from "@welshman/lib"
import {makeEvent, CLASSIFIED} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app"
import {publishThunk} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import {normalizeTopic} from "@lib/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
@@ -20,35 +20,25 @@
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
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[]
}
import {canEnforceNip70, uploadFile} from "@app/core/commands"
type Props = {
url: string
h?: string
shareToChat?: boolean
header: Snippet
initialValues?: Values
initialValues?: {
d?: string
title?: string
content?: string
price?: number
currency?: string
images?: string[]
status?: string
topics?: string[]
}
}
let {url, h, shareToChat = false, header, initialValues}: Props = $props()
const draftKey = new DraftKey<Values>(`classified:${url}:${h ?? ""}`)
if (!initialValues) {
initialValues = draftKey.get()
}
const {url, h, header, initialValues}: Props = $props()
const shouldProtect = canEnforceNip70(url)
@@ -76,7 +66,7 @@
}
const tags = [
["d", d],
["d", initialValues?.d || randomId()],
["title", title],
["summary", content],
["price", String(price), currency],
@@ -88,9 +78,7 @@
tags.push(["t", topic])
}
const protect = await shouldProtect
if (protect) {
if (await shouldProtect) {
tags.push(PROTECTED)
}
@@ -117,47 +105,27 @@
}
}
const classifiedThunk = publishThunk({
publishThunk({
relays: [url],
event: makeEvent(CLASSIFIED, {content, tags}),
})
const error = await waitForThunkError(classifiedThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: classifiedThunk.event, protect})
}
} finally {
loading = false
}
}
const content = initialValues?.content || ""
const editor = makeEditor({url, submit, content})
let loading = $state(false)
const d = $state(initialValues?.d ?? randomId())
let title = $state(initialValues?.title ?? "")
let status = $state(initialValues?.status ?? "active")
let price = $state(initialValues?.price ?? 0)
let currency = $state(initialValues?.currency ?? "SAT")
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})
})
let title = $state(initialValues?.title || "")
let status = $state(initialValues?.status || "active")
let price = $state(Number(initialValues?.price || 0))
let currency = $state(initialValues?.currency || "SAT")
let images = $state<(string | File)[]>(initialValues?.images || [])
let topics = $state(uniq(removeUndefined((initialValues?.topics || []).map(normalizeTopic))))
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
@@ -185,7 +153,7 @@
<p>Description*</p>
{/snippet}
{#snippet input()}
<div class="note-editor grow overflow-hidden">
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
{/snippet}
+1 -1
View File
@@ -28,7 +28,7 @@
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex grow flex-wrap justify-end gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
+5 -5
View File
@@ -22,15 +22,15 @@
const {url, h, onClick}: Props = $props()
const createGoal = () => pushModal(GoalCreate, {url, h, shareToChat: true})
const createGoal = () => pushModal(GoalCreate, {url, h})
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h, shareToChat: true})
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h})
const createThread = () => pushModal(ThreadCreate, {url, h, shareToChat: true})
const createThread = () => pushModal(ThreadCreate, {url, h})
const createClassified = () => pushModal(ClassifiedCreate, {url, h, shareToChat: true})
const createClassified = () => pushModal(ClassifiedCreate, {url, h})
const createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true})
const createPoll = () => pushModal(PollCreate, {url, h})
let ul: Element
+1 -1
View File
@@ -150,7 +150,7 @@
</div>
{:else}
<div
class="overflow-hidden text-ellipsis wrap-break-word"
class="overflow-hidden text-ellipsis break-words"
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
{#each shortContent as parsed, i}
{#if isNewline(parsed) && !isBlock(i - 1)}
+2 -22
View File
@@ -1,19 +1,12 @@
<script lang="ts">
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
import {isRelayUrl, getTagValue} from "@welshman/util"
import {Capacitor} from "@capacitor/core"
import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import {pushModal} from "@app/util/modal"
import {
dufflepud,
PLATFORM_URL,
IMAGE_CONTENT_TYPES,
VIDEO_CONTENT_TYPES,
THUMBNAIL_URL,
} from "@app/core/state"
import {dufflepud, PLATFORM_URL, IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
const {value, event} = $props()
@@ -29,14 +22,6 @@
return [url, true]
})
const getVideoPoster = (videoUrl: string): string | undefined => {
if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) {
return `${THUMBNAIL_URL}/thumbnail?url=${encodeURIComponent(videoUrl)}`
}
return undefined
}
const loadPreview = async () => {
const json = await postJson(dufflepud("link/preview"), {url})
@@ -57,12 +42,7 @@
<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">
<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)}
+1 -1
View File
@@ -101,7 +101,7 @@
</p>
</div>
{:else}
<div class="overflow-hidden text-ellipsis wrap-break-word">
<div class="overflow-hidden text-ellipsis break-words">
{#each shortContent as parsed, i}
{#if isNewline(parsed)}
<ContentNewline value={parsed.value} />
+2 -2
View File
@@ -45,11 +45,11 @@
{#if $quote.kind === MESSAGE}
<div
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
style="background-color: color-mix(in srgb, var(--color-primary) 10%, var(--color-base-300) 90%);">
style="background-color: color-mix(in srgb, var(--primary) 10%, var(--base-300) 90%);">
<NoteContentMinimal trimParent {url} event={$quote} />
</div>
{:else}
<NoteCard noShadow event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteContentMinimal {url} event={$quote} />
</NoteCard>
{/if}
+2 -2
View File
@@ -101,7 +101,7 @@
{/if}
<div class="relative">
<pre class="card2 card2-sm bg-alt overflow-auto text-xs"><code>{json}</code></pre>
<p class="absolute right-2 top-2 flex grow items-center justify-between">
<p class="absolute right-2 top-2 flex flex-grow items-center justify-between">
<Button onclick={copyJson} class="btn btn-neutral btn-sm flex items-center">
<Icon icon={Copy} /> Copy
</Button>
@@ -109,6 +109,6 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-primary grow" onclick={() => history.back()}>Got it</Button>
<Button class="btn btn-primary flex-grow" onclick={() => history.back()}>Got it</Button>
</ModalFooter>
</Modal>
+8 -24
View File
@@ -10,19 +10,13 @@
import {publishComment, canEnforceNip70} from "@app/core/commands"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {pushToast} from "@app/util/toast"
type Values = {
content?: string | object
}
const {url, event, onClose, onSubmit} = $props()
const draftKey = new DraftKey<Values>(`reply:${event.id}`)
const initialValues = draftKey.get()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false)
const autofocus = !isMobile
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
@@ -44,23 +38,13 @@
})
}
draftKey.clear()
onSubmit(publishComment({event, content, tags, relays: [url]}))
}
const editor = makeEditor({url, submit, uploading, autofocus: !isMobile})
let form: 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(() => {
setTimeout(() => {
@@ -68,7 +52,7 @@
})
const observer = new ResizeObserver(() => {
spacer!.style.minHeight = `${form!.offsetHeight + 60}px`
spacer!.style.minHeight = `${form!.offsetHeight}px`
})
observer.observe(form!)
@@ -84,11 +68,11 @@
in:fly
bind:this={form}
onsubmit={preventDefault(submit)}
class="left-content bottom-sai right-sai fixed z-feature mb-14 md:mb-0 w-full md:w-auto pr-2">
class="left-content bottom-sai right-sai ml-2 pl-2 fixed z-feature">
<div class="card2 mx-2 my-2 bg-alt shadow-md">
<div class="relative">
<div class="note-editor grow overflow-hidden">
<EditorContent {autofocus} {editor} />
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="Add an image"
+1 -1
View File
@@ -30,7 +30,7 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex grow flex-wrap justify-end gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} />
+34 -90
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {writable} from "svelte/store"
import {makeEvent, ZAP_GOAL} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app"
import {publishThunk} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import Bolt from "@assets/icons/bolt.svg?dataurl"
@@ -10,7 +10,6 @@
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
@@ -21,29 +20,14 @@
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
type Values = {
title: string
content: string | object
amount: number
}
import {canEnforceNip70} from "@app/core/commands"
type Props = {
url: string
h?: string
initialValues?: Values
shareToChat?: boolean
}
let {url, h, initialValues, shareToChat = false}: Props = $props()
const draftKey = new DraftKey<Values>(`goal:${url}:${h ?? ""}`)
if (!initialValues) {
initialValues = draftKey.get()
}
const {url, h}: Props = $props()
const shouldProtect = canEnforceNip70(url)
@@ -54,9 +38,9 @@
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading || loading) return
if ($uploading) return
if (!title) {
if (!content) {
return pushToast({
theme: "error",
message: "Please provide a title for your funding goal.",
@@ -64,9 +48,9 @@
}
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const summary = ed.getText({blockSeparator: "\n"}).trim()
if (!content.trim()) {
if (!summary.trim()) {
return pushToast({
theme: "error",
message: "Please provide details about your funding goal.",
@@ -75,68 +59,31 @@
const tags = [
...ed.storage.nostr.getEditorTags(),
["summary", content],
["summary", summary],
["amount", String(amount)],
["relays", url],
]
loading = true
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 (await shouldProtect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
publishThunk({
relays: [url],
event: makeEvent(ZAP_GOAL, {content, tags}),
})
history.back()
}
let loading = $state(false)
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
let title = $state(initialValues?.title ?? "")
let 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})
})
let content = $state("")
let amount = $state(1000)
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
@@ -155,7 +102,7 @@
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={title}
bind:value={content}
class="grow"
type="text"
placeholder="What do funds go towards?" />
@@ -168,7 +115,7 @@
<p>Details*</p>
{/snippet}
{#snippet input()}
<div class="note-editor grow overflow-hidden">
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
{/snippet}
@@ -176,8 +123,7 @@
<Button
data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles}
disabled={loading}>
onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
@@ -191,17 +137,17 @@
Goal Amount (sats)*
{/snippet}
{#snippet input()}
<div class="flex grow justify-end">
<label class="input input-bordered flex w-auto items-center gap-2">
<div class="flex flex-grow justify-end">
<label class="input input-bordered flex items-center gap-2">
<Icon icon={Bolt} />
<input bind:value={amount} type="number" class="w-28 grow" />
<p class="shrink-0 opacity-50">sats</p>
<input bind:value={amount} type="number" class="w-28" />
<p class="opacity-50">sats</p>
</label>
</div>
{/snippet}
</FieldInline>
<input
class="range range-primary -mt-2 w-full"
class="range range-primary -mt-2"
type="range"
min="1000"
max="100000"
@@ -211,12 +157,10 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
<Spinner {loading}>Create Goal</Spinner>
</Button>
<Button type="submit" class="btn btn-primary">Create Goal</Button>
</ModalFooter>
</Modal>
+1 -1
View File
@@ -23,7 +23,7 @@
<ModalTitle>Unable to Zap</ModalTitle>
</ModalHeader>
<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}
their zap receiver isn't correctly set up.
{:else}
@@ -97,10 +97,10 @@
tabindex="-1"
onmousedown={stopPropagation(onClear)}
ontouchstart={stopPropagation(onClear)}>
<Icon icon={CloseCircle} class="scale-150 bg-base-300!" />
<Icon icon={CloseCircle} class="scale-150 !bg-base-300" />
</span>
{:else}
<Icon icon={AddCircle} class="scale-150 bg-base-300!" />
<Icon icon={AddCircle} class="scale-150 !bg-base-300" />
{/if}
</div>
{#if !url}
-10
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import {Capacitor} from "@capacitor/core"
import {onMount, onDestroy} from "svelte"
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker} from "@welshman/signer"
@@ -104,16 +103,10 @@
mode = "connect"
}
const openSigner = () => {
controller.launchSigner()
}
const selectBunker = () => {
mode = "bunker"
}
const isIos = Capacitor.getPlatform() === "ios"
let mode: string = $state("bunker")
$effect(() => {
@@ -145,9 +138,6 @@
<BunkerUrl {controller} />
<Button class="btn {$bunker ? 'btn-neutral' : 'btn-primary'}" onclick={selectConnect}
>Log in with a QR code instead</Button>
{#if isIos}
<Button class="btn btn-neutral" onclick={openSigner}>Open in Signer</Button>
{/if}
{/if}
</ModalBody>
<ModalFooter>
+1 -3
View File
@@ -16,7 +16,6 @@
children,
minimal = false,
hideProfile = false,
noShadow = false,
url,
...restProps
}: {
@@ -24,7 +23,6 @@
children: Snippet
minimal?: boolean
hideProfile?: boolean
noShadow?: boolean
url?: string
class?: string
} = $props()
@@ -36,7 +34,7 @@
let muted = $state($isEventMuted(event))
</script>
<div class="flex flex-col gap-2 {restProps.class}" class:shadow-md={!noShadow}>
<div class="flex flex-col gap-2 shadow-md {restProps.class}">
{#if muted}
<div class="flex items-center justify-between">
<div class="row-2 relative">
@@ -9,10 +9,10 @@
<div class="flex items-start gap-4">
<CalendarEventDate event={props.event} />
<div class="flex grow flex-col">
<div class="flex flex-grow flex-col">
<CalendarEventHeader event={props.event} />
<div class="flex py-2 opacity-50">
<div class="h-px grow bg-base-content opacity-25"></div>
<div class="h-px flex-grow bg-base-content opacity-25"></div>
</div>
<Content {...props} />
</div>
@@ -17,7 +17,7 @@
</script>
<div class="flex flex-col">
<div class="flex grow flex-wrap justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-between gap-2">
<p class="text-sm">{meta.title || meta.name}</p>
{#if !isNaN(start) && !isNaN(end)}
{@const startDateDisplay = formatTimestampAsDate(start)}
+26 -69
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
import {makeEvent} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app"
import {publishThunk} from "@welshman/app"
import {Poll} from "nostr-tools/kinds"
import {isMobile, preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
@@ -13,7 +13,6 @@
import FieldInline from "@lib/components/FieldInline.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
@@ -22,34 +21,23 @@
import ModalBody from "@lib/components/ModalBody.svelte"
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70} from "@app/core/commands"
import type {PollType} from "@app/util/polls"
type Option = {
id: string
value: string
}
type Values = {
title: string
pollType: PollType
endsAt?: number
options: Option[]
}
type Props = {
url: string
h?: string
shareToChat?: boolean
}
const {url, h, shareToChat = false}: Props = $props()
const draftKey = new DraftKey<Values>(`poll:${url}:${h ?? ""}`)
const initialValues = draftKey.get()
const {url, h}: Props = $props()
const shouldProtect = canEnforceNip70(url)
type DraftOption = {
id: string
value: string
}
const back = () => history.back()
const addOption = () => {
@@ -104,8 +92,6 @@
}
const submit = async () => {
if (loading) return
if (!title.trim()) {
return pushToast({theme: "error", message: "Please provide a title for your poll."})
}
@@ -134,53 +120,26 @@
tags.push(["h", h])
}
loading = true
try {
const protect = await shouldProtect
if (protect) {
tags.push(PROTECTED)
}
const pollThunk = publishThunk({
relays: [url],
event: makeEvent(Poll, {content: title.trim(), tags}),
})
const error = await waitForThunkError(pollThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: pollThunk.event, protect})
}
} finally {
loading = false
if (await shouldProtect) {
tags.push(PROTECTED)
}
publishThunk({
relays: [url],
event: makeEvent(Poll, {content: title.trim(), tags}),
})
history.back()
}
let loading = $state(false)
let title = $state("")
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 title = $state(initialValues?.title ?? "")
let pollType = $state<PollType>(initialValues?.pollType ?? "singlechoice")
let endsAt = $state<number | undefined>(initialValues?.endsAt)
let options = $state<Option[]>(
initialValues?.options ?? [
{id: randomId(), value: "Yes"},
{id: randomId(), value: "No"},
],
)
$effect(() => {
draftKey.set({title, pollType, endsAt, options})
})
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
@@ -270,12 +229,10 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Create Poll</Spinner>
</Button>
<Button type="submit" class="btn btn-primary">Create Poll</Button>
</ModalFooter>
</Modal>
+1 -1
View File
@@ -43,7 +43,7 @@
<div class="flex flex-col gap-2 card2 card2-sm bg-alt">
<div class="flex items-center justify-between gap-2">
<label class="flex min-w-0 grow items-center gap-2">
<label class="flex min-w-0 flex-grow items-center gap-2">
{#if !closed}
{#if pollType === "singlechoice"}
<input
+3 -2
View File
@@ -32,7 +32,7 @@
</script>
<div
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 shrink-0 bg-base-200 pt-2 md:block">
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 flex-shrink-0 bg-base-200 pt-2 md:block">
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
<PrimaryNavSpaces />
{#if PLATFORM_RELAYS.length > 0}
@@ -62,7 +62,8 @@
{@render children?.()}
<!-- a little extra something for ios -->
<div class="hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-(--saib) bg-base-100 md:hidden">
<div
class="hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden">
</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">
+3 -3
View File
@@ -5,11 +5,11 @@
import type {Filter} from "@welshman/util"
import {deriveEventsDesc, deriveEventsById} from "@welshman/store"
import {formatTimestampRelative} from "@welshman/lib"
import {NOTE, ROOMS, COMMENT, MESSAGE} from "@welshman/util"
import {NOTE, ROOMS, COMMENT} from "@welshman/util"
import {repository, loadRelayList} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileSpaces from "@app/components/ProfileSpaces.svelte"
import {deriveGroupList, getSpaceUrlsFromGroupList} from "@app/core/state"
import {deriveGroupList, getSpaceUrlsFromGroupList, MESSAGE_KINDS} from "@app/core/state"
import {goToEvent} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
@@ -36,7 +36,7 @@
load({
filters: [
{authors: [pubkey], kinds: [ROOMS]},
{authors: [pubkey], limit: 1, kinds: [NOTE, COMMENT, MESSAGE]},
{authors: [pubkey], limit: 1, kinds: [NOTE, COMMENT, ...MESSAGE_KINDS]},
],
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
})
+2 -2
View File
@@ -25,10 +25,10 @@
<div class="flex flex-col gap-2">
{#each spaceUrls as url (url)}
<div class="card2 bg-alt flex flex-row items-center gap-2">
<div class="shrink-0">
<div class="flex-shrink-0">
<RelayIcon {url} size={12} />
</div>
<div class="flex grow flex-col">
<div class="flex flex-grow flex-col">
<RelayName {url} />
<div class="text-sm opacity-75">
{url}
+3 -17
View File
@@ -33,7 +33,6 @@
url?: string
reactionClass?: string
noTooltip?: boolean
innerEvent?: TrustedEvent
children?: Snippet
}
@@ -44,36 +43,23 @@
url = "",
reactionClass = "",
noTooltip = false,
innerEvent = undefined,
children,
}: Props = $props()
const eventIds = innerEvent ? [event.id, innerEvent.id] : [event.id]
const reports = deriveArray(
deriveEventsById({repository, filters: [{kinds: [REPORT], "#e": [event.id]}]}),
)
const reactions = deriveArray(
deriveEventsById({repository, filters: [{kinds: [REACTION], "#e": eventIds}]}),
deriveEventsById({repository, filters: [{kinds: [REACTION], "#e": [event.id]}]}),
)
const zaps = deriveArray(
deriveItemsByKey<Zap>({
repository,
getKey: zap => zap.response.id,
filters: [{kinds: [ZAP_RESPONSE], "#e": eventIds}],
eventToItem: (response: TrustedEvent) => {
const zap = getValidZap(response, event)
if (zap) {
return zap
}
if (innerEvent) {
return getValidZap(response, innerEvent)
}
},
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
}),
)
+1 -1
View File
@@ -121,6 +121,6 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-primary grow" onclick={back}>Done</Button>
<Button class="btn btn-primary flex-grow" onclick={back}>Done</Button>
</ModalFooter>
</Modal>
+2 -2
View File
@@ -26,8 +26,8 @@
type="button"
class="btn font-normal flex h-[unset] w-full flex-nowrap py-4 text-left items-start justify-between"
{onclick}>
<div class="flex grow flex-row items-start gap-4">
<div class="flex h-7 w-7 shrink-0 items-center justify-center">
<div class="flex flex-grow flex-row items-start gap-4">
<div class="flex h-7 w-7 flex-shrink-0 items-center justify-center">
<Icon {icon} />
</div>
<div class="flex flex-col gap-1">
+1 -1
View File
@@ -23,7 +23,7 @@
<div class="relative">
<div class="avatar relative">
<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} />
</div>
</div>
+6 -34
View File
@@ -12,29 +12,18 @@
import ComposeMenu from "@app/components/ComposeMenu.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {onDestroy, onMount} from "svelte"
type Values = {
content?: string | object
}
type Props = {
url?: string
h?: string
content?: string
onEscape?: () => void
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void
initialValues?: Values
}
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 {url, h, content, onEscape, onEditPrevious, onSubmit}: Props = $props()
const autofocus = !isMobile
@@ -72,29 +61,12 @@
onSubmit({content, tags})
draftKey?.clear()
ed.chain().clearContent().run()
}
const editor = makeEditor({url, content, autofocus, submit, uploading, aggressive: true})
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 () => {
const ed = await editor
@@ -132,8 +104,8 @@
</Button>
</Tippy>
</div>
<div class="chat-editor grow overflow-hidden">
<EditorContent {autofocus} {editor} />
<div class="chat-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
+1 -1
View File
@@ -131,7 +131,7 @@
<p>Icon</p>
{/snippet}
{#snippet input()}
<div class="flex grow items-center justify-between gap-4">
<div class="flex flex-grow items-center justify-between gap-4">
{#if imagePreview}
<div class="flex items-center gap-2">
<span class="text-sm opacity-75">Selected:</span>
+17 -37
View File
@@ -1,16 +1,8 @@
<script lang="ts">
import cx from "classnames"
import {readable} from "svelte/store"
import {
hash,
gte,
now,
displayList,
formatTimestampAsTime,
formatTimestampAsDate,
} from "@welshman/lib"
import {hash, now, displayList, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {MESSAGE, COMMENT, getTag} from "@welshman/util"
import {MESSAGE, COMMENT} from "@welshman/util"
import {
thunks,
pubkey,
@@ -35,7 +27,7 @@
import RoomItemMenuButton from "@app/components/RoomItemMenuButton.svelte"
import RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte"
import RoomItemContent from "@app/components/RoomItemContent.svelte"
import {colors, ENABLE_ZAPS, deriveEventsForUrl, deriveEvent} from "@app/core/state"
import {colors, ENABLE_ZAPS, deriveEventsForUrl} from "@app/core/state"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {getRoomItemPath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
@@ -45,7 +37,7 @@
event: TrustedEvent
replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean
addSpaceBelow?: boolean
inert?: boolean
canEdit: (event: TrustedEvent) => boolean
onEdit: (event: TrustedEvent) => void
}
@@ -55,7 +47,7 @@
event,
replyTo = undefined,
showPubkey = false,
addSpaceBelow = false,
inert = false,
canEdit,
onEdit,
}: Props = $props()
@@ -66,15 +58,7 @@
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
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 comments = deriveEventsForUrl(url, [{kinds: [COMMENT], "#e": [event.id]}])
const reply = () => replyTo!(event)
const edit = canEdit(event) ? () => onEdit(event) : undefined
@@ -92,23 +76,20 @@
<TapTarget
data-event={event.id}
{onTap}
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},
)}>
onTap={inert ? null : onTap}
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left hover:bg-base-100/50">
<div class="flex w-full gap-3 overflow-auto">
{#if showPubkey}
<Button onclick={openProfile} class="flex items-start pt-1.5 justify-center w-8 shrink-0">
<Button onclick={openProfile} class="flex items-start">
<ProfileCircle
pubkey={event.pubkey}
class="border border-solid border-base-content"
size={8} />
</Button>
{:else}
<div class="w-8 shrink-0"></div>
<div class="w-8 min-w-8 max-w-8"></div>
{/if}
<div class="min-w-0 grow pr-1">
<div class="min-w-0 flex-grow pr-1">
{#if showPubkey}
<div class="flex items-center gap-2">
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
@@ -125,7 +106,7 @@
</div>
{/if}
<div class:mt-2={showPubkey && event.kind !== MESSAGE}>
<RoomItemContent {url} event={$innerEvent ?? event} />
<RoomItemContent {url} {event} />
{#if thunk}
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
{/if}
@@ -138,10 +119,9 @@
{event}
{deleteReaction}
{createReaction}
reactionClass="tooltip-right"
innerEvent={$innerEvent} />
{#if path && $innerComments.length > 0}
{@const pubkeys = $innerComments.map(e => e.pubkey)}
reactionClass="tooltip-right" />
{#if path && $comments.length > 0}
{@const pubkeys = $comments.map(e => e.pubkey)}
{@const isOwn = $pubkey && pubkeys.includes($pubkey)}
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
{@const tooltip = `${info} commented`}
@@ -153,14 +133,14 @@
"btn-primary": isOwn,
})}>
<Icon icon={ReplyAlt} />
<span>{$innerComments.length} comment{$innerComments.length === 1 ? "" : "s"}</span>
<span>{$comments.length} comment{$comments.length === 1 ? "" : "s"}</span>
</Link>
</div>
{/if}
</div>
{#if !isMobile}
<button
class="join absolute right-2 top-0.5 border border-solid border-neutral text-xs opacity-0 transition-all pr-2"
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}>
{#if ENABLE_ZAPS}
<RoomItemZapButton {url} {event} />
+3 -4
View File
@@ -8,17 +8,16 @@
import {getRoomItemPath} from "@app/util/routes"
const props: ComponentProps<typeof NoteContent> = $props()
const path = getRoomItemPath(props.url!, props.event)
const minLength = 5000
const maxLength = 5500
</script>
<div class={cx("text-sm", {"card2 card2-sm bg-alt": props.event.kind !== MESSAGE})}>
{#if path && !isMobile}
<Link href={path}>
<NoteContent {...props} {minLength} {maxLength} />
<NoteContent {...props} />
</Link>
{:else}
<NoteContent {...props} {minLength} {maxLength} />
<NoteContent {...props} />
{/if}
</div>
+1 -1
View File
@@ -11,7 +11,7 @@
const {url, h, ...props}: Props = $props()
</script>
<div class="flex grow items-center justify-between gap-4 {props.class}">
<div class="flex flex-grow items-center justify-between gap-4 {props.class}">
<div class="flex items-center gap-3">
<RoomImage {url} {h} />
<div class="min-w-0 overflow-hidden text-ellipsis">
+1 -1
View File
@@ -27,7 +27,7 @@
<Button onclick={back} class="place-self-start pr-3 md:hidden">
<Icon icon={ArrowLeft} size={7} />
</Button>
<div class="ellipsize whitespace-nowrap flex grow items-center justify-between gap-4">
<div class="ellipsize whitespace-nowrap flex flex-grow items-center justify-between gap-4">
<div class="flex flex-col">
<div class="flex gap-2 items-center">
{@render title?.()}
+1 -1
View File
@@ -42,7 +42,7 @@
<div class="relative">
<div class="avatar relative">
<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} />
</div>
</div>
+1 -1
View File
@@ -134,7 +134,7 @@
<p>Icon</p>
{/snippet}
{#snippet input()}
<div class="flex items-center gap-4 justify-between grow">
<div class="flex items-center gap-4 justify-between flex-grow">
{#if imagePreview}
<div class="flex items-center gap-2">
<span class="text-sm opacity-75">Selected:</span>
+1 -1
View File
@@ -100,6 +100,6 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-primary grow" onclick={back}>Done</Button>
<Button class="btn btn-primary flex-grow" onclick={back}>Done</Button>
</ModalFooter>
</Modal>
+6 -6
View File
@@ -140,7 +140,7 @@
<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">
<div class="shrink-0">
<div class="flex-shrink-0">
<Button
class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
onclick={openMenu}>
@@ -270,14 +270,14 @@
{/if}
{#if hasNip29($relay)}
{#if $userRooms.length > 0}
<div class="h-2 shrink-0"></div>
<div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if}
{#each $userRooms as h (h)}
<SpaceMenuRoomItem {url} {h} />
{/each}
{#if $otherRooms.length > 0}
<div class="h-2 shrink-0"></div>
<div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader>
{#if $userRooms.length > 0}
Other Rooms
@@ -296,7 +296,7 @@
<SpaceMenuRoomItem {url} {h} />
{/each}
{#if $otherVoiceRooms.length > 0}
<div class="h-2 shrink-0"></div>
<div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
{#each $otherVoiceRooms as h (h)}
<SpaceMenuRoomItem {url} {h} />
@@ -309,11 +309,11 @@
</SecondaryNavItem>
{/if}
{/if}
<div class="h-5 shrink-0"></div>
<div class="h-5 flex-shrink-0"></div>
</div>
</SecondaryNavSection>
<div
class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+0.25rem)] md:pb-2 z-nav">
class="flex flex-shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+0.25rem)] md:pb-2 z-nav">
<VoiceWidget />
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
<SocketStatusIndicator {url} />
+19 -18
View File
@@ -2,7 +2,7 @@
import {tick} from "svelte"
import {debounce} from "throttle-debounce"
import {request} from "@welshman/net"
import {formatTimestampAsDate, groupBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
import {formatTimestampAsDate, groupBy, now, uniqBy, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
import type {TrustedEvent, Filter} from "@welshman/util"
import {sortEventsDesc} from "@welshman/util"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
@@ -10,15 +10,17 @@
import {fly} from "@lib/transition"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import {CONTENT_KINDS} from "@app/core/state"
import {goToEvent} from "@app/util/routes"
type Props = {
url: string
h?: string
kinds?: number[]
}
const {url, h}: Props = $props()
const {url, h, kinds = CONTENT_KINDS}: Props = $props()
let term = $state("")
let show = $state(false)
@@ -52,9 +54,7 @@
const getRelayUrls = () => [url]
const getFilter = (searchTerm: string): Filter =>
h
? {kinds: CONTENT_KINDS, "#h": [h], search: searchTerm}
: {kinds: CONTENT_KINDS, search: searchTerm}
h ? {kinds, "#h": [h], search: searchTerm} : {kinds, search: searchTerm}
const search = debounce(300, async (searchTerm: string) => {
controller?.abort()
@@ -76,7 +76,7 @@
filters: [getFilter(searchTerm.trim())],
})
results = sortEventsDesc(events)
results = sortEventsDesc(uniqBy(e => e.id, events))
} catch (error) {
if (!(error instanceof DOMException && error.name === "AbortError")) {
results = []
@@ -86,12 +86,6 @@
}
})
const onInput = () => {
void search(term)
}
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
const getAgeSection = (createdAt: number) => {
const age = now() - createdAt
@@ -124,6 +118,12 @@
return `${Math.floor(age / DAY)}d ago`
}
const onInput = () => {
void search(term)
}
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
const onRoomSearchResultClick = (event: TrustedEvent) => {
close()
goToEvent(event, {keepFocus: true})
@@ -162,9 +162,12 @@
{h ? "Search for content in this room." : "Search for content in this space."}
</p>
{:else if loading}
<p class="text-sm opacity-70">Searching...</p>
<div class="flex flex-col items-center gap-2 py-4">
<span class="loading loading-spinner loading-sm opacity-70"></span>
<p class="text-sm opacity-70">Searching...</p>
</div>
{:else if eventsByAge.size === 0}
<p class="text-sm opacity-70">No results found.</p>
<p class="text-center text-sm opacity-70">No results found.</p>
{:else}
<div class="col-2">
{#each eventsByAge as [key, events] (key)}
@@ -181,11 +184,9 @@
<div class="col-2">
{#each events as event (event.id)}
<button
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
class="col-2 rounded px-1 py-1 text-left transition-colors hover:bg-base-200"
onclick={() => onRoomSearchResultClick(event)}>
<p class="line-clamp-2 text-sm">
{event.content.trim() || "(No text content)"}
</p>
<NoteContentMinimal {event} />
<div class="row-2 text-xs opacity-70">
<span>{getAgeLabel(event.created_at)}</span>
<span>{formatTimestampAsDate(event.created_at)}</span>
+1 -1
View File
@@ -30,7 +30,7 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex grow flex-wrap justify-end gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} />
+24 -73
View File
@@ -1,14 +1,13 @@
<script lang="ts">
import {writable} from "svelte/store"
import {makeEvent, THREAD} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app"
import {publishThunk} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
@@ -19,23 +18,15 @@
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
type Values = {
content?: string | object
title?: string
}
import {canEnforceNip70} from "@app/core/commands"
type Props = {
url: string
h?: string
shareToChat?: boolean
}
const {url, h, shareToChat = false}: Props = $props()
const draftKey = new DraftKey<Values>(`thread:${url}:${h ?? ""}`)
const initialValues = draftKey.get()
const {url, h}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false)
@@ -45,7 +36,7 @@
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading || loading) return
if ($uploading) return
if (!title) {
return pushToast({
@@ -66,62 +57,25 @@
const tags = [...ed.storage.nostr.getEditorTags(), ["title", title]]
loading = true
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 (await shouldProtect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
publishThunk({
relays: [url],
event: makeEvent(THREAD, {content, tags}),
})
history.back()
}
let loading = $state(false)
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
let title = $state(initialValues?.title ?? "")
let 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})
})
let title: string = $state("")
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
@@ -152,7 +106,7 @@
<p>Message*</p>
{/snippet}
{#snippet input()}
<div class="note-editor grow overflow-hidden">
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
{/snippet}
@@ -160,8 +114,7 @@
<Button
data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles}
disabled={loading}>
onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
@@ -171,12 +124,10 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
<Spinner {loading}>Create Thread</Spinner>
</Button>
<Button type="submit" class="btn btn-primary">Create Thread</Button>
</ModalFooter>
</Modal>
-278
View File
@@ -1,278 +0,0 @@
<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
@@ -1,31 +0,0 @@
<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,8 +7,13 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import {currentVoiceSession, type VoiceSession} from "@app/call/stores"
import {DeviceKind, supportsAudioOutputSelection, switchVoiceActiveDevice} from "@app/call/voice"
import {
currentVoiceSession,
DeviceKind,
supportsAudioOutputSelection,
switchVoiceActiveDevice,
type VoiceSession,
} from "@app/voice"
import {popModal} from "@app/util/modal"
const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => {
@@ -21,10 +26,8 @@
let audioInputs = $state<MediaDeviceInfo[]>([])
let audioOutputs = $state<MediaDeviceInfo[]>([])
let videoInputs = $state<MediaDeviceInfo[]>([])
let selectedInput = $state("")
let selectedOutput = $state("")
let selectedVideo = $state("")
const loadDevices = async () => {
if (!navigator.mediaDevices?.enumerateDevices) return
@@ -32,11 +35,9 @@
const devices = await navigator.mediaDevices.enumerateDevices()
audioInputs = devices.filter(d => d.kind === "audioinput")
audioOutputs = devices.filter(d => d.kind === "audiooutput")
videoInputs = devices.filter(d => d.kind === "videoinput")
} catch {
audioInputs = []
audioOutputs = []
videoInputs = []
}
}
@@ -54,7 +55,6 @@
}
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput)
})
const onInputChange = () => {
@@ -65,10 +65,6 @@
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput)
}
const onVideoChange = () => {
void switchVoiceActiveDevice(DeviceKind.VideoInput, selectedVideo)
}
const onDone = () => {
popModal()
}
@@ -80,8 +76,8 @@
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Call settings</ModalTitle>
<ModalSubtitle>Microphone, speaker, and camera for this call.</ModalSubtitle>
<ModalTitle>Audio settings</ModalTitle>
<ModalSubtitle>Choose microphone and speaker for this call.</ModalSubtitle>
</ModalHeader>
<div class="flex flex-col gap-4 pt-2">
<FieldInline>
@@ -124,25 +120,6 @@
{/snippet}
</FieldInline>
{/if}
<FieldInline>
{#snippet label()}
<p>Camera</p>
{/snippet}
{#snippet input()}
<select
class="select select-bordered w-full"
bind:value={selectedVideo}
onchange={onVideoChange}
aria-label="Camera">
<option value="">Default camera</option>
{#each videoInputs as d (d.deviceId)}
<option value={d.deviceId}>
{d.label || `Camera ${d.deviceId.slice(0, 8)}`}
</option>
{/each}
</select>
{/snippet}
</FieldInline>
</div>
</ModalBody>
<ModalFooter>
+5 -4
View File
@@ -12,13 +12,14 @@
import {makeRoomId} from "@app/core/state"
import {
VoiceState,
deriveVoiceParticipants,
cancelJoinVoiceRoom,
currentVoiceRoom,
voiceState,
isParticipantSpeaking,
participantKey,
voiceState,
type VoiceParticipant,
} from "@app/call/stores"
import {cancelJoinVoiceRoom, deriveVoiceParticipants} from "@app/call/voice"
} from "@app/voice"
interface Props {
url: string
@@ -63,7 +64,7 @@
{replaceState}
{notification}
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 gap-2 items-center">
{#if isJoining}
@@ -14,7 +14,7 @@
import ModalTitle from "@lib/components/ModalTitle.svelte"
import {AbortError, TimeoutError} from "$lib/util"
import {displayRoom} from "@app/core/state"
import {joinVoiceRoom} from "@app/call/voice"
import {joinVoiceRoom} from "@app/voice"
import {popModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
+24 -137
View File
@@ -1,20 +1,15 @@
<script lang="ts">
import {readable} from "svelte/store"
import {fade, fly} from "svelte/transition"
import {fly} from "svelte/transition"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import cx from "classnames"
import {displayRelayUrl} from "@welshman/util"
import Microphone from "@assets/icons/microphone.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 MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
import PhoneRounded from "@assets/icons/phone-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 Settings from "@assets/icons/settings.svg?dataurl"
import {Capacitor} from "@capacitor/core"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
@@ -28,23 +23,16 @@
type Room,
} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {notifications} from "@app/util/notifications"
import {makeRoomPath} from "@app/util/routes"
import {
VideoCallLayout,
isDesktopLayout,
toggleCamera,
toggleScreenShare,
videoCallLayout,
} from "@app/call/video"
import {
VoiceState,
currentVoiceSession,
currentVoiceRoom,
voiceState,
isLocalSpeaking,
} from "@app/call/stores"
import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice"
leaveVoiceRoom,
toggleMute,
cancelJoinVoiceRoom,
} from "@app/voice"
const {relay, h} = $derived($page.params)
const url = $derived(relay ? decodeRelay(relay) : undefined)
@@ -53,14 +41,6 @@
)
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 => {
if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) {
return $currentVoiceRoom
@@ -86,45 +66,9 @@
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
}
const goToRoom = () => {
if (!targetRoom) return
const path = makeRoomPath(targetRoom.url, targetRoom.h)
if ($page.url.pathname !== path) {
void goto(path)
}
}
const openCallSettings = () => {
const openAudioSettings = () => {
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>
{#if targetRoom}
@@ -132,47 +76,19 @@
in:fly={{y: 60, duration: 350}}
out:fly={{y: 60, duration: 250}}
class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
<div class="flex items-start justify-between gap-2">
<button
type="button"
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"
onclick={goToRoom}
aria-label="Open room {roomName}">
<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>
<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>
<div class="flex flex-wrap items-center gap-2">
<div class="flex items-center gap-1">
{#if $voiceState === VoiceState.Joining}
<span class="loading loading-spinner loading-sm"></span>
<Button
@@ -184,45 +100,16 @@
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
<Button
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
class={cx(
mediaToggleClass,
"overflow-visible",
!$currentVoiceSession.muted && $isLocalSpeaking && "text-primary",
$currentVoiceSession.muted &&
"text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
)}
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
? 'btn-error'
: 'btn-ghost'}"
onclick={toggleMute}>
<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>
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
</Button>
<Button
data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"}
class={cx(mediaToggleClass, $currentVoiceSession.cameraOn && "text-primary")}
onclick={toggleCamera}>
<Icon icon={$currentVoiceSession.cameraOn ? VideocameraRecord : Videocamera} size={4} />
</Button>
{#if !Capacitor.isNativePlatform()}
<Button
data-tip={$currentVoiceSession.screenShareOn ? "Stop sharing" : "Share screen"}
class={cx(mediaToggleClass, $currentVoiceSession.screenShareOn && "text-primary")}
onclick={toggleScreenShare}>
<Icon icon={Monitor} size={4} />
</Button>
{/if}
<Button
data-tip="Call settings"
data-tip="Audio settings"
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
onclick={openCallSettings}>
onclick={openAudioSettings}>
<Icon icon={Settings} size={4} />
</Button>
<Button
+1 -1
View File
@@ -70,7 +70,7 @@
Amount (satoshis)
{/snippet}
{#snippet input()}
<div class="flex grow justify-end">
<div class="flex flex-grow justify-end">
<label class="input input-bordered flex items-center gap-2">
<Icon icon={Bolt} />
<input
+1 -1
View File
@@ -80,7 +80,7 @@
Amount (satoshis)
{/snippet}
{#snippet input()}
<div class="flex grow justify-end">
<div class="flex flex-grow justify-end">
<label class="input input-bordered flex items-center gap-2">
<Icon icon={Bolt} />
<input bind:value={sats} type="number" class="w-14" placeholder="0" />
+2 -2
View File
@@ -1,7 +1,7 @@
<style>
.wot-background {
fill: transparent;
stroke: var(--color-base-content);
stroke: var(--base-content);
opacity: 30%;
}
@@ -32,7 +32,7 @@
const normalizedScore = $derived(clamp([0, max], $score) / max)
const dashOffset = $derived(100 - 44 * normalizedScore)
const style = $derived(`transform: rotate(${135 - normalizedScore * 180}deg)`)
const stroke = $derived(active ? "var(--color-primary)" : "var(--color-base-content)")
const stroke = $derived(active ? "var(--primary)" : "var(--base-content)")
</script>
<div class="relative h-[14px] w-[14px]">
+5 -5
View File
@@ -118,26 +118,26 @@
<ModalBody>
<ModalHeader>
<ModalTitle>Send a Zap</ModalTitle>
<ModalSubtitle>To <ProfileLink {pubkey} class="text-primary!" /></ModalSubtitle>
<ModalSubtitle>To <ProfileLink {pubkey} class="!text-primary" /></ModalSubtitle>
</ModalHeader>
<FieldInline class="grid-cols-3!">
<FieldInline class="!grid-cols-3">
{#snippet label()}
Emoji Reaction
{/snippet}
{#snippet input()}
<div class="flex grow items-center justify-end gap-4">
<div class="flex flex-grow items-center justify-end gap-4">
<EmojiButton {onEmoji} class="btn btn-neutral">
{content}
</EmojiButton>
</div>
{/snippet}
</FieldInline>
<FieldInline class="grid-cols-3!">
<FieldInline class="!grid-cols-3">
{#snippet label()}
Amount
{/snippet}
{#snippet input()}
<div class="flex grow justify-end">
<div class="flex flex-grow justify-end">
<label class="input input-bordered flex items-center gap-2">
<Icon icon={Bolt} />
<input bind:value={amount} type="number" class="w-24" />
+6 -6
View File
@@ -147,7 +147,7 @@
<ModalBody>
<ModalHeader>
<ModalTitle>Send a Zap</ModalTitle>
<ModalSubtitle>To <ProfileLink {pubkey} class="text-primary!" /></ModalSubtitle>
<ModalSubtitle>To <ProfileLink {pubkey} class="!text-primary" /></ModalSubtitle>
</ModalHeader>
{#if invoice}
@@ -158,30 +158,30 @@
</p>
</div>
<label class="input input-bordered flex w-full items-center justify-between gap-2">
<input readonly class="ellipsize grow" value={invoice} />
<input readonly class="ellipsize flex-grow" value={invoice} />
<Button class="flex items-center" onclick={copyInvoice}>
<Icon icon={Copy} />
</Button>
</label>
{:else}
<FieldInline class="grid-cols-3!">
<FieldInline class="!grid-cols-3">
{#snippet label()}
Emoji Reaction
{/snippet}
{#snippet input()}
<div class="flex grow items-center justify-end gap-4">
<div class="flex flex-grow items-center justify-end gap-4">
<EmojiButton {onEmoji} class="btn btn-neutral">
{content}
</EmojiButton>
</div>
{/snippet}
</FieldInline>
<FieldInline class="grid-cols-3!">
<FieldInline class="!grid-cols-3">
{#snippet label()}
Amount
{/snippet}
{#snippet input()}
<div class="flex grow justify-end">
<div class="flex flex-grow justify-end">
<label class="input input-bordered flex items-center gap-2">
<Icon icon={Bolt} />
<input bind:value={amount} type="number" class="w-24" />
-29
View File
@@ -22,7 +22,6 @@ import {PollResponse} from "nostr-tools/kinds"
import {
DELETE,
REPORT,
MESSAGE,
PROFILE,
MESSAGING_RELAYS,
RELAYS,
@@ -123,34 +122,6 @@ export const prependParent = (
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
export const broadcastUserData = async (relays: string[]) => {
+69 -106
View File
@@ -1,6 +1,5 @@
import {writable} from "svelte/store"
import {get, writable} from "svelte/store"
import {
batch,
call,
uniq,
int,
@@ -26,8 +25,7 @@ import {
sortEventsDesc,
} from "@welshman/util"
import type {TrustedEvent, Filter, List} from "@welshman/util"
import {load, request, mergeRepositoryUpdates} from "@welshman/net"
import type {RepositoryUpdate} from "@welshman/net"
import {load, request} from "@welshman/net"
import {repository, loadRelay, tracker} from "@welshman/app"
import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util"
@@ -58,75 +56,57 @@ export const makeFeed = ({
let backwardWindow = [at - interval, at]
let forwardWindow = [at, at + interval]
const insertIntoBuffer = (event: TrustedEvent) => {
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)
}
const insertEvent = (event: TrustedEvent) => {
let handled = false
// Batch-insert events into the visible store with a single update
const insertEvents = (newEvents: TrustedEvent[]) => {
const visible: TrustedEvent[] = []
if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) {
const $events = get(events)
for (const event of newEvents) {
if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) {
visible.push(event)
} else {
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]
}
for (let i = 0; i < $events.length; i++) {
if ($events[i].created_at > event.created_at) {
events.set(insertAt(i, event, $events))
handled = true
break
}
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 = [
on(
repository,
"update",
batch(150, (updates: RepositoryUpdate[]) => {
const {added, removed} = mergeRepositoryUpdates(updates)
on(repository, "update", ({added, removed}) => {
if (removed.size > 0) {
buffer = buffer.filter(e => !removed.has(e.id))
events.update($events => $events.filter(e => !removed.has(e.id)))
}
if (removed.size > 0) {
buffer = buffer.filter(e => !removed.has(e.id))
events.update($events => $events.filter(e => !removed.has(e.id)))
for (const event of added) {
if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) {
insertEvent(event)
}
const matching = added.filter(
event => matchFilters(filters, event) && tracker.getRelays(event.id).has(url),
)
if (matching.length > 0) {
insertEvents(matching)
}
}),
),
}
}),
on(tracker, "add", (id: string, trackerUrl: string) => {
if (trackerUrl === url) {
const event = repository.getEvent(id)
if (event && matchFilters(filters, event)) {
insertEvents([event])
insertEvent(event)
}
}
}),
@@ -152,15 +132,17 @@ export const makeFeed = ({
element,
delay: 300,
threshold: 5000,
onScroll: async () => {
onScroll: () => {
const [since, until] = backwardWindow
backwardWindow = [since - interval, since]
insertEvents(buffer.splice(0, 30))
for (const event of buffer.splice(0, 30)) {
insertEvent(event)
}
if (until > now() - int(2, YEAR)) {
await loadTimeframe(since, until)
loadTimeframe(since, until)
} else if (!buffer.some(e => e.created_at < at)) {
backwardScroller.stop()
onBackwardExhausted?.()
@@ -173,15 +155,17 @@ export const makeFeed = ({
reverse: true,
delay: 300,
threshold: 5000,
onScroll: async () => {
onScroll: () => {
const [since, until] = forwardWindow
forwardWindow = [until, until + interval]
insertEvents(buffer.splice(0, 30))
for (const event of buffer.splice(0, 30)) {
insertEvent(event)
}
if (until < now()) {
await loadTimeframe(since, until)
loadTimeframe(since, until)
} else if (!buffer.some(e => e.created_at > at)) {
forwardScroller.stop()
onForwardExhausted?.()
@@ -224,61 +208,40 @@ export const makeCalendarFeed = ({
const events = writable(sortBy(getStart, getEventsForUrl(url, filters)))
// Batch-insert calendar events into the store with a single update
const insertEvents = (newEvents: TrustedEvent[]) => {
const valid = newEvents.filter(e => !isNaN(getStart(e)) && !isNaN(getEnd(e)))
if (valid.length === 0) return
const insertEvent = (event: TrustedEvent) => {
const start = getStart(event)
const address = getAddress(event)
if (isNaN(start) || isNaN(getEnd(event))) return
events.update($events => {
for (const event of valid) {
const start = getStart(event)
const address = getAddress(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]
}
for (let i = 0; i < $events.length; i++) {
if ($events[i].id === event.id) return $events
if (getStart($events[i]) > start) return insertAt(i, event, $events)
}
return $events
return [...$events.filter(e => getAddress(e) !== address), event]
})
}
const unsubscribers = [
on(
repository,
"update",
batch(150, (updates: RepositoryUpdate[]) => {
const {added, removed} = mergeRepositoryUpdates(updates)
on(repository, "update", ({added, removed}) => {
if (removed.size > 0) {
events.update($events => $events.filter(e => !removed.has(e.id)))
}
if (removed.size > 0) {
events.update($events => $events.filter(e => !removed.has(e.id)))
for (const event of added) {
if (matchFilters(filters, event)) {
insertEvent(event)
}
const matching = added.filter(event => matchFilters(filters, event))
if (matching.length > 0) {
insertEvents(matching)
}
}),
),
}
}),
on(tracker, "add", (id: string, trackerUrl: string) => {
if (trackerUrl === url) {
const event = repository.getEvent(id)
if (event && matchFilters(filters, event)) {
insertEvents([event])
insertEvent(event)
}
}
}),
+81 -121
View File
@@ -8,6 +8,7 @@ import {
on,
gt,
max,
find,
spec,
call,
first,
@@ -209,8 +210,6 @@ export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS
export const DUFFLEPUD_URL = "https://dufflepud.onrender.com"
export const THUMBNAIL_URL = import.meta.env.VITE_THUMBNAIL_URL
export const NIP46_PERMS =
"nip44_encrypt,nip44_decrypt," +
[
@@ -329,6 +328,8 @@ if (ENABLE_ZAPS) {
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, Poll]
export const MESSAGE_KINDS = [...CONTENT_KINDS, MESSAGE]
export const DM_KINDS = [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE]
// Settings
@@ -552,7 +553,7 @@ export const chatsById = call(() => {
setTimeout(() => {
addEvents(added)
removeEvents(removed)
}, 200)
}, 50)
}),
]
@@ -566,7 +567,7 @@ export const deriveChat = call(() => {
return (pubkeys: string[]) => _deriveChat(makeChatId(pubkeys))
})
export const chatSearch = derived(throttled(1500, chatsById), $chatsByPubkey => {
export const chatSearch = derived(throttled(800, chatsById), $chatsByPubkey => {
return createSearch(
sortBy(c => -c.last_activity, Array.from($chatsByPubkey.values())),
{
@@ -605,7 +606,7 @@ export const roomMetaEventsByIdByUrl = deriveEventsByIdByUrl({
})
export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByUrl => {
const result = new Map<string, Room[]>()
const metaByIdByUrl = new Map<string, Map<string, Room>>()
for (const [url, events] of roomMetaEventsByIdByUrl.entries()) {
const [metaEvents, deleteEvents] = partition(spec({kind: ROOM_META}), events.values())
@@ -617,8 +618,6 @@ export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByU
}
}
const metaById = new Map<string, Room>()
for (const event of metaEvents) {
const meta = tryCatch(() => readRoomMeta(event))
@@ -626,14 +625,22 @@ export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByU
continue
}
let metaById = metaByIdByUrl.get(url)
if (!metaById) {
metaById = new Map()
metaByIdByUrl.set(url, metaById)
}
const id = makeRoomId(url, meta.h)
metaById.set(id, {...meta, url, id})
}
}
if (metaById.size > 0) {
result.set(url, Array.from(metaById.values()))
}
const result = new Map<string, Room[]>()
for (const [url, metaById] of metaByIdByUrl.entries()) {
result.set(url, Array.from(metaById.values()))
}
return result
@@ -806,78 +813,36 @@ export const deriveOtherRooms = (url: string) =>
// Space/room memberships
const getSpaceMembers = (_url: string, events: TrustedEvent[]) => {
const members = new Set<string>()
for (const event of sortEventsAsc(events)) {
if (event.kind === RELAY_MEMBERS) {
members.clear()
for (const pubkey of uniq(getTagValues("member", event.tags))) {
members.add(pubkey)
}
continue
}
const pubkeys = getPubkeyTagValues(event.tags)
if (event.kind === RELAY_ADD_MEMBER) {
for (const pubkey of pubkeys) {
members.add(pubkey)
}
}
if (event.kind === RELAY_REMOVE_MEMBER) {
for (const pubkey of pubkeys) {
members.delete(pubkey)
}
}
}
return Array.from(members)
}
const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => {
const members = new Set<string>()
for (const event of sortEventsAsc(events)) {
if (event.kind === ROOM_MEMBERS && getTagValue("d", event.tags) === h) {
members.clear()
for (const pubkey of uniq(getPubkeyTagValues(event.tags))) {
members.add(pubkey)
}
continue
}
if (getTagValue("h", event.tags) !== h) {
continue
}
const pubkeys = getPubkeyTagValues(event.tags)
if (event.kind === ROOM_ADD_MEMBER) {
for (const pubkey of pubkeys) {
members.add(pubkey)
}
}
if (event.kind === ROOM_REMOVE_MEMBER) {
for (const pubkey of pubkeys) {
members.delete(pubkey)
}
}
}
return Array.from(members)
}
export const deriveSpaceMembers = (url: string) =>
derived(
deriveRelaySignedEvents(url, [{kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS]}]),
$events => getSpaceMembers(url, $events),
$events => {
const membersEvent = $events.find(spec({kind: RELAY_MEMBERS}))
if (membersEvent) {
return uniq(getTagValues("member", membersEvent.tags))
}
const members = new Set<string>()
for (const event of sortBy(e => e.created_at, $events)) {
const pubkeys = getPubkeyTagValues(event.tags)
if (event.kind === RELAY_ADD_MEMBER) {
for (const pubkey of pubkeys) {
members.add(pubkey)
}
}
if (event.kind === RELAY_REMOVE_MEMBER) {
for (const pubkey of pubkeys) {
members.delete(pubkey)
}
}
}
return Array.from(members)
},
)
export type BannedPubkeyItem = {
@@ -904,7 +869,33 @@ export const deriveRoomMembers = (url: string, h: string) => {
{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]},
]
return derived(deriveEventsForUrl(url, filters), $events => getRoomMembers(url, h, $events))
return derived(deriveEventsForUrl(url, filters), $events => {
const membersEvent = find(spec({kind: ROOM_MEMBERS}), $events)
if (membersEvent) {
return uniq(getPubkeyTagValues(membersEvent.tags))
}
const members = new Set<string>()
for (const event of sortEventsAsc($events)) {
const pubkeys = getPubkeyTagValues(event.tags)
if (event.kind === ROOM_ADD_MEMBER) {
for (const pubkey of pubkeys) {
members.add(pubkey)
}
}
if (event.kind === ROOM_REMOVE_MEMBER) {
for (const pubkey of pubkeys) {
members.delete(pubkey)
}
}
}
return Array.from(members)
})
}
export const deriveRoomAdmins = (url: string, h: string) => {
@@ -928,7 +919,7 @@ export const deriveSpaceActionItems = (url: string) =>
derived(
deriveEventsForUrl(url, [
{
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER],
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS],
},
]),
$events => {
@@ -941,50 +932,19 @@ export const deriveSpaceActionItems = (url: string) =>
for (const [h, roomEvents] of groupBy(getRoomId, $events)) {
if (!h) continue
const roomJoins: TrustedEvent[] = []
const roomLeaves: TrustedEvent[] = []
const roomMembershipEvents: TrustedEvent[] = []
for (const event of roomEvents) {
switch (event.kind) {
case ROOM_JOIN:
roomJoins.push(event)
break
case ROOM_LEAVE:
roomLeaves.push(event)
break
case ROOM_MEMBERS:
case ROOM_ADD_MEMBER:
case ROOM_REMOVE_MEMBER:
roomMembershipEvents.push(event)
break
}
}
const roomMembers = new Set(getRoomMembers(url, h, roomMembershipEvents))
const roomJoins = roomEvents.filter(spec({kind: ROOM_JOIN}))
const roomLeaves = roomEvents.filter(spec({kind: ROOM_LEAVE}))
const roomMembersEvent = roomEvents.find(spec({kind: ROOM_MEMBERS}))
const roomMembers = getTagValues("p", roomMembersEvent?.tags ?? [])
pendingJoins.push(
...removeUndefined(
Array.from(groupBy(e => e.pubkey, roomJoins).values()).map(events =>
first(sortEventsDesc(events)),
),
Array.from(groupBy(e => e.pubkey, roomJoins).values())
.map(sortEventsDesc)
.map(first),
).filter(({pubkey, created_at}) => {
if (roomMembers.has(pubkey)) return false
if (
roomMembershipEvents.some(event => {
if (event.created_at <= created_at) {
return false
}
if (event.kind === ROOM_MEMBERS) {
return true
}
return getPubkeyTagValues(event.tags).includes(pubkey)
})
) {
return false
}
if (roomMembers.includes(pubkey)) return false
if (gt(roomMembersEvent?.created_at, created_at)) return false
if (roomLeaves.some(e => e.pubkey === pubkey && e.created_at > created_at)) return false
return true
+70 -93
View File
@@ -1,8 +1,8 @@
import {page} from "$app/stores"
import type {Unsubscriber} from "svelte/store"
import {last, call, ifLet, assoc, chunk, WEEK, ago} from "@welshman/lib"
import {derived, get} from "svelte/store"
import {last, call, ifLet, assoc, chunk, sleep, identity, WEEK, ago} from "@welshman/lib"
import {PollResponse} from "nostr-tools/kinds"
import {merged} from "@welshman/store"
import {
getListTags,
getRelayTagValues,
@@ -13,22 +13,20 @@ import {
ROOM_MEMBERS,
ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER,
ROOM_JOIN,
ROOM_LEAVE,
ROOM_CREATE_PERMISSION,
RELAY_MEMBERS,
RELAY_ADD_MEMBER,
RELAY_REMOVE_MEMBER,
MESSAGE,
isSignedEvent,
unionFilters,
getTagValue,
} from "@welshman/util"
import type {Filter, List, PublishedList, TrustedEvent} from "@welshman/util"
import type {Filter, TrustedEvent} from "@welshman/util"
import {request, requestOne, Difference, DifferenceEvent} from "@welshman/net"
import {
pubkey,
loadRelay,
userFollowList,
userRelayList,
userMessagingRelayList,
loadRelayList,
@@ -44,12 +42,14 @@ import {
} from "@welshman/app"
import {
REACTION_KINDS,
MESSAGE_KINDS,
CONTENT_KINDS,
INDEXER_RELAYS,
loadSettings,
loadGroupList,
userSpaceUrls,
userGroupList,
bootstrapPubkeys,
decodeRelay,
getSpaceUrlsFromGroupList,
getSpaceRoomsFromGroupList,
@@ -57,7 +57,7 @@ import {
loadFeedsForPubkey,
} from "@app/core/state"
import {hasBlossomSupport} from "@app/core/commands"
import {LIVEKIT_PARTICIPANTS} from "@app/call/voice"
import {LIVEKIT_PARTICIPANTS} from "@app/voice"
// Utils
@@ -74,8 +74,6 @@ const pullOneWithFallback = async (
signal: AbortSignal,
onEvent?: (event: TrustedEvent) => void,
) => {
if (signal.aborted) return
const cachedEvents = repository.query([filter]).filter(isSignedEvent)
const since = last(cachedEvents.slice(10))?.created_at || 0
@@ -88,12 +86,6 @@ const pullOneWithFallback = async (
const shouldFallback =
!hasNegentropy(url) ||
(await new Promise(resolve => {
if (signal.aborted) {
resolve(false)
return
}
// If teardown wins while the diff is opening, skip the fallback path and let cleanup stay in control.
const diff = new Difference({relay: url, filter, events: cachedEvents, signal})
diff.on(DifferenceEvent.Error, () => {
@@ -119,7 +111,9 @@ export const pullWithFallback = async ({url, signal, filters, onEvent}: SyncOpts
if (signal.aborted) return
await Promise.all(filters.map(filter => pullOneWithFallback(url, filter, signal, onEvent)))
for (const filter of filters) {
pullOneWithFallback(url, filter, signal, onEvent)
}
}
const listen = ({url, signal, filters, onEvent}: SyncOpts) => {
@@ -129,8 +123,6 @@ const listen = ({url, signal, filters, onEvent}: SyncOpts) => {
}
const pullAndListen = (options: SyncOpts) => {
if (options.signal.aborted) return
pullWithFallback(options)
listen(options)
}
@@ -205,7 +197,7 @@ const syncUserRoomMembership = (url: string, h: string) => {
const syncUserData = () => {
const unsubscribersByKey = new Map<string, Unsubscriber>()
const syncGroupList = ($userGroupList: List | undefined) => {
const unsubscribeGroupList = userGroupList.subscribe($userGroupList => {
if ($userGroupList) {
const keys = new Set<string>()
@@ -234,35 +226,43 @@ const syncUserData = () => {
}
}
}
}
const syncRelayList = ($userRelayList: PublishedList | undefined) => {
const pubkey = $userRelayList?.event?.pubkey
if (!pubkey) return
loadBlossomServerList(pubkey)
loadBlockedRelayList(pubkey)
loadFollowList(pubkey)
loadGroupList(pubkey)
loadMuteList(pubkey)
loadProfile(pubkey)
loadSettings(pubkey)
loadFeedsForPubkey(pubkey)
}
const unsubscribeGroupList = merged([userGroupList]).subscribe(([$userGroupList]) => {
syncGroupList($userGroupList)
})
const unsubscribeRelayList = merged([userRelayList]).subscribe(([$userRelayList]) => {
syncRelayList($userRelayList)
const unsubscribeRelayList = userRelayList.subscribe($userRelayList => {
if ($userRelayList) {
loadBlossomServerList($userRelayList.event.pubkey)
loadBlockedRelayList($userRelayList.event.pubkey)
loadFollowList($userRelayList.event.pubkey)
loadGroupList($userRelayList.event.pubkey)
loadMuteList($userRelayList.event.pubkey)
loadProfile($userRelayList.event.pubkey)
loadSettings($userRelayList.event.pubkey)
loadFeedsForPubkey($userRelayList.event.pubkey)
}
})
const unsubscribeFollows = userFollowList.subscribe(async $userFollowList => {
for (const pubkeys of chunk(10, get(bootstrapPubkeys))) {
// This isn't urgent, avoid clogging other stuff up
await sleep(1000)
await Promise.all(
pubkeys.flatMap(pk => [
loadRelayList(pk),
loadGroupList(pk),
loadProfile(pk),
loadFollowList(pk),
loadMuteList(pk),
]),
)
}
})
return () => {
unsubscribersByKey.forEach(call)
unsubscribeGroupList()
unsubscribeRelayList()
unsubscribeFollows()
}
}
@@ -280,13 +280,8 @@ const syncSpace = (url: string, rooms: string[]) => {
url,
signal: controller.signal,
filters: [
{kinds: [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS], "#d": [room]},
{kinds: [MESSAGE, ...CONTENT_KINDS], since, "#h": [room]},
{kinds: MESSAGE_KINDS, since, "#h": [room]},
makeCommentFilter(CONTENT_KINDS, {since, "#h": [room]}),
{
kinds: [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER],
"#h": [room],
},
{kinds: [PollResponse], since},
],
})
@@ -299,13 +294,13 @@ const syncSpace = (url: string, rooms: string[]) => {
const relayKinds = [RELAY_MEMBERS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]
const roomMetaKinds = [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS]
const roomMemberKinds = [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER]
const roomMemberKinds = [ROOM_DELETE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER]
pullAndListen({
url,
signal: controller.signal,
filters: [
{kinds: [...relayKinds, ...roomMetaKinds, ...roomMemberKinds, ...CONTENT_KINDS, MESSAGE]},
{kinds: [...relayKinds, ...roomMetaKinds, ...roomMemberKinds, ...MESSAGE_KINDS]},
makeCommentFilter(CONTENT_KINDS, {since}),
{kinds: [PollResponse], since},
],
@@ -326,16 +321,15 @@ const syncSpace = (url: string, rooms: string[]) => {
}
const syncSpaces = () => {
const store = merged([userGroupList, page])
const store = derived([userGroupList, page], identity)
const unsubscribersByUrl = new Map<string, Unsubscriber>()
const roomsByUrl = new Map<string, string>()
const unsubscribe = store.subscribe(([$userGroupList, $page]) => {
const urls = new Set(getSpaceUrlsFromGroupList($userGroupList))
const currentUrl = $page.params.relay ? decodeRelay($page.params.relay) : undefined
if (currentUrl) {
urls.add(currentUrl)
if ($page.params.relay) {
urls.add(decodeRelay($page.params.relay))
}
// Stop syncing removed spaces
@@ -350,11 +344,6 @@ const syncSpaces = () => {
// Start or restart syncing for each space
for (const url of urls) {
const rooms = getSpaceRoomsFromGroupList(url, $userGroupList)
if (currentUrl === url && $page.params.h && !rooms.includes($page.params.h)) {
rooms.push($page.params.h)
}
const roomsKey = rooms.join(",")
if (unsubscribersByUrl.has(url) && roomsByUrl.get(url) === roomsKey) continue
@@ -394,7 +383,6 @@ const syncDMs = () => {
const unsubscribersByUrl = new Map<string, Unsubscriber>()
let currentPubkey: string | undefined
let currentShouldUnwrap = false
const unsubscribeAll = () => {
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
@@ -403,34 +391,6 @@ const syncDMs = () => {
}
}
const syncPubkey = ($pubkey: string | undefined, $shouldUnwrap: boolean) => {
if ($pubkey !== currentPubkey) {
unsubscribeAll()
}
if ($pubkey && $shouldUnwrap) {
loadRelayList($pubkey)
.then(() => loadMessagingRelayList($pubkey))
.then($l => {
if ($l && currentPubkey === $pubkey && currentShouldUnwrap === $shouldUnwrap) {
subscribeAll($pubkey, getRelayTagValues(getListTags($l)))
}
})
}
currentPubkey = $pubkey
currentShouldUnwrap = $shouldUnwrap
}
const syncList = ($userMessagingRelayList: List | undefined) => {
const $pubkey = pubkey.get()
const $shouldUnwrap = shouldUnwrap.get()
if ($pubkey && $shouldUnwrap) {
subscribeAll($pubkey, getRelayTagValues(getListTags($userMessagingRelayList)))
}
}
const subscribeAll = (pubkey: string, urls: string[]) => {
// Start syncing newly added relays
for (const url of urls) {
@@ -448,17 +408,34 @@ const syncDMs = () => {
}
}
const unsubscribePubkey = merged([pubkey, shouldUnwrap]).subscribe(([$pubkey, $shouldUnwrap]) => {
syncPubkey($pubkey, $shouldUnwrap)
})
// When pubkey changes, re-sync
const unsubscribePubkey = derived([pubkey, shouldUnwrap], identity).subscribe(
([$pubkey, $shouldUnwrap]) => {
if ($pubkey !== currentPubkey) {
unsubscribeAll()
}
// When user messaging relays change, update synchronization
const unsubscribeList = merged([userMessagingRelayList]).subscribe(
([$userMessagingRelayList]) => {
syncList($userMessagingRelayList)
// If we have a pubkey, refresh our user's relay list then sync our subscriptions
if ($pubkey && $shouldUnwrap) {
loadRelayList($pubkey)
.then(() => loadMessagingRelayList($pubkey))
.then($l => subscribeAll($pubkey, getRelayTagValues(getListTags($l))))
}
currentPubkey = $pubkey
},
)
// When user messaging relays change, update synchronization
const unsubscribeList = userMessagingRelayList.subscribe($userMessagingRelayList => {
const $pubkey = pubkey.get()
const $shouldUnwrap = shouldUnwrap.get()
if ($pubkey && $shouldUnwrap) {
subscribeAll($pubkey, getRelayTagValues(getListTags($userMessagingRelayList)))
}
})
return () => {
unsubscribeAll()
unsubscribePubkey()
+6 -11
View File
@@ -4,25 +4,20 @@
type Props = {
editor: Promise<Editor>
autofocus?: boolean
}
const {editor, autofocus}: Props = $props()
const {editor}: Props = $props()
let element: HTMLElement
onMount(() => {
editor.then(ed => {
if (ed.options.element) {
element?.append(ed.options.element)
editor.then(({options}) => {
if (options.element) {
element?.append(options.element)
}
if (autofocus) {
const hasContent = ed.getText().trim().length > 0
requestAnimationFrame(() => {
ed.commands.focus(hasContent ? "end" : "start")
})
if (options.autofocus) {
;(element?.querySelector("[contenteditable]") as HTMLElement)?.focus()
}
})
})
+6 -8
View File
@@ -25,9 +25,9 @@ import {pushToast} from "@app/util/toast"
export const makeEditor = async ({
encryptFiles = false,
aggressive = false,
autofocus = false,
charCount,
content = "",
onChange,
placeholder = "",
url,
submit,
@@ -36,9 +36,9 @@ export const makeEditor = async ({
}: {
encryptFiles?: boolean
aggressive?: boolean
autofocus?: boolean
charCount?: Writable<number>
content?: string | object
onChange?: (json: object) => void
content?: string
placeholder?: string
url?: string
submit: () => void
@@ -82,8 +82,9 @@ export const makeEditor = async ({
},
)
const ed = new Editor({
content: typeof content === "string" ? escapeHtml(content) : content,
return new Editor({
content: escapeHtml(content),
autofocus,
editorProps,
element: document.createElement("div"),
extensions: [
@@ -141,9 +142,6 @@ export const makeEditor = async ({
onUpdate({editor}) {
wordCount?.set(editor.storage.wordCount.words)
charCount?.set(editor.storage.wordCount.chars)
onChange?.(editor.getJSON())
},
})
return ed
}
-21
View File
@@ -1,21 +0,0 @@
const store = new Map<string, unknown>()
export class DraftKey<T> {
constructor(private key: string) {}
get(): T | undefined {
return store.get(this.key) as T | undefined
}
set(value: T): void {
store.set(this.key, value)
}
update(value: Partial<T>): void {
this.set({...this.get(), ...value} as T)
}
clear(): void {
store.delete(this.key)
}
}
+1 -33
View File
@@ -1,4 +1,4 @@
import {get, writable} from "svelte/store"
import {writable} from "svelte/store"
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker} from "@welshman/signer"
import {makeSecret} from "@welshman/util"
@@ -11,22 +11,6 @@ import {
} from "@app/core/state"
import {pushToast} from "@app/util/toast"
const APP_SCHEME = "social.flotilla"
const makeSignerCallbackUrl = (path: string) => `${APP_SCHEME}://x-callback-url/${path}`
const makeSignerLaunchUrl = (nostrconnectUrl: string) => {
const params = new URLSearchParams({
method: "connect",
nostrconnect: nostrconnectUrl,
"x-source": APP_SCHEME,
"x-success": makeSignerCallbackUrl("authSuccess"),
"x-error": makeSignerCallbackUrl("authError"),
})
return `nostrsigner://x-callback-url/auth/nip46?${params.toString()}`
}
export class Nip46Controller {
url = writable("")
bunker = writable("")
@@ -70,22 +54,6 @@ export class Nip46Controller {
}
}
launchSigner() {
const nostrconnectUrl = get(this.url)
const signerUrl = nostrconnectUrl && makeSignerLaunchUrl(nostrconnectUrl)
if (!signerUrl) {
pushToast({
theme: "error",
message: "Unable to open signer app right now. Please try again.",
})
return
}
window.location.href = signerUrl
}
stop() {
this.broker.cleanup()
this.abortController.abort()
+3 -3
View File
@@ -5,10 +5,10 @@ import {pubkey, tracker, repository, relaysByUrl} from "@welshman/app"
import {assoc, prop, first, identity, groupBy, now} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {deriveEventsByIdByUrl} from "@welshman/store"
import {sortEventsDesc, getTagValue, MESSAGE} from "@welshman/util"
import {sortEventsDesc, getTagValue} from "@welshman/util"
import {makeSpacePath, makeRoomPath, makeSpaceChatPath, makeChatPath} from "@app/util/routes"
import {
CONTENT_KINDS,
MESSAGE_KINDS,
notificationSettings,
chatsById,
userGroupList,
@@ -85,7 +85,7 @@ export const allNotifications = derived(
deriveEventsByIdByUrl({
tracker,
repository,
filters: [{kinds: [MESSAGE, ...CONTENT_KINDS]}, makeCommentFilter(CONTENT_KINDS)],
filters: [{kinds: MESSAGE_KINDS}, makeCommentFilter(MESSAGE_KINDS)],
}),
],
identity,
+3 -6
View File
@@ -17,13 +17,13 @@ import {
getRelaysFromList,
getTagValue,
matchFilters,
MESSAGE,
type Filter,
type TrustedEvent,
} from "@welshman/util"
import {
DM_KINDS,
CONTENT_KINDS,
MESSAGE_KINDS,
notificationSettings,
pushState,
shouldNotify,
@@ -45,10 +45,7 @@ export type PushPermissionResult = {
}
export const onNotification = call(() => {
const allFilters = [
{kinds: [MESSAGE, ...CONTENT_KINDS, ...DM_KINDS]},
makeCommentFilter(CONTENT_KINDS),
]
const allFilters = [{kinds: [...MESSAGE_KINDS, ...DM_KINDS]}, makeCommentFilter(MESSAGE_KINDS)]
const filters = allFilters.map(assoc("since", now()))
const subscribers: Subscriber<TrustedEvent>[] = []
@@ -161,7 +158,7 @@ export const syncRelaySubscriptions = (
userSettingsValues,
]).subscribe(
throttle(3000, ([$userSpaceUrls, {spaces, mentions}, {alerts}]) => {
const baseFilters = [{kinds: [MESSAGE, ...CONTENT_KINDS]}, makeCommentFilter(CONTENT_KINDS)]
const baseFilters = [{kinds: MESSAGE_KINDS}, makeCommentFilter(CONTENT_KINDS)]
for (const url of $userSpaceUrls) {
const {notify = true, exceptions = []} = alerts.find(spec({url})) || {}
+4 -17
View File
@@ -48,18 +48,6 @@ import {
import type {Unsubscriber} from "svelte/store"
import {db} from "@app/core/storage"
// Shared interval for all non-critical store flushes, so they batch on the same cadence
const FLUSH_INTERVAL = 3000
// Wraps a write callback to run during idle time (non-critical persistence)
const idleWrite = <T>(f: (xs: T[]) => void): ((xs: T[]) => void) => {
if (typeof requestIdleCallback !== "undefined") {
return (xs: T[]) => requestIdleCallback(() => f(xs))
}
return f
}
const kinds = {
meta: [PROFILE, FOLLOWS, MUTES, RELAYS, BLOSSOM_SERVERS, MESSAGING_RELAYS, APP_DATA, ROOMS],
alert: [ALERT_STATUS, ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID],
@@ -211,15 +199,14 @@ const loadCriticalRelays = async () => {
relaysByUrl.set(indexBy(r => r.url, await table.getAll()))
}
const syncRelays = () =>
onRelay(batch(FLUSH_INTERVAL, idleWrite(db.table<RelayProfile>("relays").bulkPut)))
const syncRelays = () => onRelay(batch(1000, db.table<RelayProfile>("relays").bulkPut))
const initRelayStats = async () => {
const table = db.table<RelayStats>("relayStats")
relayStatsByUrl.set(indexBy(r => r.url, await table.getAll()))
return onRelayStats(batch(FLUSH_INTERVAL, idleWrite(table.bulkPut)))
return onRelayStats(batch(1000, table.bulkPut))
}
const initHandles = async () => {
@@ -227,7 +214,7 @@ const initHandles = async () => {
handlesByNip05.set(indexBy(r => r.nip05, await table.getAll()))
return onHandle(batch(FLUSH_INTERVAL, idleWrite(table.bulkPut)))
return onHandle(batch(1000, table.bulkPut))
}
const initZappers = async () => {
@@ -235,7 +222,7 @@ const initZappers = async () => {
zappersByLnurl.set(indexBy(z => z.lnurl, await table.getAll()))
return onZapper(batch(FLUSH_INTERVAL, idleWrite(table.bulkPut)))
return onZapper(batch(3000, table.bulkPut))
}
const initPlaintext = async () => {
+47 -66
View File
@@ -4,35 +4,21 @@
*/
import {
DisconnectReason,
LocalParticipant,
LocalTrackPublication,
Room as LiveKitRoom,
RoomEvent,
Track,
supportsAudioOutputSelection,
type AudioCaptureOptions,
type LocalParticipant,
} from "livekit-client"
import {derived, get} from "svelte/store"
import {derived, get, writable} from "svelte/store"
import {map, removeUndefined, uniqBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
import {signer} from "@welshman/app"
import {getLivekitEndpoint} from "$lib/livekit"
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
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 {deriveLatestEventForUrl, deriveRoom, makeRoomId, type Room} from "@app/core/state"
import {pushToast} from "@app/util/toast"
export const LIVEKIT_PARTICIPANTS = 39004
@@ -41,12 +27,30 @@ export {checkRelayHasLivekit} from "$lib/livekit"
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"
export enum DeviceKind {
AudioInput = "audioinput",
AudioOutput = "audiooutput",
VideoInput = "videoinput",
}
export const switchVoiceActiveDevice = async (
@@ -67,14 +71,17 @@ export const switchVoiceActiveDevice = async (
case DeviceKind.AudioOutput:
label = "speaker"
break
case DeviceKind.VideoInput:
label = "camera"
break
}
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) => {
participantPubkeyMap.update(m => {
const next = new Map(m)
@@ -91,6 +98,24 @@ 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 (
url: string,
groupId: string,
@@ -172,9 +197,7 @@ const setUpMicrophone = async (
}
const onRoomDisconnected = (reason?: DisconnectReason) => {
videoPrimaryTileKey.set(undefined)
currentVoiceSession.set(undefined)
resetVideoCallLayout()
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
voiceState.set(VoiceState.Disconnected)
const message =
@@ -193,16 +216,11 @@ const onTrackSubscribed = (track: Track) => {
element.style.display = "none"
document.body.appendChild(element)
element.play().catch(() => {})
} else if (track.kind === Track.Kind.Video) {
triggerVideoFeedCount()
}
}
const onTrackUnsubscribed = (track: Track) => {
track.detach().forEach(el => el.remove())
if (track.kind === Track.Kind.Video) {
triggerVideoFeedCount()
}
}
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
@@ -223,17 +241,6 @@ const onParticipantDisconnected = (participant: {identity: string}) => {
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
export const cancelJoinVoiceRoom = () => {
@@ -271,7 +278,6 @@ export const joinVoiceRoom = async (
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
try {
@@ -295,14 +301,7 @@ export const joinVoiceRoom = async (
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
currentVoiceSession.set({
url,
h,
room: liveKitRoom,
muted,
cameraOn: false,
screenShareOn: false,
})
currentVoiceSession.set({url, h, room: liveKitRoom, muted})
voiceState.set(VoiceState.Connected)
playJoinSound()
} catch (e) {
@@ -321,26 +320,8 @@ export const leaveVoiceRoom = async () => {
const audio = new Audio("/leave-voice-room.mp3")
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)
videoPrimaryTileKey.set(undefined)
currentVoiceSession.set(undefined)
resetVideoCallLayout()
session.room.disconnect()
speakingParticipants.set([])
participantPubkeyMap.set(new Map())
-4
View File
@@ -1,4 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.0867 21.3879L13.7321 21.77L13.0867 21.3879ZM13.6288 20.4721L12.9833 20.0901L13.6288 20.4721ZM10.3712 20.4721L9.72579 20.8541H9.72579L10.3712 20.4721ZM10.9133 21.3879L11.5587 21.0059L10.9133 21.3879ZM2.3806 15.9137L3.07351 15.6266V15.6266L2.3806 15.9137ZM7.78958 18.9917L7.77666 19.7416L7.78958 18.9917ZM5.08658 18.6196L4.79957 19.3126H4.79957L5.08658 18.6196ZM21.6194 15.9137L22.3123 16.2007V16.2007L21.6194 15.9137ZM16.2104 18.9917L16.1975 18.2418L16.2104 18.9917ZM18.9134 18.6196L19.2004 19.3126H19.2004L18.9134 18.6196ZM19.6125 2.73704L19.2206 3.37652L19.6125 2.73704ZM21.2632 4.38775L21.9027 3.99588V3.99588L21.2632 4.38775ZM4.38751 2.73704L3.99563 2.09756V2.09756L4.38751 2.73704ZM2.7368 4.38775L2.09732 3.99588H2.09732L2.7368 4.38775ZM9.40279 19.2101L9.77986 18.5618L9.77986 18.5618L9.40279 19.2101ZM13.7321 21.77L14.2742 20.8541L12.9833 20.0901L12.4412 21.0059L13.7321 21.77ZM9.72579 20.8541L10.2679 21.77L11.5587 21.0059L11.0166 20.0901L9.72579 20.8541ZM12.4412 21.0059C12.2485 21.3316 11.7515 21.3316 11.5587 21.0059L10.2679 21.77C11.0415 23.0769 12.9585 23.0769 13.7321 21.77L12.4412 21.0059ZM10.5 2.75024H13.5V1.25024H10.5V2.75024ZM21.25 10.5002V11.5002H22.75V10.5002H21.25ZM2.75 11.5002V10.5002H1.25V11.5002H2.75ZM1.25 11.5002C1.25 12.6548 1.24959 13.5583 1.29931 14.2871C1.3495 15.0225 1.45323 15.6346 1.68769 16.2007L3.07351 15.6266C2.92737 15.2738 2.84081 14.8441 2.79584 14.185C2.75041 13.5191 2.75 12.6754 2.75 11.5002H1.25ZM7.8025 18.2418C6.54706 18.2202 5.88923 18.1403 5.37359 17.9267L4.79957 19.3126C5.60454 19.646 6.52138 19.72 7.77666 19.7416L7.8025 18.2418ZM1.68769 16.2007C2.27128 17.6096 3.39066 18.729 4.79957 19.3126L5.3736 17.9267C4.33223 17.4954 3.50486 16.668 3.07351 15.6266L1.68769 16.2007ZM21.25 11.5002C21.25 12.6754 21.2496 13.5191 21.2042 14.185C21.1592 14.8441 21.0726 15.2738 20.9265 15.6266L22.3123 16.2007C22.5468 15.6346 22.6505 15.0225 22.7007 14.2871C22.7504 13.5583 22.75 12.6548 22.75 11.5002H21.25ZM16.2233 19.7416C17.4786 19.72 18.3955 19.646 19.2004 19.3126L18.6264 17.9267C18.1108 18.1403 17.4529 18.2202 16.1975 18.2418L16.2233 19.7416ZM20.9265 15.6266C20.4951 16.668 19.6678 17.4954 18.6264 17.9267L19.2004 19.3126C20.6093 18.729 21.7287 17.6096 22.3123 16.2007L20.9265 15.6266ZM13.5 2.75024C15.1512 2.75024 16.337 2.75104 17.2619 2.83898C18.1757 2.92586 18.7571 3.09247 19.2206 3.37652L20.0044 2.09756C19.2655 1.64481 18.4274 1.44303 17.4039 1.34571C16.3915 1.24945 15.1222 1.25024 13.5 1.25024V2.75024ZM22.75 10.5002C22.75 8.87805 22.7508 7.60874 22.6545 6.59635C22.5572 5.5728 22.3554 4.7347 21.9027 3.99588L20.6237 4.77962C20.9078 5.24315 21.0744 5.82458 21.1613 6.73833C21.2492 7.66325 21.25 8.84901 21.25 10.5002H22.75ZM19.2206 3.37652C19.7925 3.72696 20.2733 4.20776 20.6237 4.77963L21.9027 3.99588C21.4286 3.22218 20.7781 2.57168 20.0044 2.09756L19.2206 3.37652ZM10.5 1.25024C8.87781 1.25024 7.6085 1.24945 6.59611 1.34571C5.57256 1.44303 4.73445 1.64481 3.99563 2.09756L4.77938 3.37652C5.24291 3.09247 5.82434 2.92586 6.73809 2.83898C7.663 2.75104 8.84876 2.75024 10.5 2.75024V1.25024ZM2.75 10.5002C2.75 8.84901 2.75079 7.66325 2.83873 6.73833C2.92561 5.82458 3.09223 5.24315 3.37628 4.77963L2.09732 3.99588C1.64457 4.7347 1.44279 5.5728 1.34547 6.59635C1.24921 7.60874 1.25 8.87805 1.25 10.5002H2.75ZM3.99563 2.09756C3.22194 2.57168 2.57144 3.22218 2.09732 3.99588L3.37628 4.77963C3.72672 4.20776 4.20752 3.72696 4.77938 3.37652L3.99563 2.09756ZM11.0166 20.0901C10.8136 19.747 10.6354 19.4444 10.4621 19.2066C10.2795 18.9562 10.0702 18.7306 9.77986 18.5618L9.02572 19.8584C9.07313 19.886 9.13772 19.9362 9.24985 20.0901C9.37122 20.2566 9.50835 20.4867 9.72579 20.8541L11.0166 20.0901ZM7.77666 19.7416C8.21575 19.7492 8.49387 19.7547 8.70588 19.7782C8.90399 19.8001 8.98078 19.8323 9.02572 19.8584L9.77986 18.5618C9.4871 18.3915 9.18246 18.3218 8.87097 18.2873C8.57339 18.2543 8.21375 18.2489 7.8025 18.2418L7.77666 19.7416ZM14.2742 20.8541C14.4916 20.4867 14.6287 20.2566 14.7501 20.0901C14.8622 19.9362 14.9268 19.886 14.9742 19.8584L14.2201 18.5618C13.9298 18.7306 13.7204 18.9562 13.5379 19.2066C13.3646 19.4444 13.1864 19.747 12.9833 20.0901L14.2742 20.8541ZM16.1975 18.2418C15.7862 18.2489 15.4266 18.2543 15.129 18.2873C14.8175 18.3218 14.5129 18.3915 14.2201 18.5618L14.9742 19.8584C15.0192 19.8323 15.096 19.8001 15.2941 19.7782C15.5061 19.7547 15.7842 19.7492 16.2233 19.7416L16.1975 18.2418Z" fill="#000000"/>
<path d="M12 7.5V14.5M8.5 11H15.5" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

+1 -2
View File
@@ -14,10 +14,9 @@
style?: string
disabled?: boolean
"data-tip"?: string
"aria-pressed"?: boolean
} = $props()
const className = $derived(`text-left cursor-pointer ${restProps.class}`)
const className = $derived(`text-left ${restProps.class}`)
const onClick = (e: Event) => {
e.preventDefault()
+2 -2
View File
@@ -12,8 +12,8 @@
</script>
<div class="btn flex h-[unset] w-full flex-nowrap py-4 text-left {props.class}">
<div class="flex grow flex-row items-start gap-4">
<div class="flex h-14 w-12 shrink-0 items-center justify-center">
<div class="flex flex-grow flex-row items-start gap-4">
<div class="flex h-14 w-12 flex-shrink-0 items-center justify-center">
{@render props.icon?.()}
</div>
<div class="flex flex-col gap-1">
+3 -2
View File
@@ -6,6 +6,7 @@
import Close from "@assets/icons/close.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {clearModals} from "@app/util/modal"
type Props = {
onClose?: any
@@ -28,7 +29,7 @@
const innerClass = $derived(
cx(
"relative text-base-content text-base-content grow pointer-events-auto",
"relative text-base-content text-base-content flex-grow pointer-events-auto",
"rounded-t-box sm:rounded-box",
{
"bg-alt shadow-m max-h-[90vh] flex flex-col max-w-full pb-sai sm:pb-0": !fullscreen,
@@ -55,7 +56,7 @@
<div class={wrapperClass}>
<div class={innerClass} transition:fly>
{#if !noEscape}
<Button class={buttonClass} onclick={onClose}>
<Button class={buttonClass} onclick={clearModals}>
<Icon icon={Close} size={6} />
</Button>
{/if}
+2 -2
View File
@@ -9,9 +9,9 @@
</script>
<div class="flex items-center gap-2 p-2 text-xs uppercase opacity-50">
<div class="h-px grow bg-base-content opacity-25"></div>
<div class="h-px flex-grow bg-base-content opacity-25"></div>
{#if children}
<p>{@render children?.()}</p>
<div class="h-px grow bg-base-content opacity-25"></div>
<div class="h-px flex-grow bg-base-content opacity-25"></div>
{/if}
</div>
-25
View File
@@ -1,25 +0,0 @@
<script lang="ts">
import type {Snippet} from "svelte"
import cx from "classnames"
import Button from "@lib/components/Button.svelte"
const {
onclick = () => {},
className = "",
children,
}: {
onclick?: () => void
className?: string
children?: Snippet
} = $props()
</script>
<div class={cx("fixed bottom-20 right-4 z-nav hide-on-keyboard md:hidden", className)}>
<Button
class="btn btn-primary border-none shadow-xl hover:opacity-90 transition-all size-[50px] rounded-xl p-0"
{onclick}>
<div class="flex items-center justify-center">
{@render children?.()}
</div>
</Button>
</div>
+2 -2
View File
@@ -8,9 +8,9 @@
const {children}: Props = $props()
</script>
<div class="h-20 shrink-0"></div>
<div class="h-20 flex-shrink-0"></div>
<div class="flex absolute bottom-sai left-0 right-0 p-6 py-4 rounded-b-box bg-base-200">
<div class="flex grow gap-4 items-center justify-between">
<div class="flex flex-grow gap-4 items-center justify-between">
{@render children?.()}
</div>
</div>
+1 -1
View File
@@ -9,6 +9,6 @@
<div
data-component="Page"
class="relative grow flex flex-col min-w-0 ml-sai mb-sai mt-sai mr-sai bg-base-200 md:ml-0 md:mb-0 {props.class}">
class="relative flex-grow flex flex-col min-w-0 ml-sai mb-sai mt-sai mr-sai bg-base-200 md:ml-0 md:mb-0 {props.class}">
{@render props.children?.()}
</div>
+1 -6
View File
@@ -10,12 +10,7 @@
let {children, element = $bindable(), ...props}: Props = $props()
const className = $derived(
cx(
props.class,
"scroll-container z-feature flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-y-auto overflow-x-hidden",
),
)
const className = cx(props.class, "scroll-container z-feature overflow-y-auto overflow-x-hidden")
</script>
<div {...props} bind:this={element} data-component="PageContent" class={className}>
+2 -4
View File
@@ -4,17 +4,15 @@
interface Props {
class?: string
visible?: boolean
children?: Snippet
}
const {children, visible = false, ...props}: Props = $props()
const {children, ...props}: Props = $props()
</script>
<div
class={cx(
"mt-sai mb-sai max-h-screen w-60 min-h-0 shrink-0 flex-col gap-1 bg-base-300 z-nav",
visible ? "flex" : "hidden md:flex",
"mt-sai mb-sai max-h-screen w-60 min-h-0 flex-shrink-0 flex-col gap-1 bg-base-300 z-nav hidden md:flex",
props.class,
)}>
{@render children?.()}
+1 -1
View File
@@ -6,6 +6,6 @@
const {children}: Props = $props()
</script>
<div class="flex items-center justify-between px-1 py-2 text-sm font-bold uppercase">
<div class="flex items-center justify-between px-4 py-2 text-sm font-bold uppercase">
{@render children?.()}
</div>
+1 -1
View File
@@ -36,7 +36,7 @@
const active = $derived($page.url.pathname === href)
const wrapperClass = $derived(
cx(restProps.class, "relative flex shrink-0 items-center gap-3 text-left transition-all", {
cx(restProps.class, "relative flex flex-shrink-0 items-center gap-3 text-left transition-all", {
"hover:bg-base-100 hover:text-base-content": true,
"text-base-content bg-base-100": active,
"tooltip tooltip-right": title,
@@ -7,6 +7,6 @@
const {...props}: Props = $props()
</script>
<div class="flex flex-col gap-3 px-2 py-2 {props.class}">
<div class="flex flex-col gap-1 px-2 py-2 {props.class}">
{@render props.children?.()}
</div>
+6 -6
View File
@@ -1,7 +1,7 @@
<style>
:global(.tippy-box[data-theme~="tooltip"]) {
background-color: var(--color-neutral);
color: var(--color-neutral-content);
background-color: var(--neutral);
color: var(--neutral-content);
border-radius: 0.5rem;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
@@ -11,19 +11,19 @@
}
:global(.tippy-box[data-theme~="tooltip"][data-placement^="top"] > .tippy-arrow::before) {
border-top-color: var(--color-neutral);
border-top-color: var(--neutral);
}
:global(.tippy-box[data-theme~="tooltip"][data-placement^="bottom"] > .tippy-arrow::before) {
border-bottom-color: var(--color-neutral);
border-bottom-color: var(--neutral);
}
:global(.tippy-box[data-theme~="tooltip"][data-placement^="left"] > .tippy-arrow::before) {
border-left-color: var(--color-neutral);
border-left-color: var(--neutral);
}
:global(.tippy-box[data-theme~="tooltip"][data-placement^="right"] > .tippy-arrow::before) {
border-right-color: var(--color-neutral);
border-right-color: var(--neutral);
}
</style>

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