mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 09:13:04 +02:00
Add room server
This commit is contained in:
@@ -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' };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user