28 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
189 changed files with 26342 additions and 1254 deletions
+44 -6
View File
@@ -153,14 +153,38 @@ jobs:
# 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
body "$R" | jq -r '.[] | "\(.id) \(.name)"' | while read -r aid aname; do
case "$aname" in
${{ runner.os == 'Windows' && '*.exe|latest.yml' || '*.AppImage|*.deb|latest-linux.yml' }})
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"
api DELETE "/releases/$ID/assets/$aid" > /dev/null ;;
esac
D=$(api DELETE "/releases/$ID/assets/$aid")
[ "$(code "$D")" = "204" ] || echo "::warning::falha ao remover $aname — HTTP $(code "$D")"
done
fi
@@ -181,3 +205,17 @@ jobs:
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
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

+77 -10
View File
@@ -21,11 +21,29 @@ código e os commits seguem em inglês, como o resto do repositório.
| 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`:
@@ -47,12 +65,33 @@ 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 |
| Emojis e stickers do grupo | Média | `UPLOAD_DIR` e o pipeline de upload já existem; falta tabela por espaço e resolução no render de mensagem |
## Regra de idioma (a partir de 2026-08-31)
@@ -72,22 +111,52 @@ fixas por `t('chave')`. O que faltar cai para o inglês sozinho.
- 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
Mensagens e composer · lista de membros · servidores e canais · amigos e DMs ·
modais de convite · configurações restantes · telas de erro
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 |
|---|---|---|
| **Enviar som ao soundboard não funciona no app desktop** (funciona no navegador) | `SoundboardPopover` pede o nome do som com `window.prompt`, que o **Electron não implementa** — não abre nada e devolve vazio, então o fluxo aborta em silêncio, sem erro. `window.prompt` aparece em exatamente um lugar no projeto: esse. O resto do código já o evitava | Trocar por um campo de texto dentro do próprio popover (ou um modal reutilizável). Some o prompt e passa a funcionar igual nos dois. **Vale criar o modal de entrada genérico**, já que não existe nenhum e outras features vão precisar |
| **Eco absurdo ao compartilhar tela com som** (app desktop) | O `loopback` do Electron captura a mistura de saída do sistema **inteiro**, que inclui o próprio Backspace tocando a voz dos outros. Essa voz volta para eles dentro da transmissão, com atraso. Não é eco acústico — é digital, então **fone não resolve**. As constraints do web (`restrictOwnAudio`) nunca chegam: o handler ignora o `_request` e monta o stream a partir do enum. É também por isso que `shareAudio` já vinha desligado por padrão no app | **Implementado (2026-09-01), não testado.** Trocado por `electron-native-screenshare`: captura com isolamento por processo — só a janela compartilhada (include) ou tudo menos este app (exclude). O PCM vai do processo principal ao renderer por IPC e vira faixa publicada no LiveKit. **Exige gerar instalador novo e reinstalar**, e só pode ser confirmado numa máquina Windows |
| **Bloco do Spotify dessincronizado** (mostra a faixa errada por um tempo) | Soma de duas esperas: a consulta roda a cada **20s** (`useSpotifyActivity`) e o envio pelo WebSocket ainda passa por um **debounce de 5s** no `activityStore`. Na pior hipótese os outros veem a música anterior por ~25s | Consultar de novo perto do fim da faixa (a duração é conhecida) em vez de só por intervalo fixo, e encurtar o debounce para esta fonte |
| **Bloco do Spotify some sozinho** | Três caminhos apagam a atividade inteira: faixa **pausada** (`is_playing: false` devolve `null`), o **vão entre faixas** (o Spotify responde `204`) e qualquer falha transitória. Some e volta = a piscada que você viu | Manter a última faixa conhecida por alguns segundos antes de apagar, e enviar um estado *pausado* explícito em vez de sumir com o bloco |
| **Barra de progresso errada / andando pausada** | Duas causas independentes: (1) o progresso é derivado de carimbos calculados com o relógio do **servidor** e desenhado contra o relógio de **quem olha** — se os relógios divergem, a barra fica deslocada; (2) a barra continua avançando localmente depois que a pessoa pausa, até a próxima consulta | Enviar o horário do servidor junto no payload para o cliente corrigir a diferença, e congelar a barra quando o estado for pausado |
## App desktop — já existia (verificado 2026-08-31)
@@ -112,8 +181,6 @@ Escolhidas pelo dono, inspiradas no Discord. **Nenhuma iniciada.**
| Feature | Tamanho | Observação técnica |
|---|---|---|
| **Fixar mensagens no canal** | Pequena | Tabela de fixadas por canal + rota de listagem; permissão natural é `MANAGE_MESSAGES` |
| **Busca com filtros** | Média | `SearchPopover` já existe; falta filtrar por autor, canal e presença de anexo — e índice no banco, senão fica lento quando o histórico crescer |
| **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 |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backspace",
"version": "1.1.0",
"version": "2.1.0",
"private": true,
"description": "Open, self-hosted communication platform \u2014 text, voice, video, and federation",
"license": "AGPL-3.0-only",
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

+2 -2
View File
@@ -1,5 +1,5 @@
appId: com.backspace.desktop
productName: Backspace
productName: Resenhacord
artifactName: "${productName}-${version}-${arch}.${ext}"
directories:
output: dist-electron
@@ -36,7 +36,7 @@ publish:
- provider: generic
url: https://git.resenha.website/devsyncwrld/backspace/releases/download/latest/
protocols:
- name: Backspace
- name: Resenhacord
schemes:
- backspace
mac:
+2 -2
View File
@@ -1,9 +1,9 @@
{
"name": "@backspace/desktop",
"version": "1.1.0",
"version": "2.1.0",
"private": true,
"license": "AGPL-3.0-only",
"description": "Backspace",
"description": "Resenhacord",
"author": {
"name": "Jannis Braun",
"email": "backspace@backspace.chat"
@@ -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

+54 -26
View File
@@ -40,26 +40,33 @@ 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,
});
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);
}
}
}
let mainWindow: BrowserWindow | null = null;
const keybindManager = new KeybindManager();
@@ -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.
@@ -437,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' ? {
@@ -570,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.
@@ -588,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();
@@ -708,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
@@ -743,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
@@ -812,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;
});
@@ -834,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 });
@@ -843,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(),
);
@@ -873,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
@@ -1083,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,
+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,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
@@ -113,6 +113,34 @@
"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
}
]
}
+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)`);
}
}
+54
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', {
@@ -645,3 +665,37 @@ export const soundboardSounds = sqliteTable('soundboard_sounds', {
}, (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),
}));
+4
View File
@@ -19,6 +19,7 @@ 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';
@@ -30,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.
@@ -139,6 +141,7 @@ async function main(): Promise<void> {
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);
@@ -148,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);
+9 -4
View File
@@ -15,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 {
@@ -190,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 });
}
@@ -296,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;
+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: [] });
}
});
}
+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 });
});
}
+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();
+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,
+5 -1
View File
@@ -16,7 +16,11 @@
"./src/constants": "./src/constants.ts",
"./src/constants.js": "./src/constants.ts",
"./src/audit": "./src/audit.ts",
"./src/audit.js": "./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",
+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`;
}
+12
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 {
@@ -441,6 +448,7 @@ export type ServerEvent =
| { type: 'presence_update'; userId: string; status: string; activities?: Activity[] }
| { 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 }
@@ -585,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 {
@@ -595,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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 16 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: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 16 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: 18 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+28 -11
View File
@@ -1,16 +1,33 @@
<?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">
<!--
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: #fff;
}
</style>
<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="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-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="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: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.
+49
View File
@@ -75,6 +75,17 @@ import type {
} from '@backspace/shared';
import type { AuditEvent } from '@backspace/shared/src/audit.js';
export interface SpaceEmoji {
id: string;
spaceId: string;
name: string;
filename: string;
uploaderId: string | null;
createdAt: number;
}
export type SpaceSticker = SpaceEmoji;
export interface SoundboardSound {
id: string;
spaceId: string;
@@ -321,6 +332,25 @@ export class BackspaceApiClient {
space: (spaceId: string, days: number) => Promise<SpaceStats>;
};
readonly expressions: {
emojis: (spaceId: string) => Promise<{ emojis: SpaceEmoji[] }>;
addEmoji: (spaceId: string, name: string, filename: string) => Promise<SpaceEmoji>;
removeEmoji: (id: string) => Promise<void>;
stickers: (spaceId: string) => Promise<{ stickers: SpaceSticker[] }>;
addSticker: (spaceId: string, name: string, filename: string) => Promise<SpaceSticker>;
removeSticker: (id: string) => Promise<void>;
};
readonly games: {
search: (q: string) => Promise<{ hits: { appId: number; name: string; cover: string; header: string }[] }>;
};
readonly pins: {
list: (channelId: string) => Promise<{ messages: MessageWithUser[] }>;
pin: (messageId: string) => Promise<{ success: boolean }>;
unpin: (messageId: string) => Promise<{ success: boolean }>;
};
readonly audit: {
log: (spaceId: string, before?: string) => Promise<{ events: AuditEvent[]; hasMore: boolean }>;
};
@@ -751,6 +781,25 @@ export class BackspaceApiClient {
request<SpaceStats>('GET', `/spaces/${spaceId}/stats?days=${days}`),
};
this.expressions = {
emojis: (spaceId) => request<{ emojis: SpaceEmoji[] }>('GET', `/spaces/${spaceId}/emojis`),
addEmoji: (spaceId, name, filename) => request<SpaceEmoji>('POST', `/spaces/${spaceId}/emojis`, { name, filename }),
removeEmoji: (id) => request<void>('DELETE', `/emojis/${id}`),
stickers: (spaceId) => request<{ stickers: SpaceSticker[] }>('GET', `/spaces/${spaceId}/stickers`),
addSticker: (spaceId, name, filename) => request<SpaceSticker>('POST', `/spaces/${spaceId}/stickers`, { name, filename }),
removeSticker: (id) => request<void>('DELETE', `/stickers/${id}`),
};
this.games = {
search: (q: string) => request<{ hits: { appId: number; name: string; cover: string; header: string }[] }>('GET', `/games/search?q=${encodeURIComponent(q)}`),
};
this.pins = {
list: (channelId: string) => request<{ messages: MessageWithUser[] }>('GET', `/channels/${channelId}/pins`),
pin: (messageId: string) => request<{ success: boolean }>('PUT', `/messages/${messageId}/pin`),
unpin: (messageId: string) => request<{ success: boolean }>('DELETE', `/messages/${messageId}/pin`),
};
this.audit = {
log: (spaceId: string, before?: string) => {
const params = new URLSearchParams();
+26 -23
View File
@@ -7,10 +7,12 @@ import { api, createApiClient } from '../api/client';
import { parseInviteInput } from '../utils/inviteParser';
import { Avatar } from './ui/Avatar';
import type { InvitePreview } from '@backspace/shared';
import { useT } from '../i18n';
type JoinPhase = 'preview' | 'connect' | 'fallback' | 'other-instance' | 'already-member';
export function JoinPage() {
const t = useT();
const { inviteCode: rawInviteCode } = useParams<{ inviteCode: string }>();
const navigate = useNavigate();
const token = useAuthStore((s) => s.token);
@@ -59,13 +61,13 @@ export function JoinPage() {
// Fetch preview on mount
useEffect(() => {
if (!rawInviteCode) {
setPreviewError('No invite code provided');
setPreviewError(t('join.page.noCode'));
setIsLoadingPreview(false);
return;
}
if (!parsed) {
setPreviewError('Invalid invite code');
setPreviewError(t('join.page.invalidCode'));
setIsLoadingPreview(false);
return;
}
@@ -84,7 +86,7 @@ export function JoinPage() {
const data = await client.spaces.invitePreview(parsed.code);
setPreview(data);
} catch (err) {
setPreviewError(err instanceof Error ? err.message : 'Failed to load invite');
setPreviewError(err instanceof Error ? err.message : t('join.page.loadFailed'));
} finally {
setIsLoadingPreview(false);
}
@@ -106,7 +108,7 @@ export function JoinPage() {
setPhase('connect');
setError('');
} else {
const msg = err instanceof Error ? err.message : 'Failed to join space';
const msg = err instanceof Error ? err.message : t('join.page.joinFailed');
if (msg.toLowerCase().includes('already a member')) {
setPhase('already-member');
setError('');
@@ -136,7 +138,7 @@ export function JoinPage() {
setFallbackPassword('');
setError('');
} else {
const msg = err instanceof Error ? err.message : 'Failed to connect';
const msg = err instanceof Error ? err.message : t('join.page.connectFailed');
if (msg.toLowerCase().includes('already a member')) {
setPhase('already-member');
setError('');
@@ -160,7 +162,7 @@ export function JoinPage() {
const space = await joinByCode(parsed.code, parsed.origin);
navigate(`/channels/${space.id}`);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to log in';
const msg = err instanceof Error ? err.message : t('join.page.loginFailed');
if (msg.toLowerCase().includes('already a member')) {
setPhase('already-member');
setError('');
@@ -204,7 +206,7 @@ export function JoinPage() {
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<p className="text-txt-tertiary">Loading invite...</p>
<p className="text-txt-tertiary">{t('join.page.loading')}</p>
</div>
</div>
);
@@ -221,16 +223,16 @@ export function JoinPage() {
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<h1 className="text-xl font-bold text-txt-primary mb-2">Invalid Invite</h1>
<h1 className="text-xl font-bold text-txt-primary mb-2">{t('join.page.invalid.title')}</h1>
<p className="text-txt-secondary text-sm mb-6">
{previewError || 'This invite link is invalid or has expired.'}
{previewError || t('join.page.invalid.body')}
</p>
{token ? (
<button
onClick={() => navigate('/channels/@me')}
className="px-6 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors"
>
Back to Backspace
{t('join.page.back')}
</button>
) : (
<Link
@@ -260,7 +262,7 @@ export function JoinPage() {
avatarColor={preview.avatarColor}
/>
</div>
<p className="text-xs text-txt-tertiary uppercase tracking-wide mb-1">You've been invited to join</p>
<p className="text-xs text-txt-tertiary uppercase tracking-wide mb-1">{t('join.page.invitedTo')}</p>
<h1 className="text-2xl font-bold text-txt-primary">{preview.spaceName}</h1>
{preview.description && (
<p className="text-txt-secondary text-sm mt-2">{preview.description}</p>
@@ -303,7 +305,7 @@ export function JoinPage() {
disabled={isJoining}
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isJoining ? 'Joining...' : `Join as ${user.displayName || user.username}`}
{isJoining ? t('join.joining') : `Join as ${user.displayName || user.username}`}
</button>
<p className="text-center text-xs text-txt-tertiary">
Not you?{' '}
@@ -378,7 +380,7 @@ export function JoinPage() {
type="text"
value={otherDomain}
onChange={(e) => setOtherDomain(e.target.value)}
placeholder="e.g. my-instance.com"
placeholder={t('join.page.instance.placeholder')}
className="input-standard flex-1 py-2.5"
autoFocus
/>
@@ -431,7 +433,7 @@ export function JoinPage() {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Your account password"
placeholder={t('join.connect.password.placeholder')}
className="input-standard w-full py-2.5"
disabled={isJoining}
autoFocus
@@ -454,7 +456,7 @@ export function JoinPage() {
disabled={isJoining || !password}
className="flex-1 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isJoining ? 'Connecting...' : 'Connect & Join'}
{isJoining ? t('join.connect.connecting') : t('join.connect.submit')}
</button>
</div>
</form>
@@ -473,24 +475,24 @@ export function JoinPage() {
</div>
<div className="mb-4 space-y-3">
<div>
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">Username</label>
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">{t('join.fallback.username')}</label>
<input
type="text"
value={fallbackUsername}
onChange={(e) => setFallbackUsername(e.target.value)}
placeholder="Your username on this instance"
placeholder={t('join.fallback.username.placeholder')}
className="input-standard w-full py-2.5"
disabled={isJoining}
autoComplete="username"
/>
</div>
<div>
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">Password for this instance</label>
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">{t('join.fallback.password')}</label>
<input
type="password"
value={fallbackPassword}
onChange={(e) => setFallbackPassword(e.target.value)}
placeholder="Password on the remote instance"
placeholder={t('join.fallback.password.placeholder')}
className="input-standard w-full py-2.5"
disabled={isJoining}
autoFocus
@@ -511,7 +513,7 @@ export function JoinPage() {
disabled={isJoining || !fallbackUsername || !fallbackPassword}
className="flex-1 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isJoining ? 'Logging in...' : 'Login & Join'}
{isJoining ? t('join.fallback.loggingIn') : t('join.fallback.submit')}
</button>
</div>
</form>
@@ -522,6 +524,7 @@ export function JoinPage() {
}
function AlreadyMemberCard({ spaceName, spaceId, navigate }: { spaceName: string; spaceId: string; navigate: (path: string) => void }) {
const t = useT();
useEffect(() => {
const timer = setTimeout(() => navigate(`/channels/${spaceId}`), 2000);
return () => clearTimeout(timer);
@@ -534,13 +537,13 @@ function AlreadyMemberCard({ spaceName, spaceId, navigate }: { spaceName: string
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-txt-primary font-medium mb-1">You're already in {spaceName}!</p>
<p className="text-txt-tertiary text-xs mb-4">Redirecting you now...</p>
<p className="text-txt-primary font-medium mb-1">{t('join.page.alreadyMember', { space: spaceName })}</p>
<p className="text-txt-tertiary text-xs mb-4">{t('join.page.redirecting')}</p>
<button
onClick={() => navigate(`/channels/${spaceId}`)}
className="px-6 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors"
>
Go to {spaceName}
{t('join.page.goTo', { space: spaceName })}
</button>
</div>
);
@@ -1,4 +1,8 @@
import { useEffect, useRef } from 'react';
import { useInAppNotificationStore } from '../stores/inAppNotificationStore';
import { AudioManager } from '../audio/AudioManager';
import { getSfxVolume } from '../utils/sfx';
import { translate, useLocaleStore } from '../i18n';
import { useChatStore } from '../stores/chatStore';
import { useVoiceStore } from '../stores/voiceStore';
import { useAuthStore } from '../stores/authStore';
@@ -51,19 +55,40 @@ export function NotificationController() {
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
if (isInitialMount.current) return;
if (windowFocused.current) return;
// Antes daqui só havia aviso com a janela fora de foco. Agora a janela em
// foco também avisa, mas dentro do app e apenas para outro canal — avisar
// sobre a conversa que a pessoa está lendo seria ruído.
const focused = windowFocused.current;
const currentChannel = state.currentChannelId;
if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) {
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
for (const { message } of newEvents) {
if (message.userId !== currentUser?.id) {
// System notices (pins, membership) are not from anyone — no toast.
if (message.type !== 'system' && message.userId !== currentUser?.id) {
if (focused && message.channelId === currentChannel) break;
const displayName = message.user?.displayName || message.user?.username || 'Someone';
const body = message.content
? message.content.replace(/[*_~`>#\-\[\]]/g, '').slice(0, 100)
: 'Sent an attachment';
sendNotification(displayName, body, {
: translate(useLocaleStore.getState().locale, 'notify.attachment');
useInAppNotificationStore.getState().push({
title: displayName,
body,
avatar: message.user?.avatar ?? null,
userId: message.user?.id,
channelId: message.channelId,
});
// Efeito próprio, no lugar do som do sistema. O balão nativo agora
// é silencioso, então este é o único som que toca.
void AudioManager.getInstance().playSound('notification', { volume: getSfxVolume() });
// O balão do sistema só faz sentido quando a janela não está à
// vista: com ela em foco, o aviso dentro do app já cumpre o papel.
if (!focused) {
sendNotification(displayName, body, { channelId: message.channelId });
}
break; // one notification per batch
}
}
+16 -14
View File
@@ -4,8 +4,10 @@ import { useAuthStore } from '../../stores/authStore';
import { api, RateLimitError } from '../../api/client';
import type { InstanceInfoResponse } from '@backspace/shared';
import { SourceCodeLink } from '../ui/SourceCodeLink';
import { useT } from '../../i18n';
export function LoginPage() {
const t = useT();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
@@ -46,11 +48,11 @@ export function LoginPage() {
setError('');
if (!username.trim()) {
setError('Username is required');
setError(t('auth.username.required'));
return;
}
if (!password) {
setError('Password is required');
setError(t('auth.password.required'));
return;
}
@@ -66,7 +68,7 @@ export function LoginPage() {
setRetryAfter(err.retryAfter);
setError('');
} else {
setError(err instanceof Error ? err.message : 'Login failed');
setError(err instanceof Error ? err.message : t('auth.login.failed'));
}
}
};
@@ -78,15 +80,15 @@ export function LoginPage() {
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
<div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-txt-primary">Welcome back!</h1>
<p className="text-txt-tertiary mt-1">We're so excited to see you again!</p>
<h1 className="text-2xl font-bold text-txt-primary">{t('auth.login.title')}</h1>
<p className="text-txt-tertiary mt-1">{t('auth.login.subtitle')}</p>
</div>
<form onSubmit={handleSubmit}>
{retryAfter > 0 && (
<div className="mb-4 p-3 bg-accent-amber/10 border border-accent-amber/30 rounded text-sm">
<p className="font-medium text-accent-amber">Too many login attempts</p>
<p className="text-txt-secondary mt-0.5">Try again in {retryAfter}s</p>
<p className="font-medium text-accent-amber">{t('auth.login.rateLimited')}</p>
<p className="text-txt-secondary mt-0.5">{t('auth.retryIn', { seconds: retryAfter })}</p>
</div>
)}
@@ -98,7 +100,7 @@ export function LoginPage() {
<div className="mb-5">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Username <span className="text-txt-danger">*</span>
{t('auth.username')} <span className="text-txt-danger">*</span>
</label>
<input
type="text"
@@ -112,7 +114,7 @@ export function LoginPage() {
<div className="mb-5">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Password <span className="text-txt-danger">*</span>
{t('auth.password')} <span className="text-txt-danger">*</span>
</label>
<input
type="password"
@@ -129,16 +131,16 @@ export function LoginPage() {
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{retryAfter > 0
? `Try again in ${retryAfter}s`
? t('auth.retryIn', { seconds: retryAfter })
: isLoading
? 'Logging in...'
: 'Log In'}
? t('auth.login.loggingIn')
: t('auth.login.submit')}
</button>
<p className="mt-3 text-sm text-txt-tertiary">
Need an account?{' '}
{t('auth.needAccount')}{' '}
<Link to={`/register${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} className="text-accent-primary hover:underline">
Register
{t('auth.register')}
</Link>
</p>
</form>
@@ -10,6 +10,7 @@ import { api, RateLimitError } from '../../api/client';
import { useTransferStore } from '../../stores/transferStore';
import { waitForTransferAttachment } from '../../utils/waitForTransfer';
import { SourceCodeLink } from '../ui/SourceCodeLink';
import { useT } from '../../i18n';
// Single-source regex for extracting a bare invite token from a pasted full URL.
// Token format: 22 chars base64url ([A-Za-z0-9_-]).
@@ -18,6 +19,7 @@ const INVITE_URL_REGEX = /[?&]invite=([A-Za-z0-9_-]{22})/;
type UsernameStatus = 'idle' | 'checking' | 'available' | 'taken' | 'invalid';
export function RegisterPage() {
const t = useT();
// Step state
const [step, setStep] = useState<1 | 2>(1);
const [direction, setDirection] = useState<'forward' | 'back'>('forward');
@@ -187,18 +189,18 @@ export function RegisterPage() {
if (trimmed.length < 3 || trimmed.length > 32) {
setUsernameStatus(trimmed.length > 0 ? 'invalid' : 'idle');
setUsernameStatusMessage(trimmed.length > 0 ? 'Username must be between 3 and 32 characters' : '');
setUsernameStatusMessage(trimmed.length > 0 ? t('auth.register.username.length') : '');
return;
}
if (!/^[a-z0-9_]+$/.test(trimmed)) {
setUsernameStatus('invalid');
setUsernameStatusMessage('Username can only contain lowercase letters, numbers, and underscores');
setUsernameStatusMessage(t('auth.register.username.charset'));
return;
}
setUsernameStatus('checking');
setUsernameStatusMessage('Checking availability...');
setUsernameStatusMessage(t('auth.register.username.checking'));
usernameCheckTimerRef.current = setTimeout(async () => {
const controller = new AbortController();
@@ -213,10 +215,10 @@ export function RegisterPage() {
setUsernameStatusMessage(result.reason);
} else if (result.available) {
setUsernameStatus('available');
setUsernameStatusMessage('Username is available');
setUsernameStatusMessage(t('auth.register.username.available'));
} else {
setUsernameStatus('taken');
setUsernameStatusMessage('Username is already taken');
setUsernameStatusMessage(t('auth.register.username.taken'));
}
} catch {
if (controller.signal.aborted) return;
@@ -265,30 +267,30 @@ export function RegisterPage() {
const trimmed = username.trim();
if (!trimmed) {
setError('Username is required');
setError(t('auth.username.required'));
return;
}
if (trimmed.length < 3 || trimmed.length > 32) {
setError('Username must be between 3 and 32 characters');
setError(t('auth.register.username.length'));
return;
}
if (!/^[a-z0-9_]+$/.test(trimmed)) {
setError('Username can only contain lowercase letters, numbers, and underscores');
setError(t('auth.register.username.charset'));
return;
}
if (usernameStatus === 'taken' || usernameStatus === 'invalid') {
return;
}
if (!password) {
setError('Password is required');
setError(t('auth.password.required'));
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters');
setError(t('auth.register.password.length'));
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
setError(t('auth.register.password.mismatch'));
return;
}
@@ -379,7 +381,7 @@ export function RegisterPage() {
setRetryAfter(err.retryAfter);
setError('');
} else {
setError(err instanceof Error ? err.message : 'Registration failed');
setError(err instanceof Error ? err.message : t('auth.register.failed'));
}
setIsRegistering(false);
}
@@ -418,7 +420,7 @@ export function RegisterPage() {
{step === 1 ? (
<div key="step1" className={`w-full${direction === 'back' ? ' animate-step-back' : ''}`}>
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-txt-primary">Create an account</h1>
<h1 className="text-2xl font-bold text-txt-primary">{t('auth.register.title')}</h1>
</div>
<form onSubmit={handleContinue}>
@@ -439,7 +441,7 @@ export function RegisterPage() {
type="text"
value={manualInviteToken}
onChange={(e) => setManualInviteToken(e.target.value)}
placeholder="Invite code or link"
placeholder={t('auth.register.invite')}
// text-base on mobile prevents iOS Safari from auto-zooming
// when the field is focused (any <input> with font-size <16px triggers zoom).
className="input-standard w-full px-3 py-2 text-base md:text-sm"
@@ -447,7 +449,7 @@ export function RegisterPage() {
autoComplete="off"
/>
{inviteChecking && (
<div className="text-xs text-txt-tertiary">Checking...</div>
<div className="text-xs text-txt-tertiary">{t('auth.register.invite.checking')}</div>
)}
{!inviteChecking && inviteCheck?.valid === true && (
<div className="text-xs text-status-online flex items-center gap-1">
@@ -459,9 +461,9 @@ export function RegisterPage() {
)}
{!inviteChecking && inviteCheck?.valid === false && (
<div className="text-xs text-txt-danger">
{inviteCheck.reason === 'expired' && 'This invite link has expired. Ask the admin for a new one.'}
{inviteCheck.reason === 'exhausted' && 'This invite has reached its usage limit. Ask the admin to extend it.'}
{inviteCheck.reason === 'invalid' && 'Invalid invite code.'}
{inviteCheck.reason === 'expired' && t('auth.register.invite.expired')}
{inviteCheck.reason === 'exhausted' && t('auth.register.invite.exhausted')}
{inviteCheck.reason === 'invalid' && t('auth.register.invite.invalid')}
</div>
)}
</div>
@@ -480,7 +482,7 @@ export function RegisterPage() {
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span>Validating invite...</span>
<span>{t('auth.register.invite.validating')}</span>
</>
) : inviteCheck?.valid === true ? (
<>
@@ -494,17 +496,17 @@ export function RegisterPage() {
<svg className="w-3 h-3 flex-shrink-0 mt-0.5 md:mt-0" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
<span>Invalid invite link — please request a new one</span>
<span>{t('auth.register.invite.invalidLink')}</span>
</>
) : (
<>Validating invite...</>
<>{t('auth.register.invite.validating')}</>
)}
</div>
)}
<div className="mb-5">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Username <span className="text-txt-danger">*</span>
{t('auth.username')} <span className="text-txt-danger">*</span>
</label>
<input
type="text"
@@ -544,7 +546,7 @@ export function RegisterPage() {
<div className="mb-5">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Password <span className="text-txt-danger">*</span>
{t('auth.password')} <span className="text-txt-danger">*</span>
</label>
<input
type="password"
@@ -557,7 +559,7 @@ export function RegisterPage() {
<div className="mb-5">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Confirm Password <span className="text-txt-danger">*</span>
{t('auth.register.confirmPassword')} <span className="text-txt-danger">*</span>
</label>
<input
type="password"
@@ -575,7 +577,7 @@ export function RegisterPage() {
// tighter desktop look from before.
className="w-full py-3 md:py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Continue
{t('auth.register.continue')}
</button>
{/* Helper text when invite is required but not yet entered */}
@@ -586,9 +588,9 @@ export function RegisterPage() {
)}
<p className="mt-3 text-sm text-txt-tertiary">
Already have an account?{' '}
{t('auth.haveAccount')}{' '}
<Link to={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} className="text-accent-primary hover:underline">
Log In
{t('auth.login.submit')}
</Link>
</p>
</form>
@@ -596,14 +598,14 @@ export function RegisterPage() {
) : (
<div key="step2" className={`w-full${direction === 'forward' ? ' animate-step-forward' : ''}`}>
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-txt-primary">Make it yours</h1>
<p className="text-txt-tertiary text-sm mt-1">Personalize your profile, or skip for now</p>
<h1 className="text-2xl font-bold text-txt-primary">{t('auth.register.profile.title')}</h1>
<p className="text-txt-tertiary text-sm mt-1">{t('auth.register.profile.subtitle')}</p>
</div>
{retryAfter > 0 && (
<div className="mb-4 p-3 bg-accent-amber/10 border border-accent-amber/30 rounded text-sm">
<p className="font-medium text-accent-amber">Too many attempts</p>
<p className="text-txt-secondary mt-0.5">Try again in {retryAfter}s</p>
<p className="font-medium text-accent-amber">{t('auth.register.rateLimited')}</p>
<p className="text-txt-secondary mt-0.5">{t('auth.retryIn', { seconds: retryAfter })}</p>
</div>
)}
@@ -652,13 +654,13 @@ export function RegisterPage() {
{/* Display Name */}
<div className="mb-5">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Display Name
{t('auth.register.displayName')}
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={username.trim() || 'Display name'}
placeholder={username.trim() || t('auth.register.displayName.placeholder')}
className="input-standard w-full py-2.5 text-base md:text-sm"
autoComplete="name"
/>
@@ -667,7 +669,7 @@ export function RegisterPage() {
{/* Avatar Color Picker */}
<div className="mb-6">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Avatar Color
{t('auth.register.avatarColor')}
</label>
{/* Color swatch row: gap tightens on narrow viewports so the 7 swatches
fit inside a 360 px viewport (p-6 inner content area is ~280 px;
@@ -701,10 +703,10 @@ export function RegisterPage() {
className="w-full py-3 md:py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{retryAfter > 0
? `Try again in ${retryAfter}s`
? t('auth.retryIn', { seconds: retryAfter })
: isRegistering
? 'Creating account...'
: 'Get Started'}
? t('auth.register.creating')
: t('auth.register.submit')}
</button>
<div className="flex items-center justify-between mt-3">
@@ -715,7 +717,7 @@ export function RegisterPage() {
// py-2 px-1 widens the tap area on mobile while keeping the visual link style.
className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50 py-2 px-1 -mx-1"
>
Back
{t('auth.register.back')}
</button>
<button
type="button"
@@ -723,7 +725,7 @@ export function RegisterPage() {
disabled={isDisabled}
className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50 py-2 px-1 -mx-1"
>
Skip for now
{t('auth.register.skip')}
</button>
</div>
</div>
@@ -745,7 +747,7 @@ export function RegisterPage() {
onClose={() => setAvatarCropSrc(null)}
imageSrc={avatarCropSrc}
onCropComplete={handleAvatarCropComplete}
title="Crop Avatar"
title={t('auth.register.cropAvatar')}
aspectRatio={1}
cropShape="round"
maxOutputDimension={256}
@@ -1,4 +1,7 @@
import React, { useRef, useEffect } from 'react';
import React, { useRef, useEffect, useMemo } from 'react';
import { useSpaceStore } from '../../stores/spaceStore';
import { useExpressionStore } from '../../stores/expressionStore';
import { api } from '../../api/client';
import Picker from '@emoji-mart/react';
import data from '@emoji-mart/data';
@@ -16,6 +19,24 @@ interface EmojiPickerProps {
export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Emojis próprios do servidor entram como categoria extra do emoji-mart.
// Sem espaço atual (DM) a lista fica vazia e a categoria não aparece.
const spaceId = useSpaceStore((s) => s.currentSpaceId);
const spaceEmojis = useExpressionStore((s) => (spaceId ? s.emojisBySpace.get(spaceId) : undefined));
const customCategories = useMemo(() => {
if (!spaceEmojis?.length) return [];
return [{
id: 'space',
name: 'Servidor',
emojis: spaceEmojis.map((e) => ({
id: e.name,
name: e.name,
keywords: [e.name],
skins: [{ src: api.uploads.url(e.filename) }],
})),
}];
}, [spaceEmojis]);
// Prevent keyboard events from bubbling out (e.g. Enter submitting the chat input)
useEffect(() => {
const el = containerRef.current;
@@ -45,6 +66,7 @@ export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps)
<div ref={containerRef} className={wrapperClass}>
<Picker
data={data}
custom={customCategories}
onEmojiSelect={onEmojiSelect}
theme="dark"
set="native"
@@ -8,8 +8,10 @@ import { getSpaceGradient } from '../../utils/gradients';
import { extractDominantColors, colorsToGradient } from '../../utils/colorExtractor';
import { MemberListToggleButton } from '../layout/MemberListToggleButton';
import { useSpaceJoin } from '../../hooks/useSpaceJoin';
import { useT } from '../../i18n';
export function ExplorePage() {
const t = useT();
const navigate = useNavigate();
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
@@ -73,7 +75,7 @@ export function ExplorePage() {
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5.5-2.5l7.51-3.49L17.5 6.5 9.99 9.99 6.5 17.5zm5.5-6.6c.61 0 1.1.49 1.1 1.1s-.49 1.1-1.1 1.1-1.1-.49-1.1-1.1.49-1.1 1.1-1.1z" />
</svg>
<span className="font-bold text-txt-primary">Explore</span>
<span className="font-bold text-txt-primary">{t('explore.title')}</span>
</div>
<div className="w-[1px] h-6 bg-surface-elevated mx-2" />
@@ -81,7 +83,7 @@ export function ExplorePage() {
<div className="relative flex-1 max-w-xs ml-2">
<input
type="text"
placeholder="Search spaces..."
placeholder={t('explore.searchPlaceholder')}
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="input-search w-full"
@@ -107,7 +109,7 @@ export function ExplorePage() {
<div className="flex-1 overflow-y-auto">
{!discoveryEnabled && (
<div className="mx-6 mt-4 p-2.5 bg-accent-amber/10 border border-accent-amber/30 rounded text-[13px] text-accent-amber">
Space discovery is disabled by the instance administrator.
{t('explore.disabled')}
</div>
)}
@@ -124,9 +126,7 @@ export function ExplorePage() {
<div className="flex flex-col items-center justify-center h-64 opacity-80">
<Mascot state="lonely" className="w-32 h-32 mb-3" />
<p className="text-txt-tertiary text-sm">
{searchQuery
? 'No spaces match your search.'
: 'No discoverable spaces yet.'}
{searchQuery ? t('explore.empty.search') : t('explore.empty.none')}
</p>
</div>
) : (
@@ -138,7 +138,7 @@ export function ExplorePage() {
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
<span className="text-[13px] text-accent-mint">
You're in all discoverable spaces
{t('explore.allJoined')}
</span>
</div>
)}
@@ -173,7 +173,7 @@ export function ExplorePage() {
<path d="M7 10l5 5 5-5z" />
</svg>
<span className="text-xs font-semibold uppercase tracking-wider text-txt-tertiary group-hover:text-txt-secondary transition-colors">
Joined
{t('explore.joined')}
</span>
<span className="text-xs text-txt-tertiary/60">
{joinedSpaces.length}
@@ -207,6 +207,7 @@ function SpaceCard({
space: TaggedExploreSpace;
onJoinSuccess: (spaceId: string) => void;
}) {
const t = useT();
const {
isJoined,
isPublic,
@@ -297,7 +298,7 @@ function SpaceCard({
? 'bg-accent-mint/20 text-accent-mint'
: 'bg-accent-amber/20 text-accent-amber'
}`}>
{isPublic ? 'Public' : 'Request'}
{isPublic ? t('explore.badge.public') : t('explore.badge.request')}
</span>
</div>
@@ -330,7 +331,7 @@ function SpaceCard({
{space.description}
</p>
) : (
<p className="text-[13px] text-txt-tertiary italic mb-3 flex-1">No description</p>
<p className="text-[13px] text-txt-tertiary italic mb-3 flex-1">{t('explore.noDescription')}</p>
)}
<div className="flex items-center gap-3 text-[12px] text-txt-tertiary mb-3">
@@ -338,7 +339,7 @@ function SpaceCard({
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="opacity-60">
<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" />
</svg>
{space.memberCount} {space.memberCount === 1 ? 'member' : 'members'}
{space.memberCount} {t(space.memberCount === 1 ? 'explore.member' : 'explore.members')}
</span>
{originLabel && (
<span className="flex items-center gap-1 text-txt-tertiary/70">
@@ -360,7 +361,7 @@ function SpaceCard({
onClick={handleViewSpace}
className="w-full py-2 bg-accent-mint/15 hover:bg-accent-mint/25 text-accent-mint text-sm font-medium rounded transition-colors"
>
View Space
{t('explore.view')}
</button>
) : isPublic ? (
<button
@@ -371,10 +372,10 @@ function SpaceCard({
{joining ? (
<span className="flex items-center justify-center gap-2">
<LoadingSpinner />
Joining...
{t('explore.joining')}
</span>
) : (
'Join Space'
t('explore.join')
)}
</button>
) : isPending ? (
@@ -382,14 +383,14 @@ function SpaceCard({
disabled
className="w-full py-2 bg-interactive-muted text-txt-tertiary text-sm font-medium rounded cursor-default"
>
Request Pending
{t('explore.requestPending')}
</button>
) : showRequestForm ? (
<div className="space-y-2">
<textarea
value={requestMessage}
onChange={(e) => setRequestMessage(e.target.value.slice(0, 200))}
placeholder="Why do you want to join? (optional)"
placeholder={t('explore.request.placeholder')}
rows={2}
className="input-standard w-full resize-none"
/>
@@ -399,13 +400,13 @@ function SpaceCard({
disabled={joining}
className="flex-1 py-1.5 bg-accent-amber hover:bg-accent-amber/80 text-[#13131a] text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{joining ? 'Sending...' : 'Send Request'}
{joining ? t('explore.request.sending') : t('explore.request.send')}
</button>
<button
onClick={cancelRequestForm}
className="px-3 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Cancel
{t('explore.request.cancel')}
</button>
</div>
</div>
@@ -414,7 +415,7 @@ function SpaceCard({
onClick={openRequestForm}
className="w-full py-2 bg-accent-amber/20 hover:bg-accent-amber/30 text-accent-amber text-sm font-medium rounded transition-colors"
>
Request to Join
{t('explore.requestToJoin')}
</button>
)}
</div>
@@ -22,8 +22,14 @@ import { parseFederatedUsername, isFederationGlobeApplicable } from '../../utils
import { useCanonicalUserView } from '../../utils/userViewLookup';
import { Username } from '../ui/Username';
import { ConfirmDialog } from '../ui/ConfirmDialog';
import { useT, type TranslationKey } from '../../i18n';
const statusLabel: Record<string, string> = { online: 'Online', idle: 'Idle', dnd: 'Do Not Disturb', offline: 'Offline' };
const statusLabelKey: Record<string, TranslationKey> = {
online: 'friends.status.online',
idle: 'friends.status.idle',
dnd: 'friends.status.dnd',
offline: 'friends.status.offline',
};
function ActivityFriendItem({
friend,
@@ -98,6 +104,7 @@ interface FriendsPageProps {
}
export function FriendsPage({ mobile }: FriendsPageProps) {
const t = useT();
const [activeTab, setActiveTab] = useState<Tab>('online');
const [pendingUnfriend, setPendingUnfriend] = useState<{ id: string; name: string } | null>(null);
const navigate = useNavigate();
@@ -172,12 +179,12 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
return (
<div className="flex-1 overflow-y-auto p-4">
<h2 className="text-xs font-bold text-txt-tertiary mb-4 tracking-wider px-2">
Online — {onlineFriends.length}
{t('friends.heading.online', { count: onlineFriends.length })}
</h2>
{onlineFriends.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full opacity-80">
<Mascot state="idle" className="w-32 h-32 mb-4" />
<p className="text-txt-tertiary text-sm">No one's online right now.</p>
<p className="text-txt-tertiary text-sm">{t('friends.empty.online')}</p>
</div>
) : (
<>
@@ -192,12 +199,12 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
return (
<div className="flex-1 overflow-y-auto p-4">
<h2 className="text-xs font-bold text-txt-tertiary mb-4 tracking-wider px-2">
All Friends — {friends.length}
{t('friends.heading.all', { count: friends.length })}
</h2>
{friends.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full opacity-80">
<Mascot state="lonely" className="w-32 h-32 mb-4" />
<p className="text-txt-tertiary text-sm">No friends yet — add someone!</p>
<p className="text-txt-tertiary text-sm">{t('friends.empty.all')}</p>
</div>
) : (
<>
@@ -212,12 +219,12 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
return (
<div className="flex-1 overflow-y-auto p-4">
<h2 className="text-xs font-bold text-txt-tertiary mb-4 tracking-wider px-2">
Pending — {pendingIncoming.length + pendingOutgoing.length}
{t('friends.heading.pending', { count: pendingIncoming.length + pendingOutgoing.length })}
</h2>
{[...pendingIncoming, ...pendingOutgoing].length === 0 ? (
<div className="flex flex-col items-center justify-center h-full opacity-80">
<Mascot state="sleeping" className="w-32 h-32 mb-4" />
<p className="text-txt-tertiary text-sm">No pending requests — Nori is napping.</p>
<p className="text-txt-tertiary text-sm">{t('friends.empty.pending')}</p>
</div>
) : (
<>
@@ -292,7 +299,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
<div className="flex flex-col items-center justify-center py-16 text-center">
<Mascot state="sleeping" className="w-[100px] h-[100px]" />
<div className="text-sm text-txt-tertiary mt-4 max-w-[240px]">
It's quiet for now... When friends start an activity, we'll show it here!
{t('friends.empty.activity')}
</div>
</div>
) : (
@@ -300,7 +307,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
{activeFriends.length > 0 && (
<div className="mb-4">
<h2 className="text-xs font-bold text-txt-tertiary mb-2 tracking-wider px-2">
Active — {activeFriends.length}
{t('friends.heading.active', { count: activeFriends.length })}
</h2>
{activeFriends.map(f => renderActivityFriend(f))}
</div>
@@ -308,7 +315,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
{idleFriends.length > 0 && (
<div className="mb-4">
<h2 className="text-xs font-bold text-txt-tertiary mb-2 tracking-wider px-2">
Online — {idleFriends.length}
{t('friends.heading.online', { count: idleFriends.length })}
</h2>
{idleFriends.map(f => renderActivityFriend(f))}
</div>
@@ -316,7 +323,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
{offlineActivityFriends.length > 0 && (
<div>
<h2 className="text-xs font-bold text-txt-tertiary mb-2 tracking-wider px-2">
Offline — {offlineActivityFriends.length}
{t('friends.heading.offline', { count: offlineActivityFriends.length })}
</h2>
{offlineActivityFriends.map(f => renderActivityFriend(f, true))}
</div>
@@ -341,7 +348,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<span className="font-semibold text-sm text-txt-primary">Friends</span>
<span className="font-semibold text-sm text-txt-primary">{t('friends.title')}</span>
</div>
) : (
<div className="h-14 px-4 flex items-center border-b border-border-hard flex-shrink-0 z-10 bg-surface-chat">
@@ -349,14 +356,14 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
<span className="font-bold text-txt-primary">Friends</span>
<span className="font-bold text-txt-primary">{t('friends.title')}</span>
</div>
<div className="w-[1px] h-6 bg-surface-elevated mx-2" />
<div className="flex items-center gap-4 ml-2">
<TabButton active={activeTab === 'online'} onClick={() => setActiveTab('online')}>Online</TabButton>
<TabButton active={activeTab === 'all'} onClick={() => setActiveTab('all')}>All</TabButton>
<TabButton active={activeTab === 'online'} onClick={() => setActiveTab('online')}>{t('friends.tab.online')}</TabButton>
<TabButton active={activeTab === 'all'} onClick={() => setActiveTab('all')}>{t('friends.tab.all')}</TabButton>
<TabButton active={activeTab === 'pending'} onClick={() => setActiveTab('pending')}>
Pending
{t('friends.tab.pending')}
{(pendingIncoming.length > 0) && (
<span className="ml-2 px-1.5 py-0.5 bg-accent-rose text-white text-[10px] rounded-full leading-none">
{pendingIncoming.length}
@@ -369,7 +376,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
activeTab === 'add' ? 'text-status-online bg-transparent' : 'bg-status-online text-[#13131a] hover:bg-status-online/90'
}`}
>
Add Friend
{t('friends.tab.add')}
</button>
</div>
<div className="ml-auto flex items-center gap-1">
@@ -391,7 +398,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
: 'border-transparent text-txt-secondary hover:text-txt-primary'
}`}
>
{tab === 'add' ? 'Add Friend' : tab === 'activity' ? 'Activity' : tab.charAt(0).toUpperCase() + tab.slice(1)}
{t(`friends.tab.${tab}` as TranslationKey)}
{tab === 'pending' && pendingIncoming.length > 0 && (
<span className="ml-1.5 px-1.5 py-0.5 text-xs bg-notification text-white rounded-full">
{pendingIncoming.length}
@@ -413,9 +420,11 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
setPendingUnfriend(null);
}
}}
title="Remove Friend"
description={`Are you sure you want to remove ${pendingUnfriend?.name ?? 'this user'} as a friend? You can always send them a new friend request later.`}
confirmLabel="Remove"
title={t('friends.remove.title')}
description={t('friends.remove.body', {
name: pendingUnfriend?.name ?? t('friends.remove.someone'),
})}
confirmLabel={t('friends.remove.confirm')}
variant="danger"
/>
</div>
@@ -429,6 +438,7 @@ function AddFriendTab({
}: {
onOpenDm: (userId: string, homeUserId?: string, homeInstance?: string | null) => void;
}) {
const t = useT();
const searchUsers = useSocialStore((s) => s.searchUsers);
const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest);
const friends = useSocialStore((s) => s.friends);
@@ -527,7 +537,7 @@ function AddFriendTab({
setDirectAddLoading(true);
try {
await sendFriendRequest(query.trim());
addToast('Friend request sent!', 'success');
addToast(t('friends.add.sent'), 'success');
setQuery('');
} catch (err) {
// The shared API client throws `new Error(body.error)` for non-2xx
@@ -548,8 +558,8 @@ function AddFriendTab({
const displayUsers = isSearchMode ? enrichedSearchResults : discoverUsers;
const displayLoading = isSearchMode ? searchLoading : discoverLoading;
const emptyLabel = isSearchMode
? 'No users match your search.'
: 'No discoverable users yet — invite people to join!';
? t('friends.add.empty.search')
: t('friends.add.empty.discover');
return (
<div className="flex-1 overflow-y-auto">
@@ -559,15 +569,29 @@ function AddFriendTab({
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
<path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
<h2 className="text-base font-bold text-txt-primary">Find People</h2>
<h2 className="text-base font-bold text-txt-primary">{t('friends.add.title')}</h2>
</div>
<p className="text-sm text-txt-tertiary mb-4">Search by username or use <span className="font-medium text-txt-secondary">user@instance</span> to add someone directly.</p>
<p className="text-sm text-txt-tertiary mb-4">
{/* The `user@instance` example is emphasised, so the sentence is
split around its placeholder rather than interpolated as text —
translations keep control of the word order either side of it. */}
{(() => {
const [before, after] = t('friends.add.hint').split('{handle}');
return (
<>
{before}
<span className="font-medium text-txt-secondary">user@instance</span>
{after}
</>
);
})()}
</p>
{/* Unified search input */}
<div className="relative mb-4">
<input
type="text"
placeholder="Search or add by username..."
placeholder={t('friends.add.searchPlaceholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="input-search w-full px-4 py-3 rounded-lg"
@@ -591,14 +615,14 @@ function AddFriendTab({
<path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
<div className="flex-1 min-w-0 text-sm text-txt-secondary">
Send friend request to <span className="font-semibold text-txt-primary">{directAddDisplay}</span>
{t('friends.add.directTo')} <span className="font-semibold text-txt-primary">{directAddDisplay}</span>
</div>
<button
onClick={handleDirectAdd}
disabled={directAddLoading}
className="px-3 py-1.5 rounded-md bg-accent-primary hover:bg-accent-primary-hover text-white text-sm font-medium transition-colors disabled:opacity-50 flex-shrink-0"
>
{directAddLoading ? 'Sending...' : 'Send Request'}
{directAddLoading ? t('friends.add.sending') : t('friends.add.send')}
</button>
</div>
)}
@@ -611,7 +635,7 @@ function AddFriendTab({
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5.5-2.5l7.51-3.49L17.5 6.5 9.99 9.99 6.5 17.5zm5.5-6.6c.61 0 1.1.49 1.1 1.1s-.49 1.1-1.1 1.1-1.1-.49-1.1-1.1.49-1.1 1.1-1.1z" />
</svg>
<span className="text-xs font-semibold tracking-wider text-txt-tertiary">Discover People</span>
<span className="text-xs font-semibold tracking-wider text-txt-tertiary">{t('friends.add.discover')}</span>
</div>
)}
@@ -665,6 +689,7 @@ function UserDiscoverCard({
onOpenDm: (userId: string, homeUserId?: string, homeInstance?: string | null) => void;
onRelationshipChange: (userId: string, origin: string, relationship: TaggedDiscoverUser['relationship'], requestId?: string) => void;
}) {
const t = useT();
const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest);
const updateFriendRequest = useSocialStore((s) => s.updateFriendRequest);
const openModal = useUIStore((s) => s.openModal);
@@ -695,7 +720,7 @@ function UserDiscoverCard({
} catch (err) {
// See handleDirectAdd above — err.message is the server error code.
const code = err instanceof Error ? err.message : undefined;
setError(mapServerErrorToMessage(code, code ?? 'Failed to send request', username));
setError(mapServerErrorToMessage(code, code ?? t('friends.add.failed'), username));
} finally {
setActionLoading(false);
}
@@ -709,7 +734,7 @@ function UserDiscoverCard({
await updateFriendRequest(user.requestId, 'accepted');
onRelationshipChange(user.id, user._instanceOrigin, 'friends');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to accept request');
setError(err instanceof Error ? err.message : t('friends.accept.failed'));
} finally {
setActionLoading(false);
}
@@ -723,7 +748,7 @@ function UserDiscoverCard({
await updateFriendRequest(user.requestId, 'declined');
onRelationshipChange(user.id, user._instanceOrigin, 'none');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to decline request');
setError(err instanceof Error ? err.message : t('friends.decline.failed'));
} finally {
setActionLoading(false);
}
@@ -741,7 +766,7 @@ function UserDiscoverCard({
await client.social.cancelRequest(user.requestId);
onRelationshipChange(user.id, user._instanceOrigin, 'none');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to cancel request');
setError(err instanceof Error ? err.message : t('friends.cancel.failed'));
} finally {
setActionLoading(false);
}
@@ -840,7 +865,7 @@ function UserDiscoverCard({
disabled={actionLoading}
className="w-full py-1.5 bg-accent-primary hover:bg-accent-primary-hover text-white text-[13px] font-medium rounded transition-colors disabled:opacity-50"
>
{actionLoading ? 'Sending...' : 'Send Friend Request'}
{actionLoading ? t('friends.add.sending') : t('friends.add.sendRequest')}
</button>
)}
{user.relationship === 'outbound_pending' && (
@@ -849,7 +874,7 @@ function UserDiscoverCard({
disabled={actionLoading || !user.requestId}
className="w-full py-1.5 bg-accent-amber/15 hover:bg-accent-amber/25 text-accent-amber text-[13px] font-medium rounded transition-colors disabled:opacity-50"
>
{actionLoading ? 'Cancelling...' : 'Request Pending'}
{actionLoading ? t('friends.add.cancelling') : t('friends.add.pending')}
</button>
)}
{user.relationship === 'inbound_pending' && (
@@ -899,6 +924,7 @@ function TabButton({ children, active, onClick }: { children: React.ReactNode, a
}
function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove: () => void, onDm: () => void }) {
const t = useT();
const canonical = useCanonicalUserView(friend as unknown as User);
const instanceLabel = friend._instanceOrigin ? (() => { try { return new URL(friend._instanceOrigin).host; } catch { return friend._instanceOrigin; } })() : '';
const { baseName: friendBaseName } = parseFederatedUsername(canonical.username);
@@ -913,7 +939,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove
<span className="text-txt-tertiary text-[13px] opacity-60 group-hover:opacity-100 transition-opacity font-medium">@{friend.username}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[12px] text-txt-tertiary font-medium">{statusLabel[friend.status] ?? friend.status}</span>
<span className="text-[12px] text-txt-tertiary font-medium">{statusLabelKey[friend.status] ? t(statusLabelKey[friend.status]!) : friend.status}</span>
{instanceLabel && (
<span className="text-[11px] text-txt-tertiary/60 font-medium">via {instanceLabel}</span>
)}
@@ -924,7 +950,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove
<button
onClick={(e) => { e.stopPropagation(); onDm(); }}
className="w-9 h-9 flex items-center justify-center bg-surface-base rounded-full text-txt-tertiary hover:text-txt-primary transition-colors"
title="Message"
title={t('friends.action.message')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-5H6V7h12v2z" />
@@ -933,7 +959,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove
<button
onClick={(e) => { e.stopPropagation(); onRemove(); }}
className="w-9 h-9 flex items-center justify-center bg-surface-base rounded-full text-txt-tertiary hover:text-txt-danger transition-colors"
title="Remove Friend"
title={t('friends.action.remove')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
@@ -951,6 +977,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
onDecline?: () => void;
onCancel?: () => void;
}) {
const t = useT();
const rawUser = request.user;
const _FALLBACK_USER = { id: '', username: '', createdAt: 0, isAdmin: false, replicatedInstances: [] } as unknown as User;
const canonicalUser = useCanonicalUserView((rawUser as unknown as User | null) ?? _FALLBACK_USER);
@@ -970,7 +997,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
<span className="text-txt-tertiary text-xs">@{user.username}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-txt-tertiary">{type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request'}</span>
<span className="text-xs text-txt-tertiary">{t(type === 'incoming' ? 'friends.request.incoming' : 'friends.request.outgoing')}</span>
{instanceLabel && (
<span className="text-[11px] text-txt-tertiary/60 font-medium">via {instanceLabel}</span>
)}
@@ -983,7 +1010,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
<button
onClick={() => onAccept?.()}
className="p-2 bg-surface-base rounded-full text-status-online hover:bg-status-online hover:text-white transition-all"
title="Accept"
title={t('friends.action.accept')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
@@ -992,7 +1019,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
<button
onClick={() => onDecline?.()}
className="p-2 bg-surface-base rounded-full text-txt-danger hover:bg-accent-rose hover:text-white transition-all"
title="Decline"
title={t('friends.action.decline')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
@@ -1003,7 +1030,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
<button
onClick={() => onCancel?.()}
className="p-2 bg-surface-base rounded-full text-txt-tertiary hover:text-txt-danger transition-all"
title="Cancel Request"
title={t('friends.action.cancel')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
@@ -1,11 +1,13 @@
import React, { useRef, useEffect, useCallback } from 'react';
import { useT } from '../../i18n';
import { createPortal } from 'react-dom';
import { EmojiPicker } from './EmojiPicker';
import { GifPicker } from './GifPicker';
import { StickerPicker } from './StickerPicker';
import { useUIStore } from '../../stores/uiStore';
import { useDragToClose } from '../../hooks/useDragToClose';
export type InputPopoverTab = 'emoji' | 'gif';
export type InputPopoverTab = 'emoji' | 'gif' | 'sticker';
interface InputPopoverProps {
activeTab: InputPopoverTab;
@@ -15,6 +17,8 @@ interface InputPopoverProps {
anchorRef: React.RefObject<HTMLElement | null>;
gifEnabled: boolean;
onTabChange: (tab: InputPopoverTab) => void;
onStickerSelect: (stickerId: string) => void;
hasStickers?: boolean;
}
interface SharedTabProps {
@@ -53,6 +57,7 @@ function DesktopPopover({
onClose,
onEmojiSelect,
onGifSelect,
onStickerSelect,
anchorRef,
gifEnabled,
onTabChange,
@@ -139,6 +144,7 @@ function DesktopPopover({
<div className="flex-1 min-h-0 overflow-hidden">
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} />}
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} />}
{activeTab === 'sticker' && <StickerPicker onStickerSelect={onStickerSelect} />}
</div>
</div>
</div>,
@@ -155,6 +161,7 @@ function MobileSheet({
onClose,
onEmojiSelect,
onGifSelect,
onStickerSelect,
gifEnabled,
onTabChange,
availableTabs,
@@ -221,6 +228,7 @@ function MobileSheet({
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} mobile />}
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} mobile />}
{activeTab === 'sticker' && <StickerPicker onStickerSelect={onStickerSelect} mobile />}
</div>
</div>
</>,
@@ -231,12 +239,17 @@ function MobileSheet({
export function InputPopover(props: InputPopoverProps) {
const isMobile = useUIStore((s) => s.isMobile);
const t = useT();
const availableTabs: { key: InputPopoverTab; label: string }[] = [
{ key: 'emoji', label: 'Emoji' },
];
if (props.gifEnabled) {
availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' });
}
// Figurinha só existe dentro de um servidor; em DM a aba não aparece.
if (props.hasStickers) {
availableTabs.push({ key: 'sticker', label: t('expressions.stickers') });
}
if (isMobile) {
return <MobileSheet {...props} availableTabs={availableTabs} />;
@@ -1,4 +1,5 @@
import React from 'react';
import { isCustomEmojiAlt } from '../../utils/customEmoji';
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Highlight, themes } from 'prism-react-renderer';
@@ -186,7 +187,22 @@ function buildComponents(): Components {
hr: () => <hr className="border-border-soft my-2" />,
// Images (in markdown content — not attachments)
img: ({ src, alt }) => (
img: ({ src, alt }) => {
// Emoji próprio do espaço chega como imagem markdown com alt `:nome:`.
// Renderiza em tamanho de texto e inline, para caber no meio da frase em
// vez de virar um bloco como uma imagem comum.
if (isCustomEmojiAlt(alt ?? undefined)) {
return (
<img
src={src}
alt={alt ?? ''}
title={alt ?? ''}
className="inline-block align-text-bottom w-[22px] h-[22px] object-contain"
loading="lazy"
/>
);
}
return (
<div className="mt-1 max-w-[400px]">
<img
src={src}
@@ -197,7 +213,8 @@ function buildComponents(): Components {
crossOrigin="anonymous"
/>
</div>
),
);
},
// Tables (GFM)
table: ({ children }) => (
@@ -3,17 +3,21 @@ import type { User } from '@backspace/shared';
import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
import { useCanonicalUserView } from '../../utils/userViewLookup';
import { EVERYONE_MENTION_ID } from '@backspace/shared/src/mentions';
import { useT } from '../../i18n';
interface MentionBadgeProps {
userId: string;
}
export const MentionBadge = React.memo(function MentionBadge({ userId }: MentionBadgeProps) {
const t = useT();
const members = useSpaceStore((s) => s.members);
const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const openUserProfile = useUIStore((s) => s.openUserProfile);
const isEveryone = userId === EVERYONE_MENTION_ID;
const member = members.find((m) => m.userId === userId);
const space = spaces.find((s) => s.id === currentSpaceId);
const ownerId = space?.ownerId;
@@ -25,7 +29,12 @@ export const MentionBadge = React.memo(function MentionBadge({ userId }: Mention
let displayName: string;
let color: string;
if (member && memberUser) {
if (isEveryone) {
// Amber, matching the highlight the mention puts on the message row, so the
// badge and the row read as one signal rather than two unrelated colours.
displayName = t('mention.everyone.label');
color = '#fbbf24';
} else if (member && memberUser) {
displayName = memberUser.displayName ?? memberUser.username;
if (member.roles && member.roles.length > 0) {
const sorted = [...member.roles].sort((a, b) => b.position - a.position);
@@ -36,12 +45,12 @@ export const MentionBadge = React.memo(function MentionBadge({ userId }: Mention
color = '#7c6cf6'; // accent-primary default
}
} else {
displayName = 'Unknown User';
displayName = t('mention.unknownUser');
color = '#a0a0aa'; // text-secondary fallback
}
const handleClick = (e: React.MouseEvent) => {
if (!member || !memberUser) return;
if (isEveryone || !member || !memberUser) return;
e.stopPropagation();
openUserProfile(memberUser, e.currentTarget.getBoundingClientRect());
};
@@ -52,7 +61,9 @@ export const MentionBadge = React.memo(function MentionBadge({ userId }: Mention
return (
<span
onClick={handleClick}
className="inline-flex items-center rounded-[3px] px-[2px] font-medium cursor-pointer transition-colors hover:brightness-125"
className={`inline-flex items-center rounded-[3px] px-[2px] font-medium transition-colors ${
isEveryone ? '' : 'cursor-pointer hover:brightness-125'
}`}
style={{ color, backgroundColor: bgColor }}
>
@{displayName}
@@ -6,6 +6,16 @@ import { useSpaceStore } from '../../stores/spaceStore';
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
import { useCanonicalUserView } from '../../utils/userViewLookup';
import { useUIStore } from '../../stores/uiStore';
import { useT } from '../../i18n';
/**
* What the composer gets back when a row is picked. @everyone is not a member,
* so the callback takes a tagged entry rather than a `MemberWithUser` — that
* keeps the two cases apart at the type level instead of by a sentinel id.
*/
export type MentionEntry =
| { kind: 'everyone' }
| { kind: 'member'; member: MemberWithUser };
const MAX_RESULTS = 8;
@@ -64,17 +74,60 @@ function MentionMemberRow({
interface MentionPopoverProps {
query: string;
selectedIndex: number;
onSelect: (member: MemberWithUser) => void;
onSelect: (entry: MentionEntry) => void;
anchorRef: React.RefObject<HTMLElement | null>;
/** Whether the @everyone row is offered — mirrors MENTION_EVERYONE. */
showEveryone: boolean;
}
function MentionEveryoneRow({
isSelected,
selectedRef,
onSelect,
mobile,
}: {
isSelected: boolean;
selectedRef: React.RefObject<HTMLDivElement>;
onSelect: (entry: MentionEntry) => void;
mobile: boolean;
}) {
const t = useT();
const rowSizing = mobile ? 'gap-3 px-3 py-2.5 min-h-[44px]' : 'gap-2.5 px-2 py-1.5';
return (
<div
ref={isSelected ? selectedRef : undefined}
onClick={() => onSelect({ kind: 'everyone' })}
className={`flex items-center mx-1 rounded cursor-pointer transition-colors ${rowSizing} ${
isSelected ? 'bg-interactive-selected' : 'hover:bg-interactive-hover'
}`}
>
<div
className={`${mobile ? 'w-7 h-7' : 'w-6 h-6'} rounded-full flex items-center justify-center flex-shrink-0 text-[13px] font-bold`}
style={{ color: '#fbbf24', backgroundColor: '#fbbf241a' }}
>
@
</div>
<span
className={`${mobile ? 'text-[15px]' : 'text-[14px]'} font-medium`}
style={{ color: '#fbbf24' }}
>
{t('mention.everyone.label')}
</span>
<span className="text-[12px] text-txt-tertiary truncate">
{t('mention.everyone.description')}
</span>
</div>
);
}
interface ResolvedListProps {
filtered: MemberWithUser[];
selectedIndex: number;
selectedRef: React.RefObject<HTMLDivElement>;
onSelect: (member: MemberWithUser) => void;
onSelect: (entry: MentionEntry) => void;
getMemberColor: (member: MemberWithUser) => string | undefined;
mobile: boolean;
showEveryone: boolean;
}
function MemberList({
@@ -84,19 +137,39 @@ function MemberList({
onSelect,
getMemberColor,
mobile,
showEveryone,
}: ResolvedListProps) {
const t = useT();
// @everyone occupies index 0 when shown, so member rows shift down by one.
// The composer clamps its keyboard selection against the same offset.
const memberOffset = showEveryone ? 1 : 0;
return (
<>
{showEveryone && (
<>
<div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
Members
{t('mention.everyone.section')}
</div>
<MentionEveryoneRow
isSelected={selectedIndex === 0}
selectedRef={selectedRef}
onSelect={onSelect}
mobile={mobile}
/>
</>
)}
{filtered.length > 0 && (
<div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
{t('mention.members')}
</div>
)}
{filtered.map((member, i) => (
<MentionMemberRow
key={member.userId}
member={member}
isSelected={i === selectedIndex}
isSelected={i + memberOffset === selectedIndex}
selectedRef={selectedRef}
onSelect={onSelect}
onSelect={(m) => onSelect({ kind: 'member', member: m })}
roleColor={getMemberColor(member)}
mobile={mobile}
/>
@@ -116,6 +189,7 @@ function DesktopMention({
onSelect,
anchorRef,
getMemberColor,
showEveryone,
}: DesktopMentionProps) {
const selectedRef = useRef<HTMLDivElement>(null);
const floatingRef = useRef<HTMLDivElement>(null);
@@ -123,7 +197,7 @@ function DesktopMention({
const { style } = useFloatingPosition(anchorRef, floatingRef, {
placement: 'top',
offset: 4,
enabled: filtered.length > 0,
enabled: filtered.length > 0 || showEveryone,
});
// Scroll selected item into view
@@ -141,6 +215,7 @@ function DesktopMention({
onSelect={onSelect}
getMemberColor={getMemberColor}
mobile={false}
showEveryone={showEveryone}
/>
</div>
</div>,
@@ -158,6 +233,7 @@ function MobileMention({
selectedIndex,
onSelect,
getMemberColor,
showEveryone,
}: MobileMentionProps) {
const selectedRef = useRef<HTMLDivElement>(null);
@@ -193,6 +269,7 @@ function MobileMention({
onSelect={onSelect}
getMemberColor={getMemberColor}
mobile={true}
showEveryone={showEveryone}
/>
</div>
</div>
@@ -201,7 +278,7 @@ function MobileMention({
);
}
export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: MentionPopoverProps) {
export function MentionPopover({ query, selectedIndex, onSelect, anchorRef, showEveryone }: MentionPopoverProps) {
const members = useSpaceStore((s) => s.members);
const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
@@ -220,7 +297,7 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me
.slice(0, MAX_RESULTS);
}, [members, query]);
if (filtered.length === 0) return null;
if (filtered.length === 0 && !showEveryone) return null;
const getMemberColor = (member: MemberWithUser): string | undefined => {
if (member.roles && member.roles.length > 0) {
@@ -240,6 +317,7 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me
anchorRef={anchorRef}
filtered={filtered}
getMemberColor={getMemberColor}
showEveryone={showEveryone}
/>
);
}
@@ -252,6 +330,7 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me
anchorRef={anchorRef}
filtered={filtered}
getMemberColor={getMemberColor}
showEveryone={showEveryone}
/>
);
}
+58 -7
View File
@@ -1,4 +1,8 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useExpressionStore } from '../../stores/expressionStore';
import { renderCustomEmojis } from '../../utils/customEmoji';
import { api } from '../../api/client';
import { useT } from '../../i18n';
import { createPortal } from 'react-dom';
import type { MessageWithUser, Embed, User } from '@backspace/shared';
import { MarkdownRenderer } from './MarkdownRenderer';
@@ -27,6 +31,7 @@ import {
type PendingAttachmentView,
} from '../../stores/pendingMessageStore';
import { useTransferStore } from '../../stores/transferStore';
import { mentionsEveryone } from '@backspace/shared/src/mentions';
interface MessageProps {
message: MessageWithUser | PendingMessageView;
@@ -131,6 +136,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
const editMessage = useChatStore((s) => s.editMessage);
const deleteMessage = useChatStore((s) => s.deleteMessage);
const members = useSpaceStore((s) => s.members);
const tr = useT();
const openUserProfile = useUIStore((s) => s.openUserProfile);
const pending = isPendingMessage(message) ? message.__pending : null;
@@ -230,6 +236,26 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
return () => clearTimeout(confirmDeleteTimeout.current);
}, []);
// Emojis próprios do espaço são resolvidos antes do markdown; sem espaço
// (DM, por exemplo) o texto passa intacto.
const emojiSpaceId = useSpaceStore((s) => s.currentSpaceId);
const emojiLookup = useExpressionStore((s) => s.emojiByName);
const contentWithEmojis = useMemo(() => {
if (!message.content || !emojiSpaceId) return message.content ?? '';
return renderCustomEmojis(
message.content,
(name) => emojiLookup(emojiSpaceId, name),
(filename) => api.uploads.url(filename),
);
}, [message.content, emojiSpaceId, emojiLookup]);
const stickerId = (message as MessageWithUser).stickerId;
const sticker = useExpressionStore((s) =>
stickerId && emojiSpaceId
? s.stickersBySpace.get(emojiSpaceId)?.find((k) => k.id === stickerId)
: undefined,
);
const isGifOnly = isGifOnlyMessage(message.content);
const imageEmbedSourceUrl = isGifOnly ? null : getImageEmbedSourceUrl(message.content, message.embeds || []);
// sourceUrl: the original URL for context menu Copy/Open Link actions
@@ -329,6 +355,15 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
setEditContent(message.content ?? '');
setIsEditing(true);
},
isPinned: Boolean((message as MessageWithUser).pinnedAt),
labels: { pin: tr('pins.pin'), unpin: tr('pins.unpin') },
onTogglePin: () => {
const pinned = Boolean((message as MessageWithUser).pinnedAt);
// Sem atualização otimista: o servidor recusa acima do limite do canal,
// e mostrar como fixada antes da confirmação mentiria nesse caso.
void (pinned ? api.pins.unpin(message.id) : api.pins.pin(message.id))
.catch(() => { /* o servidor rejeitou; o estado permanece o que era */ });
},
onDelete: () => deleteMessage(message.id, channelKey),
onReaction: (emoji: string) => toggleReaction(emoji),
onOpenEmojiPicker: () => {
@@ -385,8 +420,12 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
const replyRoleColor = (msg: { userId: string }) => getMemberDisplayColor(msg.userId);
// Self-mention highlighting
const isMentioned = currentUser && message.content?.includes('<@' + currentUser.id + '>');
// Mention highlighting — the same amber row for a direct ping and for
// @everyone, because to the reader both mean "this one is for you".
const isMentioned = Boolean(
(currentUser && message.content?.includes('<@' + currentUser.id + '>')) ||
mentionsEveryone(message.content),
);
const content = (
<div
@@ -516,7 +555,19 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
<>
{message.content && (
<div className="text-txt-message text-[15px] leading-[1.5] break-words whitespace-pre-wrap selection:bg-accent-primary/30">
<MarkdownRenderer content={message.content} />
{sticker ? (
// Figurinha ocupa a mensagem inteira, sem moldura de
// anexo: é o conteúdo, não um arquivo acompanhando texto.
<img
src={api.uploads.url(sticker.filename)}
alt={sticker.name}
title={sticker.name}
className="w-[160px] h-[160px] object-contain rounded-lg"
loading="lazy"
/>
) : (
<MarkdownRenderer content={contentWithEmojis} />
)}
{message.editedAt && (
<span className="text-[10px] text-txt-tertiary ml-1 select-none font-medium">(edited)</span>
)}
@@ -676,7 +727,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
className={`p-1 hover:bg-interactive-hover rounded transition-colors text-[14px] leading-none ${
showReactionPicker ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
title="Add reaction"
title={tr('chat.message.addReaction')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm1-13h-2v4H7v2h4v4h2v-4h4v-2h-4V7z" />
@@ -687,7 +738,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
<button
onClick={() => setReplyTo(message)}
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
title="Reply"
title={tr('chat.message.reply')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 9V5L3 12L10 19V14.9C15 14.9 18.5 16.5 21 20C20 15 17 10 10 9Z" />
@@ -700,7 +751,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
setIsEditing(true);
}}
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
title="Edit"
title={tr('chat.message.edit')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
@@ -1,12 +1,14 @@
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { useT } from '../../i18n';
import { useChatStore } from '../../stores/chatStore';
import { isDmChannel, getChannelOrigin, useSpaceStore } from '../../stores/spaceStore';
import { wsSend } from '../../hooks/useWebSocket';
import { MentionPopover } from './MentionPopover';
import { MentionPopover, type MentionEntry } from './MentionPopover';
import { TypingIndicator } from './TypingIndicator';
import { InputPopover, type InputPopoverTab } from './InputPopover';
import { AttachmentProgress } from './AttachmentProgress';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { EVERYONE_MENTION_ID, EVERYONE_MENTION_TOKEN } from '@backspace/shared/src/mentions';
import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared';
import { useSettingsStore } from '../../stores/settingsStore';
import { useUIStore } from '../../stores/uiStore';
@@ -94,6 +96,17 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Feature flags
const tr = useT();
// Figurinhas pertencem a um servidor; em DM não há o que oferecer.
const stickerSpaceId = useSpaceStore((s) => s.currentSpaceId);
const handleStickerSelect = (stickerId: string) => {
setActivePopover(null);
// Enviada de imediato: a figurinha é a mensagem inteira, então não faz
// sentido acumulá-la no campo de texto esperando um Enter.
void sendMessage(channelId, '', undefined, stickerId);
};
const gifEnabled = useSettingsStore((s) => s.gifEnabled);
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
@@ -101,6 +114,9 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
const isDm = isDmChannel(channelId);
const canSendMessages = isDm || hasPermissionBit(channelPerms, PermissionBits.SEND_MESSAGES);
const canAttachFiles = isDm || hasPermissionBit(channelPerms, PermissionBits.ATTACH_FILES);
// A DM has no roles to gate on, and pinging "everyone" in a two-person chat
// is noise, so the row is offered in space channels only.
const canMentionEveryone = !isDm && hasPermissionBit(channelPerms, PermissionBits.MENTION_EVERYONE);
// Derive staged transfers from composerStore staged ids + transferStore map
const stagedTransfers: Transfer[] = useMemo(() => {
@@ -195,6 +211,17 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
.slice(0, 8);
}, [members, mentionState]);
// The @everyone row is offered while the typed query is still a prefix of the
// word — same rule the member filter uses, so the two lists appear and vanish
// together rather than one lingering.
const showEveryone =
!!mentionState &&
canMentionEveryone &&
EVERYONE_MENTION_ID.startsWith(mentionState.query.toLowerCase());
// Row count for keyboard clamping: @everyone occupies index 0 when shown.
const mentionRowCount = filteredMembers.length + (showEveryone ? 1 : 0);
const handleTyping = useCallback(() => {
if (typingTimeoutRef.current) return;
const dm = isDmChannel(channelId);
@@ -374,13 +401,14 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
};
const selectMention = useCallback(
(member: MemberWithUser) => {
(entry: MentionEntry) => {
if (!mentionState) return;
const textarea = textareaRef.current;
const cursorPos = textarea?.selectionStart ?? draftText.length;
const before = draftText.slice(0, mentionState.startIndex);
const after = draftText.slice(cursorPos);
const insertion = `<@${member.userId}> `;
const insertion =
entry.kind === 'everyone' ? `${EVERYONE_MENTION_TOKEN} ` : `<@${entry.member.userId}> `;
const newContent = before + insertion + after;
setDraft(channelId, newContent);
setMentionState(null);
@@ -400,11 +428,11 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
const handleKeyDown = (e: React.KeyboardEvent): void => {
// Mention popover keyboard navigation
if (mentionState && filteredMembers.length > 0) {
if (mentionState && mentionRowCount > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setMentionState((prev) =>
prev ? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, filteredMembers.length - 1) } : null,
prev ? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, mentionRowCount - 1) } : null,
);
return;
}
@@ -417,8 +445,12 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const selected = filteredMembers[mentionState.selectedIndex];
if (selected) selectMention(selected);
if (showEveryone && mentionState.selectedIndex === 0) {
selectMention({ kind: 'everyone' });
return;
}
const selected = filteredMembers[mentionState.selectedIndex - (showEveryone ? 1 : 0)];
if (selected) selectMention({ kind: 'member', member: selected });
return;
}
if (e.key === 'Escape') {
@@ -529,21 +561,25 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
};
const handleEmojiSelect = useCallback(
(emoji: { native: string }) => {
(emoji: { native?: string; id?: string }) => {
// Emoji próprio não tem `native`: entra no texto como `:nome:`, que é
// o que o render resolve depois para a imagem.
const inserted = emoji.native ?? (emoji.id ? `:${emoji.id}:` : '');
if (!inserted) return;
const textarea = textareaRef.current;
if (!textarea) {
setDraft(channelId, draftText + emoji.native);
setDraft(channelId, draftText + inserted);
return;
}
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const before = draftText.slice(0, start);
const after = draftText.slice(end);
const newContent = before + emoji.native + after;
const newContent = before + inserted + after;
setDraft(channelId, newContent);
// Restore cursor position after the emoji
const newCursorPos = start + emoji.native.length;
const newCursorPos = start + inserted.length;
requestAnimationFrame(() => {
textarea.focus();
textarea.selectionStart = newCursorPos;
@@ -766,6 +802,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
onClose={() => setActivePopover(null)}
onEmojiSelect={handleEmojiSelect}
onGifSelect={handleGifSelect}
onStickerSelect={handleStickerSelect}
hasStickers={Boolean(stickerSpaceId)}
anchorRef={popoverAnchorRef}
gifEnabled={gifEnabled}
onTabChange={setActivePopover}
@@ -775,7 +813,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
{chatReplyTo && (
<div className="bg-interactive-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-white/[0.06]">
<div className="flex items-center gap-1 text-[14px] text-txt-message truncate">
<span className="opacity-60">Replying to</span>
<span className="opacity-60">{tr('chat.composer.replyingTo')}</span>
<span className="font-bold">
{chatReplyTo.user.displayName ?? chatReplyTo.user.username}
</span>
@@ -783,7 +821,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
<button
onClick={() => chatSetReplyTo(null)}
className="text-txt-tertiary hover:text-txt-primary transition-colors"
aria-label="Cancel reply"
aria-label={tr('chat.composer.cancelReply')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
@@ -804,6 +842,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
selectedIndex={mentionState.selectedIndex}
onSelect={selectMention}
anchorRef={inputContainerRef}
showEveryone={showEveryone}
/>
)}
@@ -867,7 +906,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
<button
onClick={() => removeStagedTransfer(t.id)}
className="absolute -top-2 -right-2 w-7 h-7 bg-accent-rose hover:bg-accent-rose/80 shadow-elevation-high rounded-lg flex items-center justify-center text-white transition-colors z-10"
aria-label="Remove attachment"
aria-label={tr('chat.composer.removeAttachment')}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" />
@@ -886,8 +925,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
<button
onClick={() => fileInputRef.current?.click()}
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0"
title="Attach file"
aria-label="Attach file"
title={tr('chat.composer.attach')}
aria-label={tr('chat.composer.attach')}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
@@ -915,14 +954,16 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={canAttachFiles ? handlePaste : undefined}
placeholder={placeholder ?? `Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`}
placeholder={placeholder ?? tr('chat.composer.placeholder', {
channel: channelName.startsWith('@') ? channelName : `#${channelName}`,
})}
className="input-embedded flex-1 py-[10px] px-1 resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
rows={1}
/>
{/* Active-upload indicator */}
{anyActiveOrQueued && (
<div className="p-3 text-txt-tertiary" title="Uploading…" aria-label="Uploading">
<div className="p-3 text-txt-tertiary" title={tr('chat.composer.uploadingEllipsis')} aria-label={tr('chat.composer.uploading')}>
<svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
@@ -934,7 +975,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
{failedCount > 0 && (
<span
className="text-[12px] font-medium text-accent-rose px-1 flex-shrink-0"
title="Remove or retry the failed attachment to send"
title={tr('chat.composer.failedAttachment')}
>
{failedCount} failed
</span>
@@ -956,8 +997,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
title="GIF"
aria-label="GIF picker"
title={tr('chat.composer.gif')}
aria-label={tr('chat.composer.gifPicker')}
>
{/* Outlined badge, not a filled block: the solid rectangle read as
a plain square rather than a GIF picker. Letters reuse the
@@ -977,8 +1018,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
title="Emoji"
aria-label="Emoji picker"
title={tr('chat.composer.emoji')}
aria-label={tr('chat.composer.emojiPicker')}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
@@ -991,8 +1032,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
onClick={() => void handleSubmit()}
disabled={anyUnshippable}
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] bg-accent-primary hover:bg-accent-primary-hover text-white transition-all duration-150 flex-shrink-0 disabled:opacity-50"
aria-label="Send message"
title="Send"
aria-label={tr('chat.composer.sendMessage')}
title={tr('chat.composer.send')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M3.4 20.4l17.45-7.48a1 1 0 000-1.84L3.4 3.6a.993.993 0 00-1.39.91L2 9.12c0 .5.37.93.87.99L17 12 2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z" />
@@ -0,0 +1,94 @@
import { useEffect, useRef, useState } from 'react';
import type { MessageWithUser } from '@backspace/shared';
import { api } from '../../api/client';
import { Avatar } from '../ui/Avatar';
import { useT } from '../../i18n';
interface PinsPopoverProps {
channelId: string;
onClose: () => void;
onJumpToMessage: (messageId: string) => void;
}
export function PinsPopover({ channelId, onClose, onJumpToMessage }: PinsPopoverProps) {
const t = useT();
const [messages, setMessages] = useState<MessageWithUser[]>([]);
const [loading, setLoading] = useState(true);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
api.pins.list(channelId)
.then(({ messages: list }) => { if (!cancelled) setMessages(list); })
.catch(() => { if (!cancelled) setMessages([]); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [channelId]);
useEffect(() => {
const onPointer = (e: MouseEvent | TouchEvent) => {
if (!panelRef.current?.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
// touchstart junto de mousedown: o iOS Safari não sintetiza mousedown de
// toque de forma confiável, como os outros popovers deste projeto tratam.
document.addEventListener('mousedown', onPointer);
document.addEventListener('touchstart', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('touchstart', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [onClose]);
return (
<div
ref={panelRef}
className="absolute right-0 top-full mt-2 z-[300] w-[380px] max-h-[460px] glass rounded-xl overflow-hidden flex flex-col shadow-xl"
>
<div className="px-4 py-3 border-b border-border-soft shrink-0">
<span className="text-[13px] font-semibold text-txt-primary">{t('pins.title')}</span>
</div>
<div className="flex-1 overflow-y-auto scrollbar-thin p-2">
{loading ? (
<div className="space-y-2 p-1">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-14 rounded-lg bg-surface-elevated animate-pulse" />
))}
</div>
) : messages.length === 0 ? (
<p className="text-[12px] text-txt-tertiary p-3">{t('pins.empty')}</p>
) : (
<ul className="space-y-1">
{messages.map((m) => (
<li key={m.id}>
<button
onClick={() => { onJumpToMessage(m.id); onClose(); }}
className="w-full text-left flex gap-2.5 p-2 rounded-lg hover:bg-interactive-hover transition-colors"
>
<Avatar
src={m.user.avatar}
name={m.user.displayName ?? m.user.username}
size={28}
userId={m.user.id}
/>
<div className="min-w-0 flex-1">
<div className="text-[12px] font-semibold text-txt-primary truncate">
{m.user.displayName ?? m.user.username}
</div>
<div className="text-[12px] text-txt-secondary line-clamp-2 break-words">
{m.content || ''}
</div>
</div>
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
}
@@ -1,4 +1,6 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useT } from '../../i18n';
import { parseSearchQuery } from '../../utils/searchQuery';
import { createPortal } from 'react-dom';
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
import { isDmChannel, getChannelOrigin, getApiForOrigin } from '../../stores/spaceStore';
@@ -103,6 +105,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
});
const [query, setQuery] = useState('');
const t = useT();
const [fromFilter, setFromFilter] = useState('');
const [hasFilter, setHasFilter] = useState('');
const [beforeFilter, setBeforeFilter] = useState('');
@@ -153,8 +156,16 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
}, [open, onClose]);
const doSearch = useCallback(async (searchOffset = 0) => {
const trimmed = query.trim();
if (!trimmed && !fromFilter && !hasFilter && !beforeFilter && !afterFilter) {
// Filtros digitados na consulta (`de:fulano`) valem sobre os do painel:
// quem acabou de escrever está expressando a intenção mais recente.
const parsed = parseSearchQuery(query);
const trimmed = parsed.text;
const effFrom = parsed.from ?? fromFilter;
const effHas = parsed.has ?? hasFilter;
const effBefore = parsed.before ?? beforeFilter;
const effAfter = parsed.after ?? afterFilter;
if (!trimmed && !effFrom && !effHas && !effBefore && !effAfter) {
setResults([]);
setTotalCount(0);
return;
@@ -166,10 +177,10 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
const client = getApiForOrigin(origin);
const params = {
q: trimmed || undefined,
from: fromFilter || undefined,
has: hasFilter || undefined,
before: beforeFilter || undefined,
after: afterFilter || undefined,
from: effFrom || undefined,
has: effHas || undefined,
before: effBefore || undefined,
after: effAfter || undefined,
offset: searchOffset,
limit: 25,
};
@@ -224,7 +235,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search messages..."
placeholder={t('search.placeholder')}
className="input-embedded flex-1 text-[14px]"
/>
{query && (
@@ -257,30 +268,30 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
{showFilters && (
<div className="mt-2 grid grid-cols-2 gap-2">
<div>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">From</label>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.from')}</label>
<input
type="text"
value={fromFilter}
onChange={(e) => setFromFilter(e.target.value)}
placeholder="username"
placeholder={t('search.fromPlaceholder')}
className="input-search w-full px-2 py-1 text-[13px]"
/>
</div>
<div>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">Has</label>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.has')}</label>
<select
value={hasFilter}
onChange={(e) => setHasFilter(e.target.value)}
className="input-search w-full px-2 py-1 text-[13px] appearance-none cursor-pointer"
>
<option value="">Any</option>
<option value="file">File</option>
<option value="image">Image</option>
<option value="link">Link</option>
<option value="">{t('search.hasAny')}</option>
<option value="file">{t('search.hasFile')}</option>
<option value="image">{t('search.hasImage')}</option>
<option value="link">{t('search.hasLink')}</option>
</select>
</div>
<div>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">Before</label>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.before')}</label>
<input
type="date"
value={beforeFilter}
@@ -289,7 +300,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
/>
</div>
<div>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">After</label>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.after')}</label>
<input
type="date"
value={afterFilter}
@@ -0,0 +1,62 @@
import { useEffect } from 'react';
import { api } from '../../api/client';
import { useExpressionStore } from '../../stores/expressionStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useT } from '../../i18n';
interface StickerPickerProps {
onStickerSelect: (stickerId: string) => void;
mobile?: boolean;
}
export function StickerPicker({ onStickerSelect, mobile = false }: StickerPickerProps) {
const t = useT();
const spaceId = useSpaceStore((s) => s.currentSpaceId);
const stickers = useExpressionStore((s) => (spaceId ? s.stickersBySpace.get(spaceId) : undefined)) ?? [];
const load = useExpressionStore((s) => s.load);
useEffect(() => {
if (spaceId) void load(spaceId);
}, [spaceId, load]);
// Mesmas dimensões do seletor de GIF, para as abas do popover não pularem de
// tamanho ao alternar.
const rootClass = mobile
? 'flex flex-col flex-1 min-h-0 w-full'
: 'flex flex-col h-[390px] w-[390px]';
return (
<div className={rootClass}>
<div className="px-3 pt-3 pb-2 shrink-0">
<span className="text-[11px] font-semibold uppercase tracking-wider text-txt-tertiary">
{t('expressions.pickerTitle')}
</span>
</div>
<div className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-2">
{stickers.length === 0 ? (
<p className="text-[12px] text-txt-tertiary p-3">{t('expressions.pickerEmpty')}</p>
) : (
<div className="grid grid-cols-3 gap-2">
{stickers.map((sticker) => (
<button
key={sticker.id}
type="button"
onClick={() => onStickerSelect(sticker.id)}
title={sticker.name}
className="aspect-square rounded-lg bg-surface-elevated hover:brightness-125 transition-all flex items-center justify-center p-2"
>
<img
src={api.uploads.url(sticker.filename)}
alt={sticker.name}
className="max-w-full max-h-full object-contain"
loading="lazy"
/>
</button>
))}
</div>
)}
</div>
</div>
);
}
@@ -96,3 +96,26 @@ describe('SystemMessage — icon_changed', () => {
expect(screen.getByText(/Heidi updated the group icon/)).toBeDefined();
});
});
describe('SystemMessage — message_pinned (space channel, no roster)', () => {
it('names the actor from message.user and offers both links', async () => {
const { useUIStore } = await import('../../stores/uiStore');
const msg = buildMessage({ event: 'message_pinned', messageId: 'M9' });
renderSM(msg, null);
expect(screen.getByText(/Heidi pinned/)).toBeDefined();
const before = useUIStore.getState().pinsPanelRequest;
screen.getByText('a message').click();
expect(useUIStore.getState().jumpToMessageRequest).toBe('M9');
screen.getByText('See all pinned messages.').click();
expect(useUIStore.getState().pinsPanelRequest).toBe(before + 1);
});
it('falls back to the plain renderer when messageId is missing', () => {
// A malformed payload must not throw — it degrades to the generic branch.
const msg = buildMessage({ event: 'message_pinned' });
expect(() => renderSM(msg, null)).not.toThrow();
});
});
@@ -1,5 +1,7 @@
import type { DmChannel, MessageWithUser, SpaceInviteSystemPayload, User } from '@backspace/shared';
import { SpaceInviteCard } from './SpaceInviteCard';
import { useT } from '../../i18n';
import { useUIStore } from '../../stores/uiStore';
interface SystemMessageProps {
message: MessageWithUser;
@@ -13,15 +15,20 @@ interface SystemMessageProps {
dm?: Pick<DmChannel, 'members'> | null;
}
function resolveActorName(message: MessageWithUser, dm?: Pick<DmChannel, 'members'> | null): string {
function resolveActorName(
message: MessageWithUser,
dm: Pick<DmChannel, 'members'> | null | undefined,
unknown: string,
someone: string,
): string {
if (dm) {
const fromRoster = dm.members.find(m => m.id === message.userId) as User | undefined;
if (fromRoster) {
return fromRoster.displayName ?? fromRoster.username ?? 'Unknown';
return fromRoster.displayName ?? fromRoster.username ?? unknown;
}
return 'Unknown';
return unknown;
}
return message.user?.displayName ?? message.user?.username ?? 'Someone';
return message.user?.displayName ?? message.user?.username ?? someone;
}
/**
@@ -32,10 +39,11 @@ function resolveActorName(message: MessageWithUser, dm?: Pick<DmChannel, 'member
* Exported so unit tests can render it directly without mounting MessageList.
*/
export function SystemMessage({ message, dm }: SystemMessageProps) {
const t = useT();
let data: Record<string, unknown> = {};
try { data = JSON.parse(message.content ?? '{}'); } catch { /* fall through to default branch */ }
const actorName = resolveActorName(message, dm);
const actorName = resolveActorName(message, dm, t('system.unknown'), t('system.someone'));
if (data.event === 'space_invite') {
return (
@@ -45,38 +53,65 @@ export function SystemMessage({ message, dm }: SystemMessageProps) {
);
}
// The pin notice has two links in one sentence — the pinned message, and the
// pins panel — so it is assembled from parts around the placeholders rather
// than as flat text. Word order stays with the translation.
if (data.event === 'message_pinned' && typeof data.messageId === 'string') {
const pinnedId = data.messageId;
const parts = t('system.pinned', { actor: actorName, message: '\u0000', seeAll: '\u0001' });
const [beforeMsg, rest = ''] = parts.split('\u0000');
const [between, after = ''] = rest.split('\u0001');
const linkClass = 'text-txt-secondary hover:text-txt-primary hover:underline cursor-pointer';
return (
<div className="flex items-center justify-center py-1 px-4 select-none">
<span className="text-xs text-txt-tertiary">
<span className="mr-1.5">📌</span>
{beforeMsg}
<span className={linkClass} onClick={() => useUIStore.getState().requestJumpToMessage(pinnedId)}>
{t('system.pinned.message')}
</span>
{between}
<span className={linkClass} onClick={() => useUIStore.getState().requestPinsPanel()}>
{t('system.pinned.seeAll')}
</span>
{after}
</span>
</div>
);
}
// Inline-text events.
let text = '';
let icon = '';
switch (data.event) {
case 'member_added':
icon = '→'; // →
text = `${actorName} added ${data.targetDisplayName} to the group`;
text = t('system.memberAdded', { actor: actorName, target: String(data.targetDisplayName) });
break;
case 'member_removed':
if (data.reason === 'leave') {
icon = '←'; // ←
text = `${data.targetDisplayName} left the group`;
text = t('system.memberLeft', { target: String(data.targetDisplayName) });
} else {
icon = '←';
text = `${actorName} removed ${data.targetDisplayName} from the group`;
text = t('system.memberRemoved', { actor: actorName, target: String(data.targetDisplayName) });
}
break;
case 'owner_changed':
icon = '♛'; // ♛
text = `${data.newOwnerDisplayName} is now the group owner`;
text = t('system.ownerChanged', { owner: String(data.newOwnerDisplayName) });
break;
case 'name_changed':
icon = '✎'; // ✎
// newName === null is a meaningful "cleared" state — distinct from a
// missing field — so we test for a non-empty string explicitly.
text = typeof data.newName === 'string' && data.newName.length > 0
? `${actorName} renamed the group to "${data.newName}"`
: `${actorName} cleared the group name`;
? t('system.nameChanged', { actor: actorName, name: data.newName })
: t('system.nameCleared', { actor: actorName });
break;
case 'icon_changed':
icon = '\u{1F5BC}'; // 🖼
text = `${actorName} updated the group icon`;
text = t('system.iconChanged', { actor: actorName });
break;
default:
text = message.content ?? '';

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