Compare commits

..

30 commits

Author SHA1 Message Date
Agent
fee6f565ff Email notifications: settings UI + mail adapter
- Add VITE_EMAIL_NOTIFICATION_SERVER env var
- Extend notificationSettings with email, emailFrequency, emailAddress
- Add emailNotificationState store in push/adapters/common.ts
- New src/app/mail/index.ts with MailNotifications class:
  register/unregister with mailship via NIP-98 auth,
  publish kind 30390 NIP-9a subscription events
- Wired into alerts settings save handler
- Email Digest Settings card in alerts UI
2026-08-20 11:15:35 -04:00
Jon Staab
922a0996e5 Tweak how the library page renders 2026-08-14 16:06:17 -07:00
Matt Lorentz
3d2ec8c030 Trying new video tile layout algorithm (#355)
Co-authored-by: Matt Lorentz <5+mplorentz@noreply.coracle.social>
2026-08-14 17:43:19 +00:00
Aditya Chaudhary
9d8be25bae feat: add minimal pin renderer (#368)
Reviewed-on: https://gitea.coracle.social/coracle/flotilla/pulls/368
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-14 17:36:33 +00:00
Jon Staab
31471b50b5 Fix deriving stuff in social.ts 2026-08-14 10:12:18 -07:00
Jon Staab
8e2284a2fe Fix comment display in RoomItem 2026-08-14 08:51:01 -07:00
Jon Staab
50554ec479 Small route utils refactor 2026-08-14 08:51:01 -07:00
Aditya Chaudhary
d23a6f6b75 Fix leave button requiring two clicks when screen sharing (#367)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-14 15:46:25 +00:00
Aditya Chaudhary
f5159a3b5e Show notification badge on Threads nav item (#360)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-14 15:37:27 +00:00
Jon Staab
0a85983633 Bump welshman 2026-08-13 15:13:05 -07:00
Jon Staab
cf9b6f7193 Fix network load/request calls 2026-08-13 15:06:13 -07:00
Jon Staab
29afa08053 Update link_deps for pnpm 11 2026-08-13 14:35:51 -07:00
Jon Staab
db68aa4a68 Add log sending 2026-08-13 14:35:51 -07:00
Jon Staab
2d89881aac Add links to website 2026-08-13 14:35:51 -07:00
Aditya Chaudhary
84fee9de18 Fix room icons not rendering on iOS (#361)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-13 18:09:01 +00:00
Aditya Chaudhary
f411973409 feat: add rendering for library shelves (#359)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-13 16:53:38 +00:00
Aditya Chaudhary
fc768b6393 Replace sidebar voice widget with in-room floating call controls (#348)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-13 16:01:08 +00:00
Jon Staab
d61c0d00fb Handle blocked upgrades 2026-08-12 17:37:57 -07:00
Jon Staab
620f7593e7 Add theme selector 2026-08-12 16:37:51 -07:00
Jon Staab
9cd717d5b3 Add native sharing handler 2026-08-12 16:04:57 -07:00
Aditya Chaudhary
a572918e6c Persistent call banner (#341)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-11 17:04:47 +00:00
Aditya Chaudhary
c8d003524b Fix/spurious relay membership prompt (#340)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-11 16:45:49 +00:00
Jon Staab
8243f4d245 Defer rendering a chat room for a frame 2026-08-10 15:14:32 -07:00
Aditya Chaudhary
72ee0c9ed6 Reacquire mic after screen lock kills capture mid-call (#339)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-10 18:32:11 +00:00
Aditya Chaudhary
7dcc713ec2 Refine calls: accessible controls, mic level check before joining (#331)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-10 18:14:46 +00:00
Aditya Chaudhary
65c9eac5e5 Show new-messages badge in tab title when re-foregrounding (#332)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-10 16:16:30 +00:00
Jon Staab
b2f58b11c0 Fix touch target for space menu 2026-08-07 14:10:43 -07:00
Aditya Chaudhary
749989f56b Fix DM compose bar being hidden/cut off on mobile (#335)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-07 21:10:32 +00:00
Aditya Chaudhary
9c68f66c39 fix: replace mobile space menu popover with a bottom-sheet action menu (#330)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-07 21:05:17 +00:00
Jon Staab
85a4ba127a Add e2e tests 2026-08-07 13:11:12 -07:00
120 changed files with 5641 additions and 1678 deletions

2
.env
View file

@ -3,11 +3,13 @@ VITE_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/
VITE_DEFAULT_SPACES=https://support.flotilla.social/
VITE_POMADE_SIGNERS=https://pomade.coracle.social,https://pomade.fiatjaf.com,https://pomade.nostrver.se,https://pomade.scuttle.works
VITE_PLATFORM_URL=https://app.flotilla.social
VITE_PLATFORM_ABOUT=https://flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
VITE_PLATFORM_NAME=Flotilla
VITE_PLATFORM_LOGO=static/logo.png
VITE_PLATFORM_RELAYS=
VITE_PLATFORM_LOGEE=be523f2d2255fa96b281233ba9df60d63ef74f874bea96651edd4e1f79785703
VITE_PLATFORM_ACCENT="#7161FF"
VITE_THEME="clay"
VITE_PLATFORM_DESCRIPTION="The chat app built for self-hosted communities."

View file

@ -1,7 +1,7 @@
pnpm run lint
pnpm run check
if [[ ! -z $(cat package.json | grep 'link:') ]]; then
if [[ ! -z $(grep 'link:' package.json pnpm-workspace.yaml) ]]; then
echo "Some packages are linked to local files!"
exit 1
fi

View file

@ -16,6 +16,7 @@ You can also optionally create an `.env.local` file and populate it with the fol
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
- `VITE_PLATFORM_TERMS` - URL to your terms of service page
- `VITE_PLATFORM_PRIVACY` - URL to your privacy policy page
- `VITE_PLATFORM_LOGEE` - A hex pubkey which will receive logs users send from their privacy settings
**Platform mode**
- `VITE_PLATFORM_RELAYS` - A comma-separated list of relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.

View file

@ -27,6 +27,17 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="app.flotilla.social" />
</intent-filter>
<!-- Puts Flotilla in the system share sheet. These intents carry their payload in
extras rather than a data uri, so Capacitor never emits appUrlOpen for them;
ShareIntentPlugin picks them up instead. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
</intent-filter>
</activity>
<provider

View file

@ -8,11 +8,13 @@ import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
import social.flotilla.notifications.AndroidPushFallbackPlugin;
import social.flotilla.share.ShareIntentPlugin;
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(AndroidPushFallbackPlugin.class);
registerPlugin(ShareIntentPlugin.class);
createPushNotificationChannel();
super.onCreate(savedInstanceState);
}

View file

@ -0,0 +1,53 @@
package social.flotilla.share
import android.content.Intent
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.core.content.IntentCompat
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.annotation.CapacitorPlugin
import java.io.File
@CapacitorPlugin(name = "ShareIntent")
class ShareIntentPlugin : Plugin() {
// Capacitor runs the launch intent through here as well as intents delivered while the app is
// already running, so this is the only hook we need. Retaining the event covers a cold start,
// where the web view isn't listening yet.
override fun handleOnNewIntent(intent: Intent) {
if (intent.action == Intent.ACTION_SEND) {
val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
// Android hands the same intent back when the activity is recreated, so drop the payload
// once we've taken it to avoid re-opening the share dialog.
if (uri != null) {
intent.removeExtra(Intent.EXTRA_STREAM)
// Copying a shared video can take a while, and this runs on the main thread
bridge.execute { notifyListeners("shareReceived", copyToCache(uri), true) }
} else if (text != null) {
intent.removeExtra(Intent.EXTRA_TEXT)
notifyListeners("shareReceived", JSObject().put("text", text), true)
}
}
}
// A content uri is readable by us but meaningless to the web view, so hand over a copy that
// capacitor's file server can serve.
private fun copyToCache(uri: Uri): JSObject {
val resolver = context.contentResolver
val type = resolver.getType(uri)
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(type)
val file = File.createTempFile("shared", extension?.let { ".$it" }, context.cacheDir)
resolver.openInputStream(uri)?.use { input ->
file.outputStream().use { output -> input.copyTo(output) }
}
return JSObject()
.put("path", file.absolutePath)
.put("name", file.name)
.put("type", type)
}
}

303
e2e/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,303 @@
# E2E architecture
Flotilla's end-to-end suite runs the real app against a relay network that is entirely under the
test's control. Nothing in this directory may ever open a connection to a host the test did not
create. That is the single invariant everything else is arranged around.
## The relay
Every spec runs against a real [zooid](https://github.com/coracle-social/zooid) relay in Docker, the
implementation Flotilla is built for, so protocol drift between the client's assumptions and a real
relay shows up as a failing test.
zooid is multi-tenant: it binds a config to a `Host` header and serves any number of virtual relays
from one process. `harness/zooid/config.ts` names them, one toml apiece in `harness/zooid/docker/`,
and a scenario picks one by name. A second space costs a config file and no extra process, which is
what keeps outbox routing and cross-space isolation testable.
A relay's policy is its toml and nothing else. A scenario says what is _on_ a relay — its rooms, its
members, its messages — never what the relay _is_, so there is no policy negotiation anywhere in the
harness.
### Why the relays are called `<name>.test`
The container listens on plaintext loopback, but a url that is local or insecure is dropped from
every relay selection unless the caller opts in — see `isLocalUrl` and `RelaySelection.getUrls` in
`@welshman/util` — and Flotilla never opts in, since in production neither belongs in a routing
decision. Handed `ws://localhost:3334/`, the client would load a space by its explicit url and
resolve nothing else: no outbox loads for profiles or relay lists, no relay hints.
So the client is given `wss://space.test/` and never learns the container has an address. Everything
that speaks to it goes through `zooid/transport.ts`, which dials `127.0.0.1:3334` carrying the two
headers a TLS terminator adds in front of a real deployment:
- `Host: space.test` — zooid's dispatcher binds a config to a Host (`cmd/relay/main.go`), so this is
what selects which toml answers.
- `X-Forwarded-Proto: https` — khatru derives the url it checks NIP-42 and NIP-86 signatures against
from Host plus this (`getBaseURL` in `khatru/relay.go`), arriving at `wss://space.test/`, which is
exactly what the client signed into its `relay` tag. Seeding signs the same url, so a fixture is
written over the same relay the app talks to.
`.test` is reserved by RFC 2606 and resolves nowhere, so a url that ever escapes this process fails
to connect rather than reaching a host.
## Transport: one interception point
All relay traffic is intercepted in the **Node** process via Playwright's `routeWebSocket`, applied
to the `BrowserContext` so every page in it is covered:
```
browser context ──▶ context.routeWebSocket(everything but vite's hmr socket)
│
▼
zooid.relays.get(url)
│
┌─────────────────┴─────────────────┐
│ │
a socket this process opens unknown url: a relay that
to the container, as that holds no events, and the
relay's virtual host url is recorded as a leak
```
Every socket the browser opens is terminated here, in this process, and the only one that leaves it
is the loopback connection `zooid/transport.ts` makes to the container the test started.
`assertNoLeaks()` fails a test that touched a url the scenario never declared.
Interception is installed by `as()`, on a context it creates, so a page that came from anywhere else
has none of it. Playwright's own `context` fixture — and the `page` fixture built on it — is
therefore overridden to throw, so a spec written the ordinary way fails immediately with a message
saying so rather than quietly dialling the relays in `.env`. The built-in `request` fixture goes the
same way: an `APIRequestContext` is an http client in the node process that belongs to no browser
context, so the block-all below cannot see it and nothing records what it sent.
`playwright` is the one fixture left alone, and it has to be: it is where the run's own browser comes
from, so every test would fail if it threw. A spec that goes around `as()` through
`playwright.request` or `playwright.chromium.launch()` reaches the network unwatched, and no fixture
can refuse that without refusing the suite.
Interception in Node rather than in the page is what makes multi-user testing work. Three browser
contexts logged in as three different users all dispatch into the _same_ relay, so one user
genuinely observes another user's writes, over the wire, through the client's real socket stack.
### Why not an `AdapterFactory`
The obvious alternative — `new App({getAdapter})` returning a `Repository`-backed adapter — cannot
test authentication. NIP-42 lives on `Socket`: `AuthState` listens to `SocketEvent.Receiving`/
`Sending`, and `socketPolicyAuthBuffer` replays messages that were rejected with `auth-required:`.
An `AbstractAdapter` whose `sockets` getter returns `[]` never constructs any of that. Patching the
transport instead leaves `Pool → Socket → SocketAdapter` untouched, so auth, message buffering,
replay-after-auth and reconnect are all exercised as written.
## HTTP
Relays are not the only egress. `installHttpRoutes` routes every url that is not the dev server — a
predicate rather than a `"**/*"` pattern, so the hundreds of module requests a sveltekit page makes
in dev are never even matched — and aborts what it catches. Two origins get past it: the dev server
on `localhost:1847`, which is left unrouted, and each relay's own origin, which is forwarded to the
container by the same transport carrying the same two headers, so the NIP-11 document and the NIP-86
management API the app reads are the real relay's answers, signed against the url the client used.
Every other request is aborted and recorded.
A relay's NIP-11 document decides whether a space is synced by reconciliation or a plain REQ
(NIP-77), whether a message the UI composes carries a protected `-` tag (NIP-70), what the space is
called, and which pubkey room state is trusted from. Its NIP-86 answers decide whether the user is an
admin, since a relay refuses management calls from anyone else and the method list that comes back
doubles as the client's permission set — the space, room, event and pin menus, the directory and the
library are all gated on it.
Services the app talks to are then mocked back in per-scenario, so a blocked request is always a bug
rather than ambient noise: Dufflepud (`dufflepud.coracle.social`), Blossom uploads, the push server
(`nps.flotilla.social`), the hosting API, LiveKit token endpoints, and image/thumbnail fetches. The
analytics script hard-coded in `src/app.html` is mocked with an empty body: it is requested on every
navigation whatever the scenario is doing, so leaving it to the block-all would make
`assertNoBlockedRequests()` a statement about the page shell rather than about the test.
## Containment
A browser has a fixed set of ways to put bytes on a wire.
| how the app can reach the network | what stops it |
| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `WebSocket` — `@welshman/net`'s `Socket`, the only constructor call | `context.routeWebSocket(url => !isDevServerUrl(url))`. Terminated in node; an undeclared url gets a relay holding nothing and `assertNoLeaks()` fails the test |
| `fetch` / `XMLHttpRequest` — app code, welshman, `@pomade/core` | `context.route(url => !isDevServerUrl(url))`, which aborts unless a mock registered later answers first |
| `img`, `script`, `link`, fonts, media, and every other subresource | the same route. Images never reach it — `mockImages` answers anything with `resourceType() === "image"` with a 1×1 png |
| navigation, including the external links the ui offers | the same route: a document request is routed like any other, and a popup opens in the context that owns it |
| `EventSource` | the same route. Neither `src` nor welshman constructs one |
| `navigator.sendBeacon` | no call site. The one script that would use it is the plausible tag in `src/app.html`, served with an empty body |
| a service worker | `serviceWorkers: "block"` on the context. Playwright does not route a worker's requests, so the worker is refused instead |
| a web worker | no call site in `src`; `@pomade/core`'s is argon2, which is cpu and no socket |
| Capacitor's native http and push plugins | not reachable from a browser, and the zooid config leaves `[push]` disabled so nothing is asked to register |
Two connections leave this process, both to something the test started: the browser's to the vite dev
server on `localhost:1847`, and `zooid/transport.ts`'s to `127.0.0.1:3334`. Everything else the app
initiates dies in node.
The two ends are enforced differently. A websocket to a url no scenario declared is answered rather
than refused — by a relay that EOSEs every REQ and accepts every event into the void — and the url is
recorded, so the test fails on `assertNoLeaks()` naming it rather than on a timeout somewhere
downstream. An http request nothing mocked is aborted outright, but noticing it is opt-in:
`assertNoBlockedRequests()` is for a spec that has mocked what it exercises, because some of what a
page asks for is meant to be refused.
Configuration is the other half. `boot()` overrides every `VITE_` value that names a relay — default,
indexer, search, messaging, signer, platform, blocked, the default space list — with the scenario's
own urls, through the hook `src/app/env.ts` reads them with, and points `VITE_PUSH_BRIDGE` at `ws://localhost:1/`, which nothing serves, so a push
bridge connection is reported as a leak rather than blending into a relay's traffic. The `VITE_`
values it does not override still name real hosts — the blossom server, the pomade signers, the
thumbnail service, the push server, the hosting api — and none of them is contacted at boot. Blossom
is read only when an upload starts, the thumbnail url only on android, pomade only when a signup uses
it, and the hosting api and dufflepud are mocked. They are contained by the block-all rather than by
configuration, which is the weaker of the two guarantees.
Grepping the repo for hostnames accounts for all of them, and there are only three kinds. Most are an
`href` in help text — nostr.com, nostrapps.com, nsec.app, nosta.me, nostr.how, github.com,
fountain.fm, cal.com, figma.com, coracle.tools, gitea.coracle.social, nwc.getalby.com, and the
`coracle.social` entity links `src/app/env.ts` builds — reachable only by clicking, and routed if
clicked. Two are fetched: dufflepud, whose origin is the one service url hard-coded rather than read
from `VITE_`, and the plausible tag in `src/app.html`. Both are mocked. The rest are in comments.
Under `e2e` the only hostnames are the four service origins `net/http.ts` matches on, the virtual
relays' own `.test` names, and the loopback address `zooid/transport.ts` dials.
Three things this does not cover:
- **WebRTC.** `livekit-client` opens an `RTCPeerConnection`, and Playwright cannot see one. A voice
or video room reaches its sfu over ice and dtls with nothing in between. `mockLivekit` decides
where the client is pointed, which is why its `serverUrl` has to be something the test owns, and
why `[livekit]` is omitted from the zooid config entirely. A spec that joins a call escapes this
document's guarantee and needs its own answer.
- **Playwright's own fixtures.** `context`, `page` and `request` are overridden to throw, but
`playwright` cannot be — it is where the browser comes from. A spec that reaches the network
through `playwright.request` or `playwright.chromium.launch()` is unwatched.
- **The browser itself.** A browser's own traffic is not the app's and is not routed. Playwright
launches chromium with `--disable-background-networking`, `--disable-component-update` and
`--disable-breakpad`, which is the whole of the mitigation, and which is chromium's alone —
`E2E_BROWSER=firefox` or `webkit` runs the suite under an engine those flags say nothing about.
## Determinism without freezing the clock
Timestamps are never patched. Instead every fixture is generated fresh at the start of each test and
signed with timestamps relative to the moment the test began:
```ts
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.message(user.alice, "general", "morning all", at(2, HOUR))
space.message(user.bob, "general", "morning!", at(90, MINUTE))
})
```
`at(2, HOUR)` is `now() - int(2, HOUR)` evaluated once per test, so "2 hours ago" renders the same
way on every run without the app's `now()` being touched. Fixtures are published over a real socket,
authenticated as the identity that signed them, so the relay stores exactly what it would have stored
for a real client: there are no pre-signed JSON blobs to drift out of date, and a fixture the relay
would have refused fails the test instead of appearing in a query.
## Users and sessions
Test identities are deterministic secp256k1 keypairs derived from fixed secrets (`alice`, `bob`,
`carol`, `admin`), so a pubkey is stable across runs and can be asserted on directly.
A logged-in user is created by injecting a NIP-01 session before the app boots. `src/app/session.ts`
carries a DEV-only hook: `restoreSession()` prefers `window.__TEST_SESSION__` when it is present, and
`window.__TEST_EVENTS__` becomes the repository contents a returning user's client would have found
on disk. Both branches are stripped from production builds, and injecting through the hook avoids
writing Capacitor's `SecureStorage`/`Preferences` localStorage encoding by hand.
The injected events go in _after_ `storage.ready`. Storage loads what the last session left behind
with `Repository.load`, which clears the repository before inserting, so anything put there while
IndexedDB is still opening is wiped a few milliseconds later — before the layout has rendered
anything, and long before `authPolicy` reads the room list to decide whether to answer a relay's AUTH
challenge.
The cache exists for the harness rather than for the app. In production the client bootstraps itself:
asking a keyed collection for a pubkey loads it, so `authPolicy` reading the user's room and relay
lists to decide whether to answer a challenge is itself what fetches them, as is any outbox-routed
load of the user's own data. Kind-10002 comes back from the public indexers, and `syncRelayList`
cascades into the room list from there.
Here the indexers _are_ the scenario's relays, because `boot()` points every relay list at them, and
those are members-only — so that first load is refused with `auth-required:`, while `authPolicy`,
conservative by default, will not sign for a url no list has named yet. Each waits on the other. The
injected room list breaks the circle, and it is what a user who joined a space through the UI would
have on disk anyway.
The price is that no spec exercises the bootstrap: every one of them starts already knowing which
relays it belongs to, so a regression in the chain from relay list to room list, or in `authPolicy`'s
conservative gate, leaves the suite green. Covering it needs a scenario with a public relay standing
in for an indexer.
Each user is a separate `BrowserContext`, which also gives each one its own IndexedDB and
localStorage, so nothing bleeds between users.
## Layout
```
e2e/
ARCHITECTURE.md this document
harness/
index.ts everything a spec imports: `test`, `expect`, helpers
keys.ts deterministic keypairs
zooid/
config.ts the virtual relays and their hosts — the one place they are named
relay.ts docker lifecycle, reset, seeding over its own authenticated socket
transport.ts the only thing that knows the container's address: ws and http to it
testRelay.ts the seeding affordances a scenario builds on
types.ts TestRelay, RoomOptions, RelayConnection
docker/
compose.yaml no data volume; tmpfs for /app/data and /app/media
config/ one toml per virtual relay: its host, and its whole policy
net/
websocket.ts routeWebSocket install, dispatch, transcript, leak detection
http.ts block-all + per-service mocks
app/
boot.ts env overrides, navigate, wait for mount and for the app to have read
the overrides
session.ts NIP-01 session injection
seed/
scenario.ts the `seed()` builder and relative-time helpers
space.ts one space's fixtures: rooms, members, messages, replies, profiles
specs/
*.spec.ts
```
One piece lives outside this directory: `src/lib/test/session.ts` holds the two window keys and the
getters `src/app/session.ts` reads them through. It is the only file the app ships on the harness's
behalf, and `harness/app/session.ts` duplicates the key names rather than importing them, since
importing anything under `src` would pull sveltekit into the node process.
## Resetting between tests
`Zooid.reset()` recreates the container rather than restarting it: with no volume mounted for
`/app/data`, storage lives in the container's writable layer and a tmpfs, so a fresh container is a
fresh database. The configs are bind-mounted read-only and survive, so every test starts against the
same relays with nothing in them. The container is a worker fixture, so the docker start-up cost is
paid once per worker rather than once per test.
Seeding then writes over a real authenticated socket, one per identity per relay, held open for the
rest of the test. A fixture must therefore be signed by one of the identities in `harness/keys.ts`,
since zooid authenticates every write.
## Running
The suite is not run by agents (see CLAUDE.md).
```sh
pnpm exec playwright install # once
docker pull gitea.coracle.social/coracle/zooid:latest # once; the harness never pulls
pnpm test # starts and stops the container itself
```
Every test skips when docker is unavailable, rather than failing.
One engine per run rather than a project per engine: these specs exercise sockets, auth and sync, so
a third copy of each buys much less than it costs. `E2E_BROWSER=webkit pnpm test` runs the whole
suite under another one. The container listens on a fixed port and cannot be sharded, which is why
`workers` is 1.
`src/app/env.ts` resolves every `VITE_` value through a DEV-only hook that prefers
`window.__TEST_ENV__`, which `boot()` injects per browser context, so any dev server will do and a
server already listening on `:1847` is reused. `boot()` still checks that the app read the injected
values, and fails naming the hook rather than letting a run drift onto the relays in `.env`.

96
e2e/harness/app/boot.ts Normal file
View file

@ -0,0 +1,96 @@
import type {BrowserContext} from "@playwright/test"
import {MINUTE, int, ms} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import type {TestUser} from "../keys"
import {injectEvents, injectSession} from "./session"
// Must match TEST_ENV_KEY in src/lib/test/env.ts.
const TEST_ENV_KEY = "__TEST_ENV__"
// Set the first time the app reads a value out of the injected env, which is this side's only
// evidence that the hook in src/app/env.ts ran at all.
const TEST_ENV_READ_KEY = "__TEST_ENV_READ__"
export type BootOptions = {
// Every relay list the app reads at startup is pointed here, so the urls it dials on its own
// initiative can only ever be relays the scenario created.
relays: string[]
spaces?: string[]
user?: TestUser
// What this user's client already has in local storage, e.g. their room list.
events?: TrustedEvent[]
path?: string
}
export const boot = async (
context: BrowserContext,
{relays, spaces = [], user, events = [], path = "/"}: BootOptions,
) => {
const urls = relays.join(",")
await context.addInitScript(
([key, readKey, env]) => {
Object.assign(window, {
[key]: new Proxy(env, {
get(target, prop) {
Object.assign(window, {[readKey]: true})
return Reflect.get(target, prop)
},
}),
})
},
[
TEST_ENV_KEY,
TEST_ENV_READ_KEY,
{
VITE_DEFAULT_RELAYS: urls,
VITE_INDEXER_RELAYS: urls,
VITE_DEFAULT_SEARCH_RELAYS: urls,
VITE_DEFAULT_MESSAGING_RELAYS: urls,
VITE_SIGNER_RELAYS: urls,
VITE_DEFAULT_SPACES: spaces.join(","),
VITE_PLATFORM_RELAYS: "",
VITE_BLOCKED_RELAYS: "",
// Nothing serves this url, so a push bridge connection is reported as a leak instead of
// blending into the traffic of a relay the scenario did create.
VITE_PUSH_BRIDGE: "ws://localhost:1/",
},
] as const,
)
if (user) {
await injectSession(context, user)
await injectEvents(context, events)
}
const page = await context.newPage()
await page.goto(path)
// The root layout renders nothing until its async setup block resolves, so the shell appearing
// is the first point at which the app is really running. With a session injected, wait for the
// signed-in nav instead — one the app rejected renders the landing dialog, and failing on that
// here is much easier to read than the assertions it would break later.
await page
.locator(user ? ".primary-nav" : ".fl")
.waitFor({state: "attached", timeout: ms(int(1, MINUTE))})
// src/app/env.ts reads every VITE_ value as it is imported, so by now the app has either resolved
// them against the env above or against .env's real relays — which would otherwise surface as
// every assertion in the suite timing out.
const usedTestEnv = await page.evaluate(
key => Boolean(Reflect.get(window, key)),
TEST_ENV_READ_KEY,
)
if (!usedTestEnv) {
throw new Error(
"The app read none of its VITE_ values from the harness, so it is pointed at the relays in " +
".env rather than at this scenario's. Check that src/app/env.ts still resolves them " +
"through maybeGetTestEnv, and that the dev server is running in dev mode.",
)
}
return page
}

View file

@ -0,0 +1,30 @@
import type {BrowserContext} from "@playwright/test"
import type {TrustedEvent} from "@welshman/util"
import type {TestUser} from "../keys"
// Must match TEST_SESSION_KEY and TEST_EVENTS_KEY in src/lib/test/session.ts.
const TEST_SESSION_KEY = "__TEST_SESSION__"
const TEST_EVENTS_KEY = "__TEST_EVENTS__"
// A nip01 session in the {method, data} shape @welshman/app's session handlers deserialize, so
// restoreSession can build a signer from it without any of the storage encoding a real login
// would have gone through. addInitScript runs before any page script, so this must be called
// before navigating, and it is installed on the context so every page in it boots as this user.
export const injectSession = (context: BrowserContext, user: TestUser) =>
context.addInitScript(
([key, session]) => {
Object.assign(window, {[key]: session})
},
[TEST_SESSION_KEY, {method: "nip01", data: {secret: user.secret}}] as const,
)
// The repository contents the app loads once the injected session is restored — the local cache
// a user who had used the app before would boot with.
export const injectEvents = (context: BrowserContext, events: TrustedEvent[]) =>
context.addInitScript(
([key, value]) => {
Object.assign(window, {[key]: value})
},
[TEST_EVENTS_KEY, events] as const,
)

165
e2e/harness/index.ts Normal file
View file

@ -0,0 +1,165 @@
import {test as base, expect} from "@playwright/test"
import type {BrowserContext, BrowserContextOptions, Page} from "@playwright/test"
import type {Maybe, MaybeAsync} from "@welshman/lib"
import {normalizeRelayUrl} from "@welshman/util"
import {Zooid, describeDockerProblem} from "./zooid/relay"
import {
installHttpRoutes,
mockAnalytics,
mockDufflepud,
mockHosting,
mockImages,
mockPushServer,
} from "./net/http"
import {assertNoLeaks, installWebSocketRoutes} from "./net/websocket"
import {boot} from "./app/boot"
import {seed} from "./seed/scenario"
import type {Scenario, SeedTools} from "./seed/scenario"
import type {TestUser} from "./keys"
export {expect}
export {users} from "./keys"
export type {TestUser} from "./keys"
export type {Scenario} from "./seed/scenario"
export type {SeededSpace} from "./seed/space"
export type {TenantName} from "./zooid/config"
export type {TranscriptEntry} from "./net/websocket"
export {formatTranscript, getTranscript} from "./net/websocket"
export {assertNoBlockedRequests, mockBlossom, mockLivekit} from "./net/http"
// Mirrors encodeRelay in src/app/relays.ts, which can't be imported here — it reaches the app's
// module graph, and with it sveltekit.
const encodeRelay = (url: string) =>
encodeURIComponent(
normalizeRelayUrl(url)
.replace(/^wss:\/\//, "")
.replace(/\/$/, ""),
)
export const spacePath = (url: string) => `/spaces/${encodeRelay(url)}`
export const roomPath = (url: string, h: string) => `${spacePath(url)}/${h}`
export type Harness = {
zooid: Zooid
seed(build: (tools: SeedTools) => MaybeAsync<void>): Promise<Scenario>
// A logged-in page for a user: its own browser context, its own storage, its own sockets into
// the relays every other user is talking to. `options` overrides the project's context options,
// for a spec that needs a viewport or colour scheme of its own.
as(user: TestUser, path?: string, options?: BrowserContextOptions): Promise<Page>
}
export type HarnessFixtures = {
harness: Harness
seed: Harness["seed"]
as: Harness["as"]
}
export type HarnessWorkerFixtures = {
zooid: Zooid
}
export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
// Playwright's own context — and the `page` fixture built on it — is unrouted: no websocket
// interception, no http block-all, no injected env, and nothing collecting leaks from it. A page
// born there boots the app against the relays baked into .env, which is the one thing this suite
// exists to prevent, so it is refused outright and `as()` is the only way to get a page.
context: async () => {
throw new Error(
"The built-in `context` and `page` fixtures reach the real network. Open a page with the " +
"harness's `as(user, path)` fixture, which installs interception before it navigates.",
)
},
// Playwright builds this one with `playwright.request.newContext()`, so it is an http client in
// this process that belongs to no browser context: `installHttpRoutes` cannot see it and nothing
// records what it sent.
request: async () => {
throw new Error(
"The built-in `request` fixture makes http requests from node, where nothing intercepts " +
"them. Anything the app fetches belongs in a mock installed by `as(user, path)`.",
)
},
// One container per worker, torn down when the worker ends. It is reset between tests rather
// than recreated, so the docker start-up cost is paid once.
zooid: [
// playwright reads a fixture's dependencies off this pattern, so it has to stay a pattern
// even when there are none.
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
const zooid = new Zooid()
await use(zooid)
await zooid.stop()
},
{scope: "worker"},
],
harness: async ({zooid, browser}, use, testInfo) => {
const dockerProblem = await describeDockerProblem()
testInfo.skip(Boolean(dockerProblem), dockerProblem)
await zooid.start()
await zooid.reset()
const contexts: BrowserContext[] = []
let scenario: Maybe<Scenario>
const requireScenario = () => {
if (scenario) return scenario
throw new Error("Seed a scenario before opening a page for a user")
}
const as = async (user: TestUser, path = "/", options: BrowserContextOptions = {}) => {
const {urls, cache} = requireScenario()
// The project's own `use` first, so a viewport, colour scheme or device descriptor set in
// playwright.config.ts reaches the context rather than being silently dropped.
//
// A request a service worker makes is not seen by context.route, so a worker is the one
// way out of the block-all below. Sveltekit registers src/service-worker.js on every
// navigation in dev; it has no fetch handler today, and blocking registration is what keeps
// that from being the thing containment rests on.
const context = await browser.newContext({
...testInfo.project.use,
serviceWorkers: "block",
...options,
})
contexts.push(context)
// Interception before navigation, and the block-all before the mocks — playwright matches
// the most recently registered route first, and every mock falls through what it doesn't
// recognize, so the mocks have to be registered last to be reachable at all.
await installHttpRoutes(context)
await installWebSocketRoutes(context, zooid)
await mockAnalytics(context)
await mockDufflepud(context)
await mockHosting(context)
await mockPushServer(context)
await mockImages(context)
return boot(context, {user, path, relays: urls, spaces: urls, events: cache(user)})
}
await use({
zooid,
as,
seed: async build => {
scenario = await seed(zooid, build)
return scenario
},
})
for (const context of contexts) {
await context.close()
}
for (const context of contexts) {
assertNoLeaks(context)
}
},
seed: async ({harness}, use) => use(harness.seed),
as: async ({harness}, use) => use(harness.as),
})

26
e2e/harness/keys.ts Normal file
View file

@ -0,0 +1,26 @@
import {getPubkey} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
export type TestUser = {
name: string
secret: string
pubkey: string
signer: Nip01Signer
}
const makeUser = (name: string, secret: string): TestUser => ({
name,
secret,
pubkey: getPubkey(secret),
signer: new Nip01Signer(secret),
})
// The secrets are near-zero entropy on purpose: they never leave the test process, they are
// stable across runs so a pubkey can be asserted on directly, and the leading nibbles make an
// event's author recognizable at a glance in a failed-assertion diff.
export const users = {
alice: makeUser("alice", "a11ce00000000000000000000000000000000000000000000000000000000001"),
bob: makeUser("bob", "b0b0000000000000000000000000000000000000000000000000000000000002"),
carol: makeUser("carol", "ca20100000000000000000000000000000000000000000000000000000000003"),
admin: makeUser("admin", "ad31100000000000000000000000000000000000000000000000000000000004"),
}

315
e2e/harness/net/http.ts Normal file
View file

@ -0,0 +1,315 @@
import {createHash} from "node:crypto"
import type {BrowserContext} from "@playwright/test"
import {now} from "@welshman/lib"
import type {Handle} from "@welshman/util"
import type {ZapperValues} from "@welshman/domain"
import {tenantByUrl} from "../zooid/config"
import {requestZooid} from "../zooid/transport"
// Mirrors the service urls in src/app/env.ts and .env, which this process can't import: env.ts
// reads import.meta.env and pulls in Capacitor. Nothing here is ever fetched — these are route
// patterns, and every handler answers from memory.
const DUFFLEPUD_ORIGIN = "https://dufflepud.coracle.social"
const PUSH_SERVER_ORIGIN = "https://nps.flotilla.social"
const HOSTING_ORIGIN = "https://api.hosting.coracle.social"
// Hard-coded in src/app.html, so every navigation asks for it whatever the scenario is doing.
const PLAUSIBLE_ORIGIN = "https://plausible.coracle.social"
// Relay-hosted livekit lives under a well-known path rather than an origin of its own.
const LIVEKIT_PATH = "/.well-known/nip29/livekit"
// A 1x1 transparent png, small enough to inline and real enough for an <img> to decode.
const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=",
"base64",
)
// The dev server from vite.config.ts. Traffic to it is the app loading itself rather than egress,
// so it is the one host both layers here let past, websockets included — Vite's hmr socket has to
// keep working.
export const isDevServerUrl = (url: URL) =>
url.port === "1847" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
export type BlockedRequest = {
method: string
url: string
}
const blockedByContext = new WeakMap<BrowserContext, BlockedRequest[]>()
export const getBlockedRequests = (context: BrowserContext) => {
const blocked = blockedByContext.get(context)
if (blocked) {
return blocked
}
throw new Error("installHttpRoutes was never called for this browser context")
}
/**
* Blocks every http request the app makes, except to the dev server and to a relay's own origin,
* which is forwarded to the container so that the nip-11 document and the nip-86 management api the
* app reads are the real relay's. Install this first: the per-service mocks below are registered
* later and therefore take priority, so a request that still reaches this handler is one nothing
* has mocked.
*/
export const installHttpRoutes = (context: BrowserContext) => {
const blocked: BlockedRequest[] = []
const hostByOrigin = new Map(
Array.from(tenantByUrl, ([url, host]): [string, string] => [
new URL(url.replace(/^ws/, "http")).origin,
host,
]),
)
blockedByContext.set(context, blocked)
// The dev server is left unrouted rather than matched and continued: a sveltekit page in dev is
// hundreds of module requests, and none of them is egress.
return context.route(
url => !isDevServerUrl(url),
async route => {
const request = route.request()
const {origin, pathname, search} = new URL(request.url())
const host = hostByOrigin.get(origin)
if (host) {
return route.fulfill(
await requestZooid(
host,
request.method(),
pathname + search,
request.headers(),
request.postDataBuffer() ?? undefined,
),
)
}
blocked.push({method: request.method(), url: request.url()})
return route.abort()
},
)
}
/**
* Opt-in, for a spec that wants every request the app made to have been answered by something the
* scenario stood up. Some of what a page asks for is meant to be refused — the blossom probe
* against a relay with blossom off, for one — so call this from a spec that has mocked what it
* exercises rather than from teardown.
*/
export const assertNoBlockedRequests = (context: BrowserContext) => {
const blocked = getBlockedRequests(context)
if (blocked.length > 0) {
throw new Error(
[
`The app made ${blocked.length} http request(s) nothing served:`,
...blocked.map(({method, url}) => ` ${method} ${url}`),
].join("\n"),
)
}
}
/**
* The analytics script src/app.html loads on every page. Answering with an empty body leaves
* `window.plausible` as the queueing shim src/app/analytics.ts installs, so pageviews accumulate
* in memory and nothing is ever sent — and `assertNoBlockedRequests` stays a statement about the
* scenario rather than about the page shell.
*/
export const mockAnalytics = (context: BrowserContext) =>
context.route(`${PLAUSIBLE_ORIGIN}/**`, route =>
route.fulfill({contentType: "application/javascript", body: ""}),
)
export type DufflepudFixtures = {
// The remote half of the read-state sync in src/app/notifications.ts, keyed by path.
checked?: Record<string, number>
// A link preview is only rendered when it carries a title or an image.
preview?: {title?: string; description?: string; image?: string}
handles?: {handle: string; info?: Handle}[]
// `lnurl` is hex here, not bech32 — that's the encoding dufflepud speaks.
zappers?: {lnurl: string; info?: Omit<ZapperValues, "lnurl">}[]
}
export const mockDufflepud = (context: BrowserContext, fixtures: DufflepudFixtures = {}) =>
context.route(`${DUFFLEPUD_ORIGIN}/**`, route => {
const {pathname} = new URL(route.request().url())
if (pathname === "/link/preview") {
return route.fulfill({json: fixtures.preview ?? {}})
}
if (pathname === "/handle/info") {
return route.fulfill({json: {data: fixtures.handles ?? []}})
}
if (pathname === "/zapper/info") {
return route.fulfill({json: {data: fixtures.zappers ?? []}})
}
if (pathname === "/kv/checked") {
return route.fulfill({
json: route.request().method() === "GET" ? (fixtures.checked ?? {}) : {},
})
}
return route.fallback()
})
export type BlossomOptions = {
// The blossom server the scenario expects an upload to land on, e.g. a space's own url.
server: string
}
/**
* A blossom server that keeps what it was given: an upload is hashed exactly as the real thing
* would be, so the descriptor it answers with points at a blob this mock can then serve back.
*/
export const mockBlossom = (context: BrowserContext, {server}: BlossomOptions) => {
const {origin} = new URL(server)
const blobs = new Map<string, {body: Buffer; type: string}>()
return context.route(`${origin}/**`, route => {
const request = route.request()
const method = request.method()
const {pathname} = new URL(request.url())
if (pathname === "/upload") {
if (method === "HEAD") {
return route.fulfill({status: 200, body: ""})
}
if (method === "PUT") {
const body = request.postDataBuffer() ?? Buffer.alloc(0)
const type = request.headers()["content-type"] ?? "application/octet-stream"
const sha256 = createHash("sha256").update(body).digest("hex")
blobs.set(sha256, {body, type})
return route.fulfill({
json: {sha256, type, url: `${origin}/${sha256}`, size: body.length, uploaded: now()},
})
}
}
const blob = blobs.get(pathname.slice(1).split(".")[0])
if (blob) {
return route.fulfill({
contentType: blob.type,
body: method === "HEAD" ? "" : blob.body,
})
}
return route.fallback()
})
}
/**
* The push server from src/app/push/adapters/capacitor.ts. Only the native adapters talk to it,
* so a browser run reaches this only if the platform detection regresses.
*/
export const mockPushServer = (context: BrowserContext) =>
context.route(`${PUSH_SERVER_ORIGIN}/**`, route => {
const [resource] = new URL(route.request().url()).pathname.split("/").filter(Boolean)
if (resource === "subscription") {
if (route.request().method() === "DELETE") {
return route.fulfill({json: {}})
}
// Registration is only usable if it comes back with both, and the relay is told to post
// notifications to the callback rather than the client ever fetching it.
const key = "test-push-subscription"
return route.fulfill({json: {key, callback: `${PUSH_SERVER_ORIGIN}/callback/${key}`}})
}
return route.fallback()
})
// Records straight off the hosting api, whose shapes live in src/app/hosting.ts. They're typed as
// plain objects here so that mocking a payment flow doesn't pull the app's module graph — and
// with it import.meta.env — into the node process.
export type HostingFixtures = {
plans?: object[]
// Provisioning runs on every login and ignores what it gets back, so the empty default carries
// any spec that isn't actually exercising the hosting ui.
tenant?: object
relays?: object[]
invoices?: object[]
draftInvoice?: object
}
export const mockHosting = (context: BrowserContext, fixtures: HostingFixtures = {}) =>
context.route(`${HOSTING_ORIGIN}/**`, route => {
const [resource, id, sub, detail] = new URL(route.request().url()).pathname
.split("/")
.filter(Boolean)
if (resource === "plans") {
return route.fulfill({json: {data: fixtures.plans ?? []}})
}
if (resource === "tenants") {
if (sub) {
if (sub === "relays") {
return route.fulfill({json: {data: fixtures.relays ?? []}})
}
if (sub === "invoices") {
const data = detail === "draft" ? fixtures.draftInvoice : (fixtures.invoices ?? [])
return route.fulfill({json: {data}})
}
} else {
return route.fulfill({json: {data: {pubkey: id, ...fixtures.tenant}}})
}
}
return route.fallback()
})
export type LivekitOptions = {
// Where the client is told to connect. Point it at something the test owns — the token this
// hands out is accepted by nothing else.
serverUrl: string
token?: string
}
export const mockLivekit = (context: BrowserContext, {serverUrl, token}: LivekitOptions) =>
context.route(
url => url.pathname.startsWith(LIVEKIT_PATH),
route => {
const roomId = new URL(route.request().url()).pathname.slice(LIVEKIT_PATH.length + 1)
if (roomId) {
return route.fulfill({
json: {server_url: serverUrl, participant_token: token ?? "test-participant-token"},
})
}
// The support probe in src/lib/livekit.ts reads support off the status alone.
return route.fulfill({status: 204, body: ""})
},
)
/**
* Serves a png for anything the browser is loading as an image — avatars, banners, blossom blobs,
* video posters — so a scenario's fixtures can reference urls without any of them being fetched.
*/
export const mockImages = (context: BrowserContext) =>
context.route(
url => !isDevServerUrl(url),
route => {
if (route.request().resourceType() === "image") {
return route.fulfill({contentType: "image/png", body: PNG})
}
return route.fallback()
},
)

View file

@ -0,0 +1,130 @@
import type {BrowserContext, WebSocketRoute} from "@playwright/test"
import {call, parseJson} from "@welshman/lib"
import {normalizeRelayUrl} from "@welshman/util"
import {RelayMessageType, isClientEvent, isClientReq} from "@welshman/net"
import type {ClientMessage, RelayMessage} from "@welshman/net"
import type {RelayConnection} from "../zooid/types"
import type {Zooid} from "../zooid/relay"
import {isDevServerUrl} from "./http"
export type Direction = "toRelay" | "toClient"
export type TranscriptEntry = {
url: string
direction: Direction
message: ClientMessage | RelayMessage
}
type Traffic = {
transcript: TranscriptEntry[]
leaks: Set<string>
}
const trafficByContext = new WeakMap<BrowserContext, Traffic>()
const getTraffic = (context: BrowserContext) => {
const traffic = trafficByContext.get(context)
if (traffic) {
return traffic
}
throw new Error("installWebSocketRoutes was never called for this browser context")
}
// A relay that holds nothing: REQs get an immediate EOSE and events are accepted into the void.
// Only urls the container does not serve get one, so a leak fails on the assertion that names it
// rather than on a timeout three layers away.
const openEmptyRelay = (): RelayConnection => {
let listener: (message: RelayMessage) => void = () => undefined
return {
onMessage(f) {
listener = f
},
send(message) {
if (isClientReq(message)) {
listener([RelayMessageType.Eose, message[1]])
} else if (isClientEvent(message)) {
listener([RelayMessageType.Ok, message[1].id, true, ""])
}
},
close() {},
}
}
const serve = (traffic: Traffic, zooid: Zooid, route: WebSocketRoute) => {
const url = normalizeRelayUrl(route.url())
const connection = call(() => {
const relay = zooid.relays.get(url)
if (relay) {
return relay.connect()
}
traffic.leaks.add(url)
return openEmptyRelay()
})
connection.onMessage(message => {
traffic.transcript.push({url, direction: "toClient", message})
route.send(JSON.stringify(message))
})
route.onMessage(frame => {
const message = parseJson<ClientMessage>(frame.toString())
if (message) {
traffic.transcript.push({url, direction: "toRelay", message})
connection.send(message)
}
})
route.onClose(() => connection.close())
}
/**
* The single interception point for relay traffic. It goes on the context rather than a page, so
* every page in it is covered including ones opened later, and it is safe to call before any page
* exists — routing is the environment a page is born into, not a step in a sequence.
*
* Vite's hmr socket is the one url left alone. Everything else is answered from this process, and
* a url that is not one of the container's virtual relays is served by an empty relay and recorded
* as a leak.
*/
export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) => {
const traffic: Traffic = {transcript: [], leaks: new Set()}
trafficByContext.set(context, traffic)
return context.routeWebSocket(
url => !isDevServerUrl(url),
route => serve(traffic, zooid, route),
)
}
export const getTranscript = (context: BrowserContext) => getTraffic(context).transcript
// Every frame in both directions, oldest first. Attach it to a failing test to see what the client
// actually said, and to whom.
export const formatTranscript = (context: BrowserContext) =>
getTranscript(context)
.map(
({url, direction, message}) =>
`${direction === "toRelay" ? ">>" : "<<"} ${url} ${JSON.stringify(message)}`,
)
.join("\n")
export const assertNoLeaks = (context: BrowserContext) => {
const {leaks} = getTraffic(context)
if (leaks.size > 0) {
throw new Error(
[
`The app opened a websocket to ${leaks.size} url(s) the scenario never created:`,
...Array.from(leaks).map(url => ` ${url}`),
].join("\n"),
)
}
}

View file

@ -0,0 +1,113 @@
import {int, now} from "@welshman/lib"
import type {MaybeAsync} from "@welshman/lib"
import {ROOMS, makeEvent} from "@welshman/util"
import type {SignedEvent} from "@welshman/util"
import type {Zooid} from "../zooid/relay"
import type {TenantName} from "../zooid/config"
import {users} from "../keys"
import type {TestUser} from "../keys"
import {seedSpace} from "./space"
import type {SeededSpace} from "./space"
// A fixture timestamp, as an offset from the moment the scenario started: `at(2, HOUR)` is two
// hours before the test began. Count-first, like int and ago.
export type At = (count: number, unit: number) => number
export type SeedTools = {
// Names a relay the container already serves — its policy is its toml in zooid/docker/config, so
// a scenario describes what is on a relay, never what the relay is.
relay: (name: TenantName) => SeededSpace
user: typeof users
at: At
}
export type Scenario = {
readonly startedAt: number
readonly at: At
readonly urls: string[]
space(name: TenantName): SeededSpace
// The events a returning user's client would already have on disk. Just the room list: the
// scenario's own relays stand in for the indexers, and a members-only relay won't serve the list
// that would tell authPolicy it may identify to it. See ARCHITECTURE.md, "Users and sessions".
cache(user: TestUser): SignedEvent[]
}
export const seed = async (
zooid: Zooid,
build: (tools: SeedTools) => MaybeAsync<void>,
): Promise<Scenario> => {
const startedAt = now()
const at: At = (count, unit) => startedAt - int(count, unit)
const spaces = new Map<string, SeededSpace>()
const writes: (() => Promise<void>)[] = []
const roomLists = new Map<string, SignedEvent>()
const relay = (name: TenantName) => {
const space = seedSpace({
zooid,
startedAt,
name,
enqueue: write => writes.push(write),
})
spaces.set(name, space)
return space
}
await build({relay, user: users, at})
// Seeding is async and fixtures depend on one another, so the builder only records what to
// write. Draining the queue here publishes each fixture in the order it was declared.
for (const write of writes) {
await write()
}
const seeded = Array.from(spaces.values())
// A room list is replaceable and covers every space at once, so it can only be written after
// all of them have been seeded.
const membershipsByPubkey = new Map<string, {user: TestUser; urls: string[]; tags: string[][]}>()
for (const space of seeded) {
for (const {user, rooms} of space.memberships) {
const membership = membershipsByPubkey.get(user.pubkey) ?? {user, urls: [], tags: []}
membership.urls.push(space.url)
membership.tags.push(["r", space.url], ...rooms.map(h => ["group", h, space.url]))
membershipsByPubkey.set(user.pubkey, membership)
}
}
for (const {user, urls, tags} of membershipsByPubkey.values()) {
const event = await user.signer.sign(makeEvent(ROOMS, {tags, created_at: startedAt}))
for (const url of urls) {
await zooid.publish(url, event)
}
roomLists.set(user.pubkey, event)
}
const getSpace = (name: TenantName) => {
const space = spaces.get(name)
if (space) return space
throw new Error(`No space named "${name}" was seeded`)
}
const cache = (user: TestUser) => {
const roomList = roomLists.get(user.pubkey)
return roomList ? [roomList] : []
}
return {
startedAt,
at,
cache,
urls: seeded.map(space => space.url),
space: getSpace,
}
}

191
e2e/harness/seed/space.ts Normal file
View file

@ -0,0 +1,191 @@
import {neventEncode} from "nostr-tools/nip19"
import type {Maybe, MaybeAsync} from "@welshman/lib"
import {
MESSAGE,
ROOM_ADD_MEMBER,
Resolver,
makeEvent,
tagSpec,
tagValue,
toNostrURI,
} from "@welshman/util"
import type {SignedEvent, StampedEvent} from "@welshman/util"
import {EventWriter, Profile} from "@welshman/domain"
import type {BaseEventReader} from "@welshman/domain"
import type {RoomOptions, TestRelay} from "../zooid/types"
import type {Zooid} from "../zooid/relay"
import type {TenantName} from "../zooid/config"
import {users} from "../keys"
import type {TestUser} from "../keys"
// @welshman/domain has no writer for NIP-29 kind-9 messages, and none of its readers describe one,
// so this pairs the base writer with the base reader. The behavior tags it renders — `h` via
// setRoom, `q`/`p` via addQuote/addMention — are everything a room message carries.
class MessageWriter extends EventWriter<BaseEventReader> {}
// A handle to an event the scenario is going to publish. Seeding calls record what to write and
// return before anything is written, so the event is filled in when its turn in the queue comes up.
export type SeededEvent = {
readonly event: SignedEvent
readonly id: string
}
export type ProfileValues = {
name?: string
about?: string
picture?: string
nip05?: string
}
// A queued write. Seeding is ordered — a reply's parent has to exist first — so every write goes
// through the scenario's queue rather than starting when it is declared.
export type Enqueue = (write: () => Promise<void>) => void
// A user's membership as their own client sees it, which the scenario turns into one room list
// per user once every space has been seeded.
export type SeededMembership = {
user: TestUser
rooms: string[]
}
export type SeededSpace = {
readonly name: TenantName
// The url is read off the relay handle, so this only reads once seeding has run.
readonly url: string
readonly memberships: SeededMembership[]
room(h: string, options?: RoomOptions): void
member(user: TestUser, h?: string): void
// Relay and room membership, plus a place in the user's own room list — what a user who joined
// this space through the ui would end up with.
join(user: TestUser, ...rooms: string[]): void
message(user: TestUser, h: string, content: string, createdAt?: number): SeededEvent
reply(user: TestUser, parent: SeededEvent, content: string, createdAt?: number): SeededEvent
profile(user: TestUser, values: ProfileValues, createdAt?: number): SeededEvent
event(user: TestUser, template: StampedEvent): SeededEvent
}
export type SeedSpaceOptions = {
zooid: Zooid
enqueue: Enqueue
// The moment the scenario began. A fixture declared without a timestamp is stamped with it
// rather than with the wall clock, so a run's events never drift apart from one another.
startedAt: number
name: TenantName
}
export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): SeededSpace => {
const memberships: SeededMembership[] = []
let testRelay: Maybe<TestRelay>
const relay = () => {
if (testRelay) return testRelay
throw new Error(`Space "${name}" has not been seeded yet, await seed() first`)
}
enqueue(async () => {
testRelay = await zooid.relay(name)
})
// Every fixture is published to this space, so a relay hint always resolves to its url.
const context = {resolver: new Resolver(() => [relay().url])}
const publish = (write: () => Promise<SignedEvent>): SeededEvent => {
let signed: Maybe<SignedEvent>
const event = () => {
if (signed) return signed
throw new Error(`An event seeded into "${name}" was read before seed() published it`)
}
enqueue(async () => {
signed = await write()
})
return {
get event() {
return event()
},
get id() {
return event().id
},
}
}
const publishTemplate = (user: TestUser, build: () => MaybeAsync<StampedEvent>) =>
publish(async () => relay().event(user, await build()))
const room = (h: string, roomOptions: RoomOptions = {}) =>
enqueue(() => relay().room(h, roomOptions, startedAt))
const member = (user: TestUser, h?: string) => enqueue(() => relay().member(user, h, startedAt))
const event = (user: TestUser, template: StampedEvent) => publishTemplate(user, () => template)
const join = (user: TestUser, ...roomIds: string[]) => {
memberships.push({user, rooms: roomIds})
member(user)
for (const h of roomIds) {
event(
users.admin,
makeEvent(ROOM_ADD_MEMBER, {
created_at: startedAt,
tags: [
["h", h],
["p", user.pubkey],
],
}),
)
}
}
const message = (user: TestUser, h: string, content: string, createdAt = startedAt) =>
publish(() => relay().message(user, h, content, createdAt))
// Flotilla replies in a room by quoting: Content.svelte renders a quote from the nostr uri in the
// content rather than from the q tag, so the uri has to be prepended, as prependParent does in
// src/app/rooms.ts.
const reply = (user: TestUser, parent: SeededEvent, content: string, createdAt = startedAt) =>
publishTemplate(user, async () => {
const h = tagValue(tagSpec("h"), parent.event.tags)
if (h) {
const url = relay().url
const nevent = neventEncode({...parent.event, relays: [url]})
const template = await new MessageWriter(MESSAGE, context)
.setRoom(url, h)
.addQuote(parent.event)
.addMention(parent.event.pubkey)
.setContent(toNostrURI(nevent) + "\n\n" + content)
.renderTemplate()
return {...template, created_at: createdAt}
}
throw new Error(`Cannot reply to ${parent.id}, it is not in a room`)
})
const profile = (user: TestUser, values: ProfileValues, createdAt = startedAt) =>
publishTemplate(user, async () => ({
...(await Profile.configure(context).writer().update(values).renderTemplate()),
created_at: createdAt,
}))
return {
name,
get url() {
return relay().url
},
memberships,
room,
member,
join,
message,
reply,
profile,
event,
}
}

View file

@ -0,0 +1,27 @@
/**
* The virtual relays the container serves, and the only place their names are written down.
*
* zooid is multi-tenant: it binds a config to a Host header and serves any number of them from one
* process, so a second space costs a toml in docker/config and nothing else. The names are a union
* rather than a string so that a scenario naming a relay that has no config fails to compile
* instead of hanging on a 404 from the dispatcher.
*
* Each host is a name that resolves nowhere. The container is reached on loopback and told what to
* call itself, so that what it calls itself is a url a relay selection will keep — see transport.ts
* for why that matters, and `.test` is reserved by rfc 2606, so a url that escapes this process
* fails to connect rather than reaching a host.
*/
export const tenants = {
space: "space.test",
other: "other.test",
} as const
export type TenantName = keyof typeof tenants
export const tenantUrl = (name: TenantName) => `wss://${tenants[name]}/`
export const tenantNames = Object.keys(tenants) as TenantName[]
// Which tenant a url belongs to, for the transport's Host header. A url the app opens that is not
// one of these is a leak, and is reported as one rather than dialled.
export const tenantByUrl = new Map(tenantNames.map(name => [tenantUrl(name), tenants[name]]))

View file

@ -0,0 +1,37 @@
# The relay the whole suite runs against. Bound to loopback only, and the one server this harness is
# allowed to connect to for real.
#
# The image is distroless and runs as `nonroot`. zooid creates DATA and MEDIA itself, but it cannot
# create their parent /app, so both have to arrive already mounted — and writable by uid 65532,
# which is what `mode` below is for: docker defaults a tmpfs to 1777 and podman does not, and a
# tmpfs the relay cannot write is a container that exits 1 the moment it opens its database.
# Nothing is persisted, so `up --force-recreate` is a guaranteed-empty database.
name: flotilla-e2e-zooid
services:
relay:
image: gitea.coracle.social/coracle/zooid:latest
# The harness dials this published port and sends a `Host` per virtual relay, which is what the
# dispatcher matches against the `host` in each file under config/.
ports:
- "127.0.0.1:3334:3334"
environment:
PORT: "3334"
CONFIG: /app/config
DATA: /app/data
MEDIA: /app/media
volumes:
- type: bind
source: ./config
target: /app/config
read_only: true
- type: tmpfs
target: /app/data
tmpfs:
size: 268435456
mode: 01777
- type: tmpfs
target: /app/media
tmpfs:
size: 33554432
mode: 01777

View file

@ -0,0 +1,44 @@
# A second virtual relay, identical in policy to space.toml, for specs that need two spaces on
# different relays — outbox routing, cross-space isolation. zooid is multi-tenant, so this costs a
# file and no extra process.
#
# `host` must equal the Host header the harness sends, which is the hostname of the url the client
# is given; anything else gets a 404 from the dispatcher instead of a relay. Keep it in step with
# `tenants` in harness/zooid/config.ts, and see harness/zooid/transport.ts for why it is a name
# that resolves nowhere.
host = "other.test"
schema = "e2e_other"
secret = "07be7000000000000000000000000000000000000000000000000000000000e7"
[info]
# Served to the client as nip-11. `name` is what a spec sees as the space's name, so it matches the
# name scenarios pass to `relay()`. `pubkey` is the owner, and here it is the relay's own key (the
# pubkey for `secret`) so that [roles.admin] below is the only thing granting a test identity
# can_manage.
name = "other"
pubkey = "d9220af5f36469d17322f41902b6b30dad1869cbb473b35f5e8f8d8e924f36b8"
description = "Throwaway relay for Flotilla's end-to-end suite."
[policy]
public_read = false
public_write = false
public_join = true
strip_signatures = false
[groups]
enabled = true
[management]
enabled = true
# Blossom and push are off, and [livekit] is omitted entirely, because each of them would have
# the relay or the client talk to a service the test did not create.
[blossom]
enabled = false
[push]
enabled = false
[roles.admin]
pubkeys = ["6ada7b6eabb3a8349f88667d278a275c704b553a7c57f9d8156555986884a08e"]
can_manage = true

View file

@ -0,0 +1,43 @@
# One of the virtual relays the container serves, and the only statement of this relay's policy —
# a scenario says what is on a relay, never what the relay is.
#
# `host` must equal the Host header the harness sends, which is the hostname of the url the client
# is given; anything else gets a 404 from the dispatcher instead of a relay. Keep it in step with
# `tenants` in harness/zooid/config.ts, and see harness/zooid/transport.ts for why it is a name
# that resolves nowhere.
host = "space.test"
schema = "e2e"
secret = "5e1f000000000000000000000000000000000000000000000000000000000005"
[info]
# Served to the client as nip-11. `name` is what a spec sees as the space's name, so it matches the
# name scenarios pass to `relay()`. `pubkey` is the owner, and here it is the relay's own key (the
# pubkey for `secret`) so that [roles.admin] below is the only thing granting a test identity
# can_manage.
name = "space"
pubkey = "da00132eaf7ef6bbde47e5dc409fa1d481e518db0621af31591e0306eb48ed8a"
description = "Throwaway relay for Flotilla's end-to-end suite."
[policy]
public_read = false
public_write = false
public_join = true
strip_signatures = false
[groups]
enabled = true
[management]
enabled = true
# Blossom and push are off, and [livekit] is omitted entirely, because each of them would have
# the relay or the client talk to a service the test did not create.
[blossom]
enabled = false
[push]
enabled = false
[roles.admin]
pubkeys = ["6ada7b6eabb3a8349f88667d278a275c704b553a7c57f9d8156555986884a08e"]
can_manage = true

264
e2e/harness/zooid/relay.ts Normal file
View file

@ -0,0 +1,264 @@
import {execFile} from "node:child_process"
import {fileURLToPath} from "node:url"
import {promisify} from "node:util"
import {ms, sleep} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {makeRelayAuth} from "@welshman/util"
import type {SignedEvent} from "@welshman/util"
import {ClientMessageType, isRelayAuth, isRelayOk} from "@welshman/net"
import type {ClientMessage} from "@welshman/net"
import {users} from "../keys"
import {tenantNames, tenantUrl, tenants} from "./config"
import type {TenantName} from "./config"
import {makeTestRelay} from "./testRelay"
import {connectToZooid, requestZooid} from "./transport"
import type {ZooidConnection} from "./transport"
const image = "gitea.coracle.social/coracle/zooid:latest"
const composeFile = fileURLToPath(new URL("docker/compose.yaml", import.meta.url))
const testUsersByPubkey = new Map(Object.values(users).map(user => [user.pubkey, user]))
const execFileAsync = promisify(execFile)
// execFile does not go through a shell, so a `docker` that exists only as an alias or a function —
// podman, colima, a wrapper in a shell rc file — is invisible to it however well it works when
// typed. Name the executable to drive with E2E_DOCKER in that case.
const dockerCommand = process.env.E2E_DOCKER ?? "docker"
const docker = (...args: string[]) => execFileAsync(dockerCommand, args)
const compose = (...args: string[]) => docker("compose", "-f", composeFile, ...args)
// Why the container cannot be driven, or undefined when it can. The two failures need different
// answers — a cli that is not on this process's PATH is usually a shell alias or an inherited
// environment, and telling someone to start docker sends them the wrong way — so they are reported
// apart rather than as one boolean.
const probeDocker = async () => {
try {
await docker("compose", "version")
return undefined
} catch (error) {
const {code, stderr} = Object(error) as {code?: string; stderr?: string}
if (code === "ENOENT") {
return (
`\`${dockerCommand}\` is not on PATH for the test runner. If it works in your shell it is ` +
"an alias or a function rather than an executable, which execFile cannot see: set " +
"E2E_DOCKER to the real command, e.g. `E2E_DOCKER=podman pnpm test`."
)
}
return (
`\`${dockerCommand} compose version\` failed, so the daemon is likely not running. ` +
(stderr?.trim() || String(error))
)
}
}
let problem: Maybe<Promise<Maybe<string>>>
// Asked once a worker: docker does not come and go mid-run, and the answer is also written to the
// terminal, because a skip reason otherwise reaches only the html report and a run that silently
// skips every test is the least useful thing this can do.
export const describeDockerProblem = () =>
(problem ??= probeDocker().then(reason => {
if (reason) {
console.warn(
`\nThe e2e suite cannot drive a container, so every test will skip:\n ${reason}\n`,
)
}
return reason
}))
/**
* The relay every test runs against: one zooid container on loopback, serving a virtual relay per
* entry in `tenants`. Each relay's policy is whatever its toml in docker/config says, which is the
* only place policy is written down — a scenario describes what is *on* a relay, never what the
* relay is.
*/
export class Zooid {
// Every socket into the container — the client's and seeding's alike — is one this process
// opened, so this map is also the definition of a url that is not a leak.
relays = new Map(
tenantNames.map(name => [
tenantUrl(name),
{connect: () => connectToZooid(tenants[name]), host: tenants[name]},
]),
)
private sessions = new Map<string, ZooidConnection>()
private started = false
// Verifying docker rather than bringing the container up: every test resets, and that is what
// starts it. Repeat calls are free, so the fixture can call this per test.
start = async () => {
if (this.started) return
const problem = await describeDockerProblem()
if (problem) {
throw new Error(problem)
}
const hasImage = await docker("image", "inspect", image).then(
() => true,
() => false,
)
if (!hasImage) {
throw new Error(
`The zooid image ${image} is not present locally. Fetch it with ` +
`\`${dockerCommand} pull ${image}\`, or build it from a zooid checkout with ` +
`\`${dockerCommand} build -t ${image} .\`.`,
)
}
this.started = true
}
relay = async (name: TenantName) =>
makeTestRelay({
name,
url: tenantUrl(name),
publish: event => this.publish(tenantUrl(name), event),
})
publish = async (url: string, event: SignedEvent) => {
const relay = this.relays.get(url)
if (!relay) {
throw new Error(`Attempted to publish to ${url}, which is not one of this container's relays`)
}
const connection = await this.authenticate(relay.host, event.pubkey)
const {ok, detail} = await this.send(connection, [ClientMessageType.Event, event], event.id)
if (!ok) {
throw new Error(`zooid refused a kind ${event.kind} fixture: ${detail}`)
}
}
reset = async () => {
this.closeSessions()
await this.up()
}
stop = async () => {
if (this.started) {
this.closeSessions()
await compose("down")
this.started = false
}
}
// Whatever the relay wrote before it died. Compose reports only that a container exited, so
// without this a startup failure is a status code and no reason.
private logs = () =>
compose("logs", "--no-color", "--tail", "50").then(
({stdout, stderr}) => [stdout, stderr].filter(Boolean).join("\n").trim(),
() => "",
)
private fail = async (summary: string) => {
const logs = await this.logs()
throw new Error(logs ? `${summary}\n\nzooid said:\n${logs}` : summary)
}
// Recreating rather than restarting is what makes this a reset: with storage in tmpfs and no
// volume mounted for it, a new container is a new database.
private up = async () => {
try {
await compose("up", "-d", "--force-recreate", "--wait")
} catch (error) {
const {stderr} = Object(error) as {stderr?: string}
await this.fail((stderr?.trim() || String(error)).split("\n").slice(-3).join("\n"))
}
const deadline = Date.now() + ms(30)
while (Date.now() < deadline) {
// A 404 means the dispatcher has no relay bound to that host yet, so the configs are still
// loading. Anything a relay itself answers means every tenant is ready.
const isUp = await Promise.all(
tenantNames.map(name =>
requestZooid(tenants[name], "GET", "/", {accept: "application/nostr+json"}).then(
response => response.status === 200,
() => false,
),
),
).then(results => results.every(Boolean))
if (isUp) {
return
}
await sleep(200)
}
await this.fail(
`zooid did not answer as ${tenantNames.map(name => tenants[name]).join(" and ")} within 30 ` +
"seconds.",
)
}
// NIP-42 binds an identity to a connection, so each test user seeds over its own, per relay. The
// url signed into the auth event is the one the client uses, because that is the one khatru
// rebuilds from the headers transport.ts sends — a fixture is written over the same relay the app
// talks to.
private authenticate = async (host: string, pubkey: string) => {
const key = `${host} ${pubkey}`
const session = this.sessions.get(key)
if (session) return session
const user = testUsersByPubkey.get(pubkey)
if (!user) {
throw new Error(
`Cannot publish as ${pubkey}: zooid authenticates every write, so seeded events must be ` +
"signed by one of the test identities in e2e/harness/keys.ts",
)
}
const connection = connectToZooid(host)
const [, challenge] = await connection.wait(isRelayAuth)
const event = await user.signer.sign(makeRelayAuth(`wss://${host}/`, challenge))
const {ok, detail} = await this.send(connection, [ClientMessageType.Auth, event], event.id)
if (ok) {
this.sessions.set(key, connection)
return connection
}
throw new Error(`Failed to authenticate as ${user.name} on ${host}: ${detail}`)
}
// Both halves of seeding — the auth that opens a connection and every event written over it —
// are answered with an OK naming the event that was sent.
private send = async (connection: ZooidConnection, message: ClientMessage, id: string) => {
connection.send(message)
const [, , ok, detail] = await connection.wait(reply => isRelayOk(reply) && reply[1] === id)
return {ok, detail}
}
private closeSessions = () => {
for (const connection of this.sessions.values()) {
connection.close()
}
this.sessions.clear()
}
}

View file

@ -0,0 +1,72 @@
import {
MESSAGE,
RELAY_JOIN,
ROOM_ADD_MEMBER,
ROOM_CREATE,
ROOM_EDIT_META,
makeEvent,
} from "@welshman/util"
import type {SignedEvent, StampedEvent} from "@welshman/util"
import {users} from "../keys"
import type {TestUser} from "../keys"
import type {TestRelay} from "./types"
export type TestRelayOptions = {
name: string
url: string
publish: (event: SignedEvent) => Promise<void>
}
// Seeding signs the events a real client would send and publishes them over a socket. Room
// administration is signed by `admin`, the only test identity the tomls grant can_manage.
//
// Every timestamp is the caller's: seeding runs off the scenario's clock, and reading the wall
// clock here would leave two fixtures that describe the same thing — a room's membership, say —
// stamped seconds apart, which is enough to decide which of them wins.
export const makeTestRelay = ({name, url, publish}: TestRelayOptions): TestRelay => {
const event = async (user: TestUser, template: StampedEvent) => {
const signed = await user.signer.sign(template)
await publish(signed)
return signed
}
return {
name,
url,
event,
room: async (h, meta, createdAt) => {
// A relay stamps the metadata it derives from these ops with the op's own created_at, so
// creation has to be strictly older than the edit or the edit is dropped as stale.
await event(
users.admin,
makeEvent(ROOM_CREATE, {tags: [["h", h]], created_at: createdAt - 1}),
)
const tags = [["h", h]]
if (meta.name) tags.push(["name", meta.name])
if (meta.about) tags.push(["about", meta.about])
if (meta.picture) tags.push(["picture", meta.picture])
if (meta.closed) tags.push(["closed"])
if (meta.private) tags.push(["private"])
await event(users.admin, makeEvent(ROOM_EDIT_META, {tags, created_at: createdAt}))
},
message: (user, h, content, createdAt) =>
event(user, makeEvent(MESSAGE, {content, tags: [["h", h]], created_at: createdAt})),
member: async (user, h, createdAt) => {
await event(user, makeEvent(RELAY_JOIN, {created_at: createdAt}))
if (h) {
const tags = [
["h", h],
["p", user.pubkey],
]
await event(users.admin, makeEvent(ROOM_ADD_MEMBER, {tags, created_at: createdAt}))
}
},
}
}

View file

@ -0,0 +1,144 @@
import {request as httpRequest} from "node:http"
import WebSocket from "ws"
import {parseJson} from "@welshman/lib"
import type {ClientMessage, RelayMessage} from "@welshman/net"
import type {RelayConnection} from "./types"
/**
* How the harness reaches the zooid container, and the only place that knows the container has a
* loopback address at all.
*
* The client is given `wss://<tenant>.test/` and never learns anything else. It has to be that
* rather than the container's own `ws://localhost:3334/`, because a relay selection drops a url
* that is local or insecure unless the caller opts in — see isLocalUrl and RelaySelection.getUrls
* in @welshman/util — which Flotilla never does, so the Router would resolve every outbox load,
* profile and relay hint to nothing.
*
* The two headers below are what makes the container answer to that name. zooid's dispatcher binds
* a config to a Host (cmd/relay/main.go), and khatru derives the url it checks nip-42 and nip-86
* signatures against from that Host plus the forwarded proto (getBaseURL in khatru/relay.go), so
* both the relay the config describes and the url it expects to have been signed are the tenant's.
* They are exactly what a tls terminator adds in front of a real deployment.
*/
const port = 3334
const headersFor = (host: string) => ({Host: host, "X-Forwarded-Proto": "https"})
export type ZooidConnection = RelayConnection & {
// Resolves with the first message the relay sends that matches. Seeding waits for its auth
// challenge and for the OK answering each write this way.
wait(match: (message: RelayMessage) => boolean): Promise<RelayMessage>
}
// One connection to the container, as one of its virtual relays. Node's own WebSocket cannot carry
// a Host header — fetch forbids it — so this is the one thing in the harness that `ws` is here for.
export const connectToZooid = (host: string): ZooidConnection => {
const socket = new WebSocket(`ws://127.0.0.1:${port}/`, {headers: headersFor(host)})
const listeners = new Set<(message: RelayMessage) => void>()
const pending: ClientMessage[] = []
const write = (message: ClientMessage) => socket.send(JSON.stringify(message))
socket.on("open", () => pending.splice(0).forEach(write))
socket.on("message", data => {
const message = parseJson<RelayMessage>(data.toString())
if (message) {
for (const listener of listeners) {
listener(message)
}
}
})
// The likeliest error here is a 404 from zooid's dispatcher, which means `tenants` in config.ts
// and the `host` in the tenant's toml have drifted apart.
socket.on("error", error =>
console.error(`The zooid container refused a socket for ${host}: ${error.message}`),
)
return {
onMessage: listener => {
listeners.add(listener)
},
// A client may speak before the container has finished its handshake — the page's socket is
// open as soon as playwright has a route for it, which is well before this one is.
send: message => {
if (socket.readyState === WebSocket.OPEN) {
write(message)
} else {
pending.push(message)
}
},
close: () => socket.close(),
wait: match =>
new Promise(resolve => {
const listener = (message: RelayMessage) => {
if (match(message)) {
listeners.delete(listener)
resolve(message)
}
}
listeners.add(listener)
}),
}
}
export type ZooidResponse = {
status: number
headers: Record<string, string>
body: Buffer
}
// One http request to the container, carrying the same two headers, so that the nip-11 document
// and the nip-86 management api the browser reads are the real relay's rather than a mock's.
export const requestZooid = (
host: string,
method: string,
path: string,
requestHeaders: Record<string, string>,
body?: Buffer,
): Promise<ZooidResponse> =>
new Promise((resolve, reject) => {
const request = httpRequest(
{
method,
path,
host: "127.0.0.1",
port,
headers: {
// A pseudo-header describes the request line rather than the request, and node:http
// rejects a header name with a colon in it.
...Object.fromEntries(
Object.entries(requestHeaders).filter(([name]) => !name.startsWith(":")),
),
...headersFor(host),
},
},
response => {
const chunks: Buffer[] = []
const responseHeaders: Record<string, string> = {}
for (const [name, value] of Object.entries(response.headers)) {
// Hop-by-hop headers describe the connection this answer arrived on rather than the one
// the browser is waiting on, which playwright frames itself.
if (value && !["connection", "keep-alive", "transfer-encoding"].includes(name)) {
responseHeaders[name] = Array.isArray(value) ? value.join(", ") : value
}
}
response.on("data", chunk => chunks.push(chunk))
response.on("end", () =>
resolve({
status: response.statusCode ?? 500,
headers: responseHeaders,
body: Buffer.concat(chunks),
}),
)
},
)
request.on("error", reject)
request.end(body)
})

View file

@ -0,0 +1,34 @@
import type {SignedEvent, StampedEvent} from "@welshman/util"
import type {ClientMessage, RelayMessage} from "@welshman/net"
import type {TestUser} from "../keys"
export type RoomOptions = {
name?: string
about?: string
picture?: string
closed?: boolean
private?: boolean
}
// A handle to one relay, plus the seeding affordances scenarios build on. Every seeding call
// publishes over a real socket rather than inserting into storage, so the relay stores exactly what
// it would have stored for a real client. Each one is stamped by its caller rather than off the
// wall clock, so a scenario's fixtures share one clock.
export type TestRelay = {
readonly name: string
readonly url: string
room(h: string, options: RoomOptions, createdAt: number): Promise<void>
message(user: TestUser, h: string, content: string, createdAt: number): Promise<SignedEvent>
// Grants relay membership, and room membership too when `h` is given.
member(user: TestUser, h: string | undefined, createdAt: number): Promise<void>
// Escape hatch for kinds with no affordance of their own: profiles, reactions, threads, DMs.
event(user: TestUser, event: StampedEvent): Promise<SignedEvent>
}
// One client's connection to a relay. Every connection to a url reaches the same container, which
// is what lets one browser context observe another's writes over the wire.
export type RelayConnection = {
onMessage(listener: (message: RelayMessage) => void): void
send(message: ClientMessage): void
close(): void
}

View file

@ -1,12 +0,0 @@
import {expect, test} from "@playwright/test"
test("boots the SPA on the home page", async ({page}) => {
const response = await page.goto("/")
expect(response?.ok()).toBeTruthy()
// adapter-static serves an empty shell that hydrates client-side, so the presence of
// rendered text proves the Svelte app actually mounted (not just that a file was served).
// TODO: tighten this to assert concrete onboarding UI once the markup is settled.
await expect(page.locator("body")).toContainText(/\S/, {timeout: 15_000})
})

43
e2e/specs/auth.spec.ts Normal file
View file

@ -0,0 +1,43 @@
import {CLIENT_AUTH} from "@welshman/util"
import {ClientMessageType, RelayMessageType} from "@welshman/net"
import {expect, getTranscript, roomPath, test, users} from "../harness"
test("authenticates over nip-42 before a members-only relay serves anything", async ({
seed,
as,
}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("backroom", {name: "Backroom"})
space.join(user.alice, "backroom")
space.message(user.alice, "backroom", "the secret handshake")
})
const {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "backroom"))
await expect(page.getByText("the secret handshake")).toBeVisible()
const transcript = getTranscript(page.context()).filter(entry => entry.url === url)
const challengedAt = transcript.findIndex(
({direction, message}) => direction === "toClient" && message[0] === RelayMessageType.Auth,
)
const authenticatedAt = transcript.findIndex(
({direction, message}) => direction === "toRelay" && message[0] === ClientMessageType.Auth,
)
const firstEventAt = transcript.findIndex(
({direction, message}) => direction === "toClient" && message[0] === RelayMessageType.Event,
)
expect(challengedAt).toBeGreaterThanOrEqual(0)
expect(authenticatedAt).toBeGreaterThan(challengedAt)
expect(transcript[authenticatedAt].message[1]).toMatchObject({
kind: CLIENT_AUTH,
pubkey: users.alice.pubkey,
})
// The whole point of intercepting the transport rather than swapping in an adapter: until the
// client proves who it is, the relay hands it nothing at all.
expect(firstEventAt).toBeGreaterThan(authenticatedAt)
})

View file

@ -0,0 +1,27 @@
import {expect, roomPath, test, users} from "../harness"
test("delivers one user's message to another user's open page", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
})
const {url} = scenario.space("space")
const path = roomPath(url, "general")
// Two browser contexts, two identities, one relay: bob's page is already listening when alice
// sends, so the message reaches him over the wire rather than out of a shared repository.
const alice = await as(users.alice, path)
const bob = await as(users.bob, path)
await expect(bob.getByRole("link", {name: "General"})).toBeVisible()
await alice.locator(".chat-editor [contenteditable=true]").pressSequentially("anyone there?")
await alice.locator(".chat-editor [contenteditable=true]").press("Enter")
await expect(alice.getByText("anyone there?")).toBeVisible()
await expect(bob.getByText("anyone there?")).toBeVisible()
})

57
e2e/specs/routing.spec.ts Normal file
View file

@ -0,0 +1,57 @@
import {spec} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {RelayMessageType} from "@welshman/net"
import {expect, getTranscript, roomPath, test, users} from "../harness"
test("keeps two spaces' contents on their own relays", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
const other = relay("other")
// Same room id on both relays, so anything that keys rooms by id alone conflates them.
space.room("lounge", {name: "Space Lounge"})
other.room("lounge", {name: "Other Lounge"})
space.join(user.alice, "lounge")
other.join(user.alice, "lounge")
space.message(user.alice, "lounge", "only in space")
other.message(user.alice, "lounge", "only in other")
})
const space = scenario.space("space")
const other = scenario.space("other")
const page = await as(users.alice, roomPath(space.url, "lounge"))
const deliveredByOther = (match: (event: TrustedEvent) => boolean) =>
getTranscript(page.context()).some(
({url, direction, message}) =>
url === other.url &&
direction === "toClient" &&
message[0] === RelayMessageType.Event &&
match(message[2]),
)
await expect(page.getByText("only in space")).toBeVisible()
await expect(page.getByRole("link", {name: "Space Lounge"})).toBeVisible()
// Alice belongs to both spaces, so the client is talking to the other relay at the same time —
// its room and its messages just don't belong in this one. It syncs independently, so wait until
// both have actually reached this page: `toHaveCount(0)` is equally satisfied by an element that
// has not loaded yet, which would pass against a client that does conflate them.
await expect.poll(() => deliveredByOther(event => event.content === "only in other")).toBe(true)
await expect
.poll(() => deliveredByOther(event => event.tags.some(spec(["name", "Other Lounge"]))))
.toBe(true)
await expect(page.getByText("only in other")).toHaveCount(0)
await expect(page.getByRole("link", {name: "Other Lounge"})).toHaveCount(0)
const strays = getTranscript(page.context()).filter(
({url, direction, message}) =>
direction === "toClient" &&
message[0] === RelayMessageType.Event &&
message[2].content === "only in space" &&
url !== space.url,
)
expect(strays).toEqual([])
})

