Commit Graph
296 Commits
Author SHA1 Message Date
SalastilandClaude Opus 5 36ae6fdfd0 Somewhere to send a location from
`sendMatrixLocation` has worked since #123 and had no caller anywhere in the
app. That issue was "stickers and locations can be read but not sent" and was
closed with the sticker half wired up and this half reachable by nothing.

A field rather than a map, deliberately. Drawing one means fetching tiles from
somebody's tile server, and the receiving side already decided against that -
a message that mentions a street corner should not tell a third party it was
read. Sending should not quietly undo the decision receiving made.

So it is a box, and the daemon does the hard part: it already accepts a pasted
map link, a `geo:` URI, or two numbers with a comma between them. That is what
people have to hand - nobody types coordinates from memory, they copy a link.

The label is offered first and the coordinates second, because the label is
the part a person reads: a pair of numbers arriving with no name is a pin in
the middle of nowhere as far as the recipient is concerned.

Its own button beside the sticker one, for the reason the sticker button sits
beside the emoji one: a location is the message rather than something typed
into it. Matrix only, which is the one service here that carries a place as
its own kind of message.

Closes #166.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 21:20:16 -04:00
SalastilandClaude Opus 5 e2504059f9 Point at the nobilis that records why a method has no caller
Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 21:16:47 -04:00
SalastilandClaude Opus 5 83088ebbf9 A pinned message, and putting it away for good
The bar above the log, where the live poll and prediction cards sit and for
the same reason: something that stays put while the chat moves underneath
belongs out of the scroll. Above those, because it outlasts them - a poll runs
for a minute and a pin stays until the channel replaces it.

**Dismissing it is remembered**, which is the part worth getting right. A pin
stays up for hours, so a bar that came back on every restart would be a bar
somebody dismisses every day. It is a preference rather than this window's
state, so it survives a restart and so a pin put away here is put away in a
popped-out window too.

Keyed on the pinned message's own id, not on the channel: putting this one
away must not put away the next one, and the next one is the whole reason a
channel pins anything. Capped at a hundred, oldest forgotten first - a channel
replaces its pin whenever the subject changes, and without a ceiling this is a
list that only grows and is written to disk each time.

The body goes through the same formatter every other message body does, so a
link in a pin is a link and an emote is an emote. A pin is very often exactly
a link, which is most of why one gets pinned.

It wraps and is capped in height rather than scrolling the page: a pinned wall
of text must not push the conversation off the screen, which is the one way
this could be worse than not having it at all.

Closes #130.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 20:56:20 -04:00
SalastilandClaude Opus 5 e4a0408d6e Point at the nobilis whose comments point at real files
Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 17:57:39 -04:00
SalastilandClaude Opus 5 fd80766944 A card put away stays put away
Folding a poll or a prediction lasted exactly as long as the process did. A
restart brought back every card somebody had dismissed, and a popped-out
window never knew about a fold made in the main one - each window keeps its
own copy of the store, and this lived there.

It is a preference now, which is what "folded" should always have meant: kept
on disk, shared between windows, and still there tomorrow.

Capped at two hundred. A busy Kick channel runs a poll every few minutes and
every fold leaves an id behind, so without a ceiling this is a list that only
grows and is written to disk each time. Two hundred is far past any card still
on screen - the oldest to be forgotten is one whose poll ended long ago.

