Compare commits
30 commits
3b8d321bd8
...
fee6f565ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fee6f565ff | ||
|
|
922a0996e5 | ||
|
|
3d2ec8c030 | ||
|
|
9d8be25bae | ||
|
|
31471b50b5 | ||
|
|
8e2284a2fe | ||
|
|
50554ec479 | ||
|
|
d23a6f6b75 | ||
|
|
f5159a3b5e | ||
|
|
0a85983633 | ||
|
|
cf9b6f7193 | ||
|
|
29afa08053 | ||
|
|
db68aa4a68 | ||
|
|
2d89881aac | ||
|
|
84fee9de18 | ||
|
|
f411973409 | ||
|
|
fc768b6393 | ||
|
|
d61c0d00fb | ||
|
|
620f7593e7 | ||
|
|
9cd717d5b3 | ||
|
|
a572918e6c | ||
|
|
c8d003524b | ||
|
|
8243f4d245 | ||
|
|
72ee0c9ed6 | ||
|
|
7dcc713ec2 | ||
|
|
65c9eac5e5 | ||
|
|
b2f58b11c0 | ||
|
|
749989f56b | ||
|
|
9c68f66c39 | ||
|
|
85a4ba127a |
120 changed files with 5641 additions and 1678 deletions
2
.env
2
.env
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
303
e2e/ARCHITECTURE.md
Normal 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
96
e2e/harness/app/boot.ts
Normal 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
|
||||
}
|
||||
30
e2e/harness/app/session.ts
Normal file
30
e2e/harness/app/session.ts
Normal 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
165
e2e/harness/index.ts
Normal 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
26
e2e/harness/keys.ts
Normal 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
315
e2e/harness/net/http.ts
Normal 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()
|
||||
},
|
||||
)
|
||||
130
e2e/harness/net/websocket.ts
Normal file
130
e2e/harness/net/websocket.ts
Normal 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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
113
e2e/harness/seed/scenario.ts
Normal file
113
e2e/harness/seed/scenario.ts
Normal 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
191
e2e/harness/seed/space.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
27
e2e/harness/zooid/config.ts
Normal file
27
e2e/harness/zooid/config.ts
Normal 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]]))
|
||||
37
e2e/harness/zooid/docker/compose.yaml
Normal file
37
e2e/harness/zooid/docker/compose.yaml
Normal 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
|
||||
44
e2e/harness/zooid/docker/config/other.toml
Normal file
44
e2e/harness/zooid/docker/config/other.toml
Normal 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
|
||||
43
e2e/harness/zooid/docker/config/space.toml
Normal file
43
e2e/harness/zooid/docker/config/space.toml
Normal 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
264
e2e/harness/zooid/relay.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
72
e2e/harness/zooid/testRelay.ts
Normal file
72
e2e/harness/zooid/testRelay.ts
Normal 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}))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
144
e2e/harness/zooid/transport.ts
Normal file
144
e2e/harness/zooid/transport.ts
Normal 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)
|
||||
})
|
||||
34
e2e/harness/zooid/types.ts
Normal file
34
e2e/harness/zooid/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
43
e2e/specs/auth.spec.ts
Normal 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)
|
||||
})
|
||||
27
e2e/specs/multiuser.spec.ts
Normal file
27
e2e/specs/multiuser.spec.ts
Normal 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
57
e2e/specs/routing.spec.ts
Normal 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
26
e2e/specs/space.spec.ts
Normal 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()
|
||||
})
|
||||
|
|
@ -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] ?? []])),
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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}
|
||||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
45
ios/App/ShareExtension/Info.plist
Normal file
45
ios/App/ShareExtension/Info.plist
Normal 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>
|
||||
10
ios/App/ShareExtension/ShareExtension.entitlements
Normal file
10
ios/App/ShareExtension/ShareExtension.entitlements
Normal 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>
|
||||
159
ios/App/ShareExtension/ShareViewController.swift
Normal file
159
ios/App/ShareExtension/ShareViewController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
62
link_deps
62
link_deps
|
|
@ -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.')
|
||||
|
|
|
|||
33
package.json
33
package.json
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}],
|
||||
})
|
||||
|
|
|
|||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
307
src/app/call.ts
307
src/app/call.ts
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
110
src/app/components/CallBanner.svelte
Normal file
110
src/app/components/CallBanner.svelte
Normal 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}
|
||||
199
src/app/components/CallControlBar.svelte
Normal file
199
src/app/components/CallControlBar.svelte
Normal 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}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
/
|
||||
|
|
|
|||
91
src/app/components/MicLevelMeter.svelte
Normal file
91
src/app/components/MicLevelMeter.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
15
src/app/components/NoteContentMinimalBoard.svelte
Normal file
15
src/app/components/NoteContentMinimalBoard.svelte
Normal 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}
|
||||
17
src/app/components/NoteContentMinimalPin.svelte
Normal file
17
src/app/components/NoteContentMinimalPin.svelte
Normal 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}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
137
src/app/components/Share.svelte
Normal file
137
src/app/components/Share.svelte
Normal 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>
|
||||
|
|
@ -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) => {
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
122
src/app/components/SpaceMenuActions.svelte
Normal file
122
src/app/components/SpaceMenuActions.svelte
Normal 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}
|
||||
118
src/app/components/SpaceMenuActionsMobile.svelte
Normal file
118
src/app/components/SpaceMenuActionsMobile.svelte
Normal 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}
|
||||
74
src/app/components/SpaceMenuHeader.svelte
Normal file
74
src/app/components/SpaceMenuHeader.svelte
Normal 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}
|
||||
19
src/app/components/SpaceMenuMobile.svelte
Normal file
19
src/app/components/SpaceMenuMobile.svelte
Normal 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>
|
||||
103
src/app/components/SpaceMenuNavItems.svelte
Normal file
103
src/app/components/SpaceMenuNavItems.svelte
Normal 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}
|
||||
106
src/app/components/SpaceMenuRooms.svelte
Normal file
106
src/app/components/SpaceMenuRooms.svelte
Normal 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}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
const store = new Map<string, unknown>()
|
||||
|
||||
export type Draft = {
|
||||
content?: string | object
|
||||
}
|
||||
|
||||
export class DraftKey<T> {
|
||||
constructor(private key: string) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
86
src/app/logger.ts
Normal 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})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
108
src/app/share.ts
108
src/app/share.ts
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue