Brings the submodule up to the daemon the client has been written
against. Dev mode runs this checkout rather than any nobilis built
alongside it, so leaving it behind meant the app talked to a daemon
missing everything voice needs - which is how a channel list crashed the
window on a field that daemon never sends.
Transmit microphone audio into Discord voice
Choose sound devices, and hear the other side
Identify our own audio streams the way the sound server names them
Make the system default a destination rather than an absence
Report who is in a voice channel, by name
Co-Authored-By: Claude Opus 5 <[email protected]>
The voice channel list read `members` on every channel, which a daemon
older than that field does not send - and the daemon here is exactly that,
because dev mode runs the submodule checkout rather than whatever nobilis
was built alongside. Reading `.map` of undefined threw during render,
React unmounted the entire tree, and the app appeared for an instant and
vanished with nothing in the window to act on.
Two fixes, because the crash and the blankness are separate faults.
The field is now optional on the wire type rather than merely guarded at
the point of use, so the compiler keeps this class of mistake from
returning: a client talks to a daemon it did not build, which may be a
separate binary or one already running from before an upgrade, and has to
keep working when a field is missing.
And an error boundary now catches what gets through. Unmounting everything
is a reasonable default for a page and a poor one for an application
window - it leaves no message, nothing to click, and a cause visible only
to someone who thinks to open a developer console. A render failure now
shows what broke and offers a reload.
Verified against the same stale daemon that caused the crash: no uncaught
errors, and the window renders its rail, channel pane and footer.
Co-Authored-By: Claude Opus 5 <[email protected]>
Everything built for voice so far had no way into it: joining a call meant
an RPC call by hand. A guild's voice channels now appear under its text
ones, each listing who is in it, and clicking one joins or leaves.
Who is present is shown before joining rather than after, since that is
what anyone decides on. Your own presence is included - it is how you can
see you are in a call at all - while the "empty" flag still means "nobody
else", which is what the daemon's solo-join rule tests. A user limit is
only shown once the channel is nearly full, where it starts to mean
something.
Joining from here deliberately passes soloOnly off: someone clicking a
channel in a list that shows them exactly who is already in it has chosen
to join those people. The cautious default remains for callers that say
nothing.
The list is fetched per guild rather than for all of them, because voice
membership changes constantly across every server someone is in and only
the one on screen is worth tracking. It is tagged with the guild it
describes so a reply arriving after a switch cannot be shown under the
wrong server's name.
Co-Authored-By: Claude Opus 5 <[email protected]>
The plaque at the foot of the channel list gains a microphone and a
headset button beside the name and status. Muting has to be reachable
during a call rather than several clicks into settings, which is why it
lives here and not only in the preferences pane.
Status stays per account, because each protocol carries its own. The
audio buttons are deliberately the opposite: one machine has one
microphone and one pair of speakers whatever you are signed in to, so
they are global even though they share the plaque. Deafening also mutes
the microphone, as everywhere else, and the microphone button says so
rather than appearing to be a switch that stopped working.
General settings gains microphone and output dropdowns. These read from
the daemon rather than from stored preferences: it holds the connection
and does the encoding, so it is the only place that can act on a choice,
and a second window has to show the same answer. Following the system
default stays available after choosing something else.
Co-Authored-By: Claude Opus 5 <[email protected]>
Discord's new roster was falling into the rank tiers meant for IRC, so
everyone landed under Members regardless of whether they were there.
The mode is now chosen from the data - a roster carrying a status gets the
online/offline split, one without it keeps the tiers - rather than from a
list of service names. Discord and Matrix take the first, IRC and Sneedchat
the second, which is also where rank is the more useful thing to show. A
protocol that starts reporting presence needs no change here.
Idle and Do Not Disturb sort with offline rather than online: they are
states of not being available, and Discord's own client draws them the same
way.
Opening a buffer now requests its member list. Discord only sends one for
the channel being looked at, and answers per guild rather than per channel,
so this is deliberately not part of subscribe - which fires for every
buffer tracked for unread counts and would make the replies ambiguous.
Bumps nobilis for the member list itself.
Do Not Disturb is gone from the picker, along with what it needed: the
notifier's account-status tracking and main's watching of the account list,
which existed only to enforce it with the window closed. Status is now
purely per account, so choosing one no longer reaches across the others.
moho-0.1.0.tgz was committed - 19.5MB of npm pack output, a build artifact
regenerable at any time. Untracked, and *.tgz ignored so the next `npm
pack` does not put it back.
The .presence-dot.dnd style stays: that renders other people's Discord
status in the friends list, where DND is a value they really can have.
The cog sits at the foot of the server rail under a divider, opening
Accounts and Settings. Those belong there rather than in the channel list,
which is about whichever server is selected; the two buttons that were in
its footer are gone.
That footer now shows who you are on the selected account - avatar,
display name and a status dot - and opens the status picker: Online, Idle,
Do Not Disturb. Status is per account because that is what the underlying
protocols carry, so the picker acts on the account whose channels are on
screen.
Do Not Disturb is the exception and applies to every account at once,
since it is a statement about the person rather than about a connection.
It also silences notifications, enforced in main alongside the existing
mute rules so it keeps working with the window closed - and main learns
each account's status by watching the account list the renderer already
asks for, rather than needing a channel of its own.
A retry resent only the body. sendMessage took its reply target from the
composer, and by the time a retry happens that has been cleared - so the
message went out as an ordinary one. Not Sneedchat-specific: nothing in
the path is protocol-aware, and every service lost the reply the same way.
Reading it live was worse than losing it. Start replying to someone else
before hitting retry and the retried message picked up *that* target
instead, landing under a stranger's message. The staged file was dropped
too, since the echo never kept it.
The send now takes everything it needs as arguments, and the retry passes
back what the echo recorded, so neither depends on what the composer holds
at the moment it runs. A retry also no longer consumes a reply the user
has started for their next message.
'~&@%+'.indexOf('') is 0, which is the owner slot, so anyone without a
rank prefix was filed under Owners - every Sneedchat user, and every
ordinary IRC user too. The empty case is now handled explicitly, and the
tier fallback is what it always claimed to be: owners, then moderators,
then everyone else alphabetically.
Also bumps nobilis for the Sneedchat roster and its site-owner marking.
Clicking a row moved the rail to wherever that conversation lives, which
threw you off the pinned and direct message pages onto the guild or
account that owned it - the opposite of what those pages are for. A row in
the list is by definition already on the page being shown, so it never
needs following; that behaviour exists for selections arriving from
outside the list, like a notification click or a freshly joined channel,
and only those ask for it now.
Following a Discord direct message was landing on an unrelated guild for a
second reason: its home group is the per-account DM group nobilis reports,
which the rail folds away in favour of the single cross-service page. That
id has no tile, so the pane fell back to whatever entry came first.
Following a direct message now targets the page that actually shows it,
and a session saved before the fold is migrated on load rather than
falling back the same way.
A direct message no longer inherits a muted network. Muting a server is
about channel traffic; someone messaging you directly is not that, and the
IRC test DM arriving with no notification is what made the difference
concrete.
A hidden server buffer no longer cascades at all. Hiding a buffer removes
the row that holds its mute toggle, so a server that was both muted and
hidden silenced an entire account with nothing left on screen to undo it -
which is why #archlinux could not be unmuted. The cascade now only applies
while its source can still be reached.
Each account's card in Accounts gains a collapsed list of its hidden and
muted channels, with Show and Unmute. Hiding a buffer removes the context
menu that hid it, so without a list somewhere else a hidden channel was
gone for good.
Servers, guilds and spaces can be muted from their rail tile's context
menu - the deliberate version of the cascade above, and always undoable
since the tile is always on screen. A muted tile dims and shows a bell,
contributes no unread, and suppresses notifications for everything under
it, direct messages included.
The notification path in main carries its own copy of these rules, so it
is updated in step; the two disagreeing is what made a muted channel badge
on the rail while showing nothing on the rows.
Minimise, maximise and close sat inside the title bar's drag region, so
the compositor took a press on them as the start of a window drag and the
clicks never reached the page.
The drag layer being a sibling of the buttons rather than their parent was
never enough, whatever the old comment claimed: a drag region is a
window-level hit-test area resolved before the page sees the press, so it
covers whatever falls inside its rectangle regardless of DOM order or what
paints on top. The controls now take themselves back out with no-drag,
which is the only thing that carves a hole in one.
Same mechanism that made the expanded viewer's buttons unclickable when
they overlapped this bar; the comments on both ends now describe it
accurately, since the old wording is what made this look already handled.
The corner badge on a rail tile now identifies the service rather than
counting unread. The count belongs on the channel rows, where it says
which channel to open; up here the pill already signals that something is
waiting, and what a tile needs to answer is whose server it is. Skipped on
account tiles, whose face is already the service mark, and on the pinned
page, which spans all of them.
Fixes why those counts looked missing from the rows. A muted buffer gets
no row badge, but the rail was counting it anyway - so "Its the spot",
with two muted channels, read 43 waiting while every row beneath it showed
nothing. The rail now rolls up only what the pane below would list, using
the same mute rule the rows use rather than its own copy, and skipping
hidden buffers for the same reason: there is no row to click through to.
Any server, space or account can be given an icon from its account's card
in Accounts. It outranks whatever the service supplies, and is the only
way to give one to something that has none - an IRC network, a Sneedchat
account, a guild whose owner never set one. The file is copied into the
app's own directory rather than referenced in place, since the original
may be on removable media or moved later, and validated by its leading
bytes rather than its name.
Servers and spaces can be dragged into any order, saved as ui.railOrder.
Direct messages lead the rail and pinned sits under it, neither draggable
- there is nowhere else for them to go. The saved order holds only what
has been dragged, so a guild joined later lands at the end instead of
silently rearranging a rail that was already arranged.
Pinned becomes a rail entry of its own, listing pins from every service
together. It is synthesised in the client rather than reported by nobilis,
since pinning is a preference this app owns and the daemon has no business
knowing about it - which is why it is a UI-side superset of the wire type
rather than a widening of it.
Each pane now reads pinned, then direct messages, then channels, with the
server buffer above all of them; the pinned band is scoped to the group
being shown, since the cross-service list is now its own page.
The dragged id is held in a ref rather than state. dragstart and drop are
separate events, and reading it from state means depending on a re-render
having happened in between - which it has during a real drag, but the
value is not the renderer's to lose.
The account entry is the home for buffers no backend grouped, which is how
IRC, Sneedchat and Matrix reach the rail at all. Discord never uses it -
every channel belongs to a guild and every DM to the DM entry - so it drew
a tile that opened an empty pane.
Emptiness alone is not grounds for hiding one: IRC and Sneedchat have no
buffers either until they finish connecting, and Sneedchat's Tor bootstrap
makes that several seconds. So an entry survives if something is under it,
or if its account has no buffers anywhere yet, and only one whose buffers
all live elsewhere is dropped. The rail and the channel pane share that
rule rather than each applying its own - if they disagreed the selected
group could be one with no tile, leaving a pane with no way out of it.
Sneedchat replaces its vpn_lock placeholder with the real mark. The
supplied artwork was a screenshot with the background baked in, which
would have masked to a solid square, so the background is keyed out to
alpha - keeping the eye as a hole so the silhouette still reads. Since
serviceIcon's artwork is no longer always an SVG, its field is renamed to
`mark`, and a `colour` flag marks artwork worth showing unflattened: the
rail has room and already sits beside full-colour guild icons, while the
same file still masks to a monochrome glyph in the buffer rows.
A new leftmost column holds one tile per Discord guild, per account's
direct messages, and per account for protocols with no grouping of their
own. The channel pane below now lists only that entry's channels, which
lets the per-account and per-guild collapsible headers go - about 130
lines of nesting the rail replaces, along with the guild display cap that
only existed because one pane held every server at once.
The grouping comes from nobilis (Buffer.groupId), not from parsing buffer
names here, so Matrix spaces will appear in the rail with no change to
this component. Tiles show the cached guild icon, else the service brand
mark for an account-level entry, else initials - a guild is a name first,
and showing every icon-less guild the same Discord logo would defeat the
point of the rail. Unread rolls up from the buffers underneath.
Pinned buffers stay above the list regardless of group, which is why they
carry their own service icon.
Opening a buffer subscribes to it, and that call was fired without a
rejection handler - as was the subscribe issued for every buffer when the
list loads. With the daemon momentarily gone each became an unhandled
rejection, so a single buffer list produced dozens of uncaught errors and
every channel opened produced another. Those calls now swallow the
failure: subscribing is re-issued whenever the buffer list loads, so a
dropped link repairs itself on reconnect, and a toast each would be worse
since one drop fails all of them at once.
Also teaches the YouTube matcher the /embed/ form. Discord resolves a
posted YouTube link into a rich embed whose video.url is the embed page,
which nobilis carries through as the body, so 143 stored messages held
links the matcher did not recognise. Each fell through to the content-type
probe and was fetched over the network as if it might be a media file - a
request that can only ever fail. They now render as YouTube cards.
console.log writes to stdout, and when nothing is attached to stdout that
write throws EIO synchronously - not as an 'error' event a listener could
absorb. Electron turns an uncaught exception in main into a modal error
dialog, so one stray log line made the app unusable at launch.
That is the ordinary case, not an exotic one: started from a desktop
entry or a launcher there is no terminal on the other end, while
nobilis-process pipes the daemon's whole output through console.log - so
the app would die as soon as the daemon said anything. Main now logs
through a wrapper that cannot throw, and stops trying after the first
failure since there is nowhere left to report to.
Also removes what triggered it here. Old scrollback still names the
daemon's pre-rename cache, whose files are still on disk, and the media
handler refused every one of them - 98 distinct paths, each re-requested
per message and each logged. Those directories are now allowed, so the
avatars load instead, and a refusal is reported once per path rather than
once per request.
Adds a save button to the expanded viewer, writing to the system downloads
folder unless the new General > Downloads setting points elsewhere. It
saves the original rather than the preview on screen, and a local cache
file is copied rather than re-requested - a Sneedchat attachment could not
be fetched here in any case, since only the daemon holds the Tor circuit.
Notification icons only ever worked for Discord. Electron renders them
through nativeImage, which decodes PNG and JPEG and nothing else, while
Sneedchat serves avatars as WebP and GIF (named .jpg) and Matrix serves
whatever the uploader sent - so those came out empty. Anything nativeImage
rejects is now redrawn as a PNG by the window's own renderer, which reads
every format Chromium does.
Renames the Display settings category to General, since it now holds
downloads as well as appearance. The display.* preference keys are
unchanged, so existing settings carry over.
Its close and open-outside buttons sat on top of moho's own title bar,
where they could not be clicked at all: that bar is a -webkit-app-region
drag surface, and a drag region is a window-level hit-test area the
compositor resolves before the page sees the click, so painting over it
does nothing regardless of z-index.
The viewer now starts below the title bar instead of covering it, which
also leaves moho's own maximise and close buttons reachable while a
picture is open. Media sizing switches from viewport units to percentages
of that reduced area, which vh units would have overshot.
The stage fills the backdrop, so stopping the click there meant the empty
space around the picture - the obvious place to click to dismiss - did
nothing. Only the media and the toolbar hold their clicks now.
Clicking a picture handed it to the system browser, and a video played at
message-row size where its controls barely fit. Both now open a viewer
over the window, the way Discord does.
The viewer takes a resolved source and knows nothing about where it came
from, so Matrix media fetched to the local cache, Sneedchat attachments
pulled over Tor and Discord CDN links all expand the same way. Expired
Discord links are re-signed before opening rather than expanding into a
broken box, and the inline copy stays the cheap preview - only the
expanded view loads the original.
An attachment whose signed link had lapsed showed a dead box and an invitation
to go open Discord instead. Now the preview nobilis cached while the link
still worked is displayed, dimmed, with the original one click away - nobilis
re-signs it by asking Discord for the message again.
Reading the `ex=` expiry client-side matters here: with a cached preview the
image never fails to load, so nothing would otherwise reveal that the original
is gone, and clicking would have opened a dead link. A stale attachment is now
known before it is requested, so the click reloads instead.
Where nothing was cached - anything predating this, or a non-image - the old
failure box remains, but now leads with Reload and keeps Open in Discord as
the fallback rather than the only option.
Also fixes messageUpdated blanking a message's embeds: an event carrying only
attachments (a preview arriving, links being re-signed) is not an edit and
must leave the rest of the message alone.
A revoked Discord token had no route back. The only login flow added a *new*
account, so refreshing a dead one meant removing and re-adding it, and QR was
the only method - no help when the signed-in device is what you lost.
The account row now offers Re-authenticate, which is where the problem
surfaces: a revoked token shows as "auth_failed" on that row, so the fix sits
next to the symptom rather than in the add-an-account section where it would
read as adding a second copy. It leads with a filled button and an explanatory
line when the token is actually revoked, and stays quiet otherwise.
Either method can be used for both adding and re-authenticating. A two-factor
challenge replaces the form with a code prompt, since the login is already in
flight and only needs the code; it accepts a backup code where the account has
them, and says so.
The password form states plainly that Discord often answers with a captcha
that cannot be completed here, and points at QR as the way through - better
than letting someone try repeatedly and guess why it fails.
Comfy mode only rendered the avatar for the first message of a group, so the
follow-ups had nothing holding that column open and slid left into it. A
group's second and third lines therefore sat out of line with its own first
line - every other client keeps a group's text on one left edge. Wrapped lines
never showed the problem, because a wrap happens inside message-content, which
was already positioned correctly.
Grouped rows now render the avatar slot too, just empty. An empty slot would
otherwise still claim its full 28px of height, padding every follow-up line
with about 9px of dead space, so it collapses vertically while keeping its
width.
Pinned rows carried the account's display name as a text prefix, so every
entry read "[email protected] · #archlinux" - the useful part pushed
right and truncated first, in the one list where names are already tightest.
They now lead with the service instead: the Discord or Matrix mark, the "#"
IRC channels already use, and Sneedchat's own glyph. Same information about
where a pinned channel lives, in the space an icon occupies.
Rows under an account header keep showing their own kind, since the header
above already says which account they belong to.
moho spawned nobilis unconditionally, which meant launching a second copy that
lost the flock race and exited - a pointless process launch and log noise on
every start. It now checks the socket first and attaches to whatever is
already listening, so a daemon started by hand, by a systemd unit, or by
another frontend is used as-is.
Stopping had the matching gap: an adopted daemon has no child handle to
signal. Quitting now asks it to stop over the socket (nobilis's new `shutdown`
method) and waits for the socket to go quiet before exiting, rather than
cutting short the QUITs nobilis sends to every connected network - a bare kill
leaves ghost sessions holding nicks until the server's ping timeout notices.
That needed before-quit rather than will-quit, which is synchronous and too
early to wait on anything.
The tray gains an explicit "Stop daemon". Disconnecting every account is worth
asking for deliberately rather than making it a side effect of closing a
window.
The pinned commit predated the rename, so the daemon would have emitted
thumbnail_path while this reads thumbnailPath - thumbnails would silently
never appear.
nobilis now describes attached files instead of hiding them in message text,
so media renders from mimetype and dimensions rather than by regexing URLs out
of a body. Dimensions become an aspect-ratio on the element, which reserves
each box before any bytes arrive - the message list no longer reflows under
the reader as images load. Audio gets an inline player and non-media files a
named card with a size, neither of which was renderable before.
Link unfurling stays: a URL someone typed is not an attachment, and scrollback
recorded before this still carries its media inline.
Two fixes found while testing against live accounts:
- The media scheme still whitelisted ~/.cache/moho after the daemon's rename,
so every Matrix and Sneedchat image was refused. Those roots track nobilis,
not this app - they are the daemon's directories.
- moho had no single-instance lock, so every launch stacked another window.
Copies contend for one daemon (whose flock lets exactly one own the socket)
and each keeps its own tray and notification state, so unread counts drift
apart. A second launch now raises the existing window instead, which is also
the right answer when it is hidden to the tray and looks like nothing is
running. An explicit --user-data-dir still gets its own instance, keeping a
throwaway profile usable for testing.
The daemon moves to its own repository as nobilis, so both frontends - this
one and dms-chat - depend on it as a peer instead of one of them vendoring it.
The vendored chatd tree and the Rust workspace are gone; `npm run daemon`
builds the submodule, and packaging pulls the binary out of it.
The Sneedchat smiley images go with it, next to the table that names them.
Previously nobilis held the filenames while this repository held the files,
so the two could drift apart with nothing to catch it.
Fixes found while testing against live accounts:
- The sidebar did not truncate. A flex item's automatic minimum size is its
content's min-content width, so prose-titled Matrix rooms made the buffer
list wider than its pane and painted over the message list.
- Discord's QR never rendered: the event field is `qrCodePath`, not `path`.
The Sneedchat and Matrix login progress text was blank for the same reason -
all four status events carry `detail`, not `status`.
- Every Matrix message rendered as a grey system line with no author or
avatar. IRC, Discord and Sneedchat tag an ordinary message "chat" but Matrix
tags it "message"; only the first was treated as something a person said.
- Context menus and the emoji picker swallowed their own clicks. Both
dismissed on a capture-phase mousedown, which unmounts the item before the
click can land on it - so pin, mute, hide and emoji selection all did
nothing. Presses inside the popup are now ignored.
The client is GPL-3.0-or-later; the daemon it depends on is AGPL-3.0-or-later,
so the package declares both.
The PKGBUILD builds from source the way an AUR VCS package does and installs
under /opt with a desktop entry, rather than shipping the AppImage - which
needs libfuse2 at runtime and integrates with nothing. `npm run pack` still
produces an AppImage for portable use.