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>
This commit is contained in:
2026-09-02 23:15:14 -03:00
co-authored by Claude Fable 5.1
parent a41a944742
commit 872066e5be
57 changed files with 4837 additions and 123 deletions
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:
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "1.5.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

+28 -21
View File
@@ -40,24 +40,31 @@ import {
import { migrateUserData } from './userDataMigration';
// Override Electron's package.json-derived app name so userData lives at
// "<appData>/Backspace" instead of leaking the monorepo's "@backspace/desktop"
// "<appData>/Resenhacord" instead of leaking the monorepo's "@backspace/desktop"
// package name. Must run before any app.getPath('userData') consumer.
app.setName('Backspace');
app.setName('Resenhacord');
// One-time migration from the historical scoped path. After the move the old
// folder is gone, so subsequent launches hit the old-missing no-op branch.
// One-time migrations, oldest first, so an install that skipped a release
// still walks the whole chain: "@backspace/desktop" → "Backspace" →
// "Resenhacord". Each step is a no-op once its source folder is gone, and a
// step whose target already has data leaves it alone (`new-populated`), so a
// user who already ran the renamed build is never overwritten by stale files.
// Without the second step the rename would have logged everyone out: the
// instance URL and session live under userData.
{
const appDataDir = app.getPath('appData');
const oldParent = path.join(appDataDir, '@backspace');
const result = migrateUserData({
oldDir: path.join(oldParent, 'desktop'),
newDir: path.join(appDataDir, 'Backspace'),
oldParent,
});
if (result.kind === 'migrated') {
console.log(`[userData] migrated ${result.from} → ${result.to}`);
} else if (result.kind === 'failed') {
console.error('[userData] migration failed:', result.error);
const legacyParent = path.join(appDataDir, '@backspace');
const steps: { oldDir: string; newDir: string; oldParent: string }[] = [
{ oldDir: path.join(legacyParent, 'desktop'), newDir: path.join(appDataDir, 'Backspace'), oldParent: legacyParent },
{ oldDir: path.join(appDataDir, 'Backspace'), newDir: path.join(appDataDir, 'Resenhacord'), oldParent: appDataDir },
];
for (const step of steps) {
const result = migrateUserData(step);
if (result.kind === 'migrated') {
console.log(`[userData] migrated ${result.from} → ${result.to}`);
} else if (result.kind === 'failed') {
console.error('[userData] migration failed:', result.error);
}
}
}
@@ -241,7 +248,7 @@ function applyLoginItemSettings(openAtLogin: boolean, startMinimized: boolean):
enabled: openAtLogin,
path: process.execPath,
args: startMinimized ? ['--hidden'] : [],
name: 'Backspace',
name: 'Resenhacord',
});
} else {
// Linux: setLoginItemSettings creates ~/.config/autostart/<name>.desktop.
@@ -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.
@@ -710,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
@@ -745,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
@@ -853,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(),
);
@@ -1104,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 @@
ALTER TABLE `messages` ADD `type` text DEFAULT 'user' NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -127,6 +127,13 @@
"when": 1788295936825,
"tag": "0017_purple_wildside",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1788401450986,
"tag": "0018_eminent_kid_colt",
"breakpoints": true
}
]
}
+4
View File
@@ -89,6 +89,10 @@ export const messages = sqliteTable('messages', {
// 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({
+28 -1
View File
@@ -150,6 +150,7 @@ export function buildMessageWithUser(
// 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 => ({
@@ -628,8 +629,9 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
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: Date.now(), pinnedBy: request.userId })
.set({ pinnedAt: now, pinnedBy: request.userId })
.where(eq(schema.messages.id, message.id)).run();
connectionManager.sendToChannel(spaceId, message.channelId, {
@@ -638,6 +640,31 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
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 });
});
+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.

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

+29 -12
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>
</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