26
e2e/specs/space.spec.ts Normal file
View file

@ -0,0 +1,26 @@
import {HOUR, MINUTE} from "@welshman/lib"
import {expect, roomPath, test, users} from "../harness"
test("renders a seeded space, its rooms and its messages", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.room("random", {name: "Random"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.message(user.bob, "general", "morning all", at(2, HOUR))
space.message(user.alice, "general", "morning!", at(90, MINUTE))
})
const {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "general"))
// The room the user belongs to and the one they don't are both advertised by the relay, so
// both appear in the space menu.
await expect(page.getByRole("link", {name: "General"})).toBeVisible()
await expect(page.getByRole("link", {name: "Random"})).toBeVisible()
await expect(page.getByText("morning all")).toBeVisible()
await expect(page.getByText("morning!")).toBeVisible()
})

View file

@ -1,29 +0,0 @@
import type {SignedEvent} from "@welshman/util"
import type {RelayMockConfig} from "../../src/lib/test/relayMocks"
import relay1Events from "./fixtures/relay1.json"
// Fake relay urls used by tests. Each maps to a json fixture under ./fixtures/ and an entry in
// EVENTS_BY_RELAY below. To add a relay: drop a `<name>.json` file in ./fixtures/, import it, add a
// url here, and wire it into EVENTS_BY_RELAY.
export const FIXTURE_RELAYS = {
relay1: "wss://relay1.test/",
} as const
// The events each fake relay serves. The json files hold static, pre-signed events: schnorr
// signatures are non-deterministic, so events are signed once and committed verbatim (they pass
// verifyEvent, which netContext.isEventValid enforces). Regenerate with @welshman/signer:
// await Nip01Signer.fromSecret(secret).sign(makeEvent(kind, {content, created_at}))
const EVENTS_BY_RELAY: Record<string, SignedEvent[]> = {
[FIXTURE_RELAYS.relay1]: relay1Events as SignedEvent[],
}
// Build a RelayMockConfig populating the given fixture relays (all of them when none are passed).
// Any relay not included returns nothing, keeping tests offline.
export const relayFixtures = (...urls: string[]): RelayMockConfig => {
const selected = urls.length > 0 ? urls : Object.keys(EVENTS_BY_RELAY)
return {
relays: Object.fromEntries(selected.map(url => [url, EVENTS_BY_RELAY[url] ?? []])),
}
}

