70 Commits
Author SHA1 Message Date
devsyncwrldandClaude Opus 5 5e7adfb38d chore: version 2.1.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:09:30 -03:00
devsyncwrldandClaude Opus 5 033ba63762 feat(profile): banner repositioning, and the editor rebuilt on the reference's shape
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Three things, from the screenshots.

Banner repositioning, which was a real hole. A banner was always cropped dead
centre, and a GIF could not go through the still-image cropper at all — so
there was no way to frame one. The crop is now two percentages and a zoom
stored in the profile (`banner: {x, y, zoom}`), and the banner renders as an
<img> with object-position plus a scale rather than a CSS background: those two
compose into pan-and-zoom, where `background-size: cover` cannot be zoomed
without knowing the file's dimensions. The editor drags the live element, so a
GIF keeps animating while you frame it, and nothing is ever re-encoded.

The editor now has the reference's shape: a rail of cards on the left, each
showing what it currently holds, and the live profile beside it. A card opens a
dialog with the options on one side and a preview on the other. That is the
part both earlier attempts got wrong — the first put a 360px preview inside a
640px column, the second made you leave the preview to open a section. Here
they are on screen together at full size.

The dialog edits a copy: Cancel discards it, Apply commits to the draft, and
the draft is only persisted by Save. So backing out of a dialog cannot cost
unrelated work, and neither can the per-section reset in its header.

"Surprise me" randomises the look and nothing else. Fonts, effects, decoration,
theme and nameplate change; pronouns, tag, links, board and the banner crop are
the user's own words and framing, and rolling dice over those would be
vandalism. A test asserts exactly that over 40 rolls, and that every roll lands
on ids the normaliser accepts.

Five more nameplates in the y2k direction — holo, chrome, vapor, matrix, ember
— since the existing ten were all plain gradients.

Verified: shared build, web build, server typecheck, 523 web tests (5 new:
banner clamping and round-trip, randomiser bounds).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:02:29 -03:00
devsyncwrldandClaude Opus 5 e6eba7a2b7 fix(profile): align the decorations, and make the effects actually look like something
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Three complaints, three causes.

Decorations sat off-centre. <Avatar>'s box is `size + 2 × ringWidth`, but the
overlay was positioned with a percentage inset of that box — so it drifted by
exactly the ring whenever one was present. That is why the crown floated above
the head on the profile card while looking fine on the editor tiles, which
have no ring. DecoratedAvatar now takes `size` and `ringWidth` and computes
the inset in pixels, so the picture's edge always lands at a known radius in
the SVG viewBox and every decoration is drawn against it.

The ugly ones were ugly. Flames were a blob with a black circle punched out of
it; they are now fourteen tongues of alternating height on a gradient. The
crown's band sits ON the picture's top edge instead of hovering above it.
Frost, stars and hearts were placed by eye and are now on a computed ring.
Nine more: holo disc (a conic gradient — the honest way to draw one, and CSS
has it where SVG does not), chain, cyber brackets, lightning, spikes,
butterflies, sakura, bubbles, flower. Twenty in total.

Name effects were thin. Gradient was a two-stop ramp; it now has a lit middle.
Neon was one shadow, now five layered with a buzz. Glitch was a keyframed
text-shadow, now a real RGB split duplicated through ::before/::after from
data-text. Added chrome and gold (banded metal with a specular line — the y2k
look the reference is after), holo, rainbow, ice, 3D extrude, and outline.
Sixteen effects, thirteen fonts with Audiowide, Bungee and Monoton added.

The editor tiles now sample the user's own name rather than "Gg" or the effect
label: an effect reads differently on a short word than on the name it will
carry. Two per row instead of three, and section rows gained an icon.

One test failed on the catalog growth and was right to: it used 'rainbow' as
an example of an unknown id, and rainbow is now a real effect. It uses an
absurd id instead, and a new test walks every catalog id through the
normaliser so a catalog and the normaliser can never drift apart silently.

Verified: shared build, web build, server typecheck, 518 web tests (1 new).
Not verified: pixels — no browser here; the geometry is now computed rather
than guessed, but a screenshot decides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 09:46:20 -03:00
devsyncwrldandClaude Fable 5.1 723522b90d fix(profile): give the editor room — three panels instead of a cramped column
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The owner's verdict on the first editor was "imprensado", and it was: the
settings shell caps its content at 640px, and the editor put a 360px preview
next to six-column tile grids inside that. Everything was legible and nothing
was usable.

