feat(community): „von @name“ statt Anzeigename, Benutzername vor dem Hochladen und Veröffentlichen

Logo-Kacheln, Logo-Infozeile und die Detailseite einer Community-Karte zeigen den Benutzernamen des Kontos (Hilfsfunktion byline: Profil,
sonst alter Anzeigename, sonst „Anonym“). Die Namen kommen je geladener Seite in einer Abfrage nach, ein Fehler dabei bleibt ohne Fehlertext,
ein eigenes Umbenennen aktualisiert die eigenen Einträge sofort.

Die Felder „Anzeigename“ in den Dialogen „Logo hochladen“ und „Veröffentlichen“ fallen weg, ebenso account/display-name.ts und der
localStorage-Schlüssel; der Client schickt displayName: ''. Vor dem Hochladen und Veröffentlichen verlangt requireUsername() einen Namen
(„Abbrechen“: es passiert nichts); antwortet der Dienst mit 403 usernameRequired, öffnet sich der Namensdialog, danach ist ein neuer Versuch möglich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 08:06:44 +00:00
1 parent 3440eb46c3
commit 56cb67be4b
26 files changed
+1247 -407

No files matched your search

-59
View File
@@ -1,59 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DISPLAY_NAME_KEY, rememberDisplayName, rememberedDisplayName } from './display-name';
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
describe('gemerkter Anzeigename (localStorage)', () => {
it('der Schlüssel heißt aircard.displayName', () => {
expect(DISPLAY_NAME_KEY).toBe('aircard.displayName');
});
it('ohne gespeicherten Namen ist er leer', () => {
expect(rememberedDisplayName()).toBe('');
});
it('liest den gespeicherten Namen', () => {
localStorage.setItem(DISPLAY_NAME_KEY, 'Kenso');
expect(rememberedDisplayName()).toBe('Kenso');
});
it('speichert den Namen getrimmt unter dem Schlüssel und überschreibt den alten', () => {
rememberDisplayName(' Kenso ');
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Kenso');
rememberDisplayName('Zoë');
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Zoë');
expect(rememberedDisplayName()).toBe('Zoë');
});
it.each([[''], [' ']])('ein leerer Name (%j) entfernt den Schlüssel („Anonym“)', (empty) => {
localStorage.setItem(DISPLAY_NAME_KEY, 'Kenso');
rememberDisplayName(empty);
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
expect(rememberedDisplayName()).toBe('');
});
it('gesperrter Speicher beim Lesen: leer, kein Fehler', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('gesperrt', 'SecurityError');
});
expect(rememberedDisplayName()).toBe('');
});
it('gesperrter Speicher beim Schreiben und beim Entfernen: kein Fehler', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('voll', 'QuotaExceededError');
});
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
throw new DOMException('gesperrt', 'SecurityError');
});
expect(() => rememberDisplayName('Kenso')).not.toThrow();
expect(() => rememberDisplayName('')).not.toThrow();
});
});
-25
View File
@@ -1,25 +0,0 @@
// Der zuletzt benutzte Anzeigename (Spec § 4): Der Veröffentlichen-Dialog und der Dialog „Logo hochladen“ lesen und schreiben
// denselben Schlüssel in localStorage. Jeder Zugriff steht in try/catch: ist der Speicher gesperrt (z. B. privates Fenster),
// bleibt das Feld leer und der Name wird nicht gemerkt, ohne dass etwas scheitert.
export const DISPLAY_NAME_KEY = 'aircard.displayName';
/** Der gemerkte Anzeigename; leer, wenn keiner gemerkt ist oder der Speicher nicht lesbar ist. */
export function rememberedDisplayName(): string {
try {
return localStorage.getItem(DISPLAY_NAME_KEY) ?? '';
} catch {
return '';
}
}
/** Merkt den Anzeigenamen (getrimmt); ein leerer Name („Anonym“) entfernt den gemerkten. */
export function rememberDisplayName(name: string): void {
const trimmed = name.trim();
try {
if (trimmed === '') localStorage.removeItem(DISPLAY_NAME_KEY);
else localStorage.setItem(DISPLAY_NAME_KEY, trimmed);
} catch {
// Gesperrt oder voll: der Name wird dann nur nicht gemerkt.
}
}
+51
View File
@@ -570,6 +570,57 @@ describe('loadMyUsername', () => {
});
});
describe('byline („von @name“)', () => {
const names = new Map([
['u1', 'Kenso'],
['u2', 'Mia.K'],
]);
it('mit Profil: „von @name“ (der Name, wie er gespeichert ist), der alte Anzeigename zählt dann nicht', () => {
expect(profile.byline('u1', '', names)).toBe('von @Kenso');
expect(profile.byline('u2', 'Alter Name', names)).toBe('von @Mia.K');
});
it('ohne Profil: der früher gespeicherte Anzeigename, ohne @', () => {
expect(profile.byline('u9', 'Anna', names)).toBe('von Anna');
expect(profile.byline('u9', 'Anna', new Map())).toBe('von Anna');
});
it('ohne Profil und ohne Anzeigenamen: „von Anonym“', () => {
expect(profile.byline('u9', '', names)).toBe('von Anonym');
expect(profile.byline('u9', '', new Map())).toBe('von Anonym');
});
it('ein Eintrag ohne Konto-ID (Altbestand) fällt ebenso zurück, auch wenn die Map einen leeren Schlüssel hätte', () => {
expect(profile.byline('', 'Anna', names)).toBe('von Anna');
expect(profile.byline('', '', names)).toBe('von Anonym');
expect(profile.byline('', '', new Map([['', 'Spuk']]))).toBe('von Anonym');
});
it('ein leerer Name in der Map zählt nicht als Profil', () => {
expect(profile.byline('u3', 'Anna', new Map([['u3', '']]))).toBe('von Anna');
});
it('Werte mit geschweiften Klammern oder Markup bleiben unverändert (der eingesetzte Text wird nicht noch einmal ausgewertet)', () => {
expect(profile.byline('u9', '{name}', new Map())).toBe('von {name}');
expect(profile.byline('u9', '<b>fett</b>', new Map())).toBe('von <b>fett</b>');
});
it('Englisch: „by @name“, „by Anna“, „by Anonymous“', async () => {
(await import('../i18n/i18n')).setLang('en', { persist: false });
expect(profile.byline('u1', '', names)).toBe('by @Kenso');
expect(profile.byline('u9', 'Anna', names)).toBe('by Anna');
expect(profile.byline('u9', '', names)).toBe('by Anonymous');
});
it('liest nur die übergebene Map: eine Änderung dort zeigt die nächste Zeile sofort (eigene Umbenennung)', () => {
const own = new Map([['u1', 'Alt']]);
expect(profile.byline('u1', '', own)).toBe('von @Alt');
own.set('u1', 'Neu');
expect(profile.byline('u1', '', own)).toBe('von @Neu');
});
});
describe('Merker „in dieser Sitzung schon gefragt“', () => {
it('beginnt leer; markieren schreibt aircard.usernamePrompted in sessionStorage', () => {
expect(profile.usernamePrompted()).toBe(false);
+12 -1
View File
@@ -1,7 +1,8 @@
// Benutzernamen (Spec 2026-10-05-aircard-benutzernamen-design.md): die Regeln für den Namen, Profile lesen (Tabelle `profiles`: eine
// öffentlich lesbare Zeile je Konto, Zeilen-ID = Konto-ID) und den eigenen Namen speichern (`POST /api/profil` des Dienstes
// `aircard-api`). Der Browser prüft nur vor; was gilt, entscheidet der Dienst: Er prüft Muster und Sperrliste noch einmal, und der
// Unique-Index auf `usernameKey` hält einen Namen auch bei zwei gleichzeitigen Anfragen nur einmal.
// Unique-Index auf `usernameKey` hält einen Namen auch bei zwei gleichzeitigen Anfragen nur einmal. Dazu die Zeile „von @name“ für
// Logos und Karten (`byline`).
import { Query, type Models } from 'appwrite';
import { ID_PATTERN } from '../card/config';
import { apiPost, unavailableError } from '../community/api';
@@ -75,6 +76,16 @@ export function validateUsername(name: string): string | null {
return null;
}
/**
* Die Zeile „von @name“ zu einem Eintrag (Logo-Kachel, Infozeile, Detailseite einer Karte): der Benutzername des Kontos `ownerId`, soweit
* `names` (aus `usernamesFor`) ihn kennt. Ohne Profil gilt der früher gespeicherte Anzeigename (ohne @), sonst „Anonym“. In der Sprache der Seite.
*/
export function byline(ownerId: string, legacyDisplayName: string, names: ReadonlyMap<string, string>): string {
const name = ownerId === '' ? '' : (names.get(ownerId) ?? '');
if (name !== '') return t('von @{name}', { name });
return t('von {name}', { name: legacyDisplayName !== '' ? legacyDisplayName : t('Anonym') });
}
// ---- Namen merken und melden ------------------------------------------------------------------------------
/** Konto-ID → Benutzername, nur gefundene (ein Konto ohne Profil kann sich später einen Namen wählen, also wird es wieder gefragt). */
+24
View File
@@ -690,6 +690,30 @@ describe('requireUsername', () => {
expect(dialogEl()).toBeNull();
});
it('das Signal geht an den Dialog: das Verlassen der Seite schließt ihn, das Ergebnis ist false', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue(null);
const controller = new AbortController();
const result = dialog.requireUsername(controller.signal);
await settle();
expect(dialogEl()?.open).toBe(true);
controller.abort();
await expect(result).resolves.toBe(false);
expect(dialogEl()).toBeNull();
expect(mocks.saveUsername).not.toHaveBeenCalled();
});
it('ein schon abgebrochenes Signal öffnet keinen Dialog (false); mit bekanntem Namen ist es trotzdem true', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue(null);
const controller = new AbortController();
controller.abort();
await expect(dialog.requireUsername(controller.signal)).resolves.toBe(false);
expect(dialogEl()).toBeNull();
mocks.name = 'Kenso';
await expect(dialog.requireUsername(controller.signal)).resolves.toBe(true);
});
it('läuft schon ein Namensdialog (etwa die einmalige Abfrage), teilt sich requireUsername ihn', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue(null);
+3 -3
View File
@@ -238,9 +238,9 @@ export function usernameDialog(opts: UsernameDialogOptions): Promise<string | nu
* vor dem Veröffentlichen). true: es kann weitergehen; false: der Nutzer hat „Abbrechen“ gewählt, es geschieht nichts.
* Ohne angemeldetes Konto gibt es nichts zu fordern (true): die Anmeldung kommt mit der Aktion selbst, und fehlt dem Konto dann der Name,
* antwortet der Dienst mit 403 `usernameRequired` (dann zeigt der Aufrufer den Dialog). Dasselbe gilt, wenn sich das Profil nicht lesen lässt:
* der Dienst entscheidet.
* der Dienst entscheidet. `signal` schließt den Dialog (Seite verlassen); dann ist das Ergebnis false.
*/
export async function requireUsername(): Promise<boolean> {
export async function requireUsername(signal?: AbortSignal): Promise<boolean> {
if (currentUserId() === null) return true;
let name = myUsername();
if (name === null) {
@@ -251,5 +251,5 @@ export async function requireUsername(): Promise<boolean> {
}
}
if (name !== null) return true;
return (await usernameDialog({ mode: 'required' })) !== null;
return (await usernameDialog({ mode: 'required', signal })) !== null;
}