View file

@ -1,29 +0,0 @@
[
{
"kind": 0,
"content": "{\"name\":\"Alice\"}",
"tags": [],
"created_at": 1700000000,
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"id": "9b3d138641b38364945b20d800268006c2cb7d974bb4b1d63a9f90f5ab974b90",
"sig": "de6b86274e7bcf6c02aa881ada1feee9e01ba320691711d4975916b3cd231ab43cf469a47c5db99503ed72707d5db85fede1ad3763c4fbd7c998d04f00eda6bc"
},
{
"kind": 1,
"content": "hello from the fixture relay",
"tags": [],
"created_at": 1700000000,
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"id": "b9874875bfa8d830c5c9ef3673104360cf21b94848a311febfaf52f0a652b1a9",
"sig": "85df94a2e9884ac3d280145492d5191cde2948d49a824c443a1f5d2143633eff1e1789fa7e8843b6efc3dd2dc0d7e33322edb628125d8e35de8ddca1d06ca970"
},
{
"kind": 1,
"content": "reply from bob",
"tags": [],
"created_at": 1700000001,
"pubkey": "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
"id": "171dcbdd63d474ba46da609e8b0104cbcf4801fbb581b6c343d9426280f9e1be",
"sig": "eecf26e616a6b70dc67c7eae16fc4fe159314647ba1d4581332257de7f070410aa9a9e41f571b9403ff145d16c9b32766846fce08516201263e25cf08c1ed8f1"
}
]

View file