Now it follows the shape of the reference (Discord's profile editor):

  - left, 320px: the list of sections, each showing its current value as a
    mini preview (the nameplate strip, the decorated avatar, the styled name,
    the theme swatch, the effect thumbnail). Picking one replaces the list
    with that section's editor, with a back arrow — a drill-down instead of
    sub-modals, one level deep.
  - centre: the live card, plus two in-context samples — how the name and tag
    read on a chat line, and the row in the member list with its nameplate.
  - right: the Board as others see it, with an Edit shortcut.

Tiles went from 5–6 per row to 2–3, with real size (Gg samples at 26px,
decorations on a 56px avatar, effect thumbnails 64px tall). The settings
shell drops its width cap and lets the panel fill the height for this tab
only; every other panel keeps the single reading column. Stacks to one
column below the lg breakpoint.

Verified: web build, 517 web tests. Not verified: pixels — there is no
browser here, so the proportions are from the reference and the numbers, and
a screenshot decides.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 09:27:38 -03:00
devsyncwrldandClaude Fable 5.1 e7094014b7 fix(shared): profile.ts must not reference the URL global type
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
packages/shared compiles without the DOM or Node type libraries, so `URL` has
no type there even though it exists at runtime everywhere this code runs. The
web and server builds resolved the same file under their own libs and passed;
only the shared package's own `tsc` — which CI runs first — failed, on both
runners. Reach the constructor through globalThis with a minimal structural
type instead of pulling a lib into a types-only package. Runtime unchanged,
so the deployed site (82798d5c) needs no redeploy for this.

Lesson recorded for the local checklist: run `pnpm --filter @backspace/shared
build` too — the root `typecheck` script already does; the web build alone
does not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:44:35 -03:00
devsyncwrldandClaude Fable 5.1 82798d5c5a chore: version 2.0.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The rebrand to Resenhacord moves the install and the profile directory;
a major bump marks that boundary for anyone reading the version string.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:38:30 -03:00
devsyncwrldandClaude Fable 5.1 ef9c73501c feat(profile): a full identity system — name styles, decorations, effects, nameplates, tag, links, board
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The owner asked for profile customisation that is "completo e FODA", pointing
at Discord's profile editor. This is the whole thing, end to end, with a live
preview.

What a user can now set, and where it shows:

  - Display name style: 10 fonts × 9 effects (solid, gradient, neon, toon,
    pop, gummy, prism, glitch, fire) × two colours. Popout and full profile.
  - Avatar decoration: 10 (ring, neon, flames, stars, halo, vines, pixel,
    hearts, frost, crown). Inline SVG — no image assets, scales to any size.
  - Profile effect over the banner: 9 (sparkles, rain, snow, matrix,
    confetti, aurora, embers, bubbles, hearts). CSS particles with seeded
    positions so re-renders do not change the weather.
  - Nameplate behind the member-list row: 10 presets + custom two-colour.
  - Theme: two colours washed behind the card, and the banner fallback.
  - Pronouns, and a clan-style tag (emoji + 2–4 letters) beside the name,
    also on the member row.
  - Links, up to 5, with the brand icon picked from the host.
  - Board: favourite game, up to 5 in rotation, up to 10 wanted, a quote.
    Games come from Steam search through a small server proxy (cached,
    10 min) or are typed by hand; covers only load from Steam's CDN.
  - Badges, derived rather than stored: instance admin, server owner,
    founder (joined in the space's first month), veteran (a year), OG (six
    months), stylist. Nothing to grant, revoke or migrate.

Storage is one JSON column, users.profile_style (migration 0019), and one
normaliser in shared/profile.ts that both the server (on write and on read)
and the client (on render) run. It never throws: unknown ids fall back,
strings are cut to their limits, colours must be #rrggbb, link URLs must be
https, cover URLs must be a Steam CDN host. Kept out of the federation
durable-field set on purpose — peers on older builds do not know the field,
and it is cosmetic.

The editor is a new Settings > Profile tab. Its preview is the same
ProfileCard component the popout renders, with the draft style swapped in —
what you see while editing is, by construction, what others see.

Fonts are OFL builds self-hosted from public/fonts alongside DM Sans, latin
subsets only, ~120 KB in total.

Verified: web build, 517 web tests (11 new: normaliser bounds and fallbacks,
link icon detection, badge derivation), server typecheck, 804 server tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:36:23 -03:00
devsyncwrldandClaude Fable 5.1 872066e5be feat: rebrand to Resenhacord with a new logo, and post a notice when a message is pinned
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Rebrand. The product name is now Resenhacord everywhere a person reads it:
window title, tray, notifications, About panel, PWA manifest, page title,
recovery and instance-picker screens, and the four i18n strings that named
the app. Code identifiers (BackspaceElectronAPI, @backspace/* workspace
names, the federation protocol) are unchanged — they are not a brand, and
renaming them buys nothing but churn. AGPL notices and upstream attribution
stay as they were.

Two rename details that would each have hurt real users:

  - electron-builder's productName changed but appId did not. NSIS keys the
    installation on appId, so the updater replaces the Backspace install in
    place instead of leaving a second app beside it.
  - userData is keyed on app name, so the rename would have moved every
    install to an empty profile — logged out, instance URL gone. main.ts now
    walks a migration chain ("@backspace/desktop" → "Backspace" →
    "Resenhacord"), each step a no-op once its source is gone, and one whose
    target already holds data leaves it alone. Autostart lookups accept the
    old registry entry name too.

Logo. Two speech bubbles in conversation, violet behind, rose in front with
three dots — the front one is talking. Sources are hand-written SVG in
assets/brand (square viewBox; the gap between bubbles is a mask, not paint,
so the alpha-only macOS template keeps the silhouette). PNG sources are
rendered from the icon SVG and the whole set — .icns, .ico, Linux PNGs, tray
variants, favicons, PWA, maskable — comes from the existing gen-icons
pipeline unchanged.

Pin notice. Pinning a message now posts "X pinned a message to this channel.
See all pinned messages." into the channel, the way Discord does, with two
links: the message itself (jumps to it) and the pins panel. Space messages
had no `type` column — only DM messages carried system events — so this adds
one (migration 0018, default 'user', verified against a copy of the
production database: 88 rows, all 'user'). The route inserts a type='system'
row alongside the pin and broadcasts it as message_created, so the client
renders it with the SystemMessage renderer it already has. Unpinning posts
nothing; it is housekeeping, not news. System notices are excluded from the
notification toast and the message sound — they are not from anyone.

The pins panel and jump target are local state in MainContent; a notice deep
in the timeline reaches them through two small uiStore requests (a counter
and an id), neither persisted.

Verified: web build, 506 web tests (2 new), server typecheck, 804 server
tests, desktop typecheck.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:15:14 -03:00
devsyncwrldandClaude Opus 5 a41a944742 feat(chat): @everyone mention
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Rides the existing `<@id>` token: `everyone` already matches the parser's
`[a-zA-Z0-9_-]+`, so the markdown pre-processor, the inline renderer and the
badge pick it up with no second syntax. User ids are snowflakes, so the
literal `everyone` cannot collide with one.

Gated on a new MENTION_EVERYONE permission rather than being open to all —
otherwise a role can never take it away. Granted to @everyone by default: on
a small instance the ping is a convenience, not a weapon, and an owner who
disagrees can deny it and grant it to a role instead.

Two things that would each have made the gate a no-op:

  - DEFAULT_EVERYONE_PERMISSIONS only applies at space creation, so spaces
    that predate the bit would have had every @everyone mention silently
    stripped. A boot-time backfill ORs the bit into @everyone roles only
    (`id = space_id`), leaving other roles alone so a later denial is not
    overruled on the next restart. Verified against a copy of the production
    database: three roles moved by exactly 2^15, non-@everyone roles
    untouched, second pass a no-op.
  - Enforcement covers edit as well as create; without that, posting a plain
    message and editing it is the way around the permission.

A message that lacks the permission is not rejected — losing what someone
wrote over one token is worse than posting it. The token is demoted to the
literal `@everyone` text they typed: the message reads the same and rings
nobody.

Code spans and fences never ping. People paste payloads and snippets into
chat, and a paste should not ring everyone's client. The same rule decides
the row highlight and the notification sound, so the three cannot disagree.

Verified: web build, server typecheck, 504 web tests, 804 server tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 22:10:56 -03:00
devsyncwrldandClaude Opus 5 1ec5d88fa2 chore: version 1.5.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:58:05 -03:00
devsyncwrldandClaude Opus 5 699855849d feat(i18n): translate the five systems users actually see
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Ordered by what the group hits, not by string count.

  - Chat system messages ("X added Y to the group") and their sidebar
    preview twin. These are content, not chrome, so they sat in English in
    the middle of conversations.
  - Login, register and the invite landing page — the first screen anyone
    invited to the instance sees.
  - Voice — controls, connection info, the mobile join sheet, settings tab.
  - Mobile screens.
  - Settings navigation across space, user and instance, plus Discovery and
    join requests.

Three call sites build strings outside a component and so read the locale
directly rather than through the hook: the sidebar preview formatter, the
connection-info track labels, and MobileShell's screen map — which holds
plain functions that are *called*, not rendered, where a hook would run
under whichever component invoked the map. Each says so at the definition;
all three re-render often enough that a language switch shows through
promptly.

Adds a test for a mistake made three times in this series: a file with an
exported component plus a private sub-component, where only the exported one
got the hook. tsc catches it, but only after a full build and without naming
the component. The test names it, and also flags the inverse — a hook placed
below an early return, which tsc accepts and React breaks at runtime (this
project shipped that once, as error #310). Verified by mutation: removing a
hook makes it fail and print the component.

Dictionary is at 856 keys, en and pt-BR at exact parity.

Verified: web build (tsc + vite), 492 web tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:13:54 -03:00
devsyncwrldandClaude Opus 5 94f5da4013 feat(i18n): translate five more systems, and unify the permission labels
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
- Space settings > Overview — identity, icon, banner, transfer, delete
  - Space settings > Members and Bans
  - Space settings > Roles, including the whole permission table
  - Category settings — rename, privacy, overrides, delete
  - Instance settings > General — name, discovery, GIF search

The permission labels were duplicated: the roles panel carried one English
list and the channel/category override editors carried another. Both now
read a single `perm.*` set, so renaming a permission lands in all three at
once instead of one and a half. This removes the `channel.perm.*` keys added
two commits ago rather than adding a third copy beside them.

Same pattern as before for anything chosen outside the render: the roles
panel's permission groups and the category's override definitions hold keys
and resolve them where they render. The category delete dialog emphasises
the category name, so its sentence is split around the placeholder rather
than interpolated.

Dictionary is at 723 keys, en and pt-BR at exact parity.

Verified: web build (tsc + vite), 490 web tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 20:49:25 -03:00
devsyncwrldandClaude Opus 5 4fd6ac890c feat(i18n): translate five more systems
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
- Settings > Voice: video — preview, permission states, camera picker.
    This closes the Voice & Video tab; input, output and video are all done.
  - Create channel / category / space
  - Group DMs — new DM, add friends, group settings, leave
  - Join a space — discovery, invite code, and both federated auth phases
  - Delete account, transfer ownership, and the top-level error screen

Three patterns worth noting. Strings that are chosen in a helper or held in
state (the camera's hard-blocked copy, its preview errors, the space
visibility options, the invite failure reasons) now travel as translation
keys and are resolved where they render, so switching language re-labels
what is already on screen. Sentences that wrap an emphasised literal — the
host name when joining, the space and member names when transferring — are
split around their placeholder instead of interpolated, leaving word order
to the translation. And `CreateChannel` mapped over `(t) =>` for the channel
type, which shadowed the translator; the loop variable is now `channelType`.

The error boundary in main.tsx is a class component, so it reads the locale
once per render via `translate` rather than the hook — correct here anyway,
since an error screen has no reason to re-render on a language switch.

Two GroupDmSettings tests located the name input by its aria-label, which
this change translates. They now use the `data-group-dm-name-input`
attribute the input already carries, matching how the rest of that file
locates elements — and no longer break when copy changes.

Dictionary is at 595 keys, en and pt-BR at exact parity.

Verified: web build (tsc + vite), 490 web tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 20:38:54 -03:00
devsyncwrldandClaude Opus 5 115c81cba3 feat(i18n): translate five more systems, and check for updates more often
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Update cadence: the desktop app checked once ten seconds after launch and
then every four hours. A release published while the app sits open went
unnoticed until the next poll — which is exactly what happened with 1.4.0.
It now polls every 30 minutes and also checks when the window regains focus,
throttled to one check per five minutes so alt-tabbing does not hammer the
feed. `lastCheckAt` is stamped from the `checking-for-update` event rather
than by the callers, so a manual check from the settings panel counts too.
The focus listener is on `app`, not on mainWindow, because the window is
destroyed and recreated when closed to tray.

Translations, five whole systems:
  - Settings > Desktop (autostart, updates, instance)
  - Settings > Voice: output device, which completes that tab
  - Settings > Keybinds, including the action names and conflict prompt
  - Settings > Account, the largest — profile, password, detached-instance
    notice, danger zone
  - Explore spaces

Two sentences wrap a literal that must not be translated (the `input` group
name, the `user@instance` example). Those are split around a placeholder
rather than interpolated as text, so a translation keeps control of the word
order on both sides of it. Keybind action labels gained a `labelKey`
alongside the English `label`, which stays for non-React callers.

Verified: web build (tsc + vite), desktop typecheck, 490 web tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 13:38:28 -03:00
devsyncwrldandClaude Opus 5 a54d998ba9 chore: version 1.4.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:52:31 -03:00
devsyncwrldandClaude Opus 5 860f02b2c1 feat(channels): rename channels, and quieter defaults for sound and streams
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Four owner requests in one pass.

Rename: the PATCH /api/channels/:id route already accepted a name and a
topic, and the API client already exposed it — the only thing missing was a
way to reach it. The channel settings Overview tab now edits both, gated on
MANAGE_CHANNELS like the server is, with Save/Discard appearing only when
the form is dirty. The server's lowercase-and-hyphenate rule moved to
@backspace/shared so the field can show what a name will actually become
instead of letting it change shape on save; one copy of the rule, used by
both sides.

Stream volume: someone else's screen share now starts at 50 instead of 100
(DEFAULT_STREAM_VOLUME). The publisher boosts system audio before sending,
so the old default arrived far too loud for a viewer who never touched the
slider. Only the default moves — the slider still covers the full range and
a value the user set is still persisted.

Sound effects: SFX_BASE_VOLUME 0.8 -> 0.4, halving every cue. The .ogg files
were left alone; they already peak below full scale, so the harshness was
level, not recorded distortion. Every SFX playback path goes through
getSfxVolume(), so this is the single knob.

Translations: channel settings (including the override editor's permission
labels), the friends page end to end — tabs, lists, requests, the add tab
and discovery cards — the invite modal including its per-invite failure
reasons, and the group-DM member actions. Failure reasons are carried as
translation keys rather than finished text, so results already on screen
re-label when the language changes.

Verified: web build (tsc + vite), server typecheck, 490 web tests, 804
server tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:59:25 -03:00
devsyncwrldandClaude Opus 5 293378293d fix(ci): match release assets by name, and close the guard block
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Two defects, both mine, and the second hid the first.

The cleanup built a regex and matched it against the jq line '<id> <name>', so
the anchor in latest.yml could never match — it sits after the id. Only the
.exe was removed, and each publish left another latest.yml behind. Gitea then
served the older one, so the updater kept reporting the previous version as
current: an update that exists and is never offered, with nothing logged
anywhere. Filtering now happens inside jq, comparing the name directly.

The guard meant to catch exactly that was missing its closing fi, so the step
died on a syntax error before reaching it. Verified with bash -n this time,
which is what should have happened before it ever ran on a runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:14:59 -03:00
devsyncwrldandClaude Opus 5 c5abb6fd64 chore: version 1.3.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Carries the silent native notification, which lives in the Electron main
process and therefore only takes effect from a new installer — the reason this
release is needed beyond exercising the restart prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:07:26 -03:00
devsyncwrldandClaude Opus 5 1fb61377b9 feat(notify): in-app notifications with the app's own sound
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The system balloon carries the OS notification sound, which does not belong to
this app, and it only fired while the window was out of focus — with the app
focused nothing appeared at all.

Notifications now surface inside the window, carry the same synthesised timbre
as the rest of the app's sounds, and clicking one opens the channel. The native
balloon is kept for when the window is not visible, since an in-app card
nobody can see is no notification, but it is now silent: the app plays its own
effect instead.

A focused window is notified only about other channels — announcing the
conversation someone is already reading is noise.

Also fixes the Gitea publish cleanup, which silently deleted nothing: it
interpolated an Actions expression inside a bash , and when the pattern
did not match, the loop passed over every asset. The release ended with two
latest.yml files and the updater served the older one, reporting 1.1.0 as
current — an update that exists but is never offered, with no error anywhere.
The filter is plain bash now, logs what it found, and the job fails if more
than one latest.yml survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:57:02 -03:00
devsyncwrldandClaude Opus 5 1a1067bbb6 chore: version 1.2.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Pins, filtered search, custom emojis and stickers, and the in-app restart
prompt. The prompt itself is only visible once an update is offered, so this
release is what makes it observable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:49:23 -03:00
devsyncwrldandClaude Opus 5 71d2109b2f feat(desktop): in-app prompt to restart for an update
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The main process already showed a system notification when an update finished
downloading, and nothing in the renderer listened — so the only cue vanished on
its own and was missed by anyone not watching the desktop at that moment.

A prompt now stays in the window until it is acted on or dismissed, with the
restart wired to the install-update channel the preload already exposed.

Dismissing is 'not now', not 'never': a later version reopens the prompt, since
the version it refers to has changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:31:59 -03:00
devsyncwrldandClaude Opus 5 7f08384372 feat(expressions): pickers and upload for emojis and stickers
Completes the feature: the tables existed but nothing could be put in them.

Space settings gain an Emojis & Stickers panel behind MANAGE_SPACE, with a
512KB ceiling — both are fetched on every message that uses them, so weight
matters more than fidelity. The suggested name is pre-normalised so the common
case needs no typing, and a name collision reports itself distinctly from an
upload failure: the corrective action is different.

Custom emojis join the emoji picker as their own category. They have no native
character, so selecting one inserts :name: — the same text the renderer
resolves back to an image, which also means copying a message yields something
that still reads.

Stickers get a picker tab that only appears inside a space, since that is where
they exist, and send immediately on click: a sticker is the whole message, so
parking it in the composer to await Enter would make no sense.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:25:10 -03:00
devsyncwrldandClaude Opus 5 7d003021d1 feat(expressions): custom emojis and stickers per space
Emojis and stickers get separate tables on purpose: an emoji sits inside a
sentence and a sticker is the whole message, so their sizes, limits and render
paths differ.

Emoji names are unique per space and restricted to letters, digits and
underscore — :name: has to resolve to one image, and a space or colon inside
the name would make the reference impossible to delimit. Sticker names are
labels picked from a grid, so they accept anything.

Rendering reuses the existing markdown pipeline by rewriting :name: into image
syntax, which needs no plugin. Code spans and fences are left alone: text
someone wrapped in backticks was meant to be shown literally, and turning it
into an image would destroy what they wrote.

Messages carry stickerId with ON DELETE set null — removing a sticker from the
space must not delete the history of everyone who used it. The server refuses a
sticker from another space, which would otherwise leak images between unrelated
servers.

Expressions load once per space and stay in memory: the message renderer
consults the map for every :name: it finds, and a request per lookup would turn
each message into a cascade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:57:32 -03:00
devsyncwrldandClaude Opus 5 50a8f12c77 feat(search): inline filters, and translate the search UI
The filters themselves already existed end to end — the server takes q, from,
has, before and after, the API client passes them, and the popover has fields
for each. What it lacked was discovery: the panel sits behind a button, so the
capability was invisible.

Typing 'de:fulano' or 'has:image' straight into the search box now applies the
same filters. Keys are accepted in both languages, since the app is bilingual,
and an unrecognised token falls back to being search text — otherwise a message
containing a URL or 'algo:coisa' would become unfindable.

Inline filters win over the panel's: whoever just typed one is expressing the
more recent intent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:49:05 -03:00
devsyncwrldandClaude Opus 5 4ceb5cd66a feat(pins): pin messages to a channel
Pinned state lives on the message rather than a join table: a message is
pinned in exactly one channel, its own, so a separate table would add a join to
every lookup and buy nothing.

Every message now carries its pin state, so the timeline can mark a pin without
a second request and the panel and the timeline cannot disagree. Toggling is
deliberately not optimistic — the server refuses past the channel's limit, and
showing it pinned before confirmation would lie in exactly that case.

The pins list reuses the same assembly the channel history uses, extracted into
one helper, so the two cannot drift apart in what they include.

The migration also adds the (channel, user, created) index the filtered search
will need, since both touch the same table and one migration is cheaper than
two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:45:33 -03:00
devsyncwrldandClaude Opus 5 c4158ee245 docs: retire the fixes that shipped and were confirmed in use
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:13:30 -03:00
devsyncwrld 61c68761df i18n: translate the channel and member sidebars
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Clears the two remaining rounds owed under the fork's per-update translation
rule.

Covers channels, direct messages, the context menus, the inline voice settings
and the member list's loading and grouping labels.

The member grouping needed care: one function returns either a fixed label or
a role's own name, and role names are user data that must never be translated.
It now marks only the fixed cases and the component resolves them at render,
leaving role names untouched. The function is not a component, so it cannot
call the hook itself.
2026-09-01 17:03:48 -03:00
devsyncwrld c04b8b47eb i18n: translate the chat composer and messages
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Catching up on the fork's rule that every update also translates one existing
system — three updates went by without one.

The composer's placeholder takes the channel as a parameter rather than being
concatenated, since Portuguese puts the channel elsewhere in the sentence than
English does.

The translation hook is bound to `tr` in these two files, not `t`: both already
use `t` for transfer records in inner scopes, which shadowed the function and
made it uncallable exactly where it was needed.
2026-09-01 16:59:50 -03:00
devsyncwrld ce6bba0510 fix(ci): pulseaudio headers, and an ASCII release payload
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Linux got past pipewire and stopped on pulse/pulseaudio.h — the module
compiles both backends, so both sets of headers are needed.

Windows reached the upload and Gitea rejected the release creation with HTTP
422, 'invalid UTF-8 within /name': the JSON body carried accented text and the
Windows runner's shell mangles the encoding on the way out. The payload is now
ASCII, with a comment saying why, so it does not get 'improved' back into
Portuguese.
2026-09-01 16:04:01 -03:00
devsyncwrld 9ea399ded5 fix(ci): install pipewire headers, and make the Gitea upload diagnosable
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Two separate failures on the first run.

Linux could not compile the native module: it needs libpipewire-0.3-dev, which
its README states and the apt list omitted.

Windows built everything, native module verified, then died on the upload with
a bare JSONDecodeError. The cause was curl -sf, which discards the error body,
so a failed release creation surfaced as an empty pipe and no reason at all.
The step now captures status and body and prints them, treats a failed create
as possibly the other matrix job having just created it, and passes
target_commitish so the tag can be created.

Embedded python gave way to jq: a multi-line heredoc inside a YAML literal
block ends the block at the first unindented line, which is how the file became
invalid YAML in the first place.
2026-09-01 15:58:26 -03:00
devsyncwrld 9f7723d104 ci: build on GitHub, publish updates from Gitea
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
GitHub stays the build machine — it has the Windows runners the native audio
module needs — but the update feed moves to this fork's own Gitea.

The GitHub repository is private, and electron-updater against a private GitHub
repo needs a token inside the shipped app, which is a leaked token. Gitea
serves release assets to anyone, so the installer carries no credential. The
flow was verified end to end against the live instance before writing this:
create release, upload asset, download anonymously.

The release tag is fixed at 'latest' because electron-updater fetches
latest.yml before it knows which version exists, so the URL cannot carry a
version; CI replaces that release's assets each publish. electron-builder runs
with --publish never since it cannot upload to Gitea, but still emits the
latest.yml the updater reads.

Needs a GITEA_TOKEN secret on the GitHub repository.
2026-09-01 15:49:25 -03:00
devsyncwrld 5b84843217 fix(screenshare): make shared system audio audible
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Listeners had to run the participant at 200% with attenuation off. Two causes,
both on the publishing side.

WASAPI returns the process mix at a level well below what a microphone track
arrives at, so the track was quiet before it ever left the machine. A gain
stage now boosts it, followed by a limiter — a compressor with a high ratio and
fast attack — so the boost cannot clip loud passages.

And the track was published with LiveKit's defaults, which are tuned for
speech: mono, low bitrate, and DTX enabled, which stops transmitting during
silence. On game and music audio that reads as thin and clipped. It now
publishes with the music stereo preset, stereo forced, and DTX and RED off.

Lives in packages/web, so it reaches the desktop app through a normal deploy —
no new installer.
2026-09-01 15:36:55 -03:00
devsyncwrld bd8decb4b3 fix(ci): actually compile the native audio module, and prove it
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The previous run went green and produced an installer with no system-audio
capture in it — the exact silent failure this module exists to prevent.

Two causes. electron-rebuild takes -w as one comma-separated list, not a
repeated flag; passing it twice made argv.w an array and the CLI threw
'argv.w.split is not a function', which also broke uiohook-napi's rebuild that
had been working. And pnpm 10 refuses to run a dependency's build script unless
it is listed in onlyBuiltDependencies, so node-gyp never ran for it at all —
the log said 'Ignored build scripts' and nothing else complained.

Neither surfaced because desktop's postinstall ends in , which
exists so contributors without build tools can install. That is reasonable
locally and dangerous in CI, so the workflow now asserts a compiled .node
exists and fails loudly when it does not, instead of trusting an exit code that
was designed to lie.
2026-09-01 15:13:26 -03:00
devsyncwrld 52f43d4d61 build: record electron-native-screenshare in the lockfile
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The dependency was added to packages/desktop/package.json without regenerating
the lockfile, so CI's --frozen-lockfile refused the install — correctly: a
build that resolves fresh versions is a build that differs from what was
tested.
2026-09-01 15:06:59 -03:00
devsyncwrld 7eaba5e3f3 ci: build only the architectures this group runs
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Windows arm64 doubled the slowest job in the matrix — two Electron
distributions downloaded, native modules compiled twice — and nobody here runs
Windows on ARM. Linux arm64 went for the same reason.

Removing arm64 also retires the fpm step: electron-builder's bundled fpm is
x86_64-only and failed on the arm64 runner, which is the only reason a native
fpm was installed (ruby, ruby-dev, build-essential and a gem with native
extensions, on every run). On an x86_64 runner the bundled one works.

Matrix is now Windows x64 and Linux x64.
2026-09-01 14:02:54 -03:00
devsyncwrld 124db82a0c ci: drop the macOS build from the release matrix
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Nobody in this group runs macOS, and on a private repository the macOS runner
bills at 10x — it was the most expensive platform in the matrix, compiled on
every tag for zero users.

This edits a file inherited from upstream, so it will conflict on merges. That
is the accepted cost: the alternative is paying for a macOS build indefinitely.
2026-09-01 14:00:12 -03:00
devsyncwrld b4d001eb26 ci: add a fast Windows-only desktop build
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
release.yml builds four platforms and publishes a release, which is right for a
release and slow for 'give me an exe to test'. Wall time is set by the Windows
job, which compiles native modules and downloads Electron twice because it
targets x64 and arm64 together — and Windows-on-ARM is not something this group
runs.

This path builds one architecture, caches the Electron and electron-builder
downloads (~100MB re-fetched every run otherwise), and uploads the installer as
an artifact, so it needs neither a tag nor a version bump.

Kept as a separate file: release.yml comes from upstream and takes merges, so
editing it would conflict on every update.

Cost matters too on a private repository: macOS runners bill at 10x and Windows
at 2x, so the full matrix was paying for macOS builds on every tag.
2026-09-01 13:29:18 -03:00
devsyncwrld 60c51a1635 chore: version 1.1.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
First release built from the fork. electron-builder names artifacts and the
GitHub release from package.json rather than the git tag, so the two have to
agree or electron-updater compares mismatched versions and never offers the
update. v1.0.0 is already taken by upstream's tag in this history.
2026-09-01 13:15:13 -03:00
devsyncwrld d208b0c277 build(desktop): publish releases to syncwrld/resenhacord
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-09-01 13:14:43 -03:00
devsyncwrld 2f836cd366 build(desktop): correct the fork's GitHub owner to syncwrld
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-09-01 13:11:49 -03:00
devsyncwrld f7f2cf75ab build(desktop): point releases at the fork and rebuild the new native module
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
release.yml already builds and publishes desktop installers for every platform
on a v* tag, so no new workflow was needed — it needed adapting to the fork.

electron-builder published to upstream's repository, so releases (and with them
the electron-updater feed, which had no source at all) went nowhere useful.

More consequential: postinstall ran electron-rebuild against uiohook-napi only.
electron-native-screenshare would have been compiled for Node's ABI rather than
Electron's and failed to load at runtime — and since the loader degrades
quietly by design, the symptom would have been screen sharing with no sound and
no error, which is the exact bug this module exists to fix.

Documented in docs/build-desktop.md, including why the build has to run on
GitHub (Gitea Actions provides no hosted runners, and the module needs MSVC)
and why the Windows runner stays pinned to windows-2022.
2026-09-01 13:10:10 -03:00
devsyncwrld 3deed92dd9 feat(sounds): replace call and stream effects
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The originals were all 1.14s — long enough to be intrusive for events that
fire whenever anyone joins, leaves, or watches a stream.

Synthesised rather than sourced: no third-party file, so no licensing question
in a public repository. The parameters are measured, not guessed — envelope,
spectral peaks and decay taken from two reference sounds the instance owner
supplied. The timbre is fundamental plus octave at near-equal strength
(1.00 / 0.85 / 0.10 / 0.02) decaying to 1/e in 0.19s, with no reverb.

Call join rises C4→G4, leave falls D4→G3, matching the references' intervals
and their 100ms spacing. The stream pair reuses those resolution notes as
single tones at ~55% the volume: they fire far more often during a broadcast,
so they have to sit under the call sounds rather than beside them.

Stream start is the one event that happens once per broadcast, so it can be a
chord — with a low thump and a short air layer that both die inside 150ms,
adding weight without length. Stream end mirrors it descending, quieter, and
without the air, since brightness reads as arrival.

Generators and the measured parameters are kept in tools/sfx so these can be
retuned without redoing the analysis. Total size drops from 150KB to 41KB.
2026-09-01 13:01:06 -03:00
devsyncwrld d525bbb8c5 feat(desktop): capture system audio with process isolation
Electron's audio: 'loopback' captures the whole output mix, this app's own
playback included — so everyone else's voices went back out inside the share
and each listener heard themselves. Not acoustic echo but a digital copy of
the output, which is why headphones never helped, and why shareAudio already
defaulted to off in the desktop app.

Electron offers no way to exclude our own audio: the docs allow only
'loopback' or 'loopbackWithMute', and the handler discards the renderer's
constraints (restrictOwnAudio never arrives). electron-native-screenshare does
it at the OS level — WASAPI process loopback on Windows — capturing only the
shared window when its pid resolves, and otherwise everything except us.

The module hands raw PCM to the main process, so it crosses IPC and is
scheduled onto a running cursor in Web Audio to become a MediaStreamTrack,
published as ScreenShareAudio. Loading is optional and failure degrades to a
silent share rather than blocking the app or the screen share.

The browser path is untouched: Chrome honours restrictOwnAudio and has no echo.

Verified by typecheck (web and Electron main) and the web suite. The audio path
itself cannot be exercised here — no Windows, no Electron, no audio device.
2026-09-01 00:56:34 -03:00
devsyncwrld 310e9b86be docs: diagnose the screen-share audio echo
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 22:58:59 -03:00
devsyncwrld e291b5e411 docs: drop delivered rows from the pending sections
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 22:40:44 -03:00
devsyncwrld c899253e52 fix: soundboard upload in Electron, and Spotify sync/disappearing/progress
Soundboard: naming a clip used window.prompt, which Electron does not
implement — it returned nothing, the flow aborted in silence, and adding a
sound worked in the browser while doing nothing at all in the desktop app.
Replaced with a two-step field inside the popover, identical in both.

Spotify, three separate defects behind the two symptoms reported:

Out of sync — a 20s poll stacked on the activity store's 5s debounce left
everyone else on the previous track for up to 25s. The next poll is now
scheduled just past the current track's end instead of on a fixed interval,
and a track change bypasses the debounce (it happens once every few minutes;
the debounce exists for chatty producers).

Vanishing — a paused track, and the silent gap Spotify reports between two
songs, both cleared the activity outright. Pausing is now carried as state
rather than absence, and an empty answer is tolerated for 25s before the
block comes down.

Progress bar — timestamps are computed with the server's clock and were drawn
against the viewer's, so any drift displaced the bar; and it kept advancing
after a pause until the next poll. The ready payload now carries server time
so each client can correct its own offset, and the bar freezes when paused.

Tray, native notifications and system audio in screen share were all found
already implemented and wired end to end; recorded in the roadmap rather than
built again.
2026-08-31 22:39:28 -03:00
devsyncwrld ff55d9d486 docs: plan desktop features and diagnose the Spotify sync bugs
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 14:02:21 -03:00
devsyncwrld e58021408c docs: record the Electron window.prompt soundboard bug
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 14:00:41 -03:00
devsyncwrld 2afe3453f0 docs: record four approved features not yet started
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 13:57:01 -03:00
devsyncwrld e89966435a fix(soundboard): read MANAGE_SPACE from the space bitfield
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The add-sound control was gated on channelPermissions, which carries the
per-channel bitfield; MANAGE_SPACE lives in the space-level one. The check
silently evaluated false for everybody, including owners, so the button never
rendered and there was no way to add a sound at all.

Also raise the clip cap to 2 MB — a 30-second clip at a high bitrate cleared
1 MB — and stop reporting every upload failure as 'too large', which sent
people to shrink a file that was not the problem.
2026-08-31 13:54:00 -03:00
devsyncwrld f5451e1b14 feat: soundboard, account menu, and call timer
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Soundboard: the trigger travels over the WebSocket and every client in the
call plays the clip locally, instead of mixing it into the presser's
microphone or publishing a LiveKit track. No upstream bandwidth, no media
stack changes, and the clip is not degraded by voice processing.

Fan-out uses a new sendToRoomParticipants rather than sendToRoom: the latter
broadcasts a space room to the whole space, which is right for the presence
the sidebar shows and wrong for anything audible. The cooldown is enforced
server-side — a client-side one only slows down people not trying to abuse it,
and a soundboard is the easiest thing here to turn into a weapon. Playing is
open to anyone in the call; deciding what the buttons are needs MANAGE_SPACE.

Account menu: the name in the user bar had cursor-pointer and no handler, so
the interface was already promising a click that did nothing. Offers profile,
status and copy-id — not the Clips or account switching the reference design
shows, which would be dead UI here.

Call timer: startedAt comes from the server, so a late joiner sees the call's
age rather than their own arrival. Empty space rooms are destroyed already,
which is what makes the next call start from zero — no reset logic needed.
2026-08-31 13:45:18 -03:00
devsyncwrld ef5545465d docs: mark GIF favourites, audit log and statistics delivered
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 13:30:26 -03:00
devsyncwrld 1830051732 feat(stats): voice-time and message leaderboards per space
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Voice stays get their own table rather than joining the audit log: that table
records points in time, a call is an interval, and pairing join/leave point
events would leave every query guessing at joins whose leave never arrived.

Sessions are opened and closed inside joinRoom/leaveCurrentRoom rather than at
the seven call sites that reach them, so no path can be missed, and
destroyRoom closes them too — it bypasses leaveCurrentRoom and would otherwise
leak open rows.

A restart leaves sessions open with an unknowable end time. They are closed at
startedAt, discarding that time rather than inventing it: crediting the gap
would hand someone hours they never spent, and the numbers are the point.
Mirrors the existing users.status sweep on boot.

Only closed sessions count, so a figure does not move on every refresh. Bars
scale to the leader, not the total — with five people every share of a total
looks identical. Statistics are readable by any member, since they are the
group's own numbers; the audit log, which names who did what, stays on
MANAGE_SPACE.
2026-08-31 13:29:59 -03:00
devsyncwrld bbb190cbda feat(audit): append-only audit log for spaces
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Records who changed what, and is the mechanism statistics will read — one
event table rather than two logs that drift apart.

The table is deliberately generic (action + target + JSON metadata) so a new
action needs no migration. Writes never throw: a kick must not fail because
its log entry could not be written, since the kick already happened.

Leaving is recorded as a different action from being removed. The same route
serves both, and a log that conflates them misleads exactly when it matters.

Actor is nullable with ON DELETE SET NULL: the event outlives the account, and
a log that vanished with its actor would be worthless. Reads are gated on
MANAGE_SPACE rather than a new permission bit, which would default to nobody
until every role was re-edited. Paging uses the snowflake id, stable even for
two events in the same millisecond, and an action this build does not know
still renders a row.
2026-08-31 13:22:52 -03:00
devsyncwrld fb662bfe12 feat(gif): favourites and category shortcuts
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Favourites are stored server-side per user, so one made on the phone is there
on the desktop — the point of favouriting. The whole result is stored rather
than an id: the provider offers no lookup by id, so an id-only favourite could
not be rendered without re-finding it through search.

Category chips translate their label but not their query, which goes to a
provider that indexes in English.

The star sits beside the tile button rather than inside it: a button within a
button is invalid and swallows the click. Toggling is optimistic and reverts
on failure, and favourites skip both the loading skeleton and the infinite
scroll, which belong to provider-backed browsing only.

Server caps favourites per user and rejects non-http(s) URLs, which become
<img src> in everyone's picker.
2026-08-31 13:12:50 -03:00
devsyncwrld 0fc6abeb6e docs: mark Spotify delivered, drop resolved items
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 13:04:35 -03:00
devsyncwrld 75316b0882 feat(spotify): show the current track as an activity
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
OAuth Authorization Code flow, with tokens kept server-side: refreshing needs
the client secret, so the browser never holds a Spotify token — it asks this
instance what is playing and this instance calls Spotify.

The callback arrives as a plain browser redirect with no Authorization header,
so the OAuth state carries the user id signed with the instance secret and is
compared in constant time; without that, anyone could bind their Spotify
account to another user.

Activities are now tracked per producer. pushActivities replaced the whole
list, so the desktop game detector and Spotify would erase each other — losing
exactly the case this is for, a game and Spotify at once.

Polling backs off when the tab is hidden and keeps the last known track on a
network error rather than reporting 'stopped listening'. A rejected refresh
token (access revoked on Spotify's side) drops the row so the UI stops
claiming a live connection.

Scope is read-only: user-read-currently-playing and user-read-playback-state.

Per the fork's language rule, the new UI ships in en and pt-BR, and this
round also translates the privacy panel.
2026-08-31 12:37:08 -03:00
devsyncwrld c022f2795f docs: record the i18n foundation and translation queue
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 12:25:03 -03:00
devsyncwrld 688a1335cb feat(i18n): language foundation with en and pt-BR
Nothing in the project was translatable — every string sat inline in English.

en.ts is the source dictionary and its type is derived from it, so a typo or a
missing key fails typecheck instead of rendering the raw key at runtime.
pt-BR.ts is deliberately Partial: translation proceeds one system per update
and anything absent falls back to English, so a half-migrated interface is
never broken, only partly English.

Locale is persisted, guessed from the browser on first run, and kept in sync
with <html lang> through a subscription — persisted state rehydrates after
first paint, so a one-off assignment would miss it.

Translates the voice input panel (including the mic test shipped earlier
today) and the profile card as this round's system. Language options are
labelled in the active language, so a wrong pick can always be undone.
2026-08-31 12:24:50 -03:00
devsyncwrld 89e13441c8 docs: record the pt-BR language rule and new system ideas
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 12:18:33 -03:00
devsyncwrld 63afd2fc89 fix(profile): hoist the activity hook above the early return
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The activity selector sat below `if (!isOpen || !user) return null`. `user`
arrives asynchronously, so the hook ran on some renders and not others; React
counts hooks per render and tore the tree down with error #310 as soon as a
profile finished loading.

Move it above the guard and let the selector tolerate a null user. Typecheck
and the suite both passed with the bug in place — TypeScript cannot see hook
order and nothing renders this modal across the null-to-loaded transition.
2026-08-31 12:12:23 -03:00
devsyncwrld d80de49768 chore(caddy): serve the Gitea site alongside the app
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The git server runs as its own stack in /opt/gitea and is reached by container
name over the app's internal network, publishing no host port of its own. Only
the reverse-proxy entry belongs here, where the Caddyfile lives.
2026-08-31 12:07:29 -03:00
devsyncwrld b92a0d837e docs: update roadmap after mic test and activity work
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 12:05:18 -03:00
devsyncwrld d7da0ff203 feat(activity): show the current activity on the profile card
The activity pipeline was already complete end to end — Activity type, store,
WS broadcast, server validation, presence relay, and an ActivityCard used by
four list surfaces — but the profile card rendered none of it, which is the
'Listening to Spotify' block the design calls for.

Add ProfileActivity: richer than ActivityCard because the card has room for
artwork, track and artist, so it reads details/state/assets. All optional, so
it degrades to the bare name that today's process-based detector supplies.

Also scheme-check activity image assets server-side. activity.url was already
restricted to http(s) but assets.largeImage/smallImage were only length-checked
— an asymmetry that was harmless while nothing rendered them, and is not once
they become <img src>: a client could point them at a host it controls and
harvest the IP of everyone opening that profile.
2026-08-31 12:05:01 -03:00
devsyncwrld bfe62d7078 feat(voice): mic test with loopback in voice settings
The level meter only measured a stream a call had already opened, so settings
offered no way to check a mic before joining — the panel said as much.

Add startMicTest/stopMicTest on AudioManager: the processed input bus is
routed to the master output through a dedicated gain node, so the loopback can
be disconnected precisely. Settings had deliberately never opened the mic
itself; a mic test cannot honour that, so the test hands the mic back when it
stops.

Releasing needs two independent guards, because the user may join a call
mid-test: AudioManager only stops the exact stream it opened (identity check,
not a flag), and the caller must consent — the UI reads the call state, which
AudioManager cannot, as it does not import stores. Unmounting mid-test tears
the loopback down too.
2026-08-31 11:59:51 -03:00
devsyncwrld 37407a5ecd feat(voice): open the profile card from voice participants
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The profile popout already existed and was reachable from eleven places —
messages, mentions, avatars, member list, DMs, activity panel — but no voice
surface opened it, so clicking someone during a call did nothing.

Wire it into the voice user rows (VoiceChannel's sidebar list) and the name
label on grid tiles, whose avatar was already a ProfileAvatar; the name beside
it not reacting read as the click failing.

Left mobile alone deliberately: MobileSpacesScreen already opens the profile
from its row wrapper, and MobileVoiceJoinSheet would layer a history-pushed
full-screen profile inside a bottom sheet, which cannot be verified here.
2026-08-31 11:49:57 -03:00
devsyncwrld cad3867027 docs: add fork roadmap
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 11:45:02 -03:00
devsyncwrld 20526e1bc8 feat(gif): outlined GIF glyph, and GIF picker for the profile banner
The composer's GIF button drew a filled rounded rect with the letters knocked
out, which reads as a solid square rather than a picker. Invert it: stroked
outline with filled letters, reusing the original glyph paths scaled to centre.

Banners already accept absolute URLs on both ends (server isValidAssetUrl
allows http(s); the profile render branches on banner.startsWith('http')), so
the picker stores the remote URL directly with no upload path. Previews can now
hold either a blob: or an https: URL, so revoking is guarded — calling
revokeObjectURL on a remote URL is a silent no-op that would hide a mistake.
2026-08-31 11:44:40 -03:00
devsyncwrld c70b0095a9 feat(voice): jump to the call from the voice panel
The channel name under 'Voice Connected' was a plain div. Making it navigate
needed more than an onClick: voiceStore never recorded which space the call
was in, and spaceStore.channels only holds the space currently being viewed —
so after navigating away the call's channel was unresolvable, which is also
why the label degraded to a generic 'Voice Channel'.

Capture space and channel name at join time (the only moment they are
reliable) and use them for both the label and the jump. Covers space calls
and DM calls.
2026-08-31 11:40:45 -03:00
devsyncwrld 08db5374cb build: compile better-sqlite3 from source (no Node 20 prebuilt)
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
better-sqlite3@12.11.1 ships prebuilt binaries for ABI 127/137/141/147 only;
Node 20 is ABI 115, so prebuild-install falls back to node-gyp, which fails on
node:20-slim for lack of python3/make/g++.

Add the toolchain to the builder stage, and in the runtime stage install, use
and purge it inside a single layer so the final image ships no compiler.
2026-08-31 11:09:14 -03:00
254 changed files with 51308 additions and 1370 deletions
+97
View File
@@ -0,0 +1,97 @@
# Caminho rápido: só Windows x64, disparado à mão.
#
# O `release.yml` continua sendo o release de verdade (todas as plataformas,
# publica release e alimenta o electron-updater). Este aqui existe para quando
# você só quer um .exe para testar, sem marcar versão.
#
# Arquivo separado de propósito: o release.yml veio do upstream e recebe merges;
# mexer nele criaria conflito a cada atualização.
name: Build Windows (rápido)
on:
workflow_dispatch:
inputs:
arch:
description: Arquitetura
required: false
default: x64
type: choice
options: [x64, arm64]
permissions:
contents: read
jobs:
build:
# windows-2022, não windows-latest: a imagem latest traz o Visual Studio 18,
# que o node-gyp embutido no electron-rebuild não detecta ao compilar os
# módulos nativos. Mesma razão documentada no release.yml.
runs-on: windows-2022
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.34.3
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 20
cache: pnpm
# ~100MB de binários do Electron e das ferramentas do NSIS, baixados a
# cada execução sem isto. É o maior ganho depois de cortar o arm64.
- name: Cache Electron binaries
uses: actions/cache@v4
with:
path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: electron-cache-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build shared package
run: pnpm --filter @backspace/shared build
# O postinstall termina em `|| console.warn` para que quem não tem
# ferramentas de build consiga instalar. No CI isso transforma falha em
# sucesso silencioso: o instalador sai sem o módulo nativo e o app
# compartilha tela sem som, sem erro nenhum. Então verifica-se o
# resultado em vez de confiar no código de saída.
- name: Verify native audio module compiled
shell: bash
run: |
found=$(find node_modules/.pnpm -path '*electron-native-screenshare*' -name '*.node' 2>/dev/null | head -5)
if [ -z "$found" ]; then
echo "::error::electron-native-screenshare has no compiled .node — the installer would ship without system-audio capture"
find node_modules/.pnpm -maxdepth 1 -name 'electron-native-screenshare*' -printf '%p\n' 2>/dev/null || true
exit 1
fi
echo "OK:"; echo "$found"
- name: Compile desktop TypeScript
working-directory: packages/desktop
run: pnpm exec tsc
# --publish never: sem release, sem precisar de tag nem bump de versão.
# O instalador sai como artefato desta execução.
- name: Build installer
working-directory: packages/desktop
run: pnpm exec electron-builder --win --${{ inputs.arch || 'x64' }} --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Upload installer
uses: actions/upload-artifact@v4
with:
name: backspace-windows-${{ inputs.arch || 'x64' }}
path: packages/desktop/dist-electron/*.exe
retention-days: 14
if-no-files-found: error
+221
View File
@@ -0,0 +1,221 @@
# Compila no GitHub, publica no Gitea.
#
# O GitHub entra só como máquina de build — é dele que vêm os runners Windows
# de que o módulo nativo de áudio precisa. A distribuição fica no Gitea, que
# serve os arquivos a qualquer um: assim o electron-updater não precisa de
# credencial embutida no app, o que aconteceria com um repositório privado no
# GitHub.
name: Publicar no Gitea
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: Tag a publicar (ex. v1.1.0)
required: true
type: string
permissions:
contents: read
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: windows-2022
args: --win --x64
- os: ubuntu-latest
args: --linux --x64
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Install Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libx11-dev libxtst-dev libxt-dev \
libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \
libxrandr-dev libxinerama-dev libx11-xcb-dev \
libpipewire-0.3-dev libpulse-dev
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.34.3
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 20
cache: pnpm
- name: Cache Electron binaries
uses: actions/cache@v4
with:
path: |
~/.cache/electron
~/.cache/electron-builder
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: electron-cache-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build shared package
run: pnpm --filter @backspace/shared build
# O postinstall termina em `|| console.warn` para quem não tem ferramentas
# de build. No CI isso esconde falha: o instalador sairia sem captura de
# áudio do sistema e ninguém saberia. Verifica-se o resultado.
- name: Verify native audio module compiled
if: runner.os == 'Windows'
shell: bash
run: |
found=$(find node_modules/.pnpm -path '*electron-native-screenshare*' -name '*.node' | head -5)
[ -n "$found" ] || { echo "::error::sem .node compilado — instalador sairia sem áudio do sistema"; exit 1; }
echo "$found"
- name: Compile desktop TypeScript
working-directory: packages/desktop
run: pnpm exec tsc
# --publish never: o electron-builder não sabe enviar para o Gitea. Ele
# gera os instaladores e o latest.yml (o índice que o app consulta), e o
# passo seguinte faz o upload.
- name: Build installers
working-directory: packages/desktop
run: pnpm exec electron-builder ${{ matrix.args }} --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Upload to Gitea release
shell: bash
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_API: https://git.resenha.website/api/v1/repos/devsyncwrld/backspace
TAG: latest
run: |
set -uo pipefail
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "::error::segredo GITEA_TOKEN não configurado"
exit 1
fi
# Sem -f e imprimindo o corpo: a versão anterior usava `curl -sf`, que
# engole a resposta de erro, então uma falha aqui só aparecia como um
# JSONDecodeError sem dizer o motivo.
api() {
local method=$1 path=$2; shift 2
curl -s -w '\n%{http_code}' -X "$method" \
-H "Authorization: token $GITEA_TOKEN" "$GITEA_API$path" "$@"
}
body() { sed '$d' <<<"$1"; }
code() { tail -n1 <<<"$1"; }
# jq em vez de python embutido: um heredoc multilinha dentro de um
# bloco literal de YAML encerra o bloco na primeira linha sem recuo.
json_id() { jq -r '.id // empty'; }
find_release() {
local r; r=$(api GET "/releases/tags/$TAG")
[ "$(code "$r")" = "200" ] && body "$r" | json_id || echo ""
}
# O corpo do POST fica em ASCII de proposito: o shell do runner
# Windows corrompe UTF-8 na requisicao e o Gitea recusa com
# "invalid UTF-8 within /name" (HTTP 422).
ID=$(find_release)
if [ -z "$ID" ]; then
R=$(api POST "/releases" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$TAG\",\"name\":\"Latest build\",\"target_commitish\":\"main\",\"body\":\"Installers published automatically by CI.\"}")
if [ "$(code "$R")" = "201" ]; then
ID=$(body "$R" | json_id)
echo "release criada: $ID"
else
echo "criação retornou HTTP $(code "$R"): $(body "$R")"
# O outro job da matriz pode tê-la criado no mesmo instante.
ID=$(find_release)
[ -n "$ID" ] || { echo "::error::não foi possível obter nem criar a release"; exit 1; }
echo "release encontrada após corrida: $ID"
fi
else
echo "release existente: $ID"
fi
# Remove só os anexos que esta plataforma vai repor, para os dois jobs
# não apagarem o trabalho um do outro.
#
# Padrão montado em bash puro: a versão anterior interpolava uma
# expressão do Actions dentro de um `case`, e quando ela não casou o
# laço passou em silêncio — a release ficou com dois latest.yml e o
# updater serviu o antigo, dizendo que a versão nova não existia.
R=$(api GET "/releases/$ID/assets")
if [ "$(code "$R")" != "200" ]; then
echo "::error::não foi possível listar os anexos — HTTP $(code "$R"): $(body "$R")"
exit 1
fi
# Filtro dentro do jq, comparando o nome direto. A versão anterior
# montava um regex e o casava contra "<id> <nome>", onde o `^` de
# `^latest\.yml$` ancorava no início da linha — depois do id — e nunca
# podia casar. O laço então só removia os .exe e deixava um latest.yml
# duplicado, que é o arquivo que decide se há atualização.
OLD=$(body "$R" | jq -r --arg os "${RUNNER_OS:-}" '
.[]
| select(
if $os == "Windows"
then (.name | endswith(".exe")) or .name == "latest.yml"
else (.name | endswith(".AppImage")) or (.name | endswith(".deb")) or .name == "latest-linux.yml"
end
)
| "\(.id) \(.name)"')
echo "anexos desta plataforma já na release: $(printf '%s' "$OLD" | grep -c . || true)"
if [ -n "$OLD" ]; then
printf '%s\n' "$OLD" | while read -r aid aname; do
[ -n "$aid" ] || continue
echo "removendo anexo antigo: $aname"
D=$(api DELETE "/releases/$ID/assets/$aid")
[ "$(code "$D")" = "204" ] || echo "::warning::falha ao remover $aname — HTTP $(code "$D")"
done
fi
shopt -s nullglob
sent=0
for f in packages/desktop/dist-electron/*.exe \
packages/desktop/dist-electron/*.AppImage \
packages/desktop/dist-electron/*.deb \
packages/desktop/dist-electron/latest*.yml; do
name=$(basename "$f")
R=$(api POST "/releases/$ID/assets?name=$name" -F "attachment=@$f")
if [ "$(code "$R")" != "201" ]; then
echo "::error::falha ao enviar $name — HTTP $(code "$R"): $(body "$R")"
exit 1
fi
echo "enviado: $name"
sent=$((sent+1))
done
[ "$sent" -gt 0 ] || { echo "::error::o build não produziu instaladores"; exit 1; }
echo "$sent arquivo(s) publicados"
# O updater busca latest.yml pelo nome. Duas cópias com o mesmo nome
# fazem o Gitea servir a mais antiga, e a atualização deixa de ser
# oferecida — sem erro em lugar nenhum. Falha aqui em vez de publicar
# uma release que parece boa e não atualiza.
if [ "${RUNNER_OS:-}" = "Windows" ]; then
R=$(api GET "/releases/$ID/assets")
DUP=$(body "$R" | jq -r '[.[] | select(.name == "latest.yml")] | length')
if [ "$DUP" != "1" ]; then
echo "::error::a release tem $DUP cópias de latest.yml — o updater serviria a errada"
exit 1
fi
echo "latest.yml: 1 cópia, como esperado"
fi
+11 -22
View File
@@ -14,22 +14,26 @@ jobs:
fail-fast: false
matrix:
include:
# macOS — universal build (arm64 + x64) on Apple Silicon runner
- os: macos-latest
args: --mac --arm64 --x64
# Fork: só as plataformas que o grupo usa. Windows arm64 saiu porque
# dobrava o job mais lento (duas distribuições do Electron, módulos
# nativos compilados duas vezes) e ninguém roda Windows em ARM.
# Linux arm64 saiu pelo mesmo motivo. Com o arm64 fora, o passo que
# instalava o fpm nativo perdeu a razão de existir: ele só era
# necessário porque o fpm embutido no electron-builder é x86_64 e
# falhava no runner arm64.
# macOS removido neste fork: ninguém do grupo usa, e o runner macOS
# é cobrado a 10x num repositório privado — era a plataforma mais cara
# da matriz, compilada em toda tag para zero usuários.
# Windows — x64 + arm64 on x64 runner.
# Pinned to windows-2022 (VS 2022 / v17): the windows-latest image
# ships VS "18", which the node-gyp bundled with electron-rebuild
# cannot detect ("unknown version undefined") when compiling
# uiohook-napi in postinstall.
- os: windows-2022
args: --win --x64 --arm64
args: --win --x64
# Linux — x64 on x64 runner
- os: ubuntu-latest
args: --linux --x64
# Linux — arm64 on arm64 runner
- os: ubuntu-24.04-arm
args: --linux --arm64
runs-on: ${{ matrix.os }}
@@ -59,17 +63,6 @@ jobs:
libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \
libxrandr-dev libxinerama-dev libx11-xcb-dev
- name: Install fpm for .deb packaging
if: runner.os == 'Linux'
# electron-builder bundles an x86_64-only fpm; on the arm64 runner it
# aborts the .deb target with "cannot execute binary file: Exec format
# error". Install fpm natively and set USE_SYSTEM_FPM (below) so both
# arches package their .deb with a host-native fpm. ruby-dev + make are
# needed for fpm's native gem dependencies.
run: |
sudo apt-get install -y ruby ruby-dev build-essential
sudo gem install --no-document fpm
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
@@ -97,7 +90,3 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: "false"
# Use the host-native fpm installed above instead of electron-builder's
# bundled x86_64 fpm (which can't run on the arm64 runner). No-op on
# macOS/Windows, which don't build .deb.
USE_SYSTEM_FPM: "true"
+6
View File
@@ -10,3 +10,9 @@
# Backspace API, WebSocket, and frontend — Docker DNS resolves "backspace"
reverse_proxy backspace:3000
}
# Gitea — servidor git próprio (stack em /opt/gitea). Alcançado pelo nome do
# container na rede interna; o Gitea não publica porta nenhuma no host.
{$GIT_DOMAIN:git.resenha.website} {
reverse_proxy gitea:3000
}
+22 -3
View File
@@ -21,6 +21,15 @@ COPY packages/web/package.json packages/web/
# Copy patches (referenced by pnpm-lock.yaml)
COPY patches/ patches/
# better-sqlite3 publishes no prebuilt binary for Node 20 (ABI 115) — its
# releases cover ABI 127/137/141/147 only — so prebuild-install falls back to
# compiling with node-gyp, which needs python3/make/g++. node:20-slim ships
# none of them. Builder stage only: the runtime stage copies the compiled
# .node and stays slim.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
# Install dependencies
RUN pnpm install --frozen-lockfile
@@ -39,7 +48,9 @@ FROM node:20-slim AS runtime
RUN corepack enable && corepack prepare pnpm@10.34.3 --activate
# Runtime deps only: ffmpeg (media processing) + gosu (drop to non-root in the
# entrypoint). No C toolchain — better-sqlite3 and sharp load prebuilt binaries.
# entrypoint). sharp is N-API (ABI-independent) and loads a prebuilt binary;
# better-sqlite3 no longer ships one for Node 20, so it is compiled below with
# a toolchain that is purged in the same layer.
RUN apt-get update && \
apt-get install -y --no-install-recommends ffmpeg gosu && \
rm -rf /var/lib/apt/lists/*
@@ -58,8 +69,16 @@ COPY packages/web/package.json packages/web/
# Copy patches (referenced by pnpm-lock.yaml)
COPY patches/ patches/
# Install production dependencies only (tsx is in server dependencies)
RUN pnpm install --prod --frozen-lockfile
# Install production dependencies only (tsx is in server dependencies).
# better-sqlite3 compiles from source here (no Node 20 prebuilt), so the C
# toolchain is installed, used and purged inside this single layer — the final
# image ships no compiler.
RUN apt-get update && \
apt-get install -y --no-install-recommends python3 make g++ && \
pnpm install --prod --frozen-lockfile && \
apt-get purge -y python3 make g++ && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
# Copy shared source (needed at runtime since server imports types directly)
COPY packages/shared/ packages/shared/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+32 -25
View File
@@ -1,33 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 147.46 146.59">
<!--
Resenhacord app icon: the mark on the app's own night-blue ground (#0b0b10,
the theme colour), inside the 22 %-radius rounded square the icon pipeline
masks to. A soft violet glow behind the mark keeps the badge from reading as
a flat black square at large sizes.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<style>
.cls-1 {
fill: #1d1d1b;
}
.cls-2 {
fill: url(#Unbenannter_Verlauf_515);
}
.cls-3 {
fill: url(#Unbenannter_Verlauf_547);
}
</style>
<linearGradient id="Unbenannter_Verlauf_515" data-name="Unbenannter Verlauf 515" x1="62.13" y1="77.74" x2="118.16" y2="77.74" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff"/>
<stop offset=".95" stop-color="#9a86b8"/>
<radialGradient id="glow" cx="512" cy="480" r="420" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#7c6cf6" stop-opacity="0.28"/>
<stop offset="1" stop-color="#7c6cf6" stop-opacity="0"/>
</radialGradient>
<linearGradient id="back" x1="10" y1="12" x2="74" y2="74" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#c4b5fd"/>
<stop offset="1" stop-color="#7c6cf6"/>
</linearGradient>
<linearGradient id="Unbenannter_Verlauf_547" data-name="Unbenannter Verlauf 547" x1="9.21" y1="71.45" x2="135.41" y2="71.45" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff"/>
<stop offset=".95" stop-color="#9a86b8"/>
<linearGradient id="front" x1="42" y1="42" x2="94" y2="94" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fda4af"/>
<stop offset="1" stop-color="#f472b6"/>
</linearGradient>
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
<mask id="gap" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
<rect width="100" height="100" fill="#fff"/>
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
</mask>
</defs>
<g id="Ebene_1-2" data-name="Ebene 1">
<rect class="cls-1" x="0" y="0" width="147.46" height="146.59" rx="32.42" ry="32.42"/>
<g>
<path class="cls-2" d="M100.85,75.32c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
<path class="cls-3" d="M102.43,119.43c-.64.25-1.29.48-1.93.7l-1.67.39c-.57.14-1.04.17-1.57.26-.51.09-1.02.16-1.51.21-.98.11-1.95.2-2.9.25-.96.07-1.88.12-2.82.16-3.72.14-7.36.14-10.98.07-1.81-.03-3.61-.1-5.41-.16l-2.67-.11c-.83-.03-1.76-.08-2.69-.13l-5.55-.32-.63-.04-.81-.09c-.54-.08-1.08-.16-1.62-.27-1.07-.22-2.13-.5-3.17-.88-2.07-.74-4.05-1.78-5.83-3.11-.9-.66-1.74-1.39-2.53-2.18l-1.91-1.98-7.37-7.7-7.37-7.7c-2.49-2.54-4.81-5.23-7.56-7.52-2.75-2.29-5.15-4.91-7.6-7.49-1.23-1.29-2.4-2.62-3.61-3.93l-.45-.49-.22-.25-.34-.38c-.59-.67-1.07-1.43-1.47-2.23-.77-1.61-1.13-3.41-.99-5.18.11-1.76.72-3.49,1.7-4.96.26-.35.51-.72.81-1.04l.45-.48.25-.25.96-.93,3.83-3.71,7.66-7.42,15.32-14.84,7.66-7.42.99-.95c.4-.38.8-.74,1.23-1.09.85-.69,1.73-1.33,2.67-1.87,1.87-1.11,3.91-1.92,6.02-2.42,1.06-.24,2.13-.4,3.2-.49.54-.04,1.08-.06,1.62-.06h.73s.67.02.67.02l5.33.11,10.66.21,21.32.42,10.72.23c4.1.16,8.16,1.58,11.38,4.07.83.6,1.56,1.31,2.29,2.01.67.76,1.36,1.51,1.93,2.35,1.16,1.65,2.12,3.46,2.7,5.4.59,1.92.97,3.93.94,5.93,0,1.87-.06,3.58-.09,5.38l-.85,42.64-.22,10.68c-.12,3.6-.47,7.31-1.88,10.77-.69,1.72-1.64,3.4-2.98,4.74-1.32,1.36-2.95,2.38-4.68,3.05-1.75.64-3.58.92-5.39,1.06-.9.02-1.82.04-2.71.02-.44-.01-.89,0-1.33-.06-.22-.02-.44-.06-.67-.1l-.33-.07c-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26l.33-.07c.22-.04.44-.08.67-.1.44-.05.89-.04,1.33-.06.89-.02,1.75-.07,2.62-.12,1.72-.2,3.42-.54,5-1.18,1.55-.67,2.95-1.64,4.06-2.88,1.13-1.22,1.92-2.74,2.48-4.33,1.16-3.19,1.37-6.71,1.34-10.18l-.2-10.64-.85-42.65c-.04-1.76-.05-3.61-.12-5.28-.04-1.56-.4-3.08-.9-4.52-.5-1.46-1.29-2.79-2.21-4.01-.45-.62-1-1.16-1.52-1.71-.58-.5-1.14-1.02-1.78-1.43-2.48-1.76-5.49-2.67-8.48-2.67l-10.6.19-21.32.42-10.66.21-5.33.11h-.67c-.23.02-.45.02-.6.03-.35,0-.7.04-1.04.08-.69.09-1.38.22-2.05.4-1.34.38-2.61.95-3.77,1.7-.58.37-1.12.8-1.64,1.25-.26.23-.5.47-.74.71l-.9.94-7.36,7.72-14.72,15.43-7.36,7.72-3.68,3.86-.92.96-.22.23s0,.01,0,.01c-.01,0,.01.02,0,.01.02.02.02.02.05.04,0,.01.05,0,.04,0,.02-.02,0-.01.02-.03l.81.83c1.24,1.28,2.5,2.53,3.71,3.82,2.43,2.59,4.91,5.14,7.04,8.02,2.13,2.88,4.69,5.35,7.08,7.98l7.27,7.8,7.27,7.8,1.76,1.89c.46.48.95.92,1.48,1.32,1.06.8,2.27,1.43,3.54,1.88.64.23,1.3.4,1.97.54.33.07.67.12,1.01.17l.51.06c.33.03.46.03.7.04l5.09.27c.84.05,1.69.1,2.63.12l2.65.1c1.76.06,3.51.12,5.25.15,3.48.06,6.96.07,10.34-.17,1.68-.1,3.36-.28,4.9-.57.4-.06.77-.14,1.12-.23.35-.09.76-.16,1.04-.27l.9-.31c.18-.11.36-.22.54-.3l.65-.29c2.43-1.07,5.26.03,6.33,2.46,1.07,2.43-.03,5.26-2.46,6.33-.05.02-.12.05-.17.07Z"/>
<rect width="1024" height="1024" rx="225" fill="#0b0b10"/>
<rect width="1024" height="1024" rx="225" fill="url(#glow)"/>
<g transform="translate(192 192) scale(6.4)">
<path mask="url(#gap)" fill="url(#back)"
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
<use href="#frontBubble" fill="url(#front)"/>
<g fill="#fff">
<circle cx="58" cy="62" r="3.6"/>
<circle cx="68" cy="62" r="3.6"/>
<circle cx="78" cy="62" r="3.6"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

+16 -10
View File
@@ -1,14 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 123.08 100.14">
<!-- Monochrome silhouette of mark.svg for macOS menu-bar templates (alpha only). -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<style>
.cls-1 {
fill: #1d1d1b;
}
</style>
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
<mask id="gap">
<rect width="100" height="100" fill="#fff"/>
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
</mask>
<mask id="dots">
<rect width="100" height="100" fill="#fff"/>
<circle cx="58" cy="62" r="3.6" fill="#000"/>
<circle cx="68" cy="62" r="3.6" fill="#000"/>
<circle cx="78" cy="62" r="3.6" fill="#000"/>
</mask>
</defs>
<g id="Ebene_1-2" data-name="Ebene 1">
<path class="cls-1" d="M91.64,53.93c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
<path class="cls-1" d="M93.21,98.04c-1.58.67-3.48,1.14-5.16,1.36-7.86,1.04-15.77.76-23.61.52-3.06-.04-7.8-.42-10.92-.56-2.12-.13-4.23-.56-6.23-1.28-4.01-1.34-7.48-4.13-10.26-7.27-3.66-3.85-11.08-11.56-14.75-15.4-2.49-2.54-4.81-5.23-7.56-7.52-4.08-3.48-7.59-7.49-11.21-11.42-3.78-3.58-4.71-9.08-1.77-13.49.6-.98,1.66-1.88,2.47-2.69,6.89-6.67,19.79-19.17,26.81-25.96,0,0,7.66-7.42,7.66-7.42,1.47-1.45,3.08-2.88,4.89-3.91C46.83,1.04,50.61,0,54.4,0c.89-.03,5.73.13,6.73.12,0,0,10.66.21,10.66.21,7.02.15,25.23.49,32.04.65,8.24.22,15.88,5.97,18.3,13.83,1.26,3.64.92,7.57.85,11.31,0,0-.85,42.64-.85,42.64-.31,13.92,1.41,31-17.86,30.33-.78,0-1.57-.04-2.33-.23-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26,3.35-.48,6.76-.1,9.95-1.53,6.81-2.78,8.04-10.78,7.88-17.39-.14-6.81-.88-45.63-1.05-53.3-.12-3.34.17-6.68-1.03-9.81-1.03-2.88-3-5.41-5.51-7.15-2.48-1.76-5.49-2.67-8.48-2.67,0,0-10.6.19-10.6.19-9.89.2-27.61.55-37.32.74-3.9-.11-7.81,1.33-10.51,4.18-.22.33-7.8,8.1-8.26,8.65-4.86,5.11-17.37,18.21-22.08,23.15-.73.81-4.11,4.27-4.82,5.05,0,0,0,.01,0,.01-.01,0,.01.02,0,.01.02.02.08.07.09.03.02-.02,0-.01.02-.03,0,0,.81.83.81.83,3.71,3.84,7.52,7.57,10.75,11.84,3.79,4.86,10.17,11.13,14.34,15.78,0,0,7.27,7.8,7.27,7.8,1.85,2.11,4.03,4.24,6.79,5.09,2.44.99,6.67.88,9.28,1.08,3.39.17,7.13.31,10.53.37,5.86.03,11.79.22,17.4-1.24,0,0,.9-.31.9-.31.35-.2.82-.44,1.2-.58,6.01-2.4,9.65,6.26,3.7,8.87h0Z"/>
</g>
<path mask="url(#gap)" fill="#1d1d1b"
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
<use href="#frontBubble" fill="#1d1d1b" mask="url(#dots)"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

+26 -21
View File
@@ -1,28 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 123.07 100.13">
<!--
Resenhacord mark: two speech bubbles in conversation, the front one talking.
Square viewBox so every raster target (tray, favicon, PWA) scales without
letterboxing. The gap between the bubbles is cut with a mask rather than
painted, so the mark stays legible on any background and the macOS template
(alpha-only) variant keeps the same silhouette.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<style>
.cls-1 {
fill: url(#Unbenannter_Verlauf_515);
}
.cls-2 {
fill: url(#Unbenannter_Verlauf_547);
}
</style>
<linearGradient id="Unbenannter_Verlauf_515" data-name="Unbenannter Verlauf 515" x1="52.91" y1="56.35" x2="108.94" y2="56.35" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff"/>
<stop offset=".95" stop-color="#9a86b8"/>
<linearGradient id="back" x1="10" y1="12" x2="74" y2="74" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#c4b5fd"/>
<stop offset="1" stop-color="#7c6cf6"/>
</linearGradient>
<linearGradient id="Unbenannter_Verlauf_547" data-name="Unbenannter Verlauf 547" x1="0" y1="50.06" x2="126.19" y2="50.06" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff"/>
<stop offset=".95" stop-color="#9a86b8"/>
<linearGradient id="front" x1="42" y1="42" x2="94" y2="94" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fda4af"/>
<stop offset="1" stop-color="#f472b6"/>
</linearGradient>
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
<mask id="gap">
<rect width="100" height="100" fill="#fff"/>
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
</mask>
</defs>
<g id="Ebene_1-2" data-name="Ebene 1">
<g>
<path class="cls-1" d="M91.64,53.93c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
<path class="cls-2" d="M93.21,98.04c-.64.25-1.29.48-1.93.7l-1.67.39c-.57.14-1.04.17-1.57.26-.51.09-1.02.16-1.51.21-.98.11-1.95.2-2.9.25-.96.07-1.88.12-2.82.16-3.72.14-7.36.14-10.98.07-1.81-.03-3.61-.1-5.41-.16l-2.67-.11c-.83-.03-1.76-.08-2.69-.13l-5.55-.32-.63-.04-.81-.09c-.54-.08-1.08-.16-1.62-.27-1.07-.22-2.13-.5-3.17-.88-2.07-.74-4.05-1.78-5.83-3.11-.9-.66-1.74-1.39-2.53-2.18l-1.91-1.98-7.37-7.7-7.37-7.7c-2.49-2.54-4.81-5.23-7.56-7.52-2.75-2.29-5.15-4.91-7.6-7.49-1.23-1.29-2.4-2.62-3.61-3.93l-.45-.49-.22-.25-.34-.38c-.59-.67-1.07-1.43-1.47-2.23-.77-1.61-1.13-3.41-.99-5.18.11-1.76.72-3.49,1.7-4.96.26-.35.51-.72.81-1.04l.45-.48.25-.25.96-.93,3.83-3.71,7.66-7.42,15.32-14.84,7.66-7.42.99-.95c.4-.38.8-.74,1.23-1.09.85-.69,1.73-1.33,2.67-1.87,1.87-1.11,3.91-1.92,6.02-2.42,1.06-.24,2.13-.4,3.2-.49.54-.04,1.08-.06,1.62-.06h.73s.67.02.67.02l5.33.11,10.66.21,21.32.42,10.72.23c4.1.16,8.16,1.58,11.38,4.07.83.6,1.56,1.31,2.29,2.01.67.76,1.36,1.51,1.93,2.35,1.16,1.65,2.12,3.46,2.7,5.4.59,1.92.97,3.93.94,5.93,0,1.87-.06,3.58-.09,5.38l-.85,42.64-.22,10.68c-.12,3.6-.47,7.31-1.88,10.77-.69,1.72-1.64,3.4-2.98,4.74-1.32,1.36-2.95,2.38-4.68,3.05-1.75.64-3.58.92-5.39,1.06-.9.02-1.82.04-2.71.02-.44-.01-.89,0-1.33-.06-.22-.02-.44-.06-.67-.1l-.33-.07c-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26l.33-.07c.22-.04.44-.08.67-.1.44-.05.89-.04,1.33-.06.89-.02,1.75-.07,2.62-.12,1.72-.2,3.42-.54,5-1.18,1.55-.67,2.95-1.64,4.06-2.88,1.13-1.22,1.92-2.74,2.48-4.33,1.16-3.19,1.37-6.71,1.34-10.18l-.2-10.64-.85-42.65c-.04-1.76-.05-3.61-.12-5.28-.04-1.56-.4-3.08-.9-4.52-.5-1.46-1.29-2.79-2.21-4.01-.45-.62-1-1.16-1.52-1.71-.58-.5-1.14-1.02-1.78-1.43-2.48-1.76-5.49-2.67-8.48-2.67l-10.6.19-21.32.42-10.66.21-5.33.11h-.67c-.23.02-.45.02-.6.03-.35,0-.7.04-1.04.08-.69.09-1.38.22-2.05.4-1.34.38-2.61.95-3.77,1.7-.58.37-1.12.8-1.64,1.25-.26.23-.5.47-.74.71l-.9.94-7.36,7.72-14.72,15.43-7.36,7.72-3.68,3.86-.92.96-.22.23s0,.01,0,.01c-.01,0,.01.02,0,.01.02.02.02.02.05.04,0,.01.05,0,.04,0,.02-.02,0-.01.02-.03l.81.83c1.24,1.28,2.5,2.53,3.71,3.82,2.43,2.59,4.91,5.14,7.04,8.02,2.13,2.88,4.69,5.35,7.08,7.98l7.27,7.8,7.27,7.8,1.76,1.89c.46.48.95.92,1.48,1.32,1.06.8,2.27,1.43,3.54,1.88.64.23,1.3.4,1.97.54.33.07.67.12,1.01.17l.51.06c.33.03.46.03.7.04l5.09.27c.84.05,1.69.1,2.63.12l2.65.1c1.76.06,3.51.12,5.25.15,3.48.06,6.96.07,10.34-.17,1.68-.1,3.36-.28,4.9-.57.4-.06.77-.14,1.12-.23.35-.09.76-.16,1.04-.27l.9-.31c.18-.11.36-.22.54-.3l.65-.29c2.43-1.07,5.26.03,6.33,2.46,1.07,2.43-.03,5.26-2.46,6.33-.05.02-.12.05-.17.07Z"/>
</g>
<path mask="url(#gap)" fill="url(#back)"
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
<use href="#frontBubble" fill="url(#front)"/>
<g fill="#fff">
<circle cx="58" cy="62" r="3.6"/>
<circle cx="68" cy="62" r="3.6"/>
<circle cx="78" cy="62" r="3.6"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

+56
View File
@@ -0,0 +1,56 @@
# Gerar o instalador do app desktop
O workflow **`.github/workflows/release.yml`** (herdado do upstream) já compila
para macOS, Windows e Linux e publica os instaladores como release. Ele dispara
ao empurrar uma tag `v*`.
Não foi preciso escrever workflow novo — foi preciso **adaptá-lo ao fork**.
## Por que GitHub e não Gitea
O Gitea desta instância não tem Actions habilitado, e mesmo habilitado ele
**não oferece máquinas hospedadas**: seria preciso registrar um PC Windows
como runner e mantê-lo ligado. O GitHub fornece runners Windows prontos, que é
exatamente o que falta — o módulo nativo do compartilhamento de áudio usa
WASAPI e só compila no Windows, com o compilador da Microsoft.
O Gitea continua sendo o repositório principal. O GitHub entra apenas como
espelho para compilar.
## Passos (uma vez)
1. Criar um repositório no GitHub — o `electron-builder.yml` está apontado para
`syncwrld/resenhacord`. **Se o seu for outro nome, ajuste lá.**
2. Adicionar o espelho e empurrar:
```
cd /opt/backspace
git remote add github git@github.com:syncwrld/resenhacord.git
git push github main
```
3. Marcar uma versão e empurrar a tag — é ela que dispara a compilação:
```
git tag v1.0.1 && git push github v1.0.1
```
4. Os instaladores aparecem na aba Releases do GitHub em ~15 minutos.
## O que isso resolve de quebra
O `electron-updater` já estava instalado no projeto mas **sem feed** — o app
não se atualizava sozinho. Como o `publish` agora aponta para as releases do
fork, o app passa a encontrar versões novas por conta própria. Ninguém do grupo
precisa reinstalar na mão de novo.
## Adaptações feitas para o fork
| O quê | Por quê |
|---|---|
| `publish.owner` → `syncwrld` | Apontava para o repositório do upstream |
| `postinstall` reconstrói também `electron-native-screenshare` | Rodava `electron-rebuild` só no `uiohook-napi`. O módulo novo seria compilado para a ABI do Node em vez da do Electron e falharia ao carregar — e como o carregamento degrada em silêncio, o sintoma seria "compartilha sem som", sem erro visível |
| Nota sobre `asarUnpack` | O `uiohook-napi` tem `build/` excluído do asar porque distribui binários prontos. O módulo novo **não** distribui: `build/Release/*.node` é a única cópia e não pode ser excluída |
## Detalhe herdado que vale preservar
O runner do Windows está fixado em `windows-2022`, não `windows-latest`. O
comentário no workflow explica: a imagem `latest` traz o Visual Studio 18, que
o node-gyp embutido no `electron-rebuild` não detecta ("unknown version
undefined"). Não mude isso sem testar.
+209
View File
@@ -0,0 +1,209 @@
# Roadmap — fork Resenha
Plano de features próprias desta instância. Arquivo exclusivo do fork
(nome com sufixo para não colidir com arquivos do upstream em merges).
Escrito em português por ser documento de planejamento do dono do fork; o
código e os commits seguem em inglês, como o resto do repositório.
## Entregue
| Feature | Commit | Nota |
|---|---|---|
| Ir para a call clicando no nome do canal | `c70b0095` | Exigiu o `voiceStore` passar a guardar o espaço da call — antes ele não sabia onde a call estava assim que o usuário navegava para outro servidor |
| Botão de GIF redesenhado | `20526e1b` | Contorno vazado com letras cheias, no lugar do bloco sólido |
| Explorador de GIF no banner | `20526e1b` | Sem upload: banner já aceita URL absoluta no cliente e no servidor |
| Teste de microfone com retorno | `bfe62d70` | `AudioManager.startMicTest/stopMicTest`; devolve o microfone ao parar, com duas travas independentes |
| Bloco de atividade no perfil | `d7da0ff2` | `ProfileActivity`; inclui correção de validação de assets no servidor |
| Favoritar GIFs + categorias (#6) | `fb662bfe` | Favoritos no servidor, por conta; guarda o resultado inteiro porque o provedor não busca por id |
| Registro de auditoria (#9) | `bbb190cb` | Tabela append-only genérica; 8 ganchos; sair ≠ ser expulso |
| Estatísticas do grupo | `18300517` | Tabela própria de sessões de voz; ganchos no ponto único de entrada/saída |
| Spotify por OAuth (atividade com capa e progresso) | `75316b08` | Token só no servidor; `state` assinado; atividades por fonte para não brigar com o detector do Electron |
| Menu do próprio nome | `f5451e1b` | Perfil, status e copiar ID. Sem Clips/trocar conta — não existem aqui |
| Cronômetro da call | `f5451e1b` | `startedAt` do servidor; zera sozinho porque sala vazia é destruída |
| Correções do Spotify (sincronia, sumiço, barra) | `c899253e` | Pausa virou estado; horário do servidor no `ready` corrige relógio |
| Soundboard no app desktop | `c899253e` | `window.prompt` não existe no Electron; campo inline no lugar |
| Áudio do sistema sem eco | `d525bbb8` `5b848432` | Módulo nativo com isolamento por processo + ganho e preset de música |
| Instaladores publicados pelo Gitea | `ce6bba05` | GitHub compila, Gitea distribui; atualização automática ligada |
| Fixar mensagens no canal | `4ceb5cd6` | Estado na própria mensagem; painel no cabeçalho; limite de 50 |
| Busca: filtros embutidos e tradução | `50a8f12c` | Os filtros já existiam; faltava `de:fulano` e descoberta |
| Emojis e figurinhas do servidor | `7d003021` | `:nome:` no markdown sem plugin; figurinha como mensagem inteira |
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
| Rebrand: **Resenhacord** + logo nova | (ver git log) | `productName` mudou, `appId` **não** — assim o NSIS troca a instalação antiga no lugar. `userData` migra `Backspace` → `Resenhacord` no primeiro boot (senão todo mundo cairia da sessão). Logo: duas bolhas de conversa, roxo → rosa; fonte em `assets/brand/*.svg`, PNGs e ícones gerados por `pnpm gen-icons` |
| Aviso "X fixou uma mensagem" | (ver git log) | Tabela `messages` ganhou `type` (migração 0018); a rota de pin posta uma mensagem de sistema. Dois links: pular pra mensagem e abrir o painel de fixadas |
| Menção `@everyone` | (ver git log) | Reusa o token `<@id>`; permissão `MENTION_EVERYONE` nova, concedida ao `@everyone` por padrão e retroativa via backfill no boot. Servidor neutraliza o token de quem não tem a permissão, no envio **e** na edição |
| Renomear canal (nome e tópico) | (ver git log) | Rota `PATCH /api/channels/:id` já existia; faltava interface. Normalização em `shared/constants` para servidor e web usarem a mesma regra |
| Volume da tela em 50% por padrão | (ver git log) | `DEFAULT_STREAM_VOLUME`; o publicador reforça o áudio do sistema, então o padrão anterior chegava alto demais |
| Sons 50% mais baixos | (ver git log) | Só `SFX_BASE_VOLUME` (0.8 → 0.4); os `.ogg` não foram regerados |
| Atualização do desktop verificada com mais frequência | (ver git log) | Era só na abertura e de 4 em 4 horas; agora a cada 30 min e ao focar a janela, com trava de 5 min |
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
## Já existia no código (verificado, não construir de novo)
- **Busca com filtros** — servidor (`q`, `from`, `has`, `before`, `after`),
client de API e painel no `SearchPopover` já existiam completos. Só faltava
tradução, sintaxe embutida (`de:fulano`) e descoberta.
- **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce`
escalonados em 0/150/300ms.
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
stream started/ended, alguém entra/sai da tela, entra/sai da call, câmera,
mute, ringing. Os `.ogg` estão em `web/public/sounds/`.
- **Preview de perfil ancorado** — `uiStore.openUserProfile(user, anchor, placement)`
guarda `userProfilePopout`; popout posicionado no desktop, tela cheia no
mobile. Já era aberto por mensagens, menções, avatares, lista de membros,
DMs e painel de atividade. O que faltava era só a voz — agora ligado.
Continua faltando o bloco de atividade do print do Discord, que é a #8.
Se qualquer um dos dois não se manifestar em uso, o trabalho é **depuração**,
não implementação.
## Pendente — pedido original
Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
| # | Feature | Tamanho | Observação técnica |
|---|---|---|---|
## Identidade do perfil (entregue 2026-09-02, pedido: "completo e FODA")
Uma coluna JSON (`users.profile_style`, migração 0019) normalizada no servidor
por `shared/profile.ts`. Aba **Perfil** nas configurações com prévia ao vivo
(`ProfilePanel` renderiza o mesmo `ProfileCard` que o popout).
| Peça | Onde aparece | Detalhe |
|---|---|---|
| Estilo do nome — 10 fontes × 9 efeitos × 2 cores | popout, perfil completo | Fontes OFL em `public/fonts`; efeitos são CSS (`.name-fx-*`) |
| Decoração do avatar — 10 | popout, perfil, editor | SVG inline em `DecoratedAvatar`, sem imagens |
| Efeito do perfil — 9 | banner do popout e do perfil | Partículas CSS com posições semeadas (`ProfileEffectLayer`) |
| Nameplate — 10 + personalizado | lista de membros | `.nameplate-*` |
| Tema (2 cores) | fundo do cartão e banner sem imagem | |
| Pronomes, tag (emoji + 2–4 letras) | ao lado do nome; tag também na lista de membros | |
| Links (até 5) | popout e aba Sobre | Ícone detectado pelo host; só https |
| Quadro: jogo favorito, rotação (5), quero jogar (10), citação | aba Quadro do perfil; tira "Coleção" no popout | Busca na Steam via `/api/games/search` (proxy, cache 10 min); capas só de CDN da Steam |
| Emblemas derivados | popout e perfil | admin, dono, fundador (1º mês), veterano (1 ano), OG (6 meses), estiloso — nada é armazenado |
Fora do escopo desta entrega, candidatos a seguir: perfil por servidor
(apelido/avatar por servidor), emblemas concedidos pelo admin, molduras
animadas por upload, status com expiração.
## Pendente — ideias aprovadas
| Feature | Tamanho | Observação técnica |
|---|---|---|
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
## Regra de idioma (a partir de 2026-08-31)
Funcionalidade nova sai com interface em **pt-BR**, e a cada update um sistema
existente é traduzido. Os dois idiomas **coexistem**. Código, comentários e
commits seguem em inglês.
A fundação está pronta (`src/i18n/`). Para traduzir um sistema: adicione as
chaves em `locales/en.ts`, traduza em `locales/pt-BR.ts` e troque as strings
fixas por `t('chave')`. O que faltar cai para o inglês sozinho.
### Sistemas já traduzidos
- Configurações → navegação e aba Idioma
- Configurações → Voz (dispositivo de entrada, volume, teste de microfone)
- Cartão de perfil (sobre mim, membro desde, enviar mensagem, atividade)
- Configurações → Privacidade
- Configurações → Conexões (nasceu bilíngue)
- Seletor de GIF
- Chat: composer e mensagens (placeholder, anexos, responder, reações)
- Barra lateral: servidores, canais e menus de contexto
- Lista de membros (grupos por cargo, carregamento)
- Busca (rótulos, filtros e sintaxe embutida)
- Registro de auditoria e Estatísticas (nasceram bilíngues)
- Configurações do canal (visão geral, privacidade, exclusão e rótulos de permissão)
- Amigos: abas, listas, pedidos, aba de adicionar e cartões de descoberta
- Modal de convite (incluindo os motivos de falha por convite)
- Lista de membros da DM em grupo (remover e transferir propriedade)
- Configurações → Área de trabalho (início automático, atualizações, instância)
- Configurações → Voz: dispositivo de saída (completa a aba, junto com a entrada)
- Configurações → Atalhos (incluindo os nomes das ações e o aviso de conflito)
- Configurações → Conta (perfil, senha, conta desligada da origem, zona de perigo)
- Explorar servidores (cartões, entrar, pedir para entrar)
- Configurações → Voz: vídeo (prévia, permissões, escolha de câmera) — fecha a aba
- Criar canal, categoria e servidor
- DMs em grupo (nova DM, adicionar amigos, configurações do grupo, sair)
- Entrar num servidor (descoberta, código de convite, conectar e login federado)
- Excluir conta, transferir propriedade e a tela de erro geral
- Configurações do servidor → Visão geral (identidade, ícone, banner, zona de perigo)
- Configurações do servidor → Membros e banimentos
- Configurações do servidor → Cargos (incluindo a tabela de permissões)
- Configurações da categoria (renomear, privacidade, exceções, excluir)
- Configurações da instância → Geral (nome, descoberta, busca de GIFs)
- Mensagens de sistema do chat e a prévia delas na barra lateral
- Login, registro e a página de convite
- Voz: controles, informações de conexão, folha de entrada no celular, aba Voz
- Telas do celular (`Mobile*.tsx`)
- Navegação das configurações (servidor, usuário e instância) + Descoberta e
pedidos de entrada
Os rótulos de permissão viraram um conjunto único (`perm.*`), usado pelo painel
de cargos e pelos editores de exceção de canal e de categoria — antes havia
duas cópias da mesma lista.
### Fila sugerida de tradução
Configurações → Instância: registro, federação, transmissão, armazenamento e
usuários (os cinco painéis grandes, só o dono vê) · `ConnectedInstances` ·
`UserProfileModal` · `MainContent` e `ChannelSidebar` · mensagens de erro
vindas de stores e hooks (`instanceStore`, `useLiveKit`, `transferStore`)
## Correções pendentes
| Problema | Causa provável | Correção |
|---|---|---|
## App desktop — já existia (verificado 2026-08-31)
As três pedidas já estão implementadas e ligadas de ponta a ponta. **Não
construir de novo.**
- **Bandeja** — `createTray()` em `desktop/src/main.ts`, chamada na inicialização;
fechar a janela esconde em vez de sair (`mainWindow.on('close')`).
- **Notificações nativas** — `showNotification()` no processo principal, canal
IPC `show-notification`, e o web já chama por `platform/notifications.ts`.
- **Áudio do sistema no compartilhamento** — `setDisplayMediaRequestHandler`
devolve `audio: 'loopback'`; existe caixa de seleção no `ScreenSharePicker`
ligada a `screenShareConfig.shareAudio`, que atravessa o IPC.
Se algum não se manifestar em uso, o trabalho é **depuração**, não construção.
## Aprovadas, a fazer depois (2026-08-31)
Escolhidas pelo dono, inspiradas no Discord. **Nenhuma iniciada.**
| Feature | Tamanho | Observação técnica |
|---|---|---|
| **Resposta rápida de voz (soundboard por atalho)** | Pequena | O soundboard já está pronto; falta só marcar favoritos e ligar a atalhos de teclado, sem abrir painel. Cuidado: o limite de repetição do servidor continua valendo e é o que impede virar metralhadora |
| **Cargos com ícone e exibição separada** | Média | Cargos e cores já existem; falta ícone, o campo "exibir separadamente" e o agrupamento na lista de membros |
## Ideias novas — a avaliar
Ordenadas por relação valor/custo para um servidor de grupo fechado.
| Sistema | Tamanho | Por que faz sentido aqui |
|---|---|---|
| **Backup fora da VPS** | Pequena | Hoje app, banco, uploads, backups e as três cópias do repositório morrem no mesmo evento. Um envio periódico para fora resolve |
| **Aniversários e lembretes** | Pequena | Alto retorno afetivo, custo baixo: campo de data + verificação diária + mensagem no canal |
| **Perfis por servidor** | Média | Apelido e avatar diferentes por espaço, como no Discord. O modelo já tem membro por espaço |
| **Eventos agendados com presença** | Média | "Sexta 21h" com confirmação. Encaixa nas notificações e no PWA já instalado |
| **Notificações push de verdade** | Média | O `vite-plugin-pwa` e o service worker já estão lá; falta Web Push (VAPID) e o registro no servidor |
| **Níveis e conquistas** | Média | Gamificação por tempo em call e mensagens. **Mesmo registro de eventos da auditoria e das estatísticas** — três features, um mecanismo |
| **Clipes de call** | Grande | "Salvar os últimos 30 segundos" depois de alguém falar besteira. O LiveKit tem egress; exige buffer contínuo e armazenamento |
| **Fila de música compartilhada** | Grande | Um participante-robô publicando faixa de áudio na sala LiveKit. É o que mais muda o uso de um servidor de amigos, e o mais caro |
| **Autenticação em duas etapas** | Média | Só faz sentido depois de decidir o modelo de cadastro |
## Dependência que vale respeitar
**Auditoria (#9) e Estatísticas compartilham o mesmo mecanismo**: uma tabela de
eventos append-only no servidor. Construir a auditoria primeiro e as
estatísticas como leitura agregada dessa mesma tabela evita escrever dois
sistemas de registro paralelos que divergem com o tempo.
+4 -3
View File
@@ -1,8 +1,8 @@
{
"name": "backspace",
"version": "1.0.0",
"version": "2.1.0",
"private": true,
"description": "Open, self-hosted communication platform — text, voice, video, and federation",
"description": "Open, self-hosted communication platform \u2014 text, voice, video, and federation",
"license": "AGPL-3.0-only",
"author": "Jannis Braun",
"homepage": "https://github.com/TheZwiss/backspace",
@@ -32,8 +32,9 @@
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild",
"electron",
"electron-native-screenshare",
"esbuild",
"sharp"
],
"patchedDependencies": {
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 955 B

After

Width:  |  Height:  |  Size: 712 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

+18 -5
View File
@@ -1,5 +1,5 @@
appId: com.backspace.desktop
productName: Backspace
productName: Resenhacord
artifactName: "${productName}-${version}-${arch}.${ext}"
directories:
output: dist-electron
@@ -14,16 +14,29 @@ files:
- "!**/node_modules/uiohook-napi/build/**"
- "!**/node_modules/uiohook-napi/build.bak/**"
- "!**/node_modules/uiohook-napi/bin/**"
# electron-native-screenshare has no prebuilds — build/Release/*.node is the
# only copy, so it must NOT be excluded the way uiohook-napi's is. asarUnpack
# below takes it out of the archive so the loader can find it.
asarUnpack:
- "**/*.node"
npmRebuild: false
afterPack: ./scripts/afterPack.js
publish:
- provider: github
owner: TheZwiss
repo: backspace
# Updates are served from this fork's own Gitea, not from GitHub.
#
# GitHub is only the build machine — it has the Windows runners the native
# audio module needs. Its repository is private, and electron-updater against
# a private GitHub repo would need a token shipped inside the app, which is a
# leaked token. Gitea serves release assets to anyone, so no credential ends
# up in the installer.
#
# The tag is fixed at `latest` on purpose: electron-updater fetches
# latest.yml before it knows which version exists, so the URL cannot contain
# a version. CI replaces that release's assets on every publish.
- provider: generic
url: https://git.resenha.website/devsyncwrld/backspace/releases/download/latest/
protocols:
- name: Backspace
- name: Resenhacord
schemes:
- backspace
mac:
+4 -3
View File
@@ -1,9 +1,9 @@
{
"name": "@backspace/desktop",
"version": "1.0.0",
"version": "2.1.0",
"private": true,
"license": "AGPL-3.0-only",
"description": "Backspace",
"description": "Resenhacord",
"author": {
"name": "Jannis Braun",
"email": "backspace@backspace.chat"
@@ -18,9 +18,10 @@
"clean": "rm -rf dist dist-electron",
"test": "vitest run",
"test:watch": "vitest",
"postinstall": "electron-rebuild -f -w uiohook-napi || node -e \"console.warn('[desktop] uiohook-napi native rebuild skipped - needs build tools (make, g++, python3). Only required to RUN the desktop app; the server, web client, and Docker image are unaffected.')\""
"postinstall": "electron-rebuild -f -w uiohook-napi,electron-native-screenshare || node -e \"console.warn('[desktop] uiohook-napi native rebuild skipped - needs build tools (make, g++, python3). Only required to RUN the desktop app; the server, web client, and Docker image are unaffected.')\""
},
"dependencies": {
"electron-native-screenshare": "^1.2.0",
"electron-updater": "^6.3.0",
"uiohook-napi": "^1.5.5"
},
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backspace — Connect</title>
<title>Resenhacord — Conectar</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap');
@@ -270,7 +270,7 @@
<div class="container">
<div class="card">
<div class="header">
<h1>Welcome to Backspace</h1>
<h1>Bem-vindo ao Resenhacord</h1>
<p>Connect to your instance</p>
</div>
@@ -360,7 +360,7 @@
// Update header copy to reflect the "switch" intent rather than the
// first-run "welcome" framing.
document.querySelector('.header h1').textContent = 'Switch instance';
document.querySelector('.header p').textContent = 'Connect to a different Backspace instance, or cancel to stay.';
document.querySelector('.header p').textContent = 'Conecte-se a outra instância, ou cancele para ficar nesta.';
}
}
init();
+4 -4
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Backspace — Recovery</title>
<title>Resenhacord — Recuperação</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
* { box-sizing: border-box; }
@@ -128,7 +128,7 @@
<div class="content">
<div class="container">
<div class="card">
<h1>Backspace couldn't load.</h1>
<h1>O Resenhacord não conseguiu abrir.</h1>
<p class="hint" id="hint">Loading…</p>
<div class="actions">
@@ -147,7 +147,7 @@
<pre id="diagnostic">loading…</pre>
</details>
<button class="action subtle" id="btn-quit">Quit Backspace</button>
<button class="action subtle" id="btn-quit">Fechar o Resenhacord</button>
</div>
</div>
</div>
@@ -270,7 +270,7 @@
async function init() {
if (!window.backspace) {
$('hint').textContent = 'Recovery bridge unavailable. Please quit and reinstall Backspace.';
$('hint').textContent = 'A ponte de recuperação não respondeu. Feche e reinstale o Resenhacord.';
return;
}
try {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 983 B

After

Width:  |  Height:  |  Size: 746 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 703 B

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 836 B

+171 -31
View File
@@ -40,24 +40,31 @@ import {
import { migrateUserData } from './userDataMigration';
// Override Electron's package.json-derived app name so userData lives at
// "<appData>/Backspace" instead of leaking the monorepo's "@backspace/desktop"
// "<appData>/Resenhacord" instead of leaking the monorepo's "@backspace/desktop"
// package name. Must run before any app.getPath('userData') consumer.
app.setName('Backspace');
app.setName('Resenhacord');
// One-time migration from the historical scoped path. After the move the old
// folder is gone, so subsequent launches hit the old-missing no-op branch.
// One-time migrations, oldest first, so an install that skipped a release
// still walks the whole chain: "@backspace/desktop" → "Backspace" →
// "Resenhacord". Each step is a no-op once its source folder is gone, and a
// step whose target already has data leaves it alone (`new-populated`), so a
// user who already ran the renamed build is never overwritten by stale files.
// Without the second step the rename would have logged everyone out: the
// instance URL and session live under userData.
{
const appDataDir = app.getPath('appData');
const oldParent = path.join(appDataDir, '@backspace');
const result = migrateUserData({
oldDir: path.join(oldParent, 'desktop'),
newDir: path.join(appDataDir, 'Backspace'),
oldParent,
});
if (result.kind === 'migrated') {
console.log(`[userData] migrated ${result.from} → ${result.to}`);
} else if (result.kind === 'failed') {
console.error('[userData] migration failed:', result.error);
const legacyParent = path.join(appDataDir, '@backspace');
const steps: { oldDir: string; newDir: string; oldParent: string }[] = [
{ oldDir: path.join(legacyParent, 'desktop'), newDir: path.join(appDataDir, 'Backspace'), oldParent: legacyParent },
{ oldDir: path.join(appDataDir, 'Backspace'), newDir: path.join(appDataDir, 'Resenhacord'), oldParent: appDataDir },
];
for (const step of steps) {
const result = migrateUserData(step);
if (result.kind === 'migrated') {
console.log(`[userData] migrated ${result.from} → ${result.to}`);
} else if (result.kind === 'failed') {
console.error('[userData] migration failed:', result.error);
}
}
}
@@ -241,7 +248,7 @@ function applyLoginItemSettings(openAtLogin: boolean, startMinimized: boolean):
enabled: openAtLogin,
path: process.execPath,
args: startMinimized ? ['--hidden'] : [],
name: 'Backspace',
name: 'Resenhacord',
});
} else {
// Linux: setLoginItemSettings creates ~/.config/autostart/<name>.desktop.
@@ -265,6 +272,102 @@ function applyLoginItemSettings(openAtLogin: boolean, startMinimized: boolean):
}
}
// ─── Native system-audio capture ────────────────────────────────────────────
//
// Electron's own `audio: 'loopback'` captures the whole output mix, which
// includes this app playing everyone else's voices — so those voices went back
// out inside the screen share and every listener heard themselves. Not acoustic
// echo: it is a digital copy of the output, so headphones never helped.
//
// This module captures with process-level isolation instead: only the shared
// window (include mode), or everything except this app (exclude mode).
interface NativeAudioMeta {
sampleRate: number;
channels: number;
bitsPerSample: number;
isFloat: boolean;
}
interface NativeScreenShareAudio {
startCapture(processId?: number, isIncludeMode?: boolean, onData?: (data: Buffer, meta: NativeAudioMeta) => void): boolean;
stopCapture(): boolean;
getPidFromWindowHandle(windowHandle: number): number;
isAvailable(): boolean;
getLoadError(): string | null;
}
let nativeAudio: NativeScreenShareAudio | null = null;
try {
// Required lazily and defensively: a native module that fails to load must
// degrade to sharing without audio, never stop the app from starting.
nativeAudio = require('electron-native-screenshare') as NativeScreenShareAudio;
if (!nativeAudio.isAvailable()) {
console.warn('[Main:ScreenShare] Native audio unavailable:', nativeAudio.getLoadError());
nativeAudio = null;
}
} catch (err) {
console.warn('[Main:ScreenShare] Native audio module missing:', err);
nativeAudio = null;
}
let nativeAudioActive = false;
/**
* Windows and Linux hand desktopCapturer ids of the form `window:<handle>:<n>`.
* Recovering the handle lets us capture only that window's audio, which is
* better than excluding ourselves: a game's sound goes out, the rest of the
* desktop does not.
*/
function windowHandleFromSourceId(sourceId: string): number | null {
const match = /^window:(\d+)/.exec(sourceId);
if (!match) return null;
const handle = Number(match[1]);
return Number.isFinite(handle) && handle > 0 ? handle : null;
}
function startNativeAudioCapture(sourceId: string): boolean {
if (!nativeAudio || nativeAudioActive) return false;
let targetPid = process.pid;
let includeMode = false;
const handle = windowHandleFromSourceId(sourceId);
if (handle !== null) {
const pid = nativeAudio.getPidFromWindowHandle(handle);
// pid 0 means the handle did not resolve; fall back to excluding ourselves
// rather than capturing nothing.
if (pid > 0) {
targetPid = pid;
includeMode = true;
}
}
try {
const started = nativeAudio.startCapture(targetPid, includeMode, (data, meta) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('native-audio-data', data, meta);
});
nativeAudioActive = started;
console.log('[Main:ScreenShare] Native audio', started ? 'started' : 'failed',
includeMode ? `(only pid ${targetPid})` : '(excluding self)');
return started;
} catch (err) {
console.error('[Main:ScreenShare] Native audio start failed:', err);
nativeAudioActive = false;
return false;
}
}
function stopNativeAudioCapture(): void {
if (!nativeAudio || !nativeAudioActive) return;
try {
nativeAudio.stopCapture();
} catch (err) {
console.warn('[Main:ScreenShare] Native audio stop failed:', err);
}
nativeAudioActive = false;
}
// ─── Tray Icon ──────────────────────────────────────────────────────────────
function generateFallbackTrayIcon(): Electron.NativeImage {
@@ -341,7 +444,7 @@ function createWindow(): void {
: {}),
minWidth: 940,
minHeight: 500,
title: 'Backspace',
title: 'Resenhacord',
icon: path.join(__dirname, '..', 'build', 'icon.png'),
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
...(process.platform !== 'darwin' ? {
@@ -474,7 +577,7 @@ function createTray(): void {
const icon = loadTrayIcon();
tray = new Tray(icon);
tray.setToolTip('Backspace');
tray.setToolTip('Resenhacord');
// Context menu is set by the recoveryStore subscriber in app.whenReady,
// which keeps the menu in sync with update/recovery state.
@@ -492,7 +595,9 @@ function createTray(): void {
function showNotification(title: string, body: string, onClick?: () => void): void {
if (!Notification.isSupported()) return;
const notification = new Notification({ title, body, silent: false });
// silent: o som do sistema é o que mais incomoda numa notificação de chat, e
// o app toca o seu próprio efeito — que combina com os demais sons dele.
const notification = new Notification({ title, body, silent: true });
notification.on('click', onClick ?? (() => {
mainWindow?.show();
mainWindow?.focus();
@@ -594,6 +699,10 @@ function registerIpcHandlers(): void {
ipcMain.handle('get-app-version', () => app.getVersion());
// Screen share picker coordination (used by setDisplayMediaRequestHandler)
ipcMain.on('native-audio-stop', () => {
stopNativeAudioCapture();
});
ipcMain.on('screen-share-selected', (_event, _sourceId: string | null, _shareAudio?: boolean) => {
// Handled via ipcMain.once in the display media handler — this is just
// a safety net to prevent unhandled-message warnings
@@ -608,7 +717,7 @@ function registerIpcHandlers(): void {
// executableWillLaunchAtLogin to honour Task Manager's StartupApproved state.
const osState = app.getLoginItemSettings({ path: process.execPath });
const ownEntry = osState.launchItems?.find(
(item) => item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
(item) => item.name === 'Resenhacord' || item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
);
// When the Run entry exists, its args are the source of truth for startMinimized.
// When the entry is absent (autostart is off), there is no OS state to read, so we
@@ -643,7 +752,7 @@ function registerIpcHandlers(): void {
if (process.platform === 'win32') {
const osState = app.getLoginItemSettings({ path: process.execPath });
const ownEntry = osState.launchItems?.find(
(item) => item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
(item) => item.name === 'Resenhacord' || item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
);
currentOpenAtLogin = osState.executableWillLaunchAtLogin ?? false;
// See `get-auto-launch-settings`: when the entry is absent, fall back to disk
@@ -712,7 +821,14 @@ function initAutoUpdater(): void {
let updateConfirmed = false;
// Cadence state. `lastCheckAt` is stamped from the event rather than from
// the callers, so a manual "check for updates" from the settings panel
// counts too and a focus arriving right after it doesn't check twice.
let lastCheckAt = 0;
let downloaded = false;
autoUpdater.on('checking-for-update', () => {
lastCheckAt = Date.now();
recoveryStore.update({ updateState: 'checking', lastCheckResult: null });
updateConfirmed = false;
});
@@ -734,6 +850,7 @@ function initAutoUpdater(): void {
});
autoUpdater.on('update-downloaded', (info: { version: string }) => {
downloaded = true;
const version = info.version.slice(0, 32);
recoveryStore.update({ updateState: 'downloaded', updateVersion: version });
mainWindow?.webContents.send('update-downloaded', { version });
@@ -743,7 +860,7 @@ function initAutoUpdater(): void {
// (recovery mode) is visible — no need to also fire a native toast.
if (!mainWindow?.isFocused()) {
showNotification(
'Backspace update ready',
'Resenhacord update ready',
`Click to restart and install version ${version}.`,
() => autoUpdater.quitAndInstall(),
);
@@ -773,17 +890,28 @@ function initAutoUpdater(): void {
}
});
// Initial check with 10s delay (existing behavior)
setTimeout(() => {
updateConfirmed = false;
autoUpdater.checkForUpdates().catch(() => {});
}, 10_000);
// A release published while the app sits open used to go unnoticed until
// the next poll — the startup check had long since run, and four hours is
// a long time to look at a stale version. Poll more often, and also check
// when the user comes back to the window, which is exactly when they would
// notice an update anyway.
const POLL_INTERVAL_MS = 30 * 60 * 1000;
const MIN_GAP_MS = 5 * 60 * 1000; // keeps alt-tabbing off the feed
// Periodic check every 4 hours (existing behavior)
setInterval(() => {
const maybeCheck = (): void => {
if (downloaded) return; // already staged; nothing left to look for
if (Date.now() - lastCheckAt < MIN_GAP_MS) return;
updateConfirmed = false;
autoUpdater.checkForUpdates().catch(() => {});
}, 4 * 60 * 60 * 1000);
};
setTimeout(maybeCheck, 10_000);
setInterval(maybeCheck, POLL_INTERVAL_MS);
// App-level rather than mainWindow-level: the window is destroyed and
// recreated when closed to tray and reopened, and a listener bound to the
// old instance would go quiet after the first close.
app.on('browser-window-focus', maybeCheck);
} catch {
// Auto-updater unavailable — recovery surface still functions, just
// without update affordances. autoUpdaterRef stays null; recovery action
@@ -953,7 +1081,19 @@ if (!gotTheLock) {
// `PulseaudioLoopbackForScreenShare` feature flag we enable above.
// Fails on PipeWire-only systems without pulse compat — the
// renderer catches that and toasts the user.
callback({ video: selected, ...(shareAudio ? { audio: 'loopback' } : {}) });
// Audio no longer rides on the Electron stream: `loopback` would put
// this app's own output (everyone else's voices) back into the share.
// The native module captures it separately, isolated by process, and
// the renderer turns it into the track LiveKit publishes.
if (shareAudio) {
const started = startNativeAudioCapture(sourceId);
if (!started) {
// Tell the renderer so it can say the share is going out silently,
// instead of the user assuming sound is included.
mainWindow?.webContents.send('native-audio-unavailable');
}
}
callback({ video: selected });
} catch (err) {
console.error('[Main:ScreenShare] Handler error:', err);
// @ts-ignore — deny the request without crashing
@@ -971,7 +1111,7 @@ if (!gotTheLock) {
// AGPL-3.0 § 13: native About panel advertises the version + source repo.
app.setAboutPanelOptions({
applicationName: 'Backspace',
applicationName: 'Resenhacord',
applicationVersion: app.getVersion(),
copyright: `AGPL-3.0-only · Source: ${UPSTREAM_SOURCE_URL}`,
website: UPSTREAM_SOURCE_URL,
+18
View File
@@ -65,6 +65,24 @@ contextBridge.exposeInMainWorld('backspace', {
onScreenShareSources: (callback: (sources: unknown[]) => void) => {
ipcRenderer.on('screen-share-sources', (_event, sources) => callback(sources));
},
// Raw PCM from the native capture. Arrives ~50x/second; the renderer turns it
// into a MediaStreamTrack for LiveKit.
onNativeAudioData: (callback: (data: ArrayBuffer, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => void) => {
const listener = (_event: unknown, data: Uint8Array, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => {
// Copied out of the transferred buffer: reusing it across IPC messages
// would let a later chunk overwrite one still being read.
callback(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer, meta);
};
ipcRenderer.on('native-audio-data', listener);
return () => ipcRenderer.off('native-audio-data', listener);
},
onNativeAudioUnavailable: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on('native-audio-unavailable', listener);
return () => ipcRenderer.off('native-audio-unavailable', listener);
},
stopNativeAudio: () => ipcRenderer.send('native-audio-stop'),
selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => {
ipcRenderer.send('screen-share-selected', sourceId, shareAudio ?? true);
},
+1 -1
View File
@@ -166,7 +166,7 @@ describe('buildTrayMenuTemplate', () => {
it('includes Show/Hide/Change Instance/Quit base items', () => {
const items = buildTrayMenuTemplate(defaultState());
const labels = items.map((i) => i.label);
expect(labels).toContain('Show Backspace');
expect(labels).toContain('Mostrar o Resenhacord');
expect(labels).toContain('Hide');
expect(labels).toContain('Change Instance');
expect(labels).toContain('Quit');
+1 -1
View File
@@ -122,7 +122,7 @@ export function buildTrayMenuTemplate(
actions?: Partial<MenuActions>,
): MenuItemConstructorOptions[] {
const items: MenuItemConstructorOptions[] = [
{ label: 'Show Backspace', click: actions?.onShow },
{ label: 'Mostrar o Resenhacord', click: actions?.onShow },
{ label: 'Hide', click: actions?.onHide },
{ type: 'separator' },
checkForUpdatesItem(state, () => actions?.onCheckForUpdates?.()),
@@ -0,0 +1,9 @@
CREATE TABLE `spotify_connections` (
`user_id` text PRIMARY KEY NOT NULL,
`access_token` text NOT NULL,
`refresh_token` text NOT NULL,
`expires_at` integer NOT NULL,
`spotify_user_id` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
@@ -0,0 +1,14 @@
CREATE TABLE `gif_favorites` (
`user_id` text NOT NULL,
`gif_id` text NOT NULL,
`title` text NOT NULL,
`preview_url` text NOT NULL,
`url` text NOT NULL,
`width` integer NOT NULL,
`height` integer NOT NULL,
`created_at` integer NOT NULL,
PRIMARY KEY(`user_id`, `gif_id`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_gif_favorites_user_id` ON `gif_favorites` (`user_id`);
@@ -0,0 +1,15 @@
CREATE TABLE `audit_events` (
`id` text PRIMARY KEY NOT NULL,
`space_id` text NOT NULL,
`actor_id` text,
`action` text NOT NULL,
`target_type` text,
`target_id` text,
`metadata` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`actor_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `idx_audit_events_space_created` ON `audit_events` (`space_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_audit_events_actor` ON `audit_events` (`actor_id`);
@@ -0,0 +1,14 @@
CREATE TABLE `voice_sessions` (
`id` text PRIMARY KEY NOT NULL,
`space_id` text,
`channel_id` text NOT NULL,
`user_id` text NOT NULL,
`started_at` integer NOT NULL,
`ended_at` integer,
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_voice_sessions_space_started` ON `voice_sessions` (`space_id`,`started_at`);--> statement-breakpoint
CREATE INDEX `idx_voice_sessions_user` ON `voice_sessions` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_voice_sessions_ended` ON `voice_sessions` (`ended_at`);
@@ -0,0 +1,12 @@
CREATE TABLE `soundboard_sounds` (
`id` text PRIMARY KEY NOT NULL,
`space_id` text NOT NULL,
`name` text NOT NULL,
`filename` text NOT NULL,
`uploader_id` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `idx_soundboard_space` ON `soundboard_sounds` (`space_id`);
@@ -0,0 +1,4 @@
ALTER TABLE `messages` ADD `pinned_at` integer;--> statement-breakpoint
ALTER TABLE `messages` ADD `pinned_by` text;--> statement-breakpoint
CREATE INDEX `idx_messages_channel_pinned` ON `messages` (`channel_id`,`pinned_at`);--> statement-breakpoint
CREATE INDEX `idx_messages_channel_user_created` ON `messages` (`channel_id`,`user_id`,`created_at`);
@@ -0,0 +1,26 @@
CREATE TABLE `space_emojis` (
`id` text PRIMARY KEY NOT NULL,
`space_id` text NOT NULL,
`name` text NOT NULL,
`filename` text NOT NULL,
`uploader_id` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `space_stickers` (
`id` text PRIMARY KEY NOT NULL,
`space_id` text NOT NULL,
`name` text NOT NULL,
`filename` text NOT NULL,
`uploader_id` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
ALTER TABLE `messages` ADD `sticker_id` text;--> statement-breakpoint
CREATE INDEX `idx_space_emojis_space` ON `space_emojis` (`space_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `idx_space_emojis_space_name` ON `space_emojis` (`space_id`,`name`);--> statement-breakpoint
CREATE INDEX `idx_space_stickers_space` ON `space_stickers` (`space_id`);
@@ -0,0 +1 @@
ALTER TABLE `messages` ADD `type` text DEFAULT 'user' NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE `users` ADD `profile_style` text;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -78,6 +78,69 @@
"when": 1783035334526,
"tag": "0010_broken_blazing_skull",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1788190305853,
"tag": "0011_lethal_bruce_banner",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1788192490711,
"tag": "0012_sour_pixie",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1788193162813,
"tag": "0013_fancy_betty_brant",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1788193659704,
"tag": "0014_mean_killer_shrike",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1788194631751,
"tag": "0015_young_human_fly",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1788295165743,
"tag": "0016_lyrical_freak",
"breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1788295936825,
"tag": "0017_purple_wildside",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1788401450986,
"tag": "0018_eminent_kid_colt",
"breakpoints": true
},
{
"idx": 19,
"version": "6",
"when": 1788402724322,
"tag": "0019_glamorous_crusher_hogan",
"breakpoints": true
}
]
}
+4
View File
@@ -76,6 +76,10 @@ export const config = {
sourceCodeUrl,
commit,
spotify: {
clientId: envOptional('SPOTIFY_CLIENT_ID'),
clientSecret: envOptional('SPOTIFY_CLIENT_SECRET'),
},
livekit: {
url: envOptional('LIVEKIT_URL'),
apiKey: envOptional('LIVEKIT_API_KEY'),
+2 -1
View File
@@ -3,7 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { config } from '../config.js';
import * as schema from './schema.js';
import { ensureDefaults, backfillOneOnOneDmMembership } from './migrate.js';
import { ensureDefaults, backfillOneOnOneDmMembership, grantMentionEveryoneToLegacyRoles } from './migrate.js';
import { setWorkerId } from '../utils/snowflake.js';
import { createSnapshot } from '../utils/backup.js';
import { hasPendingMigrations } from './pendingMigrations.js';
@@ -53,6 +53,7 @@ export function initDatabase() {
// Recover pre-fix broken 1-on-1 DM threads (deleted partner's membership row
// lost before the tombstone fix). Idempotent — safe no-op on every later boot.
backfillOneOnOneDmMembership(sqlite);
grantMentionEveryoneToLegacyRoles(sqlite);
// Initialize Snowflake worker ID from persisted value
const settings = sqlite.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as { worker_id: number } | undefined;
+41
View File
@@ -87,3 +87,44 @@ export function backfillOneOnOneDmMembership(db: Database.Database): void {
run();
if (restored > 0) console.log(`[backfill] restored ${restored} deleted-partner DM membership row(s)`);
}
/**
* Grant MENTION_EVERYONE on @everyone roles that predate the bit.
*
* `DEFAULT_EVERYONE_PERMISSIONS` only applies at space creation, so spaces
* created before the bit existed carry a stored value without it — and would
* silently have @everyone mentions stripped for every member. This ORs the bit
* in once.
*
* Idempotent by construction: the OR is a no-op when the bit is already set,
* and the WHERE clause skips those rows anyway. Deliberately scoped to the
* @everyone role (`id = space_id`), so an owner who later denies the
* permission is not overruled on the next boot — their denial lives on a
* different row.
*/
export function grantMentionEveryoneToLegacyRoles(db: Database.Database): void {
const BIT = 1n << 15n;
const rows = db.prepare(
`SELECT id, permissions FROM roles WHERE id = space_id`
).all() as { id: string; permissions: string | null }[];
const update = db.prepare('UPDATE roles SET permissions = ? WHERE id = ?');
let changed = 0;
for (const row of rows) {
let current: bigint;
try {
current = BigInt(row.permissions ?? '0');
} catch {
continue; // unparseable value — leave it for a human, do not guess
}
if ((current & BIT) !== 0n) continue;
update.run((current | BIT).toString(), row.id);
changed++;
}
if (changed > 0) {
console.log(`[defaults] Granted MENTION_EVERYONE to ${changed} @everyone role(s)`);
}
}
+150
View File
@@ -22,6 +22,10 @@ export const users = sqliteTable('users', {
profileUpdatedAt: integer('profile_updated_at'),
passwordChangedAt: integer('password_changed_at'),
showActivity: integer('show_activity').notNull().default(1),
// JSON ProfileStyle (shared/profile.ts), normalised on write. One column,
// not twelve: every field is optional, nothing is queried by value, and the
// shape keeps growing. Null means defaults.
profileStyle: text('profile_style'),
federationRegistryUpdatedAt: integer('federation_registry_updated_at').default(0),
federationHealPending: integer('federation_heal_pending').default(0),
federationHomeOrphaned: integer('federation_home_orphaned').default(0),
@@ -81,6 +85,18 @@ export const messages = sqliteTable('messages', {
replyToId: text('reply_to_id'),
content: text('content'),
editedAt: integer('edited_at'),
// Pinned state lives on the message rather than in a join table: a message is
// pinned in exactly one channel — its own — so a separate table would only
// add a join to every pin lookup.
pinnedAt: integer('pinned_at'),
pinnedBy: text('pinned_by'),
// Figurinha enviada como mensagem. `ON DELETE set null` de propósito: apagar
// a figurinha do servidor não pode apagar o histórico de quem a usou.
stickerId: text('sticker_id'),
// 'user' | 'system'. Space channels gained system messages with the pin
// notice; DM channels have carried them since group DMs. Same values, same
// renderer on the client.
type: text('type').notNull().default('user'),
createdAt: integer('created_at').notNull(),
}, (table) => ({
replyToFk: foreignKey({
@@ -89,6 +105,10 @@ export const messages = sqliteTable('messages', {
}).onDelete('set null'),
channelIdx: index('idx_messages_channel_id').on(table.channelId),
userIdx: index('idx_messages_user_id').on(table.userId),
// Listing a channel's pins, newest first, without scanning its history.
pinnedIdx: index('idx_messages_channel_pinned').on(table.channelId, table.pinnedAt),
// Filtered search: author within a channel, ordered by recency.
searchIdx: index('idx_messages_channel_user_created').on(table.channelId, table.userId, table.createdAt),
}));
export const attachments = sqliteTable('attachments', {
@@ -549,3 +569,133 @@ export const inviteRedemptions = sqliteTable('invite_redemptions', {
inviteIdx: index('idx_invite_redemptions_invite_id').on(table.inviteId),
userIdx: index('idx_invite_redemptions_user_id').on(table.userId),
}));
/**
* Spotify tokens, one row per user.
*
* Kept server-side on purpose: refreshing requires the client secret, so the
* browser never holds a Spotify token at all — it asks this server what is
* playing and this server talks to Spotify.
*/
export const spotifyConnections = sqliteTable('spotify_connections', {
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
accessToken: text('access_token').notNull(),
refreshToken: text('refresh_token').notNull(),
// Epoch millis at which accessToken stops working.
expiresAt: integer('expires_at').notNull(),
spotifyUserId: text('spotify_user_id'),
createdAt: integer('created_at').notNull(),
});
/**
* Favourited GIFs, one row per user per GIF.
*
* Stores the whole result rather than an id: the provider offers no lookup by
* id, so a favourites tab that only kept ids could not render without
* re-searching for something the user may never find again.
*/
export const gifFavorites = sqliteTable('gif_favorites', {
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
gifId: text('gif_id').notNull(),
title: text('title').notNull(),
previewUrl: text('preview_url').notNull(),
url: text('url').notNull(),
width: integer('width').notNull(),
height: integer('height').notNull(),
createdAt: integer('created_at').notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.userId, table.gifId] }),
userIdx: index('idx_gif_favorites_user_id').on(table.userId),
}));
/**
* Append-only record of who changed what in a space.
*
* Deliberately generic (action + target + JSON metadata) rather than a column
* per event type: new actions must not require a migration. Statistics read
* this same table — two features, one mechanism, instead of two logs that
* drift apart.
*/
export const auditEvents = sqliteTable('audit_events', {
id: text('id').primaryKey(),
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
actorId: text('actor_id').references(() => users.id, { onDelete: 'set null' }),
action: text('action').notNull(),
targetType: text('target_type'),
targetId: text('target_id'),
// JSON blob; shape depends on `action`. Never trusted for permissions.
metadata: text('metadata'),
createdAt: integer('created_at').notNull(),
}, (table) => ({
spaceIdx: index('idx_audit_events_space_created').on(table.spaceId, table.createdAt),
actorIdx: index('idx_audit_events_actor').on(table.actorId),
}));
/**
* One row per stay in a voice room, closed when the user leaves.
*
* Separate from `auditEvents` on purpose: that table records points in time,
* while a call is an interval. Storing joins and leaves as separate point
* events would make every statistics query pair rows by hand and guess at
* joins whose leave never arrived (a crash, a restart).
*
* `endedAt` null means still connected. `spaceId` is null for DM calls.
*/
export const voiceSessions = sqliteTable('voice_sessions', {
id: text('id').primaryKey(),
spaceId: text('space_id').references(() => spaces.id, { onDelete: 'cascade' }),
channelId: text('channel_id').notNull(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
startedAt: integer('started_at').notNull(),
endedAt: integer('ended_at'),
}, (table) => ({
spaceIdx: index('idx_voice_sessions_space_started').on(table.spaceId, table.startedAt),
userIdx: index('idx_voice_sessions_user').on(table.userId),
openIdx: index('idx_voice_sessions_ended').on(table.endedAt),
}));
/** Soundboard clips, per space. The file lives in the normal upload dir. */
export const soundboardSounds = sqliteTable('soundboard_sounds', {
id: text('id').primaryKey(),
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
filename: text('filename').notNull(),
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
createdAt: integer('created_at').notNull(),
}, (table) => ({
spaceIdx: index('idx_soundboard_space').on(table.spaceId),
}));
/**
* Emojis próprios de um espaço, referenciados por `:nome:` nas mensagens.
*
* O nome é único por espaço: `:trollface:` tem de resolver para uma imagem só,
* senão o render vira loteria. Espaços diferentes podem repetir o nome.
*/
export const spaceEmojis = sqliteTable('space_emojis', {
id: text('id').primaryKey(),
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
filename: text('filename').notNull(),
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
createdAt: integer('created_at').notNull(),
}, (table) => ({
spaceIdx: index('idx_space_emojis_space').on(table.spaceId),
nameUnique: uniqueIndex('idx_space_emojis_space_name').on(table.spaceId, table.name),
}));
/**
* Figurinhas do espaço. Tabela separada dos emojis de propósito: emoji entra
* no meio do texto e figurinha ocupa a mensagem inteira — tamanhos, limites e
* caminho de render são diferentes.
*/
export const spaceStickers = sqliteTable('space_stickers', {
id: text('id').primaryKey(),
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
filename: text('filename').notNull(),
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
createdAt: integer('created_at').notNull(),
}, (table) => ({
spaceIdx: index('idx_space_stickers_space').on(table.spaceId),
}));
+17
View File
@@ -15,6 +15,12 @@ import { uploadRoutes } from './routes/uploads.js';
import { filesRoutes } from './routes/files.js';
import { dmRoutes } from './routes/dm.js';
import { livekitRoutes } from './routes/livekit.js';
import { spotifyRoutes } from './routes/spotify.js';
import { auditRoutes } from './routes/audit.js';
import { statsRoutes } from './routes/stats.js';
import { soundboardRoutes } from './routes/soundboard.js';
import { expressionRoutes } from './routes/expressions.js';
import { closeOrphanedVoiceSessions } from './utils/voiceSessions.js';
import { socialRoutes } from './routes/social.js';
import { settingsRoutes } from './routes/settings.js';
import { utilRoutes } from './routes/utils.js';
@@ -25,6 +31,7 @@ import { searchRoutes } from './routes/search.js';
import { adminRoutes } from './routes/admin.js';
import { gifRoutes } from './routes/gif.js';
import { federationRoutes } from './routes/federation.js';
import { gameRoutes } from './routes/games.js';
import { startFederationWorkers, stopFederationWorkers } from './utils/federationWorker.js';
import { startBackupWorker, stopBackupWorker } from './utils/backupWorker.js';
import './utils/federationRollback.js'; // Side-effect: registers rollback callbacks for outbox terminal failures.
@@ -109,6 +116,10 @@ async function main(): Promise<void> {
// Initialize database
getDb();
// A restart leaves voice sessions open with no way to know when they really
// ended. Sweep them before anything can read the statistics.
closeOrphanedVoiceSessions();
// Reset orphaned `users.status` rows for locally-homed users. The previous
// process's in-memory disconnect timers are gone, so any non-offline row
// is stale by construction. Replicated (federated) rows are skipped — their
@@ -126,6 +137,11 @@ async function main(): Promise<void> {
await app.register(filesRoutes);
await app.register(dmRoutes);
await app.register(livekitRoutes);
await app.register(spotifyRoutes);
await app.register(auditRoutes);
await app.register(statsRoutes);
await app.register(soundboardRoutes);
await app.register(expressionRoutes);
await app.register(socialRoutes);
await app.register(settingsRoutes);
await app.register(utilRoutes);
@@ -135,6 +151,7 @@ async function main(): Promise<void> {
await app.register(searchRoutes);
await app.register(adminRoutes);
await app.register(gifRoutes);
await app.register(gameRoutes);
await app.register(federationRoutes);
await app.register(registerWebSocket);
+83
View File
@@ -0,0 +1,83 @@
import type { FastifyInstance } from 'fastify';
import { and, desc, eq, lt } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { hasPermission } from '../utils/permissions.js';
import { PermissionBits } from '@backspace/shared/src/permissions.js';
import { AUDIT_PAGE_SIZE, type AuditAction, type AuditEvent } from '@backspace/shared/src/audit.js';
export async function auditRoutes(app: FastifyInstance): Promise<void> {
app.get<{ Params: { id: string }; Querystring: { before?: string; limit?: string } }>(
'/api/spaces/:id/audit-log',
{ preHandler: authenticate },
async (request, reply) => {
const { id } = request.params;
// Gated on MANAGE_SPACE rather than a new permission bit: the log names
// who did what to whom, which is administrator-shaped information, and a
// new bit would silently default to nobody until roles were re-edited.
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
const limit = Math.min(Math.max(Number(request.query.limit) || AUDIT_PAGE_SIZE, 1), AUDIT_PAGE_SIZE);
const db = getDb();
// Ids are snowflakes, so ordering by id is chronological and gives a
// stable cursor even when two events land in the same millisecond.
const where = request.query.before
? and(eq(schema.auditEvents.spaceId, id), lt(schema.auditEvents.id, request.query.before))
: eq(schema.auditEvents.spaceId, id);
const rows = db.select({
id: schema.auditEvents.id,
spaceId: schema.auditEvents.spaceId,
action: schema.auditEvents.action,
targetType: schema.auditEvents.targetType,
targetId: schema.auditEvents.targetId,
metadata: schema.auditEvents.metadata,
createdAt: schema.auditEvents.createdAt,
actorId: schema.users.id,
actorUsername: schema.users.username,
actorDisplayName: schema.users.displayName,
actorAvatar: schema.users.avatar,
})
.from(schema.auditEvents)
.leftJoin(schema.users, eq(schema.auditEvents.actorId, schema.users.id))
.where(where)
.orderBy(desc(schema.auditEvents.id))
.limit(limit)
.all();
const events: AuditEvent[] = rows.map((r) => ({
id: r.id,
spaceId: r.spaceId,
action: r.action as AuditAction,
// Null when the account was deleted: the event stays, the actor does
// not — an audit log that vanished with its actor would be useless.
actor: r.actorId
? { id: r.actorId, username: r.actorUsername!, displayName: r.actorDisplayName, avatar: r.actorAvatar }
: null,
targetType: r.targetType,
targetId: r.targetId,
metadata: parseMetadata(r.metadata),
createdAt: r.createdAt,
}));
return reply.code(200).send({ events, hasMore: events.length === limit });
},
);
}
/** Metadata is written by us, but a malformed row must not break the whole page. */
function parseMetadata(raw: string | null): Record<string, unknown> | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
+37 -4
View File
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
import { eq, and, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { recordAuditEvent } from '../utils/auditLog.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, hasPermission, getChannelSpaceId, PermissionBits, computePermissions } from '../utils/permissions.js';
import { permissionsToString } from '@backspace/shared/src/permissions.js';
@@ -14,6 +15,11 @@ import type {
Channel,
ChannelCategory,
} from '@backspace/shared';
import {
normalizeChannelName,
CHANNEL_NAME_MIN_LENGTH,
CHANNEL_NAME_MAX_LENGTH,
} from '@backspace/shared/src/constants.js';
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
return {
@@ -189,8 +195,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
return reply.code(400).send({ error: 'Channel name is required', statusCode: 400 });
}
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-');
if (trimmedName.length < 1 || trimmedName.length > 100) {
const trimmedName = normalizeChannelName(name);
if (trimmedName.length < CHANNEL_NAME_MIN_LENGTH || trimmedName.length > CHANNEL_NAME_MAX_LENGTH) {
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
}
@@ -257,6 +263,15 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
// Return the channel with the creator's computed permissions (same shape as
// the channel_created WS event) so the client can render it immediately
// without waiting for the broadcast to round-trip.
recordAuditEvent({
spaceId: id,
actorId: request.userId,
action: 'channel.create',
targetType: 'channel',
targetId: channelId,
metadata: { name: channelData.name, type: channelData.type },
});
const creatorPerms = computePermissions(request.userId, id, channelId);
return reply.code(201).send({
...channelData,
@@ -286,8 +301,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
const updates: Partial<typeof schema.channels.$inferInsert> = {};
if (name !== undefined) {
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-');
if (trimmedName.length < 1 || trimmedName.length > 100) {
const trimmedName = normalizeChannelName(name);
if (trimmedName.length < CHANNEL_NAME_MIN_LENGTH || trimmedName.length > CHANNEL_NAME_MAX_LENGTH) {
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
}
updates.name = trimmedName;
@@ -346,6 +361,15 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
});
}
recordAuditEvent({
spaceId,
actorId: request.userId,
action: 'channel.update',
targetType: 'channel',
targetId: id,
metadata: { name: channelData.name },
});
return reply.code(200).send(channelData);
});
@@ -418,6 +442,15 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
connectionManager.sendToUser(uid, deleteEvent);
}
recordAuditEvent({
spaceId,
actorId: request.userId,
action: 'channel.delete',
targetType: 'channel',
targetId: id,
metadata: { name: channel.name },
});
return reply.code(200).send({ success: true });
});
+153
View File
@@ -0,0 +1,153 @@
import type { FastifyInstance } from 'fastify';
import { and, eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { hasPermission, isMember } from '../utils/permissions.js';
import { PermissionBits } from '@backspace/shared/src/permissions.js';
import { generateSnowflake } from '../utils/snowflake.js';
const MAX_EMOJIS_PER_SPACE = 100;
const MAX_STICKERS_PER_SPACE = 50;
const MAX_NAME_LENGTH = 32;
/**
* Nomes viram `:nome:` no texto, então só letras, números e sublinhado —
* espaço ou dois-pontos dentro do nome tornariam a referência impossível de
* delimitar.
*/
const NAME_PATTERN = /^[a-z0-9_]{2,32}$/;
function normalizeName(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const name = raw.trim().toLowerCase().replace(/\s+/g, '_').slice(0, MAX_NAME_LENGTH);
return NAME_PATTERN.test(name) ? name : null;
}
/** O nome do arquivo é chave no diretório de uploads, nunca um caminho. */
function validFilename(raw: unknown): raw is string {
return typeof raw === 'string' && raw.length > 0
&& !raw.includes('/') && !raw.includes('\\') && !raw.includes('..');
}
export async function expressionRoutes(app: FastifyInstance): Promise<void> {
// ─── Emojis ──────────────────────────────────────────────────────────────
app.get<{ Params: { id: string } }>('/api/spaces/:id/emojis', { preHandler: authenticate }, async (request, reply) => {
if (!isMember(request.params.id, request.userId)) {
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
}
const rows = getDb().select().from(schema.spaceEmojis)
.where(eq(schema.spaceEmojis.spaceId, request.params.id)).all();
return reply.code(200).send({ emojis: rows });
});
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
'/api/spaces/:id/emojis', { preHandler: authenticate }, async (request, reply) => {
const { id } = request.params;
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
const name = normalizeName(request.body?.name);
if (!name) {
return reply.code(400).send({ error: 'Name must be 2-32 chars: letters, numbers, underscore', statusCode: 400 });
}
if (!validFilename(request.body?.filename)) {
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
}
const db = getDb();
const count = db.select().from(schema.spaceEmojis)
.where(eq(schema.spaceEmojis.spaceId, id)).all().length;
if (count >= MAX_EMOJIS_PER_SPACE) {
return reply.code(409).send({ error: `At most ${MAX_EMOJIS_PER_SPACE} emojis`, statusCode: 409 });
}
const existing = db.select().from(schema.spaceEmojis)
.where(and(eq(schema.spaceEmojis.spaceId, id), eq(schema.spaceEmojis.name, name))).get();
if (existing) {
return reply.code(409).send({ error: `:${name}: already exists in this space`, statusCode: 409 });
}
const row = {
id: generateSnowflake(),
spaceId: id,
name,
filename: request.body!.filename!,
uploaderId: request.userId,
createdAt: Date.now(),
};
db.insert(schema.spaceEmojis).values(row).run();
return reply.code(201).send(row);
},
);
app.delete<{ Params: { id: string } }>('/api/emojis/:id', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
const emoji = db.select().from(schema.spaceEmojis)
.where(eq(schema.spaceEmojis.id, request.params.id)).get();
if (!emoji) return reply.code(404).send({ error: 'Emoji not found', statusCode: 404 });
if (!hasPermission(request.userId, emoji.spaceId, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
db.delete(schema.spaceEmojis).where(eq(schema.spaceEmojis.id, request.params.id)).run();
return reply.code(204).send();
});
// ─── Stickers ────────────────────────────────────────────────────────────
app.get<{ Params: { id: string } }>('/api/spaces/:id/stickers', { preHandler: authenticate }, async (request, reply) => {
if (!isMember(request.params.id, request.userId)) {
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
}
const rows = getDb().select().from(schema.spaceStickers)
.where(eq(schema.spaceStickers.spaceId, request.params.id)).all();
return reply.code(200).send({ stickers: rows });
});
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
'/api/spaces/:id/stickers', { preHandler: authenticate }, async (request, reply) => {
const { id } = request.params;
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
// Figurinha é escolhida numa grade, não digitada, então o nome é rótulo
// e aceita acento e espaço — ao contrário do emoji.
const name = typeof request.body?.name === 'string'
? request.body.name.trim().slice(0, MAX_NAME_LENGTH) : '';
if (!name) return reply.code(400).send({ error: 'Name is required', statusCode: 400 });
if (!validFilename(request.body?.filename)) {
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
}
const db = getDb();
const count = db.select().from(schema.spaceStickers)
.where(eq(schema.spaceStickers.spaceId, id)).all().length;
if (count >= MAX_STICKERS_PER_SPACE) {
return reply.code(409).send({ error: `At most ${MAX_STICKERS_PER_SPACE} stickers`, statusCode: 409 });
}
const row = {
id: generateSnowflake(),
spaceId: id,
name,
filename: request.body!.filename!,
uploaderId: request.userId,
createdAt: Date.now(),
};
db.insert(schema.spaceStickers).values(row).run();
return reply.code(201).send(row);
},
);
app.delete<{ Params: { id: string } }>('/api/stickers/:id', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
const sticker = db.select().from(schema.spaceStickers)
.where(eq(schema.spaceStickers.id, request.params.id)).get();
if (!sticker) return reply.code(404).send({ error: 'Sticker not found', statusCode: 404 });
if (!hasPermission(request.userId, sticker.spaceId, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
db.delete(schema.spaceStickers).where(eq(schema.spaceStickers.id, request.params.id)).run();
return reply.code(204).send();
});
}
+48
View File
@@ -0,0 +1,48 @@
import type { FastifyInstance } from 'fastify';
import { authenticate } from '../utils/auth.js';
import { steamCoverUrl } from '@backspace/shared/src/profile.js';
/**
* Game lookup for profile widgets, backed by Steam's public store search.
*
* Proxied rather than called from the browser: the store endpoint has no CORS
* headers, and going through here means one place to cache, to bound the
* query, and to swap the source later without touching clients. No key is
* involved — it is the same unauthenticated endpoint the store website uses.
*/
interface SteamSearchItem { id: number; name: string; tiny_image?: string }
interface GameHit { appId: number; name: string; cover: string; header: string }
const CACHE_TTL_MS = 10 * 60 * 1000;
const cache = new Map<string, { at: number; hits: GameHit[] }>();
export async function gameRoutes(app: FastifyInstance): Promise<void> {
app.get<{ Querystring: { q?: string } }>('/api/games/search', { preHandler: authenticate }, async (request, reply) => {
const q = (request.query.q ?? '').trim().slice(0, 60);
if (q.length < 2) return reply.send({ hits: [] });
const key = q.toLowerCase();
const cached = cache.get(key);
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return reply.send({ hits: cached.hits });
try {
const url = `https://store.steampowered.com/api/storesearch/?term=${encodeURIComponent(q)}&l=brazilian&cc=BR`;
const res = await fetch(url, { signal: AbortSignal.timeout(6000), headers: { 'User-Agent': 'Resenhacord/1.0' } });
if (!res.ok) return reply.send({ hits: [] });
const data = (await res.json()) as { items?: SteamSearchItem[] };
const hits: GameHit[] = (data.items ?? []).slice(0, 8).map((it) => ({
appId: it.id,
name: it.name,
cover: steamCoverUrl(it.id),
header: `https://cdn.cloudflare.steamstatic.com/steam/apps/${it.id}/header.jpg`,
}));
cache.set(key, { at: Date.now(), hits });
if (cache.size > 500) cache.delete(cache.keys().next().value!);
return reply.send({ hits });
} catch {
// Upstream hiccup is not the user's problem: an empty list lets them
// type the title by hand instead.
return reply.send({ hits: [] });
}
});
}
+65 -1
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { eq, and, desc } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import type { GifResult } from '@backspace/shared';
@@ -178,4 +178,68 @@ export async function gifRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send({ results: [], next: '' });
}
});
// ─── Favourites ──────────────────────────────────────────────────────────
// Kept per user and synced server-side so a favourite made on the phone is
// there on the desktop, which is the whole point of favouriting.
/** Cap per user: a favourites tab is a shortlist, not an archive. */
const MAX_FAVORITES = 200;
app.get('/api/gif/favorites', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
const rows = db.select().from(schema.gifFavorites)
.where(eq(schema.gifFavorites.userId, request.userId))
.orderBy(desc(schema.gifFavorites.createdAt))
.all();
const results: GifResult[] = rows.map((r) => ({
id: r.gifId, title: r.title, previewUrl: r.previewUrl,
url: r.url, width: r.width, height: r.height,
}));
return reply.code(200).send({ results });
});
app.post<{ Body: GifResult }>('/api/gif/favorites', { preHandler: authenticate }, async (request, reply) => {
const { id, title, previewUrl, url, width, height } = request.body ?? ({} as GifResult);
if (!id || typeof id !== 'string' || !previewUrl || !url) {
return reply.code(400).send({ error: 'id, previewUrl and url are required', statusCode: 400 });
}
// Only http(s): these become <img src> for everyone who opens the picker.
for (const candidate of [previewUrl, url]) {
if (!/^https?:\/\//.test(candidate)) {
return reply.code(400).send({ error: 'previewUrl and url must be http(s)', statusCode: 400 });
}
}
const db = getDb();
const count = db.select().from(schema.gifFavorites)
.where(eq(schema.gifFavorites.userId, request.userId)).all().length;
const existing = db.select().from(schema.gifFavorites)
.where(and(eq(schema.gifFavorites.userId, request.userId), eq(schema.gifFavorites.gifId, id))).get();
if (!existing && count >= MAX_FAVORITES) {
return reply.code(409).send({ error: `At most ${MAX_FAVORITES} favourites`, statusCode: 409 });
}
db.insert(schema.gifFavorites).values({
userId: request.userId,
gifId: id,
title: typeof title === 'string' ? title.slice(0, 200) : '',
previewUrl,
url,
width: Number.isFinite(width) ? width : 0,
height: Number.isFinite(height) ? height : 0,
createdAt: Date.now(),
}).onConflictDoNothing().run();
return reply.code(204).send();
});
app.delete<{ Params: { id: string } }>('/api/gif/favorites/:id', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
db.delete(schema.gifFavorites)
.where(and(eq(schema.gifFavorites.userId, request.userId), eq(schema.gifFavorites.gifId, request.params.id)))
.run();
return reply.code(204).send();
});
}
+198 -6
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify';
import { eq, and, desc, lt, inArray } from 'drizzle-orm';
import { eq, and, desc, lt, inArray, isNotNull } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
@@ -16,6 +16,7 @@ import {
} from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js';
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
import { mentionsEveryone, stripEveryoneMention } from '@backspace/shared/src/mentions.js';
import { fetchEmbedsForMessages, resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js';
/**
@@ -145,6 +146,12 @@ export function buildMessageWithUser(
content: message.content,
editedAt: message.editedAt,
createdAt: message.createdAt,
// Carried on every message so the client can show the pin marker without a
// second request, and so the pins panel and the timeline agree.
pinnedAt: message.pinnedAt,
pinnedBy: message.pinnedBy,
type: (message.type === 'system' ? 'system' : 'user') as 'user' | 'system',
stickerId: message.stickerId,
user: sanitizeUser(user),
attachments: attachmentRows.map(a => ({
id: a.id,
@@ -271,7 +278,8 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
},
}, async (request, reply) => {
const { id } = request.params;
const { content, attachments: attachmentIds, replyToId } = request.body;
const { attachments: attachmentIds, replyToId, stickerId } = request.body;
let content = request.body.content;
const spaceId = getChannelSpaceId(id);
if (!spaceId) {
@@ -287,18 +295,47 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
return reply.code(403).send({ error: 'Missing ATTACH_FILES permission', statusCode: 403 });
}
const db0 = getDb();
const db = db0;
// @everyone is a permission, not a formatting choice. Rather than reject
// the message — which loses what the author wrote over one token — the
// token is demoted to the plain text they typed, so it posts and reads the
// same but rings nobody.
if (
typeof content === 'string' &&
mentionsEveryone(content) &&
!hasPermission(request.userId, spaceId, PermissionBits.MENTION_EVERYONE, id)
) {
content = stripEveryoneMention(content);
}
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
const hasAttachments = attachmentIds && attachmentIds.length > 0;
if (!hasContent && !hasAttachments) {
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
// Figurinha vale como conteúdo: a mensagem é a figurinha.
let sticker = null;
if (stickerId) {
sticker = db0.select().from(schema.spaceStickers)
.where(eq(schema.spaceStickers.id, stickerId)).get() ?? null;
if (!sticker) {
return reply.code(400).send({ error: 'Sticker not found', statusCode: 400 });
}
// Só figurinhas do próprio espaço: aceitar de outro vazaria imagem entre
// servidores que não têm relação nenhuma.
if (sticker.spaceId !== spaceId) {
return reply.code(400).send({ error: 'Sticker belongs to another space', statusCode: 400 });
}
}
if (!hasContent && !hasAttachments && !sticker) {
return reply.code(400).send({ error: 'Message must have content, attachments or a sticker', statusCode: 400 });
}
if (content && content.length > MAX_MESSAGE_LENGTH) {
return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 });
}
const db = getDb();
const messageId = generateSnowflake();
const now = Date.now();
@@ -323,6 +360,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
channelId: id,
userId: request.userId,
replyToId: replyToId || null,
stickerId: sticker ? sticker.id : null,
content: content?.trim() || null,
createdAt: now,
}).run();
@@ -400,9 +438,21 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
return reply.code(403).send({ error: 'You can only edit your own messages', statusCode: 403 });
}
// Same gate as on create — otherwise editing a message is the way around it.
let edited = content;
if (mentionsEveryone(edited)) {
const editSpaceId = getChannelSpaceId(message.channelId);
if (
!editSpaceId ||
!hasPermission(request.userId, editSpaceId, PermissionBits.MENTION_EVERYONE, message.channelId)
) {
edited = stripEveryoneMention(edited);
}
}
const now = Date.now();
db.update(schema.messages)
.set({ content: content.trim(), editedAt: now })
.set({ content: edited.trim(), editedAt: now })
.where(eq(schema.messages.id, id))
.run();
@@ -500,4 +550,146 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send({ success: true });
});
// ─── Pins ────────────────────────────────────────────────────────────────
/**
* Assembles rows into the shape clients expect. Same batching the channel
* listing uses — kept in one place so pins and history cannot drift apart in
* what they include.
*/
function assembleMessages(rows: (typeof schema.messages.$inferSelect)[]): MessageWithUser[] {
if (rows.length === 0) return [];
const db = getDb();
const userIds = [...new Set(rows.map(m => m.userId))];
const userMap = new Map(
db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all().map(u => [u.id, u]),
);
const messageIds = rows.map(m => m.id);
const attachmentMap = new Map<string, (typeof schema.attachments.$inferSelect)[]>();
for (const att of db.select().from(schema.attachments)
.where(inArray(schema.attachments.messageId, messageIds)).all()) {
const mid = att.messageId ?? '';
if (!attachmentMap.has(mid)) attachmentMap.set(mid, []);
attachmentMap.get(mid)!.push(att);
}
const reactionsMap = fetchReactionsForMessages(messageIds);
const embedMap = fetchEmbedsForMessages(messageIds);
const replyToMap = fetchReplyToMessages(rows);
return rows.map(m => {
const user = userMap.get(m.userId);
if (!user) return null;
const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null;
return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [],
reactionsMap.get(m.id) ?? [], replyTo, embedMap.get(m.id) ?? []);
}).filter((m): m is MessageWithUser => m !== null);
}
/** Discord caps a channel at 50; the same ceiling keeps the panel usable. */
const MAX_PINS_PER_CHANNEL = 50;
app.get<{ Params: { id: string } }>('/api/channels/:id/pins', { preHandler: authenticate }, async (request, reply) => {
const { id } = request.params;
const spaceId = getChannelSpaceId(id);
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
if (!hasPermission(request.userId, spaceId, PermissionBits.VIEW_CHANNEL, id)) {
return reply.code(403).send({ error: 'Cannot view this channel', statusCode: 403 });
}
const db = getDb();
const rows = db.select()
.from(schema.messages)
.where(and(eq(schema.messages.channelId, id), isNotNull(schema.messages.pinnedAt)))
.orderBy(desc(schema.messages.pinnedAt))
.all();
return reply.code(200).send({ messages: assembleMessages(rows) });
});
app.put<{ Params: { id: string } }>('/api/messages/:id/pin', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, request.params.id)).get();
if (!message) return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
const spaceId = getChannelSpaceId(message.channelId);
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_MESSAGES, message.channelId)) {
return reply.code(403).send({ error: 'Missing MANAGE_MESSAGES permission', statusCode: 403 });
}
if (message.pinnedAt) return reply.code(200).send({ success: true });
const count = db.select({ id: schema.messages.id })
.from(schema.messages)
.where(and(eq(schema.messages.channelId, message.channelId), isNotNull(schema.messages.pinnedAt)))
.all().length;
if (count >= MAX_PINS_PER_CHANNEL) {
return reply.code(409).send({ error: `At most ${MAX_PINS_PER_CHANNEL} pinned messages per channel`, statusCode: 409 });
}
const now = Date.now();
db.update(schema.messages)
.set({ pinnedAt: now, pinnedBy: request.userId })
.where(eq(schema.messages.id, message.id)).run();
connectionManager.sendToChannel(spaceId, message.channelId, {
type: 'message_pinned',
channelId: message.channelId,
messageId: message.id,
pinned: true,
});
// A pin is a channel event, not just a flag on one row: post a system
// notice so everyone reading later knows something was pinned, and by whom.
// Same shape as the DM lifecycle notices — JSON payload, type 'system' —
// so the client renders it with the renderer it already has. Unpinning
// deliberately posts nothing; it is housekeeping, not news.
const actor = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (actor) {
const noticeId = generateSnowflake();
db.insert(schema.messages).values({
id: noticeId,
channelId: message.channelId,
userId: request.userId,
content: JSON.stringify({ event: 'message_pinned', messageId: message.id }),
type: 'system',
createdAt: now + 1, // strictly after the pin timestamp, so ordering is stable
}).run();
const noticeRow = db.select().from(schema.messages).where(eq(schema.messages.id, noticeId)).get();
if (noticeRow) {
connectionManager.sendToChannel(spaceId, message.channelId, {
type: 'message_created',
message: buildMessageWithUser(noticeRow, actor, [], [], null),
});
}
}
return reply.code(200).send({ success: true });
});
app.delete<{ Params: { id: string } }>('/api/messages/:id/pin', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, request.params.id)).get();
if (!message) return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
const spaceId = getChannelSpaceId(message.channelId);
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_MESSAGES, message.channelId)) {
return reply.code(403).send({ error: 'Missing MANAGE_MESSAGES permission', statusCode: 403 });
}
db.update(schema.messages)
.set({ pinnedAt: null, pinnedBy: null })
.where(eq(schema.messages.id, message.id)).run();
connectionManager.sendToChannel(spaceId, message.channelId, {
type: 'message_pinned',
channelId: message.channelId,
messageId: message.id,
pinned: false,
});
return reply.code(200).send({ success: true });
});
}
+85
View File
@@ -0,0 +1,85 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { hasPermission, isMember } from '../utils/permissions.js';
import { PermissionBits } from '@backspace/shared/src/permissions.js';
import { generateSnowflake } from '../utils/snowflake.js';
/** A soundboard is a shortlist of gags, not a media library. */
const MAX_SOUNDS_PER_SPACE = 48;
const MAX_NAME_LENGTH = 32;
export async function soundboardRoutes(app: FastifyInstance): Promise<void> {
app.get<{ Params: { id: string } }>(
'/api/spaces/:id/sounds',
{ preHandler: authenticate },
async (request, reply) => {
if (!isMember(request.params.id, request.userId)) {
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
}
const rows = getDb().select().from(schema.soundboardSounds)
.where(eq(schema.soundboardSounds.spaceId, request.params.id)).all();
return reply.code(200).send({ sounds: rows });
},
);
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
'/api/spaces/:id/sounds',
{ preHandler: authenticate },
async (request, reply) => {
const { id } = request.params;
// Adding is gated but playing is not: anyone in the call may press a
// button, only the people who run the space decide what the buttons are.
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
const name = (request.body?.name ?? '').trim().slice(0, MAX_NAME_LENGTH);
const filename = (request.body?.filename ?? '').trim();
if (!name || !filename) {
return reply.code(400).send({ error: 'name and filename are required', statusCode: 400 });
}
// The filename is a key into the upload directory, never a path.
if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
}
const db = getDb();
const count = db.select().from(schema.soundboardSounds)
.where(eq(schema.soundboardSounds.spaceId, id)).all().length;
if (count >= MAX_SOUNDS_PER_SPACE) {
return reply.code(409).send({ error: `At most ${MAX_SOUNDS_PER_SPACE} sounds`, statusCode: 409 });
}
const row = {
id: generateSnowflake(),
spaceId: id,
name,
filename,
uploaderId: request.userId,
createdAt: Date.now(),
};
db.insert(schema.soundboardSounds).values(row).run();
return reply.code(201).send(row);
},
);
app.delete<{ Params: { id: string } }>(
'/api/sounds/:id',
{ preHandler: authenticate },
async (request, reply) => {
const db = getDb();
const sound = db.select().from(schema.soundboardSounds)
.where(eq(schema.soundboardSounds.id, request.params.id)).get();
if (!sound) return reply.code(404).send({ error: 'Sound not found', statusCode: 404 });
if (!hasPermission(request.userId, sound.spaceId, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
db.delete(schema.soundboardSounds).where(eq(schema.soundboardSounds.id, request.params.id)).run();
return reply.code(204).send();
},
);
}
+48
View File
@@ -3,6 +3,7 @@ import type { FastifyInstance } from 'fastify';
import { eq, and, inArray } from 'drizzle-orm';
import { getDb, getRawDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { recordAuditEvent } from '../utils/auditLog.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, isSpaceOwner, isBanned, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
@@ -489,6 +490,15 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
space: spaceData,
});
recordAuditEvent({
spaceId: id,
actorId: request.userId,
action: 'space.update',
targetType: 'space',
targetId: id,
metadata: { fields: Object.keys(updates) },
});
return reply.code(200).send(spaceData);
});
@@ -1005,6 +1015,16 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
userId: uid,
});
recordAuditEvent({
spaceId: id,
actorId: request.userId,
// Leaving on your own is not the same event as being removed by someone
// else, and a log that conflates the two misleads exactly when it matters.
action: request.userId === uid ? 'member.leave' : 'member.kick',
targetType: 'user',
targetId: uid,
});
return reply.code(200).send({ success: true });
});
@@ -1068,6 +1088,17 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
}
checkVoicePermissions(id);
recordAuditEvent({
spaceId: id,
actorId: request.userId,
action: 'role.create',
targetType: 'role',
// roleId is the value just inserted; `role` is a read-back the compiler
// cannot prove returned a row.
targetId: roleId,
metadata: { name: role?.name ?? null },
});
return reply.code(201).send(role);
});
@@ -1125,6 +1156,15 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
}
checkVoicePermissions(id);
recordAuditEvent({
spaceId: id,
actorId: request.userId,
action: 'role.update',
targetType: 'role',
targetId: roleId,
metadata: { name: updated?.name ?? null },
});
return reply.code(200).send(updated);
});
@@ -1247,6 +1287,14 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
space: spaceData,
});
recordAuditEvent({
spaceId: id,
actorId: request.userId,
action: 'space.transfer_ownership',
targetType: 'user',
targetId: newOwnerId,
});
return reply.code(200).send(spaceData);
});
+219
View File
@@ -0,0 +1,219 @@
import type { FastifyInstance } from 'fastify';
import crypto from 'crypto';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { config } from '../config.js';
import { getOurOrigin } from '../utils/federationAuth.js';
import type { Activity } from '@backspace/shared';
const SPOTIFY_AUTH = 'https://accounts.spotify.com/authorize';
const SPOTIFY_TOKEN = 'https://accounts.spotify.com/api/token';
const SPOTIFY_NOW_PLAYING = 'https://api.spotify.com/v1/me/player/currently-playing';
// Read-only: enough to see the current track, nothing that can control playback
// or read the library.
const SCOPES = 'user-read-currently-playing user-read-playback-state';
/** Refresh this many ms before expiry, so a request never races the deadline. */
const REFRESH_MARGIN_MS = 60_000;
function redirectUri(): string {
return `${getOurOrigin()}/api/connections/spotify/callback`;
}
function isConfigured(): boolean {
return Boolean(config.spotify.clientId && config.spotify.clientSecret);
}
/**
* OAuth `state`, signed with the instance's JWT secret.
*
* The callback arrives as a browser redirect, which carries no Authorization
* header — so the state has to say who started the flow, and be tamper-proof
* or anyone could bind their Spotify account to someone else's user.
*/
function signState(userId: string): string {
const payload = Buffer.from(JSON.stringify({ userId, exp: Date.now() + 10 * 60_000 })).toString('base64url');
const sig = crypto.createHmac('sha256', config.jwtSecret).update(payload).digest('base64url');
return `${payload}.${sig}`;
}
function verifyState(state: string): string | null {
const [payload, sig] = state.split('.');
if (!payload || !sig) return null;
const expected = crypto.createHmac('sha256', config.jwtSecret).update(payload).digest('base64url');
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
try {
const data = JSON.parse(Buffer.from(payload, 'base64url').toString()) as { userId: string; exp: number };
if (!data.userId || typeof data.exp !== 'number' || data.exp < Date.now()) return null;
return data.userId;
} catch {
return null;
}
}
function basicAuthHeader(): string {
return 'Basic ' + Buffer.from(`${config.spotify.clientId}:${config.spotify.clientSecret}`).toString('base64');
}
/**
* Returns a usable access token, refreshing it first when it is about to
* expire. Returns null when the connection is gone or Spotify rejected the
* refresh token — the caller then treats the user as disconnected.
*/
async function getAccessToken(userId: string): Promise<string | null> {
const db = getDb();
const row = db.select().from(schema.spotifyConnections)
.where(eq(schema.spotifyConnections.userId, userId)).get();
if (!row) return null;
if (row.expiresAt - REFRESH_MARGIN_MS > Date.now()) return row.accessToken;
const res = await fetch(SPOTIFY_TOKEN, {
method: 'POST',
headers: { Authorization: basicAuthHeader(), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: row.refreshToken }),
});
if (!res.ok) {
// A refresh token is rejected when the user revoked access on Spotify's
// side. Drop the row so the UI stops claiming a live connection.
if (res.status === 400 || res.status === 401) {
db.delete(schema.spotifyConnections).where(eq(schema.spotifyConnections.userId, userId)).run();
}
return null;
}
const body = await res.json() as { access_token: string; expires_in: number; refresh_token?: string };
db.update(schema.spotifyConnections).set({
accessToken: body.access_token,
// Spotify only returns a new refresh token sometimes; keep the old one otherwise.
refreshToken: body.refresh_token ?? row.refreshToken,
expiresAt: Date.now() + body.expires_in * 1000,
}).where(eq(schema.spotifyConnections.userId, userId)).run();
return body.access_token;
}
interface SpotifyTrack {
is_playing: boolean;
progress_ms: number | null;
item: {
name: string;
duration_ms: number;
artists: { name: string }[];
album: { name: string; images: { url: string }[] };
external_urls?: { spotify?: string };
} | null;
}
/**
* Maps Spotify's payload onto the Activity shape the profile card renders.
*
* A paused track is still reported, marked `paused`. Returning null for it made
* the block disappear on every pause — and, together with the silent gap
* between two songs, produced the flicker of it vanishing and coming back.
*/
function toActivity(track: SpotifyTrack): Activity | null {
if (!track.item) return null;
const now = Date.now();
const progress = track.progress_ms ?? 0;
return {
type: 'listening',
name: 'Spotify',
paused: !track.is_playing,
details: track.item.name,
state: track.item.artists.map((a) => a.name).join(', '),
timestamps: { start: now - progress, end: now - progress + track.item.duration_ms },
assets: {
largeImage: track.item.album.images[0]?.url,
largeText: track.item.album.name,
},
url: track.item.external_urls?.spotify,
};
}
export async function spotifyRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/connections/spotify/status', { preHandler: authenticate }, async (request, reply) => {
if (!isConfigured()) return reply.code(200).send({ configured: false, connected: false });
const db = getDb();
const row = db.select({ userId: schema.spotifyConnections.userId })
.from(schema.spotifyConnections)
.where(eq(schema.spotifyConnections.userId, request.userId)).get();
return reply.code(200).send({ configured: true, connected: Boolean(row) });
});
// Returns the URL rather than redirecting: the caller is fetch(), which would
// follow a 302 to Spotify instead of navigating the window there.
app.get('/api/connections/spotify/authorize', { preHandler: authenticate }, async (request, reply) => {
if (!isConfigured()) {
return reply.code(503).send({ error: 'Spotify is not configured on this instance', statusCode: 503 });
}
const params = new URLSearchParams({
client_id: config.spotify.clientId!,
response_type: 'code',
redirect_uri: redirectUri(),
scope: SCOPES,
state: signState(request.userId),
});
return reply.code(200).send({ url: `${SPOTIFY_AUTH}?${params}` });
});
app.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
'/api/connections/spotify/callback',
async (request, reply) => {
const { code, state, error } = request.query;
const settingsUrl = `${getOurOrigin()}/channels/@me?settings=connections`;
if (error || !code || !state) return reply.redirect(`${settingsUrl}&spotify=denied`);
const userId = verifyState(state);
if (!userId) return reply.redirect(`${settingsUrl}&spotify=invalid_state`);
const res = await fetch(SPOTIFY_TOKEN, {
method: 'POST',
headers: { Authorization: basicAuthHeader(), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri() }),
});
if (!res.ok) return reply.redirect(`${settingsUrl}&spotify=exchange_failed`);
const body = await res.json() as { access_token: string; refresh_token: string; expires_in: number };
const db = getDb();
const row = {
userId,
accessToken: body.access_token,
refreshToken: body.refresh_token,
expiresAt: Date.now() + body.expires_in * 1000,
spotifyUserId: null,
createdAt: Date.now(),
};
db.insert(schema.spotifyConnections).values(row)
.onConflictDoUpdate({ target: schema.spotifyConnections.userId, set: row }).run();
return reply.redirect(`${settingsUrl}&spotify=connected`);
},
);
app.get('/api/connections/spotify/now-playing', { preHandler: authenticate }, async (request, reply) => {
const token = await getAccessToken(request.userId);
if (!token) return reply.code(200).send({ activity: null, connected: false, serverTime: Date.now() });
const res = await fetch(SPOTIFY_NOW_PLAYING, { headers: { Authorization: `Bearer ${token}` } });
// 204 means "nothing playing"; anything else non-OK is a transient problem
// and must not be reported as a lost connection.
if (res.status === 204) return reply.code(200).send({ activity: null, connected: true, serverTime: Date.now() });
if (!res.ok) return reply.code(200).send({ activity: null, connected: res.status !== 401, serverTime: Date.now() });
const track = await res.json() as SpotifyTrack;
return reply.code(200).send({ activity: toActivity(track), connected: true, serverTime: Date.now() });
});
app.delete('/api/connections/spotify', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
db.delete(schema.spotifyConnections).where(eq(schema.spotifyConnections.userId, request.userId)).run();
return reply.code(204).send();
});
}
+94
View File
@@ -0,0 +1,94 @@
import type { FastifyInstance } from 'fastify';
import { and, eq, gte, sql, isNotNull, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { isMember } from '../utils/permissions.js';
/** Windows the UI offers. Anything else is clamped into this range. */
const DEFAULT_DAYS = 30;
const MAX_DAYS = 365;
interface Leader {
userId: string;
username: string;
displayName: string | null;
avatar: string | null;
value: number;
}
export async function statsRoutes(app: FastifyInstance): Promise<void> {
app.get<{ Params: { id: string }; Querystring: { days?: string } }>(
'/api/spaces/:id/stats',
{ preHandler: authenticate },
async (request, reply) => {
const { id } = request.params;
// Any member may look: these are the group's own numbers, not moderation
// data. The audit log, which names who did what, stays admin-only.
if (!isMember(id, request.userId)) {
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
}
const days = Math.min(Math.max(Number(request.query.days) || DEFAULT_DAYS, 1), MAX_DAYS);
const since = Date.now() - days * 24 * 60 * 60 * 1000;
const db = getDb();
// Voice time. Only closed sessions count: an open one has no duration
// yet, and counting "now - startedAt" would make the numbers move every
// time the page is refreshed.
const voiceRows = db.select({
userId: schema.voiceSessions.userId,
username: schema.users.username,
displayName: schema.users.displayName,
avatar: schema.users.avatar,
value: sql<number>`sum(${schema.voiceSessions.endedAt} - ${schema.voiceSessions.startedAt})`,
})
.from(schema.voiceSessions)
.innerJoin(schema.users, eq(schema.voiceSessions.userId, schema.users.id))
.where(and(
eq(schema.voiceSessions.spaceId, id),
gte(schema.voiceSessions.startedAt, since),
isNotNull(schema.voiceSessions.endedAt),
))
.groupBy(schema.voiceSessions.userId)
.all() as Leader[];
// Messages. Scoped through the space's channels — the messages table has
// no space column.
const channelIds = db.select({ id: schema.channels.id })
.from(schema.channels)
.where(eq(schema.channels.spaceId, id))
.all()
.map((c) => c.id);
const messageRows = channelIds.length === 0 ? [] : db.select({
userId: schema.messages.userId,
username: schema.users.username,
displayName: schema.users.displayName,
avatar: schema.users.avatar,
value: sql<number>`count(*)`,
})
.from(schema.messages)
.innerJoin(schema.users, eq(schema.messages.userId, schema.users.id))
.where(and(
inArray(schema.messages.channelId, channelIds),
gte(schema.messages.createdAt, since),
))
.groupBy(schema.messages.userId)
.all() as Leader[];
const byValueDesc = (a: Leader, b: Leader) => b.value - a.value;
return reply.code(200).send({
days,
since,
voice: voiceRows.sort(byValueDesc),
messages: messageRows.sort(byValueDesc),
totals: {
voiceMs: voiceRows.reduce((sum, r) => sum + (r.value ?? 0), 0),
messages: messageRows.reduce((sum, r) => sum + (r.value ?? 0), 0),
},
});
},
);
}
+13 -2
View File
@@ -15,6 +15,7 @@ import { config } from '../config.js';
import { buildFederationHeaders, getOurOrigin } from '../utils/federationAuth.js';
import { extractDomain } from './federation.js';
import path from 'path';
import { normalizeProfileStyle } from '@backspace/shared/src/profile.js';
/** Validates that a URL is a safe asset URL (relative upload path, bare filename, or http/https) */
export function isValidAssetUrl(url: string | null | undefined): boolean {
@@ -187,7 +188,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
});
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId, profileUpdatedAt, discoverable, showActivity } = request.body;
const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId, profileUpdatedAt, discoverable, showActivity, profileStyle } = request.body;
const db = getDb();
const updateData: Record<string, string | null | undefined> = {};
@@ -426,12 +427,22 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
(updateData as Record<string, unknown>).showActivity = showActivity ? 1 : 0;
}
// Profile style is normalised, never rejected: the shared normaliser
// clamps lengths, drops bad URLs and falls back on unknown ids, so the
// stored value is always well-formed. Null resets to defaults. Kept out of
// DURABLE_PROFILE_FIELDS on purpose — peers on older builds do not know
// the field, and it is cosmetic, so it stays local to this instance.
if (profileStyle !== undefined) {
(updateData as Record<string, unknown>).profileStyle =
profileStyle === null ? null : JSON.stringify(normalizeProfileStyle(profileStyle));
}
if (Object.keys(updateData).length === 0) {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
}
// Detect profile changes and stamp timestamp
const hasProfileChange = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus'].some(f => f in updateData);
const hasProfileChange = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus', 'profileStyle'].some(f => f in updateData);
const hasDurableChange = DURABLE_PROFILE_FIELDS.some(f => f in updateData);
if (hasDurableChange) {
(updateData as Record<string, unknown>).profileUpdatedAt = Date.now();
+36
View File
@@ -0,0 +1,36 @@
import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from './snowflake.js';
import type { AuditAction } from '@backspace/shared/src/audit.js';
interface RecordAuditEventInput {
spaceId: string;
actorId: string | null;
action: AuditAction;
targetType?: string | null;
targetId?: string | null;
metadata?: Record<string, unknown> | null;
}
/**
* Appends one entry to a space's audit log.
*
* Never throws: an audit write must not be able to fail the action it is
* describing. A moderator kicking someone must not see the kick fail because
* the log could not be written — the kick already happened.
*/
export function recordAuditEvent(input: RecordAuditEventInput): void {
try {
getDb().insert(schema.auditEvents).values({
id: generateSnowflake(),
spaceId: input.spaceId,
actorId: input.actorId,
action: input.action,
targetType: input.targetType ?? null,
targetId: input.targetId ?? null,
metadata: input.metadata ? JSON.stringify(input.metadata) : null,
createdAt: Date.now(),
}).run();
} catch (err) {
console.warn('[audit] failed to record event', input.action, err);
}
}
+8
View File
@@ -1,6 +1,11 @@
import type { User, ReplicatedInstance } from '@backspace/shared';
import { normalizeProfileStyle } from '@backspace/shared/src/profile.js';
import { schema } from '../db/index.js';
function safeParse(json: string): unknown {
try { return JSON.parse(json); } catch { return null; }
}
export function sanitizeUser(row: typeof schema.users.$inferSelect, isSelf = false): User {
// Tombstoned (deleted) users — return anonymized profile
if (row.isDeleted === 1) {
@@ -54,6 +59,9 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect, isSelf = fal
homeInstance: row.homeInstance ?? null,
homeUserId: row.homeUserId ?? null,
replicatedInstances,
// Re-normalised on read as well: a row written by an older build (or a
// stricter future one) still comes out complete and bounded.
profileStyle: row.profileStyle ? normalizeProfileStyle(safeParse(row.profileStyle)) : null,
...(isSelf
? {
showActivity: row.showActivity !== 0,
@@ -0,0 +1,65 @@
import { and, eq, isNull, sql } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from './snowflake.js';
/**
* Opens a session when someone joins a voice room.
*
* Never throws: statistics must not be able to break a call. Closes any
* dangling session for the same user first — the one-room-per-user invariant
* means a second open row would be a bookkeeping error, not two real calls.
*/
export function openVoiceSession(input: {
spaceId: string | null;
channelId: string;
userId: string;
}): void {
try {
const db = getDb();
closeVoiceSession(input.userId);
db.insert(schema.voiceSessions).values({
id: generateSnowflake(),
spaceId: input.spaceId,
channelId: input.channelId,
userId: input.userId,
startedAt: Date.now(),
endedAt: null,
}).run();
} catch (err) {
console.warn('[voice-sessions] failed to open session', err);
}
}
/** Closes the user's open session, if any. Never throws. */
export function closeVoiceSession(userId: string): void {
try {
getDb().update(schema.voiceSessions)
.set({ endedAt: Date.now() })
.where(and(eq(schema.voiceSessions.userId, userId), isNull(schema.voiceSessions.endedAt)))
.run();
} catch (err) {
console.warn('[voice-sessions] failed to close session', err);
}
}
/**
* Closes sessions left open by a crash or restart.
*
* Their real end time is unknowable. Ending them at `startedAt` — a zero-length
* session — discards that time rather than inventing it: crediting the gap
* would silently hand someone hours they never spent, and the numbers are the
* entire point of keeping this table.
*/
export function closeOrphanedVoiceSessions(): void {
try {
const result = getDb().update(schema.voiceSessions)
.set({ endedAt: sql`${schema.voiceSessions.startedAt}` })
.where(isNull(schema.voiceSessions.endedAt))
.run();
if (result.changes > 0) {
console.log(`[voice-sessions] closed ${result.changes} session(s) orphaned by a restart`);
}
} catch (err) {
console.warn('[voice-sessions] failed to close orphans', err);
}
}
+66 -3
View File
@@ -168,6 +168,10 @@ export function handleClientEvent(
case 'voice_join':
handleVoiceJoin(event, userId, ws);
break;
case 'soundboard_play':
handleSoundboardPlay(event, userId);
break;
case 'voice_leave':
handleVoiceLeave(userId);
break;
@@ -470,12 +474,23 @@ function validateActivities(raw: unknown): Activity[] | null {
if (ts.start !== undefined || ts.end !== undefined) activity.timestamps = ts;
}
// Preserved through validation: without it the paused flag is stripped on
// its way to everyone else, and the block resumes ticking on their screens.
if (obj.paused === true) activity.paused = true;
if (obj.assets && typeof obj.assets === 'object') {
const aObj = obj.assets as Record<string, unknown>;
const assets: ActivityAssets = {};
if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.largeImage = aObj.largeImage;
// Image assets are rendered as <img src> by clients, so they get the same
// scheme check `url` above already has. Without it a client could point
// them at a host it controls and harvest the IP of everyone who opens
// that profile — and data: URIs would smuggle payloads through a field
// only length-checked.
if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH
&& isHttpUrl(aObj.largeImage)) assets.largeImage = aObj.largeImage;
if (typeof aObj.largeText === 'string' && aObj.largeText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.largeText = aObj.largeText;
if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.smallImage = aObj.smallImage;
if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH
&& isHttpUrl(aObj.smallImage)) assets.smallImage = aObj.smallImage;
if (typeof aObj.smallText === 'string' && aObj.smallText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.smallText = aObj.smallText;
if (Object.keys(assets).length > 0) activity.assets = assets;
}
@@ -485,6 +500,10 @@ function validateActivities(raw: unknown): Activity[] | null {
return validated;
}
function isHttpUrl(value: string): boolean {
return value.startsWith('https://') || value.startsWith('http://');
}
function handlePresenceUpdate(event: Record<string, unknown>, userId: string): void {
const status = event.status as string;
@@ -713,12 +732,14 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string, ws: Web
// Join room
connectionManager.joinRoom(channelId, userId);
// Broadcast join
// Broadcast join. Carries when this occupancy began so late joiners show the
// call's real elapsed time rather than counting from their own arrival.
connectionManager.sendToRoom(channelId, {
type: 'voice_state_update',
channelId,
userId,
action: 'join',
startedAt: connectionManager.getRoomStartedAt(channelId) ?? undefined,
});
// Also broadcast current voice status if it exists (persisted during moves)
@@ -783,6 +804,48 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string, ws: Web
}
}
/**
* Minimum gap between one person's soundboard triggers.
*
* Enforced on the server: a client-side cooldown only slows down people who
* are not trying to abuse it, and a soundboard is the easiest thing in a chat
* app to turn into a weapon.
*/
const SOUNDBOARD_COOLDOWN_MS = 2000;
const lastSoundboardPlay = new Map<string, number>();
function handleSoundboardPlay(event: Record<string, unknown>, userId: string): void {
const soundId = event.soundId;
if (typeof soundId !== 'string' || !soundId) return;
const now = Date.now();
const last = lastSoundboardPlay.get(userId) ?? 0;
if (now - last < SOUNDBOARD_COOLDOWN_MS) return;
// Must be in a voice room: a soundboard is something you press while in a
// call, not a way to make noise in a call you are not part of.
const userRoom = connectionManager.getUserRoom(userId);
if (!userRoom || userRoom.room.roomType !== 'space') return;
const sound = getDb().select().from(schema.soundboardSounds)
.where(eq(schema.soundboardSounds.id, soundId)).get();
if (!sound) return;
// And the sound must belong to the space whose call they are in.
const meta = userRoom.room.metadata as SpaceRoomMeta;
if (sound.spaceId !== meta.spaceId) return;
lastSoundboardPlay.set(userId, now);
connectionManager.sendToRoomParticipants(userRoom.roomId, {
type: 'soundboard_played',
soundId: sound.id,
userId,
name: sound.name,
filename: sound.filename,
});
}
function handleVoiceLeave(userId: string): void {
connectionManager.clearVoiceWs(userId);
const left = connectionManager.leaveCurrentRoom(userId);
+50 -2
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from 'fastify';
import type { WebSocket } from 'ws';
import { verifyJwt } from '../utils/auth.js';
import { openVoiceSession, closeVoiceSession } from '../utils/voiceSessions.js';
import { getDb, schema } from '../db/index.js';
import { eq, and, or, inArray, isNull, desc, sql } from 'drizzle-orm';
import { handleClientEvent } from './events.js';
@@ -414,6 +415,7 @@ class ConnectionManager {
type: 'space_voice_state',
spaceId,
voiceStates: snapshot.voiceStates,
voiceRoomStarts: snapshot.voiceRoomStarts,
voiceUserStates: snapshot.voiceUserStates,
spaceVoiceStates: snapshot.spaceVoiceStates,
});
@@ -441,11 +443,13 @@ class ConnectionManager {
*/
buildSpaceVoiceState(spaceId: string, userId: string): {
voiceStates: Record<string, string[]>;
voiceRoomStarts: Record<string, number>;
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
} {
const db = getDb();
const voiceStates: Record<string, string[]> = {};
const voiceRoomStarts: Record<string, number> = {};
const voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = {};
const spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> = {};
@@ -462,6 +466,8 @@ class ConnectionManager {
if (participants.size > 0) {
const ids = Array.from(participants);
voiceStates[ch.id] = ids;
const startedAt = this.getRoomStartedAt(ch.id);
if (startedAt !== null) voiceRoomStarts[ch.id] = startedAt;
for (const uid of ids) {
const status = this.getVoiceUserStatus(uid);
if (status) voiceUserStates[uid] = status;
@@ -499,7 +505,7 @@ class ConnectionManager {
}
}
return { voiceStates, voiceUserStates, spaceVoiceStates };
return { voiceStates, voiceRoomStarts, voiceUserStates, spaceVoiceStates };
}
// ─── Unified VoiceRoom API ─────────────────────────────────────────────────
@@ -714,6 +720,16 @@ class ConnectionManager {
room.participants.add(userId);
this.userToRoom.set(userId, roomId);
// Recorded here rather than at the seven call sites that lead into voice:
// every path — join, move, DM call, reconnect — funnels through this
// method, so hooking it cannot miss one.
openVoiceSession({
spaceId: room.roomType === 'space' ? (room.metadata as SpaceRoomMeta).spaceId : null,
channelId: roomId,
userId,
});
return room;
}
@@ -746,6 +762,8 @@ class ConnectionManager {
const room = this.leaveRoom(roomId, userId);
if (!room) return null;
closeVoiceSession(userId);
return { roomId, room };
}
@@ -757,6 +775,9 @@ class ConnectionManager {
const displaced: string[] = [];
for (const userId of room.participants) {
this.userToRoom.delete(userId);
// Destroying a room bypasses leaveCurrentRoom, so these sessions would
// otherwise stay open until the next restart swept them away.
closeVoiceSession(userId);
displaced.push(userId);
}
@@ -960,6 +981,30 @@ class ConnectionManager {
}
}
/**
* When the current occupancy of a room began. Null when nobody is in it —
* empty space rooms are destroyed, which is what makes the call timer reset
* once the last person leaves.
*/
getRoomStartedAt(roomId: string): number | null {
return this.voiceRooms.get(roomId)?.startedAt ?? null;
}
/**
* Send only to the people actually inside a room.
*
* Distinct from `sendToRoom`, which fans a space room out to the whole
* space — right for presence updates the sidebar shows, wrong for anything
* audible: a soundboard clip must reach the call, not everyone online.
*/
sendToRoomParticipants(roomId: string, event: ServerEvent): void {
const room = this.voiceRooms.get(roomId);
if (!room) return;
for (const userId of room.participants) {
this.sendToUser(userId, event);
}
}
/** Send to all connections of all online users. */
sendToAll(event: ServerEvent, excludeUserId?: string): void {
const message = JSON.stringify(event);
@@ -1073,7 +1118,7 @@ class ConnectionManager {
if (connections.size === 0) return;
const readyData = buildReadyPayload(userId);
const message = JSON.stringify({ type: 'ready', ...readyData });
const message = JSON.stringify({ type: 'ready', serverTime: Date.now(), ...readyData });
for (const ws of connections) {
if (ws.readyState === 1) {
ws.send(message);
@@ -1747,6 +1792,9 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
const readyData = buildReadyPayload(userId);
ws.send(JSON.stringify({
type: 'ready',
// Lets each client measure its own offset from this server, so activity
// timestamps computed here render correctly on a machine whose clock drifts.
serverTime: Date.now(),
...readyData,
}));
+7 -1
View File
@@ -14,7 +14,13 @@
"./src/activities": "./src/activities.ts",
"./src/activities.js": "./src/activities.ts",
"./src/constants": "./src/constants.ts",
"./src/constants.js": "./src/constants.ts"
"./src/constants.js": "./src/constants.ts",
"./src/audit": "./src/audit.ts",
"./src/audit.js": "./src/audit.ts",
"./src/mentions": "./src/mentions.ts",
"./src/mentions.js": "./src/mentions.ts",
"./src/profile": "./src/profile.ts",
"./src/profile.js": "./src/profile.ts"
},
"scripts": {
"build": "tsc",
+44
View File
@@ -0,0 +1,44 @@
/**
* Audit action vocabulary, shared so the server writes and the client renders
* the same strings. Values are stored in the database, so renaming one
* rewrites history — add new actions instead.
*/
export const AUDIT_ACTIONS = [
'space.update',
'space.transfer_ownership',
'channel.create',
'channel.update',
'channel.delete',
'member.kick',
'member.leave',
'member.ban',
'member.unban',
'role.create',
'role.update',
'role.delete',
'invite.create',
'message.delete',
] as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
export interface AuditEventActor {
id: string;
username: string;
displayName: string | null;
avatar: string | null;
}
export interface AuditEvent {
id: string;
spaceId: string;
action: AuditAction;
actor: AuditEventActor | null;
targetType: string | null;
targetId: string | null;
/** Shape depends on `action`; used for display only. */
metadata: Record<string, unknown> | null;
createdAt: number;
}
export const AUDIT_PAGE_SIZE = 50;
+17
View File
@@ -45,3 +45,20 @@ export const GROUP_DM_NAME_MAX_LENGTH = 50;
export const GROUP_DM_NAME_MIN_LENGTH = 1;
export const GROUP_DM_ICON_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
export const GROUP_DM_ICON_MIME_PREFIX = 'image/';
// ─── Channel Names ───────────────────────────────────────────────────────────
export const CHANNEL_NAME_MIN_LENGTH = 1;
export const CHANNEL_NAME_MAX_LENGTH = 100;
/**
* Canonical channel-name form: trimmed, lowercased, runs of whitespace folded
* into single hyphens.
*
* The server applies this before storing, so the client uses the same function
* to show what a name will actually become. Two copies of the rule would drift;
* one that lives here cannot.
*/
export function normalizeChannelName(name: string): string {
return name.trim().toLowerCase().replace(/\s+/g, '-');
}
+43
View File
@@ -0,0 +1,43 @@
/**
* The @everyone mention.
*
* It rides the existing `<@id>` token shape — `everyone` matches the same
* `[a-zA-Z0-9_-]+` the user-mention parser already accepts — so the markdown
* pre-processor, the inline renderer and the badge all pick it up without a
* second syntax. User ids are snowflakes (digits only), so the literal
* `everyone` can never collide with a real one.
*/
export const EVERYONE_MENTION_ID = 'everyone';
export const EVERYONE_MENTION_TOKEN = `<@${EVERYONE_MENTION_ID}>`;
/** Matches code spans and fences first, so their contents are never a ping. */
const CODE_OR_EVERYONE = /(```[\s\S]*?```|`[^`\n]*`)|<@everyone>/g;
/**
* Whether a message pings everyone.
*
* A token inside a code span or fenced block does not count: people paste
* message payloads and snippets into chat, and a paste should not ring
* everybody's client.
*/
export function mentionsEveryone(content: string | null | undefined): boolean {
if (!content) return false;
CODE_OR_EVERYONE.lastIndex = 0;
for (let m = CODE_OR_EVERYONE.exec(content); m; m = CODE_OR_EVERYONE.exec(content)) {
if (!m[1]) return true; // matched the token itself, not a code span
}
return false;
}
/**
* Neutralises the token into plain text, leaving code spans untouched.
*
* The server applies this when the author lacks MENTION_EVERYONE. Rejecting the
* message instead would lose what they wrote over a formatting detail; this way
* the message still posts, and reads as the literal text they typed.
*/
export function stripEveryoneMention(content: string): string {
return content.replace(CODE_OR_EVERYONE, (match, codeBlock: string | undefined) =>
codeBlock ? codeBlock : '@everyone',
);
}
+5
View File
@@ -16,6 +16,7 @@ export const PermissionBits = {
ATTACH_FILES: 1n << 12n,
READ_MESSAGE_HISTORY: 1n << 13n,
ADD_REACTIONS: 1n << 14n,
MENTION_EVERYONE: 1n << 15n,
CONNECT: 1n << 20n,
SPEAK: 1n << 21n,
MUTE_MEMBERS: 1n << 22n,
@@ -38,6 +39,10 @@ export const DEFAULT_EVERYONE_PERMISSIONS =
PermissionBits.ATTACH_FILES |
PermissionBits.READ_MESSAGE_HISTORY |
PermissionBits.ADD_REACTIONS |
// Granted by default: on a small instance the ping is a convenience, not a
// weapon. An owner who disagrees can deny it on @everyone and grant it to a
// role instead — which is why it is a bit rather than being ungated.
PermissionBits.MENTION_EVERYONE |
PermissionBits.STREAM;
// ─── Helpers ────────────────────────────────────────────────────────────────
+299
View File
@@ -0,0 +1,299 @@
/**
* Profile identity: everything a user can style about how they appear, beyond
* the avatar/banner/bio fields that predate it.
*
* Stored as one JSON column (`users.profile_style`) rather than a dozen
* columns: the shape will keep growing (new effects, new widgets), every field
* is optional, and nothing here is ever queried by value. The server
* normalises on write with `normalizeProfileStyle`, so what reaches clients is
* always a complete, bounded, well-formed object.
*
* Catalog ids are strings on purpose — a client that does not know an id
* (older build, other instance) falls back to `none`/`default` instead of
* crashing on an enum it has never seen.
*/
export const NAME_FONTS = [
'default', 'display', 'comic', 'script', 'pixel', 'future', 'serif', 'mono', 'round', 'marker',
'techno', 'block', 'retro',
] as const;
export type NameFont = (typeof NAME_FONTS)[number];
export const NAME_EFFECTS = [
'solid', 'gradient', 'chrome', 'gold', 'holo', 'neon', 'toon', 'pop', 'gummy',
'prism', 'glitch', 'fire', 'ice', 'shadow', 'outline', 'rainbow',
] as const;
export type NameEffect = (typeof NAME_EFFECTS)[number];
export const AVATAR_DECORATIONS = [
'none', 'ring', 'neon', 'cd', 'chain', 'cyber', 'pixel', 'flames', 'lightning', 'spikes',
'frost', 'stars', 'hearts', 'butterfly', 'sakura', 'vines', 'bubbles', 'crown', 'halo', 'flower',
] as const;
export type AvatarDecoration = (typeof AVATAR_DECORATIONS)[number];
export const PROFILE_EFFECTS = [
'none', 'sparkles', 'rain', 'snow', 'matrix', 'confetti', 'aurora', 'embers', 'bubbles', 'hearts',
] as const;
export type ProfileEffect = (typeof PROFILE_EFFECTS)[number];
export const NAMEPLATES = [
'none', 'sunset', 'ocean', 'neon', 'forest', 'royal', 'brasil', 'cyber', 'blossom', 'midnight',
'holo', 'chrome', 'vapor', 'matrix', 'ember', 'custom',
] as const;
export type Nameplate = (typeof NAMEPLATES)[number];
export interface NameStyle {
font: NameFont;
effect: NameEffect;
/** Two hex colours. Solid uses the first; every other effect uses both. */
colors: [string, string];
}
/**
* Where the banner image sits inside its frame.
*
* A banner is cropped by the frame, and until now the crop was always dead
* centre — fine for a photo shot for it, wrong for anything else, and there was
* no way to fix it for a GIF, which cannot go through the still-image cropper.
* `x`/`y` are CSS object-position percentages; `zoom` scales the image inside
* the frame, so together they are a pan-and-zoom crop that costs nothing at
* render time and keeps the original file untouched.
*/
export interface BannerPosition {
/** 0–100. 50 is centred. */
x: number;
y: number;
/** 100–300 (%). 100 fills the frame. */
zoom: number;
}
export interface ProfileTheme {
/** Two hex colours behind the card and the banner fallback. */
colors: [string, string];
}
export interface ProfileTag {
/** 2–4 characters, shown uppercase in a chip next to the name. */
text: string;
/** Single emoji (grapheme) in front of the text; empty for none. */
emoji: string;
color: string;
}
export interface ProfileLink {
label: string;
url: string;
}
export interface GameEntry {
/** Steam app id when picked from search; null for a free-typed title. */
appId: number | null;
name: string;
/** https URL of a cover image (Steam CDN when picked from search). */
cover: string | null;
note: string;
tags: string[];
}
export interface ProfileQuote {
text: string;
author: string;
}
export interface ProfileWidgets {
favoriteGame: GameEntry | null;
rotation: GameEntry[];
wishlist: GameEntry[];
quote: ProfileQuote | null;
}
export interface ProfileStyle {
name: NameStyle;
theme: ProfileTheme | null;
banner: BannerPosition;
decoration: AvatarDecoration;
effect: ProfileEffect;
nameplate: Nameplate;
/** Only read when nameplate === 'custom'. */
nameplateColors: [string, string];
pronouns: string;
tag: ProfileTag | null;
links: ProfileLink[];
widgets: ProfileWidgets;
}
export const PROFILE_LIMITS = {
pronouns: 40,
tagMin: 2,
tagMax: 4,
links: 5,
linkLabel: 24,
linkUrl: 200,
gameName: 80,
gameNote: 80,
gameTags: 3,
gameTag: 16,
rotation: 5,
wishlist: 10,
quoteText: 140,
quoteAuthor: 40,
} as const;
export const DEFAULT_BANNER_POSITION: BannerPosition = { x: 50, y: 50, zoom: 100 };
export const DEFAULT_PROFILE_STYLE: ProfileStyle = {
name: { font: 'default', effect: 'solid', colors: ['#efefef', '#7c6cf6'] },
theme: null,
banner: DEFAULT_BANNER_POSITION,
decoration: 'none',
effect: 'none',
nameplate: 'none',
nameplateColors: ['#7c6cf6', '#f472b6'],
pronouns: '',
tag: null,
links: [],
widgets: { favoriteGame: null, rotation: [], wishlist: [], quote: null },
};
// ─── Normalisation ────────────────────────────────────────────────────────────
const HEX = /^#[0-9a-fA-F]{6}$/;
/** Domains whose images a profile may embed. Anything else is dropped, not
* proxied — a profile is not a place to load arbitrary third-party pixels. */
const COVER_HOSTS = ['cdn.cloudflare.steamstatic.com', 'cdn.akamai.steamstatic.com', 'shared.cloudflare.steamstatic.com', 'shared.akamai.steamstatic.com', 'steamcdn-a.akamaihd.net'];
/**
* This package compiles without the DOM or Node type libraries, so the `URL`
* global has no type here even though it exists at runtime everywhere this
* code runs (browsers, Node ≥ 10). Reach it through globalThis with a minimal
* structural type rather than pulling a whole lib into a types-only package.
*/
interface UrlLike { protocol: string; hostname: string; toString(): string }
const UrlCtor = (globalThis as unknown as { URL: new (s: string) => UrlLike }).URL;
function parseUrl(value: string): UrlLike | null {
try { return new UrlCtor(value); } catch { return null; }
}
function pick<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : fallback;
}
function hex(value: unknown, fallback: string): string {
return typeof value === 'string' && HEX.test(value) ? value.toLowerCase() : fallback;
}
/** Clamps to a range, falling back when the value is not a finite number. */
function num(value: unknown, min: number, max: number, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value * 10) / 10));
}
function str(value: unknown, max: number): string {
return typeof value === 'string' ? value.trim().slice(0, max) : '';
}
function httpsUrl(value: unknown, max: number): string | null {
if (typeof value !== 'string') return null;
const v = value.trim();
if (v.length === 0 || v.length > max) return null;
const u = parseUrl(v);
return u && u.protocol === 'https:' ? u.toString() : null;
}
function coverUrl(value: unknown): string | null {
const u = httpsUrl(value, 300);
if (!u) return null;
const parsed = parseUrl(u);
return parsed && COVER_HOSTS.includes(parsed.hostname) ? u : null;
}
/** First grapheme only — one emoji, not a sentence hidden in the tag. */
function oneGrapheme(value: unknown): string {
if (typeof value !== 'string') return '';
const s = value.trim();
if (!s) return '';
const seg = (Intl as unknown as { Segmenter?: new (l: string, o: { granularity: string }) => { segment: (s: string) => Iterable<{ segment: string }> } }).Segmenter;
if (seg) {
for (const g of new seg('und', { granularity: 'grapheme' }).segment(s)) return g.segment;
}
return Array.from(s)[0] ?? '';
}
function game(value: unknown): GameEntry | null {
if (!value || typeof value !== 'object') return null;
const v = value as Record<string, unknown>;
const name = str(v.name, PROFILE_LIMITS.gameName);
if (!name) return null;
const appId = typeof v.appId === 'number' && Number.isInteger(v.appId) && v.appId > 0 ? v.appId : null;
const tags = Array.isArray(v.tags)
? v.tags.map((t) => str(t, PROFILE_LIMITS.gameTag)).filter(Boolean).slice(0, PROFILE_LIMITS.gameTags)
: [];
return { appId, name, cover: coverUrl(v.cover), note: str(v.note, PROFILE_LIMITS.gameNote), tags };
}
function games(value: unknown, max: number): GameEntry[] {
return Array.isArray(value) ? value.map(game).filter((g): g is GameEntry => g !== null).slice(0, max) : [];
}
/**
* Coerces any input into a valid ProfileStyle. Never throws: unknown ids fall
* back to defaults, over-long strings are cut, bad colours are replaced, bad
* URLs are dropped. The server stores exactly this output.
*/
export function normalizeProfileStyle(input: unknown): ProfileStyle {
const d = DEFAULT_PROFILE_STYLE;
const v = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>;
const name = (v.name && typeof v.name === 'object' ? v.name : {}) as Record<string, unknown>;
const nameColors = Array.isArray(name.colors) ? name.colors : [];
const theme = (v.theme && typeof v.theme === 'object' ? v.theme : null) as Record<string, unknown> | null;
const themeColors = theme && Array.isArray(theme.colors) ? theme.colors : null;
const npColors = Array.isArray(v.nameplateColors) ? v.nameplateColors : [];
const banner = (v.banner && typeof v.banner === 'object' ? v.banner : {}) as Record<string, unknown>;
const tag = (v.tag && typeof v.tag === 'object' ? v.tag : null) as Record<string, unknown> | null;
const tagText = tag ? str(tag.text, PROFILE_LIMITS.tagMax).toUpperCase().replace(/[^A-Z0-9]/g, '') : '';
const widgets = (v.widgets && typeof v.widgets === 'object' ? v.widgets : {}) as Record<string, unknown>;
const quote = (widgets.quote && typeof widgets.quote === 'object' ? widgets.quote : null) as Record<string, unknown> | null;
const quoteText = quote ? str(quote.text, PROFILE_LIMITS.quoteText) : '';
const links: ProfileLink[] = Array.isArray(v.links)
? v.links.map((l) => {
const o = (l && typeof l === 'object' ? l : {}) as Record<string, unknown>;
const url = httpsUrl(o.url, PROFILE_LIMITS.linkUrl);
if (!url) return null;
const label = str(o.label, PROFILE_LIMITS.linkLabel) || (parseUrl(url)?.hostname ?? url).replace(/^www\./, '');
return { label, url };
}).filter((l): l is ProfileLink => l !== null).slice(0, PROFILE_LIMITS.links)
: [];
return {
name: {
font: pick(name.font, NAME_FONTS, d.name.font),
effect: pick(name.effect, NAME_EFFECTS, d.name.effect),
colors: [hex(nameColors[0], d.name.colors[0]), hex(nameColors[1], d.name.colors[1])],
},
theme: themeColors ? { colors: [hex(themeColors[0], '#7c6cf6'), hex(themeColors[1], '#0b0b10')] } : null,
banner: {
x: num(banner.x, 0, 100, d.banner.x),
y: num(banner.y, 0, 100, d.banner.y),
zoom: num(banner.zoom, 100, 300, d.banner.zoom),
},
decoration: pick(v.decoration, AVATAR_DECORATIONS, d.decoration),
effect: pick(v.effect, PROFILE_EFFECTS, d.effect),
nameplate: pick(v.nameplate, NAMEPLATES, d.nameplate),
nameplateColors: [hex(npColors[0], d.nameplateColors[0]), hex(npColors[1], d.nameplateColors[1])],
pronouns: str(v.pronouns, PROFILE_LIMITS.pronouns),
tag: tagText.length >= PROFILE_LIMITS.tagMin
? { text: tagText, emoji: oneGrapheme(tag?.emoji), color: hex(tag?.color, '#7c6cf6') }
: null,
links,
widgets: {
favoriteGame: game(widgets.favoriteGame),
rotation: games(widgets.rotation, PROFILE_LIMITS.rotation),
wishlist: games(widgets.wishlist, PROFILE_LIMITS.wishlist),
quote: quoteText ? { text: quoteText, author: str(quote?.author, PROFILE_LIMITS.quoteAuthor) } : null,
},
};
}
/** Whether a style differs from the defaults — used to skip rendering work. */
export function isDefaultProfileStyle(s: ProfileStyle | null | undefined): boolean {
return !s || JSON.stringify(s) === JSON.stringify(DEFAULT_PROFILE_STYLE);
}
/** Steam's poster-format cover for an app id. */
export function steamCoverUrl(appId: number): string {
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/library_600x900.jpg`;
}
+23 -3
View File
@@ -29,6 +29,8 @@ export interface User {
showActivity?: boolean;
/** Self-view only: this federated account's home instance was reset/lost — it now operates as a sovereign local account (detach spec). */
federationHomeOrphaned?: boolean;
/** Name style, decorations, effects, tag, links, widgets. See shared/profile.ts. Null = defaults. */
profileStyle?: import('./profile').ProfileStyle | null;
}
export interface ReplicatedInstance {
@@ -212,6 +214,11 @@ export interface Message {
type?: 'user' | 'system';
editedAt: number | null;
createdAt: number;
/** Epoch millis when pinned; null when not pinned. */
pinnedAt?: number | null;
pinnedBy?: string | null;
/** Figurinha enviada como mensagem; o conteúdo fica vazio nesse caso. */
stickerId?: string | null;
}
export interface MessageWithUser extends Message {
@@ -369,6 +376,12 @@ export interface ActivityAssets {
export interface Activity {
type: ActivityType;
name: string;
/**
* Playback is paused. Kept as a state rather than dropping the activity:
* pausing a track used to remove the block entirely, so it vanished and
* reappeared on every pause and every gap between songs.
*/
paused?: boolean;
details?: string;
state?: string;
timestamps?: ActivityTimestamps;
@@ -403,6 +416,7 @@ export type ClientEvent =
| { type: 'typing_start'; channelId: string }
| { type: 'presence_update'; status: 'online' | 'idle' | 'dnd' }
| { type: 'voice_join'; channelId: string }
| { type: 'soundboard_play'; soundId: string }
| { type: 'voice_leave' }
| { type: 'dm_message_create'; dmChannelId: string; content?: string; attachments?: string[]; replyToId?: string }
| { type: 'dm_typing_start'; dmChannelId: string }
@@ -426,13 +440,15 @@ export type ClientEvent =
// Server → Client Events
export type ServerEvent =
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }>; userActivities?: Record<string, Activity[]>; rejectedPeerOrigins?: string[]; awaitingApprovalPeerOrigins?: string[]; activePeerOrigins?: string[]; pendingApprovalCount?: number }
| { type: 'ready'; serverTime?: number; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceRoomStarts?: Record<string, number>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }>; userActivities?: Record<string, Activity[]>; rejectedPeerOrigins?: string[]; awaitingApprovalPeerOrigins?: string[]; activePeerOrigins?: string[]; pendingApprovalCount?: number }
| { type: 'message_created'; message: MessageWithUser }
| { type: 'message_updated'; message: MessageWithUser }
| { type: 'message_deleted'; messageId: string; channelId: string }
| { type: 'typing'; channelId: string; userId: string; username: string }
| { type: 'presence_update'; userId: string; status: string; activities?: Activity[] }
| { type: 'voice_state_update'; channelId: string; userId: string; action: 'join' | 'leave' }
| { type: 'voice_state_update'; channelId: string; userId: string; action: 'join' | 'leave'; startedAt?: number }
| { type: 'soundboard_played'; soundId: string; userId: string; name: string; filename: string }
| { type: 'message_pinned'; channelId: string; messageId: string; pinned: boolean }
| { type: 'member_joined'; spaceId: string; member: MemberWithUser }
| { type: 'member_left'; spaceId: string; userId: string }
| { type: 'dm_message_created'; message: DmMessageWithUser }
@@ -451,7 +467,7 @@ export type ServerEvent =
| { type: 'dm_call_ended'; dmChannelId: string }
| { type: 'dm_call_undeliverable'; dmChannelId: string | null; federatedCallId: string; terminal: boolean; phase: DmCallPhase; failures: DmCallUndeliverableFailure[] }
| { type: 'voice_status_update'; userId: string; channelId: string; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }
| { type: 'space_voice_state'; spaceId: string; voiceStates: Record<string, string[]>; voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> }
| { type: 'space_voice_state'; spaceId: string; voiceStates: Record<string, string[]>; voiceRoomStarts?: Record<string, number>; voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> }
| { type: 'dm_channel_created'; dmChannel: DmChannel }
| { type: 'dm_channel_closed'; dmChannelId: string }
| { type: 'dm_channel_updated'; dmChannelId: string; name: string | null; icon: string | null }
@@ -577,6 +593,8 @@ export interface UpdateUserRequest {
profileUpdatedAt?: number;
discoverable?: boolean;
showActivity?: boolean;
/** Normalised server-side; pass null to reset to defaults. */
profileStyle?: import('./profile').ProfileStyle | null;
}
export interface UpdateMemberRequest {
@@ -587,6 +605,8 @@ export interface CreateMessageRequest {
content: string;
attachments?: string[];
replyToId?: string;
/** Envio de figurinha; nesse caso `content` fica vazio. */
stickerId?: string;
}
export interface UpdateMessageRequest {
+3 -3
View File
@@ -4,15 +4,15 @@
<meta charset="UTF-8" />
<meta name="color-scheme" content="dark" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="description" content="The self-hosted Discord and TeamSpeak alternative. HD voice, video, and screen share — open source and free (AGPL-3.0)." />
<meta name="description" content="Resenhacord — o lugar da resenha. Voz, vídeo e tela em HD, num servidor só nosso. Fork do Backspace, código aberto (AGPL-3.0)." />
<meta name="theme-color" content="#0b0b10" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Backspace" />
<meta name="apple-mobile-web-app-title" content="Resenhacord" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<title>Backspace</title>
<title>Resenhacord</title>
</head>
<body class="bg-surface-base text-txt-primary">
<div id="root"></div>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6
View File
@@ -91,3 +91,9 @@ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
---
Display fonts for profile name styles (latin subsets, Google Fonts builds), all
licensed under the SIL Open Font License 1.1 above:
Bangers, Cinzel, Fredoka, Orbitron, Pacifico, Permanent Marker, Playfair Display,
Press Start 2P, Rubik Mono One, Space Mono.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More