Compare commits
43 Commits
1.7.2
...
hodlbod/sandbox
| Author | SHA1 | Date | |
|---|---|---|---|
| 290585e974 | |||
| f1f2083c88 | |||
| f42889c3c2 | |||
| a75e1f96eb | |||
| 85c5293082 | |||
| 37efa6a62c | |||
| 1d5f91fb6c | |||
| ef18655776 | |||
| b786e858d9 | |||
| f4ebc4e99e | |||
| 65ca8a7fd8 | |||
| 7f1e98dcb2 | |||
| 4c19ee823b | |||
| 8e2dd8b278 | |||
| 8d35b3aad2 | |||
| 613cad31c0 | |||
| 3779a90f26 | |||
| 7470f28f31 | |||
| 17fb4e780b | |||
| 30c2a6ef79 | |||
| 0547e9513f | |||
| 70e5172f1b | |||
| 61c568a112 | |||
| ae2ba6f44d | |||
| f84006fbe4 | |||
| fed34a2747 | |||
| 80df16f97b | |||
| 18cb245599 | |||
| fd6cc84be6 | |||
| 9311cab3b2 | |||
| fceccf47be | |||
| fe20fbfd28 | |||
| 4f3a2a1660 | |||
| 1c8457a4bf | |||
| 8710043a02 | |||
| dc46b42cb6 | |||
| 2f1972e70a | |||
| c5fcf12165 | |||
| 61ed632579 | |||
| 86f4b75c52 | |||
| b26ab916d5 | |||
| c882198206 | |||
| 4aef27ffd5 |
+1
-1
@@ -9,4 +9,4 @@ build
|
||||
|
||||
# Env files (keep .env for build; exclude local overrides)
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.*.local
|
||||
|
||||
@@ -19,5 +19,6 @@ VITE_DEFAULT_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io
|
||||
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
|
||||
VITE_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=
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
@@ -28,6 +28,7 @@ node_modules/
|
||||
.pnpm-store/
|
||||
build/
|
||||
.svelte-kit/
|
||||
.next/
|
||||
|
||||
# Rust/Tauri
|
||||
*target/
|
||||
@@ -69,7 +70,9 @@ GoogleService-Info.plist
|
||||
.roo
|
||||
.idea/
|
||||
.vscode/
|
||||
.claude/
|
||||
|
||||
# OS generated
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
package-lock.json
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
## Project Overview
|
||||
|
||||
Flotilla is a Nostr "relays as groups" community chat client. It implements NIP-29 (relay-based groups) to create Discord-like spaces (servers) and rooms (channels).
|
||||
|
||||
Please visit our [issue tracker](https://gitea.coracle.social/coracle/flotilla/issues) to contribute. Any new issues should be opened without a milestone, label, or project and the project owners will triage them.
|
||||
|
||||
### Milestones
|
||||
|
||||
Milestones indicate how soon a given task should be tackled.
|
||||
|
||||
- [Current](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=1) issues are immediately actionable.
|
||||
- [Next](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=2) means an issue is blocked.
|
||||
- [Future](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=3) means we're deferring work until a later date.
|
||||
|
||||
### Labels
|
||||
|
||||
- [Design](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=15) issues need design work before being implemented. This might take the form of a high-quality mockup, wireframes, user flows, or just a couple notes about where things go, depending on the nature of the task.
|
||||
- [Dev](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=16) issues are ready to be implemented. Most of the work will be related to architecting and writing code.
|
||||
- [Easy](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=14) issues have no dependencies, and are scoped quite narrowly.
|
||||
- [Priority](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=6) issues include bugs and urgent feature requests. These should get attention first if possible, although sometimes long-standing performance issues or subtle bugs might end up here for a while.
|
||||
- [Ideas](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=13) are for things that aren't scoped out yet, or which need protocol work before getting designed or implemented.
|
||||
|
||||
### Projects
|
||||
|
||||
Issues may or may not have a project. Projects are used to group issues thematically just for organization.
|
||||
|
||||
## Coding conventions
|
||||
|
||||
There are a few conventions that are helpful to know right out of the gate.
|
||||
|
||||
- Most nostr protocol functionality is implemented via the [welshman library](https://welshman.coracle.social/)
|
||||
- Use Svelte 4 **stores** rather than runes for all state outside UI components
|
||||
- Most global state flows through Welshman's `repository` (unidirectional)
|
||||
- Query state using `deriveEventsMapped` or `deriveProfile` etc
|
||||
- Events are published via `publishThunk`, which allows for optimistic UI updates during signing/pow generation.
|
||||
- Components should have minimal props - e.g. instead of passing a whole `relay` through, pass its `url`.
|
||||
- Use `AbortController` when possible instead of request ids
|
||||
- Use `undefined` or optional properties instead of `null`
|
||||
- Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect.
|
||||
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
|
||||
- When dynamically building classes, use `cx` from `classnames`.
|
||||
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates.
|
||||
|
||||
## Contributing Workflow
|
||||
|
||||
To contribute, do the following:
|
||||
|
||||
- Find or create an issue and assign yourself (comment instead if you're not able to self-assign)
|
||||
- If the issue is a design task, attach or link out to any mockups/wireframes/flowcharts
|
||||
- Once a design task is completed, a maintainer will remove the `design` label and add the `dev` label
|
||||
- If the issue is a development task, fork the repository and create a branch prefixed by the issue number, e.g. `105-deep-links`
|
||||
- Before requesting a review, be sure to review any agent-generated code, run the pre-commit hooks, and test the changes.
|
||||
- Open a PR and request a review. A maintainer will get back to you with requested changes, or will merge the PR.
|
||||
- Keep your PR up-to-date by rebasing frequently on `dev`. Avoid force-pushing to `dev`.
|
||||
- PRs are rebased, squashed, and merged to keep commit history simple.
|
||||
- An issue may have multiple PRs. Once complete, it can be closed.
|
||||
@@ -16,11 +16,13 @@ You can also optionally create an `.env.local` file and populate it with the fol
|
||||
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
||||
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
|
||||
|
||||
These values **won't** be used for a built version. Instead, env variables should be provided to `build.sh` directly or to the built container.
|
||||
|
||||
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
|
||||
|
||||
## Development
|
||||
|
||||
See [CONTRIBUTING.md](AGENTS.md).
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -44,4 +44,7 @@
|
||||
<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>
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
|
||||
temp_env=$(declare -p -x)
|
||||
|
||||
if [ -f .env.template ]; then
|
||||
source .env.template
|
||||
fi
|
||||
if [ -f .env.local ]; then
|
||||
source .env.local
|
||||
if [ -f .env ]; then
|
||||
source .env
|
||||
fi
|
||||
|
||||
# Avoid overwriting env vars provided directly
|
||||
|
||||
@@ -392,7 +392,7 @@
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.7.5;
|
||||
MARKETING_VERSION = 1.7.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
@@ -24,8 +24,10 @@
|
||||
<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 for voice chat in rooms.</string>
|
||||
<string>Flotilla uses the microphone when you enable it in a voice room.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
|
||||
+9
-8
@@ -22,6 +22,7 @@
|
||||
"@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",
|
||||
@@ -35,7 +36,7 @@
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"svelte": "^5.48.0",
|
||||
"svelte-check": "^4.3.5",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.53.1",
|
||||
"vite": "^5.4.21"
|
||||
@@ -58,7 +59,7 @@
|
||||
"@getalby/lightning-tools": "^6.1.0",
|
||||
"@getalby/sdk": "^5.1.2",
|
||||
"@noble/curves": "^1.9.7",
|
||||
"@pomade/core": "^0.2.2",
|
||||
"@pomade/core": "^0.2.3",
|
||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@tiptap/core": "^2.27.2",
|
||||
@@ -77,7 +78,7 @@
|
||||
"@welshman/store": "^0.8.12",
|
||||
"@welshman/util": "^0.8.12",
|
||||
"compressorjs-next": "^1.1.2",
|
||||
"daisyui": "^4.12.24",
|
||||
"daisyui": "^5.5.19",
|
||||
"date-picker-svelte": "^2.17.0",
|
||||
"dotenv": "^16.6.1",
|
||||
"emoji-picker-element": "^1.28.1",
|
||||
@@ -87,7 +88,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.6.14",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"throttle-debounce": "^5.0.2",
|
||||
@@ -100,9 +101,9 @@
|
||||
"onlyBuiltDependencies": [
|
||||
"sharp",
|
||||
"nostr-signer-capacitor-plugin"
|
||||
],
|
||||
"overrides": {
|
||||
"sharp": "0.35.0-rc.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"sharp": "0.35.0-rc.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+397
-378
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -1,6 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import dotenv from "dotenv"
|
||||
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
|
||||
|
||||
dotenv.config({path: ".env.local"})
|
||||
dotenv.config({path: ".env.template"})
|
||||
dotenv.config({path: ".env"})
|
||||
|
||||
export default defineConfig({
|
||||
preset,
|
||||
|
||||
+258
-262
@@ -1,45 +1,25 @@
|
||||
@import "@welshman/editor/index.css";
|
||||
@import "tailwindcss";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@config "../tailwind.config.js";
|
||||
|
||||
/* Fonts */
|
||||
|
||||
@font-face {
|
||||
font-family: "Satoshis";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src:
|
||||
local(""),
|
||||
url("/fonts/Satoshi Symbol.ttf") format("truetype");
|
||||
@utility 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");
|
||||
@utility 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");
|
||||
@utility 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");
|
||||
@utility pl-sai {
|
||||
padding-left: var(--sail);
|
||||
}
|
||||
|
||||
@utility px-sai {
|
||||
@apply pl-sai pr-sai;
|
||||
}
|
||||
|
||||
/* root */
|
||||
@@ -52,98 +32,224 @@
|
||||
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
[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 py-sai {
|
||||
@apply pt-sai pb-sai;
|
||||
}
|
||||
|
||||
.mobile [data-tip]::before {
|
||||
display: none !important;
|
||||
@utility p-sai {
|
||||
@apply py-sai px-sai;
|
||||
}
|
||||
|
||||
/* safe area insets */
|
||||
@utility mt-sai {
|
||||
margin-top: var(--sait);
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.pt-sai {
|
||||
padding-top: var(--sait);
|
||||
@utility mr-sai {
|
||||
margin-right: var(--sair);
|
||||
}
|
||||
|
||||
@utility mb-sai {
|
||||
margin-bottom: var(--saib);
|
||||
}
|
||||
|
||||
@utility ml-sai {
|
||||
margin-left: var(--sail);
|
||||
}
|
||||
|
||||
@utility mx-sai {
|
||||
@apply ml-sai mr-sai;
|
||||
}
|
||||
|
||||
@utility my-sai {
|
||||
@apply mt-sai mb-sai;
|
||||
}
|
||||
|
||||
@utility m-sai {
|
||||
@apply my-sai mx-sai;
|
||||
}
|
||||
|
||||
@utility top-sai {
|
||||
top: var(--sait);
|
||||
}
|
||||
|
||||
@utility right-sai {
|
||||
right: var(--sair);
|
||||
}
|
||||
|
||||
@utility bottom-sai {
|
||||
bottom: var(--saib);
|
||||
}
|
||||
|
||||
@utility left-sai {
|
||||
left: var(--sail);
|
||||
}
|
||||
|
||||
@utility card2 {
|
||||
@apply rounded-box text-base-content p-4 sm:p-6;
|
||||
}
|
||||
|
||||
@utility column {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
@utility center {
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
|
||||
@utility row-2 {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
@utility row-3 {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
@utility row-4 {
|
||||
@apply flex items-center gap-4;
|
||||
}
|
||||
|
||||
@utility col-2 {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
@utility col-3 {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
@utility col-4 {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
@utility col-8 {
|
||||
@apply flex flex-col gap-8;
|
||||
}
|
||||
|
||||
@utility ellipsize {
|
||||
@apply overflow-hidden text-ellipsis;
|
||||
}
|
||||
|
||||
@utility content-padding-x {
|
||||
@apply px-4 sm:px-8 md:px-12;
|
||||
}
|
||||
|
||||
@utility content-padding-t {
|
||||
@apply pt-4 sm:pt-8 md:pt-12;
|
||||
}
|
||||
|
||||
@utility content-padding-b {
|
||||
@apply pb-4 sm:pb-8 md:pb-12;
|
||||
}
|
||||
|
||||
@utility content-padding-y {
|
||||
@apply pt-4 pb-4 sm:pt-8 sm:pb-8 md:pt-12 md:pb-12;
|
||||
}
|
||||
|
||||
@utility content-sizing {
|
||||
@apply m-auto w-full max-w-3xl;
|
||||
}
|
||||
|
||||
@utility content {
|
||||
@apply m-auto w-full max-w-3xl px-4 pt-4 pb-4 sm:px-8 sm:pt-8 sm:pb-8 md:px-12 md:pt-12 md:pb-12;
|
||||
}
|
||||
|
||||
@utility heading {
|
||||
@apply text-center text-2xl;
|
||||
}
|
||||
|
||||
@utility subheading {
|
||||
@apply text-center text-xl;
|
||||
}
|
||||
|
||||
@utility superheading {
|
||||
@apply text-center text-4xl;
|
||||
}
|
||||
|
||||
@utility link {
|
||||
@apply text-primary cursor-pointer underline;
|
||||
}
|
||||
|
||||
/* content visibility */
|
||||
|
||||
@utility cv {
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Fonts */
|
||||
|
||||
@font-face {
|
||||
font-family: "Satoshis";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src:
|
||||
local(""),
|
||||
url("/fonts/Satoshi Symbol.ttf") format("truetype");
|
||||
}
|
||||
|
||||
.pr-sai {
|
||||
padding-right: var(--sair);
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src:
|
||||
local(""),
|
||||
url("/fonts/Lato-Regular.ttf") format("truetype");
|
||||
}
|
||||
|
||||
.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");
|
||||
}
|
||||
|
||||
.pl-sai {
|
||||
padding-left: var(--sail);
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src:
|
||||
local(""),
|
||||
url("/fonts/Italic.ttf") format("truetype");
|
||||
}
|
||||
|
||||
.px-sai {
|
||||
@apply pl-sai pr-sai;
|
||||
/* 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));
|
||||
}
|
||||
|
||||
.py-sai {
|
||||
@apply pt-sai pb-sai;
|
||||
[data-theme] {
|
||||
@apply bg-base-300;
|
||||
}
|
||||
|
||||
.p-sai {
|
||||
@apply py-sai px-sai;
|
||||
.mobile [data-tip]::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
/* safe area insets */
|
||||
}
|
||||
|
||||
/* utilities */
|
||||
@@ -165,110 +271,18 @@
|
||||
@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 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;
|
||||
@apply text-base-content p-2 sm:p-4;
|
||||
}
|
||||
|
||||
[data-tip]::before {
|
||||
@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;
|
||||
@apply overflow-hidden text-ellipsis;
|
||||
}
|
||||
|
||||
.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,
|
||||
@@ -278,21 +292,21 @@
|
||||
}
|
||||
|
||||
.tiptap {
|
||||
--tiptap-object-bg: var(--neutral);
|
||||
--tiptap-object-fg: var(--neutral-content);
|
||||
--tiptap-active-bg: var(--primary);
|
||||
--tiptap-active-fg: var(--primary-content);
|
||||
--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-suggestions {
|
||||
--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-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-suggestions__item {
|
||||
@apply border-l-2 border-solid border-base-100;
|
||||
@apply border-base-100 border-l-2 border-solid;
|
||||
}
|
||||
|
||||
.tiptap-suggestions__selected {
|
||||
@@ -312,13 +326,13 @@
|
||||
}
|
||||
|
||||
.note-editor .tiptap {
|
||||
--tiptap-object-bg: var(--base-200);
|
||||
@apply input input-bordered h-auto min-h-32 rounded-box p-[.65rem] pb-6;
|
||||
--tiptap-object-bg: var(--color-base-200);
|
||||
@apply input rounded-box h-auto min-h-32 p-[.65rem] pb-6;
|
||||
}
|
||||
|
||||
.input-editor .tiptap {
|
||||
--tiptap-object-bg: var(--base-200);
|
||||
@apply input input-bordered h-auto p-[.65rem];
|
||||
--tiptap-object-bg: var(--color-base-200);
|
||||
@apply input h-auto p-[.65rem];
|
||||
}
|
||||
|
||||
/* link-content, based on tiptap */
|
||||
@@ -330,8 +344,8 @@
|
||||
white-space: nowrap;
|
||||
border-radius: 3px;
|
||||
padding: 0 0.25rem;
|
||||
background-color: var(--base-100);
|
||||
color: var(--base-content);
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
/* content rendered by welshman/content */
|
||||
@@ -347,23 +361,31 @@
|
||||
/* date input */
|
||||
|
||||
.picker {
|
||||
--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-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-time-field {
|
||||
@apply input input-bordered rounded-lg px-0;
|
||||
@apply input 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;
|
||||
}
|
||||
|
||||
.tippy-target > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tippy-box {
|
||||
@apply rounded-box shadow-xl;
|
||||
}
|
||||
@@ -371,15 +393,15 @@
|
||||
/* emoji picker */
|
||||
|
||||
emoji-picker {
|
||||
--background: var(--base-100);
|
||||
--border-color: var(--base-100);
|
||||
--background: var(--color-base-100);
|
||||
--border-color: var(--color-base-100);
|
||||
--border-radius: var(--rounded-box);
|
||||
--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);
|
||||
--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);
|
||||
}
|
||||
|
||||
/* progress */
|
||||
@@ -390,28 +412,12 @@ progress[value]::-webkit-progress-value {
|
||||
|
||||
/* content width for fixed elements */
|
||||
|
||||
.cw {
|
||||
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
|
||||
}
|
||||
|
||||
.cw-full {
|
||||
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
|
||||
}
|
||||
|
||||
.cb {
|
||||
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
|
||||
}
|
||||
|
||||
.ct {
|
||||
@apply top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)];
|
||||
.left-content {
|
||||
@apply md:left-[calc(18.5rem+var(--sail))];
|
||||
}
|
||||
|
||||
/* Keyboard open state adjustments */
|
||||
|
||||
body.keyboard-open .cb {
|
||||
@apply bottom-sai;
|
||||
}
|
||||
|
||||
body.keyboard-open .hide-on-keyboard {
|
||||
display: none;
|
||||
}
|
||||
@@ -419,23 +425,13 @@ body.keyboard-open .hide-on-keyboard {
|
||||
/* chat view */
|
||||
|
||||
.chat__compose {
|
||||
@apply cb cw fixed z-compose;
|
||||
@apply z-compose relative mb-14 shrink-0 md:mb-0;
|
||||
}
|
||||
|
||||
.chat__compose-zone {
|
||||
@apply cb cw fixed z-compose;
|
||||
}
|
||||
|
||||
.chat__compose-zone .chat__compose-inner {
|
||||
.chat__compose .chat__compose-inner {
|
||||
@apply min-w-0;
|
||||
}
|
||||
|
||||
.chat__scroll-down {
|
||||
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
|
||||
}
|
||||
|
||||
/* content visibility */
|
||||
|
||||
.cv {
|
||||
content-visibility: auto;
|
||||
@apply pb-sai z-feature fixed right-4 bottom-28 md:bottom-16;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import {Room as LiveKitRoom} from "livekit-client"
|
||||
import {derived, writable} from "svelte/store"
|
||||
import {type Room} from "@app/core/state"
|
||||
|
||||
export type VoiceSession = {
|
||||
url: string
|
||||
h: string
|
||||
room: LiveKitRoom
|
||||
muted: boolean
|
||||
cameraOn: boolean
|
||||
screenShareOn: boolean
|
||||
}
|
||||
|
||||
export type Pubkey = string
|
||||
|
||||
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
||||
|
||||
export enum VoiceState {
|
||||
Joining = "joining",
|
||||
Connected = "connected",
|
||||
Disconnected = "disconnected",
|
||||
}
|
||||
|
||||
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
||||
|
||||
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
||||
|
||||
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
||||
|
||||
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
||||
|
||||
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
||||
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
|
||||
|
||||
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
|
||||
const pk = pubkeyFromLiveKitIdentity(identity)
|
||||
return pk ? {pubkey: pk, identity} : {identity}
|
||||
}
|
||||
|
||||
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
||||
|
||||
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
||||
|
||||
export const isParticipantSpeaking = derived(
|
||||
speakingParticipants,
|
||||
$participants => (p: VoiceParticipant) =>
|
||||
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
||||
)
|
||||
|
||||
export const isLocalSpeaking = derived(
|
||||
[currentVoiceSession, speakingParticipants],
|
||||
([$session, $speaking]) => {
|
||||
if (!$session?.room) return false
|
||||
const local = participantFromLiveKitIdentity($session.room.localParticipant.identity)
|
||||
return $speaking.some(sp => participantKey(sp) === participantKey(local))
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
import {Track} from "livekit-client"
|
||||
import {MediaQuery} from "svelte/reactivity"
|
||||
import {derived, get, writable} from "svelte/store"
|
||||
import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
export enum VideoCallLayout {
|
||||
Chat = "chat",
|
||||
Video = "video",
|
||||
Split = "split",
|
||||
}
|
||||
|
||||
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
|
||||
|
||||
export enum ViewportSize {
|
||||
Desktop = "desktop",
|
||||
Mobile = "mobile",
|
||||
}
|
||||
|
||||
export const videoCallViewportSync = {
|
||||
previousLayout: undefined as ViewportSize | undefined,
|
||||
}
|
||||
|
||||
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
|
||||
|
||||
export const resetVideoCallLayout = () => {
|
||||
videoCallViewportSync.previousLayout = undefined
|
||||
videoCallLayout.set(VideoCallLayout.Chat)
|
||||
}
|
||||
|
||||
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
|
||||
|
||||
export const toggleVideoPrimaryTile = (key: string) => {
|
||||
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
|
||||
}
|
||||
|
||||
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
|
||||
|
||||
const countLiveVisualFeeds = (session: VoiceSession): number => {
|
||||
const room = session.room
|
||||
let n = 0
|
||||
const lp = room.localParticipant
|
||||
if (session.cameraOn) {
|
||||
const pub = lp.getTrackPublication(Track.Source.Camera)
|
||||
if (pub?.track) n += 1
|
||||
}
|
||||
if (session.screenShareOn) {
|
||||
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
|
||||
if (pub?.track) n += 1
|
||||
}
|
||||
for (const rp of room.remoteParticipants.values()) {
|
||||
for (const source of VISUAL_SOURCES) {
|
||||
const pub = rp.getTrackPublication(source)
|
||||
if (pub?.isSubscribed && pub.track) n += 1
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
export const triggerVideoFeedCount = () => {
|
||||
currentVoiceSession.update(s => (s ? {...s} : s))
|
||||
}
|
||||
|
||||
export const videoTileCount = derived([currentVoiceSession, voiceState], ([$session, $state]) => {
|
||||
if ($state !== VoiceState.Connected || !$session) return 0
|
||||
return countLiveVisualFeeds($session)
|
||||
})
|
||||
|
||||
export const toggleCamera = async () => {
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session) return
|
||||
|
||||
const cameraOn = !session.cameraOn
|
||||
try {
|
||||
await session.room.localParticipant.setCameraEnabled(cameraOn)
|
||||
currentVoiceSession.set({...session, cameraOn})
|
||||
} catch {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: cameraOn ? "Could not access camera" : "Could not turn off camera",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const toggleScreenShare = async () => {
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session) return
|
||||
|
||||
const screenShareOn = !session.screenShareOn
|
||||
try {
|
||||
await session.room.localParticipant.setScreenShareEnabled(screenShareOn)
|
||||
currentVoiceSession.set({...session, screenShareOn})
|
||||
} catch {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,51 +4,77 @@
|
||||
*/
|
||||
import {
|
||||
DisconnectReason,
|
||||
LocalParticipant,
|
||||
LocalTrackPublication,
|
||||
Room as LiveKitRoom,
|
||||
RoomEvent,
|
||||
Track,
|
||||
supportsAudioOutputSelection,
|
||||
type AudioCaptureOptions,
|
||||
type LocalParticipant,
|
||||
} from "livekit-client"
|
||||
import {derived, get, writable} from "svelte/store"
|
||||
import {derived, get} 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 {deriveLatestEventForUrl, deriveRoom, makeRoomId, type Room} from "@app/core/state"
|
||||
import {
|
||||
currentVoiceRoom,
|
||||
currentVoiceSession,
|
||||
participantFromLiveKitIdentity,
|
||||
participantKey,
|
||||
participantPubkeyMap,
|
||||
pubkeyFromLiveKitIdentity,
|
||||
speakingParticipants,
|
||||
VoiceState,
|
||||
type VoiceParticipant,
|
||||
voiceState,
|
||||
} from "@app/call/stores"
|
||||
import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video"
|
||||
import {deriveLatestEventForUrl, deriveRoom, makeRoomId} from "@app/core/state"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
export const LIVEKIT_PARTICIPANTS = 39004
|
||||
|
||||
export {checkRelayHasLivekit} from "$lib/livekit"
|
||||
|
||||
export type VoiceSession = {
|
||||
url: string
|
||||
h: string
|
||||
room: LiveKitRoom
|
||||
muted: boolean
|
||||
export {supportsAudioOutputSelection}
|
||||
|
||||
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
|
||||
|
||||
export enum DeviceKind {
|
||||
AudioInput = "audioinput",
|
||||
AudioOutput = "audiooutput",
|
||||
VideoInput = "videoinput",
|
||||
}
|
||||
|
||||
export type Pubkey = string
|
||||
|
||||
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
||||
|
||||
export enum VoiceState {
|
||||
Joining = "joining",
|
||||
Connected = "connected",
|
||||
Disconnected = "disconnected",
|
||||
export const switchVoiceActiveDevice = async (
|
||||
kind: DeviceKind,
|
||||
targetDeviceId: string,
|
||||
): Promise<void> => {
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session) return
|
||||
const id = targetDeviceId === "" ? LIVEKIT_DEFAULT_DEVICE_ID : targetDeviceId
|
||||
try {
|
||||
await session.room.switchActiveDevice(kind, id)
|
||||
} catch {
|
||||
let label: string
|
||||
switch (kind) {
|
||||
case DeviceKind.AudioInput:
|
||||
label = "microphone"
|
||||
break
|
||||
case DeviceKind.AudioOutput:
|
||||
label = "speaker"
|
||||
break
|
||||
case DeviceKind.VideoInput:
|
||||
label = "camera"
|
||||
break
|
||||
}
|
||||
pushToast({theme: "error", message: `Error changing ${label}`})
|
||||
}
|
||||
}
|
||||
|
||||
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
||||
|
||||
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
||||
|
||||
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
||||
|
||||
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
||||
|
||||
const addParticipant = (identity: string) => {
|
||||
participantPubkeyMap.update(m => {
|
||||
const next = new Map(m)
|
||||
@@ -65,24 +91,6 @@ const deleteParticipant = (identity: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
||||
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
|
||||
|
||||
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
|
||||
const pk = pubkeyFromLiveKitIdentity(identity)
|
||||
return pk ? {pubkey: pk, identity} : {identity}
|
||||
}
|
||||
|
||||
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
||||
|
||||
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
||||
|
||||
export const isParticipantSpeaking = derived(
|
||||
speakingParticipants,
|
||||
$participants => (p: VoiceParticipant) =>
|
||||
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
||||
)
|
||||
|
||||
const fetchLivekitToken = async (
|
||||
url: string,
|
||||
groupId: string,
|
||||
@@ -164,7 +172,9 @@ 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 =
|
||||
@@ -183,11 +193,16 @@ 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}[]) => {
|
||||
@@ -208,6 +223,17 @@ 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 = () => {
|
||||
@@ -245,6 +271,7 @@ 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 {
|
||||
@@ -268,7 +295,14 @@ export const joinVoiceRoom = async (
|
||||
|
||||
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
||||
|
||||
currentVoiceSession.set({url, h, room: liveKitRoom, muted})
|
||||
currentVoiceSession.set({
|
||||
url,
|
||||
h,
|
||||
room: liveKitRoom,
|
||||
muted,
|
||||
cameraOn: false,
|
||||
screenShareOn: false,
|
||||
})
|
||||
voiceState.set(VoiceState.Connected)
|
||||
playJoinSound()
|
||||
} catch (e) {
|
||||
@@ -287,8 +321,26 @@ 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())
|
||||
@@ -38,7 +38,7 @@
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||
<div class="flex grow flex-wrap justify-end gap-2">
|
||||
{#if h && showRoom}
|
||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||
Posted in #<RoomName {h} {url} />
|
||||
|
||||
@@ -20,24 +20,33 @@
|
||||
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} from "@app/core/commands"
|
||||
|
||||
type Values = {
|
||||
d: string
|
||||
title: string
|
||||
content: string | object
|
||||
location: string
|
||||
start?: number
|
||||
end?: number
|
||||
}
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
h?: string
|
||||
header: Snippet
|
||||
initialValues?: {
|
||||
d: string
|
||||
title: string
|
||||
content: string
|
||||
location: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
initialValues?: Values
|
||||
}
|
||||
|
||||
const {url, h, header, initialValues}: Props = $props()
|
||||
let {url, h, header, initialValues}: Props = $props()
|
||||
|
||||
const draftKey = new DraftKey<Values>(`calendar:${url}:${h ?? ""}`)
|
||||
|
||||
if (!initialValues) {
|
||||
initialValues = draftKey.get()
|
||||
}
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
@@ -74,9 +83,9 @@
|
||||
const ed = await editor
|
||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||
const tags = [
|
||||
["d", initialValues?.d || randomId()],
|
||||
["d", d],
|
||||
["title", title],
|
||||
["location", location || ""],
|
||||
["location", location],
|
||||
["start", start.toString()],
|
||||
["end", end.toString()],
|
||||
...daysBetween(start, end).map(D => ["D", D.toString()]),
|
||||
@@ -95,17 +104,27 @@
|
||||
|
||||
pushToast({message: "Your event has been saved!"})
|
||||
publishThunk({event, relays: [url]})
|
||||
draftKey.clear()
|
||||
history.back()
|
||||
}
|
||||
|
||||
const content = initialValues?.content || ""
|
||||
const editor = makeEditor({url, submit, uploading, content})
|
||||
|
||||
let title = $state(initialValues?.title || "")
|
||||
let location = $state(initialValues?.location || "")
|
||||
const d = $state(initialValues?.d ?? randomId())
|
||||
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 = Boolean(initialValues?.end)
|
||||
let endDirty = $state(Boolean(initialValues?.end))
|
||||
let content = $state(initialValues?.content ?? "")
|
||||
|
||||
const onChange = (json: object) => {
|
||||
content = json
|
||||
}
|
||||
|
||||
const editor = makeEditor({url, submit, uploading, onChange, content})
|
||||
|
||||
$effect(() => {
|
||||
draftKey.set({d, title, location, start, end, content})
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!endDirty && start) {
|
||||
@@ -136,7 +155,7 @@
|
||||
{#snippet input()}
|
||||
<div
|
||||
class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
|
||||
<div class="input-editor flex-grow overflow-hidden">
|
||||
<div class="input-editor grow overflow-hidden">
|
||||
<EditorContent {editor} />
|
||||
</div>
|
||||
<Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
const end = $derived(parseInt(meta.end))
|
||||
</script>
|
||||
|
||||
<div class="flex flex-grow flex-wrap justify-between gap-2">
|
||||
<div class="flex grow flex-wrap justify-between gap-2">
|
||||
<p class="text-xl">{meta.title || meta.name}</p>
|
||||
{#if !isNaN(start) && !isNaN(end)}
|
||||
{@const startDateDisplay = formatTimestampAsDate(start)}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{#if meta.location}
|
||||
<span class="flex items-start gap-1">
|
||||
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
|
||||
<span class="break-words">{meta.location}</span>
|
||||
<span class="wrap-break-word">{meta.location}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
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"
|
||||
|
||||
@@ -66,6 +67,7 @@
|
||||
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)))
|
||||
|
||||
@@ -196,8 +198,6 @@
|
||||
let compose: ChatCompose | undefined = $state()
|
||||
let parent: TrustedEvent | undefined = $state()
|
||||
let eventToEdit: TrustedEvent | undefined = $state()
|
||||
let chatCompose: HTMLElement | undefined = $state()
|
||||
let dynamicPadding: HTMLElement | undefined = $state()
|
||||
|
||||
const elements = $derived.by(() => {
|
||||
const elements = []
|
||||
@@ -233,20 +233,6 @@
|
||||
for (const pubkey of others) {
|
||||
loadMessagingRelayList(pubkey)
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (dynamicPadding && chatCompose) {
|
||||
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(chatCompose!)
|
||||
observer.observe(dynamicPadding!)
|
||||
|
||||
return () => {
|
||||
observer.unobserve(chatCompose!)
|
||||
observer.unobserve(dynamicPadding!)
|
||||
}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -294,7 +280,6 @@
|
||||
</PageBar>
|
||||
|
||||
<PageContent class="flex flex-col-reverse gap-2 pt-4">
|
||||
<div bind:this={dynamicPadding}></div>
|
||||
{#if missingRelayLists.length > 0}
|
||||
<div class="py-12">
|
||||
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
||||
@@ -335,9 +320,10 @@
|
||||
</Spinner>
|
||||
{@render info?.()}
|
||||
</p>
|
||||
<div class="h-screen"></div>
|
||||
</PageContent>
|
||||
|
||||
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
|
||||
<div class="chat__compose bg-base-200">
|
||||
<div>
|
||||
{#if parent}
|
||||
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
||||
@@ -352,7 +338,8 @@
|
||||
{onSubmit}
|
||||
{onEscape}
|
||||
{onEditPrevious}
|
||||
content={eventToEdit?.content}
|
||||
initialValues={eventToEdit}
|
||||
draftKey={eventToEdit ? undefined : draftKey}
|
||||
disabled={Boolean(missingRelayLists.length)} />
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
@@ -10,23 +10,40 @@
|
||||
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
|
||||
}
|
||||
|
||||
const {content, disabled = false, onEscape, onEditPrevious, onSubmit}: Props = $props()
|
||||
let {
|
||||
initialValues,
|
||||
disabled = false,
|
||||
draftKey,
|
||||
onEscape,
|
||||
onEditPrevious,
|
||||
onSubmit,
|
||||
}: Props = $props()
|
||||
|
||||
if (!initialValues) {
|
||||
initialValues = draftKey?.get()
|
||||
}
|
||||
|
||||
const autofocus = !isMobile && !disabled
|
||||
|
||||
const uploading = writable(false)
|
||||
|
||||
const editorClass = $derived(
|
||||
cx("chat-editor flex-grow overflow-hidden", {
|
||||
cx("chat-editor grow overflow-hidden", {
|
||||
"pointer-events-none opacity-50": disabled,
|
||||
}),
|
||||
)
|
||||
@@ -59,18 +76,29 @@
|
||||
|
||||
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)
|
||||
@@ -95,7 +123,7 @@
|
||||
{/if}
|
||||
</Button>
|
||||
<div class={editorClass} aria-disabled={disabled}>
|
||||
<EditorContent {editor} />
|
||||
<EditorContent {autofocus} {editor} />
|
||||
</div>
|
||||
<Button
|
||||
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
<Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}>
|
||||
<div
|
||||
class="cursor-pointer border-t border-solid border-base-100 px-6 py-2 transition-colors hover:bg-base-100 {props.class}"
|
||||
class="cursor-pointer border-t border-solid border-base-100 px-3 py-2 transition-colors hover:bg-base-100 {props.class}"
|
||||
class:bg-base-100={active}>
|
||||
<div class="flex flex-col justify-start gap-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<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"
|
||||
@@ -8,13 +7,9 @@
|
||||
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()
|
||||
@@ -28,10 +23,6 @@
|
||||
<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
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||
<div class="flex grow flex-wrap justify-end gap-2">
|
||||
{#if h && showRoom}
|
||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||
Posted in #<RoomName {h} {url} />
|
||||
|
||||
@@ -20,25 +20,34 @@
|
||||
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, uploadFile} from "@app/core/commands"
|
||||
|
||||
type Values = {
|
||||
d: string
|
||||
title: string
|
||||
content: string | object
|
||||
price: number
|
||||
currency: string
|
||||
images: (string | File)[]
|
||||
status: string
|
||||
topics: string[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
h?: string
|
||||
header: Snippet
|
||||
initialValues?: {
|
||||
d?: string
|
||||
title?: string
|
||||
content?: string
|
||||
price?: number
|
||||
currency?: string
|
||||
images?: string[]
|
||||
status?: string
|
||||
topics?: string[]
|
||||
}
|
||||
initialValues?: Values
|
||||
}
|
||||
|
||||
const {url, h, header, initialValues}: Props = $props()
|
||||
let {url, h, header, initialValues}: Props = $props()
|
||||
|
||||
const draftKey = new DraftKey<Values>(`classified:${url}:${h ?? ""}`)
|
||||
|
||||
if (!initialValues) {
|
||||
initialValues = draftKey.get()
|
||||
}
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
@@ -66,7 +75,7 @@
|
||||
}
|
||||
|
||||
const tags = [
|
||||
["d", initialValues?.d || randomId()],
|
||||
["d", d],
|
||||
["title", title],
|
||||
["summary", content],
|
||||
["price", String(price), currency],
|
||||
@@ -110,22 +119,32 @@
|
||||
event: makeEvent(CLASSIFIED, {content, tags}),
|
||||
})
|
||||
|
||||
draftKey.clear()
|
||||
history.back()
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const content = initialValues?.content || ""
|
||||
const editor = makeEditor({url, submit, content})
|
||||
|
||||
let loading = $state(false)
|
||||
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))))
|
||||
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})
|
||||
})
|
||||
</script>
|
||||
|
||||
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||
@@ -153,7 +172,7 @@
|
||||
<p>Description*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="note-editor flex-grow overflow-hidden">
|
||||
<div class="note-editor grow overflow-hidden">
|
||||
<EditorContent {editor} />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||
<div class="flex grow flex-wrap justify-end gap-2">
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||
<ThunkStatusOrDeleted {event} />
|
||||
{#if showActivity}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
|
||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
||||
import Revote from "@assets/icons/revote.svg?dataurl"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
@@ -11,6 +12,7 @@
|
||||
import ThreadCreate from "@app/components/ThreadCreate.svelte"
|
||||
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
|
||||
import GoalCreate from "@app/components/GoalCreate.svelte"
|
||||
import PollCreate from "@app/components/PollCreate.svelte"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
@@ -28,6 +30,8 @@
|
||||
|
||||
const createClassified = () => pushModal(ClassifiedCreate, {url, h})
|
||||
|
||||
const createPoll = () => pushModal(PollCreate, {url, h})
|
||||
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
@@ -60,4 +64,10 @@
|
||||
Create Thread
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onclick={createPoll}>
|
||||
<Icon size={4} icon={Revote} />
|
||||
Ask a Question
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="overflow-hidden text-ellipsis break-words"
|
||||
class="overflow-hidden text-ellipsis wrap-break-word"
|
||||
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
|
||||
{#each shortContent as parsed, i}
|
||||
{#if isNewline(parsed) && !isBlock(i - 1)}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
<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} from "@app/core/state"
|
||||
import {
|
||||
dufflepud,
|
||||
PLATFORM_URL,
|
||||
IMAGE_CONTENT_TYPES,
|
||||
VIDEO_CONTENT_TYPES,
|
||||
THUMBNAIL_URL,
|
||||
} from "@app/core/state"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
|
||||
const {value, event} = $props()
|
||||
@@ -22,6 +29,14 @@
|
||||
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})
|
||||
|
||||
@@ -42,7 +57,12 @@
|
||||
<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} class="max-h-96 rounded-box object-contain object-center">
|
||||
<video
|
||||
controls
|
||||
src={url}
|
||||
poster={getVideoPoster(url)}
|
||||
preload="metadata"
|
||||
class="max-h-96 rounded-box object-contain object-center">
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden text-ellipsis break-words">
|
||||
<div class="overflow-hidden text-ellipsis wrap-break-word">
|
||||
{#each shortContent as parsed, i}
|
||||
{#if isNewline(parsed)}
|
||||
<ContentNewline value={parsed.value} />
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
{#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(--primary) 10%, var(--base-300) 90%);">
|
||||
style="background-color: color-mix(in srgb, var(--color-primary) 10%, var(--color-base-300) 90%);">
|
||||
<NoteContentMinimal trimParent {url} event={$quote} />
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -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 flex-grow items-center justify-between">
|
||||
<p class="absolute right-2 top-2 flex grow items-center justify-between">
|
||||
<Button onclick={copyJson} class="btn btn-neutral btn-sm flex items-center">
|
||||
<Icon icon={Copy} /> Copy
|
||||
</Button>
|
||||
@@ -109,6 +109,6 @@
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-primary flex-grow" onclick={() => history.back()}>Got it</Button>
|
||||
<Button class="btn btn-primary grow" onclick={() => history.back()}>Got it</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
@@ -10,13 +10,19 @@
|
||||
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())
|
||||
|
||||
@@ -38,13 +44,23 @@
|
||||
})
|
||||
}
|
||||
|
||||
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(() => {
|
||||
@@ -64,11 +80,15 @@
|
||||
</script>
|
||||
|
||||
<div bind:this={spacer}></div>
|
||||
<form in:fly bind:this={form} onsubmit={preventDefault(submit)} class="cb cw fixed z-feature pt-3">
|
||||
<form
|
||||
in:fly
|
||||
bind:this={form}
|
||||
onsubmit={preventDefault(submit)}
|
||||
class="left-content bottom-sai right-sai ml-2 pl-2 fixed z-feature">
|
||||
<div class="card2 mx-2 my-2 bg-alt shadow-md">
|
||||
<div class="relative">
|
||||
<div class="note-editor flex-grow overflow-hidden">
|
||||
<EditorContent {editor} />
|
||||
<div class="note-editor grow overflow-hidden">
|
||||
<EditorContent {autofocus} {editor} />
|
||||
</div>
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||
<div class="flex grow flex-wrap justify-end gap-2">
|
||||
{#if h && showRoom}
|
||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||
Posted in #<RoomName {h} {url} />
|
||||
|
||||
@@ -20,14 +20,28 @@
|
||||
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} from "@app/core/commands"
|
||||
|
||||
type Values = {
|
||||
title: string
|
||||
content: string | object
|
||||
amount: number
|
||||
}
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
h?: string
|
||||
initialValues?: Values
|
||||
}
|
||||
|
||||
const {url, h}: Props = $props()
|
||||
let {url, h, initialValues}: Props = $props()
|
||||
|
||||
const draftKey = new DraftKey<Values>(`goal:${url}:${h ?? ""}`)
|
||||
|
||||
if (!initialValues) {
|
||||
initialValues = draftKey.get()
|
||||
}
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
@@ -40,7 +54,7 @@
|
||||
const submit = async () => {
|
||||
if ($uploading) return
|
||||
|
||||
if (!content) {
|
||||
if (!title) {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: "Please provide a title for your funding goal.",
|
||||
@@ -48,9 +62,9 @@
|
||||
}
|
||||
|
||||
const ed = await editor
|
||||
const summary = ed.getText({blockSeparator: "\n"}).trim()
|
||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||
|
||||
if (!summary.trim()) {
|
||||
if (!content.trim()) {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: "Please provide details about your funding goal.",
|
||||
@@ -59,7 +73,7 @@
|
||||
|
||||
const tags = [
|
||||
...ed.storage.nostr.getEditorTags(),
|
||||
["summary", summary],
|
||||
["summary", content],
|
||||
["amount", String(amount)],
|
||||
["relays", url],
|
||||
]
|
||||
@@ -74,16 +88,33 @@
|
||||
|
||||
publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(ZAP_GOAL, {content, tags}),
|
||||
event: makeEvent(ZAP_GOAL, {content: title, tags}),
|
||||
})
|
||||
|
||||
draftKey.clear()
|
||||
history.back()
|
||||
}
|
||||
|
||||
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
|
||||
let title = $state(initialValues?.title ?? "")
|
||||
let amount = $state(initialValues?.amount ?? 1000)
|
||||
let content = $state(initialValues?.content ?? "")
|
||||
|
||||
let content = $state("")
|
||||
let amount = $state(1000)
|
||||
const onChange = (json: object) => {
|
||||
content = json
|
||||
}
|
||||
|
||||
const editor = makeEditor({
|
||||
url,
|
||||
submit,
|
||||
uploading,
|
||||
onChange,
|
||||
placeholder: "What's on your mind?",
|
||||
content,
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
draftKey.update({title, content, amount})
|
||||
})
|
||||
</script>
|
||||
|
||||
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||
@@ -102,7 +133,7 @@
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus={!isMobile}
|
||||
bind:value={content}
|
||||
bind:value={title}
|
||||
class="grow"
|
||||
type="text"
|
||||
placeholder="What do funds go towards?" />
|
||||
@@ -115,7 +146,7 @@
|
||||
<p>Details*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="note-editor flex-grow overflow-hidden">
|
||||
<div class="note-editor grow overflow-hidden">
|
||||
<EditorContent {editor} />
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -137,7 +168,7 @@
|
||||
Goal Amount (sats)*
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="flex flex-grow justify-end">
|
||||
<div class="flex grow justify-end">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<Icon icon={Bolt} />
|
||||
<input bind:value={amount} type="number" class="w-28" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
||||
import {Poll} from "nostr-tools/kinds"
|
||||
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
|
||||
import NoteContentThread from "@app/components/NoteContentThread.svelte"
|
||||
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
|
||||
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
|
||||
import NoteContentPoll from "@app/components/NoteContentPoll.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
|
||||
const props: ComponentProps<typeof Content> = $props()
|
||||
@@ -19,6 +21,8 @@
|
||||
<NoteContentClassified {...props} />
|
||||
{:else if props.event.kind === ZAP_GOAL}
|
||||
<NoteContentGoal {...props} />
|
||||
{:else if props.event.kind === Poll}
|
||||
<NoteContentPoll {...props} />
|
||||
{:else}
|
||||
<Content {...props} />
|
||||
{/if}
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<CalendarEventDate event={props.event} />
|
||||
<div class="flex flex-grow flex-col">
|
||||
<div class="flex grow flex-col">
|
||||
<CalendarEventHeader event={props.event} />
|
||||
<div class="flex py-2 opacity-50">
|
||||
<div class="h-px flex-grow bg-base-content opacity-25"></div>
|
||||
<div class="h-px grow bg-base-content opacity-25"></div>
|
||||
</div>
|
||||
<Content {...props} />
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
||||
import {Poll} from "nostr-tools/kinds"
|
||||
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
|
||||
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
|
||||
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
|
||||
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
|
||||
import NoteContentMinimalPoll from "@app/components/NoteContentMinimalPoll.svelte"
|
||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||
|
||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||
@@ -19,6 +21,8 @@
|
||||
<NoteContentMinimalClassified {...props} />
|
||||
{:else if props.event.kind === ZAP_GOAL}
|
||||
<NoteContentMinimalGoal {...props} />
|
||||
{:else if props.event.kind === Poll}
|
||||
<NoteContentMinimalPoll {...props} />
|
||||
{:else}
|
||||
<ContentMinimal {...props} />
|
||||
{/if}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-grow flex-wrap justify-between gap-2">
|
||||
<div class="flex grow flex-wrap justify-between gap-2">
|
||||
<p class="text-sm">{meta.title || meta.name}</p>
|
||||
{#if !isNaN(start) && !isNaN(end)}
|
||||
{@const startDateDisplay = formatTimestampAsDate(start)}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {derived} from "svelte/store"
|
||||
import {PollResponse} from "nostr-tools/kinds"
|
||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||
import {deriveEvents} from "@app/core/state"
|
||||
import {getPollResults} from "@app/util/polls"
|
||||
|
||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||
|
||||
const responses = deriveEvents([{kinds: [PollResponse], "#e": [props.event.id]}])
|
||||
|
||||
const results = derived(responses, $responses => getPollResults(props.event, $responses))
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-0">
|
||||
<ContentMinimal {...props} />
|
||||
<span class="text-xs opacity-50">{$results.voters} voter{$results.voters === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {onMount} from "svelte"
|
||||
import {request} from "@welshman/net"
|
||||
import {PollResponse} from "nostr-tools/kinds"
|
||||
import PollVotes from "@app/components/PollVotes.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
|
||||
const props: ComponentProps<typeof Content> = $props()
|
||||
|
||||
onMount(() => {
|
||||
if (!props.url) {
|
||||
return
|
||||
}
|
||||
|
||||
request({
|
||||
relays: [props.url],
|
||||
filters: [{kinds: [PollResponse], "#e": [props.event.id]}],
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<Content event={props.event} showEntire url={props.url} />
|
||||
|
||||
{#if props.url}
|
||||
<PollVotes url={props.url} event={props.event} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,255 @@
|
||||
<script lang="ts">
|
||||
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
|
||||
import {makeEvent} from "@welshman/util"
|
||||
import {publishThunk} from "@welshman/app"
|
||||
import {Poll} from "nostr-tools/kinds"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import HamburgerMenu from "@assets/icons/hamburger-menu.svg?dataurl"
|
||||
import PlusCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {PROTECTED} from "@app/core/state"
|
||||
import {canEnforceNip70} from "@app/core/commands"
|
||||
import {DraftKey} from "@app/util/drafts"
|
||||
import type {PollType} from "@app/util/polls"
|
||||
|
||||
type Option = {
|
||||
id: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type Values = {
|
||||
title: string
|
||||
pollType: PollType
|
||||
endsAt?: number
|
||||
options: Option[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
h?: string
|
||||
}
|
||||
|
||||
const {url, h}: Props = $props()
|
||||
const draftKey = new DraftKey<Values>(`poll:${url}:${h ?? ""}`)
|
||||
const initialValues = draftKey.get()
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const addOption = () => {
|
||||
options = [...options, {id: randomId(), value: ""}]
|
||||
}
|
||||
|
||||
const removeOption = (id: string) => {
|
||||
options = options.filter(option => option.id !== id)
|
||||
}
|
||||
|
||||
const updateOption = (id: string, value: string) => {
|
||||
options = options.map(option => (option.id === id ? {...option, value} : option))
|
||||
}
|
||||
|
||||
const reorderOptions = (targetId: string) => {
|
||||
if (!draggedOptionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const sourceIndex = options.findIndex(option => option.id === draggedOptionId)
|
||||
const targetIndex = options.findIndex(option => option.id === targetId)
|
||||
|
||||
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
options = insertAt(targetIndex, options[sourceIndex], removeAt(sourceIndex, options))
|
||||
}
|
||||
|
||||
const onDragStart = (e: DragEvent, id: string) => {
|
||||
draggedOptionId = id
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
e.dataTransfer.setData("text/plain", id)
|
||||
}
|
||||
}
|
||||
|
||||
const onDragOver = (e: DragEvent, targetId: string) => {
|
||||
e.preventDefault()
|
||||
reorderOptions(targetId)
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent, targetId: string) => {
|
||||
e.preventDefault()
|
||||
reorderOptions(targetId)
|
||||
draggedOptionId = undefined
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
draggedOptionId = undefined
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!title.trim()) {
|
||||
return pushToast({theme: "error", message: "Please provide a title for your poll."})
|
||||
}
|
||||
|
||||
const nonEmptyOptions = removeUndefined(options.map(option => option.value.trim() || undefined))
|
||||
|
||||
if (nonEmptyOptions.length < 2) {
|
||||
return pushToast({theme: "error", message: "Please provide at least two options."})
|
||||
}
|
||||
|
||||
if (endsAt && endsAt <= now()) {
|
||||
return pushToast({theme: "error", message: "End time must be in the future."})
|
||||
}
|
||||
|
||||
const tags: string[][] = [
|
||||
...nonEmptyOptions.map(option => ["option", randomId(), option]),
|
||||
["polltype", pollType],
|
||||
["relay", url],
|
||||
]
|
||||
|
||||
if (endsAt) {
|
||||
tags.push(["endsAt", String(endsAt)])
|
||||
}
|
||||
|
||||
if (h) {
|
||||
tags.push(["h", h])
|
||||
}
|
||||
|
||||
if (await shouldProtect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(Poll, {content: title.trim(), tags}),
|
||||
})
|
||||
|
||||
draftKey.clear()
|
||||
history.back()
|
||||
}
|
||||
|
||||
let draggedOptionId = $state<string | undefined>()
|
||||
let title = $state(initialValues?.title ?? "")
|
||||
let pollType = $state<PollType>(initialValues?.pollType ?? "singlechoice")
|
||||
let endsAt = $state<number | undefined>(initialValues?.endsAt)
|
||||
let options = $state<Option[]>(
|
||||
initialValues?.options ?? [
|
||||
{id: randomId(), value: "Yes"},
|
||||
{id: randomId(), value: "No"},
|
||||
],
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
draftKey.set({title, pollType, endsAt, options})
|
||||
})
|
||||
</script>
|
||||
|
||||
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||
<ModalBody>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Create a Poll</ModalTitle>
|
||||
<ModalSubtitle>Ask a question and collect votes right in the feed.</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<div class="col-8 relative">
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Question*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus={!isMobile}
|
||||
bind:value={title}
|
||||
class="grow"
|
||||
type="text"
|
||||
placeholder="What would you like to ask?" />
|
||||
</label>
|
||||
{/snippet}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Options*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="flex flex-col gap-2" role="list">
|
||||
{#each options as option, index (option.id)}
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
draggable="true"
|
||||
role="listitem"
|
||||
ondragstart={e => onDragStart(e, option.id)}
|
||||
ondragover={e => onDragOver(e, option.id)}
|
||||
ondrop={e => onDrop(e, option.id)}
|
||||
ondragend={onDragEnd}>
|
||||
<div class="cursor-move opacity-70" aria-label="Drag handle">
|
||||
<Icon icon={HamburgerMenu} size={4} />
|
||||
</div>
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<input
|
||||
value={option.value}
|
||||
class="grow"
|
||||
type="text"
|
||||
placeholder={`Option ${index + 1}`}
|
||||
oninput={e => updateOption(option.id, e.currentTarget.value)} />
|
||||
</label>
|
||||
<Button class="btn btn-ghost btn-sm" onclick={() => removeOption(option.id)}>
|
||||
<Icon icon={MinusCircle} size={4} />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
<Button class="btn btn-outline btn-sm self-end" onclick={addOption}>
|
||||
<Icon icon={PlusCircle} size={4} />
|
||||
Add option
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Field>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
Poll type
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<select class="select select-bordered w-full max-w-xs" bind:value={pollType}>
|
||||
<option value="singlechoice">Single choice</option>
|
||||
<option value="multiplechoice">Multiple choice</option>
|
||||
</select>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
Ends at
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<DateTimeInput bind:value={endsAt} />
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary">Create Poll</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import CommentActions from "@app/components/CommentActions.svelte"
|
||||
import RoomLink from "@app/components/RoomLink.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import {makePollPath} from "@app/util/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const h = getTagValue("h", event.tags)
|
||||
</script>
|
||||
|
||||
<Link
|
||||
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-md"
|
||||
href={makePollPath(url, event.id)}>
|
||||
<NoteContent {event} {url} />
|
||||
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||
<span class="whitespace-nowrap py-1 text-sm opacity-75">
|
||||
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
||||
{#if h}
|
||||
in <RoomLink {url} {h} />
|
||||
{/if}
|
||||
</span>
|
||||
<CommentActions segment="polls" showActivity {url} {event} />
|
||||
</div>
|
||||
</Link>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import {tweened} from "svelte/motion"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {noop} from "@welshman/lib"
|
||||
import {stopPropagation} from "@lib/html"
|
||||
import {getPollType, isPollClosed} from "@app/util/polls"
|
||||
|
||||
type Props = {
|
||||
event: TrustedEvent
|
||||
option: {id: string; label: string}
|
||||
results: {voters: number; options: {id: string; votes: number}[]}
|
||||
selectedIds: string[]
|
||||
setSingleChoice: (id: string) => void
|
||||
toggleMultipleChoice: (id: string) => void
|
||||
}
|
||||
|
||||
const {event, option, results, selectedIds, setSingleChoice, toggleMultipleChoice}: Props =
|
||||
$props()
|
||||
|
||||
const pollType = getPollType(event)
|
||||
const closed = isPollClosed(event)
|
||||
|
||||
const selected = $derived(
|
||||
pollType === "singlechoice" ? selectedIds[0] === option.id : selectedIds.includes(option.id),
|
||||
)
|
||||
const onselect = () =>
|
||||
pollType === "singlechoice" ? setSingleChoice(option.id) : toggleMultipleChoice(option.id)
|
||||
|
||||
const votes = $derived(results.options.find(r => r.id === option.id)?.votes || 0)
|
||||
const maxVotes = $derived(Math.max(...results.options.map(r => r.votes), 1))
|
||||
|
||||
const tweenedVotes = tweened(votes, {duration: 300})
|
||||
const tweenedMax = tweened(maxVotes, {duration: 300})
|
||||
|
||||
$effect(() => {
|
||||
tweenedVotes.set(votes)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
tweenedMax.set(maxVotes)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 card2 card2-sm bg-alt">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label class="flex min-w-0 grow items-center gap-2">
|
||||
{#if !closed}
|
||||
{#if pollType === "singlechoice"}
|
||||
<input
|
||||
name={event.id}
|
||||
type="radio"
|
||||
class="radio radio-primary radio-sm"
|
||||
checked={selected}
|
||||
onclick={stopPropagation(noop)}
|
||||
onchange={onselect} />
|
||||
{:else}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary checkbox-sm"
|
||||
checked={selected}
|
||||
onclick={stopPropagation(noop)}
|
||||
onchange={onselect} />
|
||||
{/if}
|
||||
{/if}
|
||||
<span class="truncate">{option.label}</span>
|
||||
</label>
|
||||
<span class="whitespace-nowrap text-xs opacity-75">{votes} vote{votes === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
<progress class="progress progress-primary" value={$tweenedVotes} max={$tweenedMax}></progress>
|
||||
</div>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import {onDestroy} from "svelte"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {pubkey, publishThunk, abortThunk} from "@welshman/app"
|
||||
import {PollResponse} from "nostr-tools/kinds"
|
||||
import {formatTimestampRelative} from "@welshman/lib"
|
||||
import {deriveEvents} from "@app/core/state"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {makePollResponse} from "@app/core/commands"
|
||||
import PollOption from "@app/components/PollOption.svelte"
|
||||
import {
|
||||
getPollEndsAt,
|
||||
getPollOptions,
|
||||
getPollResponseSelections,
|
||||
getPollResults,
|
||||
getPollType,
|
||||
isPollClosed,
|
||||
} from "@app/util/polls"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const responses = deriveEvents([{kinds: [PollResponse], "#e": [event.id]}])
|
||||
|
||||
const pollType = getPollType(event)
|
||||
const options = getPollOptions(event)
|
||||
const closed = isPollClosed(event)
|
||||
const endsAt = getPollEndsAt(event)
|
||||
const publishDelay = pollType === "multiplechoice" ? 10_000 : undefined
|
||||
|
||||
const getOwnResponse = (responses: TrustedEvent[]) => {
|
||||
let latest: TrustedEvent | undefined
|
||||
|
||||
for (const response of responses) {
|
||||
if (response.pubkey !== $pubkey) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!latest || response.created_at > latest.created_at) {
|
||||
latest = response
|
||||
}
|
||||
}
|
||||
|
||||
return latest
|
||||
}
|
||||
|
||||
const publishSelection = (selection: string[]) => {
|
||||
if (activeThunk) {
|
||||
abortThunk(activeThunk)
|
||||
}
|
||||
|
||||
if (selection.length === 0) {
|
||||
activeThunk = undefined
|
||||
return
|
||||
}
|
||||
|
||||
activeThunk = publishThunk({
|
||||
relays: [url],
|
||||
event: makePollResponse({event, selectedIds: selection}),
|
||||
delay: publishDelay,
|
||||
})
|
||||
}
|
||||
|
||||
const publishCurrentSelection = () => {
|
||||
const selection = pollType === "singlechoice" ? selectedIds.slice(0, 1) : selectedIds
|
||||
|
||||
if (selection.length === 0) {
|
||||
return pushToast({theme: "error", message: "Please select at least one option."})
|
||||
}
|
||||
|
||||
publishSelection(selection)
|
||||
}
|
||||
|
||||
const results = $derived(getPollResults(event, $responses))
|
||||
const ownResponse = $derived(getOwnResponse($responses))
|
||||
|
||||
const setSingleChoice = (id: string) => {
|
||||
selectedIds = [id]
|
||||
publishCurrentSelection()
|
||||
}
|
||||
|
||||
const toggleMultipleChoice = (id: string) => {
|
||||
selectedIds = selectedIds.includes(id)
|
||||
? selectedIds.filter(selectedId => selectedId !== id)
|
||||
: [...selectedIds, id]
|
||||
|
||||
publishCurrentSelection()
|
||||
}
|
||||
|
||||
let selectedIds = $state<string[]>([])
|
||||
let activeThunk: ReturnType<typeof publishThunk> | undefined
|
||||
|
||||
$effect(() => {
|
||||
if (ownResponse) {
|
||||
selectedIds = getPollResponseSelections(ownResponse, pollType)
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (activeThunk) {
|
||||
abortThunk(activeThunk)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each options as option (option.id)}
|
||||
<PollOption {event} {option} {results} {selectedIds} {setSingleChoice} {toggleMultipleChoice} />
|
||||
{/each}
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-sm opacity-75">
|
||||
{pollType === "multiplechoice" ? "Multiple choice" : "Single choice"}
|
||||
{#if endsAt}
|
||||
{#if closed}
|
||||
• Ended {formatTimestampRelative(endsAt)}
|
||||
{:else}
|
||||
• Ends {formatTimestampRelative(endsAt)}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-sm opacity-75">{results.voters} vote{results.voters === 1 ? "" : "s"}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -14,7 +14,7 @@
|
||||
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {notifications} from "@app/util/notifications"
|
||||
import {goToChat} from "@app/util/routes"
|
||||
import {goToChat, makeSpacePath} from "@app/util/routes"
|
||||
|
||||
type Props = {
|
||||
children?: Snippet
|
||||
@@ -26,22 +26,20 @@
|
||||
|
||||
const showSettingsMenu = () => pushModal(MenuSettings)
|
||||
|
||||
const anySpaceNotifications = $derived($userSpaceUrls.some(p => $notifications.has(p)))
|
||||
const anySpaceNotifications = $derived(
|
||||
$userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))),
|
||||
)
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
|
||||
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 shrink-0 bg-base-200 pt-2 md:block">
|
||||
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
|
||||
<PrimaryNavSpaces />
|
||||
{#if PLATFORM_RELAYS.length > 0}
|
||||
<Divider />
|
||||
{/if}
|
||||
<div>
|
||||
<PrimaryNavItem
|
||||
title="Settings"
|
||||
href="/settings/profile"
|
||||
prefix="/settings"
|
||||
class="tooltip-right">
|
||||
<div class="flex flex-col">
|
||||
<PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings">
|
||||
{#if $userProfile?.picture}
|
||||
<ImageIcon alt="Settings" src={$userProfile?.picture} class="rounded-full" size={10} />
|
||||
{:else}
|
||||
@@ -51,11 +49,10 @@
|
||||
<PrimaryNavItem
|
||||
title="Messages"
|
||||
onclick={chatHandler}
|
||||
class="tooltip-right"
|
||||
notification={$notifications.has("/chat")}>
|
||||
<ImageIcon alt="Messages" src={Letter} size={8} />
|
||||
</PrimaryNavItem>
|
||||
<PrimaryNavItem title="Search" href="/people" class="tooltip-right">
|
||||
<PrimaryNavItem title="Search" href="/people">
|
||||
<ImageIcon alt="Search" src={Magnifier} size={8} />
|
||||
</PrimaryNavItem>
|
||||
</div>
|
||||
@@ -65,11 +62,10 @@
|
||||
{@render children?.()}
|
||||
|
||||
<!-- a little extra something for ios -->
|
||||
<div
|
||||
class="bottom-nav hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden">
|
||||
<div class="hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-(--saib) bg-base-100 md:hidden">
|
||||
</div>
|
||||
<div
|
||||
class="bottom-nav hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
|
||||
class="hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
|
||||
<div class="content-padding-x content-sizing flex justify-between px-2">
|
||||
<div class="flex gap-2 sm:gap-6">
|
||||
<PrimaryNavItem title="Search" href="/people">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import {deriveRelayDisplay} from "@welshman/app"
|
||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||
import {makeSpacePath, goToSpace} from "@app/util/routes"
|
||||
@@ -12,11 +12,13 @@
|
||||
const {url}: Props = $props()
|
||||
|
||||
const onClick = () => goToSpace(url)
|
||||
|
||||
const display = $derived(deriveRelayDisplay(url))
|
||||
</script>
|
||||
|
||||
<PrimaryNavItem
|
||||
onclick={onClick}
|
||||
title={displayRelayUrl(url)}
|
||||
title={$display}
|
||||
class="tooltip-right"
|
||||
notification={$notifications.has(makeSpacePath(url))}>
|
||||
<RelayIcon {url} size={10} class="rounded-full" />
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
const itemHeight = 56
|
||||
const navPadding = 8 * itemHeight
|
||||
const itemLimit = $derived((windowHeight - navPadding) / itemHeight)
|
||||
const itemLimit = $derived(Math.max(0, (windowHeight - navPadding) / itemHeight))
|
||||
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
|
||||
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
|
||||
</script>
|
||||
@@ -23,7 +23,7 @@
|
||||
{#each PLATFORM_RELAYS as url (url)}
|
||||
<PrimaryNavItemSpace {url} />
|
||||
{:else}
|
||||
<PrimaryNavItem title="Home" href="/home" class="tooltip-right">
|
||||
<PrimaryNavItem title="Home" href="/home">
|
||||
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
|
||||
</PrimaryNavItem>
|
||||
<Divider />
|
||||
@@ -33,7 +33,6 @@
|
||||
<PrimaryNavItem
|
||||
href="/spaces"
|
||||
title="All Spaces"
|
||||
class="tooltip-right"
|
||||
prefix="no-highlight"
|
||||
notification={otherSpaceNotifications}>
|
||||
<ImageIcon alt="All Spaces" src={Widget} size={8} />
|
||||
|
||||
@@ -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="flex-shrink-0">
|
||||
<div class="shrink-0">
|
||||
<RelayIcon {url} size={12} />
|
||||
</div>
|
||||
<div class="flex flex-grow flex-col">
|
||||
<div class="flex grow flex-col">
|
||||
<RelayName {url} />
|
||||
<div class="text-sm opacity-75">
|
||||
{url}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Reaction from "@app/components/Reaction.svelte"
|
||||
import ReportDetails from "@app/components/ReportDetails.svelte"
|
||||
import {REACTION_KINDS} from "@app/core/state"
|
||||
import {REACTION_KINDS, deriveUserIsSpaceAdmin} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
|
||||
interface Props {
|
||||
@@ -78,6 +78,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||
|
||||
const onReportClick = () => pushModal(ReportDetails, {url, event})
|
||||
|
||||
const reportReasons = $derived(uniq(map(e => getTag("e", e.tags)?.[2], $reports.values())))
|
||||
@@ -118,7 +120,7 @@
|
||||
|
||||
{#if $reactions.length > 0 || $zaps.length || $reports.length > 0 || children}
|
||||
<div class="flex min-w-0 flex-wrap gap-2">
|
||||
{#if url && $reports.length > 0}
|
||||
{#if url && $reports.length > 0 && $userIsAdmin}
|
||||
<button
|
||||
type="button"
|
||||
data-tip={`This content has been reported as "${displayList(reportReasons)}".`}
|
||||
|
||||
@@ -121,6 +121,6 @@
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-primary flex-grow" onclick={back}>Done</Button>
|
||||
<Button class="btn btn-primary grow" onclick={back}>Done</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
@@ -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 flex-grow flex-row items-start gap-4">
|
||||
<div class="flex h-7 w-7 flex-shrink-0 items-center justify-center">
|
||||
<div class="flex grow flex-row items-start gap-4">
|
||||
<div class="flex h-7 w-7 shrink-0 items-center justify-center">
|
||||
<Icon {icon} />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
|
||||
@@ -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>
|
||||
@@ -35,7 +35,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<h2 class="ellipsize whitespace-nowrap text-xl">
|
||||
<RelayName {url} />
|
||||
</h2>
|
||||
|
||||
@@ -12,18 +12,29 @@
|
||||
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
|
||||
}
|
||||
|
||||
const {url, h, content, onEscape, onEditPrevious, onSubmit}: Props = $props()
|
||||
let {url, h, initialValues, onEscape, onEditPrevious, onSubmit}: Props = $props()
|
||||
|
||||
const draftKey = url || h ? new DraftKey<Values>(`room:${url ?? ""}:${h ?? ""}`) : undefined
|
||||
|
||||
if (!initialValues) {
|
||||
initialValues = draftKey?.get()
|
||||
}
|
||||
|
||||
const autofocus = !isMobile
|
||||
|
||||
@@ -61,12 +72,29 @@
|
||||
|
||||
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
|
||||
@@ -104,8 +132,8 @@
|
||||
</Button>
|
||||
</Tippy>
|
||||
</div>
|
||||
<div class="chat-editor flex-grow overflow-hidden">
|
||||
<EditorContent {editor} />
|
||||
<div class="chat-editor grow overflow-hidden">
|
||||
<EditorContent {autofocus} {editor} />
|
||||
</div>
|
||||
<Button
|
||||
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Popover from "@lib/components/Popover.svelte"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
import Tooltip from "@lib/components/Tooltip.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
@@ -206,39 +207,39 @@
|
||||
<strong class="text-lg">Room Permissions</strong>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{#if $room?.isRestricted}
|
||||
<Button
|
||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
||||
data-tip="Only members can send messages.">
|
||||
<Icon size={4} icon={Microphone} /> Restricted
|
||||
</Button>
|
||||
<Tooltip content="Only members can send messages.">
|
||||
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||
<Icon size={4} icon={Microphone} /> Restricted
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{#if $room?.isPrivate}
|
||||
<Button
|
||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
||||
data-tip="Only members can view messages.">
|
||||
<Icon size={4} icon={Lock} /> Private
|
||||
</Button>
|
||||
<Tooltip content="Only members can view messages.">
|
||||
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||
<Icon size={4} icon={Lock} /> Private
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{#if $room?.isHidden}
|
||||
<Button
|
||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
||||
data-tip="This room is not visible to non-members.">
|
||||
<Icon size={4} icon={EyeClosed} /> Hidden
|
||||
</Button>
|
||||
<Tooltip content="This room is not visible to non-members.">
|
||||
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||
<Icon size={4} icon={EyeClosed} /> Hidden
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{#if $room?.isClosed}
|
||||
<Button
|
||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
||||
data-tip="Requests to join this room will be ignored.">
|
||||
<Icon size={4} icon={MinusCircle} /> Closed
|
||||
</Button>
|
||||
<Tooltip content="Requests to join this room will be ignored.">
|
||||
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||
<Icon size={4} icon={MinusCircle} /> Closed
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{#if !$room?.isRestricted && !$room?.isPrivate && !$room?.isHidden && !$room?.isClosed}
|
||||
<Button
|
||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
||||
data-tip="This room has no additional access controls.">
|
||||
<Icon size={4} icon={Eye} /> Public
|
||||
</Button>
|
||||
<Tooltip content="This room has no additional access controls.">
|
||||
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||
<Icon size={4} icon={Eye} /> Public
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
<p>Icon</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="flex flex-grow items-center justify-between gap-4">
|
||||
<div class="flex grow items-center justify-between gap-4">
|
||||
{#if imagePreview}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm opacity-75">Selected:</span>
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
event: TrustedEvent
|
||||
replyTo?: (event: TrustedEvent) => void
|
||||
showPubkey?: boolean
|
||||
addSpaceBelow?: boolean
|
||||
inert?: boolean
|
||||
canEdit: (event: TrustedEvent) => boolean
|
||||
onEdit: (event: TrustedEvent) => void
|
||||
@@ -47,6 +48,7 @@
|
||||
event,
|
||||
replyTo = undefined,
|
||||
showPubkey = false,
|
||||
addSpaceBelow = false,
|
||||
inert = false,
|
||||
canEdit,
|
||||
onEdit,
|
||||
@@ -77,19 +79,22 @@
|
||||
<TapTarget
|
||||
data-event={event.id}
|
||||
onTap={inert ? null : onTap}
|
||||
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left hover:bg-base-100/50">
|
||||
class={cx(
|
||||
"group relative flex w-full cursor-default flex-col px-2 py-0.5 text-left hover:bg-base-100/50",
|
||||
{"mt-1.5": showPubkey, "mb-1.5": addSpaceBelow},
|
||||
)}>
|
||||
<div class="flex w-full gap-3 overflow-auto">
|
||||
{#if showPubkey}
|
||||
<Button onclick={openProfile} class="flex items-start">
|
||||
<Button onclick={openProfile} class="flex items-start pt-1.5 justify-center w-8 shrink-0">
|
||||
<ProfileCircle
|
||||
pubkey={event.pubkey}
|
||||
class="border border-solid border-base-content"
|
||||
size={8} />
|
||||
</Button>
|
||||
{:else}
|
||||
<div class="w-8 min-w-8 max-w-8"></div>
|
||||
<div class="w-8 shrink-0"></div>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-grow pr-1">
|
||||
<div class="min-w-0 grow pr-1">
|
||||
{#if showPubkey}
|
||||
<div class="flex items-center gap-2">
|
||||
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
|
||||
@@ -140,7 +145,7 @@
|
||||
</div>
|
||||
{#if !isMobile}
|
||||
<button
|
||||
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
|
||||
class="join absolute right-2 top-0.5 border border-solid border-neutral text-xs opacity-0 transition-all pr-2"
|
||||
class:group-hover:opacity-100={!isMobile}>
|
||||
{#if ENABLE_ZAPS}
|
||||
<RoomItemZapButton {url} {event} />
|
||||
|
||||
@@ -8,16 +8,22 @@
|
||||
import {getRoomItemPath} from "@app/util/routes"
|
||||
|
||||
const props: ComponentProps<typeof NoteContent> = $props()
|
||||
const MESSAGE_MIN_LENGTH = 5000
|
||||
const MESSAGE_MAX_LENGTH = 5500
|
||||
|
||||
const path = getRoomItemPath(props.url!, props.event)
|
||||
const minLength =
|
||||
props.minLength ?? (props.event.kind === MESSAGE ? MESSAGE_MIN_LENGTH : undefined)
|
||||
const maxLength =
|
||||
props.maxLength ?? (props.event.kind === MESSAGE ? MESSAGE_MAX_LENGTH : undefined)
|
||||
</script>
|
||||
|
||||
<div class={cx("text-sm", {"card2 card2-sm bg-alt": props.event.kind !== MESSAGE})}>
|
||||
{#if path && !isMobile}
|
||||
<Link href={path}>
|
||||
<NoteContent {...props} />
|
||||
<NoteContent {...props} {minLength} {maxLength} />
|
||||
</Link>
|
||||
{:else}
|
||||
<NoteContent {...props} />
|
||||
<NoteContent {...props} {minLength} {maxLength} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getPubkeyTagValues} from "@welshman/util"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
</script>
|
||||
|
||||
{#each getPubkeyTagValues(event.tags) as pubkey}
|
||||
<div class="py-1 text-center text-xs opacity-75">
|
||||
<ProfileLink unstyled class="text-primary" {url} {pubkey} /> left the room
|
||||
</div>
|
||||
{/each}
|
||||
@@ -11,7 +11,7 @@
|
||||
const {url, h, ...props}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex flex-grow items-center justify-between gap-4 {props.class}">
|
||||
<div class="flex grow items-center justify-between gap-4 {props.class}">
|
||||
<div class="flex items-center gap-3">
|
||||
<RoomImage {url} {h} />
|
||||
<div class="min-w-0 overflow-hidden text-ellipsis">
|
||||
|
||||
@@ -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 flex-grow items-center justify-between gap-4">
|
||||
<div class="ellipsize whitespace-nowrap flex grow items-center justify-between gap-4">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex gap-2 items-center">
|
||||
{@render title?.()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
<p>Icon</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="flex items-center gap-4 justify-between flex-grow">
|
||||
<div class="flex items-center gap-4 justify-between grow">
|
||||
{#if imagePreview}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm opacity-75">Selected:</span>
|
||||
|
||||
@@ -100,6 +100,6 @@
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-primary flex-grow" onclick={back}>Done</Button>
|
||||
<Button class="btn btn-primary grow" onclick={back}>Done</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import {derived} from "svelte/store"
|
||||
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
||||
import {deriveRelay, createSearch, pubkey} from "@welshman/app"
|
||||
import {Poll} from "nostr-tools/kinds"
|
||||
import {deriveRelay, deriveRelayDisplay, createSearch, pubkey} from "@welshman/app"
|
||||
import {fly} from "@lib/transition"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||
@@ -17,6 +18,7 @@
|
||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
||||
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
||||
import Revote from "@assets/icons/revote.svg?dataurl"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||
@@ -64,11 +66,13 @@
|
||||
const {url} = $props()
|
||||
|
||||
const relay = deriveRelay(url)
|
||||
const display = deriveRelayDisplay(url)
|
||||
const chatPath = makeSpacePath(url, "chat")
|
||||
const goalsPath = makeSpacePath(url, "goals")
|
||||
const threadsPath = makeSpacePath(url, "threads")
|
||||
const classifiedsPath = makeSpacePath(url, "classifieds")
|
||||
const calendarPath = makeSpacePath(url, "calendar")
|
||||
const pollsPath = makeSpacePath(url, "polls")
|
||||
const userRooms = deriveUserRooms(url)
|
||||
const otherRooms = deriveOtherRooms(url)
|
||||
const otherVoiceRooms = deriveOtherVoiceRooms(url)
|
||||
@@ -136,12 +140,14 @@
|
||||
|
||||
<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="flex-shrink-0">
|
||||
<div class="shrink-0">
|
||||
<Button
|
||||
class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
|
||||
onclick={openMenu}>
|
||||
<div class="flex items-center justify-between">
|
||||
<strong class="flex items-center gap-1 relative">
|
||||
<strong
|
||||
class="flex items-center gap-1 relative tooltip tooltip-right"
|
||||
data-tip={$display}>
|
||||
<RelayName {url} class="ellipsize" />
|
||||
<div
|
||||
class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0"
|
||||
@@ -257,16 +263,21 @@
|
||||
<Icon icon={CalendarMinimalistic} /> Calendar
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if $spaceKinds.has(Poll)}
|
||||
<SecondaryNavItem href={pollsPath}>
|
||||
<Icon icon={Revote} /> Polls
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if hasNip29($relay)}
|
||||
{#if $userRooms.length > 0}
|
||||
<div class="h-2 flex-shrink-0"></div>
|
||||
<div class="h-2 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 flex-shrink-0"></div>
|
||||
<div class="h-2 shrink-0"></div>
|
||||
<SecondaryNavHeader>
|
||||
{#if $userRooms.length > 0}
|
||||
Other Rooms
|
||||
@@ -285,7 +296,7 @@
|
||||
<SpaceMenuRoomItem {url} {h} />
|
||||
{/each}
|
||||
{#if $otherVoiceRooms.length > 0}
|
||||
<div class="h-2 flex-shrink-0"></div>
|
||||
<div class="h-2 shrink-0"></div>
|
||||
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
|
||||
{#each $otherVoiceRooms as h (h)}
|
||||
<SpaceMenuRoomItem {url} {h} />
|
||||
@@ -298,11 +309,11 @@
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="h-5 flex-shrink-0"></div>
|
||||
<div class="h-5 shrink-0"></div>
|
||||
</div>
|
||||
</SecondaryNavSection>
|
||||
<div
|
||||
class="flex flex-shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+0.25rem)] md:pb-2 z-nav">
|
||||
class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+0.25rem)] md:pb-2 z-nav">
|
||||
<VoiceWidget />
|
||||
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
|
||||
<SocketStatusIndicator {url} />
|
||||
|
||||
@@ -24,12 +24,13 @@
|
||||
const shouldNotifyForRoom = deriveShouldNotify(url, h)
|
||||
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
|
||||
const notification = $derived($shouldNotifyForRoom ? $notifications.has(path) : false)
|
||||
const roomName = $derived($room?.name || h)
|
||||
</script>
|
||||
|
||||
{#if roomType === RoomType.Voice}
|
||||
<VoiceRoomItem {url} {h} {replaceState} {notification} />
|
||||
{:else}
|
||||
<SecondaryNavItem href={path} {replaceState} {notification}>
|
||||
<SecondaryNavItem href={path} title={roomName} {replaceState} {notification}>
|
||||
<RoomNameWithImage {url} {h} />
|
||||
{#if showDifferenceIcon}
|
||||
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script lang="ts">
|
||||
import {tick} from "svelte"
|
||||
import {createSearch} from "@welshman/app"
|
||||
import {debounce} from "throttle-debounce"
|
||||
import {request} from "@welshman/net"
|
||||
import {formatTimestampAsDate, groupBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {MESSAGE} from "@welshman/util"
|
||||
import type {TrustedEvent, Filter} from "@welshman/util"
|
||||
import {sortEventsDesc} from "@welshman/util"
|
||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
import {fly} from "@lib/transition"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import {deriveEventsForUrl} from "@app/core/state"
|
||||
import {CONTENT_KINDS} from "@app/core/state"
|
||||
import {goToEvent} from "@app/util/routes"
|
||||
|
||||
type Props = {
|
||||
@@ -19,14 +20,16 @@
|
||||
|
||||
const {url, h}: Props = $props()
|
||||
|
||||
const spaceMessages = deriveEventsForUrl(
|
||||
url,
|
||||
h ? [{kinds: [MESSAGE], "#h": [h]}] : [{kinds: [MESSAGE]}],
|
||||
)
|
||||
|
||||
let term = $state("")
|
||||
let show = $state(false)
|
||||
let results = $state<TrustedEvent[]>([])
|
||||
let loading = $state(false)
|
||||
let input: HTMLInputElement | undefined = $state()
|
||||
let controller: AbortController | undefined
|
||||
|
||||
const relayStatus = $derived(
|
||||
h ? `Searching this room on relay: ${url}.` : `Searching this space on relay: ${url}.`,
|
||||
)
|
||||
|
||||
const open = () => {
|
||||
show = true
|
||||
@@ -40,21 +43,53 @@
|
||||
const clear = () => {
|
||||
term = ""
|
||||
show = false
|
||||
loading = false
|
||||
results = []
|
||||
controller?.abort()
|
||||
controller = undefined
|
||||
}
|
||||
|
||||
const getRelayUrls = () => [url]
|
||||
|
||||
const getFilter = (searchTerm: string): Filter =>
|
||||
h
|
||||
? {kinds: CONTENT_KINDS, "#h": [h], search: searchTerm}
|
||||
: {kinds: CONTENT_KINDS, search: searchTerm}
|
||||
|
||||
const search = debounce(300, async (searchTerm: string) => {
|
||||
controller?.abort()
|
||||
|
||||
if (!searchTerm.trim()) {
|
||||
loading = false
|
||||
results = []
|
||||
return
|
||||
}
|
||||
|
||||
controller = new AbortController()
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const events = await request({
|
||||
relays: getRelayUrls(),
|
||||
autoClose: true,
|
||||
signal: controller.signal,
|
||||
filters: [getFilter(searchTerm.trim())],
|
||||
})
|
||||
|
||||
results = sortEventsDesc(events)
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
||||
results = []
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
})
|
||||
|
||||
const onInput = () => {
|
||||
show = true
|
||||
void search(term)
|
||||
}
|
||||
|
||||
const searchIndex = $derived.by(() =>
|
||||
createSearch($spaceMessages, {
|
||||
getValue: event => event.id,
|
||||
fuseOptions: {keys: ["content"]},
|
||||
}),
|
||||
)
|
||||
|
||||
const results = $derived(term ? searchIndex.searchOptions(term) : [])
|
||||
|
||||
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
|
||||
|
||||
const getAgeSection = (createdAt: number) => {
|
||||
@@ -95,73 +130,74 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
|
||||
<Icon size={4} icon={Magnifier} />
|
||||
</button>
|
||||
{#if show}
|
||||
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
|
||||
<div class="fixed cw top-0 right-0 z-feature p-2">
|
||||
<div
|
||||
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
|
||||
transition:fly={{y: -40, duration: 150}}>
|
||||
<div class="flex justify-between">
|
||||
<strong>Search</strong>
|
||||
<Button onclick={clear}>
|
||||
<Icon icon={CloseCircle} />
|
||||
</Button>
|
||||
</div>
|
||||
<label class="input input-sm input-bordered flex w-full items-center gap-2">
|
||||
<Icon size={4} icon={Magnifier} />
|
||||
<input
|
||||
bind:this={input}
|
||||
bind:value={term}
|
||||
class="min-w-0 grow"
|
||||
type="text"
|
||||
placeholder={h ? "Search this room..." : "Search this space..."}
|
||||
oninput={onInput} />
|
||||
</label>
|
||||
<div class="max-h-[65vh] overflow-y-auto">
|
||||
{#if !term}
|
||||
<p class="text-sm opacity-70">
|
||||
{h ? "Search for messages in this room." : "Search for messages across this space."}
|
||||
</p>
|
||||
{:else if eventsByAge.size === 0}
|
||||
<p class="text-sm opacity-70">No results found.</p>
|
||||
{:else}
|
||||
<div class="col-2">
|
||||
{#each eventsByAge as [key, events] (key)}
|
||||
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
|
||||
<Icon size={4} icon={Magnifier} />
|
||||
</button>
|
||||
{#if show}
|
||||
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
|
||||
<div class="fixed top-sai right-sai left-content z-feature p-2">
|
||||
<div
|
||||
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
|
||||
transition:fly={{y: -40, duration: 150}}>
|
||||
<div class="flex justify-between">
|
||||
<strong>Search</strong>
|
||||
<Button onclick={clear}>
|
||||
<Icon icon={CloseCircle} />
|
||||
</Button>
|
||||
</div>
|
||||
<label class="input input-sm input-bordered flex w-full items-center gap-2">
|
||||
<Icon size={4} icon={Magnifier} />
|
||||
<input
|
||||
bind:this={input}
|
||||
bind:value={term}
|
||||
class="min-w-0 grow"
|
||||
type="text"
|
||||
placeholder={h ? "Search this room..." : "Search this space..."}
|
||||
oninput={onInput} />
|
||||
</label>
|
||||
<div class="max-h-[65vh] overflow-y-auto">
|
||||
<p class="mb-2 text-xs opacity-70">{relayStatus}</p>
|
||||
{#if !term}
|
||||
<p class="text-sm opacity-70">
|
||||
{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>
|
||||
{:else if eventsByAge.size === 0}
|
||||
<p class="text-sm opacity-70">No results found.</p>
|
||||
{:else}
|
||||
<div class="col-2">
|
||||
{#each eventsByAge as [key, events] (key)}
|
||||
<div class="col-2">
|
||||
<p class="text-xs uppercase tracking-wide opacity-60">
|
||||
{#if key === "day"}
|
||||
Last 24 Hours
|
||||
{:else if key === "week"}
|
||||
Last 7 Days
|
||||
{:else}
|
||||
Older
|
||||
{/if}
|
||||
</p>
|
||||
<div class="col-2">
|
||||
<p class="text-xs uppercase tracking-wide opacity-60">
|
||||
{#if key === "day"}
|
||||
Last 24 Hours
|
||||
{:else if key === "week"}
|
||||
Last 7 Days
|
||||
{:else}
|
||||
Older
|
||||
{/if}
|
||||
</p>
|
||||
<div class="col-2">
|
||||
{#each events as event (event.id)}
|
||||
<button
|
||||
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
|
||||
onclick={() => onRoomSearchResultClick(event)}>
|
||||
<p class="line-clamp-2 text-sm">
|
||||
{event.content.trim() || "(No text content)"}
|
||||
</p>
|
||||
<div class="row-2 text-xs opacity-70">
|
||||
<span>{getAgeLabel(event.created_at)}</span>
|
||||
<span>{formatTimestampAsDate(event.created_at)}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#each events as event (event.id)}
|
||||
<button
|
||||
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
|
||||
onclick={() => onRoomSearchResultClick(event)}>
|
||||
<p class="line-clamp-2 text-sm">
|
||||
{event.content.trim() || "(No text content)"}
|
||||
</p>
|
||||
<div class="row-2 text-xs opacity-70">
|
||||
<span>{getAgeLabel(event.created_at)}</span>
|
||||
<span>{formatTimestampAsDate(event.created_at)}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||
<div class="flex grow flex-wrap justify-end gap-2">
|
||||
{#if h && showRoom}
|
||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||
Posted in #<RoomName {h} {url} />
|
||||
|
||||
@@ -18,15 +18,22 @@
|
||||
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} from "@app/core/commands"
|
||||
|
||||
type Values = {
|
||||
content?: string | object
|
||||
title?: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
h?: string
|
||||
}
|
||||
|
||||
const {url, h}: Props = $props()
|
||||
|
||||
const draftKey = new DraftKey<Values>(`thread:${url}:${h ?? ""}`)
|
||||
const initialValues = draftKey.get()
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const uploading = writable(false)
|
||||
@@ -70,12 +77,29 @@
|
||||
event: makeEvent(THREAD, {content, tags}),
|
||||
})
|
||||
|
||||
draftKey.clear()
|
||||
history.back()
|
||||
}
|
||||
|
||||
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
|
||||
let title = $state(initialValues?.title ?? "")
|
||||
let content = $state(initialValues?.content ?? "")
|
||||
|
||||
let title: string = $state("")
|
||||
const onChange = (json: object) => {
|
||||
content = json
|
||||
}
|
||||
|
||||
const editor = makeEditor({
|
||||
url,
|
||||
submit,
|
||||
uploading,
|
||||
onChange,
|
||||
placeholder: "What's on your mind?",
|
||||
content,
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
draftKey.update({title, content})
|
||||
})
|
||||
</script>
|
||||
|
||||
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||
@@ -106,7 +130,7 @@
|
||||
<p>Message*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="note-editor flex-grow overflow-hidden">
|
||||
<div class="note-editor grow overflow-hidden">
|
||||
<EditorContent {editor} />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -1,28 +1,98 @@
|
||||
<script lang="ts">
|
||||
import {parse, renderAsHtml} from "@welshman/content"
|
||||
import Close from "@assets/icons/close.svg?dataurl"
|
||||
import {fly} from "@lib/transition"
|
||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import {toast, popToast} from "@app/util/toast"
|
||||
|
||||
let touchStartY = 0
|
||||
let touchStartTime = 0
|
||||
let dragY = $state(0)
|
||||
let isSettling = $state(false)
|
||||
let containerEl = $state<HTMLDivElement | undefined>(undefined)
|
||||
|
||||
$effect(() => {
|
||||
if ($toast) {
|
||||
dragY = 0
|
||||
isSettling = false
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!containerEl) return
|
||||
containerEl.addEventListener("touchmove", onTouchMove, {passive: false})
|
||||
return () => containerEl!.removeEventListener("touchmove", onTouchMove)
|
||||
})
|
||||
|
||||
const onActionClick = () => {
|
||||
$toast!.action!.onclick()
|
||||
popToast($toast!.id)
|
||||
}
|
||||
|
||||
const onClose = () => popToast($toast!.id)
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
touchStartY = e.touches[0].clientY
|
||||
touchStartTime = Date.now()
|
||||
dragY = 0
|
||||
isSettling = false
|
||||
}
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
const delta = e.touches[0].clientY - touchStartY
|
||||
if (delta < 0) {
|
||||
e.preventDefault()
|
||||
isSettling = false
|
||||
dragY = delta
|
||||
} else {
|
||||
dragY = 0
|
||||
}
|
||||
}
|
||||
|
||||
const onTouchEnd = (e: TouchEvent) => {
|
||||
const delta = e.changedTouches[0].clientY - touchStartY
|
||||
const duration = Date.now() - touchStartTime
|
||||
const isQuickFlick = duration < 400 && delta < 0
|
||||
const isSlowDismiss = delta < -40
|
||||
|
||||
if (isQuickFlick || isSlowDismiss) {
|
||||
dragY = 0
|
||||
popToast($toast!.id)
|
||||
} else {
|
||||
isSettling = true
|
||||
dragY = 0
|
||||
setTimeout(() => {
|
||||
isSettling = false
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $toast}
|
||||
{@const theme = $toast.theme || "info"}
|
||||
<div transition:fly class="bottom-sai right-sai toast z-toast">
|
||||
<div
|
||||
bind:this={containerEl}
|
||||
transition:fly={{y: -20}}
|
||||
class="fixed z-toast top-[calc(var(--sait)+0.5rem)] left-[calc(var(--sail)+0.5rem)] right-[calc(var(--sair)+0.5rem)] flex flex-col gap-2 md:right-4 md:bottom-4 md:top-auto md:left-auto md:w-80"
|
||||
style={dragY !== 0 || isSettling
|
||||
? `transform: translateY(${dragY}px)${isSettling ? "; transition: transform 200ms ease-out" : ""}`
|
||||
: ""}
|
||||
ontouchstart={onTouchStart}
|
||||
ontouchend={onTouchEnd}>
|
||||
{#key $toast.id}
|
||||
<div
|
||||
role="alert"
|
||||
class="alert flex justify-center whitespace-normal text-left"
|
||||
class="alert relative flex justify-center whitespace-normal text-left"
|
||||
class:bg-base-100={theme === "info"}
|
||||
class:text-base-content={theme === "info"}
|
||||
class:alert-error={theme === "error"}>
|
||||
<p class:welshman-content-error={theme === "error"}>
|
||||
<Button
|
||||
class="absolute -top-2 -right-2 btn btn-circle btn-neutral btn-xs hidden md:inline-flex flex justify-center items-center"
|
||||
onclick={onClose}>
|
||||
<Icon icon={Close} size={4} />
|
||||
</Button>
|
||||
<p class="md:pr-6" class:welshman-content-error={theme === "error"}>
|
||||
{#if $toast.message}
|
||||
{@html renderAsHtml(parse({content: $toast.message}))}
|
||||
{#if $toast.action}
|
||||
@@ -35,9 +105,6 @@
|
||||
<Component toast={$toast} {...props} />
|
||||
{/if}
|
||||
</p>
|
||||
<Button class="flex items-center opacity-75" onclick={() => popToast($toast.id)}>
|
||||
<Icon icon={CloseCircle} />
|
||||
</Button>
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {Track} from "livekit-client"
|
||||
import {displayProfileByPubkey, loadProfile} from "@welshman/app"
|
||||
import Pin from "@assets/icons/pin.svg?dataurl"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import VideoCallTile from "@app/components/VideoCallTile.svelte"
|
||||
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
||||
import {get} from "svelte/store"
|
||||
import {
|
||||
VideoCallLayout,
|
||||
isDesktopLayout,
|
||||
toggleVideoPrimaryTile,
|
||||
videoCallLayout,
|
||||
videoCallViewportSync,
|
||||
ViewportSize,
|
||||
videoPrimaryTileKey,
|
||||
} from "@app/call/video"
|
||||
import {currentVoiceSession, currentVoiceRoom, pubkeyFromLiveKitIdentity} from "@app/call/stores"
|
||||
|
||||
type Props = {
|
||||
layout: VideoCallLayout
|
||||
mobile?: boolean
|
||||
url: string
|
||||
h: string
|
||||
class?: string
|
||||
}
|
||||
|
||||
type VideoTileData = {
|
||||
identity: string
|
||||
isLocal: boolean
|
||||
trackSid: string
|
||||
track: Track | undefined
|
||||
source: Track.Source.Camera | Track.Source.ScreenShare
|
||||
}
|
||||
|
||||
type TileLayout = "spotlight" | "default" | "strip"
|
||||
|
||||
const {layout, mobile = false, url, h, class: className = ""}: Props = $props()
|
||||
|
||||
$effect(() => {
|
||||
const currentLayout = isDesktopLayout.current ? ViewportSize.Desktop : ViewportSize.Mobile
|
||||
const {previousLayout} = videoCallViewportSync
|
||||
if (previousLayout === undefined) {
|
||||
videoCallViewportSync.previousLayout = currentLayout
|
||||
return
|
||||
}
|
||||
if (previousLayout === currentLayout) return
|
||||
const p = get(videoCallLayout)
|
||||
if (previousLayout === ViewportSize.Desktop && currentLayout === ViewportSize.Mobile) {
|
||||
if (p === VideoCallLayout.Split) videoCallLayout.set(VideoCallLayout.Video)
|
||||
} else if (previousLayout === ViewportSize.Mobile && currentLayout === ViewportSize.Desktop) {
|
||||
if (p === VideoCallLayout.Chat) videoCallLayout.set(VideoCallLayout.Split)
|
||||
}
|
||||
videoCallViewportSync.previousLayout = currentLayout
|
||||
})
|
||||
|
||||
const isViewingCurrentCallRoom = $derived(
|
||||
$currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
|
||||
)
|
||||
|
||||
const showVideoContent = $derived(
|
||||
isViewingCurrentCallRoom &&
|
||||
(mobile
|
||||
? layout === VideoCallLayout.Video
|
||||
: layout === VideoCallLayout.Split || layout === VideoCallLayout.Video),
|
||||
)
|
||||
|
||||
const videoTiles = $derived.by(() => {
|
||||
const session = $currentVoiceSession
|
||||
if (!session || $currentVoiceRoom?.url !== url || $currentVoiceRoom?.h !== h) {
|
||||
return []
|
||||
}
|
||||
|
||||
const room = session.room
|
||||
const videoTiles: VideoTileData[] = []
|
||||
const user = room.localParticipant
|
||||
|
||||
if (session.cameraOn) {
|
||||
const localPub = user.getTrackPublication(Track.Source.Camera)
|
||||
videoTiles.push({
|
||||
identity: user.identity,
|
||||
isLocal: true,
|
||||
trackSid: localPub?.trackSid ?? "local-camera",
|
||||
track: localPub?.track,
|
||||
source: Track.Source.Camera,
|
||||
})
|
||||
}
|
||||
|
||||
if (session.screenShareOn) {
|
||||
const localPub = user.getTrackPublication(Track.Source.ScreenShare)
|
||||
videoTiles.push({
|
||||
identity: user.identity,
|
||||
isLocal: true,
|
||||
trackSid: localPub?.trackSid ?? "local-screen",
|
||||
track: localPub?.track,
|
||||
source: Track.Source.ScreenShare,
|
||||
})
|
||||
}
|
||||
|
||||
for (const rp of room.remoteParticipants.values()) {
|
||||
const camPub = rp.getTrackPublication(Track.Source.Camera)
|
||||
if (camPub?.isSubscribed && camPub.track) {
|
||||
videoTiles.push({
|
||||
identity: rp.identity,
|
||||
isLocal: false,
|
||||
trackSid: camPub.trackSid,
|
||||
track: camPub.track,
|
||||
source: Track.Source.Camera,
|
||||
})
|
||||
}
|
||||
const screenPub = rp.getTrackPublication(Track.Source.ScreenShare)
|
||||
if (screenPub?.isSubscribed && screenPub.track) {
|
||||
videoTiles.push({
|
||||
identity: rp.identity,
|
||||
isLocal: false,
|
||||
trackSid: screenPub.trackSid,
|
||||
track: screenPub.track,
|
||||
source: Track.Source.ScreenShare,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return videoTiles
|
||||
})
|
||||
|
||||
/** Identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */
|
||||
const tileKey = (t: VideoTileData) => `${t.identity}\x1f${t.source}`
|
||||
|
||||
const primaryTile = $derived.by(() => {
|
||||
const k = $videoPrimaryTileKey
|
||||
if (k === undefined) return undefined
|
||||
return videoTiles.find(t => tileKey(t) === k)
|
||||
})
|
||||
|
||||
const secondaryTiles = $derived.by(() => {
|
||||
const p = primaryTile
|
||||
if (p === undefined) return videoTiles
|
||||
const pk = tileKey(p)
|
||||
return videoTiles.filter(t => tileKey(t) !== pk)
|
||||
})
|
||||
|
||||
const useSpotlightLayout = $derived(primaryTile !== undefined)
|
||||
const useMultiGrid = $derived(!useSpotlightLayout && videoTiles.length > 2)
|
||||
|
||||
$effect(() => {
|
||||
const k = $videoPrimaryTileKey
|
||||
if (k === undefined) return
|
||||
if (!videoTiles.some(t => tileKey(t) === k)) {
|
||||
videoPrimaryTileKey.set(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
for (const t of videoTiles) {
|
||||
const pk = pubkeyFromLiveKitIdentity(t.identity)
|
||||
if (pk) loadProfile(pk)
|
||||
}
|
||||
})
|
||||
|
||||
const labelFor = (identity: string, source: VideoTileData["source"]) => {
|
||||
const pk = pubkeyFromLiveKitIdentity(identity)
|
||||
const name = pk ? displayProfileByPubkey(pk) : "Unknown"
|
||||
return source === Track.Source.ScreenShare ? `${name} · screen` : name
|
||||
}
|
||||
|
||||
const showTileGrid = $derived(videoTiles.length > 0)
|
||||
|
||||
const spotlightHandlerFor = (key: string) => () => {
|
||||
toggleVideoPrimaryTile(key)
|
||||
}
|
||||
|
||||
const panelChrome = $derived(
|
||||
cx(
|
||||
mobile &&
|
||||
"flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden bg-base-200 px-2 pt-4 md:hidden pb-[calc(3.5rem+var(--saib))]",
|
||||
!mobile &&
|
||||
"flex min-h-0 w-full min-w-0 flex-1 flex-col gap-2 overflow-hidden bg-base-200 px-2 pb-2 pt-4",
|
||||
className,
|
||||
),
|
||||
)
|
||||
</script>
|
||||
|
||||
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
|
||||
<div
|
||||
class={cx(
|
||||
"relative isolate overflow-hidden rounded-box shadow-sm",
|
||||
layout === "spotlight" && "min-h-0 flex-1",
|
||||
layout === "default" && "aspect-video w-full min-h-0",
|
||||
layout === "strip" && "aspect-video w-44 shrink-0",
|
||||
tile.source === Track.Source.ScreenShare ? "bg-black" : "bg-base-100",
|
||||
)}>
|
||||
{#if tile.track}
|
||||
<VideoCallTile
|
||||
track={tile.track}
|
||||
muted={tile.isLocal}
|
||||
fit={tile.source === Track.Source.ScreenShare ? "contain" : "cover"}
|
||||
class="pointer-events-none absolute inset-0" />
|
||||
{:else}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<ProfileCircle pubkey={pubkeyFromLiveKitIdentity(tile.identity)} {url} size={14} />
|
||||
</div>
|
||||
{/if}
|
||||
<span
|
||||
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-base-100/80 px-1.5 py-0.5 text-xs">
|
||||
{labelFor(tile.identity, tile.source)}{tile.isLocal ? " (you)" : ""}
|
||||
</span>
|
||||
{#if videoTiles.length > 1}
|
||||
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
|
||||
<Button
|
||||
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
|
||||
aria-pressed={pinned}
|
||||
class={cx(
|
||||
"absolute right-1 top-1 z-20 btn btn-xs btn-square btn-ghost",
|
||||
pinned ? "btn-active bg-primary/25 text-primary" : "bg-base-100/70",
|
||||
)}
|
||||
onclick={spotlightHandlerFor(tileKey(tile))}>
|
||||
<Icon icon={Pin} size={3} />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet videoPanelBody()}
|
||||
{#if showTileGrid}
|
||||
{#if useSpotlightLayout && primaryTile}
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
{@render videoTile(primaryTile, "spotlight")}
|
||||
{#if secondaryTiles.length > 0}
|
||||
<div
|
||||
class="flex max-h-40 shrink-0 flex-row gap-2 overflow-x-auto overflow-y-hidden py-0.5">
|
||||
{#each secondaryTiles as tile (tileKey(tile))}
|
||||
{@render videoTile(tile, "strip")}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if useMultiGrid}
|
||||
<div
|
||||
class="grid min-h-0 flex-1 grid-cols-1 content-start gap-2 overflow-y-auto sm:grid-cols-2">
|
||||
{#each videoTiles as tile (tileKey(tile))}
|
||||
{@render videoTile(tile, "default")}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||
{#each videoTiles as tile (tileKey(tile))}
|
||||
{@render videoTile(tile, "default")}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div
|
||||
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-box bg-base-200/50 p-4 text-center text-sm opacity-80">
|
||||
<p>No camera or screen share yet.</p>
|
||||
<p class="text-xs">Use the camera or screen share control to share video.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if showVideoContent}
|
||||
<div class={panelChrome}>
|
||||
{#if mobile}
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
{@render videoPanelBody()}
|
||||
</div>
|
||||
<div class="shrink-0 pb-2">
|
||||
<VoiceWidget />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@render videoPanelBody()}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type {Track} from "livekit-client"
|
||||
import cx from "classnames"
|
||||
|
||||
type Props = {
|
||||
track: Track
|
||||
muted?: boolean
|
||||
fit?: "cover" | "contain"
|
||||
class?: string
|
||||
}
|
||||
|
||||
const {track, muted = true, fit = "cover", class: className = ""}: Props = $props()
|
||||
|
||||
let videoElement = $state<HTMLVideoElement | undefined>()
|
||||
|
||||
$effect(() => {
|
||||
const element = videoElement
|
||||
const activeTrack = track
|
||||
if (!element) return
|
||||
activeTrack.attach(element)
|
||||
return () => {
|
||||
activeTrack.detach(element)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
class={cx("h-full w-full", fit === "contain" ? "object-contain" : "object-cover", className)}
|
||||
playsinline
|
||||
{muted}></video>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import {currentVoiceSession, type VoiceSession} from "@app/call/stores"
|
||||
import {DeviceKind, supportsAudioOutputSelection, switchVoiceActiveDevice} from "@app/call/voice"
|
||||
import {popModal} from "@app/util/modal"
|
||||
|
||||
const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => {
|
||||
const livekitDeviceId = session.room.getActiveDevice(kind)
|
||||
if (livekitDeviceId === undefined || livekitDeviceId === "" || livekitDeviceId === "default") {
|
||||
return ""
|
||||
}
|
||||
return livekitDeviceId
|
||||
}
|
||||
|
||||
let audioInputs = $state<MediaDeviceInfo[]>([])
|
||||
let audioOutputs = $state<MediaDeviceInfo[]>([])
|
||||
let videoInputs = $state<MediaDeviceInfo[]>([])
|
||||
let selectedInput = $state("")
|
||||
let selectedOutput = $state("")
|
||||
let selectedVideo = $state("")
|
||||
|
||||
const loadDevices = async () => {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) return
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||
audioInputs = devices.filter(d => d.kind === "audioinput")
|
||||
audioOutputs = devices.filter(d => d.kind === "audiooutput")
|
||||
videoInputs = devices.filter(d => d.kind === "videoinput")
|
||||
} catch {
|
||||
audioInputs = []
|
||||
audioOutputs = []
|
||||
videoInputs = []
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadDevices()
|
||||
navigator.mediaDevices?.addEventListener?.("devicechange", loadDevices)
|
||||
return () => navigator.mediaDevices?.removeEventListener?.("devicechange", loadDevices)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const session = $currentVoiceSession
|
||||
if (!session) {
|
||||
popModal()
|
||||
return
|
||||
}
|
||||
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
|
||||
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
|
||||
selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput)
|
||||
})
|
||||
|
||||
const onInputChange = () => {
|
||||
void switchVoiceActiveDevice(DeviceKind.AudioInput, selectedInput)
|
||||
}
|
||||
|
||||
const onOutputChange = () => {
|
||||
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput)
|
||||
}
|
||||
|
||||
const onVideoChange = () => {
|
||||
void switchVoiceActiveDevice(DeviceKind.VideoInput, selectedVideo)
|
||||
}
|
||||
|
||||
const onDone = () => {
|
||||
popModal()
|
||||
}
|
||||
|
||||
// Output not support in Safari
|
||||
const canPickOutput = supportsAudioOutputSelection()
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
<ModalBody>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Call settings</ModalTitle>
|
||||
<ModalSubtitle>Microphone, speaker, and camera for this call.</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<div class="flex flex-col gap-4 pt-2">
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Microphone</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
bind:value={selectedInput}
|
||||
onchange={onInputChange}
|
||||
aria-label="Microphone">
|
||||
<option value="">Default microphone</option>
|
||||
{#each audioInputs as d (d.deviceId)}
|
||||
<option value={d.deviceId}>
|
||||
{d.label || `Microphone ${d.deviceId.slice(0, 8)}…`}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
{#if canPickOutput}
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Speaker</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
bind:value={selectedOutput}
|
||||
onchange={onOutputChange}
|
||||
aria-label="Speaker">
|
||||
<option value="">Default speaker</option>
|
||||
{#each audioOutputs as d (d.deviceId)}
|
||||
<option value={d.deviceId}>
|
||||
{d.label || `Speaker ${d.deviceId.slice(0, 8)}…`}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
{/if}
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Camera</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
bind:value={selectedVideo}
|
||||
onchange={onVideoChange}
|
||||
aria-label="Camera">
|
||||
<option value="">Default camera</option>
|
||||
{#each videoInputs as d (d.deviceId)}
|
||||
<option value={d.deviceId}>
|
||||
{d.label || `Camera ${d.deviceId.slice(0, 8)}…`}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-primary w-full" onclick={onDone}>Done</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
@@ -12,14 +12,13 @@
|
||||
import {makeRoomId} from "@app/core/state"
|
||||
import {
|
||||
VoiceState,
|
||||
deriveVoiceParticipants,
|
||||
cancelJoinVoiceRoom,
|
||||
currentVoiceRoom,
|
||||
voiceState,
|
||||
isParticipantSpeaking,
|
||||
participantKey,
|
||||
voiceState,
|
||||
type VoiceParticipant,
|
||||
} from "@app/voice"
|
||||
} from "@app/call/stores"
|
||||
import {cancelJoinVoiceRoom, deriveVoiceParticipants} from "@app/call/voice"
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
@@ -64,7 +63,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}
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import {AbortError, TimeoutError} from "$lib/util"
|
||||
import {displayRoom} from "@app/core/state"
|
||||
import {joinVoiceRoom} from "@app/voice"
|
||||
import {joinVoiceRoom} from "@app/call/voice"
|
||||
import {popModal} from "@app/util/modal"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
@@ -45,6 +47,16 @@
|
||||
|
||||
const goBack = () => history.back()
|
||||
|
||||
const handleJoinError = (e: unknown) => {
|
||||
if (e instanceof AbortError) return
|
||||
console.error("Failed to join voice room", e)
|
||||
let message = "Failed to join voice room"
|
||||
if (e instanceof TimeoutError)
|
||||
message = "Connection timed out. Please check your network and try again."
|
||||
else if (e instanceof Error) message = e.message
|
||||
pushToast({theme: "error", message})
|
||||
}
|
||||
|
||||
const joinVoice = async () => {
|
||||
popModal()
|
||||
await joinVoiceRoom(
|
||||
@@ -52,7 +64,7 @@
|
||||
h,
|
||||
startWithoutMic,
|
||||
startWithoutMic ? undefined : selectedDeviceId || undefined,
|
||||
)
|
||||
).catch(handleJoinError)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
<script lang="ts">
|
||||
import {readable} from "svelte/store"
|
||||
import {fly} from "svelte/transition"
|
||||
import {fade, 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 MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
|
||||
import Videocamera from "@assets/icons/videocamera.svg?dataurl"
|
||||
import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl"
|
||||
import Monitor from "@assets/icons/monitor.svg?dataurl"
|
||||
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
|
||||
import 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"
|
||||
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
|
||||
import {
|
||||
decodeRelay,
|
||||
@@ -21,16 +28,23 @@
|
||||
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,
|
||||
leaveVoiceRoom,
|
||||
toggleMute,
|
||||
cancelJoinVoiceRoom,
|
||||
} from "@app/voice"
|
||||
isLocalSpeaking,
|
||||
} from "@app/call/stores"
|
||||
import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice"
|
||||
|
||||
const {relay, h} = $derived($page.params)
|
||||
const url = $derived(relay ? decodeRelay(relay) : undefined)
|
||||
@@ -39,6 +53,14 @@
|
||||
)
|
||||
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
|
||||
@@ -63,6 +85,46 @@
|
||||
await goto(makeRoomPath(targetRoom.url, targetRoom.h))
|
||||
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
|
||||
}
|
||||
|
||||
const goToRoom = () => {
|
||||
if (!targetRoom) return
|
||||
const path = makeRoomPath(targetRoom.url, targetRoom.h)
|
||||
if ($page.url.pathname !== path) {
|
||||
void goto(path)
|
||||
}
|
||||
}
|
||||
|
||||
const openCallSettings = () => {
|
||||
pushModal(VoiceCallAudioSettingsDialog)
|
||||
}
|
||||
|
||||
const showChatButton = $derived($voiceState === VoiceState.Connected && isViewingCurrentVoiceRoom)
|
||||
|
||||
const isChatPanelActive = $derived(
|
||||
showChatButton &&
|
||||
(isDesktopLayout.current
|
||||
? $videoCallLayout === VideoCallLayout.Split
|
||||
: $videoCallLayout === VideoCallLayout.Chat),
|
||||
)
|
||||
|
||||
const onChatToggle = () => {
|
||||
if (!showChatButton) return
|
||||
if (isDesktopLayout.current) {
|
||||
videoCallLayout.update(p =>
|
||||
p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split,
|
||||
)
|
||||
} else {
|
||||
videoCallLayout.update(p =>
|
||||
p === VideoCallLayout.Video ? VideoCallLayout.Chat : VideoCallLayout.Video,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const chatUnread = $derived(
|
||||
targetRoom !== undefined && $notifications.has(makeRoomPath(targetRoom.url, targetRoom.h)),
|
||||
)
|
||||
|
||||
const mediaToggleClass = "center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
|
||||
</script>
|
||||
|
||||
{#if targetRoom}
|
||||
@@ -70,19 +132,47 @@
|
||||
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 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>
|
||||
<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>
|
||||
{/if}
|
||||
<span class="ellipsize text-xs opacity-70">
|
||||
{roomName} / {spaceName}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if $voiceState === VoiceState.Joining}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<Button
|
||||
@@ -94,11 +184,46 @@
|
||||
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
|
||||
<Button
|
||||
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
|
||||
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
|
||||
? 'btn-error'
|
||||
: 'btn-ghost'}"
|
||||
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",
|
||||
)}
|
||||
onclick={toggleMute}>
|
||||
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
|
||||
<span class="relative inline-flex items-center justify-center overflow-visible">
|
||||
<Icon icon={Microphone} size={4} />
|
||||
{#if $currentVoiceSession.muted}
|
||||
<span
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center overflow-visible"
|
||||
aria-hidden="true">
|
||||
<span
|
||||
class="h-[1.3px] w-[150%] max-w-none shrink-0 -rotate-45 rounded-full bg-current"
|
||||
></span>
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"}
|
||||
class={cx(mediaToggleClass, $currentVoiceSession.cameraOn && "text-primary")}
|
||||
onclick={toggleCamera}>
|
||||
<Icon icon={$currentVoiceSession.cameraOn ? VideocameraRecord : Videocamera} size={4} />
|
||||
</Button>
|
||||
{#if !Capacitor.isNativePlatform()}
|
||||
<Button
|
||||
data-tip={$currentVoiceSession.screenShareOn ? "Stop sharing" : "Share screen"}
|
||||
class={cx(mediaToggleClass, $currentVoiceSession.screenShareOn && "text-primary")}
|
||||
onclick={toggleScreenShare}>
|
||||
<Icon icon={Monitor} size={4} />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
data-tip="Call settings"
|
||||
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
|
||||
onclick={openCallSettings}>
|
||||
<Icon icon={Settings} size={4} />
|
||||
</Button>
|
||||
<Button
|
||||
data-tip="Leave room"
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
Amount (satoshis)
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="flex flex-grow justify-end">
|
||||
<div class="flex grow justify-end">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<Icon icon={Bolt} />
|
||||
<input
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
Amount (satoshis)
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="flex flex-grow justify-end">
|
||||
<div class="flex grow justify-end">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<Icon icon={Bolt} />
|
||||
<input bind:value={sats} type="number" class="w-14" placeholder="0" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<style>
|
||||
.wot-background {
|
||||
fill: transparent;
|
||||
stroke: var(--base-content);
|
||||
stroke: var(--color-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(--primary)" : "var(--base-content)")
|
||||
const stroke = $derived(active ? "var(--color-primary)" : "var(--color-base-content)")
|
||||
</script>
|
||||
|
||||
<div class="relative h-[14px] w-[14px]">
|
||||
|
||||
@@ -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 flex-grow items-center justify-end gap-4">
|
||||
<div class="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 flex-grow justify-end">
|
||||
<div class="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" />
|
||||
|
||||
@@ -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 flex-grow" value={invoice} />
|
||||
<input readonly class="ellipsize 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 flex-grow items-center justify-end gap-4">
|
||||
<div class="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 flex-grow justify-end">
|
||||
<div class="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" />
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import {Nip01Signer} from "@welshman/signer"
|
||||
import type {UploadTask} from "@welshman/editor"
|
||||
import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util"
|
||||
import {PollResponse} from "nostr-tools/kinds"
|
||||
import {
|
||||
DELETE,
|
||||
REPORT,
|
||||
@@ -351,6 +352,22 @@ export const publishReaction = ({relays, ...params}: ReactionParams & {relays: s
|
||||
publishThunk({event: makeReaction({url: relays[0], ...params}), relays})
|
||||
}
|
||||
|
||||
// Polls
|
||||
|
||||
export type PollResponseParams = {
|
||||
event: TrustedEvent
|
||||
selectedIds: string[]
|
||||
}
|
||||
|
||||
export const makePollResponse = ({event, selectedIds}: PollResponseParams) =>
|
||||
makeEvent(PollResponse, {
|
||||
content: "",
|
||||
tags: [["e", event.id], ...selectedIds.map(selectedId => ["response", selectedId])],
|
||||
})
|
||||
|
||||
export const publishPollResponse = ({relays, ...params}: PollResponseParams & {relays: string[]}) =>
|
||||
publishThunk({event: makePollResponse(params), relays})
|
||||
|
||||
// Comments
|
||||
|
||||
export type CommentParams = {
|
||||
|
||||
+102
-65
@@ -1,5 +1,6 @@
|
||||
import {get, writable} from "svelte/store"
|
||||
import {writable} from "svelte/store"
|
||||
import {
|
||||
batch,
|
||||
call,
|
||||
uniq,
|
||||
int,
|
||||
@@ -25,7 +26,8 @@ import {
|
||||
sortEventsDesc,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent, Filter, List} from "@welshman/util"
|
||||
import {load, request} from "@welshman/net"
|
||||
import {load, request, mergeRepositoryUpdates} from "@welshman/net"
|
||||
import type {RepositoryUpdate} from "@welshman/net"
|
||||
import {repository, loadRelay, tracker} from "@welshman/app"
|
||||
import {createScroller} from "@lib/html"
|
||||
import {daysBetween} from "@lib/util"
|
||||
@@ -56,57 +58,75 @@ export const makeFeed = ({
|
||||
let backwardWindow = [at - interval, at]
|
||||
let forwardWindow = [at, at + interval]
|
||||
|
||||
const insertEvent = (event: TrustedEvent) => {
|
||||
let handled = false
|
||||
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)
|
||||
}
|
||||
|
||||
if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) {
|
||||
const $events = get(events)
|
||||
// Batch-insert events into the visible store with a single update
|
||||
const insertEvents = (newEvents: TrustedEvent[]) => {
|
||||
const visible: TrustedEvent[] = []
|
||||
|
||||
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
|
||||
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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return $events
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribers = [
|
||||
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)))
|
||||
}
|
||||
on(
|
||||
repository,
|
||||
"update",
|
||||
batch(150, (updates: RepositoryUpdate[]) => {
|
||||
const {added, removed} = mergeRepositoryUpdates(updates)
|
||||
|
||||
for (const event of added) {
|
||||
if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) {
|
||||
insertEvent(event)
|
||||
if (removed.size > 0) {
|
||||
buffer = buffer.filter(e => !removed.has(e.id))
|
||||
events.update($events => $events.filter(e => !removed.has(e.id)))
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
const matching = added.filter(
|
||||
event => matchFilters(filters, event) && tracker.getRelays(event.id).has(url),
|
||||
)
|
||||
|
||||
if (matching.length > 0) {
|
||||
insertEvents(matching)
|
||||
}
|
||||
}),
|
||||
),
|
||||
on(tracker, "add", (id: string, trackerUrl: string) => {
|
||||
if (trackerUrl === url) {
|
||||
const event = repository.getEvent(id)
|
||||
|
||||
if (event && matchFilters(filters, event)) {
|
||||
insertEvent(event)
|
||||
insertEvents([event])
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -137,9 +157,7 @@ export const makeFeed = ({
|
||||
|
||||
backwardWindow = [since - interval, since]
|
||||
|
||||
for (const event of buffer.splice(0, 30)) {
|
||||
insertEvent(event)
|
||||
}
|
||||
insertEvents(buffer.splice(0, 30))
|
||||
|
||||
if (until > now() - int(2, YEAR)) {
|
||||
loadTimeframe(since, until)
|
||||
@@ -160,9 +178,7 @@ export const makeFeed = ({
|
||||
|
||||
forwardWindow = [until, until + interval]
|
||||
|
||||
for (const event of buffer.splice(0, 30)) {
|
||||
insertEvent(event)
|
||||
}
|
||||
insertEvents(buffer.splice(0, 30))
|
||||
|
||||
if (until < now()) {
|
||||
loadTimeframe(since, until)
|
||||
@@ -208,40 +224,61 @@ export const makeCalendarFeed = ({
|
||||
|
||||
const events = writable(sortBy(getStart, getEventsForUrl(url, filters)))
|
||||
|
||||
const insertEvent = (event: TrustedEvent) => {
|
||||
const start = getStart(event)
|
||||
const address = getAddress(event)
|
||||
|
||||
if (isNaN(start) || isNaN(getEnd(event))) return
|
||||
// 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
|
||||
|
||||
events.update($events => {
|
||||
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)
|
||||
}
|
||||
for (const event of valid) {
|
||||
const start = getStart(event)
|
||||
const address = getAddress(event)
|
||||
|
||||
return [...$events.filter(e => getAddress(e) !== address), event]
|
||||
let handled = false
|
||||
for (let i = 0; i < $events.length; i++) {
|
||||
if ($events[i].id === event.id) {
|
||||
handled = true
|
||||
break
|
||||
}
|
||||
if (getStart($events[i]) > start) {
|
||||
$events = insertAt(i, event, $events)
|
||||
handled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!handled) {
|
||||
$events = [...$events.filter(e => getAddress(e) !== address), event]
|
||||
}
|
||||
}
|
||||
return $events
|
||||
})
|
||||
}
|
||||
|
||||
const unsubscribers = [
|
||||
on(repository, "update", ({added, removed}) => {
|
||||
if (removed.size > 0) {
|
||||
events.update($events => $events.filter(e => !removed.has(e.id)))
|
||||
}
|
||||
on(
|
||||
repository,
|
||||
"update",
|
||||
batch(150, (updates: RepositoryUpdate[]) => {
|
||||
const {added, removed} = mergeRepositoryUpdates(updates)
|
||||
|
||||
for (const event of added) {
|
||||
if (matchFilters(filters, event)) {
|
||||
insertEvent(event)
|
||||
if (removed.size > 0) {
|
||||
events.update($events => $events.filter(e => !removed.has(e.id)))
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
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)) {
|
||||
insertEvent(event)
|
||||
insertEvents([event])
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
+126
-81
@@ -3,11 +3,11 @@ import {context as pomadeContext} from "@pomade/core"
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
import {derived, readable, writable} from "svelte/store"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {Poll} from "nostr-tools/kinds"
|
||||
import {
|
||||
on,
|
||||
gt,
|
||||
max,
|
||||
find,
|
||||
spec,
|
||||
call,
|
||||
first,
|
||||
@@ -191,7 +191,9 @@ export const PLATFORM_TERMS = import.meta.env.VITE_PLATFORM_TERMS
|
||||
|
||||
export const PLATFORM_PRIVACY = import.meta.env.VITE_PLATFORM_PRIVACY
|
||||
|
||||
export const PLATFORM_LOGO = PLATFORM_URL + "/logo.png"
|
||||
export const PLATFORM_LOGO = import.meta.env.PROD
|
||||
? PLATFORM_URL + "/logo.png"
|
||||
: import.meta.env.VITE_PLATFORM_LOGO.replace(/^static/, "") || PLATFORM_URL + "/logo.png"
|
||||
|
||||
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
|
||||
|
||||
@@ -207,6 +209,8 @@ 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," +
|
||||
[
|
||||
@@ -323,7 +327,7 @@ if (ENABLE_ZAPS) {
|
||||
REACTION_KINDS.push(ZAP_RESPONSE)
|
||||
}
|
||||
|
||||
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED]
|
||||
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, Poll]
|
||||
|
||||
export const MESSAGE_KINDS = [...CONTENT_KINDS, MESSAGE]
|
||||
|
||||
@@ -550,7 +554,7 @@ export const chatsById = call(() => {
|
||||
setTimeout(() => {
|
||||
addEvents(added)
|
||||
removeEvents(removed)
|
||||
}, 50)
|
||||
}, 200)
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -564,7 +568,7 @@ export const deriveChat = call(() => {
|
||||
return (pubkeys: string[]) => _deriveChat(makeChatId(pubkeys))
|
||||
})
|
||||
|
||||
export const chatSearch = derived(throttled(800, chatsById), $chatsByPubkey => {
|
||||
export const chatSearch = derived(throttled(1500, chatsById), $chatsByPubkey => {
|
||||
return createSearch(
|
||||
sortBy(c => -c.last_activity, Array.from($chatsByPubkey.values())),
|
||||
{
|
||||
@@ -603,7 +607,7 @@ export const roomMetaEventsByIdByUrl = deriveEventsByIdByUrl({
|
||||
})
|
||||
|
||||
export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByUrl => {
|
||||
const metaByIdByUrl = new Map<string, Map<string, Room>>()
|
||||
const result = new Map<string, Room[]>()
|
||||
|
||||
for (const [url, events] of roomMetaEventsByIdByUrl.entries()) {
|
||||
const [metaEvents, deleteEvents] = partition(spec({kind: ROOM_META}), events.values())
|
||||
@@ -615,6 +619,8 @@ export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByU
|
||||
}
|
||||
}
|
||||
|
||||
const metaById = new Map<string, Room>()
|
||||
|
||||
for (const event of metaEvents) {
|
||||
const meta = tryCatch(() => readRoomMeta(event))
|
||||
|
||||
@@ -622,22 +628,14 @@ 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})
|
||||
}
|
||||
}
|
||||
|
||||
const result = new Map<string, Room[]>()
|
||||
|
||||
for (const [url, metaById] of metaByIdByUrl.entries()) {
|
||||
result.set(url, Array.from(metaById.values()))
|
||||
if (metaById.size > 0) {
|
||||
result.set(url, Array.from(metaById.values()))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -810,36 +808,78 @@ 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 => {
|
||||
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)
|
||||
},
|
||||
$events => getSpaceMembers(url, $events),
|
||||
)
|
||||
|
||||
export type BannedPubkeyItem = {
|
||||
@@ -866,33 +906,7 @@ export const deriveRoomMembers = (url: string, h: string) => {
|
||||
{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]},
|
||||
]
|
||||
|
||||
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)
|
||||
})
|
||||
return derived(deriveEventsForUrl(url, filters), $events => getRoomMembers(url, h, $events))
|
||||
}
|
||||
|
||||
export const deriveRoomAdmins = (url: string, h: string) => {
|
||||
@@ -916,7 +930,7 @@ export const deriveSpaceActionItems = (url: string) =>
|
||||
derived(
|
||||
deriveEventsForUrl(url, [
|
||||
{
|
||||
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS],
|
||||
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER],
|
||||
},
|
||||
]),
|
||||
$events => {
|
||||
@@ -929,19 +943,50 @@ export const deriveSpaceActionItems = (url: string) =>
|
||||
for (const [h, roomEvents] of groupBy(getRoomId, $events)) {
|
||||
if (!h) continue
|
||||
|
||||
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 ?? [])
|
||||
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))
|
||||
|
||||
pendingJoins.push(
|
||||
...removeUndefined(
|
||||
Array.from(groupBy(e => e.pubkey, roomJoins).values())
|
||||
.map(sortEventsDesc)
|
||||
.map(first),
|
||||
Array.from(groupBy(e => e.pubkey, roomJoins).values()).map(events =>
|
||||
first(sortEventsDesc(events)),
|
||||
),
|
||||
).filter(({pubkey, created_at}) => {
|
||||
if (roomMembers.includes(pubkey)) return false
|
||||
if (gt(roomMembersEvent?.created_at, created_at)) return false
|
||||
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 (roomLeaves.some(e => e.pubkey === pubkey && e.created_at > created_at)) return false
|
||||
|
||||
return true
|
||||
|
||||
+96
-73
@@ -1,7 +1,8 @@
|
||||
import {page} from "$app/stores"
|
||||
import type {Unsubscriber} from "svelte/store"
|
||||
import {derived, get} from "svelte/store"
|
||||
import {last, call, ifLet, assoc, chunk, sleep, identity, WEEK, ago} from "@welshman/lib"
|
||||
import {last, call, ifLet, assoc, chunk, WEEK, ago} from "@welshman/lib"
|
||||
import {PollResponse} from "nostr-tools/kinds"
|
||||
import {merged} from "@welshman/store"
|
||||
import {
|
||||
getListTags,
|
||||
getRelayTagValues,
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
ROOM_MEMBERS,
|
||||
ROOM_ADD_MEMBER,
|
||||
ROOM_REMOVE_MEMBER,
|
||||
ROOM_JOIN,
|
||||
ROOM_LEAVE,
|
||||
ROOM_CREATE_PERMISSION,
|
||||
RELAY_MEMBERS,
|
||||
RELAY_ADD_MEMBER,
|
||||
@@ -20,12 +23,11 @@ import {
|
||||
unionFilters,
|
||||
getTagValue,
|
||||
} from "@welshman/util"
|
||||
import type {Filter, TrustedEvent} from "@welshman/util"
|
||||
import type {Filter, List, PublishedList, TrustedEvent} from "@welshman/util"
|
||||
import {request, requestOne, Difference, DifferenceEvent} from "@welshman/net"
|
||||
import {
|
||||
pubkey,
|
||||
loadRelay,
|
||||
userFollowList,
|
||||
userRelayList,
|
||||
userMessagingRelayList,
|
||||
loadRelayList,
|
||||
@@ -48,7 +50,6 @@ import {
|
||||
loadGroupList,
|
||||
userSpaceUrls,
|
||||
userGroupList,
|
||||
bootstrapPubkeys,
|
||||
decodeRelay,
|
||||
getSpaceUrlsFromGroupList,
|
||||
getSpaceRoomsFromGroupList,
|
||||
@@ -56,7 +57,7 @@ import {
|
||||
loadFeedsForPubkey,
|
||||
} from "@app/core/state"
|
||||
import {hasBlossomSupport} from "@app/core/commands"
|
||||
import {LIVEKIT_PARTICIPANTS} from "@app/voice"
|
||||
import {LIVEKIT_PARTICIPANTS} from "@app/call/voice"
|
||||
|
||||
// Utils
|
||||
|
||||
@@ -73,6 +74,8 @@ 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
|
||||
|
||||
@@ -85,6 +88,12 @@ 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, () => {
|
||||
@@ -110,9 +119,7 @@ export const pullWithFallback = async ({url, signal, filters, onEvent}: SyncOpts
|
||||
|
||||
if (signal.aborted) return
|
||||
|
||||
for (const filter of filters) {
|
||||
pullOneWithFallback(url, filter, signal, onEvent)
|
||||
}
|
||||
await Promise.all(filters.map(filter => pullOneWithFallback(url, filter, signal, onEvent)))
|
||||
}
|
||||
|
||||
const listen = ({url, signal, filters, onEvent}: SyncOpts) => {
|
||||
@@ -122,6 +129,8 @@ const listen = ({url, signal, filters, onEvent}: SyncOpts) => {
|
||||
}
|
||||
|
||||
const pullAndListen = (options: SyncOpts) => {
|
||||
if (options.signal.aborted) return
|
||||
|
||||
pullWithFallback(options)
|
||||
listen(options)
|
||||
}
|
||||
@@ -196,7 +205,7 @@ const syncUserRoomMembership = (url: string, h: string) => {
|
||||
const syncUserData = () => {
|
||||
const unsubscribersByKey = new Map<string, Unsubscriber>()
|
||||
|
||||
const unsubscribeGroupList = userGroupList.subscribe($userGroupList => {
|
||||
const syncGroupList = ($userGroupList: List | undefined) => {
|
||||
if ($userGroupList) {
|
||||
const keys = new Set<string>()
|
||||
|
||||
@@ -225,43 +234,35 @@ const syncUserData = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const syncRelayList = ($userRelayList: PublishedList | undefined) => {
|
||||
const pubkey = $userRelayList?.event?.pubkey
|
||||
|
||||
if (!pubkey) return
|
||||
|
||||
loadBlossomServerList(pubkey)
|
||||
loadBlockedRelayList(pubkey)
|
||||
loadFollowList(pubkey)
|
||||
loadGroupList(pubkey)
|
||||
loadMuteList(pubkey)
|
||||
loadProfile(pubkey)
|
||||
loadSettings(pubkey)
|
||||
loadFeedsForPubkey(pubkey)
|
||||
}
|
||||
|
||||
const unsubscribeGroupList = merged([userGroupList]).subscribe(([$userGroupList]) => {
|
||||
syncGroupList($userGroupList)
|
||||
})
|
||||
|
||||
const unsubscribeRelayList = userRelayList.subscribe($userRelayList => {
|
||||
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),
|
||||
]),
|
||||
)
|
||||
}
|
||||
const unsubscribeRelayList = merged([userRelayList]).subscribe(([$userRelayList]) => {
|
||||
syncRelayList($userRelayList)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribersByKey.forEach(call)
|
||||
unsubscribeGroupList()
|
||||
unsubscribeRelayList()
|
||||
unsubscribeFollows()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,8 +280,14 @@ const syncSpace = (url: string, rooms: string[]) => {
|
||||
url,
|
||||
signal: controller.signal,
|
||||
filters: [
|
||||
{kinds: [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS], "#d": [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},
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -292,17 +299,15 @@ 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_ADD_MEMBER, ROOM_REMOVE_MEMBER]
|
||||
const roomMemberKinds = [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER]
|
||||
|
||||
pullAndListen({
|
||||
url,
|
||||
signal: controller.signal,
|
||||
filters: [
|
||||
{kinds: relayKinds},
|
||||
{kinds: roomMetaKinds},
|
||||
{kinds: roomMemberKinds},
|
||||
{kinds: MESSAGE_KINDS, since},
|
||||
{kinds: [...relayKinds, ...roomMetaKinds, ...roomMemberKinds, ...MESSAGE_KINDS]},
|
||||
makeCommentFilter(CONTENT_KINDS, {since}),
|
||||
{kinds: [PollResponse], since},
|
||||
],
|
||||
onEvent: event => {
|
||||
if (event.kind === ROOM_META) {
|
||||
@@ -314,22 +319,23 @@ const syncSpace = (url: string, rooms: string[]) => {
|
||||
listen({
|
||||
url,
|
||||
signal: controller.signal,
|
||||
filters: [{kinds: REACTION_KINDS}],
|
||||
filters: [{kinds: REACTION_KINDS}, {kinds: [PollResponse]}],
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}
|
||||
|
||||
const syncSpaces = () => {
|
||||
const store = derived([userGroupList, page], identity)
|
||||
const store = merged([userGroupList, page])
|
||||
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 ($page.params.relay) {
|
||||
urls.add(decodeRelay($page.params.relay))
|
||||
if (currentUrl) {
|
||||
urls.add(currentUrl)
|
||||
}
|
||||
|
||||
// Stop syncing removed spaces
|
||||
@@ -344,6 +350,11 @@ 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
|
||||
@@ -383,6 +394,7 @@ const syncDMs = () => {
|
||||
const unsubscribersByUrl = new Map<string, Unsubscriber>()
|
||||
|
||||
let currentPubkey: string | undefined
|
||||
let currentShouldUnwrap = false
|
||||
|
||||
const unsubscribeAll = () => {
|
||||
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
|
||||
@@ -391,6 +403,34 @@ const syncDMs = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const syncPubkey = ($pubkey: string | undefined, $shouldUnwrap: boolean) => {
|
||||
if ($pubkey !== currentPubkey) {
|
||||
unsubscribeAll()
|
||||
}
|
||||
|
||||
if ($pubkey && $shouldUnwrap) {
|
||||
loadRelayList($pubkey)
|
||||
.then(() => loadMessagingRelayList($pubkey))
|
||||
.then($l => {
|
||||
if ($l && currentPubkey === $pubkey && currentShouldUnwrap === $shouldUnwrap) {
|
||||
subscribeAll($pubkey, getRelayTagValues(getListTags($l)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
currentPubkey = $pubkey
|
||||
currentShouldUnwrap = $shouldUnwrap
|
||||
}
|
||||
|
||||
const syncList = ($userMessagingRelayList: List | undefined) => {
|
||||
const $pubkey = pubkey.get()
|
||||
const $shouldUnwrap = shouldUnwrap.get()
|
||||
|
||||
if ($pubkey && $shouldUnwrap) {
|
||||
subscribeAll($pubkey, getRelayTagValues(getListTags($userMessagingRelayList)))
|
||||
}
|
||||
}
|
||||
|
||||
const subscribeAll = (pubkey: string, urls: string[]) => {
|
||||
// Start syncing newly added relays
|
||||
for (const url of urls) {
|
||||
@@ -408,33 +448,16 @@ const syncDMs = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// When pubkey changes, re-sync
|
||||
const unsubscribePubkey = derived([pubkey, shouldUnwrap], identity).subscribe(
|
||||
([$pubkey, $shouldUnwrap]) => {
|
||||
if ($pubkey !== currentPubkey) {
|
||||
unsubscribeAll()
|
||||
}
|
||||
|
||||
// If we have a pubkey, refresh our user's relay list then sync our subscriptions
|
||||
if ($pubkey && $shouldUnwrap) {
|
||||
loadRelayList($pubkey)
|
||||
.then(() => loadMessagingRelayList($pubkey))
|
||||
.then($l => subscribeAll($pubkey, getRelayTagValues(getListTags($l))))
|
||||
}
|
||||
|
||||
currentPubkey = $pubkey
|
||||
},
|
||||
)
|
||||
const unsubscribePubkey = merged([pubkey, shouldUnwrap]).subscribe(([$pubkey, $shouldUnwrap]) => {
|
||||
syncPubkey($pubkey, $shouldUnwrap)
|
||||
})
|
||||
|
||||
// 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)))
|
||||
}
|
||||
})
|
||||
const unsubscribeList = merged([userMessagingRelayList]).subscribe(
|
||||
([$userMessagingRelayList]) => {
|
||||
syncList($userMessagingRelayList)
|
||||
},
|
||||
)
|
||||
|
||||
return () => {
|
||||
unsubscribeAll()
|
||||
|
||||
@@ -4,20 +4,25 @@
|
||||
|
||||
type Props = {
|
||||
editor: Promise<Editor>
|
||||
autofocus?: boolean
|
||||
}
|
||||
|
||||
const {editor}: Props = $props()
|
||||
const {editor, autofocus}: Props = $props()
|
||||
|
||||
let element: HTMLElement
|
||||
|
||||
onMount(() => {
|
||||
editor.then(({options}) => {
|
||||
if (options.element) {
|
||||
element?.append(options.element)
|
||||
editor.then(ed => {
|
||||
if (ed.options.element) {
|
||||
element?.append(ed.options.element)
|
||||
}
|
||||
|
||||
if (options.autofocus) {
|
||||
;(element?.querySelector("[contenteditable]") as HTMLElement)?.focus()
|
||||
if (autofocus) {
|
||||
const hasContent = ed.getText().trim().length > 0
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
ed.commands.focus(hasContent ? "end" : "start")
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,9 +25,9 @@ import {pushToast} from "@app/util/toast"
|
||||
export const makeEditor = async ({
|
||||
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
|
||||
content?: string | object
|
||||
onChange?: (json: object) => void
|
||||
placeholder?: string
|
||||
url?: string
|
||||
submit: () => void
|
||||
@@ -82,9 +82,8 @@ export const makeEditor = async ({
|
||||
},
|
||||
)
|
||||
|
||||
return new Editor({
|
||||
content: escapeHtml(content),
|
||||
autofocus,
|
||||
const ed = new Editor({
|
||||
content: typeof content === "string" ? escapeHtml(content) : content,
|
||||
editorProps,
|
||||
element: document.createElement("div"),
|
||||
extensions: [
|
||||
@@ -142,6 +141,9 @@ export const makeEditor = async ({
|
||||
onUpdate({editor}) {
|
||||
wordCount?.set(editor.storage.wordCount.words)
|
||||
charCount?.set(editor.storage.wordCount.chars)
|
||||
onChange?.(editor.getJSON())
|
||||
},
|
||||
})
|
||||
|
||||
return ed
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
const store = new Map<string, unknown>()
|
||||
|
||||
export class DraftKey<T> {
|
||||
constructor(private key: string) {}
|
||||
|
||||
get(): T | undefined {
|
||||
return store.get(this.key) as T | undefined
|
||||
}
|
||||
|
||||
set(value: T): void {
|
||||
store.set(this.key, value)
|
||||
}
|
||||
|
||||
update(value: Partial<T>): void {
|
||||
this.set({...this.get(), ...value} as T)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
store.delete(this.key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {now, removeUndefined, uniq} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getTagValue, getTags, getTagValues} from "@welshman/util"
|
||||
|
||||
export type PollType = "singlechoice" | "multiplechoice"
|
||||
|
||||
export type PollOption = {
|
||||
id: string
|
||||
label: string
|
||||
votes: number
|
||||
}
|
||||
|
||||
export const getPollType = (event: TrustedEvent): PollType =>
|
||||
getTagValue("polltype", event.tags) === "multiplechoice" ? "multiplechoice" : "singlechoice"
|
||||
|
||||
export const getPollOptions = (event: TrustedEvent) =>
|
||||
removeUndefined(
|
||||
getTags("option", event.tags).map(tag => {
|
||||
const [, id, label = id] = tag
|
||||
|
||||
if (!id) return undefined
|
||||
|
||||
return {id, label}
|
||||
}),
|
||||
)
|
||||
|
||||
export const getPollEndsAt = (event: TrustedEvent) => {
|
||||
const endsAt = getTagValue("endsAt", event.tags)
|
||||
|
||||
if (!endsAt) return undefined
|
||||
|
||||
const timestamp = parseInt(endsAt)
|
||||
|
||||
return Number.isNaN(timestamp) ? undefined : timestamp
|
||||
}
|
||||
|
||||
export const isPollClosed = (event: TrustedEvent) => {
|
||||
const endsAt = getPollEndsAt(event)
|
||||
|
||||
return typeof endsAt === "number" ? endsAt <= now() : false
|
||||
}
|
||||
|
||||
export const getPollResponseSelections = (event: TrustedEvent, pollType = getPollType(event)) => {
|
||||
const selections = getTagValues("response", event.tags)
|
||||
|
||||
return pollType === "singlechoice" ? selections.slice(0, 1) : uniq(selections)
|
||||
}
|
||||
|
||||
export const getPollResults = (event: TrustedEvent, responses: TrustedEvent[]) => {
|
||||
const options = getPollOptions(event).map(option => ({...option, votes: 0}))
|
||||
const counts = new Map(options.map(option => [option.id, option]))
|
||||
const latestByPubkey = new Map<string, TrustedEvent>()
|
||||
|
||||
for (const response of responses) {
|
||||
const current = latestByPubkey.get(response.pubkey)
|
||||
|
||||
if (!current || response.created_at > current.created_at) {
|
||||
latestByPubkey.set(response.pubkey, response)
|
||||
}
|
||||
}
|
||||
|
||||
for (const response of latestByPubkey.values()) {
|
||||
for (const optionId of getPollResponseSelections(response, getPollType(event))) {
|
||||
const option = counts.get(optionId)
|
||||
|
||||
if (option) {
|
||||
option.votes += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
options,
|
||||
voters: latestByPubkey.size,
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {page} from "$app/stores"
|
||||
import {nthEq} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getAddress} from "@welshman/util"
|
||||
import {Poll} from "nostr-tools/kinds"
|
||||
import {tracker, userMessagingRelayList} from "@welshman/app"
|
||||
import {identity} from "@welshman/lib"
|
||||
import {
|
||||
@@ -90,6 +91,8 @@ export const makeClassifiedPath = (url: string, address?: string) =>
|
||||
export const makeCalendarPath = (url: string, address?: string) =>
|
||||
makeSpacePath(url, "calendar", address)
|
||||
|
||||
export const makePollPath = (url: string, id?: string) => makeSpacePath(url, "polls", id)
|
||||
|
||||
export const scrollToEvent = (id: string) => {
|
||||
const element = document.querySelector(`[data-event="${id}"]`) as any
|
||||
|
||||
@@ -146,6 +149,10 @@ export const getEventPath = (event: TrustedEvent, urls: string[]) => {
|
||||
return makeCalendarPath(url, getAddress(event))
|
||||
}
|
||||
|
||||
if (event.kind === Poll) {
|
||||
return makePollPath(url, event.id)
|
||||
}
|
||||
|
||||
if (event.kind === MESSAGE) {
|
||||
return makeMessagePath(url, event)
|
||||
}
|
||||
@@ -192,5 +199,7 @@ export const getRoomItemPath = (url: string, event: TrustedEvent) => {
|
||||
return makeGoalPath(url, event.id)
|
||||
case EVENT_TIME:
|
||||
return makeCalendarPath(url, getAddress(event))
|
||||
case Poll:
|
||||
return makePollPath(url, event.id)
|
||||
}
|
||||
}
|
||||
|
||||
+17
-4
@@ -48,6 +48,18 @@ 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],
|
||||
@@ -199,14 +211,15 @@ const loadCriticalRelays = async () => {
|
||||
relaysByUrl.set(indexBy(r => r.url, await table.getAll()))
|
||||
}
|
||||
|
||||
const syncRelays = () => onRelay(batch(1000, db.table<RelayProfile>("relays").bulkPut))
|
||||
const syncRelays = () =>
|
||||
onRelay(batch(FLUSH_INTERVAL, idleWrite(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(1000, table.bulkPut))
|
||||
return onRelayStats(batch(FLUSH_INTERVAL, idleWrite(table.bulkPut)))
|
||||
}
|
||||
|
||||
const initHandles = async () => {
|
||||
@@ -214,7 +227,7 @@ const initHandles = async () => {
|
||||
|
||||
handlesByNip05.set(indexBy(r => r.nip05, await table.getAll()))
|
||||
|
||||
return onHandle(batch(1000, table.bulkPut))
|
||||
return onHandle(batch(FLUSH_INTERVAL, idleWrite(table.bulkPut)))
|
||||
}
|
||||
|
||||
const initZappers = async () => {
|
||||
@@ -222,7 +235,7 @@ const initZappers = async () => {
|
||||
|
||||
zappersByLnurl.set(indexBy(z => z.lnurl, await table.getAll()))
|
||||
|
||||
return onZapper(batch(3000, table.bulkPut))
|
||||
return onZapper(batch(FLUSH_INTERVAL, idleWrite(table.bulkPut)))
|
||||
}
|
||||
|
||||
const initPlaintext = async () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ const staticTitles = new Map<string, string>([
|
||||
["/spaces/[relay]/classifieds", "Classifieds"],
|
||||
["/spaces/[relay]/calendar", "Calendar"],
|
||||
["/spaces/[relay]/goals", "Goals"],
|
||||
["/spaces/[relay]/polls", "Polls"],
|
||||
["/chat", "Messages"],
|
||||
["/join", "Join Space"],
|
||||
["/people", "Find People"],
|
||||
@@ -35,6 +36,7 @@ const eventRoutes = new Set([
|
||||
"/spaces/[relay]/goals/[id]",
|
||||
"/spaces/[relay]/calendar/[address]",
|
||||
"/spaces/[relay]/classifieds/[address]",
|
||||
"/spaces/[relay]/polls/[id]",
|
||||
])
|
||||
|
||||
type RouteParams = Record<string, string | undefined>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -14,6 +14,7 @@
|
||||
style?: string
|
||||
disabled?: boolean
|
||||
"data-tip"?: string
|
||||
"aria-pressed"?: boolean
|
||||
} = $props()
|
||||
|
||||
const className = $derived(`text-left ${restProps.class}`)
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
</script>
|
||||
|
||||
<div class="btn flex h-[unset] w-full flex-nowrap py-4 text-left {props.class}">
|
||||
<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">
|
||||
<div class="flex grow flex-row items-start gap-4">
|
||||
<div class="flex h-14 w-12 shrink-0 items-center justify-center">
|
||||
{@render props.icon?.()}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user