@ -1,30 +0,0 @@
import type {Page} from "@playwright/test"
import type {RelayMockConfig} from "../../src/lib/test/relayMocks"
// Must match RELAY_MOCKS_KEY in src/lib/test/relayMocks.ts.
const RELAY_MOCKS_KEY = "__RELAY_MOCKS__"
// Hard safety net: intercept every real websocket so a test can never reach the network, even if
// some code path opens a socket directly (e.g. relay AUTH) rather than going through the adapter
// layer. We never call route.connectToServer(), so the socket connects to Playwright's in-process
// mock and simply receives nothing.
export const blockWebsockets = (page: Page) => page.routeWebSocket(/^wss?:\/\//, () => {})
// Inject the relay-mock config the app reads on startup. addInitScript runs before any page script
// on every navigation, so this must be called before page.goto().
export const injectRelayConfig = (page: Page, config: RelayMockConfig) =>
page.addInitScript(
([key, value]) => {
Object.assign(window, {[key]: value})
},
[RELAY_MOCKS_KEY, config] as const,
)
// Full network isolation plus optional fixtures, in one call. With no config, every relay returns
// nothing (requirement 1). Pass {relays: {url: events}} to populate specific relays (requirement 2).
export const setupRelayMocks = async (page: Page, config: RelayMockConfig = {}) => {
await blockWebsockets(page)
await injectRelayConfig(page, config)
}
export type {RelayMockConfig}

View file

@ -7,6 +7,8 @@
objects = {
/* Begin PBXBuildFile section */
052C05E5302BD03100DD0620 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 052C05E2302BD03100DD0620 /* ShareViewController.swift */; };
052C05D0302BD03100DD0620 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 052C05C6302BD03100DD0620 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
3478F0332E846FEB002431E0 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
@ -18,8 +20,36 @@
AC8D5382B9575A9124613C5D /* Pods_Flotilla_Chat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DA42C7E3CF3FFF7A17A3A729 /* Pods_Flotilla_Chat.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
052C05CE302BD03100DD0620 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
proxyType = 1;
remoteGlobalIDString = 052C05C5302BD03100DD0620;
remoteInfo = ShareExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
052C05D1302BD03100DD0620 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
052C05D0302BD03100DD0620 /* ShareExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
052C05E2302BD03100DD0620 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
052C05E3302BD03100DD0620 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
052C05E4302BD03100DD0620 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Flotilla Chat.entitlements"; sourceTree = "<group>"; };
052C05C6302BD03100DD0620 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Flotilla Chat.release.xcconfig"; path = "Pods/Target Support Files/Pods-Flotilla Chat/Pods-Flotilla Chat.release.xcconfig"; sourceTree = "<group>"; };
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
@ -38,6 +68,13 @@
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
052C05C3302BD03100DD0620 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@ -49,6 +86,16 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
052C05E1302BD03100DD0620 /* ShareExtension */ = {
isa = PBXGroup;
children = (
052C05E2302BD03100DD0620 /* ShareViewController.swift */,
052C05E3302BD03100DD0620 /* Info.plist */,
052C05E4302BD03100DD0620 /* ShareExtension.entitlements */,
);
path = ShareExtension;
sourceTree = "<group>";
};
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
isa = PBXGroup;
children = (
@ -63,6 +110,7 @@
3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */,
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */,
504EC3061FED79650016851F /* App */,
052C05E1302BD03100DD0620 /* ShareExtension */,
504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */,
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
@ -73,6 +121,7 @@
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* Flotilla Chat.app */,
052C05C6302BD03100DD0620 /* ShareExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@ -106,6 +155,25 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
052C05C5302BD03100DD0620 /* ShareExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = 052C05D5302BD03100DD0620 /* Build configuration list for PBXNativeTarget "ShareExtension" */;
buildPhases = (
052C05C2302BD03100DD0620 /* Sources */,
052C05C3302BD03100DD0620 /* Frameworks */,
052C05C4302BD03100DD0620 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = ShareExtension;
packageProductDependencies = (
);
productName = ShareExtension;
productReference = 052C05C6302BD03100DD0620 /* ShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
504EC3031FED79650016851F /* Flotilla Chat */ = {
isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Flotilla Chat" */;
@ -115,10 +183,12 @@
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
052C05D1302BD03100DD0620 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
052C05CF302BD03100DD0620 /* PBXTargetDependency */,
);
name = "Flotilla Chat";
productName = App;
@ -132,9 +202,12 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 920;
LastSwiftUpdateCheck = 2630;
LastUpgradeCheck = 2630;
TargetAttributes = {
052C05C5302BD03100DD0620 = {
CreatedOnToolsVersion = 26.3;
};
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
@ -156,11 +229,19 @@
projectRoot = "";
targets = (
504EC3031FED79650016851F /* Flotilla Chat */,
052C05C5302BD03100DD0620 /* ShareExtension */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
052C05C4302BD03100DD0620 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3021FED79650016851F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@ -214,6 +295,14 @@
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
052C05C2302BD03100DD0620 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
052C05E5302BD03100DD0620 /* ShareViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@ -224,6 +313,14 @@
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
052C05CF302BD03100DD0620 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 052C05C5302BD03100DD0620 /* ShareExtension */;
targetProxy = 052C05CE302BD03100DD0620 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
@ -244,6 +341,78 @@
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
052C05D2302BD03100DD0620 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.9.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
052C05D3302BD03100DD0620 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.9.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
504EC3141FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@ -424,6 +593,15 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
052C05D5302BD03100DD0620 /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
052C05D2302BD03100DD0620 /* Debug */,
052C05D3302BD03100DD0620 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "Flotilla Chat.app"
BlueprintName = "Flotilla Chat"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "Flotilla Chat.app"
BlueprintName = "Flotilla Chat"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "Flotilla Chat.app"
BlueprintName = "Flotilla Chat"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -16,6 +16,17 @@
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>social.flotilla</string>
<key>CFBundleURLSchemes</key>
<array>
<string>flotilla</string>
</array>
</dict>
</array>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>

View file

@ -8,5 +8,9 @@
<array>
<string>applinks:app.flotilla.social</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.social.flotilla</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Flotilla</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsText</key>
<true/>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.social.flotilla</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,159 @@
import ObjectiveC
import UIKit
import UniformTypeIdentifiers
// A share extension runs in its own process and can't reach the app or its web view, so the
// payload is handed over as a flotilla://share url, which the root layout picks up via
// appUrlOpen. Media is far too big for a url, so it gets copied into the shared app group
// container — the one directory both processes can read — and passed along by path.
class ShareViewController: UIViewController {
private let appGroup = "group.social.flotilla"
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let attachments = (extensionContext?.inputItems.first as? NSExtensionItem)?.attachments ?? []
// Browsers attach a page's title alongside its url, so take these in order of specificity.
// Photos offers a jpeg representation of a heic asset, which saves us re-encoding it later.
for type in [UTType.movie, UTType.jpeg, UTType.image, UTType.url, UTType.plainText] {
for provider in attachments where provider.hasItemConformingToTypeIdentifier(type.identifier) {
provider.loadItem(forTypeIdentifier: type.identifier) { value, _ in
if type == .url {
self.shareText((value as? URL)?.absoluteString)
} else if type == .plainText {
self.shareText(value as? String)
} else {
self.shareFile(value)
}
}
return
}
}
finish()
}
private func shareText(_ text: String?) {
guard let text = text else {
return finish()
}
open([URLQueryItem(name: "text", value: text)])
}
private func shareFile(_ value: NSSecureCoding?) {
let manager = FileManager.default
guard let container = manager.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
return finish()
}
// Nothing prunes the group container for us, and the app is done with the last share by the
// time a new one arrives. Emptying a directory of our own leaves the rest of the container,
// Library/ included, alone.
let directory = container.appendingPathComponent("shares")
try? manager.removeItem(at: directory)
do {
try manager.createDirectory(at: directory, withIntermediateDirectories: true)
if let source = value as? URL {
let destination = directory.appendingPathComponent(source.lastPathComponent)
try manager.copyItem(at: source, to: destination)
open(file: destination)
} else if let image = value as? UIImage, let data = image.jpegData(compressionQuality: 0.9) {
let destination = directory.appendingPathComponent("shared.jpg")
try data.write(to: destination)
open(file: destination)
} else {
finish()
}
} catch {
finish()
}
}
private func open(file: URL) {
open([
URLQueryItem(name: "path", value: file.path),
URLQueryItem(name: "name", value: file.lastPathComponent),
URLQueryItem(
name: "type",
value: UTType(filenameExtension: file.pathExtension)?.preferredMIMEType
),
])
}
private func open(_ queryItems: [URLQueryItem]) {
guard var components = URLComponents(string: "flotilla://share") else {
return finish()
}
components.queryItems = queryItems
guard let url = components.url else {
return finish()
}
DispatchQueue.main.async {
NSLog("Flotilla: opening %@", url.absoluteString)
// Which of these works varies by ios version and neither reports whether the app actually
// came forward, so try both. Opening the same url twice is harmless.
let foundApplication = self.openViaResponderChain(url)
self.extensionContext?.open(url) { opened in
NSLog(
"Flotilla: responder chain %@, extensionContext.open %@",
foundApplication ? "found UIApplication" : "found nothing",
opened ? "succeeded" : "failed"
)
// Completing the request tears down the extension, which can cancel an open springboard
// hasn't picked up yet
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.finish()
}
}
}
}
// UIApplication isn't reachable from an extension, so walk the responder chain to find it. Its
// open(_:options:completionHandler:) isn't visible to us at compile time either, and the
// openURL: that extensions used for years now force-returns false, so call it via the runtime.
private func openViaResponderChain(_ url: URL) -> Bool {
let selector = NSSelectorFromString("openURL:options:completionHandler:")
var responder: UIResponder? = self
while let current = responder {
if let application = current as? UIApplication,
let method = class_getInstanceMethod(type(of: application), selector)
{
typealias OpenURL = @convention(c) (UIApplication, Selector, NSURL, NSDictionary, AnyObject?)
-> Void
let open = unsafeBitCast(method_getImplementation(method), to: OpenURL.self)
let completion: @convention(block) (Bool) -> Void = { opened in
NSLog("Flotilla: UIApplication.open %@", opened ? "succeeded" : "failed")
}
open(application, selector, url as NSURL, NSDictionary(), completion as AnyObject)
return true
}
responder = current.next
}
return false
}
private func finish() {
extensionContext?.completeRequest(returningItems: nil)
}
}

View file

@ -10,27 +10,49 @@ if (execSync('git status --porcelain', { encoding: 'utf8' }).trim() && !force) {
process.exit(1)
}
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
// One entry per directory under ../welshman/packages.
const packages = [
'app',
'content',
'domain',
'editor',
'feeds',
'lib',
'net',
'signer',
'store',
'util',
]
pkg.pnpm = pkg.pnpm || {}
pkg.pnpm.overrides = pkg.pnpm.overrides || {}
pkg.pnpm.overrides["@welshman/app"] = "link:../welshman/packages/app"
pkg.pnpm.overrides["@welshman/content"] = "link:../welshman/packages/content"
pkg.pnpm.overrides["@welshman/editor"] = "link:../welshman/packages/editor"
pkg.pnpm.overrides["@welshman/feeds"] = "link:../welshman/packages/feeds"
pkg.pnpm.overrides["@welshman/lib"] = "link:../welshman/packages/lib"
pkg.pnpm.overrides["@welshman/net"] = "link:../welshman/packages/net"
pkg.pnpm.overrides["@welshman/router"] = "link:../welshman/packages/router"
pkg.pnpm.overrides["@welshman/signer"] = "link:../welshman/packages/signer"
pkg.pnpm.overrides["@welshman/store"] = "link:../welshman/packages/store"
pkg.pnpm.overrides["@welshman/util"] = "link:../welshman/packages/util"
// pkg.pnpm.overrides["nostr-editor"] = "link:../nostr-editor"
// pkg.pnpm.overrides["@pomade/core"] = "link:../pomade/packages/core"
// pkg.pnpm.overrides["nostr-signer-capacitor-plugin"] = "link:../nostr-signer-capacitor-plugin"
// pnpm 11 no longer reads the `pnpm` field in package.json, so overrides live in the workspace
// file. They are written for the install and reverted straight after: a committed link: override
// points every clone and every ci run at a checkout that only exists on one machine.
const file = 'pnpm-workspace.yaml'
const workspace = fs.readFileSync(file, 'utf8')
fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n')
if (!/^overrides:$/m.test(workspace)) {
console.error(`Error: ${file} has no \`overrides:\` block to link welshman into.`)
process.exit(1)
}
execSync('pnpm i', { stdio: 'inherit' })
const links = packages
.map(name => ` '@welshman/${name}': link:../welshman/packages/${name}`)
.join('\n')
execSync('git checkout -f pnpm-lock.yaml', { stdio: 'inherit' })
execSync('git checkout -f package.json', { stdio: 'inherit' })
// Other packages worth linking now and then:
// '@pomade/core': link:../pomade/packages/core
// nostr-editor: link:../nostr-editor
// nostr-signer-capacitor-plugin: link:../nostr-signer-capacitor-plugin
fs.writeFileSync(file, workspace.replace(/^overrides:$/m, `overrides:\n${links}`))
try {
execSync('pnpm i', { stdio: 'inherit' })
} finally {
// Restore what was read rather than checking the file out, so any unrelated edits survive.
fs.writeFileSync(file, workspace)
execSync('git checkout -f pnpm-lock.yaml', { stdio: 'inherit' })
}
console.log('\nWelshman is linked. pnpm will warn that node_modules is out of sync until you')
console.log('run `pnpm i` again, which unlinks it.')

View file

@ -10,7 +10,7 @@
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check src && eslint src",
"lint": "prettier --check src e2e playwright.config.ts && eslint src e2e",
"test": "playwright test",
"test:ui": "playwright test --ui",
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
@ -26,6 +26,7 @@
"@tailwindcss/postcss": "^4.2.2",
"@types/eslint": "^9.6.1",
"@types/node": "^25.9.1",
"@types/ws": "^8.18.1",
"autoprefixer": "^10.4.23",
"classnames": "^2.5.1",
"eslint": "^9.39.2",
@ -41,7 +42,8 @@
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1",
"vite": "^6.4.2"
"vite": "^6.4.2",
"ws": "^8.18.3"
},
"type": "module",
"dependencies": {
@ -69,20 +71,21 @@
"@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.27.2",
"@tiptap/pm": "^2.27.2",
"@tiptap/suggestion": "^2.27.2",
"@types/qrcode": "^1.5.6",
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^1.0.2",
"@vite-pwa/sveltekit": "^1.1.0",
"@welshman/app": "^0.9.0-pre2",
"@welshman/content": "^0.9.0-pre2",
"@welshman/domain": "^0.9.0-pre2",
"@welshman/editor": "^0.9.0-pre2",
"@welshman/feeds": "^0.9.0-pre2",
"@welshman/lib": "^0.9.0-pre2",
"@welshman/net": "^0.9.0-pre2",
"@welshman/signer": "^0.9.0-pre2",
"@welshman/store": "^0.9.0-pre2",
"@welshman/util": "^0.9.0-pre2",
"@welshman/app": "^0.9.0-pre5",
"@welshman/content": "^0.9.0-pre5",
"@welshman/domain": "^0.9.0-pre5",
"@welshman/editor": "^0.9.0-pre5",
"@welshman/feeds": "^0.9.0-pre5",
"@welshman/lib": "^0.9.0-pre5",
"@welshman/net": "^0.9.0-pre5",
"@welshman/signer": "^0.9.0-pre5",
"@welshman/store": "^0.9.0-pre5",
"@welshman/util": "^0.9.0-pre5",
"cheerio": "^1.2.0",
"compressorjs-next": "^1.1.2",
"dotenv": "^16.6.1",
@ -96,10 +99,14 @@
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
"nostr-tools": "^2.19.4",
"prettier-plugin-tailwindcss": "^0.7.2",
"prosemirror-model": "^1.25.7",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.12.0",
"prosemirror-view": "^1.41.8",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"throttle-debounce": "^5.0.2",
"tippy.js": "^6.3.7"
},
"packageManager": "pnpm@11.5.1"
"packageManager": "pnpm@11.20.0"
}

View file