The ids this keys on are stable while a card is open (`card_id` reuses the
open card's id rather than deriving a new one), and the daemon holds the live
card across a client restart - so closing moho and opening it again finds the
same card, already folded. A daemon restart mid-poll is the one case that
still forgets: Kick's payload carries no id of its own, so the card's id is
derived from its title and start time, and re-deriving it after the live card
is gone can land a second out.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 17:25:56 -04:00
SalastilandClaude Opus 5 a584d9a399 Create account, beside log in
The button for it, next to the two ways in that already existed. Enabled only
with a server, a name and a password, since unlike SSO this one genuinely
needs all three.

Where the homeserver wants more than an open registration - a captcha, an
email, its terms agreed to - the daemon answers with which, and with the
advice to sign up on the server's own page. That is not a failure this window
can fix: those stages need a person somewhere this window is not.

Closes #165.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 17:08:18 -04:00
SalastilandClaude Opus 5 715f999944 Somewhere to point the other device's camera
The dialog draws the QR code beside the emoji rather than instead of them.
Both are offered because both are needed: a code cannot be made without
cross-signing, and when there is no code the emoji are the only way through.

Asked for once the flow is past "requested" - a code can only be made after
both sides have agreed what they are verifying - and keyed on the verification
so a new one fetches its own picture rather than showing the last session's.

Drawn as an image from a data URI rather than injected as markup. The SVG is
the daemon's own drawing of a QR grid and carries nothing from anywhere else,
so injecting it would have been safe; not making the exception is the habit
worth keeping.

White behind it on both themes. A scanner reads contrast, and a code drawn
dark-on-dark is a code nothing can read.

Closes #163.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 17:05:05 -04:00
SalastilandClaude Opus 5 bd05e9c861 A field the server will refuse is not offered
The client half of asking a homeserver what it allows: where it says an
account may not change its display name or its picture, the field is fixed and
says why on hover, rather than accepting a change and reporting a refusal
afterwards.

Only where it actually said so. `canChangeName === false` is the test rather
than a falsy check, because absent means permitted - most homeservers answer
nothing here, and treating silence as a refusal would grey out a working field
on almost every server there is.

Closes #164.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 17:00:36 -04:00
SalastilandClaude Opus 5 fd35eb0b77 Read receipts you can read, and click
Two complaints about the same corner of the screen.

The hover toolbar sat hard against the right edge, which is where Discord puts
it because Discord has nothing else there. This does: Matrix draws who has
read each line in that same gutter, so the toolbar covered them - a standing
fact made unreadable and, now, a control made unclickable. It used to fade the
markers to a quarter opacity on hover and call that settled, which is a way of
saying the toolbar wins rather than a way of both fitting.

They both fit now. The toolbar's offset is the marker cluster's widest form -
three overlapped faces and a "+N" - so it clears them exactly and no further,
and only on rows that have any: an IRC channel has no read receipts and should
not have its toolbar shoved inwards for something that is not there.

And the faces answer "who" now. Three overlapped avatars answer "has anybody"
and cannot answer the question somebody clicking them has. The names were in a
tooltip, which cannot be read at leisure, cannot be scrolled, and vanishes if
you move towards it - so clicking opens a list instead, anchored to the faces
rather than to the pointer, and clamped inside the window since it opens from
the right-hand gutter and grows leftwards.

Capped at twelve names. Past that the answer stops being a list of people and
becomes "the room has caught up", which "and N others" says better than
another twenty rows.

Closes #146, #147.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 16:52:29 -04:00
SalastilandClaude Opus 5 b99250b214 Point at the nobilis that asks for a roster when one is wanted
Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 16:38:28 -04:00
SalastilandClaude Opus 5 d2bcb3ef36 Point at the nobilis that speaks four more drafts
Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 16:29:52 -04:00
SalastilandClaude Opus 5 4fd3bc693c A name is not a person, so show what is
The client half of IRC learning who is in a room. A member now carries the
hostmask and services account the network gave, and whether the network calls
them a program.

Hovering a name says what is actually known: the account they are identified
as, and the mask. Both absent where the server never said, which is most
networks for most people - so the tooltip stays the nick it always was rather
than growing empty labels.

The bot badge is kept quiet on purpose. It sits beside every one of a bot's
lines, and a badge that loud at that frequency reads as an alert rather than a
fact.

Part of #148, #152, #154.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 16:11:59 -04:00
SalastilandClaude Opus 5 899ac140bb Say what this client actually speaks
The README's protocol section described moho as it stood before most of this
year's work. Discord was "message edit/delete/reactions/replies sync" - no
voice calls, no slash commands, no polls, no invites, no threads, no search,
no captcha window. IRC listed no capabilities at all, and neither typing nor
the notify list. Matrix had no group calls, no spaces, no knocking, no
stickers. Kick had no polls or predictions. Sneedchat did not mention the
captcha on the login form it now answers.

Rewritten against a fresh inventory of each backend rather than from memory.
IRC's paragraph names its twelve IRCv3 capabilities outright, because on that
protocol the capability list is the honest description - and it is also the
one place this client is measurably behind, which is easier to see when the
list is written down.

Discord's says plainly that there is no video or screen share, since a README
that only lists what works is a README somebody has to test to trust.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 15:45:27 -04:00
SalastilandClaude Opus 5 7c773e9c94 A way to take somebody off the friends list
The daemon could always do it - declining a request, withdrawing one and
unfriending are the same DELETE, which is how Discord models it - but nothing
in the client ever called it that way. The list could be read, messaged and
picked from, and never edited.

It is a right-click on the row rather than a button on it. The row is itself a
button: clicking it opens the conversation, so a second control inside it
would be invalid as well as cramped - and a remove sitting permanently beside
a name is a remove that eventually gets pressed by the hand that was already
moving. The menu carries the person's name in the entry, which is the
confirmation.

No countdown in front of it, unlike leaving a server. That one needs an invite
to undo and there may be nobody left to ask; this is undone by asking again,
so the cost of a misclick is a sentence to somebody you know rather than a
door that has shut.

One piece of menu state for the whole list rather than one per row, because a
row is drawn per friend and `useContextMenu` is a hook that cannot be called
inside the map that draws them.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 13:09:37 -04:00
SalastilandClaude Opus 5 e11aa7dea5 Somewhere to tick the box, so the answer is yes
Joining a server, adding a friend and creating one were three links to
discord.com, because Discord asks for a captcha on all of them and this client
had nowhere to put one. They are the actions now.

The challenge appears in a window of moho's own, parented to the one that
asked for it - the challenge belongs to the action and the action belongs to a
window. It is not a browser session: nothing is signed into, nothing is
navigated, and the partition goes with the window. One checkbox.

Why a window and not a panel, since that is the obvious question. The widget
is bound to the site it belongs to and the token records where it was solved,
so the page holding it has to genuinely be on discord.com - which this
renderer is not, and should not become: admitting hcaptcha.com's script into
the page holding the bridge to the daemon would put a third-party blob in the
most privileged place in the app. A window loading a real discord.com page has
neither problem. The origin is theirs because the page is theirs, and Discord's
own CSP names hcaptcha.com in script-src, connect-src and frame-src, because
rendering this widget on this origin is what their client does too.

Discord's page is stopped the moment its document exists. Only the origin was
wanted; letting the app boot would mean megabytes to show a checkbox, and a
running client underneath the thing being answered. That stop arrives back as
a failed navigation, which is why the load's own error is ignored once the
document is there - reporting "could not reach Discord" about a page that is
on screen is the one message guaranteed to be wrong.

The token comes back as a navigation to a scheme nothing can follow, which is
the only channel a sandboxed renderer with no preload has to a parent that is
listening - and it closes the window the instant the box is ticked.

`rpcAnsweringCaptcha` makes the detour invisible at the call site: make the
call, and if the answer is a question, ask it and call again. Once,
deliberately - a second challenge straight after the first means Discord is
not going to be satisfied by anything happening here, and a loop of puzzles is
worse than a sentence saying so. A cancelled challenge says nothing at all,
because closing it already said it.

Verified end to end before wiring it up: the window comes up on
`https://discord.com`, the hCaptcha iframe renders against Discord's own site
key, and the token channel delivers back to main.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 11:08:39 -04:00
SalastilandClaude Opus 5 be32f5e7b6 A mute made elsewhere, drawn as a mute made elsewhere
The other half of reading Discord's own mute settings: the row goes quiet, and
the menu says why.

`isMutedBuffer` is where this window decides a conversation is muted, so a
mute the account carries belongs there beside the two already in it - the
buffer's own, and its rail entry's.

The menu entry is the part worth being careful about. A mute made on the
account is not this window's to undo, and an "Unmute" that quietly did nothing
would be worse than no entry at all - so where there is one, the entry says
which service holds it and does not pretend to be a switch.

Closes #119.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 02:13:46 -04:00
SalastilandClaude Opus 5 84fd94ba6f A link to hand somebody, and a clipboard to put it on
The other half of making a Discord invite: the form that asks the three
questions Discord asks, and the link that comes back.

A form rather than one button, because an invite is not one thing - a link
posted in a public thread and one sent to a person want opposite answers to
"how long" and "how many", and picking for somebody would be picking wrong
half the time. Asked next to the gesture, with Discord's own defaults.

The link is shown as well as copied. Copying silently is a gesture with no
evidence it happened, and an invite is a thing people read before they send
it. It clears when the panel shuts: a link belongs to the options it was made
with, and offering to copy a stale one is worse than offering nothing.

Not on a one-to-one, which is the one Discord conversation with no link into
it - an invite there would have to add a third person, and Discord answers
that by making a new group instead, which is what the button beside this one
already does. A group message takes an invite exactly as a channel does, and
both arrive as `kind: 'dm'`, so the roster is what tells them apart.

Also the first thing in this client to write to the clipboard, which is done
from main: the page's own `navigator.clipboard` wants a secure context and a
permission the app has no way to grant itself. Text only - the renderer has
no business putting anything else on the system clipboard.

Closes #121.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 02:05:18 -04:00
SalastilandClaude Opus 5 82b828b3f1 Point at the nobilis that does not mention you to yourself
A quit line names whoever left, and the mention test searched every line for
your nick - so closing the client notified you once per channel.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 21:02:07 -04:00
SalastilandClaude Opus 5 09bbe42605 Install the build that was just made, not the one that sorts first
Naming builds after their commit gave dist/ more than one AppImage for the
first time, and the install script took whichever `ls` listed first. That was
correct for exactly as long as builds overwrote each other: moho-76c7c45 sorts
before moho-ede11ca, so `npm run deploy` built the new one and then installed
the old one over the top of it, reporting success either way.

Caught because the launcher kept starting a build that did not have the change
in it.

It asks for the current commit's own file now, and falls back to the newest by
time where there is no git or no matching name - so a build that did not
happen fails loudly rather than quietly launching yesterday.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 19:37:33 -04:00
SalastilandClaude Opus 5 ede11cad27 A message may not fetch from a stranger on being read
Rendering a formatted body fetched every https image it named, the moment it
was drawn. Where this client wrote the markup that is the feature - a Discord
emoji is a URL built from an id. Where the sender wrote it, which on Matrix is
whenever they send a formatted_body, it is a way to be told that the message
was read, by whom, and from what address. Nothing in a chat message needs
that.

So RichText is told which kind of markup it has, and only ours may reach for
a remote picture. The prop is off by default, which is the safe way round:
markup arriving from a service is somebody else's until a call site says
otherwise.

A refused image keeps its alt text rather than vanishing - what a picture was
called is worth having, and a tracking pixel has no name to keep.

Measured in the running client, against a formatted body carrying
`<img src="https://cdn.example.com/tracker-probe.png" alt="[the picture]">`:

    rendered:  "a message with a picture the sender chose [the picture]"
    img tags:  0
    requests:  none

And the other way, on a Discord channel where the markup is ours: custom
emoji still draw and still fetch from the CDN.

Note for anyone reading this later: inline images do not render on Matrix at
all today, and did not before this - `mxc://` was already refused, so the safe
source was dropped while the dangerous one was honoured. Making mxc work means
resolving it through the daemon, which has the machinery
(`cached_media_path`); it is not built here because no message in this store
has ever carried one.

Closes #145

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 18:46:31 -04:00
SalastilandClaude Opus 5 adc83b8a0b Write down what the sanitiser was accused of, and cleared of
#142 suspected that parsing a message's HTML fetched the things it named -
remote stylesheets and fonts, seen as blocked requests while switching
conversations. It does not. A body carrying a stylesheet link, an @font-face,
an https image, a CSS url() and a script src was parsed through this exact
path with the network watched: nothing was requested, and no policy was
violated. The document really is inert.

So the comment saying so now says it was measured, and points at where the
real reach is: `safeImageSrc` passes any https image straight through to a
rendered <img>, which is what makes inline images and custom emoji work and
also what lets a sender learn their message was read. That is #145, and a
decision rather than a fix.

The blocked requests that started this remain unattributed. They were real -
captured twice - but they are not the sanitiser's, they are not in any stored
message, and a full reload plus a walk through every account produces none.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 18:30:49 -04:00
SalastilandClaude Opus 5 76c7c452c8 Name a build after the commit it came from
The artifacts were called moho-0.1.0 - a version nobody has bumped since the
first commit and nobody intends to, which made every build ever produced
indistinguishable from every other one by name. Two people comparing "the
0.1.0 AppImage" were not necessarily talking about the same file.

They are named after the commit now, with `-dirty` where the tree was not
clean - which is the honest answer for a build made from uncommitted work,
and the one thing a hash alone cannot tell you.

The install script already globbed for *.AppImage, so nothing downstream
cared what the file was called.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 17:54:24 -04:00
SalastilandClaude Opus 5 7d5e3777f4 Stop the log moving under whoever is reading it
Two complaints, one cause: the height of the log changes after it is drawn,
and only one of the two cases anybody reads in was putting it back.

**A picture with no measurements had no box.** Where a service sends
dimensions - Discord, Matrix - the space was already reserved. Where it does
not, which is every link a message merely contains and therefore almost
everything on Sneedchat, the row was nothing at all until the image decoded
and then it was three hundred pixels. Now an unmeasured picture gets a shape
to sit in and measures itself on load, so the change is from a box to a box
rather than from nothing.

**And reading the past had nothing holding it.** The observer put the view
back when pinned to the bottom, and did nothing otherwise - while the
scroller sets `overflow-anchor: none`, which is us telling the browser not to
do it either. So an image loading three hundred lines back moved the reader,
and nobody was going to move them back. The row at the top of the view is now
noted as they scroll and put back where it was after anything resizes.

**The drawn tail was also sliding off them.** A message arriving pushes one
off the top of the window of rows that actually get built - which for
somebody parked in the past is the ground moving, and can unmount the very
row they are reading. While they are away from the bottom the tail grows
instead of sliding. It goes back to a tail when they return to the present,
which is where the button and the scroll-back-down already reset it.

Measured against live #fishtank, in the configuration this was reported from -
window in a split pane, member list open, message column 254px wide:

    60s pinned to the bottom: 0 of 120 samples off the bottom, worst gap 0px,
    the jump-to-present button never appeared.
    45s parked in the past: the log grew 661px underneath, and the row at the
    top of the view did not move once.

Before, the same test showed gaps of 320-600px while pinned, and a synthetic
400px growth above the reader moved them the whole 400.

Closes #143, #144

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 17:47:12 -04:00
SalastilandClaude Opus 5 3ba8bde3c6 Point at the nobilis that files its protocols properly
All five backends split into modules named after what they do; no behaviour
change, 457 tests throughout.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 17:11:05 -04:00
SalastilandClaude Opus 5 0048bd7083 Point at the nobilis that files its protocols
IRC and Discord are folders now, like the other three, and backend holds
nothing that is not a protocol.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 16:09:02 -04:00
SalastilandClaude Opus 5 643f34f6b2 Give Sneedchat its sign-in form back
The username, password and TOTP fields were taken out when the forum put a
CAPTCHA on its login form, and rightly: a form that cannot work is worse than
no form, because it looks like the way in. moho answers that CAPTCHA now, so
the way in is a way in again.

The browser sign-in stays underneath it, because it is still the answer to the
one thing the solver cannot do - the same service can ask for a browser
fingerprint check instead of a proof of work, and nothing headless can produce
one. It reads as the fallback it now is rather than as the only door, and it
keeps the warning that matters: everything above it goes through Tor and it
does not.

Verified in the running client: the form draws its three fields and its
Connect button, and the daemon's own live probe signs in with a username and
password through Tor, past the gate and past the CAPTCHA.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 20:17:55 -04:00
SalastilandClaude Opus 5 a95995667f Point at the nobilis that can sign in again
Three fixes behind the Sneedchat outage: a Tor client that could not be
unstuck, a session refresh that discarded what it could not replace, and the
captcha the sign-in form grew.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 20:05:58 -04:00
SalastilandClaude Opus 5 433d2708e8 Give a notification back the picture it was drawing
A notification from Sneedchat or Matrix has never had an icon on it.

Electron draws one through nativeImage, which reads PNG and JPEG and nothing
else, so anything it cannot decode is handed to the window's own renderer to
be redrawn - Chromium reads WebP and GIF natively. That transcode asked the
renderer to fetch the file back out of the moho-media scheme, and the window's
policy allows that scheme for images and for media but not for connect-src.
So the fetch was refused, every time, since the policy was written. The catch
around it swallowed the refusal, the function returned null, and the
notification went out bare - which is exactly the symptom the transcode exists
to fix, on exactly the services it was written for. 83% of the Sneedchat
avatars in this cache are WebP or GIF.

Main already holds the path and may read it, so it hands the renderer the
bytes as a data URL instead of asking it to go and get them. A data: image
does not taint the canvas, so the PNG comes back out.

Deliberately not the other fix. Widening connect-src to the media scheme is
the smaller diff and the worse answer: that scheme can also reach the daemon's
config directory, where the account tokens live, and allowing connect there
turns "a script in this window could ask for that file" into "a script in this
window could read it".

The type in the data URL is sniffed from the first bytes rather than taken
from the name, because Sneedchat serves WebP called .jpg - ten of two hundred
cached avatars are misfiled that way, and the declared type is the one the
decoder is handed.

Verified in the running client against a real cached avatar: the old path
answers "Failed to fetch", the new one turns the same 96x96 WebP into a PNG.

Closes #141

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-07 18:06:02 -04:00
SalastilandClaude Opus 5 46e3d7f953 Draw the part of the log somebody is reading
A window left open for a day was holding 690MB, and most of what it was
holding was not on screen and could not be got to without scrolling.

Every message in a conversation was a mounted row - avatar, embeds, pictures,
reactions - however far up the log it sat, and a conversation could hold two
thousand of them. So the log is drawn as a tail of what is loaded, and reading
upwards lengthens the tail: the same gesture that fetches an older page, one
step earlier in the same sequence. Coming back to the present lets it go
again, whether by the button or by scrolling there.

A tail rather than a floating window because a chat log is read from the
bottom: there is nothing below the drawn part, so there is no spacer, no
estimated row height, and no second opinion about how tall the page is. The
scroll machinery measures what it always did.

Underneath it, the same in memory: the two-thousand-message ceiling is for the
conversation being read, and everything else keeps a screenful. Four hundred
buffers were all being held at the ceiling meant for one of them, and every
message was already written to the daemon's store on the way in - so what a
trim costs is a page fetched again if somebody goes back, which is the fetch
they would have made anyway.

And two things that were making the client slow rather than large: every row
subscribed to the conversation's emoji, permissions, pins and colour settings
itself, so two thousand rows meant twelve thousand live subscriptions re-run
on every change anywhere in the client; and nothing was memoised, so a message
in one channel redrew every row of another. Read once per list now, and a row
redraws when its own message does.

Measured on #archlinux, opening and reading back three pages: 8,338 DOM nodes
before and 3,794 after, and 1,374 rather than 8,336 once back at the present.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-07 17:44:58 -04:00
SalastilandClaude Opus 5 9518d1fb98 A way to take a pin off, where the pins are
Pinning worked and unpinning was only ever reachable from the message
itself - so undoing one click meant finding that message again in the
log, which for a pin is usually the oldest thing in the conversation.

The cross sits beside each row in the pinned list, which is where
somebody looks when they want a pin gone. The row leaves the list at
once rather than waiting for the service to say so, because a row that
lingers after being dismissed reads as a failure; the service's own
answer follows and would replace it either way.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-07 01:05:32 -04:00
SalastilandClaude Opus 5 d7c4d81739 Somewhere to send a message on to
"Forward…" on a message, and a searchable list of this window's
conversations to choose from - ordered by where you have been talking,
since a message being sent on usually goes somewhere you were just now.

The row above a forwarded message says "Forwarded" rather than wearing a
reply's arrow, and fills in whose message it was when the daemon has read
the original.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-07 00:40:38 -04:00
SalastilandClaude Opus 5 59ee06f71b Pin the nobilis that reads a forward as a forward
Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-07 00:21:25 -04:00
SalastilandClaude Opus 5 6ea68c3d42 A window for the form a bot asks for
Drawn from what the bot sent rather than from a template: its labels,
its placeholders, which boxes are one line and which are several, and
which may be left empty. A client that decided any of that itself would
be filling in somebody else's form.

Refused sends keep what was typed on screen, because retyping a form is
the worst way to learn that a field was too long.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 23:23:34 -04:00
SalastilandClaude Opus 5 99e48588ac Pin the nobilis that knows who owns a room
Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 23:08:57 -04:00
SalastilandClaude Opus 5 7d9b70d50e Pin the nobilis that meets Element on the media server
Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 22:45:35 -04:00
SalastilandClaude Opus 5 c33bdc812b Encrypt the media, with the keys the room hands round
Sixteen random bytes and an index that cycles, which is what Element
makes - a client that generates differently is one whose media nobody
else can read. Handed to LiveKit as HKDF material rather than as a key,
because that is what it derives its cipher key from and what Element
gives it, and the ratchet window and keyring size are Element's too.

Rolled when somebody leaves, so what is said after they go is not
readable by them, and re-sent when somebody arrives, which is the
difference between the two events.

And a call that is connected but unreadable now says so: an encrypted
call with a missing key looks exactly like a quiet room, so anybody whose
key has not arrived a few seconds after they joined is named.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 22:06:06 -04:00
SalastilandClaude Opus 5 4e9f6ddbb2 A LiveKit client, for the calls held on a media server
One stream up and everybody else's back down, rather than a connection
per person - which is what Element's calls are and, past a handful of
people, the only shape that works.

Deliberately not a second implementation of the call: it hands back the
same people-and-streams the mesh does, so the grid, the speaking ring and
the corner view do not know which kind they are drawing, and mute and
screen share reach whichever is up.

Two things learned by hitting them. The renderer's own CSP refused the
websocket - a signalling connection is neither http nor https - so `wss:`
is allowed now, and plaintext only to this machine, since a server across
a network should not be carrying signalling in the clear. And the stage
now goes up before the joining rather than after it: connecting to a
media server takes a moment, and setting the phase afterwards overwrote
the "connected" the call had already reported.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 20:45:59 -04:00
SalastilandClaude Opus 5 c777be1c14 A call moho cannot join says so where the call is
The room bar reads the transports in each membership: where somebody is
on a media server - which is what Element's calls are - it names that
instead of offering a Join that would fail.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 14:22:20 -04:00
SalastilandClaude Opus 5 ce64d76ab8 The window half of a call between everybody in a room
One capture, lent to every leg: five connections asking the machine for
the same camera get one capture and four refusals, so the group opens it
once and hands it to each connection - and stops it only when the last
one goes.

A bar at the top of a room where a call is already happening, since
nobody was invited to it. The header's call buttons start one where the
conversation is a room and ring somebody where it is a person, because
those are different things.

The grid needed nothing: it was built for a call of five and had only
ever been fed by two.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 14:01:02 -04:00
SalastilandClaude Opus 5 f4d7b21e8d Pin the nobilis that can ignore somebody anywhere
And the menu that asks it: one entry on a message and one on a member,
saying in the answer whether the service was told or moho is simply
declining to show what still arrives.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 13:32:32 -04:00
SalastilandClaude Opus 5 8608c8d03d A button for stickers, a prompt for a reason, and spaces on the menu
The window half of the four Matrix features: a sticker button of its own
beside the emoji one, because they are different gestures - an emoji goes
into the line being written and a sticker is the message - and a picker
that shows packs and nothing else.

A shared prompt for the two things that need a sentence written before
they happen: a knock, where the reason is what the people inside decide
by, and a report, where the reason is the whole report.

Spaces on a room's own menu, since a space is the server's grouping
rather than this window's categories. And the room directory now says
which rooms are asked rather than entered, and which are open to a
space's members - one Join button for all three was a button that failed
for two of them.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 13:15:51 -04:00
SalastilandClaude Opus 5 7e9f07b2b2 Pin the nobilis that speaks IRC's newer half
Typing, a notify list, and the server's own copy of what was sent - plus
the capability-parsing bug underneath them, which had every IRC server
looking like it supported nothing.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 12:46:31 -04:00
SalastilandClaude Opus 5 c39bd1cf9f Put the picture where you want it
The corner it retreats to was one fixed corner, which is the wrong one
whenever the thing you are reading is under it. So the panel is dragged
by its title bar - by the whole bar when it is minimised, since then the
bar is the whole panel - and it stays where it is put, across restarts,
because moving it is a decision about this screen and having to make it
again every launch is the complaint about panels that cannot be moved.

Wholly inside the window, never half over an edge: the part of the bar
that hangs past the window is the part that is not a handle, so a panel
parked there could be looked at and never picked up again. A position
remembered from a larger screen is pulled back in on arrival for the
same reason.

And never into moho's own title bar. That bar is a compositor drag
region: a press inside one begins a window move before the page ever
sees it, so a panel resting there would be unmovable - the trap the
window controls and the lightbox each already carry a note about. It is
kept below the bar, and marked no-drag as well.

The buttons on the handle are not part of the handle. Pressing minimise
minimises.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 06:36:32 -04:00
SalastilandClaude Opus 5 b0bbb845b3 Somewhere to start the picture when the chat is in its own window
Watching was offered on the channel's row in the list, and a popped-out
conversation has no list to right-click. So it is offered where the
conversation is too: beside the chip that already says what is on air,
in the header both windows draw.

Only while there is something to watch, and it is the same button that
stops it - the pair the call buttons next to it already are.

Per window rather than per channel, because that is what the picture is:
the popped-out chat plays it in its own window, the main window keeps
whatever it was showing, and neither reaches into the other.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 06:19:18 -04:00
SalastilandClaude Opus 5 9e3fa49321 Watch the stream in the window that has the chat
A Kick channel is a stream as much as a chat, and the video was in a
browser tab beside the window that had the conversation in it.

Not a player of its own. The frame a call draws into is now its own
thing - VideoStage - and a call and a stream are two sources feeding it:
the same pane, the same corner it retreats to, the same minimise that
puts the picture down without ending it, the same sizing. What differs
is the pipe. A call is real-time media that arrives as a MediaStream on
srcObject; a stream is one-to-many HLS off a CDN, which needs Media
Source Extensions and so needs hls.js, and that is the only piece here
a call has no use for.

Receiving only, and one at a time: nothing broadcasts, nobody else is in
it, and starting a stream during a call is refused rather than fought
over - the picture has one place to go.

Offered where the channel is, by right-clicking it, and only while there
is something to watch: an offline channel's playlist is a signed URL to
nothing, and an entry that fails a few seconds after being pressed is
worse than one that isn't there. The same menu is how it stops.

Capped below 1080p60. That rendition is 5.8 Mbps of 60fps decode beside
a chat client, and the ladder underneath it still adapts. A refused
segment is read as an expired token rather than a dead stream, and
answered by loading whichever playback URL the daemon has since sent.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 00:58:07 -04:00
SalastilandClaude Opus 5 7eec5bffa7 A call with everybody in it, and a way to look at one
A call between two people needed two tiles. A call between five needs a
grid, and the grid has to be worked out rather than tabulated: fitTiles
tries every column count and keeps the one that makes the faces largest
in the space there actually is, so the same five people fill a wide
window differently from a narrow one and neither leaves slivers.

Sixteen by nine, because that is what cameras are and what a face fits
in. Capped, because two people on a large screen do not want two
enormous windows and the conversation underneath should survive.

Who is talking is measured here rather than asked for: the audio is in
this window, so an AnalyserNode reads it and lights the tile in the same
green the Discord voice view uses - a ring rather than a reordering,
since a grid that rearranged itself every time somebody spoke would be
unreadable.

Pressing a face makes that person large with everybody else in a strip
underneath, and pressing them again puts everybody back. It is a
decision the person watching made, so nothing in the call undoes it -
only their leaving, which takes the reason with it.

Walking away from the conversation leaves the call in the corner, with a
title bar naming who is in it, a way back to the room it is held in, and
a minimise that puts the picture away without hanging up.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 00:35:54 -04:00
SalastilandClaude Opus 5 983e12d008 Pin the nobilis that paces what it sends to Discord
Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 22:30:18 -04:00
SalastilandClaude Opus 5 5d99b42b08 Calls, in the window that can actually make them
The media is here because this is Chromium: a peer connection, a microphone
with echo cancellation, a camera, and hardware decode for whatever comes
back. The daemon carries the offer, the answer and the network paths, and
knows nothing about how any of it works.

Two buttons on a Matrix conversation, a stage that floats over whatever you
are reading - a call outlives navigating away, and one that vanished when
you looked elsewhere would be one you could not hang up - and a ringing
panel with two answers, because answering a video call with audio only is
a thing people do on purpose.

Screen sharing is here too, and is the half Discord will share: the picker
is the desktop's own list of screens and windows with a still of each,
since two browser windows are named almost the same and showing the wrong
one to a room is the mistake worth preventing. The screen goes in as its
own track, so somebody sharing is still in the call.

Three things this needed that were not obvious. Chromium will not hand over
a microphone unasked, and an unanswered permission request does not fail,
it hangs - which is what a call that never started looked like. A call
whose first phase was its initial value announced nothing, so no call ever
appeared on screen. And a machine with no camera should still make video
calls: the camera is dropped and it says so, rather than failing the whole
request, microphone included.

Verified between two accounts on different homeservers: rang, answered,
connected with live audio both ways, then a screen shared and drawn at the
far end.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 22:11:09 -04:00
SalastilandClaude Opus 5 295d464301 Pin the nobilis that keeps one account's channels out of another's departure
Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 20:57:44 -04:00
SalastilandClaude Opus 5 45f92babcc Somewhere to find a thread, and something to do about a member
The header gets a thread button on Discord channels, listing what is going
on in this one and, in a forum, what has been posted to it - saying which
are archived, because an archived thread reads as a dead one until you open
it and opening it brings it back. Picking one opens it as its own
conversation.

The member menu speaks Discord now: remove, ban and time out, which it had
always drawn for other services and never offered here, plus the roles this
account may hand out. What somebody already holds is in the member list, so
taking a role away is offered beside giving one.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 20:44:19 -04:00