diff --git a/apps/client/package.json b/apps/client/package.json
index de8ec26..1d91008 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -16,7 +16,7 @@
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
- "test:botsu": "node --experimental-strip-types --test src/botsu/apps/catalog.test.ts src/botsu/apps/launcher.test.ts src/botsu/profile/summary.test.ts src/botsu/presence/connection-generation.test.ts src/botsu/presence/cursor-publisher.test.ts src/botsu/presence/cursor-state.test.ts src/botsu/presence/socket-message.test.ts src/botsu/presence/state.test.ts",
+ "test:botsu": "node --experimental-strip-types --test src/botsu/apps/catalog.test.ts src/botsu/apps/launcher.test.ts src/botsu/profile/summary.test.ts src/botsu/presence/compact-presence.test.ts src/botsu/presence/connection-generation.test.ts src/botsu/presence/cursor-publisher.test.ts src/botsu/presence/cursor-state.test.ts src/botsu/presence/socket-message.test.ts src/botsu/presence/state.test.ts src/botsu/shell/layout.test.ts",
"typecheck:botsu": "../../node_modules/.bin/tsc -p tsconfig.botsu.json && ../../node_modules/.bin/tsc -p tsconfig.botsu-ui.json",
"prepare": "husky install",
"commit": "git-cz",
diff --git a/apps/client/src/botsu/presence/BotsuPresence.tsx b/apps/client/src/botsu/presence/BotsuPresence.tsx
index 18f3e5c..6e46028 100644
--- a/apps/client/src/botsu/presence/BotsuPresence.tsx
+++ b/apps/client/src/botsu/presence/BotsuPresence.tsx
@@ -19,6 +19,7 @@ import {
type RemoteCursorState,
} from './cursor-state';
import { createPresenceWebSocketUrl, parsePresenceSocketMessage } from './socket-message';
+import { createCompactPresence } from './compact-presence';
const getInitials = (displayName: string): string =>
displayName
@@ -178,6 +179,7 @@ export function BotsuPresence() {
() => projectVisibleRemoteCursors(remoteCursors, state.participants, location.pathname),
[location.pathname, remoteCursors, state.participants]
);
+ const compactPresence = useMemo(() => createCompactPresence(participants, 4), [participants]);
const statusLabel = {
connecting: 'Connexion…',
@@ -196,13 +198,19 @@ export function BotsuPresence() {
{participants.length > 0 && (
- {participants.map((participant) => {
+ {participants.map((participant, index) => {
const avatarUrl = participant.avatarUrl
? mxcUrlToHttp(mx, participant.avatarUrl, useAuthentication, 48, 48, 'crop') ??
undefined
: undefined;
return (
- -
+
- = compactPresence.visible.length ? 'botsu-visually-hidden' : undefined
+ }
+ key={participant.userId}
+ title={`${participant.displayName} (${participant.userId})`}
+ >
{avatarUrl ? (
@@ -210,10 +218,15 @@ export function BotsuPresence() {
getInitials(participant.displayName)
)}
- {participant.displayName}
+ {participant.displayName}
);
})}
+ {compactPresence.hiddenCount > 0 && (
+ -
+ +{compactPresence.hiddenCount}
+
+ )}
)}
diff --git a/apps/client/src/botsu/presence/compact-presence.test.ts b/apps/client/src/botsu/presence/compact-presence.test.ts
new file mode 100644
index 0000000..ed188a0
--- /dev/null
+++ b/apps/client/src/botsu/presence/compact-presence.test.ts
@@ -0,0 +1,23 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createCompactPresence } from './compact-presence.ts';
+
+const participants = ['Ada', 'Bob', 'Chloé', 'Dina', 'Eli'].map((displayName, index) => ({
+ userId: `@member${index}:botsu.net`,
+ displayName,
+}));
+
+test('compact presence keeps four members visible and counts the remainder', () => {
+ const compact = createCompactPresence(participants, 4);
+
+ assert.deepEqual(
+ compact.visible.map((participant) => participant.displayName),
+ ['Ada', 'Bob', 'Chloé', 'Dina']
+ );
+ assert.equal(compact.hiddenCount, 1);
+ assert.equal(compact.totalCount, 5);
+});
+
+test('compact presence rejects an invalid visible member limit', () => {
+ assert.throws(() => createCompactPresence(participants, 0), /positive integer/);
+});
diff --git a/apps/client/src/botsu/presence/compact-presence.ts b/apps/client/src/botsu/presence/compact-presence.ts
new file mode 100644
index 0000000..b0e163b
--- /dev/null
+++ b/apps/client/src/botsu/presence/compact-presence.ts
@@ -0,0 +1,20 @@
+export type CompactPresence = {
+ visible: readonly T[];
+ hiddenCount: number;
+ totalCount: number;
+};
+
+export const createCompactPresence = (
+ participants: readonly T[],
+ visibleLimit: number
+): CompactPresence => {
+ if (!Number.isInteger(visibleLimit) || visibleLimit < 1) {
+ throw new RangeError('visibleLimit must be a positive integer');
+ }
+
+ return {
+ visible: participants.slice(0, visibleLimit),
+ hiddenCount: Math.max(0, participants.length - visibleLimit),
+ totalCount: participants.length,
+ };
+};
diff --git a/apps/client/src/botsu/shell/layout.test.ts b/apps/client/src/botsu/shell/layout.test.ts
new file mode 100644
index 0000000..805bb7b
--- /dev/null
+++ b/apps/client/src/botsu/shell/layout.test.ts
@@ -0,0 +1,20 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import test from 'node:test';
+
+const shellCssUrl = new URL('./shell.css', import.meta.url);
+
+const getRuleBody = (css: string, selector: string): string => {
+ const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
+ assert.ok(match, `Missing CSS rule for ${selector}`);
+ return match[1];
+};
+
+test('BOTSU shell leaves room for the active call footer', async () => {
+ const css = await readFile(shellCssUrl, 'utf8');
+ const shellRule = getRuleBody(css, '.botsu-shell');
+
+ assert.match(shellRule, /height:\s*100%;/);
+ assert.doesNotMatch(shellRule, /height:\s*100dvh;/);
+});
diff --git a/apps/client/src/botsu/shell/shell.css b/apps/client/src/botsu/shell/shell.css
index 5bb386d..1ec39df 100644
--- a/apps/client/src/botsu/shell/shell.css
+++ b/apps/client/src/botsu/shell/shell.css
@@ -1,7 +1,7 @@
.botsu-shell {
width: 100%;
min-width: 0;
- height: 100dvh;
+ height: 100%;
overflow: auto;
color: var(--botsu-color-text);
background-color: var(--botsu-color-canvas);
@@ -183,7 +183,7 @@
.botsu-presence ul {
display: flex;
justify-content: flex-end;
- gap: 0.3rem;
+ gap: 0;
margin: 0;
padding: 0;
list-style: none;
@@ -192,10 +192,13 @@
.botsu-presence li {
display: flex;
align-items: center;
- gap: 0.3rem;
font-size: 0.68rem;
}
+.botsu-presence li + li {
+ margin-left: -0.35rem;
+}
+
.botsu-presence-avatar {
width: 1.5rem;
height: 1.5rem;
@@ -215,6 +218,31 @@
object-fit: cover;
}
+.botsu-presence-overflow {
+ min-width: 1.5rem;
+ height: 1.5rem;
+ justify-content: center;
+ color: var(--botsu-color-text);
+ background: var(--botsu-color-surface);
+ border: var(--botsu-border-width) solid var(--botsu-color-border);
+ border-radius: 999px;
+ font-family: ui-monospace, monospace;
+ font-size: 0.58rem;
+ font-weight: 800;
+}
+
+.botsu-visually-hidden {
+ position: absolute !important;
+ width: 1px !important;
+ height: 1px !important;
+ padding: 0 !important;
+ margin: -1px !important;
+ overflow: hidden !important;
+ clip: rect(0, 0, 0, 0) !important;
+ white-space: nowrap !important;
+ border: 0 !important;
+}
+
.botsu-remote-cursors {
position: fixed;
inset: 0;