@ -1,27 +1,47 @@
import {defineConfig, devices} from "@playwright/test"
// E2E tests live in ./e2e and run against the dev server (port 1847 from vite.config.ts).
// Run with `pnpm test:e2e` (after `pnpm exec playwright install` to fetch browsers).
// One engine per run rather than a project per engine: these specs exercise protocol behaviour —
// sockets, auth, sync — so a third copy of each buys much less than it costs. Run the whole suite
// under another engine with `E2E_BROWSER=webkit pnpm test`.
const deviceNames: Record<string, string> = {
chromium: "Desktop Chrome",
firefox: "Desktop Firefox",
webkit: "Desktop Safari",
}
const device = devices[deviceNames[process.env.E2E_BROWSER ?? "chromium"]]
export default defineConfig({
testDir: "e2e",
fullyParallel: true,
testDir: "e2e/specs",
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: "html",
// Fixture setup is charged to the test, and the harness fixture does a lot of it: the container
// is recreated and waits for the relay to answer, seeding authenticates a socket per
// identity, and the first test of a run also pays vite's on-demand compile of the whole
// sveltekit graph. Playwright's 30s default kills that partway through as a flake.
timeout: 120_000,
// An assertion carries its own deadline, which the test's does not bound, and the first one in a
// spec runs the moment the shell mounts: it waits out the socket opening, the nip-42 exchange,
// the three negentropy pulls a space is synced with and a render. Playwright's 5s default is a
// coin flip on a loaded machine.
expect: {timeout: 30_000},
// One container on a fixed port, so the suite can't be sharded.
workers: 1,
// list alongside html: the html report is where a failure is investigated, but a run that skips
// has to say so on the terminal, where the person who started it is looking.
reporter: [["list"], ["html"]],
use: {
baseURL: "http://localhost:1847",
trace: "on-first-retry",
},
// Boots the SvelteKit dev server before the suite and reuses one if already running locally.
// Boots the SvelteKit dev server before the suite and reuses one if already running locally. The
// app resolves its VITE_ values against a key the harness injects per browser context, so any
// dev server will do.
webServer: {
command: "pnpm dev",
url: "http://localhost:1847",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [
{name: "chromium", use: {...devices["Desktop Chrome"]}},
{name: "firefox", use: {...devices["Desktop Firefox"]}},
{name: "webkit", use: {...devices["Desktop Safari"]}},
],
projects: [{name: "zooid", use: {...device}}],
})

Binary file not shown.

View file

@ -1,8 +1,12 @@
# Allow link_deps to change stuff in the working tree
verifyDepsBeforeRun: warn
allowBuilds:
nostr-signer-capacitor-plugin: true
cbor-extract: false
esbuild: false
sharp: true
nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/436adec9ed1e71569748cd56aa697f361e7a8d47: true
minimumReleaseAgeExclude:
- '@pomade/core'
- '@welshman/app'
@ -16,14 +20,4 @@ minimumReleaseAgeExclude:
- '@welshman/store'
- '@welshman/util'
overrides:
'@welshman/app': link:../welshman/packages/app
'@welshman/content': link:../welshman/packages/content
'@welshman/domain': link:../welshman/packages/domain
'@welshman/editor': link:../welshman/packages/editor
'@welshman/feeds': link:../welshman/packages/feeds
'@welshman/lib': link:../welshman/packages/lib
'@welshman/net': link:../welshman/packages/net
'@welshman/signer': link:../welshman/packages/signer
'@welshman/store': link:../welshman/packages/store
'@welshman/util': link:../welshman/packages/util
sharp: ^0.35.3

View file

@ -2,7 +2,6 @@
* Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
*/
import {MediaQuery} from "svelte/reactivity"
import {
DisconnectReason,
LocalParticipant,
@ -15,6 +14,7 @@ import {
supportsAudioOutputSelection,
type AudioCaptureOptions,
} from "livekit-client"
import {App} from "@capacitor/app"
import {derived, get, writable} from "svelte/store"
import {first, not, nthEq, reject, uniqBy} from "@welshman/lib"
import {makeHttpAuth, makeHttpAuthHeader, sortEventsDesc, tagSpec, tagValues} from "@welshman/util"
@ -31,8 +31,217 @@ export const LIVEKIT_PARTICIPANTS = 39004
export {supportsAudioOutputSelection}
/**
* Aspect ratio constraints for tiles. The lower bound is dynamic
* (1:1 on landscape, 3:4 on portrait); the upper bound is 16:9.
*/
const TILE_ASPECT_PORTRAIT = 3 / 4
const TILE_ASPECT_LANDSCAPE = 16 / 9
const TILE_GAP = 8
/**
* Minimum pixel height for a tile before we allow the grid to
* overflow (scroll) instead of forcing tiles into portrait mode.
* Calibrated so ~6-8 tiles on a standard portrait phone fit
* without scrolling; beyond that, scroll is acceptable.
*/
const MIN_TILE_HEIGHT = 120
/**
* A single row in an adaptive tile grid.
*/
export type TileRow = {
columnCount: number
tileWidth: number
tileHeight: number
/** Total width of this row including gaps — for the template's max-width */
rowWidth: number
aspectRatio: number
}
/**
* Adaptive tile grid: all tiles share the same dimensions. Full rows
* fill the container width; partial rows are centered.
*
* Example (3 tiles, 2 columns):
* row 0: [ square ] [ square ]
* row 1: [ square ] (centered, same size)
*/
export type AdaptiveTileGrid = {
rows: TileRow[]
totalWidth: number
totalHeight: number
}
/**
* Score for comparing candidate layouts. Lower is better.
* Uses named fields instead of opaque array "keys" (per review feedback).
*/
type LayoutScore = {
/** Penalty for vertical overflow: 0 if none, else huge */
verticalOverflowPenalty: number
/** Penalty for horizontal overflow: 0 if none, else huge */
horizontalOverflowPenalty: number
/** Penalty when tiles fall below MIN_TILE_HEIGHT even while fitting */
minTileHeightPenalty: number
/** Negative tile area (we want to maximize area, so negate) */
negativeArea: number
/** Leftover whitespace (underflow) in px */
whitespace: number
/** Average absolute deviation from 16:9 across all tiles */
aspectDeviation: number
}
const compareScores = (a: LayoutScore, b: LayoutScore): number => {
const fields: (keyof LayoutScore)[] = [
"verticalOverflowPenalty",
"horizontalOverflowPenalty",
"minTileHeightPenalty",
"negativeArea",
"whitespace",
"aspectDeviation",
]
for (const field of fields) {
if (a[field] !== b[field]) return a[field] - b[field]
}
return 0
}
/**
* Compute the largest tile size that fits within a given width and height,
* bounded by [minAspect, TILE_ASPECT_LANDSCAPE]. The tile is shrunk to fit
* whichever dimension is more constraining, so it never overflows.
*/
const fitTile = (availWidth: number, availHeight: number, minAspect: number) => {
const fillAspect = availWidth / availHeight
const aspectRatio = Math.max(minAspect, Math.min(TILE_ASPECT_LANDSCAPE, fillAspect))
if (fillAspect >= aspectRatio) {
const tileWidth = availHeight * aspectRatio
return {tileWidth, tileHeight: availHeight, aspectRatio}
}
const tileHeight = availWidth / aspectRatio
return {tileWidth: availWidth, tileHeight, aspectRatio}
}
/**
* Build a candidate grid for a given column count. All tiles share the
* same dimensions; the partial last row (if any) is centered with the
* same tile size as full rows.
*/
const buildCandidate = (
tileCount: number,
columnCount: number,
containerWidth: number,
containerHeight: number,
minAspect: number,
): AdaptiveTileGrid | undefined => {
const gap = TILE_GAP
const fullRowCount = Math.floor(tileCount / columnCount)
const remainder = tileCount % columnCount
const totalRowCount = fullRowCount + (remainder > 0 ? 1 : 0)
const availHeight = (containerHeight - (totalRowCount - 1) * gap) / totalRowCount
const availWidth = (containerWidth - (columnCount - 1) * gap) / columnCount
if (availWidth <= 0 || availHeight <= 0) return undefined
const {tileWidth, tileHeight, aspectRatio} = fitTile(availWidth, availHeight, minAspect)
const rows: TileRow[] = []
for (let r = 0; r < fullRowCount; r++) {
rows.push({
columnCount,
tileWidth,
tileHeight,
rowWidth: columnCount * tileWidth + (columnCount - 1) * gap,
aspectRatio,
})
}
if (remainder > 0) {
rows.push({
columnCount: remainder,
tileWidth,
tileHeight,
rowWidth: remainder * tileWidth + (remainder - 1) * gap,
aspectRatio,
})
}
const totalHeight = totalRowCount * tileHeight + (totalRowCount - 1) * gap
const totalWidth = columnCount * tileWidth + (columnCount - 1) * gap
return {rows, totalWidth, totalHeight}
}
/**
* Compute an adaptive tile grid. All tiles share the same dimensions;
* partial rows are centered. Tiles flex between a minimum aspect
* (1:1 on landscape, 3:4 on portrait) and 16:9, capped so they never
* overflow the container.
*
* Only allows overflow (scroll) when tiles would be below
* MIN_TILE_HEIGHT (~6-8 tiles on portrait phone).
*
* Prioritises:
* 1. No overflow (tiles shrink to fit within aspect bounds)
* 2. Minimal whitespace
* 3. Aspect ratios close to 16:9
* 4. Larger tiles
*/
export const computeAdaptiveGrid = (
tileCount: number,
containerWidth: number,
containerHeight: number,
): AdaptiveTileGrid | undefined => {
if (tileCount <= 0 || containerWidth <= 0 || containerHeight <= 0) return undefined
const minAspect = containerWidth / containerHeight >= 4 / 3 ? 1 : TILE_ASPECT_PORTRAIT
let best: AdaptiveTileGrid | undefined
let bestScore: LayoutScore | undefined
for (let columnCount = 1; columnCount <= tileCount; columnCount++) {
const candidate = buildCandidate(
tileCount,
columnCount,
containerWidth,
containerHeight,
minAspect,
)
if (!candidate) continue
const {tileWidth, tileHeight, aspectRatio} = candidate.rows[0]
const verticalOverflow = Math.max(0, candidate.totalHeight - containerHeight)
const horizontalOverflow = Math.max(0, candidate.totalWidth - containerWidth)
const whitespace =
Math.max(0, containerWidth - candidate.totalWidth) +
Math.max(0, containerHeight - candidate.totalHeight)
const totalArea = tileWidth * tileHeight * tileCount
const overflowPenaltyMultiplier = tileHeight >= MIN_TILE_HEIGHT ? 1_000_000 : 1
const verticalOverflowPenalty = verticalOverflow * overflowPenaltyMultiplier
const horizontalOverflowPenalty = horizontalOverflow * overflowPenaltyMultiplier
const minTileHeightPenalty =
tileHeight < MIN_TILE_HEIGHT ? (MIN_TILE_HEIGHT - tileHeight) * 1000 : 0
const score: LayoutScore = {
verticalOverflowPenalty,
horizontalOverflowPenalty,
minTileHeightPenalty,
negativeArea: -totalArea,
whitespace,
aspectDeviation: Math.abs(aspectRatio - TILE_ASPECT_LANDSCAPE),
}
if (!bestScore || compareScores(score, bestScore) < 0) {
bestScore = score
best = candidate
}
}
return best
}
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
export type CallSession = {
@ -65,11 +274,6 @@ export enum VideoCallLayout {
Split = "split",
}
export enum ViewportSize {
Desktop = "desktop",
Mobile = "mobile",
}
export enum DeviceKind {
AudioInput = "audioinput",
AudioOutput = "audiooutput",
@ -109,24 +313,10 @@ export const triggerVideoTrackRevision = () => {
videoTrackRevision.update(n => n + 1)
}
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
export const videoCallViewportSync = {
previousLayout: undefined as ViewportSize | undefined,
}
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
export const videoTileCount = derived(
[currentCallSession, callState, videoTrackRevision, participantMediaState],
([$session, $state]) => {
if ($state !== CallState.Connected || !$session) return 0
return countLiveVisualFeeds($session)
},
)
export const joinVoiceRoom = async (
url: string,
h: string,
@ -284,7 +474,12 @@ export const leaveVoiceRoom = async () => {
// Only reset shared UI state if this session is still current. A slow leave
// that was superseded by a new join (bounded by a timeout in joinVoiceRoom)
// must not clobber the freshly-joined session when it finally completes.
if (get(currentCallSession) === session) {
//
// Compare the LiveKit room rather than the session object: turning off the
// screen share above emits LocalTrackUnpublished, whose handler replaces the
// store value with a new object for the same call. An identity check would
// fail there and leave the UI stuck in a connected state.
if (get(currentCallSession)?.livekit === session.livekit) {
callState.set(CallState.Disconnected)
callMicMuted.set(true)
currentCallSession.set(undefined)
@ -374,8 +569,26 @@ export const switchCallActiveDevice = async (
}
}
/**
* On mobile, locking the screen can suspend microphone capture without ever
* ending the underlying MediaStreamTrack: the local participant still looks
* connected and unmuted, but publishes silence until the track is manually
* reacquired. LiveKit only guards against this for tracks attached to a DOM
* element (i.e. video), so the mic needs the same treatment on foreground
* return. `App.addListener("appStateChange", ...)` fires from Capacitor's web
* fallback too, so this covers both native and browser tabs.
*/
export const syncCallAudioResume = () => {
const listener = App.addListener("appStateChange", ({isActive}) => {
if (isActive) void reacquireMicrophoneIfNeeded()
})
return () => {
listener.then(l => l.remove())
}
}
export const resetVideoCallLayout = () => {
videoCallViewportSync.previousLayout = undefined
videoCallLayout.set(VideoCallLayout.Chat)
}
@ -458,27 +671,6 @@ const teardownRoom = (livekit: LiveKitRoom) => {
livekit.disconnect()
}
const countLiveVisualFeeds = (session: CallSession): number => {
const livekit = session.livekit
let n = 0
const lp = livekit.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 livekit.remoteParticipants.values()) {
for (const source of VISUAL_SOURCES) {
const pub = rp.getTrackPublication(source)
if (pub?.isSubscribed && pub.track && !pub.isMuted) n += 1
}
}
return n
}
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
muted: !participant.isMicrophoneEnabled,
cameraOn: participant.isCameraEnabled,
@ -582,6 +774,33 @@ const setUpMicrophone = async (
return muted
}
const reacquireMicrophoneIfNeeded = async () => {
const session = get(currentCallSession)
if (!session || get(callMicMuted)) return
const track = session.livekit.localParticipant.getTrackPublication(
Track.Source.Microphone,
)?.audioTrack
if (!track || track.isMuted || track.isUserProvided) return
// Mirrors LiveKit's own (mobile-only, video-track-only) reacquisition
// check: a capture device that died silently still reports readyState
// "live", but the browser flips `muted`/`enabled` on the underlying
// MediaStreamTrack. Checking for actual silence instead would false-
// positive any time the user simply isn't talking.
const {mediaStreamTrack} = track
const needsReacquisition =
mediaStreamTrack.readyState !== "live" || mediaStreamTrack.muted || !mediaStreamTrack.enabled
if (!needsReacquisition) return
try {
await track.restartTrack()
} catch {
// Best-effort: the user can still recover via mute/unmute or by
// rejoining if reacquiring the mic fails here.
}
}
const clearReconnectSchedule = () => {
if (reconnectTimeout !== undefined) {
clearTimeout(reconnectTimeout)

View file

@ -3,6 +3,7 @@
import Dialog from "@lib/components/Dialog.svelte"
import Landing from "@app/components/Landing.svelte"
import Toast from "@app/components/Toast.svelte"
import CallBanner from "@app/components/CallBanner.svelte"
import PrimaryNav from "@app/components/PrimaryNav.svelte"
import {app} from "@app/core"
import {modal} from "@app/modal"
@ -14,13 +15,16 @@
const {children}: Props = $props()
</script>
<div class="flex h-screen overflow-hidden">
{#if $app.user?.pubkey}
<PrimaryNav>
{@render children?.()}
</PrimaryNav>
{:else if !$modal}
<Dialog noEscape children={{component: Landing, props: {}}} />
{/if}
<div class="flex h-screen flex-col overflow-hidden">
<div class="flex min-h-0 flex-1 overflow-hidden">
{#if $app.user?.pubkey}
<PrimaryNav>
{@render children?.()}
</PrimaryNav>
{:else if !$modal}
<Dialog noEscape children={{component: Landing, props: {}}} />
{/if}
</div>
<CallBanner />
</div>
<Toast />

View file

@ -1,13 +1,23 @@
<script lang="ts">
import {onMount} from "svelte"
import type {PinboardReader} from "@welshman/domain"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import {errorMessage} from "@lib/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import {shareEventToChat} from "@app/share"
import PinAdd from "@app/components/PinAdd.svelte"
import PinboardEdit from "@app/components/PinboardEdit.svelte"
import {deletes} from "@app/core"
import {deriveUserIsSpaceAdmin} from "@app/management"
import {shareEvent} from "@app/share"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
type Props = {
url: string
@ -17,9 +27,38 @@
const {url, board, onClick}: Props = $props()
const canManage = deriveUserIsSpaceAdmin(url)
const showInfo = () => pushModal(EventInfo, {url, event: board.event})
const share = () => shareEventToChat(url, "Shelf", board.event)
const share = () => shareEvent(url, "Shelf", board.event)
const addLink = () => pushModal(PinAdd, {url, address: board.address()})
const edit = () => pushModal(PinboardEdit, {url, board})
const deleteBoard = async () => {
try {
const command = await $deletes.deleteEvent(board.event)
const error = await command.publishAsRelay(url).then(thunk => thunk.waitForError())
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Shelf deleted!"})
}
} catch (e) {
console.error(e)
pushToast({theme: "error", message: errorMessage(e)})
}
}
const confirmDelete = () =>
pushModal(Confirm, {
title: "Delete Shelf",
message: `Delete "${board.title() || "this shelf"}"?`,
confirm: deleteBoard,
})
let ul: Element
@ -41,4 +80,24 @@
Share to chat
</Button>
</li>
{#if $canManage}
<li>
<Button onclick={addLink}>
<Icon icon={AddCircle} />
Add link
</Button>
</li>
<li>
<Button onclick={edit}>
<Icon icon={Pen} />
Edit shelf
</Button>
</li>
<li>
<Button class="text-error" onclick={confirmDelete}>
<Icon icon={TrashBin} />
Delete shelf
</Button>
</li>
{/if}
</ul>

View file

@ -0,0 +1,110 @@
<script lang="ts">
import {fly} from "svelte/transition"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import cx from "classnames"
import {displayRelayUrl} from "@welshman/util"
import Microphone from "@assets/icons/microphone.svg?dataurl"
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
import EndCall from "@assets/icons/end-call-rounded.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {decodeRelay} from "@app/relays"
import {displayRoom} from "@app/rooms"
import {makeRoomPath} from "@app/routes"
import {
CallState,
callState,
callTargetRoom,
callMicMuted,
cancelJoinVoiceRoom,
leaveVoiceRoom,
toggleMute,
} from "@app/call"
const {relay, h} = $derived($page.params)
const routeUrl = $derived(relay ? decodeRelay(relay) : undefined)
// The call's own room page already shows full controls (CallControlBar), so
// the banner would just be redundant clutter there.
const isViewingCurrentVoiceRoom = $derived(
$callTargetRoom !== undefined &&
routeUrl !== undefined &&
typeof h === "string" &&
$callTargetRoom.url === routeUrl &&
$callTargetRoom.h === h,
)
const visible = $derived(
($callState === CallState.Joining || $callState === CallState.Connected) &&
$callTargetRoom !== undefined &&
!isViewingCurrentVoiceRoom,
)
const roomName = $derived(
$callTargetRoom ? displayRoom($callTargetRoom.url, $callTargetRoom.h) : "",
)
const spaceName = $derived($callTargetRoom ? displayRelayUrl($callTargetRoom.url) : "")
const goToRoom = () => {
if (!$callTargetRoom) return
void goto(makeRoomPath($callTargetRoom.url, $callTargetRoom.h))
}
// leaveVoiceRoom no-ops during Joining (no session exists yet to leave) — cancel
// the in-flight join instead, otherwise this button silently does nothing.
const endCall = () => {
if ($callState === CallState.Joining) {
cancelJoinVoiceRoom()
} else {
leaveVoiceRoom()
}
}
</script>
{#if visible}
<div
in:fly={{y: 60, duration: 250}}
out:fly={{y: 60, duration: 200}}
class="relative flex shrink-0 items-center gap-3 border-t border-line bg-surface py-2 pl-4 pr-2 mb-[calc(var(--saib)+3.5rem)] md:mb-[var(--saib)] md:pl-6">
<Button
class="flex min-w-0 flex-1 items-center gap-2 text-left"
onclick={goToRoom}
aria-label="Return to call in {roomName}">
<span
class={cx("h-2 w-2 shrink-0 rounded-full", {
"animate-pulse bg-warning": $callState === CallState.Joining,
"bg-success": $callState === CallState.Connected,
})}
aria-hidden="true"></span>
<span class="min-w-0 truncate text-sm">
<span class="font-semibold">
{$callState === CallState.Joining ? "Joining call…" : "Voice connected"}
</span>
<span class="text-content-muted">· {roomName} / {spaceName}</span>
</span>
</Button>
<div class="flex shrink-0 items-center gap-2">
{#if $callState === CallState.Connected}
<Button
aria-label={$callMicMuted ? "Unmute microphone" : "Mute microphone"}
aria-pressed={!$callMicMuted}
class={cx(
"button button-circle button-sm",
$callMicMuted ? "button-neutral" : "button-primary",
)}
onclick={toggleMute}>
<Icon icon={$callMicMuted ? MicrophoneOff : Microphone} size={4} />
</Button>
{/if}
<Button
aria-label={$callState === CallState.Joining
? "Cancel joining voice room"
: "Leave voice room"}
class="button button-circle button-sm button-error"
onclick={endCall}>
<Icon icon={EndCall} size={4} />
</Button>
</div>
</div>
{/if}

View file

@ -0,0 +1,199 @@
<script lang="ts">
import {fade} from "svelte/transition"
import cx from "classnames"
import {Capacitor} from "@capacitor/core"
import Microphone from "@assets/icons/microphone.svg?dataurl"
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
import VideocameraOff from "@assets/icons/videocamera-off.svg?dataurl"
import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl"
import Monitor from "@assets/icons/monitor.svg?dataurl"
import EndCall from "@assets/icons/end-call-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 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 {rooms} from "@app/core"
import {RoomType, getRoomType} from "@app/rooms"
import {pushModal} from "@app/modal"
import {notifications} from "@app/notifications"
import {makeRoomPath} from "@app/routes"
import {
VideoCallLayout,
toggleCamera,
toggleScreenShare,
videoCallLayout,
CallState,
currentCallSession,
callTargetRoom,
callMicMuted,
callState,
cancelJoinVoiceRoom,
leaveVoiceRoom,
toggleMute,
} from "@app/call"
type Props = {
url: string
h: string
// suppress the full in-call control row on desktop — used where a wider,
// unobstructed copy of it already floats over the video panel
hideConnectedOnDesktop?: boolean
}
const {url, h, hideConnectedOnDesktop = false}: Props = $props()
const roomStore = $derived($rooms.forRoom(url, h))
const room = $derived($roomStore)
const isVoiceRoom = $derived(room !== undefined && getRoomType(room) === RoomType.Voice)
const isTargetingThisRoom = $derived(
$callTargetRoom !== undefined && $callTargetRoom.url === url && $callTargetRoom.h === h,
)
const joiningHere = $derived($callState === CallState.Joining && isTargetingThisRoom)
const connectedHere = $derived($callState === CallState.Connected && isTargetingThisRoom)
// callTargetRoom isn't cleared on cancel/leave, so it can still point at this room
// after the call has ended — check actual state, not just "is this the last target".
const showJoin = $derived(isVoiceRoom && !joiningHere && !connectedHere)
const openJoinDialog = () => pushModal(VoiceRoomJoinDialog, {url, h})
const openCallSettings = () => pushModal(VoiceCallAudioSettingsDialog)
const roomPath = $derived(makeRoomPath(url, h))
const chatUnread = $derived($notifications.has(roomPath))
const isChatPanelActive = $derived($videoCallLayout === VideoCallLayout.Split)
const onChatToggle = () => {
videoCallLayout.update(p =>
p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split,
)
}
</script>
{#if joiningHere || connectedHere || showJoin}
<!-- every state shares this grid cell so switching between them cross-fades in
place instead of pushing/jumping past each other as flex siblings, and the
grid auto-sizes to whichever state is actually present (nothing reserves
layout space when e.g. the connected bar is display:none on desktop). -->
<div class="grid items-center justify-center">
{#if joiningHere}
<div
in:fade={{duration: 160}}
out:fade={{duration: 120}}
class="pointer-events-auto col-start-1 row-start-1 flex h-10 items-center gap-1.5 rounded-full border border-line bg-surface/95 px-1.5 shadow-xl backdrop-blur-md">
<span
data-tip="Joining call…"
role="status"
aria-label="Joining call"
class="spinner spinner-sm mx-1.5 shrink-0"></span>
<Button
data-tip="Cancel"
aria-label="Cancel joining voice room"
class="button button-circle button-xs button-neutral"
onclick={cancelJoinVoiceRoom}>
<Icon icon={CloseCircle} size={3} />
</Button>
</div>
{/if}
{#if connectedHere}
<div
in:fade={{duration: 160}}
out:fade={{duration: 120}}
class={cx(
"pointer-events-auto col-start-1 row-start-1 flex items-center gap-1.5 rounded-full border border-line bg-surface/95 p-1.5 shadow-xl backdrop-blur-md md:gap-2 md:p-2",
hideConnectedOnDesktop && "md:hidden",
)}>
<Button
data-tip={$callMicMuted ? "Unmute" : "Mute"}
aria-label={$callMicMuted ? "Unmute microphone" : "Mute microphone"}
aria-pressed={!$callMicMuted}
class={cx(
"button button-circle max-md:h-9 max-md:w-9",
$callMicMuted ? "button-neutral" : "button-primary",
)}
onclick={toggleMute}>
<Icon icon={$callMicMuted ? MicrophoneOff : Microphone} size={4.5} />
</Button>
{#if $currentCallSession}
<Button
data-tip={$currentCallSession.cameraOn ? "Stop camera" : "Start camera"}
aria-label={$currentCallSession.cameraOn ? "Turn off camera" : "Turn on camera"}
aria-pressed={$currentCallSession.cameraOn}
class={cx(
"button button-circle max-md:h-9 max-md:w-9",
$currentCallSession.cameraOn ? "button-primary" : "button-neutral",
)}
onclick={toggleCamera}>
<Icon
icon={$currentCallSession.cameraOn ? VideocameraRecord : VideocameraOff}
size={4.5} />
</Button>
{#if !Capacitor.isNativePlatform()}
<Button
data-tip={$currentCallSession.screenShareOn ? "Stop sharing" : "Share screen"}
aria-label={$currentCallSession.screenShareOn
? "Stop sharing screen"
: "Share screen"}
aria-pressed={$currentCallSession.screenShareOn}
class={cx(
"button button-circle max-md:h-9 max-md:w-9",
$currentCallSession.screenShareOn ? "button-primary" : "button-neutral",
)}
onclick={toggleScreenShare}>
<Icon icon={Monitor} size={4.5} />
</Button>
{/if}
{/if}
<Button
data-tip="Call settings"
aria-label="Call settings"
class="button button-circle button-neutral max-md:h-9 max-md:w-9"
onclick={openCallSettings}>
<Icon icon={Settings} size={4.5} />
</Button>
<Button
data-tip="Toggle chat"
aria-label="Toggle chat panel"
aria-pressed={isChatPanelActive}
class={cx(
"button button-circle relative max-md:h-9 max-md:w-9",
isChatPanelActive ? "button-primary" : "button-neutral",
)}
onclick={onChatToggle}>
<Icon icon={ChatRound} size={4.5} />
{#if chatUnread}
<span
class="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-primary ring-2 ring-surface"
aria-hidden="true"></span>
{/if}
</Button>
<div class="mx-0.5 h-6 w-px shrink-0 bg-line" aria-hidden="true"></div>
<Button
data-tip="Leave call"
aria-label="Leave voice room"
class="button button-circle button-error max-md:h-9 max-md:w-9"
onclick={leaveVoiceRoom}>
<Icon icon={EndCall} size={5} />
</Button>
</div>
{/if}
{#if showJoin}
<div
in:fade={{duration: 160}}
out:fade={{duration: 120}}
class="pointer-events-auto col-start-1 row-start-1">
<Button
data-tip="Join call"
aria-label="Join voice room"
class="button button-primary button-circle shadow-xl"
onclick={openJoinDialog}>
<Icon icon={PhoneCallingRounded} size={4.5} />
</Button>
</div>
{/if}
</div>
{/if}

View file

@ -17,6 +17,7 @@
enumerate,
formatTimestampAsDate,
} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent, EventTemplate, EventContent} from "@welshman/util"
import {makeEvent, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE} from "@welshman/util"
import {parse, isLink} from "@welshman/content"
@ -26,6 +27,7 @@
import Icon from "@lib/components/Icon.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte"
import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
@ -43,8 +45,9 @@
import {userSettingsValues} from "@app/settings"
import {deriveChat, makeChatId} from "@app/chats"
import {pushModal} from "@app/modal"
import {DraftKey} from "@app/drafts"
import {DraftKey, type Draft} from "@app/drafts"
import {prependParent} from "@app/rooms"
import {pendingShare, type Share} from "@app/share"
import {pushToast} from "@app/toast"
type Props = {
@ -56,7 +59,7 @@
const chatId = makeChatId(pubkeys)
const chat = deriveChat(chatId)
const draftKey = new DraftKey<{content?: string | object}>(`dm:${chatId}`)
const draftKey = new DraftKey<Draft>(`dm:${chatId}`)
const others = remove($user.pubkey, pubkeys)
const messagingRelayLists = $app.use(MessagingRelayLists).index.$
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayLists.has(pk)))
@ -182,6 +185,26 @@
let compose: ChatCompose | undefined = $state()
let parent: TrustedEvent | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
let share: Maybe<Share> = $state()
// Claim the share once we're on screen. Sharing into the conversation you're already looking at
// doesn't re-create this component, so this can't be read once on mount.
$effect(() => {
if ($pendingShare) {
share = $pendingShare
pendingShare.set(undefined)
}
})
const initialValues = $derived.by((): Share | undefined => {
if (eventToEdit) {
return {type: "text", value: eventToEdit.content}
}
if (share) {
return share
}
})
const elements = $derived.by(() => {
const elements = []
@ -263,67 +286,69 @@
</div>
</PageBar>
<div class="flex flex-col-reverse gap-4 py-2 bg-surface scroll-container h-screen overflow-y-auto">
{#if missingRelayLists.length > 0}
<div class="py-12">
<div class="card flex flex-col gap-2 m-auto max-w-md items-center text-center">
<p class="flex gap-2 text-lg text-error">
<Icon icon={Danger} />
Direct messages are not enabled
</p>
<p>
Ask
{#each missingRelayLists as pubkey (pubkey)}
<ProfileLink {pubkey} />
{/each}
to enable direct messaging by opening this conversation in their $app.
</p>
<div class="room flex min-h-0 min-w-0 flex-1 flex-col">
<PageContent class="flex flex-col-reverse gap-4 py-2 bg-surface !mb-0">
{#if missingRelayLists.length > 0}
<div class="py-12">
<div class="card flex flex-col gap-2 m-auto max-w-md items-center text-center">
<p class="flex gap-2 text-lg text-error">
<Icon icon={Danger} />
Direct messages are not enabled
</p>
<p>
Ask
{#each missingRelayLists as pubkey (pubkey)}
<ProfileLink {pubkey} />
{/each}
to enable direct messaging by opening this conversation in their $app.
</p>
</div>
</div>
</div>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "date"}
<Divider>{value}</Divider>
{:else}
<ChatMessage
event={$state.snapshot(value as TrustedEvent)}
{pubkeys}
{showPubkey}
{replyTo}
canEdit={canEditEvent}
onEdit={onEditEvent} />
{/if}
{/each}
<p class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
<Spinner {loading}>
{#if loading}
Looking for messages...
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "date"}
<Divider>{value}</Divider>
{:else}
End of message history
<ChatMessage
event={$state.snapshot(value as TrustedEvent)}
{pubkeys}
{showPubkey}
{replyTo}
canEdit={canEditEvent}
onEdit={onEditEvent} />
{/if}
</Spinner>
{@render info?.()}
</p>
<div class="h-screen"></div>
</div>
{/each}
<p
class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
<Spinner {loading}>
{#if loading}
Looking for messages...
{:else}
End of message history
{/if}
</Spinner>
{@render info?.()}
</p>
</PageContent>
<div class="room__compose bg-surface">
<div>
{#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if eventToEdit}
<ChatComposeEdit clear={clearEventToEdit} />
{/if}
<div class="room__compose bg-surface">
<div>
{#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if eventToEdit}
<ChatComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key initialValues}
<ChatCompose
bind:this={compose}
{onSubmit}
{onEscape}
{onEditPrevious}
{initialValues}
draftKey={eventToEdit ? undefined : draftKey}
disabled={Boolean(missingRelayLists.length)} />
{/key}
</div>
{#key eventToEdit}
<ChatCompose
bind:this={compose}
{onSubmit}
{onEscape}
{onEditPrevious}
initialValues={eventToEdit}
draftKey={eventToEdit ? undefined : draftKey}
disabled={Boolean(missingRelayLists.length)} />
{/key}
</div>

View file

@ -11,22 +11,19 @@
import Spinner from "@lib/components/Spinner.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {type DraftKey} from "@app/drafts"
type Values = {
content?: string | object
}
import {type DraftKey, type Draft} from "@app/drafts"
import type {Share} from "@app/share"
type Props = {
disabled?: boolean
draftKey?: DraftKey<Values>
draftKey?: DraftKey<Draft>
onEscape?: () => void
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void
initialValues?: Values
initialValues?: Share
}
let {
const {
initialValues,
disabled = false,
draftKey,
@ -35,10 +32,6 @@
onSubmit,
}: Props = $props()
if (!initialValues) {
initialValues = draftKey?.get()
}
const autofocus = !isMobile && !disabled
const uploading = writable(false)
@ -81,7 +74,9 @@
ed.chain().clearContent().run()
}
let content = $state(initialValues?.content ?? "")
let content = $state(
initialValues?.type === "text" ? initialValues.value : (draftKey?.get()?.content ?? ""),
)
const onChange = (json: object) => {
content = json
@ -103,6 +98,12 @@
onMount(async () => {
const ed = await editor
ed.view.dom.addEventListener("keydown", handleKeyDown)
if (initialValues?.type === "file") {
ed.chain()
.addFile(initialValues.value, ed.state.selection.from + 1)
.run()
}
})
onDestroy(async () => {

View file

@ -15,7 +15,7 @@
import Report from "@app/components/Report.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import PinboardSelect from "@app/components/PinboardSelect.svelte"
import {shareEventToChat} from "@app/share"
import {shareEvent} from "@app/share"
import {deriveUserIsSpaceAdmin} from "@app/management"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
@ -40,7 +40,7 @@
const addToLibrary = () => pushModal(PinboardSelect, {url, event})
const share = () => shareEventToChat(url, noun, event)
const share = () => shareEvent(url, noun, event)
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})

View file

@ -9,7 +9,13 @@
import CardButton from "@lib/components/CardButton.svelte"
import LogIn from "@app/components/LogIn.svelte"
import SignUp from "@app/components/SignUp.svelte"
import {PLATFORM_TERMS, PLATFORM_PRIVACY, PLATFORM_NAME, PLATFORM_DESCRIPTION} from "@app/env"
import {
PLATFORM_ABOUT,
PLATFORM_TERMS,
PLATFORM_PRIVACY,
PLATFORM_NAME,
PLATFORM_DESCRIPTION,
} from "@app/env"
import {pushModal} from "@app/modal"
const logIn = () => pushModal(LogIn)
@ -50,7 +56,8 @@
</CardButton>
</Button>
<p class="text-center text-xs opacity-75">
By using {PLATFORM_NAME}, you consent to our
By using <Link external class="link" href={PLATFORM_ABOUT}>{PLATFORM_NAME}</Link>, you consent
to our
<Link external class="link" href={PLATFORM_TERMS}>Terms of Service</Link> and
<Link external class="link" href={PLATFORM_PRIVACY}>Privacy Policy</Link>.
</p>

View file

@ -15,10 +15,8 @@
import LogOut from "@app/components/LogOut.svelte"
import {user} from "@app/core"
import {pushModal} from "@app/modal"
import {theme} from "@app/theme"
const logout = () => pushModal(LogOut)
const toggleTheme = () => theme.set($theme === "dark" ? "light" : "dark")
</script>
<Modal>
@ -74,7 +72,7 @@
</Link>
</div>
<div class="flex gap-3 items-center opacity-75 text-sm">
<Button onclick={toggleTheme}>Theme</Button>
<Link replaceState href="/settings/theme">Theme</Link>
/
<Link replaceState href="/settings/about">About</Link>
/

View file

@ -0,0 +1,91 @@
<script lang="ts">
import cx from "classnames"
import Microphone from "@assets/icons/microphone.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
type Props = {
track: MediaStreamTrack | undefined
error?: string
offMessage?: string
}
const {track, error, offMessage = "Microphone is off"}: Props = $props()
let level = $state(0)
$effect(() => {
const t = track
level = 0
if (!t || error) return
let frame: number | undefined
const context = new AudioContext()
const analyser = context.createAnalyser()
analyser.fftSize = 512
context.createMediaStreamSource(new MediaStream([t])).connect(analyser)
const data = new Uint8Array(analyser.frequencyBinCount)
// Smooth into a plain local, not into `level` itself: reading the `$state`
// we also write would make this effect depend on its own output, so every
// frame would invalidate and re-run it, tearing down the AudioContext
// before it ever produced a reading.
let smoothed = 0
const tick = () => {
if (!t.enabled || t.readyState !== "live") {
smoothed = 0
level = 0
frame = requestAnimationFrame(tick)
return
}
analyser.getByteTimeDomainData(data)
let sumSquares = 0
for (const v of data) {
const normalized = (v - 128) / 128
sumSquares += normalized * normalized
}
const rms = Math.sqrt(sumSquares / data.length)
const NOISE_FLOOR = 0.03
const gated = rms < NOISE_FLOOR ? 0 : (rms - NOISE_FLOOR) / (1 - NOISE_FLOOR)
smoothed += (Math.min(1, gated * 4) - smoothed) * 0.5
if (smoothed < 0.005) smoothed = 0
level = smoothed
frame = requestAnimationFrame(tick)
}
tick()
return () => {
if (frame !== undefined) cancelAnimationFrame(frame)
context.close().catch(() => {})
}
})
</script>
<div class="flex items-center gap-2">
<Icon icon={Microphone} size={4} class="shrink-0 opacity-70" />
{#if error}
<p class="text-xs text-error" role="alert">{error}</p>
{:else if !track}
<p class="text-xs opacity-60">{offMessage}</p>
{:else}
<div
class="flex flex-1 items-center gap-1"
role="meter"
aria-label="Microphone input level"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(level * 100)}>
{#each Array(5) as _, i (i)}
<div
class={cx(
"h-2 flex-1 rounded-full transition-colors",
level * 5 > i ? "bg-success" : "bg-surface-more",
)}>
</div>
{/each}
</div>
<span class="shrink-0 text-xs opacity-60">Speak to test</span>
{/if}
</div>

View file

@ -1,11 +1,13 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL} from "@welshman/util"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL, PINBOARD, PIN} from "@welshman/util"
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 NoteContentMinimalBoard from "@app/components/NoteContentMinimalBoard.svelte"
import NoteContentMinimalPin from "@app/components/NoteContentMinimalPin.svelte"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
const props: ComponentProps<typeof ContentMinimal> = $props()
@ -22,6 +24,10 @@
<NoteContentMinimalGoal {...props} />
{:else if props.event.kind === POLL}
<NoteContentMinimalPoll {...props} />
{:else if props.event.kind === PINBOARD}
<NoteContentMinimalBoard {...props} />
{:else if props.event.kind === PIN}
<NoteContentMinimalPin {...props} />
{:else}
<ContentMinimal {...props} />
{/if}

View file

@ -0,0 +1,15 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {Pinboard} from "@welshman/domain"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
import {reader} from "@app/core"
const props: ComponentProps<typeof ContentMinimal> = $props()
const board = reader(Pinboard)(props.event)
</script>
<span class="text-sm">{board?.title() || "Untitled shelf"}</span>
{#if board?.description()}
<span class="truncate min-w-0 block opacity-70">{board.description()}</span>
{/if}

View file

@ -0,0 +1,17 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {Pin} from "@welshman/domain"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
import {reader} from "@app/core"
const props: ComponentProps<typeof ContentMinimal> = $props()
const loadPin = reader(Pin)(props.event)
</script>
{#await loadPin then pin}
<span class="text-sm">{pin.title() || "Untitled pin"}</span>
{#if pin.content()}
<span class="truncate min-w-0 block opacity-70">{pin.content()}</span>
{/if}
{/await}

View file

@ -1,22 +1,20 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {onMount} from "svelte"
import {request} from "@welshman/net"
import {POLL_RESPONSE} from "@welshman/util"
import PollVotes from "@app/components/PollVotes.svelte"
import Content from "@app/components/Content.svelte"
import {network} from "@app/core"
const props: ComponentProps<typeof Content> = $props()
onMount(() => {
if (!props.url) {
return
if (props.url) {
$network.request({
relays: [props.url],
filters: [{kinds: [POLL_RESPONSE], "#e": [props.event.id]}],
})
}
request({
relays: [props.url],
filters: [{kinds: [POLL_RESPONSE], "#e": [props.event.id]}],
})
})
</script>

View file

@ -13,7 +13,7 @@
import PinEdit from "@app/components/PinEdit.svelte"
import {deletes} from "@app/core"
import {deriveUserIsSpaceAdmin} from "@app/management"
import {shareEventToChat} from "@app/share"
import {shareEvent} from "@app/share"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
@ -29,7 +29,7 @@
const showInfo = () => pushModal(EventInfo, {url, event: pin.event})
const share = () => shareEventToChat(url, "Link", pin.event)
const share = () => shareEvent(url, "Link", pin.event)
const edit = () => pushModal(PinEdit, {url, pin})

View file

@ -1,27 +1,32 @@
<script lang="ts">
import {Pins} from "@welshman/app"
import cx from "classnames"
import type {PinboardReader} from "@welshman/domain"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import MenuButton from "@lib/components/MenuButton.svelte"
import PinItem from "@app/components/PinItem.svelte"
import BoardMenu from "@app/components/BoardMenu.svelte"
import {app} from "@app/core"
import {makeLibraryPath} from "@app/routes"
type Props = {
url: string
board: PinboardReader
selected: boolean
onclick: () => void
}
const {url, board}: Props = $props()
const pins = $app.use(Pins).forBoard(board.address()).$
const detailPath = makeLibraryPath(url, board.address())
const {url, board, selected, onclick}: Props = $props()
</script>
<div class="card relative flex flex-col gap-2">
<div class="relative w-56 shrink-0">
<Button
{onclick}
aria-pressed={selected}
class={cx("card card-sm card-interactive flex h-full w-full flex-col items-start gap-1 pr-10", {
"card-primary": selected,
})}>
<strong class="truncate w-full text-left">{board.title() || "Untitled shelf"}</strong>
{#if board.description()}
<span class="line-clamp-2 text-left text-sm opacity-70">{board.description()}</span>
{/if}
</Button>
<div class="absolute right-2 top-2 z-feature">
<MenuButton
class="button button-neutral button-sm button-square"
@ -30,17 +35,4 @@
component={BoardMenu}
componentProps={{url, board}} />
</div>
<div class="flex flex-col pr-8">
<strong class="text-lg">{board.title() || "Untitled shelf"}</strong>
{#if board.description()}
<span class="text-sm opacity-70">{board.description()}</span>
{/if}
</div>
{#each $pins.slice(0, 1) as pin (pin.id())}
<PinItem minimal {url} {pin} />
{/each}
<Link href={detailPath} class="button button-primary">
<Icon icon={AltArrowRight} />
<strong>View all content</strong>
</Link>
</div>

View file

@ -7,7 +7,7 @@
import RelayDescription from "@app/components/RelayDescription.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {roomLists, user} from "@app/core"
import {deriveUserRooms} from "@app/rooms"
import {userSpaceUrls} from "@app/rooms"
type Props = {
url: string
@ -15,7 +15,6 @@
}
const {url, hideFavorites}: Props = $props()
const rooms = deriveUserRooms(url)
const favorited = $roomLists.pubkeysForUrl(url).$
const favoritedPubkeys = $derived(remove($user.pubkey, $favorited))
</script>
@ -30,11 +29,10 @@
<RelayIcon {url} size={10} class="rounded-full" />
</div>
</div>
{#if $rooms.includes(url)}
{#if $userSpaceUrls.includes(url)}
<div
class="tip absolute -right-1 -top-1 h-5 w-5 rounded-full bg-primary text-primary-content"
data-tip="You are already a member of this space.">
<Icon icon={CheckCircle} class="scale-110" />
class="absolute -right-1 -top-1 h-5 w-5 rounded-full bg-primary text-primary-content flex justify-center items-center">
<Icon icon={CheckCircle} class="scale-120" />
</div>
{/if}
</div>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import {formatTimestamp} from "@welshman/lib"
import {getIdFilters, matchTag, tagSpec} from "@welshman/util"
import {load, LOCAL_RELAY_URL} from "@welshman/net"
import {LOCAL_RELAY_URL} from "@welshman/net"
import type {TrustedEvent} from "@welshman/util"
import Card from "@lib/components/Card.svelte"
import Button from "@lib/components/Button.svelte"
@ -10,6 +10,7 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import ReportMenu from "@app/components/ReportMenu.svelte"
import {network} from "@app/core"
import {pushModal} from "@app/modal"
import {goToEvent} from "@app/routes"
@ -55,7 +56,7 @@
{/if}
<div class="card card-sm">
{#if etag}
{#await load({relays: [url, LOCAL_RELAY_URL], filters: getIdFilters([etag[1]])})}
{#await $network.load({relays: [url, LOCAL_RELAY_URL], filters: getIdFilters([etag[1]])})}
<p>Loading</p>
{:then reportedEvents}
{#if reportedEvents.length === 0}

View file

@ -7,14 +7,16 @@
import {debounce} from "throttle-debounce"
import cx from "classnames"
import {now, ifLet, int, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, ROOM_ADD_MEMBER} from "@welshman/util"
import {publish} from "@welshman/app"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import Login2 from "@assets/icons/login-3.svg?dataurl"
import Close from "@assets/icons/close.svg?dataurl"
import {fade, fly} from "@lib/transition"
import {popKey} from "@lib/implicit"
import {documentActive} from "@lib/html"
import Button from "@lib/components/Button.svelte"
import Divider from "@lib/components/Divider.svelte"
import Icon from "@lib/components/Icon.svelte"
@ -27,17 +29,10 @@
import RoomPinnedMessages from "@app/components/RoomPinnedMessages.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import VideoCallContent from "@app/components/VideoCallContent.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import CallControlBar from "@app/components/CallControlBar.svelte"
import {deletes, relays, rooms, thunks, user} from "@app/core"
import {publishRoomJoinRequest} from "@app/access"
import {
CallState,
callTargetRoom,
callState,
VideoCallLayout,
videoCallLayout,
videoTileCount,
} from "@app/call"
import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call"
import {
PROTECTED,
RoomType,
@ -50,6 +45,7 @@
import {makeFeed} from "@app/feeds"
import {checked, deferredRoomPath, setChecked} from "@app/notifications"
import {makeRoomPath} from "@app/routes"
import {pendingShare, type Share} from "@app/share"
import {pushToast} from "@app/toast"
type Props = {
@ -63,56 +59,64 @@
const addMemberKind = h ? ROOM_ADD_MEMBER : RELAY_ADD_MEMBER
const isVoiceRoom = $derived($room && getRoomType($room) === RoomType.Voice)
const isCallTargetingThisRoom = $derived($callTargetRoom?.url === url && $callTargetRoom?.h === h)
const voiceConnectedHere = $derived(
isVoiceRoom &&
$callState === CallState.Connected &&
$callTargetRoom?.url === url &&
$callTargetRoom?.h === h,
isVoiceRoom && $callState === CallState.Connected && isCallTargetingThisRoom,
)
// Reuses voiceConnectedHere (rather than re-deriving isVoiceRoom/callState) so it
// can't diverge and stay true for a different voice room the call isn't targeting
// — that previously hid this room's messages/compose row while connected elsewhere.
const showMobileVideoPanel = $derived(
isVoiceRoom && $callState === CallState.Connected && $videoCallLayout === VideoCallLayout.Video,
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video,
)
const roomPath = h ? makeRoomPath(url, h) : undefined
// Basic pass at issue #121's "chat overlaid on video" idea: float the chat panel over
// the full-bleed video instead of splitting the screen into fixed columns, so the call
// stays the visual focus. The panel opens from — and collapses back to — a chat FAB.
const isOverlayChat = $derived(voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split)
const videoCallChatHidden = $derived(
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video,
)
const roomPath = h ? makeRoomPath(url, h) : undefined
const closeChat = () => videoCallLayout.set(VideoCallLayout.Video)
$effect(() => {
deferredRoomPath.set(videoCallChatHidden ? roomPath : undefined)
if (roomPath && voiceConnectedHere && !videoCallChatHidden) {
setChecked(roomPath)
}
// The messages pane (and its scroll position) is offscreen while the call view is
// showing — don't leave its floating "scroll down"/"new messages" chips stranded.
if (videoCallChatHidden) {
showScrollButton = false
showFixedNewMessages = false
}
})
onDestroy(() => deferredRoomPath.set(undefined))
let prevVideoTileCount = $state(0)
let wasConnectedHere = $state(false)
$effect(() => {
if ($callState !== CallState.Connected) {
videoCallLayout.set(VideoCallLayout.Chat)
prevVideoTileCount = 0
if (!voiceConnectedHere) {
if ($callState !== CallState.Connected) {
videoCallLayout.set(VideoCallLayout.Chat)
}
wasConnectedHere = false
return
}
const here = isVoiceRoom && $callTargetRoom?.url === url && $callTargetRoom?.h === h
const n = $videoTileCount
if (!here) {
prevVideoTileCount = 0
return
}
if (prevVideoTileCount === 0 && n >= 1) {
// Land on the call view (chat as a FAB) as soon as the call starts here, whether
// or not anyone's camera is on — chat is one tap away via the FAB.
if (!wasConnectedHere) {
videoCallLayout.set(VideoCallLayout.Video)
wasConnectedHere = true
}
if (prevVideoTileCount >= 1 && n === 0 && $videoCallLayout === VideoCallLayout.Split) {
videoCallLayout.set(VideoCallLayout.Chat)
}
prevVideoTileCount = n
})
const shouldProtect = $relays.hasNip(url, 70)
@ -179,7 +183,7 @@
}
const onSubmit = async ({content, tags}: EventContent) => {
if (!content && !share) {
if (!content && !sharedEvent) {
return
}
@ -212,8 +216,8 @@
command.publishToRelays([url])
}
if (share) {
template = await prependParent(share, template, url)
if (sharedEvent) {
template = await prependParent(sharedEvent, template, url)
}
if (parent) {
@ -301,8 +305,10 @@
}
}
const onVisibilityChange = () => {
if (document.hidden) {
// A tab can be `visible` but unfocused (user alt-tabbed to another app), so we
// can't rely on document.hidden alone to know the room is actually being watched.
const onActiveChange = (active: boolean) => {
if (!active) {
lastVisibleAt = now()
} else if ($events.some(e => e.pubkey !== $user.pubkey && e.created_at > lastVisibleAt)) {
newMessagesAfter = lastVisibleAt
@ -318,7 +324,7 @@
let isUserScrolling = $state(false)
let loadingBackward = $state(true)
let loadingForward = $state(true)
let share = $state(popKey<TrustedEvent | undefined>("share"))
let share: Maybe<Share> = $state()
let parent: TrustedEvent | undefined = $state()
let element: HTMLElement | undefined = $state()
let lastVisibleAt = now()
@ -332,6 +338,27 @@
let compose: RoomCompose | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
// Claim the share once we're on screen. Sharing into the room you're already looking at
// doesn't re-create this component, so this can't be read once on mount.
$effect(() => {
if ($pendingShare) {
share = $pendingShare
pendingShare.set(undefined)
}
})
const sharedEvent = $derived(share?.type === "event" ? share.value : undefined)
const initialValues = $derived.by((): Share | undefined => {
if (eventToEdit) {
return {type: "text", value: eventToEdit.content}
}
if (share) {
return share
}
})
const clearIsUserScrolling = debounce(150, () => {
isUserScrolling = false
})
@ -448,42 +475,60 @@
const onEditPrevious = () => ifLet($events.toReversed().find(canEditEvent), onEditEvent)
onMount(() => {
start()
// Defer rendering until navigation finishes
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(start)
})
document.addEventListener("visibilitychange", onVisibilityChange)
const unsubscribeActive = documentActive.subscribe(onActiveChange)
return () => {
// Wrap in a closure to avoid calling a stale cleanup function
cleanup?.()
document.removeEventListener("visibilitychange", onVisibilityChange)
unsubscribeActive()
cancelAnimationFrame(frame)
}
})
</script>
<div
class={cx(
"flex min-h-0 flex-1 flex-col",
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split && "md:flex-row",
)}>
<div class={cx("flex min-h-0 flex-1 flex-col", isOverlayChat && "relative")}>
{#if h && voiceConnectedHere}
<VideoCallContent
layout={$videoCallLayout}
{url}
{h}
class="hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex" />
class={cx(
"hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex",
isOverlayChat && "md:absolute md:inset-0",
)} />
{/if}
{#if h && voiceConnectedHere}
<VideoCallContent
layout={$videoCallLayout}
mobile
{url}
{h}
class={cx("md:hidden", isOverlayChat && "absolute inset-0")} />
{/if}
<div
class={cx(
"room flex min-h-0 min-w-0 flex-1 flex-col",
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video && "md:hidden",
videoCallChatHidden && "hidden",
isOverlayChat &&
"absolute inset-x-2 top-4 bottom-[calc(3.5rem+var(--saib))] z-popover overflow-hidden rounded-2xl border border-line bg-surface/95 shadow-lg md:left-auto md:right-4 md:bottom-4 md:w-96 md:max-w-[26rem] md:bg-surface/90",
)}>
{#if h}
<RoomPinnedMessages {url} {h} />
{#if isOverlayChat}
<Button
aria-label="Close room chat"
class="button button-neutral button-xs button-circle absolute right-2 top-2 z-popover"
onclick={closeChat}>
<Icon icon={Close} size={4} />
</Button>
{/if}
{#if h && isVoiceRoom && $callState === CallState.Connected}
<VideoCallContent layout={$videoCallLayout} mobile {url} {h} class="md:hidden" />
{#if h}
<RoomPinnedMessages {url} {h} />
{/if}
<div
@ -565,8 +610,12 @@
<div
class={cx(
"room__compose flex flex-col gap-1 md:flex-row md:gap-0",
"room__compose flex flex-row items-center gap-1 px-2",
showMobileVideoPanel && "max-md:hidden",
// the connected bar (mic/camera/screenshare/settings/chat/leave) is too wide
// to sit beside the compose input on any screen size — give it its own row.
// gap-2 only applies here (the inline row stays flush with no gap).
voiceConnectedHere && "flex-col items-stretch gap-2",
)}>
<div class="room__compose-inner min-w-0 flex-1">
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
@ -595,32 +644,28 @@
{#if parent}
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if share}
<RoomComposeParent event={share} clear={clearShare} verb="Sharing" />
{#if sharedEvent}
<RoomComposeParent event={sharedEvent} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<RoomComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key eventToEdit}
{#key initialValues}
<RoomCompose
{url}
{h}
{onSubmit}
{onEscape}
{onEditPrevious}
initialValues={eventToEdit}
{initialValues}
bind:this={compose} />
{/key}
{/if}
</div>
{#if isVoiceRoom || $callState === CallState.Joining || $callState === CallState.Connected}
<div
class={cx(
"hide-on-keyboard flex-shrink-0 p-2 md:hidden",
showMobileVideoPanel && "hidden",
)}>
<VoiceWidget />
{#if h}
<div class="hide-on-keyboard flex flex-shrink-0 items-center justify-center py-2">
<CallControlBar {url} {h} hideConnectedOnDesktop />
</div>
{/if}
</div>

View file

@ -13,29 +13,22 @@
import ComposeMenu from "@app/components/ComposeMenu.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/drafts"
import {DraftKey, type Draft} from "@app/drafts"
import type {Share} from "@app/share"
import {onDestroy, onMount} from "svelte"
type Values = {
content?: string | object
}
type Props = {
url?: string
h?: string
onEscape?: () => void
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void
initialValues?: Values
initialValues?: Share
}
let {url, h, initialValues, onEscape, onEditPrevious, onSubmit}: Props = $props()
const {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 draftKey = url || h ? new DraftKey<Draft>(`room:${url ?? ""}:${h ?? ""}`) : undefined
const autofocus = !isMobile
@ -76,7 +69,9 @@
}
let popover: Instance | undefined = $state()
let content = $state(initialValues?.content ?? "")
let content = $state(
initialValues?.type === "text" ? initialValues.value : (draftKey?.get()?.content ?? ""),
)
const onChange = (json: object) => {
content = json
@ -98,6 +93,12 @@
onMount(async () => {
const ed = await editor
ed.view.dom.addEventListener("keydown", handleKeyDown)
if (initialValues?.type === "file") {
ed.chain()
.addFile(initialValues.value, ed.state.selection.from + 1)
.run()
}
})
onDestroy(async () => {
@ -106,7 +107,7 @@
})
</script>
<form class="relative flex gap-2 p-2" onsubmit={preventDefault(submit)}>
<form class="relative flex gap-2 py-2" onsubmit={preventDefault(submit)}>
<div class="join">
<Button
class="join-item h-10 w-10 min-w-10 button button-neutral"

View file

@ -10,7 +10,7 @@
formatTimestampAsDate,
} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {MESSAGE, COMMENT, matchTag, tagSpec, tagValue} from "@welshman/util"
import {MESSAGE, COMMENT, getIdOrAddress, matchTag, tagSpec, tagValue} from "@welshman/util"
import {isMobile} from "@lib/html"
import Pen from "@assets/icons/pen.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
@ -33,7 +33,7 @@
import {colors} from "@app/theme"
import {ENABLE_ZAPS} from "@app/env"
import {deriveEvent, deriveEventsForUrl} from "@app/repository"
import {getRoomItemPath} from "@app/routes"
import {makeContentPath} from "@app/routes"
import {pushModal} from "@app/modal"
type Props = {
@ -47,7 +47,6 @@
const {url, event, replyTo = undefined, showPubkey = false, canEdit, onEdit}: Props = $props()
const path = getRoomItemPath(url, event)
const h = tagValue(tagSpec("h"), event.tags)
const today = formatTimestampAsDate(now())
const profileDisplay = $profiles.display(event.pubkey, [url]).$
@ -63,7 +62,12 @@
: readable([])
const innerEvent = isQuoteOnly ? deriveEvent(qTag![1], [url]) : readable(undefined)
const path = $derived(
$innerEvent && makeContentPath(url, $innerEvent.kind, getIdOrAddress($innerEvent)),
)
const reply = () => replyTo!(event)
const edit = canEdit(event) ? () => onEdit(event) : undefined
const onTap = () => pushModal(RoomItemMenuMobile, {url, event, reply, edit})

View file

@ -1,14 +1,14 @@
<script lang="ts">
import cx from "classnames"
import type {ComponentProps} from "svelte"
import {MESSAGE} from "@welshman/util"
import {MESSAGE, getIdOrAddress} from "@welshman/util"
import {isMobile} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import {getRoomItemPath} from "@app/routes"
import {makeContentPath} from "@app/routes"
const props: ComponentProps<typeof NoteContent> = $props()
const path = getRoomItemPath(props.url!, props.event)
const path = makeContentPath(props.url!, props.event.kind, getIdOrAddress(props.event))
const minLength = 5000
const maxLength = 5500

View file

@ -1,7 +1,7 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {tagSpec, tagValue} from "@welshman/util"
import {getIdOrAddress, tagSpec, tagValue} from "@welshman/util"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
@ -21,7 +21,7 @@
import {reactions, relays, roomPinLists, user} from "@app/core"
import {ROOM, deriveUserIsRoomAdmin} from "@app/rooms"
import {ENABLE_ZAPS} from "@app/env"
import {getRoomItemPath} from "@app/routes"
import {makeContentPath} from "@app/routes"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
@ -34,7 +34,7 @@
const {url, event, reply}: Props = $props()
const h = tagValue(tagSpec(ROOM), event.tags) ?? ""
const path = getRoomItemPath(url, event)
const path = makeContentPath(url, event.kind, getIdOrAddress(event))
const pinIds = $roomPinLists.pins(url, h).$
const userIsRoomAdmin = deriveUserIsRoomAdmin(url, h)
const isPinned = $derived($pinIds.includes(event.id))

View file

@ -1,7 +1,6 @@
<script lang="ts">
import {onMount, tick} from "svelte"
import {debounce} from "throttle-debounce"
import {load} from "@welshman/net"
import {groupBy, uniqBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
import type {TrustedEvent, Filter} from "@welshman/util"
import {MESSAGE, sortEventsDesc, displayRelayUrl} from "@welshman/util"
@ -19,6 +18,7 @@
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import {CONTENT_KINDS} from "@app/content"
import {getEventsForUrl} from "@app/repository"
import {network} from "@app/core"
import {popModal} from "@app/modal"
import {goToEvent} from "@app/routes"
@ -51,7 +51,7 @@
results = sortEventsDesc(getEventsForUrl(url, [filter]))
try {
const events = await load({
const events = await $network.load({
relays: [url],
signal: controller.signal,
filters: [filter],

View file

@ -0,0 +1,137 @@
<script lang="ts">
import {derived} from "svelte/store"
import cx from "classnames"
import {remove} from "@welshman/lib"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import {preventDefault} from "@lib/html"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.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 ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import {chatSearch} from "@app/chats"
import {user} from "@app/core"
import {deriveUserRoomSearch} from "@app/rooms"
import {makeChatPath, makeRoomPath} from "@app/routes"
import {shareTo, type Share} from "@app/share"
type Props = {
share: Share
}
const {share}: Props = $props()
const roomSearch = deriveUserRoomSearch()
const toggle = (path: string) => {
selection = path === selection ? "" : path
}
const back = () => history.back()
const submit = () => shareTo(selection, share, {replaceState: true})
let term = $state("")
let selection = $state("")
const roomResults = $derived($roomSearch.searchOptions(term))
const chatResults = $derived($chatSearch.searchOptions(term))
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Share</ModalTitle>
<ModalSubtitle>Where would you like to share this?</ModalSubtitle>
</ModalHeader>
<label class="input input-sm flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder="Search rooms and conversations..." />
</label>
<div class="flex min-h-96 flex-col gap-4">
{#if roomResults.length > 0}
<p class="text-xs uppercase tracking-wide opacity-60">Rooms</p>
<div class="flex flex-col gap-2">
{#each roomResults as { url, h } (makeRoomPath(url, h))}
{@const path = makeRoomPath(url, h)}
<Button
type="button"
class={cx(
"flex w-full min-w-0 items-center justify-between gap-2 button",
selection === path ? "button-primary" : "button-neutral",
)}
onclick={() => toggle(path)}>
<RoomNameWithImage {url} {h} />
<RelayName {url} class="min-w-0 truncate text-xs opacity-60" />
</Button>
{/each}
</div>
{/if}
{#if chatResults.length > 0}
<p class="text-xs uppercase tracking-wide opacity-60">Direct Messages</p>
<div class="flex flex-col gap-2">
{#each chatResults as chat (chat.id)}
{@const path = makeChatPath(chat.pubkeys)}
{@const others = remove($user.pubkey, chat.pubkeys)}
<Button
type="button"
class={cx(
"flex w-full min-w-0 items-center justify-start gap-2 button",
selection === path ? "button-primary" : "button-neutral",
)}
onclick={() => toggle(path)}>
{#if others.length > 1}
<ProfileCircles pubkeys={others} size={5} />
<span class="min-w-0 truncate">
<ProfileName pubkey={others[0]} />
+{others.length - 1}
</span>
{:else if others.length === 1}
<ProfileCircle pubkey={others[0]} size={5} />
<span class="min-w-0 truncate">
<ProfileName pubkey={others[0]} />
</span>
{:else}
<ProfileCircle pubkey={$user.pubkey} size={5} />
Note to self
{/if}
</Button>
{/each}
</div>
{/if}
{#if roomResults.length === 0 && chatResults.length === 0}
<p class="py-12 text-center opacity-75">
{#if term}
Nothing matched your search.
{:else}
Join a space or start a conversation to have somewhere to share to.
{/if}
</p>
{/if}
</div>
</ModalBody>
<ModalFooter>
<Button class="button button-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="button button-primary" disabled={!selection}>
Share
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</Modal>

View file

@ -1,10 +1,8 @@
<script lang="ts">
import {goto} from "$app/navigation"
import type {TrustedEvent} from "@welshman/util"
import {preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import {setKey} from "@lib/implicit"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
@ -15,6 +13,7 @@
import ModalBody from "@lib/components/ModalBody.svelte"
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import {makeRoomPath} from "@app/routes"
import {shareTo} from "@app/share"
import {rooms} from "@app/core"
type Props = {
@ -30,8 +29,7 @@
const back = () => history.back()
const onSubmit = () => {
setKey("share", event)
goto(makeRoomPath(url, selection), {replaceState: true})
shareTo(makeRoomPath(url, selection), {type: "event", value: event}, {replaceState: true})
}
const toggleRoom = (h: string) => {

View file

@ -1,79 +1,12 @@
<script lang="ts">
import {derived} from "svelte/store"
import cx from "classnames"
import {
displayRelayUrl,
EVENT_TIME,
ZAP_GOAL,
THREAD,
CLASSIFIED,
PINBOARD,
POLL,
} from "@welshman/util"
import {createSearch} 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"
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
import Home from "@assets/icons/home.svg?dataurl"
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl"
import ServerPath from "@assets/icons/server-path.svg?dataurl"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import Exit from "@assets/icons/logout-3.svg?dataurl"
import Letter from "@assets/icons/letter.svg?dataurl"
import Login from "@assets/icons/login-3.svg?dataurl"
import History from "@assets/icons/history.svg?dataurl"
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic-2.svg?dataurl"
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"
import BellOff from "@assets/icons/bell-off.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Popover from "@lib/components/Popover.svelte"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
import SpaceInvite from "@app/components/SpaceInvite.svelte"
import SpaceExit from "@app/components/SpaceExit.svelte"
import SpaceEdit from "@app/components/SpaceEdit.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte"
import RelayName from "@app/components/RelayName.svelte"
import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
import SpaceSearch from "@app/components/SpaceSearch.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte"
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import SpaceMenuHeader from "@app/components/SpaceMenuHeader.svelte"
import SpaceMenuNavItems from "@app/components/SpaceMenuNavItems.svelte"
import SpaceMenuRooms from "@app/components/SpaceMenuRooms.svelte"
import Link from "@lib/components/Link.svelte"
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
import {relays, user} from "@app/core"
import {ENABLE_ZAPS} from "@app/env"
import {CONTENT_KINDS} from "@app/content"
import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting"
import {
deriveSpaceSupportedMethods,
deriveUserCanCreateRoom,
deriveUserIsSpaceAdmin,
} from "@app/management"
import {
deriveOtherRooms,
deriveOtherVoiceRooms,
deriveUserRooms,
displayRoom,
userSpaceUrls,
} from "@app/rooms"
import {deriveEventsForUrl} from "@app/repository"
import {deriveSpaceActionItems} from "@app/actionItems"
import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings"
import {pushModal} from "@app/modal"
import {makeSpacePath, goToChat} from "@app/routes"
import {notifications} from "@app/notifications"
import {makeSpacePath} from "@app/routes"
type Props = {
url: string
@ -81,335 +14,43 @@
}
const {url, mobile = false}: Props = $props()
const relay = $relays.one(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)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const hostedRelay = deriveHostedRelay(url)
const actionItems = deriveSpaceActionItems(url)
const spaceKinds = derived(
deriveEventsForUrl(url, [{kinds: CONTENT_KINDS}]),
$events => new Set($events.map(e => e.kind)),
)
const boardEvents = deriveEventsForUrl(url, [{kinds: [PINBOARD]}])
const hasNip29 = $derived($relay?.hasNip(29) ?? false)
const supportedMethods = deriveSpaceSupportedMethods(url)
const showLibrary = $derived($boardEvents.length > 0 || $supportedMethods.includes("signevent"))
const roomSearch = derived(otherRooms, $otherRooms =>
createSearch(
$otherRooms.map(h => ({h, name: displayRoom(url, h)})),
{
getValue: item => item.h,
fuseOptions: {keys: ["name"]},
},
),
)
const openMenu = () => {
showMenu = true
}
const toggleMenu = () => {
showMenu = !showMenu
}
const startEdit = () => pushModal(SpaceEdit, {url, initialValues: $relay || {url}})
const showActionItems = () => pushModal(SpaceActionItems, {url})
const openSearch = () => pushModal(SpaceSearch, {url})
const canCreateRoom = deriveUserCanCreateRoom(url)
const createInvite = () => pushModal(SpaceInvite, {url})
const leaveSpace = () => pushModal(SpaceExit, {url})
const joinSpace = () => pushModal(SpaceJoin, {url})
const addRoom = () => pushModal(RoomCreate, {url})
const contactOwner = () => goToChat([$relay!.pubkey!])
const shouldNotify = deriveShouldNotify(url)
const toggleSpaceNotifications = () => {
setSpaceNotifications(url, !$shouldNotify)
}
const clearTerm = () => {
setTimeout(() => {
term = ""
}, 100)
}
let term = $state("")
let showMenu = $state(false)
let element: Element | undefined = $state()
</script>
{#snippet spaceHeader()}
<Button
class={cx(
mobile
? "space-menu__header-button text-content"
: "relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-surface",
)}
onclick={openMenu}>
<div class="flex items-center justify-between">
<strong class="flex items-center gap-1 relative">
<RelayName {url} class="truncate min-w-0" />
<div
class={cx(
"absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary text-primary-content transition-all",
$userIsAdmin && $actionItems.length > 0 ? "opacity-100" : "opacity-0",
)}>
</div>
{#if $notificationSettings.push && !$shouldNotify}
<Icon icon={BellOff} size={3} class="opacity-50" />
{/if}
</strong>
<Icon icon={AltArrowDown} />
</div>
<span class="text-xs text-primary">{displayRelayUrl(url)}</span>
</Button>
{#if showMenu}
<Popover hideOnClick onClose={toggleMenu}>
<ul
transition:fly
class="menu absolute z-popover mt-2 w-full gap-1 rounded-2xl bg-surface p-2">
<li>
<Button onclick={createInvite}>
<Icon icon={LinkRound} />
Create Invite
</Button>
</li>
{#if $userIsAdmin}
<li>
<Button onclick={showActionItems}>
<Icon icon={Danger} />
Action Items ({$actionItems.length})
{#if $actionItems.length > 0}
<div class="h-2 w-2 rounded-full bg-primary text-primary-content"></div>
{/if}
</Button>
</li>
{/if}
{#if $relay?.pubkey && $relay.pubkey !== $user.pubkey}
<li>
<Button onclick={contactOwner}>
<Icon icon={Letter} />
Contact Owner
</Button>
</li>
{/if}
<li>
{#if $notificationSettings.push}
<Button onclick={toggleSpaceNotifications}>
<Icon icon={$shouldNotify ? Bell : BellOff} />
{$shouldNotify ? "Turn off" : "Turn on"} notifications
</Button>
{:else}
<Link href="/settings/alerts">
<Icon icon={Bell} />
Enable notifications
</Link>
{/if}
</li>
{#if HOSTING_ENABLED && $hostedRelay.relay}
<li>
<Link href={makeSpacePath(url, "admin")}>
<Icon icon={ServerPath} />
Hosting settings
</Link>
</li>
{:else if $userIsAdmin}
<li>
<Button onclick={startEdit}>
<Icon icon={Pen} />
Edit Space
</Button>
</li>
{/if}
<li>
{#if $userSpaceUrls.includes(url)}
<Button onclick={leaveSpace} class="text-error">
<Icon icon={Exit} />
Leave Space
</Button>
{:else}
<Button
onclick={joinSpace}
class="bg-primary text-primary-content"
style="color: var(--primary-content)">
<Icon icon={Login} />
Join Space
</Button>
{/if}
</li>
</ul>
</Popover>
{/if}
{/snippet}
{#snippet spaceNavItems()}
<SecondaryNavItem href={makeSpacePath(url, "about")}>
<Icon icon={Home} /> Space Details
</SecondaryNavItem>
{#if hasNip29}
<SecondaryNavItem href={makeSpacePath(url, "recent")}>
<Icon icon={History} /> Recent Activity
</SecondaryNavItem>
{:else}
<SecondaryNavItem href={chatPath} notification={$notifications.has(chatPath)}>
<Icon icon={ChatRound} /> Chat
</SecondaryNavItem>
{/if}
<SecondaryNavItem href={makeSpacePath(url, "directory")}>
<Icon icon={UsersGroup} /> Directory
</SecondaryNavItem>
{#if showLibrary}
<SecondaryNavItem href={makeSpacePath(url, "library")}>
<Icon icon={GalleryWide} /> Library
</SecondaryNavItem>
{/if}
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
<SecondaryNavItem href={goalsPath}>
<Icon icon={StarFallMinimalistic} /> Goals
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(THREAD)}
<SecondaryNavItem href={threadsPath}>
<Icon icon={NotesMinimalistic} /> Threads
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(CLASSIFIED)}
<SecondaryNavItem href={classifiedsPath}>
<Icon icon={CaseMinimalistic} /> Classifieds
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(EVENT_TIME)}
<SecondaryNavItem href={calendarPath}>
<Icon icon={CalendarMinimalistic} /> Calendar
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(POLL)}
<SecondaryNavItem href={pollsPath}>
<Icon icon={Revote} /> Polls
</SecondaryNavItem>
{/if}
{#if hasNip29}
<SecondaryNavItem onclick={openSearch}>
<Icon icon={Magnifier} /> Search
</SecondaryNavItem>
{/if}
{/snippet}
{#snippet spaceRooms()}
{#if hasNip29}
{#if $userRooms.length > 0}
{#if !mobile}
<div class="h-2 shrink-0"></div>
{/if}
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if}
{#each $userRooms as h (h)}
<SpaceMenuRoomItem {url} {h} tooltip={!mobile} />
{/each}
{#if $otherRooms.length > 0}
{#if !mobile}
<div class="h-2 shrink-0"></div>
{/if}
<SecondaryNavHeader>
{#if $userRooms.length > 0}
Other Rooms
{:else}
Rooms
{/if}
</SecondaryNavHeader>
{/if}
{#if $otherRooms.length > 20}
<label class="input input-sm flex items-center gap-2">
<Icon icon={Magnifier} />
<input bind:value={term} onblur={clearTerm} class="grow" />
</label>
{/if}
{#each $roomSearch.searchValues(term) as h (h)}
<SpaceMenuRoomItem {url} {h} tooltip={!mobile} />
{/each}
{#if $otherVoiceRooms.length > 0}
{#if !mobile}
<div class="h-2 shrink-0"></div>
{/if}
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
{#each $otherVoiceRooms as h (h)}
<SpaceMenuRoomItem {url} {h} tooltip={!mobile} />
{/each}
{/if}
{#if $canCreateRoom}
<SecondaryNavItem onclick={addRoom}>
<Icon icon={AddCircle} />
Create room
</SecondaryNavItem>
{/if}
{/if}
{/snippet}
{#snippet spaceFooter()}
<VoiceWidget />
<Link
href={makeSpacePath(url, "about")}
class={cx(mobile ? "space-menu__status-link" : "button button-neutral button-sm h-10")}>
<SocketStatusIndicator {url} />
</Link>
{/snippet}
<div bind:this={element} class="flex min-h-0 flex-1 flex-col">
<div class="flex min-h-0 flex-1 flex-col">
{#if mobile}
<SecondaryNavSection class="space-menu min-h-0 flex-1 flex flex-col gap-3 p-0">
<div class="card space-menu__card space-menu__header shrink-0 z-nav-item">
{@render spaceHeader()}
<SpaceMenuHeader {url} mobile />
</div>
<div class="space-menu__scroll flex flex-col -mt-12 pt-12 -mb-8 pb-8">
<div class="card space-menu__card flex flex-col gap-1">
<SecondaryNavHeader>Space</SecondaryNavHeader>
{@render spaceNavItems()}
<SpaceMenuNavItems {url} />
</div>
{#if hasNip29 && ($userRooms.length > 0 || $otherRooms.length > 0 || $otherVoiceRooms.length > 0 || $canCreateRoom)}
<div class="card space-menu__card flex flex-col gap-1">
{@render spaceRooms()}
</div>
{/if}
<SpaceMenuRooms {url} mobile />
</div>
<div class="card space-menu__card space-menu__status shrink-0 z-nav-item">
{@render spaceFooter()}
<Link href={makeSpacePath(url, "about")} class="space-menu__status-link">
<SocketStatusIndicator {url} />
</Link>
</div>
</SecondaryNavSection>
{:else}
<SecondaryNavSection class="min-h-0 flex-1 flex flex-col pb-0">
<div class="shrink-0">
{@render spaceHeader()}
<SpaceMenuHeader {url} />
</div>
<div
class="space-menu__scroll flex min-h-0 flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto py-1">
{@render spaceNavItems()}
{@render spaceRooms()}
<SpaceMenuNavItems {url} />
<SpaceMenuRooms {url} />
<div class="h-5 shrink-0"></div>
</div>
</SecondaryNavSection>
<div class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-1 md:pb-2 z-nav">
{@render spaceFooter()}
<Link href={makeSpacePath(url, "about")} class="button button-neutral button-sm h-10">
<SocketStatusIndicator {url} />
</Link>
</div>
{/if}
</div>

View file

@ -0,0 +1,122 @@
<script lang="ts">
import cx from "classnames"
import Danger from "@assets/icons/danger.svg?dataurl"
import ServerPath from "@assets/icons/server-path.svg?dataurl"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import Exit from "@assets/icons/logout-3.svg?dataurl"
import Letter from "@assets/icons/letter.svg?dataurl"
import Login from "@assets/icons/login-3.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import BellOff from "@assets/icons/bell-off.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import SpaceInvite from "@app/components/SpaceInvite.svelte"
import SpaceExit from "@app/components/SpaceExit.svelte"
import SpaceEdit from "@app/components/SpaceEdit.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte"
import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
import {relays, user} from "@app/core"
import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting"
import {deriveUserIsSpaceAdmin} from "@app/management"
import {userSpaceUrls} from "@app/rooms"
import {deriveSpaceActionItems} from "@app/actionItems"
import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings"
import {pushModal, popModal} from "@app/modal"
import {makeSpacePath, goToChat} from "@app/routes"
type Props = {
url: string
}
const {url}: Props = $props()
const relay = $relays.one(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const hostedRelay = deriveHostedRelay(url)
const actionItems = deriveSpaceActionItems(url)
const shouldNotify = deriveShouldNotify(url)
const createInvite = () => pushModal(SpaceInvite, {url})
const startEdit = () => pushModal(SpaceEdit, {url, initialValues: $relay || {url}})
const leaveSpace = () => pushModal(SpaceExit, {url})
const joinSpace = () => pushModal(SpaceJoin, {url})
const showActionItems = () => pushModal(SpaceActionItems, {url})
const contactOwner = () => goToChat([$relay!.pubkey!])
const toggleSpaceNotifications = () => {
popModal()
setSpaceNotifications(url, !$shouldNotify)
}
</script>
{#snippet actionButton(
onclick: () => void,
icon: string,
label: string,
variant?: "error" | "primary",
)}
<li>
<Button
{onclick}
class={cx({
"text-error": variant === "error",
"bg-primary text-primary-content": variant === "primary",
})}
style={variant === "primary" ? "color: var(--primary-content)" : undefined}>
<Icon {icon} />
{label}
</Button>
</li>
{/snippet}
{@render actionButton(createInvite, LinkRound, "Create Invite")}
{#if $userIsAdmin}
<li>
<Button onclick={showActionItems}>
<Icon icon={Danger} />
Action Items ({$actionItems.length})
{#if $actionItems.length > 0}
<div class="h-2 w-2 rounded-full bg-primary text-primary-content"></div>
{/if}
</Button>
</li>
{/if}
{#if $relay?.pubkey && $relay.pubkey !== $user.pubkey}
{@render actionButton(contactOwner, Letter, "Contact Owner")}
{/if}
{#if $notificationSettings.push}
{@render actionButton(
toggleSpaceNotifications,
$shouldNotify ? Bell : BellOff,
`${$shouldNotify ? "Turn off" : "Turn on"} notifications`,
)}
{:else}
<li>
<Link href="/settings/alerts">
<Icon icon={Bell} />
Enable notifications
</Link>
</li>
{/if}
{#if HOSTING_ENABLED && $hostedRelay.relay}
<li>
<Link href={makeSpacePath(url, "admin")}>
<Icon icon={ServerPath} />
Hosting settings
</Link>
</li>
{:else if $userIsAdmin}
{@render actionButton(startEdit, Pen, "Edit Space")}
{/if}
{#if $userSpaceUrls.includes(url)}
{@render actionButton(leaveSpace, Exit, "Leave Space", "error")}
{:else}
{@render actionButton(joinSpace, Login, "Join Space", "primary")}
{/if}

View file

@ -0,0 +1,118 @@
<script lang="ts">
import cx from "classnames"
import Danger from "@assets/icons/danger.svg?dataurl"
import ServerPath from "@assets/icons/server-path.svg?dataurl"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import Exit from "@assets/icons/logout-3.svg?dataurl"
import Letter from "@assets/icons/letter.svg?dataurl"
import Login from "@assets/icons/login-3.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import BellOff from "@assets/icons/bell-off.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import SpaceInvite from "@app/components/SpaceInvite.svelte"
import SpaceExit from "@app/components/SpaceExit.svelte"
import SpaceEdit from "@app/components/SpaceEdit.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte"
import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
import {relays, user} from "@app/core"
import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting"
import {deriveUserIsSpaceAdmin} from "@app/management"
import {userSpaceUrls} from "@app/rooms"
import {deriveSpaceActionItems} from "@app/actionItems"
import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings"
import {pushModal, popModal} from "@app/modal"
import {makeSpacePath, goToChat} from "@app/routes"
type Props = {
url: string
}
const {url}: Props = $props()
const relay = $relays.one(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const hostedRelay = deriveHostedRelay(url)
const actionItems = deriveSpaceActionItems(url)
const shouldNotify = deriveShouldNotify(url)
const createInvite = () => pushModal(SpaceInvite, {url}, {replaceState: true})
const startEdit = () =>
pushModal(SpaceEdit, {url, initialValues: $relay || {url}}, {replaceState: true})
const leaveSpace = () => pushModal(SpaceExit, {url}, {replaceState: true})
const joinSpace = () => pushModal(SpaceJoin, {url}, {replaceState: true})
const showActionItems = () => pushModal(SpaceActionItems, {url}, {replaceState: true})
const contactOwner = () => goToChat([$relay!.pubkey!], {replaceState: true})
const toggleSpaceNotifications = () => {
popModal()
setSpaceNotifications(url, !$shouldNotify)
}
</script>
{#snippet actionButton(
onclick: () => void,
icon: string,
label: string,
variant?: "error" | "primary",
)}
<Button
class={cx("button w-full justify-start", {
"button-neutral": !variant,
"button-error": variant === "error",
"button-primary": variant === "primary",
})}
{onclick}>
<Icon size={4} {icon} />
{label}
</Button>
{/snippet}
{@render actionButton(createInvite, LinkRound, "Create Invite")}
{#if $userIsAdmin}
<Button class="button button-neutral w-full justify-start" onclick={showActionItems}>
<Icon size={4} icon={Danger} />
Action Items ({$actionItems.length})
{#if $actionItems.length > 0}
<div class="h-2 w-2 rounded-full bg-primary text-primary-content"></div>
{/if}
</Button>
{/if}
{#if $relay?.pubkey && $relay.pubkey !== $user.pubkey}
{@render actionButton(contactOwner, Letter, "Contact Owner")}
{/if}
{#if $notificationSettings.push}
{@render actionButton(
toggleSpaceNotifications,
$shouldNotify ? Bell : BellOff,
`${$shouldNotify ? "Turn off" : "Turn on"} notifications`,
)}
{:else}
<Link href="/settings/alerts" replaceState class="button button-neutral w-full justify-start">
<Icon size={4} icon={Bell} />
Enable notifications
</Link>
{/if}
{#if HOSTING_ENABLED && $hostedRelay.relay}
<Link
href={makeSpacePath(url, "admin")}
replaceState
class="button button-neutral w-full justify-start">
<Icon size={4} icon={ServerPath} />
Hosting settings
</Link>
{:else if $userIsAdmin}
{@render actionButton(startEdit, Pen, "Edit Space")}
{/if}
{#if $userSpaceUrls.includes(url)}
{@render actionButton(leaveSpace, Exit, "Leave Space", "error")}
{:else}
{@render actionButton(joinSpace, Login, "Join Space", "primary")}
{/if}

View file

@ -0,0 +1,74 @@
<script lang="ts">
import cx from "classnames"
import {displayRelayUrl} from "@welshman/util"
import {fly} from "@lib/transition"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import BellOff from "@assets/icons/bell-off.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Popover from "@lib/components/Popover.svelte"
import SpaceMenuMobile from "@app/components/SpaceMenuMobile.svelte"
import SpaceMenuActions from "@app/components/SpaceMenuActions.svelte"
import RelayName from "@app/components/RelayName.svelte"
import {deriveUserIsSpaceAdmin} from "@app/management"
import {deriveSpaceActionItems} from "@app/actionItems"
import {notificationSettings, deriveShouldNotify} from "@app/settings"
import {pushModal} from "@app/modal"
type Props = {
url: string
mobile?: boolean
}
const {url, mobile = false}: Props = $props()
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const actionItems = deriveSpaceActionItems(url)
const shouldNotify = deriveShouldNotify(url)
const openMenu = () => {
if (mobile) {
pushModal(SpaceMenuMobile, {url})
} else {
showMenu = true
}
}
const toggleMenu = () => {
showMenu = !showMenu
}
let showMenu = $state(false)
</script>
<Button
class={cx(
mobile
? "space-menu__header-button text-content"
: "relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-surface",
)}
onclick={openMenu}>
<div class="flex items-center justify-between">
<strong class="flex items-center gap-1 relative">
<RelayName {url} class="truncate min-w-0" />
<div
class={cx(
"absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary text-primary-content transition-all",
$userIsAdmin && $actionItems.length > 0 ? "opacity-100" : "opacity-0",
)}>
</div>
{#if $notificationSettings.push && !$shouldNotify}
<Icon icon={BellOff} size={3} class="opacity-50" />
{/if}
</strong>
<Icon icon={AltArrowDown} />
</div>
<span class="text-xs text-primary">{displayRelayUrl(url)}</span>
</Button>
{#if showMenu && !mobile}
<Popover hideOnClick onClose={toggleMenu}>
<ul transition:fly class="menu absolute z-popover mt-2 w-full gap-1 rounded-2xl bg-surface p-2">
<SpaceMenuActions {url} />
</ul>
</Popover>
{/if}

View file

@ -0,0 +1,19 @@
<script lang="ts">
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import SpaceMenuActionsMobile from "@app/components/SpaceMenuActionsMobile.svelte"
type Props = {
url: string
}
const {url}: Props = $props()
</script>
<Modal>
<ModalBody>
<div class="flex flex-col gap-2">
<SpaceMenuActionsMobile {url} />
</div>
</ModalBody>
</Modal>

View file

@ -0,0 +1,103 @@
<script lang="ts">
import {derived} from "svelte/store"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, PINBOARD, POLL} from "@welshman/util"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
import Home from "@assets/icons/home.svg?dataurl"
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import History from "@assets/icons/history.svg?dataurl"
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic-2.svg?dataurl"
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 Icon from "@lib/components/Icon.svelte"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import SpaceSearch from "@app/components/SpaceSearch.svelte"
import {relays} from "@app/core"
import {ENABLE_ZAPS} from "@app/env"
import {CONTENT_KINDS} from "@app/content"
import {deriveSpaceSupportedMethods} from "@app/management"
import {deriveEventsForUrl} from "@app/repository"
import {makeSpacePath} from "@app/routes"
import {notifications} from "@app/notifications"
import {pushModal} from "@app/modal"
type Props = {
url: string
}
const {url}: Props = $props()
const relay = $relays.one(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 spaceKinds = derived(
deriveEventsForUrl(url, [{kinds: CONTENT_KINDS}]),
$events => new Set($events.map(e => e.kind)),
)
const boardEvents = deriveEventsForUrl(url, [{kinds: [PINBOARD]}])
const hasNip29 = $derived($relay?.hasNip(29) ?? false)
const supportedMethods = deriveSpaceSupportedMethods(url)
const showLibrary = $derived($boardEvents.length > 0 || $supportedMethods.includes("signevent"))
const openSearch = () => pushModal(SpaceSearch, {url})
</script>
<SecondaryNavItem href={makeSpacePath(url, "about")}>
<Icon icon={Home} /> Space Details
</SecondaryNavItem>
{#if hasNip29}
<SecondaryNavItem href={makeSpacePath(url, "recent")}>
<Icon icon={History} /> Recent Activity
</SecondaryNavItem>
{:else}
<SecondaryNavItem href={chatPath} notification={$notifications.has(chatPath)}>
<Icon icon={ChatRound} /> Chat
</SecondaryNavItem>
{/if}
<SecondaryNavItem href={makeSpacePath(url, "directory")}>
<Icon icon={UsersGroup} /> Directory
</SecondaryNavItem>
{#if showLibrary}
<SecondaryNavItem href={makeSpacePath(url, "library")}>
<Icon icon={GalleryWide} /> Library
</SecondaryNavItem>
{/if}
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
<SecondaryNavItem href={goalsPath} notification={$notifications.has(goalsPath)}>
<Icon icon={StarFallMinimalistic} /> Goals
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(THREAD)}
<SecondaryNavItem href={threadsPath} notification={$notifications.has(threadsPath)}>
<Icon icon={NotesMinimalistic} /> Threads
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(CLASSIFIED)}
<SecondaryNavItem href={classifiedsPath} notification={$notifications.has(classifiedsPath)}>
<Icon icon={CaseMinimalistic} /> Classifieds
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(EVENT_TIME)}
<SecondaryNavItem href={calendarPath} notification={$notifications.has(calendarPath)}>
<Icon icon={CalendarMinimalistic} /> Calendar
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(POLL)}
<SecondaryNavItem href={pollsPath} notification={$notifications.has(pollsPath)}>
<Icon icon={Revote} /> Polls
</SecondaryNavItem>
{/if}
{#if hasNip29}
<SecondaryNavItem onclick={openSearch}>
<Icon icon={Magnifier} /> Search
</SecondaryNavItem>
{/if}

View file

@ -0,0 +1,106 @@
<script lang="ts">
import {derived} from "svelte/store"
import {createSearch} from "@welshman/app"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte"
import {relays} from "@app/core"
import {deriveUserCanCreateRoom} from "@app/management"
import {deriveUserRooms, deriveOtherRooms, deriveOtherVoiceRooms, displayRoom} from "@app/rooms"
import {pushModal} from "@app/modal"
type Props = {
url: string
mobile?: boolean
}
const {url, mobile = false}: Props = $props()
const relay = $relays.one(url)
const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url)
const otherVoiceRooms = deriveOtherVoiceRooms(url)
const canCreateRoom = deriveUserCanCreateRoom(url)
const roomSearch = derived(otherRooms, $otherRooms =>
createSearch(
$otherRooms.map(h => ({h, name: displayRoom(url, h)})),
{
getValue: item => item.h,
fuseOptions: {keys: ["name"]},
},
),
)
const addRoom = () => pushModal(RoomCreate, {url})
const clearTerm = () => {
setTimeout(() => {
term = ""
}, 100)
}
let term = $state("")
</script>
{#snippet content()}
{#if $userRooms.length > 0}
{#if !mobile}
<div class="h-2 shrink-0"></div>
{/if}
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if}
{#each $userRooms as h (h)}
<SpaceMenuRoomItem {url} {h} tooltip={!mobile} />
{/each}
{#if $otherRooms.length > 0}
{#if !mobile}
<div class="h-2 shrink-0"></div>
{/if}
<SecondaryNavHeader>
{#if $userRooms.length > 0}
Other Rooms
{:else}
Rooms
{/if}
</SecondaryNavHeader>
{/if}
{#if $otherRooms.length > 20}
<label class="input input-sm flex items-center gap-2">
<Icon icon={Magnifier} />
<input bind:value={term} onblur={clearTerm} class="grow" />
</label>
{/if}
{#each $roomSearch.searchValues(term) as h (h)}
<SpaceMenuRoomItem {url} {h} tooltip={!mobile} />
{/each}
{#if $otherVoiceRooms.length > 0}
{#if !mobile}
<div class="h-2 shrink-0"></div>
{/if}
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
{#each $otherVoiceRooms as h (h)}
<SpaceMenuRoomItem {url} {h} tooltip={!mobile} />
{/each}
{/if}
{#if $canCreateRoom}
<SecondaryNavItem onclick={addRoom}>
<Icon icon={AddCircle} />
Create room
</SecondaryNavItem>
{/if}
{/snippet}
{#if ($relay?.hasNip(29) ?? false) && ($userRooms.length > 0 || $otherRooms.length > 0 || $otherVoiceRooms.length > 0 || $canCreateRoom)}
{#if mobile}
<div class="card space-menu__card flex flex-col gap-1">
{@render content()}
</div>
{:else}
{@render content()}
{/if}
{/if}

View file

@ -1,7 +1,6 @@
<script lang="ts">
import {onMount, tick} from "svelte"
import {debounce} from "throttle-debounce"
import {load} from "@welshman/net"
import {groupBy, uniqBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
import type {TrustedEvent, Filter} from "@welshman/util"
import {MESSAGE, sortEventsDesc, displayRelayUrl, tagValue, tagSpec} from "@welshman/util"
@ -20,6 +19,7 @@
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import {CONTENT_KINDS} from "@app/content"
import {getEventsForUrl} from "@app/repository"
import {network} from "@app/core"
import {popModal} from "@app/modal"
import {pushToast} from "@app/toast"
import {goToEvent} from "@app/routes"
@ -51,7 +51,7 @@
results = sortEventsDesc(getEventsForUrl(url, [filter]))
try {
const events = await load({
const events = await $network.load({
relays: [url],
signal: controller.signal,
filters: [filter],

View file

@ -2,21 +2,16 @@
import {spec} from "@welshman/lib"
import cx from "classnames"
import {Track} from "livekit-client"
import {get} from "svelte/store"
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 CallControlBar from "@app/components/CallControlBar.svelte"
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
import {
VideoCallLayout,
isDesktopLayout,
toggleVideoPrimaryTile,
videoCallLayout,
videoCallViewportSync,
ViewportSize,
videoPrimaryTileKey,
currentCallSession,
callTargetRoom,
@ -24,6 +19,8 @@
participantMediaState,
pubkeyFromLiveKitIdentity,
videoTrackRevision,
computeAdaptiveGrid,
type AdaptiveTileGrid,
} from "@app/call"
import {profiles} from "@app/core"
@ -43,36 +40,17 @@
source: Track.Source.Camera | Track.Source.ScreenShare
}
type TileLayout = "spotlight" | "default" | "strip"
type TileLayoutVariant = "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(
$callTargetRoom?.url === url && $callTargetRoom?.h === h,
)
const showVideoContent = $derived(
isViewingCurrentCallRoom &&
(mobile
? layout === VideoCallLayout.Video
: layout === VideoCallLayout.Split || layout === VideoCallLayout.Video),
(layout === VideoCallLayout.Split || layout === VideoCallLayout.Video),
)
const videoTiles = $derived.by(() => {
@ -173,10 +151,14 @@
return videoTiles.filter(t => tileKey(t) !== pk)
})
let gridWidth = $state(0)
let gridHeight = $state(0)
const useSpotlightLayout = $derived(primaryTile !== undefined)
const useMultiGrid = $derived(!useSpotlightLayout && videoTiles.length > 2)
const multiGridClass = $derived(
layout === VideoCallLayout.Split ? "grid-cols-1" : "grid-cols-1 sm:grid-cols-2",
const useMultiGrid = $derived(!useSpotlightLayout)
const tileGrid = $derived<AdaptiveTileGrid | undefined>(
useMultiGrid ? computeAdaptiveGrid(videoTiles.length, gridWidth, gridHeight) : undefined,
)
$effect(() => {
@ -217,15 +199,18 @@
)
</script>
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
{#snippet videoTile(tile: VideoTileData, layout: TileLayoutVariant)}
{@const media = $mediaStateByIdentity(tile.liveKitIdentity)}
{@const label = labelFor(tile.liveKitIdentity, tile.source)}
<div
class={cx(
"relative isolate overflow-hidden rounded-2xl shadow-sm",
// bg-surface-more (rather than bg-surface, same as the panel behind it) so a
// camera-off tile reads as its own card instead of blending into the panel.
"relative isolate overflow-hidden rounded-2xl border border-line shadow-sm",
layout === "spotlight" && "min-h-0 flex-1",
layout === "default" && "aspect-video w-full min-h-0",
layout === "default" && "min-h-0 h-full w-full",
layout === "strip" && "aspect-video w-44 shrink-0",
tile.source === Track.Source.ScreenShare ? "bg-black" : "bg-surface",
tile.source === Track.Source.ScreenShare ? "bg-black" : "bg-surface-more",
)}>
{#if tile.track}
<VideoCallTile
@ -249,12 +234,13 @@
{/if}
<span
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-surface/80 px-1.5 py-0.5 text-xs">
{labelFor(tile.liveKitIdentity, tile.source)}{tile.isLocal ? " (you)" : ""}
{label}{tile.isLocal ? " (you)" : ""}
</span>
{#if videoTiles.length > 1}
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
<Button
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
aria-label={pinned ? `Exit spotlight for ${label}` : `Spotlight ${label}`}
aria-pressed={pinned}
class={cx(
`button button-${pinned ? "primary" : "ghost"} button-xs button-square`,
@ -283,21 +269,35 @@
{/if}
</div>
{:else if useMultiGrid}
<div class={cx("grid min-h-0 flex-1 content-start gap-2 overflow-y-auto", multiGridClass)}>
{#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
bind:clientWidth={gridWidth}
bind:clientHeight={gridHeight}
class="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
{#if tileGrid}
<div class="flex flex-col items-center gap-2">
{#each tileGrid.rows as row, rowIndex}
{@const offset = tileGrid.rows
.slice(0, rowIndex)
.reduce((sum, r) => sum + r.columnCount, 0)}
<div
class="flex flex-nowrap justify-center gap-2"
style={`max-width: ${row.rowWidth}px`}>
{#each videoTiles.slice(offset, offset + row.columnCount) as tile (tileKey(tile))}
<div
class="overflow-hidden rounded-2xl shrink-0"
style={`width: ${row.tileWidth}px; height: ${row.tileHeight}px`}>
{@render videoTile(tile, "default")}
</div>
{/each}
</div>
{/each}
</div>
{/if}
</div>
{/if}
{:else}
<div
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-2xl bg-surface p-4 text-center text-sm opacity-80">
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-2xl border border-line bg-surface-more p-4 text-center text-sm opacity-80">
<p>Waiting for participants…</p>
</div>
{/if}
@ -305,17 +305,24 @@
{#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}
{@render videoPanelBody()}
</div>
<!-- fixed (viewport-relative), not absolute within panelChrome — panelChrome's
flex-computed height isn't guaranteed to reach the real viewport bottom, so
an absolute child positioned against its box can end up overlapping the
fixed bottom nav bar. Matches the same bottom-nav-clearing offset the old
chat FAB used.
RoomChat mounts both a desktop and a mobile VideoCallContent instance at
once while connected (each gated by its own `class` prop, e.g. "md:hidden"
vs "hidden ... md:flex"), so this fixed bar needs the same mobile/desktop
split — otherwise both instances render their own copy at the identical
screen position simultaneously. -->
<div
class={cx(
"left-content pointer-events-none fixed right-sai z-popover justify-center bottom-[calc(4.5rem+var(--saib))] md:bottom-4",
mobile ? "flex md:hidden" : "hidden md:flex",
)}>
<CallControlBar {url} {h} />
</div>
{/if}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import {spec} from "@welshman/lib"
import {RoomEvent, Track} from "livekit-client"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Modal from "@lib/components/Modal.svelte"
@ -8,6 +9,7 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import MicLevelMeter from "@app/components/MicLevelMeter.svelte"
import {
currentCallSession,
DeviceKind,
@ -31,6 +33,11 @@
let selectedInput = $state("")
let selectedOutput = $state("")
let selectedVideo = $state("")
let micTrack = $state<MediaStreamTrack | undefined>(undefined)
const readMicTrack = (session: CallSession): MediaStreamTrack | undefined =>
session.livekit.localParticipant.getTrackPublication(Track.Source.Microphone)?.track
?.mediaStreamTrack
const loadDevices = async () => {
if (!navigator.mediaDevices?.enumerateDevices) return
@ -61,10 +68,29 @@
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput)
// Mic mute/unmute publishes or unpublishes the microphone track; keep the
// level meter in sync with whichever track is actually live right now.
const {livekit} = session
const refreshMicTrack = () => {
micTrack = readMicTrack(session)
}
refreshMicTrack()
livekit.on(RoomEvent.LocalTrackPublished, refreshMicTrack)
livekit.on(RoomEvent.LocalTrackUnpublished, refreshMicTrack)
return () => {
livekit.off(RoomEvent.LocalTrackPublished, refreshMicTrack)
livekit.off(RoomEvent.LocalTrackUnpublished, refreshMicTrack)
}
})
const onInputChange = () => {
void switchCallActiveDevice(DeviceKind.AudioInput, selectedInput)
const onInputChange = async () => {
await switchCallActiveDevice(DeviceKind.AudioInput, selectedInput)
// Switching the active device restarts the existing LocalAudioTrack in
// place with a new MediaStreamTrack rather than emitting a publish event,
// so the meter needs an explicit re-read here.
const session = $currentCallSession
if (session) micTrack = readMicTrack(session)
}
const onOutputChange = () => {
@ -109,6 +135,7 @@
</select>
{/snippet}
</FieldInline>
<MicLevelMeter track={micTrack} offMessage="Microphone is off — unmute to test it" />
{#if canPickOutput}
<FieldInline>
{#snippet label()}

View file

@ -35,6 +35,10 @@
const {url, h, replaceState = false, notification = false}: Props = $props()
// Beyond this many participants, a full name+avatar row per person makes the
// sidebar item too tall — fall back to the compact ProfileCircles cluster instead.
const ACTIVE_LIST_MAX = 5
const participants = deriveCallParticipants(url, h)
const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : [])))
const isActive = $derived(
@ -85,7 +89,7 @@
<RoomName {url} {h} />
</div>
{#if participantPubkeys.length > 0}
{#if isActive}
{#if isActive && $participants.length <= ACTIVE_LIST_MAX}
{#each $participants as p (participantKey(p as CallParticipant))}
{@const media = $mediaStateByIdentity(p.liveKitIdentity)}
<div class="flex items-center gap-2 ml-6">

View file

@ -13,6 +13,7 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import {AbortError, TimeoutError} from "$lib/util"
import MicLevelMeter from "@app/components/MicLevelMeter.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {displayRoom} from "@app/rooms"
import {deriveCallParticipants, joinVoiceRoom, loadCallParticipants} from "@app/call"
@ -33,6 +34,7 @@
let audioInputs = $state<MediaDeviceInfo[]>([])
let selectedDeviceId = $state("")
let startWithoutMic = $state(false)
let micError = $state(false)
const loadDevices = async () => {
if (!navigator.mediaDevices?.enumerateDevices) return
@ -49,7 +51,60 @@
void loadDevices()
})
const goBack = () => history.back()
// Live mic level meter so the user can confirm their selected microphone is
// actually picking up sound before joining, rather than finding out mid-call.
// The stream handle stays a plain local while only the track is reactive: the
// effect below both reads and writes it, and reading reactive state an effect
// also writes makes that effect depend on its own output, so it would re-run
// forever and cancel the in-flight getUserMedia every time.
let previewStream: MediaStream | undefined
let previewTrack = $state<MediaStreamTrack | undefined>(undefined)
const stopMicPreview = () => {
previewStream?.getTracks().forEach(t => t.stop())
previewStream = undefined
previewTrack = undefined
}
$effect(() => {
// Re-runs when selectedDeviceId or startWithoutMic changes.
void selectedDeviceId
const withoutMic = startWithoutMic
stopMicPreview()
micError = false
if (withoutMic || !navigator.mediaDevices?.getUserMedia) return
let cancelled = false
const deviceId = selectedDeviceId
void (async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: deviceId ? {deviceId: {exact: deviceId}} : true,
})
if (cancelled) {
stream.getTracks().forEach(t => t.stop())
return
}
previewStream = stream
previewTrack = stream.getAudioTracks()[0]
} catch {
if (!cancelled) micError = true
}
})()
return () => {
cancelled = true
stopMicPreview()
}
})
const goBack = () => {
stopMicPreview()
history.back()
}
const handleJoinError = (e: unknown) => {
if (e instanceof AbortError) return
@ -62,6 +117,7 @@
}
const joinVoice = async () => {
stopMicPreview()
popModal()
await joinVoiceRoom(
url,
@ -121,6 +177,13 @@
</select>
{/snippet}
</FieldInline>
{#if !startWithoutMic}
<MicLevelMeter
track={previewTrack}
error={micError
? "Could not access this microphone. Check your browser permissions, or join without one."
: undefined} />
{/if}
</div>
</ModalBody>
<ModalFooter>

View file

@ -1,228 +0,0 @@
<script lang="ts">
import {readable} from "svelte/store"
import {fade, fly} from "svelte/transition"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import cx from "classnames"
import {Capacitor} from "@capacitor/core"
import {displayRelayUrl} from "@welshman/util"
import type {Room} from "@welshman/app"
import Microphone from "@assets/icons/microphone.svg?dataurl"
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
import VideocameraOff from "@assets/icons/videocamera-off.svg?dataurl"
import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl"
import Monitor from "@assets/icons/monitor.svg?dataurl"
import EndCall from "@assets/icons/end-call-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 Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
import {rooms} from "@app/core"
import {decodeRelay} from "@app/relays"
import {RoomType, displayRoom, getRoomType} from "@app/rooms"
import {pushModal} from "@app/modal"
import {notifications} from "@app/notifications"
import {makeRoomPath} from "@app/routes"
import {
VideoCallLayout,
isDesktopLayout,
toggleCamera,
toggleScreenShare,
videoCallLayout,
CallState,
currentCallSession,
callTargetRoom,
callMicMuted,
callState,
cancelJoinVoiceRoom,
leaveVoiceRoom,
toggleMute,
} from "@app/call"
const {relay, h} = $derived($page.params)
const url = $derived(relay ? decodeRelay(relay) : undefined)
const displayedRoomStore = $derived(
url && h && typeof h === "string" ? $rooms.forRoom(url, h) : readable(undefined),
)
const routeDisplayedRoom = $derived($displayedRoomStore)
const isViewingCurrentVoiceRoom = $derived(
$callTargetRoom !== undefined &&
url !== undefined &&
typeof h === "string" &&
$callTargetRoom.url === url &&
$callTargetRoom.h === h,
)
const targetRoom = $derived.by((): Room | undefined => {
if ($callState === CallState.Joining || $callState === CallState.Connected) {
return $callTargetRoom
}
if ($callState === CallState.Disconnected) {
if (routeDisplayedRoom) {
if (getRoomType(routeDisplayedRoom) === RoomType.Voice) {
return routeDisplayedRoom
}
return undefined
}
return $callTargetRoom
}
return $callTargetRoom
})
const roomName = $derived(targetRoom ? displayRoom(targetRoom.url, targetRoom.h) : "")
const spaceName = $derived(targetRoom ? displayRelayUrl(targetRoom.url) : "")
const openJoinDialog = async () => {
if (!targetRoom) return
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($callState === CallState.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)),
)
</script>
{#if targetRoom}
<div
in:fly={{y: 60, duration: 350}}
out:fly={{y: 60, duration: 250}}
class="card card-sm flex flex-col gap-2">
<div class="flex items-start justify-between gap-2">
<Button
class="min-w-0 flex-1 rounded-xl px-2 py-1 text-left hover:bg-surface"
onclick={goToRoom}
aria-label="Open room {roomName}">
<div class="flex flex-col gap-0.5">
{#if $callState === CallState.Joining}
<span class="text-sm font-semibold text-warning">Joining...</span>
{:else if $callState === CallState.Connected}
<span class="text-sm font-semibold text-success">Voice Connected</span>
{:else}
<span class="text-sm font-semibold text-content-muted">Disconnected</span>
{/if}
<span class="truncate min-w-0 text-xs text-content-muted">
{roomName} / {spaceName}
</span>
</div>
</Button>
{#if showChatButton}
<Button
data-tip="Toggle Chat"
class={cx(
"button button-sm button-square tip tip-top relative",
isChatPanelActive && "button-primary",
)}
onclick={onChatToggle}>
<Icon icon={ChatRound} size={4} />
{#if chatUnread}
<span
transition:fade={{duration: 150}}
class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"
aria-hidden="true"></span>
{/if}
</Button>
{/if}
</div>
<div class="flex items-center gap-1.5">
{#if $callState === CallState.Joining}
<Spinner size="sm" />
<Button
data-tip="Cancel"
class="button button-sm button-square tip tip-top"
onclick={cancelJoinVoiceRoom}>
<Icon icon={CloseCircle} size={4} />
</Button>
{:else if $callState === CallState.Connected && $currentCallSession}
<Button
data-tip={$callMicMuted ? "Unmute" : "Mute"}
class={cx(
"button button-sm button-square tip tip-top",
$callMicMuted ? "button-neutral" : "button-primary",
)}
onclick={toggleMute}>
<Icon icon={$callMicMuted ? MicrophoneOff : Microphone} size={$callMicMuted ? 4 : 4.5} />
</Button>
<Button
data-tip={$currentCallSession.cameraOn ? "Turn off camera" : "Turn on camera"}
class={cx(
"button button-sm button-square tip tip-top",
$currentCallSession.cameraOn ? "button-primary" : "button-neutral",
)}
onclick={toggleCamera}>
<Icon icon={$currentCallSession.cameraOn ? VideocameraRecord : VideocameraOff} size={4} />
</Button>
{#if !Capacitor.isNativePlatform()}
<Button
data-tip={$currentCallSession.screenShareOn ? "Stop sharing" : "Share screen"}
class={cx(
"button button-sm button-square tip tip-top",
$currentCallSession.screenShareOn ? "button-primary" : "button-neutral",
)}
onclick={toggleScreenShare}>
<Icon icon={Monitor} size={3.75} />
</Button>
{/if}
<Button
data-tip="Call settings"
class="button button-neutral button-sm button-square tip tip-top"
onclick={openCallSettings}>
<Icon icon={Settings} size={4} />
</Button>
<Button
data-tip="Leave room"
class="button button-error button-sm button-square tip tip-top"
onclick={leaveVoiceRoom}>
<Icon icon={EndCall} size={4.5} />
</Button>
{:else}
<Button
data-tip="Join Voice"
class="button button-primary button-sm button-square tip tip-top"
onclick={openJoinDialog}>
<Icon icon={PhoneCallingRounded} size={4.5} />
</Button>
{/if}
</div>
</div>
{/if}

View file

@ -12,6 +12,7 @@ import {
Domain,
FollowLists,
Handles,
Logger,
MessagingRelayLists,
MuteLists,
Network,
@ -39,7 +40,6 @@ import {
} from "@welshman/app"
import type {AppPolicy, DerivedPlugin, Plugin, Session} from "@welshman/app"
import type {BaseEventReader, EventWriter, KindFactory} from "@welshman/domain"
import {maybeMakeRelayMockAdapter} from "@lib/test/relayMocks"
import {DEFAULT_RELAYS, DEFAULT_SEARCH_RELAYS, DUFFLEPUD_URL, INDEXER_RELAYS} from "@app/env"
// Flotilla's own policies (ingest, sockets, storage) can't be imported here — they depend on
@ -55,9 +55,6 @@ export const appPolicies: AppPolicy[] = [
const makeApp = (user?: User) => {
const instance: App = new App({
user,
// Test-only: when Playwright has injected window.__RELAY_MOCKS__, serve relays from in-memory
// fixtures instead of the network. No-op for real users; stripped from production builds.
getAdapter: import.meta.env.DEV ? maybeMakeRelayMockAdapter() : undefined,
config: {
dufflepudUrl: DUFFLEPUD_URL,
getDefaultRelays: always(DEFAULT_RELAYS),
@ -146,6 +143,7 @@ export const deletes = usePlugin(Deletes)
export const domain = usePlugin(Domain)
export const followLists = usePlugin(FollowLists)
export const handles = usePlugin(Handles)
export const logger = usePlugin(Logger)
export const messagingRelayLists = usePlugin(MessagingRelayLists)
export const muteLists = usePlugin(MuteLists)
export const network = usePlugin(Network)

View file

@ -1,5 +1,9 @@
const store = new Map<string, unknown>()
export type Draft = {
content?: string | object
}
export class DraftKey<T> {
constructor(private key: string) {}

View file

@ -20,7 +20,7 @@ import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
import {RoomReferenceExtension} from "@app/editor/RoomReferenceExtension"
import RoomSuggestion from "@app/editor/RoomSuggestion.svelte"
import {NativeClipboardPasteExtension} from "@app/editor/clipboard"
import {compressFileForUpload, uploadFile} from "@app/uploads"
import {UPLOAD_MIME_TYPES, compressFileForUpload, uploadFile} from "@app/uploads"
import {userSpaceUrls} from "@app/rooms"
import {PLATFORM_RELAYS} from "@app/env"
import {pushToast} from "@app/toast"
@ -108,6 +108,7 @@ export const makeEditor = async ({
},
fileUpload: {
config: {
allowedMimeTypes: UPLOAD_MIME_TYPES,
upload: async (attrs: FileAttributes) =>
uploadFile(await compressFileForUpload(attrs.file), {url, encrypt: encryptFiles}),
onDrop: () => uploading?.set(true),

View file

@ -2,71 +2,82 @@ import {Capacitor} from "@capacitor/core"
import * as nip19 from "nostr-tools/nip19"
import {identity} from "@welshman/lib"
import {normalizeRelayUrl} from "@welshman/util"
import {maybeGetTestEnv} from "@lib/test/env"
const fromCsv = (s: string) => (s || "").split(",").filter(identity)
export const PUSH_SERVER = import.meta.env.VITE_PUSH_SERVER
// Test-only: when Playwright has injected window.__TEST_ENV__, VITE_ values resolve against it, so
// each browser context can be pointed at the relays its own test created. Vite folds DEV to false
// in a production build, so the branch and the import are stripped from it.
const env = (key: string): string =>
(import.meta.env.DEV ? maybeGetTestEnv(key) : undefined) ?? import.meta.env[key]
export const PUSH_BRIDGE = normalizeRelayUrl(import.meta.env.VITE_PUSH_BRIDGE)
export const PUSH_SERVER = env("VITE_PUSH_SERVER")
export const PUSH_BRIDGE = normalizeRelayUrl(env("VITE_PUSH_BRIDGE"))
export const EMAIL_NOTIFICATION_SERVER = import.meta.env.VITE_EMAIL_NOTIFICATION_SERVER
export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios"
export const SIGNER_RELAYS = fromCsv(import.meta.env.VITE_SIGNER_RELAYS).map(normalizeRelayUrl)
export const SIGNER_RELAYS = fromCsv(env("VITE_SIGNER_RELAYS")).map(normalizeRelayUrl)
export const BLOCKED_RELAYS = fromCsv(import.meta.env.VITE_BLOCKED_RELAYS).map(normalizeRelayUrl)
export const BLOCKED_RELAYS = fromCsv(env("VITE_BLOCKED_RELAYS")).map(normalizeRelayUrl)
export const INDEXER_RELAYS = fromCsv(import.meta.env.VITE_INDEXER_RELAYS).map(normalizeRelayUrl)
export const INDEXER_RELAYS = fromCsv(env("VITE_INDEXER_RELAYS")).map(normalizeRelayUrl)
export const DEFAULT_RELAYS = fromCsv(import.meta.env.VITE_DEFAULT_RELAYS).map(normalizeRelayUrl)
export const DEFAULT_RELAYS = fromCsv(env("VITE_DEFAULT_RELAYS")).map(normalizeRelayUrl)
export const DEFAULT_SEARCH_RELAYS = fromCsv(import.meta.env.VITE_DEFAULT_SEARCH_RELAYS).map(
export const DEFAULT_SEARCH_RELAYS = fromCsv(env("VITE_DEFAULT_SEARCH_RELAYS")).map(
normalizeRelayUrl,
)
export const DEFAULT_MESSAGING_RELAYS = fromCsv(import.meta.env.VITE_DEFAULT_MESSAGING_RELAYS).map(
export const DEFAULT_MESSAGING_RELAYS = fromCsv(env("VITE_DEFAULT_MESSAGING_RELAYS")).map(
normalizeRelayUrl,
)
export const PLATFORM_RELAYS = fromCsv(import.meta.env.VITE_PLATFORM_RELAYS).map(normalizeRelayUrl)
export const PLATFORM_RELAYS = fromCsv(env("VITE_PLATFORM_RELAYS")).map(normalizeRelayUrl)
export const PLATFORM_URL = import.meta.env.VITE_PLATFORM_URL
export const PLATFORM_URL = env("VITE_PLATFORM_URL")
export const PLATFORM_TERMS = import.meta.env.VITE_PLATFORM_TERMS
export const PLATFORM_ABOUT = env("VITE_PLATFORM_ABOUT")
export const PLATFORM_PRIVACY = import.meta.env.VITE_PLATFORM_PRIVACY
export const PLATFORM_TERMS = env("VITE_PLATFORM_TERMS")
export const PLATFORM_PRIVACY = env("VITE_PLATFORM_PRIVACY")
export const PLATFORM_LOGO = import.meta.env.PROD
? PLATFORM_URL + "/logo.png"
: import.meta.env.VITE_PLATFORM_LOGO.replace(/^static/, "") || PLATFORM_URL + "/logo.png"
: env("VITE_PLATFORM_LOGO").replace(/^static/, "") || PLATFORM_URL + "/logo.png"
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
export const PLATFORM_NAME = env("VITE_PLATFORM_NAME")
export const PLATFORM_ACCENT = import.meta.env.VITE_PLATFORM_ACCENT
export const PLATFORM_LOGEE = env("VITE_PLATFORM_LOGEE")
export const PLATFORM_ACCENT = env("VITE_PLATFORM_ACCENT")
// components visual preset (see src/lib/components/theme.css). Selected per
// deployment via VITE_THEME, which is assumed to be set to a known theme
// (e.g. "clay" or "flat") — there is no default.
export const FL_THEME = import.meta.env.VITE_THEME
export const FL_THEME = env("VITE_THEME")
export const PLATFORM_DESCRIPTION = import.meta.env.VITE_PLATFORM_DESCRIPTION
export const PLATFORM_DESCRIPTION = env("VITE_PLATFORM_DESCRIPTION")
export const POMADE_SIGNERS = fromCsv(import.meta.env.VITE_POMADE_SIGNERS)
export const POMADE_SIGNERS = fromCsv(env("VITE_POMADE_SIGNERS"))
export const DEFAULT_BLOSSOM_SERVERS = fromCsv(import.meta.env.VITE_DEFAULT_BLOSSOM_SERVERS)
export const DEFAULT_BLOSSOM_SERVERS = fromCsv(env("VITE_DEFAULT_BLOSSOM_SERVERS"))
export const DEFAULT_SPACES = fromCsv(import.meta.env.VITE_DEFAULT_SPACES).map(normalizeRelayUrl)
export const DEFAULT_SPACES = fromCsv(env("VITE_DEFAULT_SPACES")).map(normalizeRelayUrl)
export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS
export const DEFAULT_PUBKEYS = env("VITE_DEFAULT_PUBKEYS")
export const HOSTING_BACKEND_URL = import.meta.env.VITE_HOSTING_BACKEND_URL
export const HOSTING_BACKEND_URL = env("VITE_HOSTING_BACKEND_URL")
export const HOSTING_RELAY_DOMAIN = import.meta.env.VITE_HOSTING_RELAY_DOMAIN
export const HOSTING_RELAY_DOMAIN = env("VITE_HOSTING_RELAY_DOMAIN")
export const DUFFLEPUD_URL = "https://dufflepud.coracle.social"
export const THUMBNAIL_URL = import.meta.env.VITE_THUMBNAIL_URL
export const THUMBNAIL_URL = env("VITE_THUMBNAIL_URL")
export const dufflepud = (path: string) => DUFFLEPUD_URL + "/" + path

86
src/app/logger.ts Normal file
View file

@ -0,0 +1,86 @@
import type {Unsubscriber} from "svelte/store"
import {Capacitor} from "@capacitor/core"
import {tryCatch} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {DirectMessage} from "@welshman/domain"
import type {Logger} from "@welshman/app"
import {logger, wraps, writer} from "@app/core"
import {PLATFORM_LOGEE} from "@app/env"
const formatValue = (value: unknown) => {
if (value instanceof Error) return value.stack || value.message
if (typeof value === "string") return value
return tryCatch(() => JSON.stringify(value)) || String(value)
}
const bindLogging = ($logger: Logger) => {
const {error} = console
const log = (source: string, value: unknown) => $logger.log(source, {message: formatValue(value)})
const onError = (event: ErrorEvent) => log("error", event.error || event.message)
const onUnhandledRejection = (event: PromiseRejectionEvent) => log("rejection", event.reason)
console.error = (...args: unknown[]) => {
log("console", args.map(formatValue).join(" "))
error(...args)
}
$logger.log("app", {
platform: Capacitor.getPlatform(),
build: import.meta.env.VITE_BUILD_HASH,
userAgent: navigator.userAgent,
})
window.addEventListener("error", onError)
window.addEventListener("unhandledrejection", onUnhandledRejection)
return () => {
console.error = error
window.removeEventListener("error", onError)
window.removeEventListener("unhandledrejection", onUnhandledRejection)
}
}
// Errors are only useful after the fact if someone kept them around, so collect them into the
// app's logger, which the user can hand over via `sendLogs` when something goes wrong. Logging in
// swaps in a fresh app with an empty log, so re-bind against the new one when that happens.
export const setupLogging = () => {
let unbind: Maybe<Unsubscriber>
const unsubscribe = logger.subscribe($logger => {
unbind?.()
unbind = bindLogging($logger)
})
return () => {
unbind?.()
unsubscribe()
}
}
export const sendLogs = async () => {
const messages = logger.get().messages.get()
const lines: string[] = []
let size = 0
// Relays commonly cap events at 64kb, so send only as much recent history as will fit
for (const message of messages.slice().reverse()) {
const line = JSON.stringify(message)
size += line.length
if (size > 32_000) break
lines.unshift(line)
}
const event = await writer(DirectMessage)
.setContent("```" + lines.join("\n") + "```")
.addRecipient(PLATFORM_LOGEE)
.renderTemplate()
return wraps.get().publish({event, recipients: [PLATFORM_LOGEE], pow: 16})
}

View file

@ -4,9 +4,11 @@ import {page} from "$app/stores"
import {assoc, prop, first, identity, groupBy, now, throttle, parseJson, gt} from "@welshman/lib"
import type {SignedEvent, TrustedEvent} from "@welshman/util"
import {
getIdOrAddress,
sortEventsDesc,
tagSpec,
tagValue,
COMMENT,
MESSAGE,
makeHttpAuth,
makeHttpAuthHeader,
@ -15,7 +17,13 @@ import {synced, throttled, withGetter} from "@welshman/store"
import {Relays, RoomLists} from "@welshman/app"
import {deriveEventsByIdByUrl} from "@app/repository"
import {app, fromApp} from "@app/core"
import {makeSpacePath, makeRoomPath, makeSpaceChatPath, makeChatPath} from "@app/routes"
import {
makeSpacePath,
makeRoomPath,
makeSpaceChatPath,
makeChatPath,
makeContentPath,
} from "@app/routes"
import {CONTENT_KINDS, makeCommentFilter} from "@app/content"
import {notificationSettings} from "@app/settings"
import {chatsById} from "@app/chats"
@ -174,6 +182,42 @@ export const syncCheckedRemote = () => {
// Derived notifications state
// The content item an event belongs to - either the content event itself, or the root of a comment
const getContentTarget = (event: TrustedEvent) => {
if (CONTENT_KINDS.includes(event.kind)) {
return {kind: event.kind, idOrAddress: getIdOrAddress(event)}
}
if (event.kind === COMMENT) {
const kind = parseInt(tagValue(tagSpec("K"), event.tags) || "")
const idOrAddress = tagValue(tagSpec("A"), event.tags) || tagValue(tagSpec("E"), event.tags)
if (CONTENT_KINDS.includes(kind) && idOrAddress) {
return {kind, idOrAddress}
}
}
}
// Assumes `events` is sorted descending, so the first event seen per content item wins.
const latestEventByContentPath = (url: string, events: TrustedEvent[]) => {
const byPath = new Map<string, {listPath: string; latestEvent: TrustedEvent}>()
for (const event of events) {
const target = getContentTarget(event)
if (!target) continue
const path = makeContentPath(url, target.kind, target.idOrAddress)
const listPath = makeContentPath(url, target.kind)
if (path && listPath && !byPath.has(path)) {
byPath.set(path, {listPath, latestEvent: event})
}
}
return byPath
}
export const allNotifications = derived(
throttled(
1000,
@ -256,6 +300,17 @@ export const allNotifications = derived(
}
}
}
for (const [path, {listPath, latestEvent}] of latestEventByContentPath(url, events)) {
if (hasNotification(path, latestEvent)) {
paths.add(path)
paths.add(listPath)
if (hasNotification(spacePath, latestEvent)) {
paths.add(spacePath)
}
}
}
}
return paths

View file

@ -25,7 +25,7 @@ import {User} from "@welshman/app"
import {app, messagingRelayLists, network, roomLists} from "@app/core"
import {DM_KINDS, CONTENT_KINDS, makeCommentFilter} from "@app/content"
import {notificationSettings, shouldNotify, userSettingsValues} from "@app/settings"
import {getEventPath, goToSpace} from "@app/routes"
import {makeEventPath, goToSpace} from "@app/routes"
export type PushSubscription = {
key: string
@ -119,7 +119,7 @@ export const onPushNotificationAction = async (action: ActionPerformed) => {
})
if (event) {
goto(await getEventPath(event, [relay]))
goto(await makeEventPath(event, [relay]))
} else {
goToSpace(relay)
}

View file

@ -15,7 +15,7 @@ import {
toNostrURI,
} from "@welshman/util"
import type {EventContent, TrustedEvent} from "@welshman/util"
import {RoomLists, makeRoomKey} from "@welshman/app"
import {RoomLists, makeRoomKey, createSearch} from "@welshman/app"
import type {Room, RoomMeta} from "@welshman/app"
import {
deriveUserItem,
@ -25,10 +25,12 @@ import {
rooms,
router,
thunks,
relays,
user,
} from "@app/core"
import {deriveUserIsSpaceAdmin} from "@app/management"
import {deriveEventsForUrl} from "@app/repository"
import {makeRoomPath} from "@app/routes"
export enum MembershipStatus {
Initial,
@ -218,3 +220,30 @@ export const deriveUserRoomMembershipStatus = (url: string, h: string) =>
return isMember ? MembershipStatus.Granted : MembershipStatus.Initial
},
)
export const deriveUserRoomSearch = () =>
derived(
[userSpaceUrls, userRoomList, rooms.get().byUrl.$],
([$userSpaceUrls, $userRoomList, $roomsByUrl]) => {
const options = $userSpaceUrls.flatMap(url => {
const favorites = new Set($userRoomList?.roomsForUrl(url) ?? [])
// Spaces that aren't NIP-29 relays keep all their messages in the space-wide chat
const roomIds = relays.get().get(url)?.hasNip(29)
? ($roomsByUrl.get(url) ?? []).map(room => room.h)
: ["chat"]
return roomIds.map(h => ({
url,
h,
name: displayRoom(url, h),
isFavorite: favorites.has(h),
}))
})
return createSearch(options, {
getValue: option => makeRoomPath(option.url, option.h),
fuseOptions: {keys: ["name", "url"]},
sortFn: ({item}) => (item.isFavorite ? 0 : 1),
})
},
)

View file

@ -13,7 +13,7 @@ import {
POLL,
THREAD,
ZAP_GOAL,
getAddress,
getIdOrAddress,
hexTags,
tagSpec,
tagValue,
@ -57,13 +57,13 @@ export const makeRoomPath = (url: string, h: string) => `/spaces/${encodeRelay(u
export const makeSpaceChatPath = (url: string) => makeRoomPath(url, "chat")
export const goToChat = (pubkeys: string[] = []) => {
export const goToChat = (pubkeys: string[] = [], options: {replaceState?: boolean} = {}) => {
if (messagingRelayLists.get().urls(user.get().pubkey).get().length === 0) {
pushModal(ChatEnable, {next: () => goToChat(pubkeys)})
pushModal(ChatEnable, {next: () => goToChat(pubkeys, options)})
} else if (pubkeys.length === 0) {
goto(lastChatUrl ?? "/chat")
goto(lastChatUrl ?? "/chat", options)
} else {
goto(makeChatPath(pubkeys))
goto(makeChatPath(pubkeys), options)
}
}
@ -107,10 +107,10 @@ export const goToMovedSpace = (oldUrl: string, newUrl: string) =>
export const goToHome = () => {
if (PLATFORM_RELAYS.length > 0) {
goToSpace(PLATFORM_RELAYS[0], get(page).url.hash)
} else {
goto("/home" + get(page).url.hash)
return goToSpace(PLATFORM_RELAYS[0], get(page).url.hash)
}
return goto("/home" + get(page).url.hash)
}
// Content types, events
@ -135,8 +135,83 @@ export const makeCalendarPath = (url: string, address?: string) =>
export const makePollPath = (url: string, id?: string) => makeSpacePath(url, "polls", id)
export const makeLibraryPath = (url: string, address?: string) =>
makeSpacePath(url, "library", address)
// Shelves are selected in place on the library page rather than having their own
// route, so the address goes in a query param.
export const makeLibraryPath = (url: string, address?: string) => {
const path = makeSpacePath(url, "library")
if (address) {
return path + "?" + new URLSearchParams({board: address}).toString()
}
return path
}
export const makeContentPath = (url: string, kind: number, idOrAddress?: string) => {
switch (kind) {
case ZAP_GOAL:
return makeGoalPath(url, idOrAddress)
case THREAD:
return makeThreadPath(url, idOrAddress)
case CLASSIFIED:
return makeClassifiedPath(url, idOrAddress)
case EVENT_TIME:
return makeCalendarPath(url, idOrAddress)
case PINBOARD:
return makeLibraryPath(url, idOrAddress)
case POLL:
return makePollPath(url, idOrAddress)
}
}
export const makeEventPath = (event: TrustedEvent, urls: string[]) => {
if (DM_KINDS.includes(event.kind)) {
return makeChatPath([event.pubkey, ...tagValues(hexTags("p"), event.tags)])
}
if (urls.length > 0) {
const url = urls[0]
if (event.kind === MESSAGE) {
return makeMessagePath(url, event)
}
const path = makeContentPath(url, event.kind, getIdOrAddress(event))
if (path) {
return path
}
const parentKind = tagValue(tagSpec("K"), event.tags)
const parentIdOrAddress =
tagValue(tagSpec("A"), event.tags) ?? tagValue(tagSpec("E"), event.tags)
if (parentKind && parentIdOrAddress) {
if (parseInt(parentKind) === MESSAGE) {
return makeMessagePath(url, event)
}
const parentPath = makeContentPath(url, parseInt(parentKind), parentIdOrAddress)
if (parentPath) {
return parentPath
}
}
}
return entityLink(nip19.neventEncode({id: event.id, relays: urls}))
}
export const makeEventPermalink = (event: TrustedEvent, url?: string) => {
const urls = url ? [url] : Array.from(app.get().tracker.getRelays(event.id))
const path = makeEventPath(event, urls)
if (path.includes("://")) {
return path
}
return `${PLATFORM_URL}${path}#${nip19.neventEncode({id: event.id, relays: urls})}`
}
export const scrollToEvent = (id: string) => {
const element = document.querySelector(`[data-event="${id}"]`) as any
@ -159,7 +234,7 @@ export const scrollToEvent = (id: string) => {
export const goToEvent = (event: TrustedEvent, options: Record<string, any> = {}) => {
const urls = Array.from(app.get().tracker.getRelays(event.id))
const path = getEventPath(event, urls)
const path = makeEventPath(event, urls)
if (path.includes("://")) {
window.open(path)
@ -169,99 +244,3 @@ export const goToEvent = (event: TrustedEvent, options: Record<string, any> = {}
goto(path, {replaceState, ...options})
}
}
export const getEventPath = (event: TrustedEvent, urls: string[]) => {
if (DM_KINDS.includes(event.kind)) {
return makeChatPath([event.pubkey, ...tagValues(hexTags("p"), event.tags)])
}
if (urls.length > 0) {
const url = urls[0]
if (event.kind === ZAP_GOAL) {
return makeGoalPath(url, event.id)
}
if (event.kind === THREAD) {
return makeThreadPath(url, event.id)
}
if (event.kind === CLASSIFIED) {
return makeClassifiedPath(url, getAddress(event))
}
if (event.kind === EVENT_TIME) {
return makeCalendarPath(url, getAddress(event))
}
if (event.kind === PINBOARD) {
return makeLibraryPath(url, getAddress(event))
}
if (event.kind === POLL) {
return makePollPath(url, event.id)
}
if (event.kind === MESSAGE) {
return makeMessagePath(url, event)
}
const address = tagValue(tagSpec("A"), event.tags)
const kind = tagValue(tagSpec("K"), event.tags)
const id = tagValue(tagSpec("E"), event.tags)
if (id && kind) {
if (parseInt(kind) === ZAP_GOAL) {
return makeGoalPath(url, id)
}
if (parseInt(kind) === THREAD) {
return makeThreadPath(url, id)
}
if (parseInt(kind) === MESSAGE) {
return makeMessagePath(url, event)
}
}
if (address && kind) {
if (parseInt(kind) === CLASSIFIED) {
return makeClassifiedPath(url, address)
}
if (parseInt(kind) === EVENT_TIME) {
return makeCalendarPath(url, address)
}
}
}
return entityLink(nip19.neventEncode({id: event.id, relays: urls}))
}
export const makeEventPermalink = (event: TrustedEvent, url?: string) => {
const urls = url ? [url] : Array.from(app.get().tracker.getRelays(event.id))
const path = getEventPath(event, urls)
if (path.includes("://")) {
return path
}
return `${PLATFORM_URL}${path}#${nip19.neventEncode({id: event.id, relays: urls})}`
}
export const getRoomItemPath = (url: string, event: TrustedEvent) => {
switch (event.kind) {
case THREAD:
return makeThreadPath(url, event.id)
case CLASSIFIED:
return makeClassifiedPath(url, getAddress(event))
case ZAP_GOAL:
return makeGoalPath(url, event.id)
case EVENT_TIME:
return makeCalendarPath(url, getAddress(event))
case PINBOARD:
return makeLibraryPath(url, getAddress(event))
case POLL:
return makePollPath(url, event.id)
}
}

View file

@ -2,6 +2,7 @@ import type {ClientOptions} from "@pomade/core"
import type {Wallet} from "@welshman/util"
import {nip01, nip07, nip46, nip55, pomade, toSession} from "@welshman/app"
import type {Session} from "@welshman/app"
import {getTestEvents, maybeGetTestSession} from "@lib/test/session"
import {app, login, session} from "@app/core"
import {wallet} from "@app/lightning"
import {kv, ss, storage} from "@app/storage"
@ -61,12 +62,27 @@ const readLegacySession = async () => {
// The session is derived from the app's user, so it can't be synced to storage directly —
// read it back once at startup, then persist it whenever the identity changes.
export const restoreSession = async () => {
const $session = (await ss.get<Session>("session")) ?? (await readLegacySession())
// Test-only: when Playwright has injected window.__TEST_SESSION__, that identity wins over
// whatever is in storage. No-op for real users; stripped from production builds.
const testSession = import.meta.env.DEV ? maybeGetTestSession() : undefined
const $session = testSession ?? (await ss.get<Session>("session")) ?? (await readLegacySession())
if ($session) {
await login($session)
}
// Logging in builds a fresh app, so the test's cached events go into its repository afterwards,
// the same way storage loads what the last session left behind. Storage gets there first and
// loads with Repository.load, which clears the repository before inserting, so these have to
// wait for it or they are wiped before anything reads them.
if (testSession) {
await storage.get()?.ready
for (const event of getTestEvents()) {
app.get().repository.publish(event)
}
}
return session.subscribe($session => {
if ($session) {
ss.set("session", $session)

View file

@ -26,7 +26,6 @@ export type SettingsValues = {
hide_sensitive: boolean
trusted_relays: string[]
report_usage: boolean
report_errors: boolean
relay_auth: RelayAuthMode
send_delay: number
font_size: number
@ -39,7 +38,6 @@ export const defaultSettings: SettingsValues = {
hide_sensitive: true,
trusted_relays: [],
report_usage: true,
report_errors: true,
relay_auth: RelayAuthMode.Conservative,
send_delay: 0,
font_size: 1.1,

View file

@ -1,18 +1,108 @@
import {writable} from "svelte/store"
import {goto} from "$app/navigation"
import {Capacitor, registerPlugin} from "@capacitor/core"
import type {PluginListenerHandle} from "@capacitor/core"
import {noop} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {setKey} from "@lib/implicit"
import {relays} from "@app/core"
import {app, relays} from "@app/core"
import {pushModal} from "@app/modal"
import {makeSpaceChatPath} from "@app/routes"
import EventShare from "@app/components/EventShare.svelte"
import {pushToast} from "@app/toast"
import {UPLOAD_MIME_TYPES} from "@app/uploads"
import ShareDialog from "@app/components/Share.svelte"
import ShareEvent from "@app/components/ShareEvent.svelte"
// Share an event to chat. On NIP-29 relays we prompt for a room to share into;
// otherwise we stash the event and jump to the space chat composer, which quotes it.
export const shareEventToChat = (url: string, noun: string, event: TrustedEvent) => {
export type Share =
| {type: "event"; value: TrustedEvent}
| {type: "text"; value: string}
| {type: "file"; value: File}
export const pendingShare = writable<Maybe<Share>>(undefined)
// Set pendingShare after goto so the current view doesn't pop it
export const shareTo = async (
path: string,
share: Share,
options: {replaceState?: boolean} = {},
) => {
await goto(path, options)
pendingShare.set(share)
}
export const shareEvent = (url: string, noun: string, event: TrustedEvent) => {
if (relays.get().get(url)?.hasNip(29)) {
pushModal(EventShare, {url, noun, event})
pushModal(ShareEvent, {url, noun, event})
} else {
setKey("share", event)
goto(makeSpaceChatPath(url))
shareTo(makeSpaceChatPath(url), {type: "event", value: event})
}
}
const openShareDialog = (share: Share) => {
if (app.get().user?.pubkey) {
pushModal(ShareDialog, {share})
}
}
export const shareText = (value: string) => openShareDialog({type: "text", value})
type NativeShare = {
text?: string
path?: string
name?: string
type?: string
}
// Shared media is copied somewhere capacitor's file server can reach, since neither an android
// content uri nor an ios app group url means anything to the web view.
export const shareFromNative = async ({
text,
path,
name = "",
type: mimeType = "",
}: NativeShare) => {
if (text) {
shareText(text)
} else if (path) {
if (!UPLOAD_MIME_TYPES.includes(mimeType)) {
return pushToast({theme: "error", message: "Flotilla can't share that type of file."})
}
try {
const response = await fetch(Capacitor.convertFileSrc(path))
if (!response.ok) {
throw new Error(`Failed to read ${path} (${response.status})`)
}
const value = new File([await response.blob()], name, {type: mimeType})
openShareDialog({type: "file", value})
} catch (e) {
console.error(e)
pushToast({theme: "error", message: "Something went wrong reading the shared file."})
}
}
}
type ShareIntentPlugin = {
addListener(
eventName: "shareReceived",
listener: (share: NativeShare) => void,
): Promise<PluginListenerHandle>
}
const ShareIntent = registerPlugin<ShareIntentPlugin>("ShareIntent")
export const setupShareIntents = () => {
if (Capacitor.getPlatform() === "android") {
const listener = ShareIntent.addListener("shareReceived", shareFromNative)
return () => {
listener.then(handle => handle.remove())
}
}
return noop
}

View file

@ -12,18 +12,16 @@ import {
import type {TrustedEvent} from "@welshman/util"
import {withGetter} from "@welshman/store"
import {getCommentTagValues, getReplyTagValues} from "@welshman/domain"
import {followLists, muteLists, profiles, user} from "@app/core"
import {FollowLists, MuteLists} from "@welshman/app"
import {deriveUserItem, profiles, user} from "@app/core"
import {DEFAULT_PUBKEYS} from "@app/env"
export const bootstrapPubkeys = derived(
[user, followLists.get().index.$],
([$user, $followLists]) => {
const appPubkeys = DEFAULT_PUBKEYS.split(",")
const userPubkeys = shuffle($followLists.get($user.pubkey)?.pubkeys() ?? [])
export const bootstrapPubkeys = derived(deriveUserItem(FollowLists), $userFollowList => {
const appPubkeys = DEFAULT_PUBKEYS.split(",")
const userPubkeys = shuffle($userFollowList?.pubkeys() ?? [])
return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys]
},
)
return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys]
})
// Ids and addresses of an event's immediate parents, falling back to its thread roots.
const getParents = ({kind, tags}: TrustedEvent) => {
@ -33,9 +31,8 @@ const getParents = ({kind, tags}: TrustedEvent) => {
}
export const isEventMuted = withGetter(
derived([user, muteLists.get().index.$], ([$user, $muteLists]) => {
const muteList = $muteLists.get($user.pubkey)
const tags = muteList?.tags() ?? []
derived([user, deriveUserItem(MuteLists)], ([$user, $muteList]) => {
const tags = $muteList?.tags() ?? []
const mutedEvents = new Set(tagValues(hexTags("e"), tags))
const mutedPubkeys = new Set(tagValues(hexTags("p"), tags))
const mutedAddresses = new Set(tagValues(addressTags("a"), tags))
@ -47,7 +44,7 @@ export const isEventMuted = withGetter(
: undefined
return (e: TrustedEvent) => {
if (!muteList) return false
if (!$muteList) return false
if ($user.pubkey === e.pubkey) return false
if (mutedPubkeys.has(e.pubkey)) return true
if (mutedEvents.has(e.id)) return true

View file

@ -1,6 +1,8 @@
import twColors from "tailwindcss/colors"
import {kv} from "@app/storage"
import {derived, readable} from "svelte/store"
import {synced} from "@welshman/store"
import {FL_THEME} from "@app/env"
import {kv} from "@app/storage"
export const colors = [
["amber", twColors.amber[600]],
@ -24,15 +26,35 @@ export const colors = [
["zinc", twColors.zinc[600]],
]
export const theme = synced({
key: "theme",
defaultValue: window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
// Every theme with token values in lib/components/theme.css
export const flThemes = ["clay", "flat", "navy"]
export const flTheme = synced({
key: "flTheme",
defaultValue: FL_THEME,
storage: kv,
})
// Hook for development
Object.assign(window, {
setFlTheme: (t: string) => {
document.body.setAttribute("data-fl-theme", t)
},
export const theme = synced({
key: "theme",
defaultValue: "system",
storage: kv,
})
const prefersDark = readable(window.matchMedia("(prefers-color-scheme: dark)").matches, set => {
const query = window.matchMedia("(prefers-color-scheme: dark)")
const onChange = () => set(query.matches)
query.addEventListener("change", onChange)
return () => query.removeEventListener("change", onChange)
})
// What actually gets stamped on the document, since `theme` may defer to the os
export const activeTheme = derived([theme, prefersDark], ([$theme, $prefersDark]) => {
if ($theme === "system") {
return $prefersDark ? "dark" : "light"
}
return $theme
})

View file

@ -61,6 +61,22 @@ export const getBlossomServer = async (options: GetBlossomServerOptions = {}) =>
return first(DEFAULT_BLOSSOM_SERVERS)!
}
// The editor's default list leaves out the formats an iPhone camera actually produces, so
// anything shared from Photos would be rejected without a word. Heic gets re-encoded by the
// compressor on its way to a blossom server; quicktime is uploaded as-is.
export const UPLOAD_MIME_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/heic",
"image/heif",
"video/mp4",
"video/mpeg",
"video/webm",
"video/quicktime",
]
export type CompressFileOptions = {
maxWidth?: number
maxHeight?: number

View file

@ -6,7 +6,6 @@
</style>
<script lang="ts">
import {onMount} from "svelte"
import {maybe} from "@welshman/lib"
const {
@ -22,46 +21,63 @@
const px = size * 4
const isSafari =
// The ios app's wkwebview has no "Safari" token in its user agent, so
// detect the engine instead of sniffing for the Safari browser.
const isWebkit =
typeof navigator !== "undefined" &&
/safari/i.test(navigator.userAgent) &&
!/chrome|chromium|android/i.test(navigator.userAgent)
/applewebkit/i.test(navigator.userAgent) &&
!/chrome|chromium|android|firefox/i.test(navigator.userAgent)
// Strip any query string/fragment - hosted icons are often served with one.
const isRemoteSvg = (value: string) =>
!value.startsWith("data:") && /\.svg$/i.test(value.split(/[?#]/)[0]!)
let canceled = false
let objectUrl = $state(maybe<string>())
const src = $derived(objectUrl || icon)
// Primal issues 302 redirects from blossom, which messes up safari
const fetchSvg = async () => {
// Primal issues 302 redirects from blossom, which breaks mask-image on webkit.
const fetchSvg = async (url: string) => {
try {
const response = await fetch(icon, {
const response = await fetch(url, {
mode: "cors",
credentials: "omit",
})
if (response.ok) {
const blob = await response.blob()
if (!canceled) {
objectUrl = URL.createObjectURL(blob)
}
return URL.createObjectURL(await response.blob())
}
} catch {
// pass
}
}
onMount(() => {
if (isSafari && icon.toLowerCase().endsWith(".svg")) {
fetchSvg()
// Re-fetch when icon changes, revoking the previous blob url.
$effect(() => {
const url = icon
let canceled = false
let created: string | undefined
if (isWebkit && isRemoteSvg(url)) {
fetchSvg(url).then(blobUrl => {
if (!blobUrl) return
if (canceled) {
URL.revokeObjectURL(blobUrl)
} else {
created = blobUrl
objectUrl = blobUrl
}
})
}
return () => {
canceled = true
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
if (created) {
URL.revokeObjectURL(created)
objectUrl = undefined
}
}
})

View file

@ -68,12 +68,8 @@
@apply mt-2 pt-3;
}
.space-menu__header {
@apply p-3;
}
.space-menu__header-button {
@apply rounded-2xl p-0;
@apply w-full rounded-2xl p-0 p-3;
&:hover {
@apply bg-transparent;

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