The header read presence from the online list of whichever channel was on screen, so a partner outside that channel showed as away with an initial until the DM was opened full screen. The DM target now carries avatar fields, the client keeps every channel's online list and reads the DM's own, asks for it when the PiP opens, and the server refreshes DM rooms whenever a member connects, disconnects for good, or changes status.
Channels remember their former names, the channel list sends them to clients, and the #name pass in the renderer resolves an old name to the channel and shows its current name. A current name always wins over another channel's former one.
A custom emote relayed from Discord arrived as its bare :name: shortcode, and one typed in Haven so it would show on the Discord side stayed as text here. Haven now draws <:name:id> inline, fetching the picture once through the server so nobody's browser talks to Discord's CDN, and a Haven :name: goes out as the paired server's own emote of that name. Reported by Raidenphantom.
Kick refused anyone who was not in the channel's live presence map, so a member who had gone offline could not be removed at all. Kicking is a membership change; only the kicked notice, the socket rooms and the online list need a connection, and they are skipped when there is none. Success is reported as a success toast rather than an error.
Closing the top-bar banner, or ticking Don't show this again on the Android promo, now keeps it gone on that account across reloads and devices. The permanent flag it checked was never written anywhere, so it returned on every page load.
A forum channel now opens as a list of topic cards, newest activity on top, each with its title, tags, author, reply count and first image, and a Gallery view of square tiles. The toolbar sorts by Recently active or Date posted, filters by the channel's tags (match some or all), and a New Post button opens a composer with title, body and tags. Admins keep the tag list in Channel Functions, and the author or a moderator can retitle or retag a topic from its card. Also in: an NSFW switch per channel with a Hide NSFW channels setting, a search box inside Settings (Ctrl+F), Create Temp Channel opening the normal create form with Temporary ticked, and & and + allowed in channel names. Requested by test2 and Dispencer2.
4.5.0 taught the server to make its certificate itself, but Start Haven.bat, Install Haven.ps1 and the web installer still went looking for openssl.exe first and reported a skipped certificate as done. They now call the same generator through scripts/gen-cert.js, the certificate carries the CA and server-auth flags phones expect when you import it, and the guide and support page stop telling people to install OpenSSL. Reported by MutantRabbit767.
The effects pick lived only in localStorage, so a desktop launch that landed on a different storage origin came back with the theme's default effects while the theme itself survived through the server. Every picker write now mirrors to user_preferences like the theme does, and the preferences handler restores it before the theme is applied. Reported by Dispencer2.
Dropping, pasting or picking several files at once used to keep the first
and drop the rest, and the queue stopped at a fixed five. Every file now
queues, in the main composer, threads and the DM PiP alike, up to a new
Max Attachments per Message setting under Uploads & Limits (1 to 50,
default 10) that counts images and other files together. A batch that
has nowhere to go, or runs past the cap, gets one toast instead of one
per file. The per-minute upload allowance follows the setting (twice it,
never under the old ten) so a full drop is not cut off part way.
Also bumps the cache busters for the modules touched today and adds the
Unreleased CHANGELOG entries for #5561, #5426 and #5574.
Requested by Sheoji.
An invite created by someone who cannot see every public channel no longer drops the invitee into channels the inviter never saw, and unticking every box means no channels rather than all of them. Fixes#5569. Thanks to @birdcrazy.
The tunnel module looks for cloudflared on PATH, then in the data directory's bin folder, and downloads the official release binary there on first start when neither has it. The installer pre-fetches the same file when Cloudflare is chosen. A refused port on Windows now points at the Hyper-V/WSL reserved range check. Thanks to @Amnibro.
The desktop and mobile promo popups and the recovery-codes notice remember Don't show again on the account instead of in localStorage, so hardened browsers that wipe storage stop re-showing them. The 18+ and Terms checkboxes now appear only when creating an account; login carries a note that signing in accepts the current Terms. Thanks to @Bo0sted.
Server-side signaling and codec negotiation for a native Desktop screen share path. Off unless the user opts in from Settings, and the toggle only appears inside Haven Desktop builds that carry the native helper. Browser and per-app audio capture are unchanged. Thanks to @bernardokcosta.
Custom sound names keep letters in any script, numbers, spaces and emoji instead of being stripped to ASCII on upload and rename, so the name you typed is the name you see. Fixes#5565. Thanks to @delenda-delenda.
Bot and webhook messages keep their name in the reply banner. Reply enrichment in channel history, live sends, threads and the webhook HTTP path now shares one helper that formats authors the same way message rendering does. Fixes#5564. Thanks to @delenda-delenda.
Promotion bots (CouchBot and friends) type the ping line and put the stream link, title, and thumbnail in a rich embed. Ferry only read the embed when the Discord message had no text, so Haven got "X is live" with nothing to click or look at. Rich embeds are now read alongside the typed text, the promoted link is policy-checked on its own so an allowlist server drops the link and keeps the announcement, and Discord's own unfurls of typed links stay ignored.
A forum is a regular channel where every top-level message is a topic.
Replies go in the message's thread, the topic shows its reply count, and a
reply bumps the topic to the newest end of the channel, so old topics
resurface instead of sinking. Toggle it in Channel Functions or tick Forum
when creating a channel.
The history query orders topics by their latest activity, with cursors
resolved to activity stamps so paging keeps working, and topics never
compact into each other so a bump can move the node in place. Covered by an
integration test that boots a scratch server.
The lookup for an existing DM checked that both people were members of an
is_dm channel and nothing else. Now that group DM channels exist with the
same flag, a 1:1 DM between two people who also share a group could resolve
to the group instead. The lookup now requires exactly two members (one for a
notes-to-self DM), so a direct message always lands in a direct message.
* Group DM E2E: crypto core, key distribution, and tests
Implements the design in docs/group-dm-e2e-plan.md far enough to prove the
properties hold. No UI yet — this is the layer everything else sits on.
public/js/e2e-group.js holds the primitives, deliberately free of storage,
sockets and DOM so the same file runs in the browser and under node --test.
Confidentiality comes from an AES-256 epoch key wrapped per member over the
pairwise ECDH secret that already exists; authenticity from an ECDSA P-256
signature over a digest binding channel, epoch, sender and the previous
message. Verification happens before decryption and a rejected message
returns null plaintext, so a forgery cannot be rendered by mistake.
The signature is what makes one shared group key safe: every member can
decrypt, only a key holder can author.
src/socketHandlers/groupE2E.js distributes wrapped blobs the server cannot
open, while enforcing what no client can check for itself — only a member
may publish an epoch, epochs are strictly sequential, a member reads only
their own blob, and an epoch must cover exactly the current membership.
That last rule stops a member publishing an epoch that silently omits
someone, locking them out of a conversation the UI still shows them in.
Two suites, 28 tests, runnable with npm test. The crypto suite is written
as attacks rather than happy paths: forging as another member, re-attributing
a genuine message, tampering, stripping the signature, replaying into another
channel or epoch, reading an epoch you were not given, and unwrapping a key
not wrapped for you. It also asserts scrollback still decrypts across several
rotations, which is the regression a per-message ratchet would have caused,
and that concurrent sends sharing a prev do not false-alarm.
Confirmed the suite is load-bearing by disabling the signature check: exactly
the five attack tests fail and nothing else.
* Group DMs invite instead of auto-joining, and rewraps require a request.
Addresses review on #5499: drop leftover setup.js, consent plus roster
dedup on start-group-dm, and pin rewraps to a prior request plus the
recipient's TOFU public key.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
The web client HTML-escaped CDN query strings before the media proxy fetched them, so signed Discord URLs 404ed and the bubble was blank. Decode first, align the /uploads/ image regex with mobile, and stop 403ing Discord with a bot User-Agent.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add Navidrome rich presence integration
Shows what a user is playing in Navidrome on their Haven profile, through the
Havidrome companion plugin posting to a reserved per-user webhook.
Users generate a revocable webhook token in Settings > Activity > Navidrome.
The webhook takes strict multipart: title and duration are required, and cover
bytes are magic byte checked and held in Haven's memory. The Haven server hands
those bytes to viewers' browsers from its own /api/activity/navidrome-cover
path, so Navidrome is never contacted and its address never leaves the plugin.
Presence runs through the existing activity slot so it stays online gated,
clears on disconnect, and auto expires at track end. The profile card gains a
live progress bar; pause freezes it, stop clears it.
* Update the open profile card live from presence
The profile card was a snapshot, so a track pausing, resuming, changing or
ending did nothing until it was reopened. It now re-renders its activity from
the online-users broadcasts the client already receives, so the bar freezes on
pause, resumes on play, swaps on a new track and clears on stop while the card
stays open. It rides the existing presence stream, no new traffic.
* Make the whole profile card live and stop it clipping off-screen
The open profile card only re-rendered its music activity from presence;
status (online/away/dnd/offline) and custom status text stayed frozen at
whatever they were when it opened. It now refreshes all three from the
online-users broadcasts the client already receives, so a user going away,
setting a status or dropping offline updates the card in place while it
stays open. Shared _statusDotClass/_statusLabel/_profileStatusTextHtml
helpers keep the initial render and the live refresh in sync, and the
custom-status line gets a stable slot so it can appear and clear. Rides
the existing presence stream, no new traffic.
Positioning measured the card with a hardcoded 320x400 guess, but its
width is a rem value and its height varies with content, so once font-size
or zoom differed from 16px the real card was larger than the guess and the
viewport clamp let it spill off the right edge (desktop app and web). It
now measures the real offsetWidth/offsetHeight before clamping.
* Expire a paused Navidrome track after 5 minutes idle
A paused track cleared its play-to-end timer and set no replacement, so
the slot lingered indefinitely with no further posts. Since presence is
gated on being online, the stale track stayed hidden while offline but
reappeared on reconnect. Arm a 5-minute idle timeout while paused instead;
every update resets it, so an actively-paused client stays shown.
* Rate-limit the Navidrome cover route
The cover-serving GET had no limiter while the webhook it pairs with did.
It's viewer-facing (every open profile card fetches it), so add a separate,
looser cap (120/min vs the webhook's 60/min) rather than reusing the
webhook limiter, keeping the two routes consistent without throttling
legitimate viewing.
* Make the music presence webhook generic, not Navidrome-only
The rich presence webhook was tied to Navidrome and a single companion
plugin, so Haven had a settings page that only worked with one source and
would point at nothing if that plugin went away.
It is now a source-agnostic Listening webhook. Any music player that can
run a plugin or a script posts a title, artist and duration (plus optional
album, position, cover art and a source label) to a personal URL. The
contract is documented in listening_api.md.
- Route is /api/webhooks/listening/:token, cover is
/api/activity/listening-cover/:userId. Table navidrome_tokens is renamed
to listening_tokens with a migration that keeps existing tokens.
- A track is identified by a server-side hash of title, artist and
duration rather than a poster-supplied id, so nothing untrusted is used
as an identifier.
- Expiry is a capped lease instead of trusting the reported duration: a
playing track is held until it would end, up to 60 minutes, and long
media stays visible by sending a heartbeat. A fake duration can no
longer leave a status stuck.
Anyone still using the old Navidrome webhook needs to point their plugin
at the new /api/webhooks/listening URL.
* Scroll long track titles and artists instead of clipping them
A long title or artist was cut off with an ellipsis at whatever width
the viewer's profile card happened to be, so different people saw
different amounts of the same track. Past 30 characters the text now
scrolls in a seamless loop at a constant speed, gated on character
count rather than pixel width so it reads the same for everyone.
Shorter text is unchanged, and the scroll respects reduced-motion.
* Trim the redundant intro in the listening API doc
Save Profile called the group save unconditionally. With the manager closed
there were no checkboxes in the DOM, so an empty selection went to the server,
which read it as "leave every group". The save now only runs while the
manager is open, and the manager's checkboxes are cleared whenever it closes
so an old selection cannot be replayed later.
get-role-channel-access returned the whole channel table to any member asking
about a group, private channel names included. Non-role-managers now get only
the channels the group grants.
Also restores the roles_created key that pl.json lost in the merge, removes a
stray word from the zh admin note, flattens a nested CSS rule to match the
rest of the stylesheet, and trims trailing whitespace.
* Allow level 0 role levels to be created
Also change the text where appropriate to indicate level 0 roles can be created.
* Allow roles to also be edited to be level 0.
* let server allow role updates to level 0 be saved
* fix non-english locals litterally saying "self-serve" in english
* Enforce permission removal server-side on create-role and update-role
* Fix update-role audit log values
The values sent to the audit log were sending True for parameters that were supplied. It wasn't checking if something actually changed.
Fixed to properly set the values based on what changed.
Signed-off-by: birdcrazy <227081272+birdcrazy@users.noreply.github.com>
* Add spacing between audit-detail-pair
There was no space between values and the following key in the audit log
Signed-off-by: birdcrazy <227081272+birdcrazy@users.noreply.github.com>
* Fix lvl 0 assignment reporting the previous roles in audit log
Signed-off-by: birdcrazy <227081272+birdcrazy@users.noreply.github.com>
* Hide permissions list role editors when role lvl=0
* Change wording for level_0_role_note
* Role list refinement
Add level indicators to roles in the role list.
Add divider between level-0 roles and leveled roles.
Add "Groups" lable above level-0 roles.
* Change role sidebar level display to match the style of the channel role manager
* fix html class defined with a .
* Add "groups" divider to channel roles modal.
* fix translation pointing to wrong key
* Add groups to profile settings.
list user's current groups.
Group assignment still needs to be added.
* add missing translation key, change groups list style
* Hide Groups section if no lvl 0 roles are available
Add group-manager so user can select groups to join
as well as a save button and a socket to allow user to apply lvl 0 roles to themselves.
* bug fix: groups section doesn't display because roles arn't loaded.
* add style to groups manager, and fix bug where saving rename causes user's groups to disapear until hard refresh
* Add methods to allow user to see which channels each "Group" grants.
Using temporary tooltip for now.
* First attempt at a popup containing the channel grants
* channel access refinements
keep within window bounds.
only list sub-channels if the group grants the parent, or the user already has access to the parent.
* remove owner parent channel check as that is overcomplicating things for now.
* Add translations for group channel access popup
* bug fix: two groups both grant the same channel, user removes one, and adds the other at the same time, the channel would be removed.
* Also save groups on save-rename-btn click
---------
Signed-off-by: birdcrazy <227081272+birdcrazy@users.noreply.github.com>
Reported by Raidenphantom against Ferry. SaucyBot and the bots like it post the
picture as an embed image with the source page in embed.url, and Ferry was
relaying only the summary, so what reached Haven was a link. Haven then unfurled
it, which is the fragile path: a channel full of them ends up with dead
previews, and the picture was the point of the message.
The embed image now comes through as a URL of its own, which Haven renders as an
ordinary chat image. Where an image is present the source link is dropped, since
that is the thing that would be unfurled, and the title and description are kept
for context. Embeds with no image relay exactly as before, link included, so
sharing an ordinary link is unchanged. Every embed on the message contributes
its image, because these bots post multi-image sets as several embeds.
The URL goes in the media half rather than the authored half, matching how
attachments are already handled: Ferry builds it out of Discord's response
rather than anyone typing it, and putting it through the link filter would throw
the whole message away on an allowlist server. That loosens nothing. Whether a
viewer's browser actually fetches the image is still decided by the preview
allowlist at render time, which is the control that stops a third-party host
seeing everyone who scrolls past.
No new setting. The concern raised was disk space, and there is none to spend:
Ferry passes the URL through and re-hosts nothing, exactly as it already does
for attachments.
#5543 raised MAX_SDP_SIZE from 16 KB to 65536, which is exactly
maxHttpBufferSize. The frame carries the event name, channel code, target id
and offer id on top of the SDP, so an offer sized at the limit puts the frame
over it, and socket.io does not reject an oversized frame, it closes the
connection.
That turned the failure being fixed into a worse one. The bug was an offer
silently rejected, leaving a peer waiting; at the very top of the new range it
became the sender getting dropped from the call outright. Measured on the merged
code: a 65536 byte offer disconnected the socket.
48 KB instead. Still triple the old limit and clear of the renegotiation sizes
that motivated the change, with 16 KB of headroom for the envelope. Verified
that 49152, 49200 and 60000 byte offers are now refused at the application
level with the connection intact.
maxHttpBufferSize stays where it is. It was deliberately brought down from 1 MB
and applies to every socket event, so widening it to make room for SDP would
hand that headroom to everything else too.
Transfer Admin asks the current admin for their Haven password before it will
move anything. An admin who signs in through OIDC does not have one, so on a
server where everybody arrives through SSO the feature was not awkward, it was
shut: admin could not be handed to anyone, ever, and there is no other route to
it. That was the second half of #5539.
Those accounts now confirm with a code from their authenticator instead. Two
factor has to be switched on before they can, which is the deliberate part:
this prompt exists to demand a second factor before an irreversible handover,
and "you are already signed in" is not one. Anyone with a real password hash,
meaning every local account, is untouched and still takes the password path.
The check keys on an OIDC account with no bcrypt hash rather than on SSO alone,
matching the guard the admin password reset paths already use, so an account
that somehow held both would keep its password prompt. The modal picks its
field from the same condition and the server rejects the wrong credential
either way, so the two cannot drift into disagreeing.
Verified against a running instance. As an SSO admin: a password is refused, a
missing code is refused, a wrong code is refused, the right code transfers, and
with two factor off the request is refused with an explanation. As a local
admin: wrong password refused, missing password refused, a TOTP code does not
substitute, correct password transfers. Also clicked the whole thing through
the real modal end to end.
ADMIN_USERNAME is meant to bootstrap the first admin on a new server and
nothing more. Login and the socket handshake both treat it that way: they only
promote the account while the server has no admin at all. Registration did not
check, it promoted on the username alone.
That gap only opens once the name is free again. Rename the admin account, or
remove it while another admin holds the server, and the next person to register
the old name arrives as a second admin standing over the real one. On a server
with open sign-ups that is anyone who reads the compose file.
Registration now runs the same check as the other two. A brand new server has
no admin to find, so first-run setup is unchanged, and a server that has
genuinely lost every admin can still re-bootstrap the same way it always could.
Verified both directions against a running instance: with an admin present the
new account comes back is_admin=0, and with none present it still comes back
is_admin=1.
Two loose ends from #5536.
Zelda ships as a theme and sits in both theme pickers, but it was still
missing from the admin Default Theme dropdown and from the allowlist that
accepts a default, so it was the one built-in an admin could not set.
The login page also started storing the server default as though the visitor
had picked it. That reads fine on a first visit and then bites: the page only
consults the server default when nothing is stored, so an admin who later
changes the default would never reach a returning visitor who has not signed
in. It now applies without storing, matching the published-theme branch right
beside it. applyEffects gained an optional theme argument for that case, since
it otherwise reads the theme back out of storage to decide which overlays to
run.
The app page keeps storing it. It re-applies the server default on every
connect for anyone with no saved preference, so there is nothing to go stale.
* Add missing Themes to default-theme dropdown admin setting
And reorganize the themes to match the order in the picker.
Signed-off-by: birdcrazy <227081272+birdcrazy@users.noreply.github.com>
* Make auth theme selector built-in theme list match the in-app theme selector list
Signed-off-by: birdcrazy <227081272+birdcrazy@users.noreply.github.com>
* allow added default-theme list items to be saved
* Make server default theme button "Active" on login page
When navigating to the login page for the first time, the server's default theme was correctly being applied to the page
but the `id="auth-theme-bar"` theme selection button for that theme was not becomeing "active".
---------
Signed-off-by: birdcrazy <227081272+birdcrazy@users.noreply.github.com>
The previous commit cited #5540 for the Discord-to-Haven image bug. #5540 is
about dynamic bot voice audio and has nothing to do with it; the bug was
reported directly rather than through an issue. The comments no longer point
anywhere misleading.
Images posted on Discord never arrived in Haven, and neither did the message
they came with. Haven to Discord was fine, which is what made it look like one
direction of the bridge was simply not wired up.
Ferry relays an attachment as a link to Discord's CDN, built from Discord's own
API response. Haven's link filter is set to an allowlist by default and that
list does not carry cdn.discordapp.com or media.discordapp.net, so the check
failed and Ferry threw the whole relayed message away, the author's text along
with the picture.
The filter now sees only what the Discord author actually typed. Attachment and
sticker URLs are added after it runs, since nobody typed them and they are not
a link anyone chose to post. Somebody pasting a blocked domain on Discord is
still filtered exactly as before, image attached or not.
Adding Discord's CDN to the seeded allowlist would also have cleared the symptom,
but it would open that domain to every member of the server rather than to the
bridge, and it would not have helped any server already past the one-time seed.
The existing ferry tests run without a database, so automod short-circuits to
"allowed" and never exercised this path, which is how it got through. The new
test file loads Ferry against a stubbed automod that is switched on and carrying
the real seeded allowlist, and it fails against the previous code.
Three problems reported from real use:
Ferry appeared to relay only the admin's messages. It was the permission: no
default role holds use_ferry, so an admin sets Ferry up, tests it successfully
as an admin, and it silently does nothing for everyone else. In mirror mode
there is not even a toast, by design, so there was no way to tell. The panel
now has a "Choose who can send" step listing every role with a switch, writing
the same permission the Roles editor does. Nobody should have to go hunting
through another screen to make the feature they just configured work.
The channel dropdowns were flat lists. Sub-channels were indistinguishable from
channels, and Haven allows the same display name under two different parents,
so the list could show two identical entries with no way to pick the right one.
Haven channels now nest under their parent, Discord channels group by their
Discord category, and a sub-channel whose parent is not visible lands under
"Other" instead of disappearing.
@mentions did not cross. Discord only pings when the text contains <@id>, so a
relayed "@someone" was inert no matter what the pings setting said. Outgoing
names are now resolved against the destination server, and only an exact,
unambiguous match is turned into a real ping: two people answering to one name
means it stays plain text, because pinging the wrong stranger is worse than not
pinging. Incoming mentions use the message's own resolved list, so they are
exact rather than guessed. The Server Members intent is now requested when
either DMs or pings are on, not just DMs.
Security:
- The Discord DM recipient came from the client and was never validated or
authorized. Scoping the autocomplete to paired servers was not a control,
since the id travels with the send and a client could skip the lookup. The id
is now shape-checked, and the recipient is confirmed to be a member of a
server the channel is actually paired with before anything is sent. A channel
with no pairings can no longer send a DM at all.
- Member lookups had no rate limit. Each one fans out to up to five Discord
calls, and a burst of 429s is how a bot token gets temporarily banned, so
they now have their own flood bucket.
- use_ferry was checked without a channel, so a channel-scoped role grant
silently did nothing and only a server-wide grant worked. Same class of bug
as #5433.
- The composer remembered a chosen DM recipient's id but not who it belonged
to, so editing the name by hand sent a private message to whoever was picked
before. The id is now bound to the exact recipient text and dropped the
moment it stops matching.
From @Raidenphantom's independent bridge, which surfaced two real gaps:
- A Discord edit is now applied to the Haven copy instead of being ignored.
Messages we never relayed are not resurrected by an edit, which is what stops
a third-party bot's delayed image embed from arriving after its original was
filtered out, and embed-only updates are ignored so a link preview cannot
replace the author's text.
- Custom emotes arrived as raw <:name:id> mid-sentence and now read as :name:,
rendering as a real emoji when the Haven server has one by that name.
Inbound Discord messages also now pass through the automod link check. A bridge
is an excellent spam vector, and relayed messages were skipping the controls
every Haven member is held to.
Each Haven server brings its own Discord bot, since Discord caps unverified
applications at 100 guilds. Reading uses a gateway WebSocket, because Discord
has no outgoing webhook for new messages. Writing uses a per-pairing Discord
channel webhook rather than the bot user, which is the only way a relayed
message can carry the Haven author's own name and avatar.
Pairings live in ferry_links and act as the allowlist, not just a routing
table: a member can only reach Discord channels an admin paired with the Haven
channel they are standing in. Each pairing picks its own direction (two way, or
either one way) and its own outgoing mode (mirror everything, or only messages
addressed with the "=>" prefix). Sending needs the new use_ferry permission.
The trigger is "=>" and not ">>" because a leading ">" is Haven's blockquote
marker, so an unresolved target would render as a quote instead of visibly
failing. Resolved targets are stripped from the stored message and recorded in
messages.ferry_target so history shows a destination badge instead of raw
routing syntax.
Loop prevention drops the gateway echo of our own webhook ids and, by default,
every other bot. Mentions are blocked by default so one member cannot ping a
whole Discord server through the bridge. DMs are off by default, outbound only,
and stamped with the author's name, since a bot cannot impersonate in a DM.
ws becomes a direct dependency; it was only reaching us through socket.io.
* Rework message search into a persistent overlay panel
Search results now open as an overlay on the right side over the member
list, the way Discord does it. The panel stays open across channel
switches and only closes when you close it yourself. Public channels
share one panel while each DM keeps its own.
Results paginate at 25 per page. The panel clears and asks you to
refresh when a channel or one of your roles changes, so it never shows
messages you can no longer see. A collapsed sidebar expands
automatically while the panel is open.
* Make message search global with SQLite full text search
Search now spans every channel you can see instead of just the current
one, backed by an FTS5 index that stays in sync with messages through
triggers. Results are scoped to your channel memberships on every query,
so you never see anything you don't have access to. in:#channel narrows
to one channel, from: and has: still work.
The tokenizer is admin configurable through HAVEN_SEARCH_TOKENIZER and
reconciled at boot: unicode61 by default (closest to Discord), with
porter for stemming and trigram for substring matching. Changing it
rebuilds the index once on the next restart. Results come back 25 per
page with a total count, sorted newest first.
* Add a filter picker and cross-channel results to search
Clicking the search box opens a filter popover with from:, in: and has:
pickers. Each lists what you can see (members, channels, media types),
rendered client side with a prefix filter box, and clicking one appends
the filter to the search box. A filter button next to the box toggles
the popover so you can reopen it without closing search.
Channel names are not unique, so in:#code selects a channel by its code
while in:name still matches by name. The picker fills the code form so
duplicate names are never ambiguous.
Search results now show the channel each message came from as
#name (code) above the author, and clicking a result switches to that
channel before jumping to the message so cross-channel results work.
* Add date, pinned, and audio filters plus a sort control to search
The filter popover gains before:, after: and during: date pickers, a
pinned toggle, and audio in the has: list. before/after take a boundary
date and during matches a single day. A sort row lets you order results
by newest, oldest, or most relevant, which the search engine already
supported but the UI never exposed.
All of these run server side against the message index, so they stay
scoped to the channels you can see.
* Guard against stale search results, add a loading state and recents
Each search request carries a sequence token that the server echoes
back, and the client only renders the response matching its latest
request. A slow earlier query can no longer overwrite newer results.
A spinner shows while a request is in flight.
Recent searches are kept in local storage and offered at the top of the
filter popover when the box is empty. They save on Enter, so the list
holds real searches rather than the partials typed on the way there.
* Render search results as real messages and rate limit search
Search results now render through the message formatter, so images,
uploaded video and audio, spoilers, mentions and links look the same as
they do in chat. Audio uses preload=none so nothing loads until play.
Page links are not unfurled automatically. Each link gets a Load button
that renders just that embed through the existing preview scheduler and
cache, so a page of results never fires a burst of preview fetches.
Right clicking a result opens a Copy link and Delete menu. Delete targets
the result's own channel, and the row is removed only once the server
confirms the delete. Results that head a thread show a reply count badge.
The bottom pager gains a page number input so you can jump straight to a
page instead of clicking through the arrows one fetch at a time.
Search also gets its own per account rate limit of 10 requests per 10
seconds. When it trips the client keeps its current results and shows a
toast.
Steam and Spotify linking failed with "Link session expired" for any account
whose password_version had moved past 1: a password change, an admin reset, or
a recovery code. Fresh accounts were fine, which is why it looked intermittent.
The short-lived token these redirects carry was the only one Haven mints that
left out pwv. verifyToken checks that field on every token carrying a user id
and reads a missing one as 1, so the token looked like it predated the
account's first password change and got rejected on the way back.
It now carries the real value, which also keeps the property the check exists
for: changing the password mid-flow still invalidates the link in progress.
Closes#5527