+1 -1
View File
@@ -232,7 +232,7 @@ export function JoinPage() {
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
@@ -64,7 +64,8 @@ export function NotificationController() {
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';
@@ -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,6 +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;
@@ -52,6 +53,33 @@ 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 = '';
@@ -353,7 +353,7 @@ export function AppLayout() {
if (!user || showBootSkeleton) {
return (
<div className="h-full flex bg-surface-base" role="status" aria-label="Loading Backspace">
<div className="h-full flex bg-surface-base" role="status" aria-label="Loading Resenhacord">
{/* Space strip */}
<div className="w-[72px] hidden md:flex flex-col items-center gap-3 pt-4 bg-surface-base flex-shrink-0">
{Array.from({ length: 5 }, (_, i) => (
@@ -56,6 +56,20 @@ export function MainContent() {
const [searchOpen, setSearchOpen] = useState(false);
const [pinsOpen, setPinsOpen] = useState(false);
const [jumpToMessageId, setJumpToMessageId] = useState<string | null>(null);
const pinsPanelRequest = useUIStore((st) => st.pinsPanelRequest);
const jumpToMessageRequest = useUIStore((st) => st.jumpToMessageRequest);
const clearJumpToMessageRequest = useUIStore((st) => st.clearJumpToMessageRequest);
// Requests arrive from the pin system notice (see SystemMessage). The
// counter starts at 0 and only ever increments, so the initial render is a
// no-op and every later change is a real click.
useEffect(() => {
if (pinsPanelRequest > 0) setPinsOpen(true);
}, [pinsPanelRequest]);
useEffect(() => {
if (!jumpToMessageRequest) return;
setJumpToMessageId(jumpToMessageRequest);
clearJumpToMessageRequest();
}, [jumpToMessageRequest, clearJumpToMessageRequest]);
// Resolve the DM header's "first other" member through the canonical view
// cache. The hook must be called unconditionally at the component top, so we
@@ -111,7 +111,7 @@ function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMe
const buttonContent = (
<button onClick={onClick} className={`${getButtonClasses()} ${dimmed ? 'opacity-40 saturate-50' : ''}`} style={backgroundStyle} title={tooltipText ? undefined : name}>
{type === 'dm' ? (
<img src="/icons/logo-mark.svg" alt="Backspace" className="w-[25px] h-auto" />
<img src="/icons/logo-mark.svg" alt="Resenhacord" className="w-[25px] h-auto" />
) : type === 'action' ? (
actionType === 'add' ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
@@ -246,6 +246,7 @@ export function SoundController() {
// shape (where DM messages may carry dmChannelId at runtime).
for (const { channelId, message } of newEvents) {
if (!channelId) continue;
if (message.type === 'system') continue; // a pin notice is not a message to you
const isDm = isDmChannel(channelId);
if (
shouldPlayMessageSound({
+10 -4
View File
@@ -352,11 +352,11 @@ export const en = {
// Settings — desktop
'settings.desktop.title': 'Desktop',
'settings.desktop.startAtBoot': 'Start at boot',
'settings.desktop.startAtBoot.description': 'Automatically launch Backspace when you log in',
'settings.desktop.startAtBoot.description': 'Automatically launch Resenhacord when you log in',
'settings.desktop.startMinimized': 'Start minimized',
'settings.desktop.startMinimized.description': 'Start hidden in the system tray instead of showing the window',
'settings.desktop.version': 'Version {version}',
'settings.desktop.appName': 'Backspace Desktop',
'settings.desktop.appName': 'Resenhacord Desktop',
'settings.desktop.update.description': 'Check for new versions of the desktop app',
'settings.desktop.update.check': 'Check for Updates',
'settings.desktop.update.checking': 'Checking...',
@@ -383,7 +383,7 @@ export const en = {
'settings.keybinds.delete': 'Delete',
'settings.keybinds.escToCancel': 'ESC to cancel',
'settings.keybinds.accessibility.title': 'Accessibility Permission Required',
'settings.keybinds.accessibility.description': 'Backspace needs Accessibility permission for global shortcuts to work outside the app.',
'settings.keybinds.accessibility.description': 'Resenhacord needs Accessibility permission for global shortcuts to work outside the app.',
'settings.keybinds.accessibility.grant': 'Grant Permission',
'settings.keybinds.hookError.title': 'Global Shortcuts Unavailable',
'settings.keybinds.hookError.description': 'Failed to start input listener. On Linux, your user may need to be in the {group} group.',
@@ -786,7 +786,7 @@ export const en = {
// Instance settings — general
'instance.general.title': 'General',
'instance.general.description': 'Configure your Backspace instance. These settings affect all users.',
'instance.general.description': 'Configure your Resenhacord instance. These settings affect all users.',
'instance.general.loading': 'Loading settings...',
'instance.general.name': 'Instance Name',
'instance.general.name.description': 'The name shown on the login page and to federated instances.',
@@ -1026,6 +1026,12 @@ export const en = {
'mention.everyone.section': 'Special',
'mention.unknownUser': 'Unknown User',
'mention.members': 'Members',
// Pin notice
'system.pinned': '{actor} pinned {message} to this channel. {seeAll}',
'system.pinned.message': 'a message',
'system.pinned.seeAll': 'See all pinned messages.',
'system.preview.pinned': '{actor} pinned a message',
'join.page.back': 'Back to Resenhacord',
} as const;
export type TranslationKey = keyof typeof en;
+10 -4
View File
@@ -351,11 +351,11 @@ export const ptBR: Partial<Dictionary> = {
// Configurações — área de trabalho
'settings.desktop.title': 'Área de trabalho',
'settings.desktop.startAtBoot': 'Abrir junto com o sistema',
'settings.desktop.startAtBoot.description': 'Inicia o Backspace sozinho quando você faz login',
'settings.desktop.startAtBoot.description': 'Inicia o Resenhacord sozinho quando você faz login',
'settings.desktop.startMinimized': 'Abrir minimizado',
'settings.desktop.startMinimized.description': 'Começa escondido na bandeja em vez de abrir a janela',
'settings.desktop.version': 'Versão {version}',
'settings.desktop.appName': 'Backspace Desktop',
'settings.desktop.appName': 'Resenhacord Desktop',
'settings.desktop.update.description': 'Procurar versões novas do aplicativo',
'settings.desktop.update.check': 'Procurar atualizações',
'settings.desktop.update.checking': 'Procurando...',
@@ -382,7 +382,7 @@ export const ptBR: Partial<Dictionary> = {
'settings.keybinds.delete': 'Excluir',
'settings.keybinds.escToCancel': 'ESC para cancelar',
'settings.keybinds.accessibility.title': 'Precisa da permissão de Acessibilidade',
'settings.keybinds.accessibility.description': 'O Backspace precisa da permissão de Acessibilidade para os atalhos funcionarem fora do aplicativo.',
'settings.keybinds.accessibility.description': 'O Resenhacord precisa da permissão de Acessibilidade para os atalhos funcionarem fora do aplicativo.',
'settings.keybinds.accessibility.grant': 'Conceder permissão',
'settings.keybinds.hookError.title': 'Atalhos globais indisponíveis',
'settings.keybinds.hookError.description': 'Não foi possível iniciar o leitor de entrada. No Linux, talvez o seu usuário precise estar no grupo {group}.',
@@ -785,7 +785,7 @@ export const ptBR: Partial<Dictionary> = {
// Configurações da instância — geral
'instance.general.title': 'Geral',
'instance.general.description': 'Configure a sua instância do Backspace. Estas opções valem para todo mundo.',
'instance.general.description': 'Configure a sua instância do Resenhacord. Estas opções valem para todo mundo.',
'instance.general.loading': 'Carregando configurações...',
'instance.general.name': 'Nome da instância',
'instance.general.name.description': 'O nome que aparece na tela de login e para as instâncias federadas.',
@@ -1025,4 +1025,10 @@ export const ptBR: Partial<Dictionary> = {
'mention.everyone.section': 'Especial',
'mention.unknownUser': 'Usuário desconhecido',
'mention.members': 'Membros',
// Aviso de mensagem fixada
'system.pinned': '{actor} fixou {message} neste canal. {seeAll}',
'system.pinned.message': 'uma mensagem',
'system.pinned.seeAll': 'Ver todas as mensagens fixadas.',
'system.preview.pinned': '{actor} fixou uma mensagem',
'join.page.back': 'Voltar ao Resenhacord',
};
+16
View File
@@ -32,6 +32,17 @@ interface Toast {
}
interface UIState {
/**
* Requests from far-away components to the channel header. The pins panel
* and the jump target are local state in MainContent; a system notice deep
* in the timeline has no way to reach them except through the store. The
* counter changes on every request so repeated clicks re-trigger.
*/
pinsPanelRequest: number;
requestPinsPanel: () => void;
jumpToMessageRequest: string | null;
requestJumpToMessage: (messageId: string) => void;
clearJumpToMessageRequest: () => void;
sidebarOpen: boolean;
memberListOpen: boolean;
activeModal: ModalType;
@@ -89,6 +100,11 @@ interface UIState {
export const useUIStore = create<UIState>()(
persist(
(set, get) => ({
pinsPanelRequest: 0,
requestPinsPanel: () => set((st) => ({ pinsPanelRequest: st.pinsPanelRequest + 1 })),
jumpToMessageRequest: null,
requestJumpToMessage: (messageId) => set({ jumpToMessageRequest: messageId }),
clearJumpToMessageRequest: () => set({ jumpToMessageRequest: null }),
sidebarOpen: true,
memberListOpen: true,
activeModal: null,
+3
View File
@@ -173,6 +173,9 @@ function formatSystemPreview(content: string | null, actor: User | null | undefi
case 'icon_changed': {
return tr('system.iconChanged', { actor: actorName });
}
case 'message_pinned': {
return tr('system.preview.pinned', { actor: actorName });
}
default:
return tr('system.preview.generic');
}
+3 -3
View File
@@ -11,9 +11,9 @@ export default defineConfig({
registerType: 'autoUpdate',
includeAssets: ['icons/favicon-32.png', 'icons/favicon-16.png', 'icons/apple-touch-icon.png'],
manifest: {
name: 'Backspace',
short_name: 'Backspace',
description: 'The self-hosted Discord and TeamSpeak alternative. HD voice, video, and screen share — open source and free (AGPL-3.0).',
name: 'Resenhacord',
short_name: 'Resenhacord',
description: '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).',
display: 'standalone',
start_url: '/',
theme_color: '#0b0b10',