From f4e64eea2335b62c14fe10e34b1c08b1430083b0 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 18 Aug 2026 16:48:25 -0400 Subject: [PATCH] refactor(gui): extract the share dialogs' pure logic into tested modules --- src/gui/src/UI/Dashboard/shareAvatar.js | 47 +++++++++++++ src/gui/src/UI/Dashboard/shareAvatar.test.js | 51 ++++++++++++++ src/gui/src/UI/UIWindowShare.js | 26 +------- src/gui/src/helpers/share_modes.js | 58 ++++++++++++++++ src/gui/src/helpers/share_modes.test.js | 70 ++++++++++++++++++++ 5 files changed, 227 insertions(+), 25 deletions(-) create mode 100644 src/gui/src/UI/Dashboard/shareAvatar.js create mode 100644 src/gui/src/UI/Dashboard/shareAvatar.test.js create mode 100644 src/gui/src/helpers/share_modes.js create mode 100644 src/gui/src/helpers/share_modes.test.js diff --git a/src/gui/src/UI/Dashboard/shareAvatar.js b/src/gui/src/UI/Dashboard/shareAvatar.js new file mode 100644 index 000000000..7a77bcb14 --- /dev/null +++ b/src/gui/src/UI/Dashboard/shareAvatar.js @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// The initial-and-color avatar the share modal shows next to each person. + +/** + * Stable, name-derived hue so a person keeps the same color across reopenings. + * + * @param {string} [name] + * @returns {number} hue in [0, 360) + */ +export const avatarHue = (name) => { + let hue = 0; + for ( const char of String(name ?? '') ) { + hue = (hue * 31 + char.codePointAt(0)) % 360; + } + return hue; +}; + +/** + * The name's first character, uppercased; `?` when there is no name. + * + * @param {string} [name] + * @returns {string} + */ +export const avatarInitial = (name) => { + const trimmed = String(name ?? '').trim(); + // Iterating takes a whole code point; charAt(0) halves an emoji into tofu. + for ( const char of trimmed ) return char.toUpperCase(); + return '?'; +}; diff --git a/src/gui/src/UI/Dashboard/shareAvatar.test.js b/src/gui/src/UI/Dashboard/shareAvatar.test.js new file mode 100644 index 000000000..75965dc53 --- /dev/null +++ b/src/gui/src/UI/Dashboard/shareAvatar.test.js @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { avatarHue, avatarInitial } from './shareAvatar.js'; + +describe('avatarHue', () => { + it('gives the same person the same color every time', () => { + expect(avatarHue('juan')).toBe(avatarHue('juan')); + }); + + it('stays a usable hue', () => { + for ( const name of ['a', 'juan', 'someone@example.com', '𝒥'.repeat(40)] ) { + const hue = avatarHue(name); + expect(Number.isInteger(hue)).toBe(true); + expect(hue).toBeGreaterThanOrEqual(0); + expect(hue).toBeLessThan(360); + } + }); + + it('tells different people apart', () => { + expect(avatarHue('ann')).not.toBe(avatarHue('bob')); + }); + + it('handles a missing name instead of throwing', () => { + expect(avatarHue(undefined)).toBe(0); + expect(avatarHue(null)).toBe(0); + expect(avatarHue('')).toBe(0); + }); +}); + +describe('avatarInitial', () => { + it('uppercases the first letter', () => { + expect(avatarInitial('juan')).toBe('J'); + expect(avatarInitial('Ann')).toBe('A'); + }); + + it('ignores surrounding whitespace', () => { + expect(avatarInitial(' ann ')).toBe('A'); + }); + + it('falls back to ? when there is no name', () => { + // A pending invite has no username until the recipient joins. + expect(avatarInitial('')).toBe('?'); + expect(avatarInitial(' ')).toBe('?'); + expect(avatarInitial(undefined)).toBe('?'); + expect(avatarInitial(null)).toBe('?'); + }); + + it('keeps an astral first character whole', () => { + // charAt(0) would return half a surrogate pair and render as tofu. + expect(avatarInitial('😀 team')).toBe('😀'); + }); +}); diff --git a/src/gui/src/UI/UIWindowShare.js b/src/gui/src/UI/UIWindowShare.js index 98f0de4b7..1f6986371 100644 --- a/src/gui/src/UI/UIWindowShare.js +++ b/src/gui/src/UI/UIWindowShare.js @@ -23,31 +23,7 @@ import path from '../lib/path.js'; import { owner_of_path } from '../helpers/path_owner.js'; import { invalidate_shared_roots } from '../helpers/shared_access.js'; import { icons } from '../helpers/actionIcons.js'; - -// Offered when granting. The API accepts `see` and `list` too, but they are a -// developer-level distinction with no place in this dialog — a row already set -// to one is shown as-is rather than quietly rounded up to `read`. -const MODES = ['read', 'write', 'manage']; - -// Already HTML-safe: `i18n()` encodes what it returns, and an unencoded mode -// from the API is encoded here. Encoding a label again turns the `&` in -// "Can edit & share" into a literal `&`. -const mode_label = (mode) => { - if ( mode === 'write' ) return i18n('share_access_write'); - if ( mode === 'manage' ) return i18n('share_access_manage'); - if ( mode === 'read' ) return i18n('share_access_read'); - return html_encode(mode); -}; - -const options_for = (current) => { - const modes = MODES.includes(current) ? MODES : [current, ...MODES]; - return modes - .map( - (mode) => - ``, - ) - .join(''); -}; +import { mode_label, options_for } from '../helpers/share_modes.js'; /** * Sharing dialog for one file or directory. diff --git a/src/gui/src/helpers/share_modes.js b/src/gui/src/helpers/share_modes.js new file mode 100644 index 000000000..5261a9cd3 --- /dev/null +++ b/src/gui/src/helpers/share_modes.js @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// Access levels the sharing dialogs offer, shared so the two can't drift. + +// The API also accepts `see` and `list`, a developer-level distinction with no +// place in these dialogs; a row already set to one is shown as-is. +export const MODES = ['read', 'write', 'manage']; + +/** + * Human-readable label for an access mode, ready to drop into HTML. + * + * Already HTML-safe — encoding it again renders the `&` in "Can edit & share". + * + * @param {string} mode + * @returns {string} HTML-safe label + */ +export const mode_label = (mode) => { + if ( mode === 'write' ) return i18n('share_access_write'); + if ( mode === 'manage' ) return i18n('share_access_manage'); + if ( mode === 'read' ) return i18n('share_access_read'); + return html_encode(mode); +}; + +/** + * `