Add room server

This commit is contained in:
Jack Kingsman
2026-03-19 19:22:40 -07:00
parent dbe2915635
commit 5b166c4b66
27 changed files with 1703 additions and 346 deletions
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import { ChatHeader } from '../components/ChatHeader';
import type { Channel, Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
import { CONTACT_TYPE_ROOM } from '../types';
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
@@ -170,6 +171,38 @@ describe('ChatHeader key visibility', () => {
expect(onToggleNotifications).toHaveBeenCalledTimes(1);
});
it('hides trace and notification controls for room-server contacts', () => {
const pubKey = '41'.repeat(32);
const contact: Contact = {
public_key: pubKey,
name: 'Ops Board',
type: CONTACT_TYPE_ROOM,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Ops Board' };
render(
<ChatHeader {...baseProps} conversation={conversation} channels={[]} contacts={[contact]} />
);
expect(screen.queryByRole('button', { name: 'Path Discovery' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Direct Trace' })).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Enable notifications for this conversation' })
).not.toBeInTheDocument();
});
it('hides the delete button for the canonical Public channel', () => {
const channel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public', false);
const conversation: Conversation = { type: 'channel', id: PUBLIC_CHANNEL_KEY, name: 'Public' };
+8 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { getContactAvatar } from '../utils/contactAvatar';
import { CONTACT_TYPE_REPEATER } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
describe('getContactAvatar', () => {
it('returns complete avatar info', () => {
@@ -30,6 +30,13 @@ describe('getContactAvatar', () => {
expect(avatar1.background).toBe(avatar2.background);
});
it('returns room avatar for type=3', () => {
const avatar = getContactAvatar('Ops Board', 'abc123def456', CONTACT_TYPE_ROOM);
expect(avatar.text).toBe('🛖');
expect(avatar.background).toBe('#6b4f2a');
expect(avatar.textColor).toBe('#ffffff');
});
it('non-repeater types use normal avatar', () => {
const avatar0 = getContactAvatar('John', 'abc123', 0);
const avatar1 = getContactAvatar('John', 'abc123', 1);
+64 -1
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ConversationPane } from '../components/ConversationPane';
@@ -41,6 +41,21 @@ vi.mock('../components/RepeaterDashboard', () => ({
RepeaterDashboard: () => <div data-testid="repeater-dashboard" />,
}));
vi.mock('../components/RoomServerPanel', () => ({
RoomServerPanel: ({
onAuthenticatedChange,
}: {
onAuthenticatedChange?: (value: boolean) => void;
}) => (
<div>
<div data-testid="room-server-panel" />
<button type="button" onClick={() => onAuthenticatedChange?.(true)}>
Authenticate room
</button>
</div>
),
}));
vi.mock('../components/MapView', () => ({
MapView: () => <div data-testid="map-view" />,
}));
@@ -216,6 +231,54 @@ describe('ConversationPane', () => {
});
});
it('gates room chat behind room login controls until authenticated', async () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'contact',
id: 'cc'.repeat(32),
name: 'Ops Board',
},
contacts: [
{
public_key: 'cc'.repeat(32),
name: 'Ops Board',
type: 3,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
},
],
})}
/>
);
await waitFor(() => {
expect(screen.getByTestId('room-server-panel')).toBeInTheDocument();
expect(screen.getByTestId('chat-header')).toBeInTheDocument();
});
expect(screen.queryByTestId('message-list')).not.toBeInTheDocument();
expect(screen.queryByTestId('message-input')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Authenticate room' }));
await waitFor(() => {
expect(screen.getByTestId('message-list')).toBeInTheDocument();
expect(screen.getByTestId('message-input')).toBeInTheDocument();
});
});
it('passes unread marker props to MessageList only for channel conversations', async () => {
render(
<ConversationPane
+41 -1
View File
@@ -4,7 +4,7 @@ import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MessageList } from '../components/MessageList';
import type { Message } from '../types';
import { CONTACT_TYPE_ROOM, type Contact, type Message } from '../types';
const scrollIntoViewMock = vi.fn();
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
@@ -81,6 +81,46 @@ describe('MessageList channel sender rendering', () => {
expect(screen.getByText('A')).toBeInTheDocument();
});
it('renders room-server DM messages using stored sender attribution instead of the room contact', () => {
const roomContact: Contact = {
public_key: 'ab'.repeat(32),
name: 'Ops Board',
type: CONTACT_TYPE_ROOM,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
render(
<MessageList
messages={[
createMessage({
type: 'PRIV',
conversation_key: roomContact.public_key,
text: 'status update: ready',
sender_name: 'Alice',
sender_key: '12'.repeat(32),
}),
]}
contacts={[roomContact]}
loading={false}
/>
);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.queryByText('Ops Board')).not.toBeInTheDocument();
expect(screen.getByText('status update: ready')).toBeInTheDocument();
});
it('gives clickable sender avatars an accessible label', () => {
render(
<MessageList
+84 -6
View File
@@ -2,7 +2,13 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Sidebar } from '../components/Sidebar';
import { CONTACT_TYPE_REPEATER, type Channel, type Contact, type Favorite } from '../types';
import {
CONTACT_TYPE_REPEATER,
CONTACT_TYPE_ROOM,
type Channel,
type Contact,
type Favorite,
} from '../types';
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
@@ -51,16 +57,19 @@ function renderSidebar(overrides?: {
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
}) {
const aliceName = 'Alice';
const roomName = 'Ops Board';
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
const flightChannel = makeChannel('BB'.repeat(16), '#flight');
const opsChannel = makeChannel('CC'.repeat(16), '#ops');
const alice = makeContact('11'.repeat(32), aliceName);
const board = makeContact('33'.repeat(32), roomName, CONTACT_TYPE_ROOM);
const relay = makeContact('22'.repeat(32), 'Relay', CONTACT_TYPE_REPEATER);
const unreadCounts = overrides?.unreadCounts ?? {
[getStateKey('channel', flightChannel.key)]: 2,
[getStateKey('channel', opsChannel.key)]: 1,
[getStateKey('contact', alice.public_key)]: 3,
[getStateKey('contact', board.public_key)]: 5,
[getStateKey('contact', relay.public_key)]: 4,
};
@@ -69,7 +78,7 @@ function renderSidebar(overrides?: {
const view = render(
<Sidebar
contacts={[alice, relay]}
contacts={[alice, board, relay]}
channels={channels}
activeConversation={null}
onSelectConversation={vi.fn()}
@@ -87,7 +96,7 @@ function renderSidebar(overrides?: {
/>
);
return { ...view, flightChannel, opsChannel, aliceName };
return { ...view, flightChannel, opsChannel, aliceName, roomName };
}
function getSectionHeaderContainer(title: string): HTMLElement {
@@ -108,6 +117,7 @@ describe('Sidebar section summaries', () => {
expect(within(getSectionHeaderContainer('Favorites')).getByText('2')).toBeInTheDocument();
expect(within(getSectionHeaderContainer('Channels')).getByText('1')).toBeInTheDocument();
expect(within(getSectionHeaderContainer('Contacts')).getByText('3')).toBeInTheDocument();
expect(within(getSectionHeaderContainer('Room Servers')).getByText('5')).toBeInTheDocument();
expect(within(getSectionHeaderContainer('Repeaters')).getByText('4')).toBeInTheDocument();
});
@@ -169,16 +179,25 @@ describe('Sidebar section summaries', () => {
);
});
it('renders room servers in their own section', () => {
const { roomName } = renderSidebar();
expect(screen.getByRole('button', { name: 'Room Servers' })).toBeInTheDocument();
expect(screen.getByText(roomName)).toBeInTheDocument();
});
it('expands collapsed sections during search and restores collapse state after clearing search', async () => {
const { opsChannel, aliceName } = renderSidebar();
const { opsChannel, aliceName, roomName } = renderSidebar();
fireEvent.click(screen.getByRole('button', { name: 'Tools' }));
fireEvent.click(screen.getByRole('button', { name: 'Channels' }));
fireEvent.click(screen.getByRole('button', { name: 'Contacts' }));
fireEvent.click(screen.getByRole('button', { name: 'Room Servers' }));
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
const search = screen.getByLabelText('Search conversations');
fireEvent.change(search, { target: { value: 'alice' } });
@@ -193,19 +212,22 @@ describe('Sidebar section summaries', () => {
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
});
});
it('persists collapsed section state across unmount and remount', () => {
const { opsChannel, aliceName, unmount } = renderSidebar();
const { opsChannel, aliceName, roomName, unmount } = renderSidebar();
fireEvent.click(screen.getByRole('button', { name: 'Tools' }));
fireEvent.click(screen.getByRole('button', { name: 'Channels' }));
fireEvent.click(screen.getByRole('button', { name: 'Contacts' }));
fireEvent.click(screen.getByRole('button', { name: 'Room Servers' }));
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
unmount();
renderSidebar();
@@ -213,6 +235,7 @@ describe('Sidebar section summaries', () => {
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
});
it('renders same-name channels when keys differ and allows selecting both', () => {
@@ -289,6 +312,12 @@ describe('Sidebar section summaries', () => {
const alphaChannel = makeChannel('CC'.repeat(16), '#alpha');
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
const amy = makeContact('22'.repeat(32), 'Amy');
const zebraRoom = makeContact('55'.repeat(32), 'Zebra Room', CONTACT_TYPE_ROOM, {
last_seen: 100,
});
const alphaRoom = makeContact('66'.repeat(32), 'Alpha Room', CONTACT_TYPE_ROOM, {
last_advert: 300,
});
const relayZulu = makeContact('33'.repeat(32), 'Zulu Relay', CONTACT_TYPE_REPEATER, {
last_seen: 100,
});
@@ -297,7 +326,7 @@ describe('Sidebar section summaries', () => {
});
const props = {
contacts: [zed, amy, relayZulu, relayAlpha],
contacts: [zed, amy, zebraRoom, alphaRoom, relayZulu, relayAlpha],
channels: [publicChannel, zebraChannel, alphaChannel],
activeConversation: null,
onSelectConversation: vi.fn(),
@@ -306,6 +335,7 @@ describe('Sidebar section summaries', () => {
[getStateKey('channel', zebraChannel.key)]: 300,
[getStateKey('channel', alphaChannel.key)]: 100,
[getStateKey('contact', zed.public_key)]: 200,
[getStateKey('contact', zebraRoom.public_key)]: 350,
},
unreadCounts: {},
mentions: {},
@@ -328,18 +358,26 @@ describe('Sidebar section summaries', () => {
.getAllByText(/Relay$/)
.map((node) => node.textContent)
.filter((text): text is string => Boolean(text));
const getRoomsOrder = () =>
screen
.getAllByText(/Room$/)
.map((node) => node.textContent)
.filter((text): text is string => Boolean(text));
const { unmount } = render(<Sidebar {...props} />);
expect(getChannelsOrder()).toEqual(['#zebra', '#alpha']);
expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
expect(getRoomsOrder()).toEqual(['Zebra Room', 'Alpha Room']);
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
fireEvent.click(screen.getByRole('button', { name: 'Sort Channels alphabetically' }));
fireEvent.click(screen.getByRole('button', { name: 'Sort Contacts alphabetically' }));
fireEvent.click(screen.getByRole('button', { name: 'Sort Room Servers alphabetically' }));
expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
expect(getContactsOrder()).toEqual(['Amy', 'Zed']);
expect(getRoomsOrder()).toEqual(['Alpha Room', 'Zebra Room']);
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
unmount();
@@ -347,9 +385,49 @@ describe('Sidebar section summaries', () => {
expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
expect(getContactsOrder()).toEqual(['Amy', 'Zed']);
expect(getRoomsOrder()).toEqual(['Alpha Room', 'Zebra Room']);
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
});
it('sorts room servers like contacts by DM recency first, then advert recency', () => {
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
const dmRecentRoom = makeContact('77'.repeat(32), 'DM Recent Room', CONTACT_TYPE_ROOM, {
last_advert: 100,
});
const advertOnlyRoom = makeContact('88'.repeat(32), 'Advert Only Room', CONTACT_TYPE_ROOM, {
last_seen: 300,
});
const noRecencyRoom = makeContact('99'.repeat(32), 'No Recency Room', CONTACT_TYPE_ROOM);
render(
<Sidebar
contacts={[noRecencyRoom, advertOnlyRoom, dmRecentRoom]}
channels={[publicChannel]}
activeConversation={null}
onSelectConversation={vi.fn()}
onNewMessage={vi.fn()}
lastMessageTimes={{
[getStateKey('contact', dmRecentRoom.public_key)]: 400,
}}
unreadCounts={{}}
mentions={{}}
showCracker={false}
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
legacySortOrder="recent"
/>
);
const roomRows = screen
.getAllByText(/Room$/)
.map((node) => node.textContent)
.filter((text): text is string => Boolean(text));
expect(roomRows).toEqual(['DM Recent Room', 'Advert Only Room', 'No Recency Room']);
});
it('sorts contacts by DM recency first, then advert recency, then no-recency at the bottom', () => {
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
const dmRecent = makeContact('11'.repeat(32), 'DM Recent', 1, { last_advert: 100 });