summaryrefslogtreecommitdiff
path: root/ui/lib
Commit message (Collapse)AuthorAge
* Push messaging fixes:Owen Jacobson2025-11-10
| | | | | * Remove push subscriptions on logout, to trigger a resubscription when logging back in. * Don't try to call `this.push` when there's no `this` to have a `push` on.
* Update (most) Node dependencies.Owen Jacobson2025-11-09
| | | | | | The `jsdom` upgrade entails upgrading Node, as something changed internally to jsdom in how it imports other modules. Upgrading vitest caused the details of some of our test cases to change, but the semantics of the affected tests are the same. They also split resetting mocks from resetting mocks' behaviours, which required a small config change to preserve our tests' correctness.
* De minimis "send me a notification" implementation.Owen Jacobson2025-11-08
| | | | | | | | | | | | | | | | | | When a user clicks "send a test notification," Pilcrow delivers a push message (with a fixed payload) to all active subscriptions. The included client then displays this as a notification, using browser APIs to do so. This lets us verify that push notification works, end to end - and it appears to. The API endpoint for sending a test notification is not documented. I didn't feel it prudent to extensively document an endpoint that is intended to be temporary and whose side effects are very much subject to change. However, for posterity, the endpoint is POST /api/push/ping {} and the push message payload is ping Subscriptions with permanent delivery failures are nuked when we encounter them. Subscriptions with temporary failures cause the `ping` endpoint to return an internal server error, and are not retried. We'll likely want retry logic - including retry logic to handle server restarts - for any more serious use, but for a smoke test, giving up immediately is fine. To make the push implementation testable, `App` is now generic over it. Tests use a dummy implementation that stores sent messages in memory. This has some significant limitations, documented in the test suite, but it beats sending real notifications to nowhere in tests.
* Add a button to the client to set up a push subscription.Owen Jacobson2025-11-07
| | | | | | Once a user has set up a push subscription, the client will re-establish it as needed whenever possible, falling back to manual intervention only when it is unable to create a push subscription. This change imposes some architectural changes to the client, though they're not huge: the `session` type now includes a body of state (`push`) whose methods also call into the Pilcrow API. Previously, calls to the API were not made within the `session` types, and were instead only made by page and layout code, but orchestrating that for the push subscription lifecycle proved too complex to deal with. This is an experimental alternative, but it might be something we explore further in the future.
* Generate, store, and deliver a VAPID key.Owen Jacobson2025-08-30
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | VAPID is used to authenticate applications to push brokers, as part of the [Web Push] specification. It's notionally optional, but we believe [Apple requires it][apple], and in any case making it impossible to use subscription URLs without the corresponding private key available, and thus harder to impersonate the server, seems like a good security practice regardless. [Web Push]: https://developer.mozilla.org/en-US/docs/Web/API/Push_API [apple]: https://developer.apple.com/documentation/usernotifications/sending-web-push-notifications-in-web-apps-and-browsers There are several implementations of VAPID for Rust: * [web_push](https://docs.rs/web-push/latest/web_push/) includes an implementation of VAPID but requires callers to provision their own keys. We will likely use this crate for Web Push fulfilment, but we cannot use it for key generation. * [vapid](https://docs.rs/vapid/latest/vapid/) includes an implementation of VAPID key generation. It delegates to `openssl` to handle cryptographic operations. * [p256](https://docs.rs/p256/latest/p256/) implements NIST P-256 in Rust. It's maintained by the RustCrypto team, though as of this writing it is largely written by a single contributor. It isn't specifically designed for use with VAPID. I opted to use p256 for this, as I believe the RustCrypto team are the most likely to produce a correct and secure implementation, and because openssl has consistently left a bad taste in my mouth for years. Because it's a general implementation of the algorithm, I expect that it will require more work for us to adapt it for use with VAPID specifically; I'm willing to chance it and we can swap it out for the vapid crate if it sucks. This has left me with one area of uncertainty: I'm not actually sure I'm using the right parts of p256. The choice of `ecdsa::SigningKey` over `p256::SecretKey` is based on [the MDN docs] using phrases like "This value is part of a signing key pair generated by your application server, and usable with elliptic curve digital signature (ECDSA), over the P-256 curve." and on [RFC 8292]'s "The 'k' parameter includes an ECDSA public key in uncompressed form that is encoded using base64url encoding. However, we won't be able to test my implementation until we implement some other key parts of Web Push, which are out of scope of this commit. [the MDN docs]: https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription/options [RFC 8292]: https://datatracker.ietf.org/doc/html/rfc8292#section-3.2 Following the design used for storing logins and users, VAPID keys are split into a non-synchronized part (consisting of the private key), whose exposure would allow others to impersonate the Pilcrow server, and a synchronized part (consisting of event coordinates and, notionally, the public key), which is non-sensitive and can be safely shared with any user. However, the public key is derived from the stored private key, rather than being stored directly, to minimize redundancy in the stored data. Following the design used for expiring stale entities, the app checks for and creates, or rotates, its VAPID key using middleware that runs before most API requests. If, at that point, the key is either absent, or more than 30 days old, it is replaced. This imposes a small tax on API request latency, which is used to fund prompt and automatic key rotation without the need for an operator-facing key management interface. VAPID keys are delivered to clients via the event stream, as laid out in `docs/api/events.md`. There are a few reasons for this, but the big one is that changing the VAPID key would immediately invalidate push subscriptions: we throw away the private key, so we wouldn't be able to publish to them any longer. Clients must replace their push subscriptions in order to resume delivery, and doing so promptly when notified that the key has changed will minimize the gap. This design is intended to allow for manual key rotation. The key can be rotated "immedately" by emptying the `vapid_key` and `vapid_signing_key` tables (which destroys the rotated kye); the server will generate a new one before it is needed, and will notify clients that the key has been invalidated. This change includes client support for tracking the current VAPID key. The client doesn't _use_ this information anywhere, yet, but it has it.
* Split `user` into a chat-facing entity and an authentication-facing entity.Owen Jacobson2025-08-26
| | | | | | | | | | | | | | The taxonomy is now as follows: * A _login_ is someone's identity for the purposes of authenticating to the service. Logins are not synchronized, and in fact are not published anywhere in the current API. They have a login ID, a name and a password. * A _user_ is someone's identity for the purpose of participating in conversations. Users _are_ synchronized, as before. They have a user ID, a name, and a creation instant for the purposes of synchronization. In practice, a user exists for every login - in fact, users' names are stored in the login table and are joined in, rather than being stored redundantly in the user table. A login ID and its corresponding user ID are always equal, and the user and login ID types support conversion and comparison to facilitate their use in this context. Tokens are now associated with logins, not users. The currently-acting identity is passed down into app types as a login, not a user, and then resolved to a user where appropriate within the app methods. As a side effect, the `GET /api/boot` method now returns a `login` key instead of a `user` key. The structure of the nested value is unchanged.
* Factor data-to-JSON-string construction out of stitches.Owen Jacobson2025-08-21
| | | | This is a recurring and nameable operation; let's give it a name before we use it further.
* Render message markdown to HTML inside of `<Message />`.Owen Jacobson2025-08-19
| | | | This simplifies data flow, at the potential expense of re-rendering HTML more often than strictly necessary. Requiring every path that produces a message-shaped object to pre-render markdown made things more interdependent than intended and slowed me down.
* Create swatches for Svelte components.ojacobson2025-07-10
|\ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A swatch is a live, and ideally editable, example of an element of the service. They serve as: * Documentation: what is this element, how do you use it, what does it do? * Demonstration: what does this element look like? * Manual test scaffolding: when I change this element like _so_, what happens? Swatches are collectively available under `/.swatch/` on a running instance. They do not require setup or login for simplicity's sake and because they don't _do_ anything that requires either of those things. Swatches are manually curated. First, we lack the technical infrastructure needed to do this based on static analysis, and second, manual curation lets us include affordances like "interesting values," that would be tricky to express as part of the type or schema for the component. The tradeoff, however, is that they will fall out of step with the components if not reviewed regularly. Swatches are _possible_ because we've gone to efforts to avoid global data access or direct side effects (including API requests) in our components, delegating that upwards to `+page`s and `+layout`s. However, the isolation is imperfect. For example, the swatch for `Conversation`, which renders the conversation sidebar entries, causes actual attempts to boot the app as browsers pre-fetch the links on mouseover, and clicking them will take the user to the "real" application because they really are links. Merges swatch into main.
| * Event capture and display tools.Owen Jacobson2025-07-08
| | | | | | | | | | | | This is meant to be used in swatches, to display the events and callbacks generated by a component as part of the swatch. The usage pattern is described in the comments (in both places). Naturally, this has its own swatch.
| * Create "derivers," as an exception-free option for working with structured ↵Owen Jacobson2025-07-08
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | data in swatches. This is meant to be used alongside `$derive`, for inputs with complex structure. For example: ```js let jsonInput = $state('{}'); let json = $derived(deriver.json(jsonInput)); // … <textarea bind:value={jsonInput}></textarea> ``` This allows textual editing of the data, while preventing exceptions due to syntax or logical errors in partially-edited data from breaking Svelte's derive process (see comments). Note that these exceptions are not considered [unexpected errors] by SvelteKit, because they do not arise "while handling a request;" they are considered errors by Svelte, but Svelte doesn't appear to provide any affordances for handling errors in this context, so we have to bring our own. [unexpected errors]: https://svelte.dev/docs/kit/errors#Unexpected-errors
* | Remove container divs for `MessageInput` and `CreateConversationForm`.ojacobson2025-07-09
|\ \ | | | | | | | | | | | | | | | | | | | | | The styles for the `MessageInput` and `CreateConversationForm` components assumed that a container div would be present, and the components did not render as intended without that div. Therefore: the divs were "part of" the component in most of the ways that matter, not part of the context in which the component is used. It turns out that those divs aren't necessary or interesting anyways - they were targets for layout, but the same layout can be achieved without them. This change removes the divs entirely. Merges component-div-nesting into main.
| * | A few semantically-thin wrapper divs.Owen Jacobson2025-07-08
| | | | | | | | | | | | This is an extension of the previous commit: we don't need these divs _at all_ to achieve the layout we want, and we aren't attaching behaviour or semantics to them, so, out they go.
| * | Move container divs for components into those components.Owen Jacobson2025-07-08
| | | | | | | | | | | | | | | | | | The styles for the `MessageInput` and `CreateConversationForm` components assume that the container div will be present, and the components will not render as intended without them. Therefore: they are "part of" the component in most of the ways that matter, not part of the context in which the component is used. Moving the divs into the component will make it easier to reuse these components (for example, in swatches). The diff for this looks worse than it is because of indentation changes.
* | | Stop sending `{}` to the `/api/auth/login` endpoint when the login form ↵ojacobson2025-07-09
|\ \ \ | |_|/ |/| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | hasn't been touched. Steps to reproduce: **Note**: You will need to watch the traffic in a DOM inspector; this has no user-observable symptoms because there's presently no error reporting for the login form. 1. In a new private tab, visit the `/login` page of a Pilcrow instance. 2. **Without touching the username or password fields**, click `sign in`. The client _should_ send a request to `/api/auth/login` with the following payload: ```json { "name": "", "password": "" } ``` However, it instead sends an empty payload, leading to a 422 Unprocessable Content response as the request is missing required fields. Subsequent requests, or any request after the user enters data in the input fields, are correctly serialized. Merges login-form-nulls into main.
| * | Set non-`undefined` initial values for the login form.Owen Jacobson2025-07-08
| |/ | | | | | | The default state of a `$state()` with no arguments is `undefined`, which was then leaking out of this component if the user clicks `sign in` without changing the values. Axiom, our HTTP client library, suppresses fields with `undefined` values in JSON payloads (sensibly enough), leading to empty requests.
* / Remove the (entirely unused and unusable) `body` property from `Message`.Owen Jacobson2025-07-08
|/
* Rename "channel" to "conversation" throughout the client.Owen Jacobson2025-07-03
| | | | Existing client state, stored in local storage, is migrated to new keys (that mention "conversation" instead of "channel" where appropriate) the first time the client loads.
* Move the `/ch` channel view to `/c` (for conversation).Owen Jacobson2025-07-03
|
* Replace `channel` with `conversation` throughout the API.Owen Jacobson2025-07-03
| | | | This is a **breaking change** for essentially all clients. Thankfully, there's presently just the one, so we don't need to go to much effort to accommoate that; the client is modified in this commit to adapt, users can reload their client, and life will go on.
* Boot the client by consuming events.Owen Jacobson2025-06-20
| | | | We use the same event processing glue that the client has for keeping up with live events, which means that a significant chunk of state management code goes away entirely.
* tools/reformatOwen Jacobson2025-06-11
|
* Use browser-native methods to hide elements, not CSS alone.Owen Jacobson2025-05-22
| | | | The hidden `textarea` used to attach the form value to the DOM was being included in the ARIA accessibility tree, at least in testing (I didn't check in a browser). While we could suppress this iwth `aria-role="hidden"`, the WHATWG recommendation is to Not Do That, and to find another way to hide the element, instead. Marking the element as hidden accomplishes that goal, _and_ gets rid of a style rule.
* Merge branch 'prop/unread-channels'Owen Jacobson2025-05-16
|\
| * Avoid converting DateTime values into numbersOwen Jacobson2025-05-15
| |
| * Fix up spots where we still tried to treat `remote.channels.all` as a map.Owen Jacobson2025-05-15
| | | | | | | | | | | | | | | | | | | | In ae93188f0f4f36086622636ba9ae4810cbd1f8c9, `remote.channels.all` became a flat array of channels, instead of a map, in order to simplify some of the reasoning around how state changes propagate. However, I neglected to remove all Map-shaped calls referring to it. This lead to some pretty interesting behaviour: * The client could not track unread state, because reconciling local state against the remote state would find no remote state, then throw away local state entirely as a result. * The client would not actually update when a new channel appeared. * The client would not actually update when a channel disappeared.
* | Merge remote-tracking branch 'codeberg/prop/unread-channels'Owen Jacobson2025-05-16
|\|
| * Move derivation of the synthesized view of channels (and messages) into ↵Owen Jacobson2025-05-14
| | | | | | | | `session`.
| * Track created-at times for each channel.Owen Jacobson2025-05-13
| |
* | Restore the placeholder when the editable input is emptied out after ↵Owen Jacobson2025-05-13
| | | | | | | | | | | | modification. This also avoids using `placeholder` on elements where it's nonstandard, like `<div>`s.
* | Support non-mouse accessibility.Owen Jacobson2025-05-13
| | | | | | | | | | * Give the input `div` a marker to tell screen readers &c that it is a textbox. * Ensure that it participates in tab order. (Zero is a sentinel value, see <https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/tabindex>.)
* | Make the resulting form more amenable to normal DOM operations.Owen Jacobson2025-05-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | This is purely an aesthetic choice: * The DOM `reset()` function can be used to clear the form, but can't be used to clear editable DIVs. Binding the editable div to a (hidden) form field allows `reset()` to clear both. * We can find the target `form` element out of the event, but the API needed to do so differs between events dispatched to form controls and events dispatched to random DOM nodes. Using `closest('form')` works for both kinds of event target. In practice, there is little need to make sure the message input form uses "normal" DOM APIs for functional reasons. Everything inside `MessageInput` is controllable through the component's script. This change isn't based on a functional need, but rather in the hopes that integrating with the DOM APIs makes it easier for _code we don't control_ - screen readers, password managers, saved-form support in browsers, &c - to integrate with Pilcrow. It is purely speculative. (This also used to be necessary because Firefox didn't support `contenteditable="plaintext-only"`, but [support was added in March][ff-pto] [ff-pto]: https://www.mozilla.org/en-US/firefox/136.0/releasenotes/#:~:text=The%20value%20plaintext%2Donly%20can%20now%20be%20specified%20for%20the%20contenteditable%20attribute%2C%20making%20the%20raw%20text%20of%20an%20element%20editable%20but%20without%20supporting%20rich%20text%20formatting.
* | Implement the disabled state:Owen Jacobson2025-05-13
| | | | | | | | | | * Suppress input (including paste) while the input is disabled. * Style the input to make it visible that it's not accepting input.
* | Send multi-line messages as multiple lines.Owen Jacobson2025-05-13
| | | | | | | | For whatever reason, `innerText` captures interior line breaks, while `textContent` does not. Wild. DOM APIs.
* | Fix up input to be a contenteditable divKit La Touche2025-05-11
|/
* Rather than exploding a user into properties inside `runs`, use a helper method.Owen Jacobson2025-05-08
|
* Render messages as ghosts when there's a pending delete, too.Owen Jacobson2025-05-06
|
* Render "ghost" messages for unsent messages.Owen Jacobson2025-05-06
| | | | | | | | | | | There is a subtle race conditon in this code, which is likely not fixable without a protocol change: * Ghost messages can disappear before their "real" message replacement shows up, if the client finishes sending (i.e., receives an HTTP response on the POST) before the server delivers the real message. * Ghost messages can be duplicated briefly, if the client receives the real message before the client finishes sending. Both happen in practice; we make no ordering guarantees between requests. To aviod this, we'd to give clients a way to correlate pending sends with received messages. This would require fundamentally the same capabilities, like per-operation nonces, that preventing duplicate operations will require.
* Un-nest `Message` from `MessageRun`.Owen Jacobson2025-05-06
| | | | | | A `MessageRun` is a visual container with a specific layout - bordered, with a drop shadow, with a name badge on the top-left, which is either positioned to the left (`other-message`) or right (`own-message`). It is content-agnostic. This facilitates putting things besides live messages inside of a message run. As a side effect, this gets rid of ActiveChannel; most of what it was doing makes more sense living in the channel view's `+page.svelte`.
* Don't retry operations where we received an unacceptable response.Owen Jacobson2025-05-05
| | | | | | | | This was actually two issues in one! Issue 1: `isRetryable` did not consider whether we got a response or not. It assumed that the presence of a request in the error signaled that the error was definitely due to network issues, when in fact it's the presence of a request _and_ the absence of a response. That's my misreading of the Axios docs; the replacement `isRetryable` is more thorough. Issue 2: operations in the outbox queue that fail with an exception stop the outbox drain process from making further progress, _and_ they stay in the queue. The outbox now dequeues jobs that throw an exception, and restarts itself if it terminates with a non-empty queue. The code that does this is _heinous_, but it seems to work well enough… Words I'm sure I won't come to regret.
* Use the outbox for more than just message sends.Owen Jacobson2025-05-05
| | | | | | | | | | A handful of operations are "synchronized" - that is, the server sends back information about them when the client asks to perform them, but notifies _all_ clients of completion through the event stream. As of this writing, these operations include sending and deleting messages, creating and deleting channels, and anything that creates new users. We can use the outbox for most of these. I've opted _not_ to use the outbox for creating users, as that often takes place when the client is not connected to the event stream (and can't be) and so cannot discover that the operation has been completed after it is sent. Outboxed tasks are objects, not closures, even though they behave in closure-like ways (`send()` carries out what amounts to "attempt this operation until it succeeds"). This is deliberate; I want the properties of incomplete tasks to be inspectable down the line, so that we can put them in the UI. If they're mere closures, we can't do that. It is deliberate that `outbox.postToChannel` et al do not return promises. I contemplated it, but it will interact weirdly with outbox serialization, when we get to that. Rather than relying on a promise to determine when an operation has completed, clients of `Outbox` should monitor its properties. (We can add additional view properties to make that easier.)
* Send messages through an outbox, rather than sending them to the API ↵Owen Jacobson2025-05-02
| | | | | | | | directly from the UI. This primarily serves to free up the message input immediately, so that the user can start drafting their next message right away. The wait while a message is being sent is actively disruptive when using Pilcrow on a server with noticable latency (hi.grimoire.ca has around 700ms), and this largely alleviates it. Unsent messages can be lost if the client is closed or deactivated before they make it to the head of the queue.
* Retry boot every five seconds.Owen Jacobson2025-04-23
|\ | | | | | | Merged in spite of misgivings. This method will loop over the request until it completes, even if the user moves to a view where the response is no longer relevant.
| * Naming improvements c/o KitOwen Jacobson2025-04-23
| |
| * When booting a session, retry every five seconds if unable to send the request.Owen Jacobson2025-04-22
| | | | | | | | | | | | This is intended to transparently resume the session (using `boot` to start over) after more serious connection interruptions. It interacts with the heartbeat timeout: we let the browser try to reconnect through `EventSource` on its own for up to 30 seconds, before intervening, closing the event source, and starting attempts to call `boot`. This covers both initial boot, which will now hang if the server is unavailable (sorry), and reconnection after an event timeout. No other operations are retried (particularly, sending a message is _not_ retried).
* | Remove trailing spaceKit La Touche2025-04-22
| |
* | Add the following attributes to all markdown-generated links:Owen Jacobson2025-04-21
|/ | | | | | | | * `target="_blank"`: when Pilcrow is running in a browser, clicking a link should not replace Pilcrow with the target of the link. Pilcrow is "app-like" enough that opening links in a new tab _by default_, without user intervention, is likely more appropriate. * `rel="noreferrer"`, which (A) stops most UAs from setting a referrer header when following those links, and (B) also implies `noopener`, preventing the link target from using `window.opener` from reaching back into Pilcrow's DOM. I briefly experimented with DOMPurify's `RETURN_DOM_FRAGMENT` mode, which would have made the tests somewhat easier to write, but I wasn't able to find a good way to integrate the returned `DocumentFragment` objects with Svelte components, so HTML-as-strings it is. Sigh.
* Restart the event connection if heartbeats stop showing up.Owen Jacobson2025-04-08
| | | | | | | | The changes introduced in the previous commit make it possible to detect lost connections and restart them, so do so. The process is pretty simple - a new remote state is spun up using `/api/boot`, swapped in for the existing state, and a `new EventSource` is started from that new remote state to consume events. This can induce some anomalies. For example, messages that arrive on the server between the loss of one connection and the creation of the next one just "show up" in boot, without ever appearing in the event stream. (This is technically also true on client startup, but it's easier to expect in that situation.) This is something we'll need to consider when implementing things like notifications or unread flags, though the ones we have today, which are state-based, do work fine. By design, this _does not_ retry either the `/api/boot` call or the new event source setup. Event sources will try to reconnect on their own, up to a point, so that's fine, but we need to build something more robust for `/api/boot`. I want to tackle that separately from detecting lost connections and reacting to them, but that does mean that this is not a complete solution to client reconnects.
* Remove stray debug outputOwen Jacobson2025-04-04
|
* Merge branch 'prop/rename-login-to-user'Owen Jacobson2025-04-03
|\