mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-07 05:45:11 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95c874e643 | |||
| d36c63f6b1 | |||
| e8a4f5c349 | |||
| b022aea71f | |||
| 5225a1c766 | |||
| 41400c0528 | |||
| 07928d930c | |||
| 26742d0c88 |
@@ -296,9 +296,9 @@ cd frontend
|
|||||||
npm run test:run
|
npm run test:run
|
||||||
```
|
```
|
||||||
|
|
||||||
### Before Completing Changes
|
### Before Completing Major Changes
|
||||||
|
|
||||||
**Always run `./scripts/all_quality.sh` before finishing any changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes.
|
**Run `./scripts/all_quality.sh` before finishing major changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes. For minor changes (like wording, color, spacing, etc.), wait until prompted to run the quality gate.
|
||||||
|
|
||||||
## API Summary
|
## API Summary
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
|||||||
* Run multiple Python bots that can analyze messages and respond to DMs and channels
|
* Run multiple Python bots that can analyze messages and respond to DMs and channels
|
||||||
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
||||||
* Access your radio remotely over your network or VPN
|
* Access your radio remotely over your network or VPN
|
||||||
* Search for hashtag room names for channels you don't have keys for yet
|
* Search for hashtag channel names for channels you don't have keys for yet
|
||||||
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
|
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
|
||||||
* Use the more recent 1.14 firmwares which support multibyte pathing
|
* Use the more recent 1.14 firmwares which support multibyte pathing
|
||||||
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ If the audit finds a mismatch, you'll see an error in the application UI and you
|
|||||||
|
|
||||||
## HTTPS
|
## HTTPS
|
||||||
|
|
||||||
WebGPU room-finding requires a secure context when you are not on `localhost`.
|
WebGPU channel-finding requires a secure context when you are not on `localhost`.
|
||||||
|
|
||||||
Generate a local cert and start the backend with TLS:
|
Generate a local cert and start the backend with TLS:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -101,7 +101,7 @@ app/
|
|||||||
- Packet `path_len` values are hop counts, not byte counts.
|
- Packet `path_len` values are hop counts, not byte counts.
|
||||||
- Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
- Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
||||||
- Channel slot count comes from firmware-reported `DEVICE_INFO.max_channels`; do not hardcode `40` when scanning/offloading channel slots.
|
- Channel slot count comes from firmware-reported `DEVICE_INFO.max_channels`; do not hardcode `40` when scanning/offloading channel slots.
|
||||||
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same room reuse the loaded slot; new rooms fill free slots up to the discovered channel capacity, then evict the least recently used cached room.
|
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same channel reuse the loaded slot; new channels fill free slots up to the discovered channel capacity, then evict the least recently used cached channel.
|
||||||
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
|
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
|
||||||
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
|
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
|
||||||
- Contacts persist canonical direct-route fields (`direct_path`, `direct_path_len`, `direct_path_hash_mode`) so contact sync and outbound DM routing reuse the exact stored hop width instead of inferring from path bytes.
|
- Contacts persist canonical direct-route fields (`direct_path`, `direct_path_len`, `direct_path_hash_mode`) so contact sync and outbound DM routing reuse the exact stored hop width instead of inferring from path bytes.
|
||||||
|
|||||||
+1
-1
@@ -266,7 +266,7 @@ class ContactNameHistory(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ContactActiveRoom(BaseModel):
|
class ContactActiveRoom(BaseModel):
|
||||||
"""A channel/room where a contact has been active."""
|
"""A channel where a contact has been active."""
|
||||||
|
|
||||||
channel_key: str
|
channel_key: str
|
||||||
channel_name: str
|
channel_name: str
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
|||||||
requested_name = request.name
|
requested_name = request.name
|
||||||
is_hashtag = requested_name.startswith("#")
|
is_hashtag = requested_name.startswith("#")
|
||||||
|
|
||||||
# Reserve the canonical Public room so it cannot drift to another key,
|
# Reserve the canonical Public channel so it cannot drift to another key,
|
||||||
# and the well-known Public key cannot be renamed to something else.
|
# and the well-known Public key cannot be renamed to something else.
|
||||||
if is_public_channel_name(requested_name):
|
if is_public_channel_name(requested_name):
|
||||||
if request.key:
|
if request.key:
|
||||||
|
|||||||
+1
-1
@@ -9,8 +9,8 @@
|
|||||||
<meta name="theme-color" content="#111419" />
|
<meta name="theme-color" content="#111419" />
|
||||||
<meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." />
|
<meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." />
|
||||||
<title>RemoteTerm for MeshCore</title>
|
<title>RemoteTerm for MeshCore</title>
|
||||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
|||||||
+22
-4
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
import { takePrefetchOrFetch } from './prefetch';
|
import { takePrefetchOrFetch } from './prefetch';
|
||||||
import { useWebSocket } from './useWebSocket';
|
import { useWebSocket } from './useWebSocket';
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
useConversationNavigation,
|
useConversationNavigation,
|
||||||
useRealtimeAppState,
|
useRealtimeAppState,
|
||||||
useBrowserNotifications,
|
useBrowserNotifications,
|
||||||
|
useFaviconBadge,
|
||||||
|
useUnreadTitle,
|
||||||
useRawPacketStatsSession,
|
useRawPacketStatsSession,
|
||||||
} from './hooks';
|
} from './hooks';
|
||||||
import { AppShell } from './components/AppShell';
|
import { AppShell } from './components/AppShell';
|
||||||
@@ -22,6 +24,7 @@ import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
|||||||
import { messageContainsMention } from './utils/messageParser';
|
import { messageContainsMention } from './utils/messageParser';
|
||||||
import { getStateKey } from './utils/conversationState';
|
import { getStateKey } from './utils/conversationState';
|
||||||
import type { Conversation, Message, RawPacket } from './types';
|
import type { Conversation, Message, RawPacket } from './types';
|
||||||
|
import { CONTACT_TYPE_ROOM } from './types';
|
||||||
|
|
||||||
interface ChannelUnreadMarker {
|
interface ChannelUnreadMarker {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
@@ -249,6 +252,21 @@ export function App() {
|
|||||||
} = useConversationMessages(activeConversation, targetMessageId);
|
} = useConversationMessages(activeConversation, targetMessageId);
|
||||||
removeConversationMessagesRef.current = removeConversationMessages;
|
removeConversationMessagesRef.current = removeConversationMessages;
|
||||||
|
|
||||||
|
// Room servers replay stored history as a burst of DMs, all arriving with similar received_at
|
||||||
|
// but spanning a wide range of sender_timestamps. Sort by sender_timestamp for room contacts
|
||||||
|
// so the display reflects the original send order rather than our radio's receipt order.
|
||||||
|
const activeContactIsRoom =
|
||||||
|
activeConversation?.type === 'contact' &&
|
||||||
|
contacts.find((c) => c.public_key === activeConversation.id)?.type === CONTACT_TYPE_ROOM;
|
||||||
|
const sortedMessages = useMemo(() => {
|
||||||
|
if (!activeContactIsRoom || messages.length === 0) return messages;
|
||||||
|
return [...messages].sort((a, b) => {
|
||||||
|
const aTs = a.sender_timestamp ?? a.received_at;
|
||||||
|
const bTs = b.sender_timestamp ?? b.received_at;
|
||||||
|
return aTs !== bTs ? aTs - bTs : a.id - b.id;
|
||||||
|
});
|
||||||
|
}, [activeContactIsRoom, messages]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
unreadCounts,
|
unreadCounts,
|
||||||
mentions,
|
mentions,
|
||||||
@@ -259,6 +277,8 @@ export function App() {
|
|||||||
markAllRead,
|
markAllRead,
|
||||||
refreshUnreads,
|
refreshUnreads,
|
||||||
} = useUnreadCounts(channels, contacts, activeConversation);
|
} = useUnreadCounts(channels, contacts, activeConversation);
|
||||||
|
useFaviconBadge(unreadCounts, mentions, favorites);
|
||||||
|
useUnreadTitle(unreadCounts, favorites);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeConversation?.type !== 'channel') {
|
if (activeConversation?.type !== 'channel') {
|
||||||
@@ -423,7 +443,7 @@ export function App() {
|
|||||||
config,
|
config,
|
||||||
health,
|
health,
|
||||||
favorites,
|
favorites,
|
||||||
messages,
|
messages: sortedMessages,
|
||||||
messagesLoading,
|
messagesLoading,
|
||||||
loadingOlder,
|
loadingOlder,
|
||||||
hasOlderMessages,
|
hasOlderMessages,
|
||||||
@@ -502,9 +522,7 @@ export function App() {
|
|||||||
onChannelCreate: handleCreateCrackedChannel,
|
onChannelCreate: handleCreateCrackedChannel,
|
||||||
};
|
};
|
||||||
const newMessageModalProps = {
|
const newMessageModalProps = {
|
||||||
contacts,
|
|
||||||
undecryptedCount,
|
undecryptedCount,
|
||||||
onSelectConversation: handleSelectConversationWithTargetReset,
|
|
||||||
onCreateContact: handleCreateContact,
|
onCreateContact: handleCreateContact,
|
||||||
onCreateChannel: handleCreateChannel,
|
onCreateChannel: handleCreateChannel,
|
||||||
onCreateHashtagChannel: handleCreateHashtagChannel,
|
onCreateHashtagChannel: handleCreateHashtagChannel,
|
||||||
|
|||||||
@@ -284,10 +284,6 @@ export function AppShell({
|
|||||||
{...newMessageModalProps}
|
{...newMessageModalProps}
|
||||||
open={showNewMessage}
|
open={showNewMessage}
|
||||||
onClose={onCloseNewMessage}
|
onClose={onCloseNewMessage}
|
||||||
onSelectConversation={(conv) => {
|
|
||||||
newMessageModalProps.onSelectConversation(conv);
|
|
||||||
onCloseNewMessage();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SecurityWarningModal health={statusProps.health} />
|
<SecurityWarningModal health={statusProps.health} />
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export function ChannelFloodScopeOverrideModal({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Regional Override</DialogTitle>
|
<DialogTitle>Regional Override</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Room-level regional routing temporarily changes the radio flood scope before send and
|
Channel-level regional routing temporarily changes the radio flood scope before send and
|
||||||
restores it after. This can noticeably slow room sends.
|
restores it after. This can noticeably slow channel sends.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -201,7 +201,9 @@ export function ChatHeader({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigator.clipboard.writeText(conversation.id);
|
navigator.clipboard.writeText(conversation.id);
|
||||||
toast.success(
|
toast.success(
|
||||||
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
|
conversation.type === 'channel'
|
||||||
|
? 'Channel key copied!'
|
||||||
|
: 'Contact key copied!'
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
|
|||||||
@@ -242,8 +242,8 @@ export function ContactInfoPane({
|
|||||||
|
|
||||||
<ActivityChartsSection analytics={analytics} />
|
<ActivityChartsSection analytics={analytics} />
|
||||||
|
|
||||||
<MostActiveRoomsSection
|
<MostActiveChannelsSection
|
||||||
rooms={analytics?.most_active_rooms ?? []}
|
channels={analytics?.most_active_rooms ?? []}
|
||||||
onNavigateToChannel={onNavigateToChannel}
|
onNavigateToChannel={onNavigateToChannel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -515,8 +515,8 @@ export function ContactInfoPane({
|
|||||||
|
|
||||||
<ActivityChartsSection analytics={analytics} />
|
<ActivityChartsSection analytics={analytics} />
|
||||||
|
|
||||||
<MostActiveRoomsSection
|
<MostActiveChannelsSection
|
||||||
rooms={analytics?.most_active_rooms ?? []}
|
channels={analytics?.most_active_rooms ?? []}
|
||||||
onNavigateToChannel={onNavigateToChannel}
|
onNavigateToChannel={onNavigateToChannel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -588,23 +588,23 @@ function MessageStatsSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MostActiveRoomsSection({
|
function MostActiveChannelsSection({
|
||||||
rooms,
|
channels,
|
||||||
onNavigateToChannel,
|
onNavigateToChannel,
|
||||||
}: {
|
}: {
|
||||||
rooms: ContactActiveRoom[];
|
channels: ContactActiveRoom[];
|
||||||
onNavigateToChannel?: (channelKey: string) => void;
|
onNavigateToChannel?: (channelKey: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (rooms.length === 0) {
|
if (channels.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-5 py-3 border-b border-border">
|
<div className="px-5 py-3 border-b border-border">
|
||||||
<SectionLabel>Most Active Rooms</SectionLabel>
|
<SectionLabel>Most Active Channels</SectionLabel>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{rooms.map((room) => (
|
{channels.map((channel) => (
|
||||||
<div key={room.channel_key} className="flex justify-between items-center text-sm">
|
<div key={channel.channel_key} className="flex justify-between items-center text-sm">
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
onNavigateToChannel
|
onNavigateToChannel
|
||||||
@@ -614,15 +614,15 @@ function MostActiveRoomsSection({
|
|||||||
role={onNavigateToChannel ? 'button' : undefined}
|
role={onNavigateToChannel ? 'button' : undefined}
|
||||||
tabIndex={onNavigateToChannel ? 0 : undefined}
|
tabIndex={onNavigateToChannel ? 0 : undefined}
|
||||||
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
|
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
|
||||||
onClick={() => onNavigateToChannel?.(room.channel_key)}
|
onClick={() => onNavigateToChannel?.(channel.channel_key)}
|
||||||
>
|
>
|
||||||
{room.channel_name.startsWith('#') || isPublicChannelKey(room.channel_key)
|
{channel.channel_name.startsWith('#') || isPublicChannelKey(channel.channel_key)
|
||||||
? room.channel_name
|
? channel.channel_name
|
||||||
: `#${room.channel_name}`}
|
: `#${channel.channel_name}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||||
{room.message_count.toLocaleString()} msg
|
{channel.message_count.toLocaleString()} msg
|
||||||
{room.message_count !== 1 ? 's' : ''}
|
{channel.message_count !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { toast } from './ui/sonner';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { extractPacketPayloadHex } from '../utils/pathUtils';
|
import { extractPacketPayloadHex } from '../utils/pathUtils';
|
||||||
|
|
||||||
interface CrackedRoom {
|
interface CrackedChannel {
|
||||||
roomName: string;
|
channelName: string;
|
||||||
key: string;
|
key: string;
|
||||||
packetId: number;
|
packetId: number;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -45,7 +45,7 @@ export function CrackerPanel({
|
|||||||
const [twoWordMode, setTwoWordMode] = useState(false);
|
const [twoWordMode, setTwoWordMode] = useState(false);
|
||||||
const [progress, setProgress] = useState<ProgressReport | null>(null);
|
const [progress, setProgress] = useState<ProgressReport | null>(null);
|
||||||
const [queue, setQueue] = useState<Map<number, QueueItem>>(new Map());
|
const [queue, setQueue] = useState<Map<number, QueueItem>>(new Map());
|
||||||
const [crackedRooms, setCrackedRooms] = useState<CrackedRoom[]>([]);
|
const [crackedChannels, setCrackedChannels] = useState<CrackedChannel[]>([]);
|
||||||
const [wordlistLoaded, setWordlistLoaded] = useState(false);
|
const [wordlistLoaded, setWordlistLoaded] = useState(false);
|
||||||
const [gpuAvailable, setGpuAvailable] = useState<boolean | null>(null);
|
const [gpuAvailable, setGpuAvailable] = useState<boolean | null>(null);
|
||||||
const [undecryptedPacketCount, setUndecryptedPacketCount] = useState<number | null>(null);
|
const [undecryptedPacketCount, setUndecryptedPacketCount] = useState<number | null>(null);
|
||||||
@@ -325,14 +325,14 @@ export function CrackerPanel({
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
const newRoom: CrackedRoom = {
|
const newCracked: CrackedChannel = {
|
||||||
roomName: result.roomName,
|
channelName: result.roomName,
|
||||||
key: result.key,
|
key: result.key,
|
||||||
packetId: nextId!,
|
packetId: nextId!,
|
||||||
message: result.decryptedMessage || '',
|
message: result.decryptedMessage || '',
|
||||||
crackedAt: Date.now(),
|
crackedAt: Date.now(),
|
||||||
};
|
};
|
||||||
setCrackedRooms((prev) => [...prev, newRoom]);
|
setCrackedChannels((prev) => [...prev, newCracked]);
|
||||||
|
|
||||||
// Auto-add channel if not already exists
|
// Auto-add channel if not already exists
|
||||||
const keyUpper = result.key.toUpperCase();
|
const keyUpper = result.key.toUpperCase();
|
||||||
@@ -580,20 +580,20 @@ export function CrackerPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cracked rooms list */}
|
{/* Cracked channels list */}
|
||||||
{crackedRooms.length > 0 && (
|
{crackedChannels.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground mb-1">Cracked Rooms:</div>
|
<div className="text-xs text-muted-foreground mb-1">Cracked Channels:</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{crackedRooms.map((room, i) => (
|
{crackedChannels.map((channel, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="text-sm bg-success/10 border border-success/20 rounded px-2 py-1"
|
className="text-sm bg-success/10 border border-success/20 rounded px-2 py-1"
|
||||||
>
|
>
|
||||||
<span className="text-success font-medium">#{room.roomName}</span>
|
<span className="text-success font-medium">#{channel.channelName}</span>
|
||||||
<span className="text-muted-foreground ml-2 text-xs">
|
<span className="text-muted-foreground ml-2 text-xs">
|
||||||
"{room.message.slice(0, 50)}
|
"{channel.message.slice(0, 50)}
|
||||||
{room.message.length > 50 ? '...' : ''}"
|
{channel.message.length > 50 ? '...' : ''}"
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -604,8 +604,8 @@ export function CrackerPanel({
|
|||||||
<hr className="border-border" />
|
<hr className="border-border" />
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
For unknown-keyed GroupText packets, this will attempt to dictionary attack, then brute
|
For unknown-keyed GroupText packets, this will attempt to dictionary attack, then brute
|
||||||
force payloads as they arrive, testing room names up to the specified length to discover
|
force payloads as they arrive, testing channel names up to the specified length to discover
|
||||||
active rooms on the local mesh (GroupText packets may not be hashtag messages; we have no
|
active channels on the local mesh (GroupText packets may not be hashtag messages; we have no
|
||||||
way of knowing but try as if they are).
|
way of knowing but try as if they are).
|
||||||
<strong> Retry failed at n+1</strong> will let the cracker return to the failed queue and
|
<strong> Retry failed at n+1</strong> will let the cracker return to the failed queue and
|
||||||
pick up messages it couldn't crack, attempting them at one longer length.
|
pick up messages it couldn't crack, attempting them at one longer length.
|
||||||
@@ -613,8 +613,8 @@ export function CrackerPanel({
|
|||||||
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
|
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
|
||||||
dictionary pass; this can substantially increase search time and also result in
|
dictionary pass; this can substantially increase search time and also result in
|
||||||
false-positives.
|
false-positives.
|
||||||
<strong> Decrypt historical</strong> will run an async job on any room name it finds to see
|
<strong> Decrypt historical</strong> will run an async job on any channel name it finds to
|
||||||
if any historically captured packets will decrypt with that key.
|
see if any historically captured packets will decrypt with that key.
|
||||||
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and
|
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and
|
||||||
may allow accelerated cracking and/or system instability.
|
may allow accelerated cracking and/or system instability.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { Dice5 } from 'lucide-react';
|
import { Dice5 } from 'lucide-react';
|
||||||
import type { Contact, Conversation } from '../types';
|
|
||||||
import { getContactDisplayName } from '../utils/pubkey';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -17,14 +15,12 @@ import { Checkbox } from './ui/checkbox';
|
|||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
|
|
||||||
type Tab = 'existing' | 'new-contact' | 'new-room' | 'hashtag';
|
type Tab = 'new-contact' | 'new-channel' | 'hashtag';
|
||||||
|
|
||||||
interface NewMessageModalProps {
|
interface NewMessageModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
contacts: Contact[];
|
|
||||||
undecryptedCount: number;
|
undecryptedCount: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSelectConversation: (conversation: Conversation) => void;
|
|
||||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
||||||
@@ -32,18 +28,16 @@ interface NewMessageModalProps {
|
|||||||
|
|
||||||
export function NewMessageModal({
|
export function NewMessageModal({
|
||||||
open,
|
open,
|
||||||
contacts,
|
|
||||||
undecryptedCount,
|
undecryptedCount,
|
||||||
onClose,
|
onClose,
|
||||||
onSelectConversation,
|
|
||||||
onCreateContact,
|
onCreateContact,
|
||||||
onCreateChannel,
|
onCreateChannel,
|
||||||
onCreateHashtagChannel,
|
onCreateHashtagChannel,
|
||||||
}: NewMessageModalProps) {
|
}: NewMessageModalProps) {
|
||||||
const [tab, setTab] = useState<Tab>('existing');
|
const [tab, setTab] = useState<Tab>('new-contact');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [contactKey, setContactKey] = useState('');
|
const [contactKey, setContactKey] = useState('');
|
||||||
const [roomKey, setRoomKey] = useState('');
|
const [channelKey, setChannelKey] = useState('');
|
||||||
const [tryHistorical, setTryHistorical] = useState(false);
|
const [tryHistorical, setTryHistorical] = useState(false);
|
||||||
const [permitCapitals, setPermitCapitals] = useState(false);
|
const [permitCapitals, setPermitCapitals] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -53,7 +47,7 @@ export function NewMessageModal({
|
|||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setName('');
|
setName('');
|
||||||
setContactKey('');
|
setContactKey('');
|
||||||
setRoomKey('');
|
setChannelKey('');
|
||||||
setTryHistorical(false);
|
setTryHistorical(false);
|
||||||
setPermitCapitals(false);
|
setPermitCapitals(false);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -71,12 +65,12 @@ export function NewMessageModal({
|
|||||||
}
|
}
|
||||||
// handleCreateContact sets activeConversation with the backend-normalized key
|
// handleCreateContact sets activeConversation with the backend-normalized key
|
||||||
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
||||||
} else if (tab === 'new-room') {
|
} else if (tab === 'new-channel') {
|
||||||
if (!name.trim() || !roomKey.trim()) {
|
if (!name.trim() || !channelKey.trim()) {
|
||||||
setError('Room name and key are required');
|
setError('Channel name and key are required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await onCreateChannel(name.trim(), roomKey.trim(), tryHistorical);
|
await onCreateChannel(name.trim(), channelKey.trim(), tryHistorical);
|
||||||
} else if (tab === 'hashtag') {
|
} else if (tab === 'hashtag') {
|
||||||
const channelName = name.trim();
|
const channelName = name.trim();
|
||||||
const validationError = validateHashtagName(channelName);
|
const validationError = validateHashtagName(channelName);
|
||||||
@@ -136,7 +130,7 @@ export function NewMessageModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showHistoricalOption = tab !== 'existing' && undecryptedCount > 0;
|
const showHistoricalOption = undecryptedCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -152,9 +146,8 @@ export function NewMessageModal({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>New Conversation</DialogTitle>
|
<DialogTitle>New Conversation</DialogTitle>
|
||||||
<DialogDescription className="sr-only">
|
<DialogDescription className="sr-only">
|
||||||
{tab === 'existing' && 'Select an existing contact to start a conversation'}
|
|
||||||
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
|
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
|
||||||
{tab === 'new-room' && 'Create a private room with a shared encryption key'}
|
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
|
||||||
{tab === 'hashtag' && 'Join a public hashtag channel'}
|
{tab === 'hashtag' && 'Join a public hashtag channel'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -167,53 +160,12 @@ export function NewMessageModal({
|
|||||||
}}
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<TabsList className="grid w-full grid-cols-4">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="existing">Existing</TabsTrigger>
|
|
||||||
<TabsTrigger value="new-contact">Contact</TabsTrigger>
|
<TabsTrigger value="new-contact">Contact</TabsTrigger>
|
||||||
<TabsTrigger value="new-room">Room</TabsTrigger>
|
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
|
||||||
<TabsTrigger value="hashtag">Hashtag</TabsTrigger>
|
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="existing" className="mt-4">
|
|
||||||
<div className="max-h-[300px] overflow-y-auto rounded-md border">
|
|
||||||
{contacts.filter((contact) => contact.public_key.length === 64).length === 0 ? (
|
|
||||||
<div className="p-4 text-center text-muted-foreground">No contacts available</div>
|
|
||||||
) : (
|
|
||||||
contacts
|
|
||||||
.filter((contact) => contact.public_key.length === 64)
|
|
||||||
.map((contact) => (
|
|
||||||
<div
|
|
||||||
key={contact.public_key}
|
|
||||||
className="cursor-pointer px-4 py-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
(e.currentTarget as HTMLElement).click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
onSelectConversation({
|
|
||||||
type: 'contact',
|
|
||||||
id: contact.public_key,
|
|
||||||
name: getContactDisplayName(
|
|
||||||
contact.name,
|
|
||||||
contact.public_key,
|
|
||||||
contact.last_advert
|
|
||||||
),
|
|
||||||
});
|
|
||||||
resetForm();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getContactDisplayName(contact.name, contact.public_key, contact.last_advert)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="contact-name">Name</Label>
|
<Label htmlFor="contact-name">Name</Label>
|
||||||
@@ -235,23 +187,23 @@ export function NewMessageModal({
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="new-room" className="mt-4 space-y-4">
|
<TabsContent value="new-channel" className="mt-4 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="room-name">Room Name</Label>
|
<Label htmlFor="channel-name">Channel Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="room-name"
|
id="channel-name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Room name"
|
placeholder="Channel name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="room-key">Room Key</Label>
|
<Label htmlFor="channel-key">Channel Key</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="room-key"
|
id="channel-key"
|
||||||
value={roomKey}
|
value={channelKey}
|
||||||
onChange={(e) => setRoomKey(e.target.value)}
|
onChange={(e) => setChannelKey(e.target.value)}
|
||||||
placeholder="Pre-shared key (hex)"
|
placeholder="Pre-shared key (hex)"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
@@ -265,7 +217,7 @@ export function NewMessageModal({
|
|||||||
const hex = Array.from(bytes)
|
const hex = Array.from(bytes)
|
||||||
.map((b) => b.toString(16).padStart(2, '0'))
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
setRoomKey(hex);
|
setChannelKey(hex);
|
||||||
}}
|
}}
|
||||||
title="Generate random key"
|
title="Generate random key"
|
||||||
aria-label="Generate random key"
|
aria-label="Generate random key"
|
||||||
@@ -299,7 +251,7 @@ export function NewMessageModal({
|
|||||||
onChange={(e) => setPermitCapitals(e.target.checked)}
|
onChange={(e) => setPermitCapitals(e.target.checked)}
|
||||||
className="w-4 h-4 rounded border-input accent-primary"
|
className="w-4 h-4 rounded border-input accent-primary"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">Permit capitals in room key derivation</span>
|
<span className="text-sm">Permit capitals in channel key derivation</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-muted-foreground pl-7">
|
<p className="text-xs text-muted-foreground pl-7">
|
||||||
Not recommended; most companions normalize to lowercase
|
Not recommended; most companions normalize to lowercase
|
||||||
@@ -353,11 +305,9 @@ export function NewMessageModal({
|
|||||||
{loading ? 'Creating...' : 'Create & Add Another'}
|
{loading ? 'Creating...' : 'Create & Add Another'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{tab !== 'existing' && (
|
<Button onClick={handleCreate} disabled={loading}>
|
||||||
<Button onClick={handleCreate} disabled={loading}>
|
{loading ? 'Creating...' : 'Create'}
|
||||||
{loading ? 'Creating...' : 'Create'}
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ function buildGroupTextResolutionCandidates(channels: Channel[]): GroupTextResol
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveGroupTextRoomName(
|
function resolveGroupTextChannelName(
|
||||||
payload: {
|
payload: {
|
||||||
channelHash?: string;
|
channelHash?: string;
|
||||||
cipherMac?: string;
|
cipherMac?: string;
|
||||||
@@ -211,15 +211,15 @@ function getPacketContext(
|
|||||||
groupTextCandidates: GroupTextResolutionCandidate[]
|
groupTextCandidates: GroupTextResolutionCandidate[]
|
||||||
) {
|
) {
|
||||||
const fallbackSender = packet.decrypted_info?.sender ?? null;
|
const fallbackSender = packet.decrypted_info?.sender ?? null;
|
||||||
const fallbackRoom = packet.decrypted_info?.channel_name ?? null;
|
const fallbackChannel = packet.decrypted_info?.channel_name ?? null;
|
||||||
|
|
||||||
if (!inspection.decoded?.payload.decoded) {
|
if (!inspection.decoded?.payload.decoded) {
|
||||||
if (!fallbackSender && !fallbackRoom) {
|
if (!fallbackSender && !fallbackChannel) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: fallbackRoom ? 'Room' : 'Context',
|
title: fallbackChannel ? 'Channel' : 'Context',
|
||||||
primary: fallbackRoom ?? 'Sender metadata available',
|
primary: fallbackChannel ?? 'Sender metadata available',
|
||||||
secondary: fallbackSender ? `Sender: ${fallbackSender}` : null,
|
secondary: fallbackSender ? `Sender: ${fallbackSender}` : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -231,11 +231,12 @@ function getPacketContext(
|
|||||||
ciphertext?: string;
|
ciphertext?: string;
|
||||||
decrypted?: { sender?: string; message?: string };
|
decrypted?: { sender?: string; message?: string };
|
||||||
};
|
};
|
||||||
const roomName = fallbackRoom ?? resolveGroupTextRoomName(payload, groupTextCandidates);
|
const channelName =
|
||||||
|
fallbackChannel ?? resolveGroupTextChannelName(payload, groupTextCandidates);
|
||||||
return {
|
return {
|
||||||
title: roomName ? 'Room' : 'Channel',
|
title: 'Channel',
|
||||||
primary:
|
primary:
|
||||||
roomName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
|
channelName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
|
||||||
secondary: payload.decrypted?.sender
|
secondary: payload.decrypted?.sender
|
||||||
? `Sender: ${payload.decrypted.sender}`
|
? `Sender: ${payload.decrypted.sender}`
|
||||||
: fallbackSender
|
: fallbackSender
|
||||||
|
|||||||
@@ -88,12 +88,12 @@ export function SecurityWarningModal({ health }: SecurityWarningModalProps) {
|
|||||||
|
|
||||||
<div className="space-y-3 break-words text-sm leading-6 text-muted-foreground">
|
<div className="space-y-3 break-words text-sm leading-6 text-muted-foreground">
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Bots are enabled, and app-wide Basic Auth is not configured.
|
Bots are not disabled, and app-wide Basic Auth is not configured.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
<p>
|
<p>
|
||||||
Without one of those protections, or another access-control layer in front of
|
Without one of those protections, or another access-control layer in front of
|
||||||
RemoteTerm, anyone on your local network who can reach this app can run Python code on
|
RemoteTerm, anyone on your local network who can reach this app can run Python code on
|
||||||
the computer hosting this instance.
|
the computer hosting this instance via the bot system.
|
||||||
</p>
|
</p>
|
||||||
<p className="font-semibold text-foreground">
|
<p className="font-semibold text-foreground">
|
||||||
This is only safe on protected or isolated networks with appropriate access control. If
|
This is only safe on protected or isolated networks with appropriate access control. If
|
||||||
|
|||||||
@@ -748,7 +748,7 @@ export function Sidebar({
|
|||||||
icon: <LockOpen className="h-4 w-4" />,
|
icon: <LockOpen className="h-4 w-4" />,
|
||||||
label: (
|
label: (
|
||||||
<>
|
<>
|
||||||
{showCracker ? 'Hide' : 'Show'} Room Finder
|
{showCracker ? 'Hide' : 'Show'} Channel Finder
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-1 text-[11px]',
|
'ml-1 text-[11px]',
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { Label } from '../ui/label';
|
import { Label } from '../ui/label';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Separator } from '../ui/separator';
|
import { Separator } from '../ui/separator';
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||||
import { toast } from '../ui/sonner';
|
import { toast } from '../ui/sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { api } from '../../api';
|
import { api } from '../../api';
|
||||||
@@ -15,21 +17,12 @@ const BotCodeEditor = lazy(() =>
|
|||||||
const TYPE_LABELS: Record<string, string> = {
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
mqtt_private: 'Private MQTT',
|
mqtt_private: 'Private MQTT',
|
||||||
mqtt_community: 'Community MQTT',
|
mqtt_community: 'Community MQTT',
|
||||||
bot: 'Bot',
|
bot: 'Python Bot',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
apprise: 'Apprise',
|
apprise: 'Apprise',
|
||||||
sqs: 'Amazon SQS',
|
sqs: 'Amazon SQS',
|
||||||
};
|
};
|
||||||
|
|
||||||
const LIST_TYPE_OPTIONS = [
|
|
||||||
{ value: 'mqtt_private', label: 'Private MQTT' },
|
|
||||||
{ value: 'mqtt_community', label: 'Community MQTT' },
|
|
||||||
{ value: 'bot', label: 'Bot' },
|
|
||||||
{ value: 'webhook', label: 'Webhook' },
|
|
||||||
{ value: 'apprise', label: 'Apprise' },
|
|
||||||
{ value: 'sqs', label: 'Amazon SQS' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE = 'meshcore/{IATA}/{PUBLIC_KEY}/packets';
|
const DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE = 'meshcore/{IATA}/{PUBLIC_KEY}/packets';
|
||||||
const DEFAULT_COMMUNITY_BROKER_HOST = 'mqtt-us-v1.letsmesh.net';
|
const DEFAULT_COMMUNITY_BROKER_HOST = 'mqtt-us-v1.letsmesh.net';
|
||||||
const DEFAULT_COMMUNITY_BROKER_HOST_EU = 'mqtt-eu-v1.letsmesh.net';
|
const DEFAULT_COMMUNITY_BROKER_HOST_EU = 'mqtt-eu-v1.letsmesh.net';
|
||||||
@@ -42,30 +35,6 @@ const DEFAULT_MESHRANK_TRANSPORT = 'tcp';
|
|||||||
const DEFAULT_MESHRANK_AUTH_MODE = 'none';
|
const DEFAULT_MESHRANK_AUTH_MODE = 'none';
|
||||||
const DEFAULT_MESHRANK_IATA = 'XYZ';
|
const DEFAULT_MESHRANK_IATA = 'XYZ';
|
||||||
|
|
||||||
const CREATE_TYPE_OPTIONS = [
|
|
||||||
{ value: 'mqtt_private', label: 'Private MQTT' },
|
|
||||||
{ value: 'mqtt_community_meshrank', label: 'MeshRank' },
|
|
||||||
{ value: 'mqtt_community_letsmesh_us', label: 'LetsMesh (US)' },
|
|
||||||
{ value: 'mqtt_community_letsmesh_eu', label: 'LetsMesh (EU)' },
|
|
||||||
{ value: 'mqtt_community', label: 'Community MQTT/meshcoretomqtt' },
|
|
||||||
{ value: 'bot', label: 'Bot' },
|
|
||||||
{ value: 'webhook', label: 'Webhook' },
|
|
||||||
{ value: 'apprise', label: 'Apprise' },
|
|
||||||
{ value: 'sqs', label: 'Amazon SQS' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type DraftType = (typeof CREATE_TYPE_OPTIONS)[number]['value'];
|
|
||||||
|
|
||||||
type DraftRecipe = {
|
|
||||||
savedType: string;
|
|
||||||
detailLabel: string;
|
|
||||||
defaultName: string;
|
|
||||||
defaults: {
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
scope: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function createCommunityConfigDefaults(
|
function createCommunityConfigDefaults(
|
||||||
overrides: Partial<Record<string, unknown>> = {}
|
overrides: Partial<Record<string, unknown>> = {}
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
@@ -122,11 +91,41 @@ const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
|
|||||||
return "[BOT] Plong!"
|
return "[BOT] Plong!"
|
||||||
return None`;
|
return None`;
|
||||||
|
|
||||||
const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
type DraftType =
|
||||||
mqtt_private: {
|
| 'mqtt_private'
|
||||||
|
| 'mqtt_community'
|
||||||
|
| 'mqtt_community_meshrank'
|
||||||
|
| 'mqtt_community_letsmesh_us'
|
||||||
|
| 'mqtt_community_letsmesh_eu'
|
||||||
|
| 'webhook'
|
||||||
|
| 'apprise'
|
||||||
|
| 'sqs'
|
||||||
|
| 'bot';
|
||||||
|
|
||||||
|
type CreateIntegrationDefinition = {
|
||||||
|
value: DraftType;
|
||||||
|
savedType: string;
|
||||||
|
label: string;
|
||||||
|
section: string;
|
||||||
|
description: string;
|
||||||
|
defaultName: string;
|
||||||
|
nameMode: 'counted' | 'fixed';
|
||||||
|
defaults: {
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
scope: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
||||||
|
{
|
||||||
|
value: 'mqtt_private',
|
||||||
savedType: 'mqtt_private',
|
savedType: 'mqtt_private',
|
||||||
detailLabel: 'Private MQTT',
|
label: 'Private MQTT',
|
||||||
|
section: 'Bulk Forwarding',
|
||||||
|
description:
|
||||||
|
'Customizable-scope forwarding of all or some messages to an MQTT broker of your choosing, in raw and/or decrypted form.',
|
||||||
defaultName: 'Private MQTT',
|
defaultName: 'Private MQTT',
|
||||||
|
nameMode: 'counted',
|
||||||
defaults: {
|
defaults: {
|
||||||
config: {
|
config: {
|
||||||
broker_host: '',
|
broker_host: '',
|
||||||
@@ -140,10 +139,29 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
|||||||
scope: { messages: 'all', raw_packets: 'all' },
|
scope: { messages: 'all', raw_packets: 'all' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mqtt_community_meshrank: {
|
{
|
||||||
|
value: 'mqtt_community',
|
||||||
savedType: 'mqtt_community',
|
savedType: 'mqtt_community',
|
||||||
detailLabel: 'MeshRank',
|
label: 'Community MQTT/meshcoretomqtt',
|
||||||
|
section: 'Community MQTT',
|
||||||
|
description:
|
||||||
|
'MeshcoreToMQTT-compatible raw-packet feed publishing, compatible with community aggregators (in other words, make your companion radio also serve as an observer node). Superset of other Community MQTT presets.',
|
||||||
|
defaultName: 'Community MQTT',
|
||||||
|
nameMode: 'counted',
|
||||||
|
defaults: {
|
||||||
|
config: createCommunityConfigDefaults(),
|
||||||
|
scope: { messages: 'none', raw_packets: 'all' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'mqtt_community_meshrank',
|
||||||
|
savedType: 'mqtt_community',
|
||||||
|
label: 'MeshRank',
|
||||||
|
section: 'Community MQTT',
|
||||||
|
description:
|
||||||
|
'A community MQTT config preconfigured for MeshRank, requiring only the provided topic from your MeshRank configuration. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||||
defaultName: 'MeshRank',
|
defaultName: 'MeshRank',
|
||||||
|
nameMode: 'fixed',
|
||||||
defaults: {
|
defaults: {
|
||||||
config: createCommunityConfigDefaults({
|
config: createCommunityConfigDefaults({
|
||||||
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
|
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
|
||||||
@@ -158,10 +176,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
|||||||
scope: { messages: 'none', raw_packets: 'all' },
|
scope: { messages: 'none', raw_packets: 'all' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mqtt_community_letsmesh_us: {
|
{
|
||||||
|
value: 'mqtt_community_letsmesh_us',
|
||||||
savedType: 'mqtt_community',
|
savedType: 'mqtt_community',
|
||||||
detailLabel: 'LetsMesh (US)',
|
label: 'LetsMesh (US)',
|
||||||
|
section: 'Community MQTT',
|
||||||
|
description:
|
||||||
|
'A community MQTT config preconfigured for the LetsMesh US-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional EU configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||||
defaultName: 'LetsMesh (US)',
|
defaultName: 'LetsMesh (US)',
|
||||||
|
nameMode: 'fixed',
|
||||||
defaults: {
|
defaults: {
|
||||||
config: createCommunityConfigDefaults({
|
config: createCommunityConfigDefaults({
|
||||||
broker_host: DEFAULT_COMMUNITY_BROKER_HOST,
|
broker_host: DEFAULT_COMMUNITY_BROKER_HOST,
|
||||||
@@ -170,10 +193,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
|||||||
scope: { messages: 'none', raw_packets: 'all' },
|
scope: { messages: 'none', raw_packets: 'all' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mqtt_community_letsmesh_eu: {
|
{
|
||||||
|
value: 'mqtt_community_letsmesh_eu',
|
||||||
savedType: 'mqtt_community',
|
savedType: 'mqtt_community',
|
||||||
detailLabel: 'LetsMesh (EU)',
|
label: 'LetsMesh (EU)',
|
||||||
|
section: 'Community MQTT',
|
||||||
|
description:
|
||||||
|
'A community MQTT config preconfigured for the LetsMesh EU-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional US configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||||
defaultName: 'LetsMesh (EU)',
|
defaultName: 'LetsMesh (EU)',
|
||||||
|
nameMode: 'fixed',
|
||||||
defaults: {
|
defaults: {
|
||||||
config: createCommunityConfigDefaults({
|
config: createCommunityConfigDefaults({
|
||||||
broker_host: DEFAULT_COMMUNITY_BROKER_HOST_EU,
|
broker_host: DEFAULT_COMMUNITY_BROKER_HOST_EU,
|
||||||
@@ -182,30 +210,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
|||||||
scope: { messages: 'none', raw_packets: 'all' },
|
scope: { messages: 'none', raw_packets: 'all' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mqtt_community: {
|
{
|
||||||
savedType: 'mqtt_community',
|
value: 'webhook',
|
||||||
detailLabel: 'Community MQTT/meshcoretomqtt',
|
|
||||||
defaultName: 'Community MQTT',
|
|
||||||
defaults: {
|
|
||||||
config: createCommunityConfigDefaults(),
|
|
||||||
scope: { messages: 'none', raw_packets: 'all' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
bot: {
|
|
||||||
savedType: 'bot',
|
|
||||||
detailLabel: 'Bot',
|
|
||||||
defaultName: 'Bot',
|
|
||||||
defaults: {
|
|
||||||
config: {
|
|
||||||
code: DEFAULT_BOT_CODE,
|
|
||||||
},
|
|
||||||
scope: { messages: 'all', raw_packets: 'none' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
webhook: {
|
|
||||||
savedType: 'webhook',
|
savedType: 'webhook',
|
||||||
detailLabel: 'Webhook',
|
label: 'Webhook',
|
||||||
|
section: 'Automation',
|
||||||
|
description:
|
||||||
|
'Generic webhook for decrypted channel/DM messages with customizable verb, method, and optional HMAC signature.',
|
||||||
defaultName: 'Webhook',
|
defaultName: 'Webhook',
|
||||||
|
nameMode: 'counted',
|
||||||
defaults: {
|
defaults: {
|
||||||
config: {
|
config: {
|
||||||
url: '',
|
url: '',
|
||||||
@@ -217,10 +230,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
|||||||
scope: { messages: 'all', raw_packets: 'none' },
|
scope: { messages: 'all', raw_packets: 'none' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
apprise: {
|
{
|
||||||
|
value: 'apprise',
|
||||||
savedType: 'apprise',
|
savedType: 'apprise',
|
||||||
detailLabel: 'Apprise',
|
label: 'Apprise',
|
||||||
|
section: 'Automation',
|
||||||
|
description:
|
||||||
|
'A wide-ranging generic fanout, capable of forwarding decrypted channel/DM messages to Discord, Telegram, email, SMS, and many others.',
|
||||||
defaultName: 'Apprise',
|
defaultName: 'Apprise',
|
||||||
|
nameMode: 'counted',
|
||||||
defaults: {
|
defaults: {
|
||||||
config: {
|
config: {
|
||||||
urls: '',
|
urls: '',
|
||||||
@@ -230,10 +248,14 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
|||||||
scope: { messages: 'all', raw_packets: 'none' },
|
scope: { messages: 'all', raw_packets: 'none' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sqs: {
|
{
|
||||||
|
value: 'sqs',
|
||||||
savedType: 'sqs',
|
savedType: 'sqs',
|
||||||
detailLabel: 'Amazon SQS',
|
label: 'Amazon SQS',
|
||||||
|
section: 'Bulk Forwarding',
|
||||||
|
description: 'Send full or scope-customized raw or decrypted packets to an SQS',
|
||||||
defaultName: 'Amazon SQS',
|
defaultName: 'Amazon SQS',
|
||||||
|
nameMode: 'counted',
|
||||||
defaults: {
|
defaults: {
|
||||||
config: {
|
config: {
|
||||||
queue_url: '',
|
queue_url: '',
|
||||||
@@ -246,15 +268,41 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
|||||||
scope: { messages: 'all', raw_packets: 'none' },
|
scope: { messages: 'all', raw_packets: 'none' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
{
|
||||||
|
value: 'bot',
|
||||||
|
savedType: 'bot',
|
||||||
|
label: 'Python Bot',
|
||||||
|
section: 'Automation',
|
||||||
|
description:
|
||||||
|
'A simple, Python-based interface for basic bots that can respond to DM and channel messages.',
|
||||||
|
defaultName: 'Bot',
|
||||||
|
nameMode: 'counted',
|
||||||
|
defaults: {
|
||||||
|
config: {
|
||||||
|
code: DEFAULT_BOT_CODE,
|
||||||
|
},
|
||||||
|
scope: { messages: 'all', raw_packets: 'none' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries(
|
||||||
|
CREATE_INTEGRATION_DEFINITIONS.map((definition) => [definition.value, definition])
|
||||||
|
) as Record<DraftType, CreateIntegrationDefinition>;
|
||||||
|
|
||||||
function isDraftType(value: string): value is DraftType {
|
function isDraftType(value: string): value is DraftType {
|
||||||
return value in DRAFT_RECIPES;
|
return value in CREATE_INTEGRATION_DEFINITIONS_BY_VALUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCreateIntegrationDefinition(draftType: DraftType) {
|
||||||
|
return CREATE_INTEGRATION_DEFINITIONS_BY_VALUE[draftType];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDraftName(draftType: DraftType, name: string, configs: FanoutConfig[]) {
|
function normalizeDraftName(draftType: DraftType, name: string, configs: FanoutConfig[]) {
|
||||||
const recipe = DRAFT_RECIPES[draftType];
|
const definition = getCreateIntegrationDefinition(draftType);
|
||||||
return name || getDefaultIntegrationName(recipe.savedType, configs);
|
if (name) return name;
|
||||||
|
if (definition.nameMode === 'fixed') return definition.defaultName;
|
||||||
|
return getDefaultIntegrationName(definition.savedType, configs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDraftConfig(draftType: DraftType, config: Record<string, unknown>) {
|
function normalizeDraftConfig(draftType: DraftType, config: Record<string, unknown>) {
|
||||||
@@ -305,22 +353,160 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
|
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
|
||||||
if (draftType.startsWith('mqtt_community_')) {
|
if (getCreateIntegrationDefinition(draftType).savedType === 'mqtt_community') {
|
||||||
return { messages: 'none', raw_packets: 'all' };
|
return { messages: 'none', raw_packets: 'all' };
|
||||||
}
|
}
|
||||||
return scope;
|
return scope;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneDraftDefaults(draftType: DraftType) {
|
function cloneDraftDefaults(draftType: DraftType) {
|
||||||
const recipe = DRAFT_RECIPES[draftType];
|
const recipe = getCreateIntegrationDefinition(draftType);
|
||||||
return {
|
return {
|
||||||
config: structuredClone(recipe.defaults.config),
|
config: structuredClone(recipe.defaults.config),
|
||||||
scope: structuredClone(recipe.defaults.scope),
|
scope: structuredClone(recipe.defaults.scope),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CreateIntegrationDialog({
|
||||||
|
open,
|
||||||
|
options,
|
||||||
|
selectedType,
|
||||||
|
onOpenChange,
|
||||||
|
onSelect,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
options: readonly CreateIntegrationDefinition[];
|
||||||
|
selectedType: DraftType | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSelect: (type: DraftType) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
}) {
|
||||||
|
const selectedOption =
|
||||||
|
options.find((option) => option.value === selectedType) ?? options[0] ?? null;
|
||||||
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [showScrollHint, setShowScrollHint] = useState(false);
|
||||||
|
|
||||||
|
const updateScrollHint = useCallback(() => {
|
||||||
|
const container = listRef.current;
|
||||||
|
if (!container) {
|
||||||
|
setShowScrollHint(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowScrollHint(container.scrollTop + container.clientHeight < container.scrollHeight - 8);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const frame = window.requestAnimationFrame(updateScrollHint);
|
||||||
|
window.addEventListener('resize', updateScrollHint);
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frame);
|
||||||
|
window.removeEventListener('resize', updateScrollHint);
|
||||||
|
};
|
||||||
|
}, [open, options, updateScrollHint]);
|
||||||
|
|
||||||
|
const sectionedOptions = [...new Set(options.map((o) => o.section))]
|
||||||
|
.map((section) => ({
|
||||||
|
section,
|
||||||
|
options: options.filter((option) => option.section === section),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.options.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
aria-describedby={undefined}
|
||||||
|
hideCloseButton
|
||||||
|
className="flex max-h-[calc(100dvh-2rem)] w-[96vw] max-w-[960px] flex-col overflow-hidden p-0 sm:rounded-xl"
|
||||||
|
>
|
||||||
|
<DialogHeader className="border-b border-border px-5 py-4">
|
||||||
|
<DialogTitle>Create Integration</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden md:grid-cols-[240px_minmax(0,1fr)]">
|
||||||
|
<div className="relative border-b border-border bg-muted/20 md:border-b-0 md:border-r">
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
onScroll={updateScrollHint}
|
||||||
|
className="max-h-56 overflow-y-auto p-2 md:max-h-[420px]"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sectionedOptions.map((group) => (
|
||||||
|
<div key={group.section} className="space-y-1.5">
|
||||||
|
<div className="px-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
|
{group.section}
|
||||||
|
</div>
|
||||||
|
{group.options.map((option) => {
|
||||||
|
const selected = option.value === selectedOption?.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-md border px-3 py-2 text-left transition-colors',
|
||||||
|
selected
|
||||||
|
? 'border-primary bg-accent text-foreground'
|
||||||
|
: 'border-transparent bg-transparent hover:bg-accent/70'
|
||||||
|
)}
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={() => onSelect(option.value)}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium">{option.label}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showScrollHint && (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-gradient-to-t from-background via-background/85 to-transparent px-4 pb-2 pt-8">
|
||||||
|
<div className="rounded-full border border-border/80 bg-background/95 px-2 py-1 text-muted-foreground shadow-sm">
|
||||||
|
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 space-y-4 overflow-y-auto px-5 py-5 md:min-h-[280px] md:max-h-[420px]">
|
||||||
|
{selectedOption ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
|
{selectedOption.section}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold">{selectedOption.label}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
{selectedOption.description}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
No integration types are currently available.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 border-t border-border px-5 py-4 sm:justify-end">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onCreate} disabled={!selectedOption}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getDetailTypeLabel(detailType: string) {
|
function getDetailTypeLabel(detailType: string) {
|
||||||
if (isDraftType(detailType)) return DRAFT_RECIPES[detailType].detailLabel;
|
if (isDraftType(detailType)) return getCreateIntegrationDefinition(detailType).label;
|
||||||
return TYPE_LABELS[detailType] || detailType;
|
return TYPE_LABELS[detailType] || detailType;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1499,9 +1685,9 @@ export function SettingsFanoutSection({
|
|||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
|
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
|
||||||
const [inlineEditName, setInlineEditName] = useState('');
|
const [inlineEditName, setInlineEditName] = useState('');
|
||||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
const [selectedCreateType, setSelectedCreateType] = useState<DraftType | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const addMenuRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const loadConfigs = useCallback(async () => {
|
const loadConfigs = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1516,18 +1702,28 @@ export function SettingsFanoutSection({
|
|||||||
loadConfigs();
|
loadConfigs();
|
||||||
}, [loadConfigs]);
|
}, [loadConfigs]);
|
||||||
|
|
||||||
|
const availableCreateOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
CREATE_INTEGRATION_DEFINITIONS.filter(
|
||||||
|
(definition) => definition.savedType !== 'bot' || !health?.bots_disabled
|
||||||
|
),
|
||||||
|
[health?.bots_disabled]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!addMenuOpen) return;
|
if (!createDialogOpen) return;
|
||||||
|
if (availableCreateOptions.length === 0) {
|
||||||
const handlePointerDown = (event: MouseEvent) => {
|
setSelectedCreateType(null);
|
||||||
if (!addMenuRef.current?.contains(event.target as Node)) {
|
return;
|
||||||
setAddMenuOpen(false);
|
}
|
||||||
}
|
if (
|
||||||
};
|
selectedCreateType &&
|
||||||
|
availableCreateOptions.some((option) => option.value === selectedCreateType)
|
||||||
document.addEventListener('mousedown', handlePointerDown);
|
) {
|
||||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
return;
|
||||||
}, [addMenuOpen]);
|
}
|
||||||
|
setSelectedCreateType(availableCreateOptions[0].value);
|
||||||
|
}, [createDialogOpen, availableCreateOptions, selectedCreateType]);
|
||||||
|
|
||||||
const handleToggleEnabled = async (cfg: FanoutConfig) => {
|
const handleToggleEnabled = async (cfg: FanoutConfig) => {
|
||||||
try {
|
try {
|
||||||
@@ -1541,7 +1737,7 @@ export function SettingsFanoutSection({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (cfg: FanoutConfig) => {
|
const handleEdit = (cfg: FanoutConfig) => {
|
||||||
setAddMenuOpen(false);
|
setCreateDialogOpen(false);
|
||||||
setInlineEditingId(null);
|
setInlineEditingId(null);
|
||||||
setInlineEditName('');
|
setInlineEditName('');
|
||||||
setDraftType(null);
|
setDraftType(null);
|
||||||
@@ -1552,7 +1748,7 @@ export function SettingsFanoutSection({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStartInlineEdit = (cfg: FanoutConfig) => {
|
const handleStartInlineEdit = (cfg: FanoutConfig) => {
|
||||||
setAddMenuOpen(false);
|
setCreateDialogOpen(false);
|
||||||
setInlineEditingId(cfg.id);
|
setInlineEditingId(cfg.id);
|
||||||
setInlineEditName(cfg.name);
|
setInlineEditName(cfg.name);
|
||||||
};
|
};
|
||||||
@@ -1611,7 +1807,7 @@ export function SettingsFanoutSection({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
if (currentDraftType) {
|
if (currentDraftType) {
|
||||||
const recipe = DRAFT_RECIPES[currentDraftType];
|
const recipe = getCreateIntegrationDefinition(currentDraftType);
|
||||||
await api.createFanoutConfig({
|
await api.createFanoutConfig({
|
||||||
type: recipe.savedType,
|
type: recipe.savedType,
|
||||||
name: normalizeDraftName(currentDraftType, editName.trim(), configs),
|
name: normalizeDraftName(currentDraftType, editName.trim(), configs),
|
||||||
@@ -1663,18 +1859,16 @@ export function SettingsFanoutSection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddCreate = async (type: string) => {
|
const handleAddCreate = (type: DraftType) => {
|
||||||
if (!isDraftType(type)) return;
|
const definition = getCreateIntegrationDefinition(type);
|
||||||
const defaults = cloneDraftDefaults(type);
|
const defaults = cloneDraftDefaults(type);
|
||||||
setAddMenuOpen(false);
|
setCreateDialogOpen(false);
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setDraftType(type);
|
setDraftType(type);
|
||||||
setEditName(
|
setEditName(
|
||||||
type === 'mqtt_community_meshrank' ||
|
definition.nameMode === 'fixed'
|
||||||
type === 'mqtt_community_letsmesh_us' ||
|
? definition.defaultName
|
||||||
type === 'mqtt_community_letsmesh_eu'
|
: getDefaultIntegrationName(definition.savedType, configs)
|
||||||
? DRAFT_RECIPES[type].defaultName
|
|
||||||
: getDefaultIntegrationName(DRAFT_RECIPES[type].savedType, configs)
|
|
||||||
);
|
);
|
||||||
setEditConfig(defaults.config);
|
setEditConfig(defaults.config);
|
||||||
setEditScope(defaults.scope);
|
setEditScope(defaults.scope);
|
||||||
@@ -1683,13 +1877,15 @@ export function SettingsFanoutSection({
|
|||||||
const editingConfig = editingId ? configs.find((c) => c.id === editingId) : null;
|
const editingConfig = editingId ? configs.find((c) => c.id === editingId) : null;
|
||||||
const detailType = draftType ?? editingConfig?.type ?? null;
|
const detailType = draftType ?? editingConfig?.type ?? null;
|
||||||
const isDraft = draftType !== null;
|
const isDraft = draftType !== null;
|
||||||
const configGroups = LIST_TYPE_OPTIONS.map((opt) => ({
|
const configGroups = Object.entries(TYPE_LABELS)
|
||||||
type: opt.value,
|
.map(([type, label]) => ({
|
||||||
label: opt.label,
|
type,
|
||||||
configs: configs
|
label,
|
||||||
.filter((cfg) => cfg.type === opt.value)
|
configs: configs
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })),
|
.filter((cfg) => cfg.type === type)
|
||||||
})).filter((group) => group.configs.length > 0);
|
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.configs.length > 0);
|
||||||
|
|
||||||
// Detail view
|
// Detail view
|
||||||
if (detailType) {
|
if (detailType) {
|
||||||
@@ -1823,37 +2019,22 @@ export function SettingsFanoutSection({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative inline-block" ref={addMenuRef}>
|
<Button type="button" size="sm" onClick={() => setCreateDialogOpen(true)}>
|
||||||
<Button
|
Add Integration
|
||||||
type="button"
|
</Button>
|
||||||
size="sm"
|
|
||||||
aria-haspopup="menu"
|
<CreateIntegrationDialog
|
||||||
aria-expanded={addMenuOpen}
|
open={createDialogOpen}
|
||||||
onClick={() => setAddMenuOpen((open) => !open)}
|
options={availableCreateOptions}
|
||||||
>
|
selectedType={selectedCreateType}
|
||||||
Add Integration
|
onOpenChange={setCreateDialogOpen}
|
||||||
</Button>
|
onSelect={setSelectedCreateType}
|
||||||
{addMenuOpen && (
|
onCreate={() => {
|
||||||
<div
|
if (selectedCreateType) {
|
||||||
role="menu"
|
handleAddCreate(selectedCreateType);
|
||||||
className="absolute left-0 top-full z-10 mt-2 min-w-72 rounded-md border border-input bg-background p-1 shadow-md"
|
}
|
||||||
>
|
}}
|
||||||
{CREATE_TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map(
|
/>
|
||||||
(opt) => (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
className="flex w-full rounded-sm px-3 py-2 text-left text-sm hover:bg-muted"
|
|
||||||
onClick={() => handleAddCreate(opt.value)}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{configGroups.length > 0 && (
|
{configGroups.length > 0 && (
|
||||||
<div className="columns-1 gap-4 md:columns-2">
|
<div className="columns-1 gap-4 md:columns-2">
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ export { useRealtimeAppState } from './useRealtimeAppState';
|
|||||||
export { useConversationActions } from './useConversationActions';
|
export { useConversationActions } from './useConversationActions';
|
||||||
export { useConversationNavigation } from './useConversationNavigation';
|
export { useConversationNavigation } from './useConversationNavigation';
|
||||||
export { useBrowserNotifications } from './useBrowserNotifications';
|
export { useBrowserNotifications } from './useBrowserNotifications';
|
||||||
|
export { useFaviconBadge, useUnreadTitle } from './useFaviconBadge';
|
||||||
export { useRawPacketStatsSession } from './useRawPacketStatsSession';
|
export { useRawPacketStatsSession } from './useRawPacketStatsSession';
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
import type { Favorite } from '../types';
|
||||||
|
import { getStateKey } from '../utils/conversationState';
|
||||||
|
|
||||||
|
const APP_TITLE = 'RemoteTerm for MeshCore';
|
||||||
|
const UNREAD_APP_TITLE = 'RemoteTerm';
|
||||||
|
const BASE_FAVICON_PATH = '/favicon.svg';
|
||||||
|
const GREEN_BADGE_FILL = '#16a34a';
|
||||||
|
const RED_BADGE_FILL = '#dc2626';
|
||||||
|
const BADGE_CENTER = 750;
|
||||||
|
const BADGE_OUTER_RADIUS = 220;
|
||||||
|
const BADGE_INNER_RADIUS = 180;
|
||||||
|
|
||||||
|
let baseFaviconSvgPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
|
export type FaviconBadgeState = 'none' | 'green' | 'red';
|
||||||
|
|
||||||
|
function getUnreadDirectMessageCount(unreadCounts: Record<string, number>): number {
|
||||||
|
return Object.entries(unreadCounts).reduce(
|
||||||
|
(sum, [stateKey, count]) => sum + (stateKey.startsWith('contact-') ? count : 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUnreadFavoriteChannelCount(
|
||||||
|
unreadCounts: Record<string, number>,
|
||||||
|
favorites: Favorite[]
|
||||||
|
): number {
|
||||||
|
return favorites.reduce(
|
||||||
|
(sum, favorite) =>
|
||||||
|
sum +
|
||||||
|
(favorite.type === 'channel' ? unreadCounts[getStateKey('channel', favorite.id)] || 0 : 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTotalUnreadCount(unreadCounts: Record<string, number>): number {
|
||||||
|
return Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFavoriteUnreadCount(
|
||||||
|
unreadCounts: Record<string, number>,
|
||||||
|
favorites: Favorite[]
|
||||||
|
): number {
|
||||||
|
return favorites.reduce((sum, favorite) => {
|
||||||
|
const stateKey = getStateKey(favorite.type, favorite.id);
|
||||||
|
return sum + (unreadCounts[stateKey] || 0);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUnreadTitle(
|
||||||
|
unreadCounts: Record<string, number>,
|
||||||
|
favorites: Favorite[]
|
||||||
|
): string {
|
||||||
|
const unreadCount = getFavoriteUnreadCount(unreadCounts, favorites);
|
||||||
|
if (unreadCount <= 0) {
|
||||||
|
return APP_TITLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = unreadCount > 99 ? '99+' : String(unreadCount);
|
||||||
|
return `(${label}) ${UNREAD_APP_TITLE}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveFaviconBadgeState(
|
||||||
|
unreadCounts: Record<string, number>,
|
||||||
|
mentions: Record<string, boolean>,
|
||||||
|
favorites: Favorite[]
|
||||||
|
): FaviconBadgeState {
|
||||||
|
if (Object.values(mentions).some(Boolean) || getUnreadDirectMessageCount(unreadCounts) > 0) {
|
||||||
|
return 'red';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getUnreadFavoriteChannelCount(unreadCounts, favorites) > 0) {
|
||||||
|
return 'green';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBadgedFaviconSvg(baseSvg: string, badgeFill: string): string {
|
||||||
|
const closingTagIndex = baseSvg.lastIndexOf('</svg>');
|
||||||
|
if (closingTagIndex === -1) {
|
||||||
|
return baseSvg;
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = `
|
||||||
|
<circle cx="${BADGE_CENTER}" cy="${BADGE_CENTER}" r="${BADGE_OUTER_RADIUS}" fill="#ffffff"/>
|
||||||
|
<circle cx="${BADGE_CENTER}" cy="${BADGE_CENTER}" r="${BADGE_INNER_RADIUS}" fill="${badgeFill}"/>
|
||||||
|
`;
|
||||||
|
return `${baseSvg.slice(0, closingTagIndex)}${badge}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBaseFaviconSvg(): Promise<string> {
|
||||||
|
if (!baseFaviconSvgPromise) {
|
||||||
|
baseFaviconSvgPromise = fetch(BASE_FAVICON_PATH, { cache: 'force-cache' })
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load favicon SVG: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
baseFaviconSvgPromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseFaviconSvgPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertFaviconLinks(rel: 'icon' | 'shortcut icon', href: string): void {
|
||||||
|
const links = Array.from(document.head.querySelectorAll<HTMLLinkElement>(`link[rel="${rel}"]`));
|
||||||
|
const targets = links.length > 0 ? links : [document.createElement('link')];
|
||||||
|
|
||||||
|
for (const link of targets) {
|
||||||
|
if (!link.parentNode) {
|
||||||
|
link.rel = rel;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
link.type = 'image/svg+xml';
|
||||||
|
link.href = href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFaviconHref(href: string): void {
|
||||||
|
upsertFaviconLinks('icon', href);
|
||||||
|
upsertFaviconLinks('shortcut icon', href);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnreadTitle(unreadCounts: Record<string, number>, favorites: Favorite[]): void {
|
||||||
|
const title = useMemo(() => getUnreadTitle(unreadCounts, favorites), [favorites, unreadCounts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = title;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.title = APP_TITLE;
|
||||||
|
};
|
||||||
|
}, [title]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFaviconBadge(
|
||||||
|
unreadCounts: Record<string, number>,
|
||||||
|
mentions: Record<string, boolean>,
|
||||||
|
favorites: Favorite[]
|
||||||
|
): void {
|
||||||
|
const objectUrlRef = useRef<string | null>(null);
|
||||||
|
const badgeState = useMemo(
|
||||||
|
() => deriveFaviconBadgeState(unreadCounts, mentions, favorites),
|
||||||
|
[favorites, mentions, unreadCounts]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (objectUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(objectUrlRef.current);
|
||||||
|
objectUrlRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (badgeState === 'none') {
|
||||||
|
applyFaviconHref(BASE_FAVICON_PATH);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeFill = badgeState === 'red' ? RED_BADGE_FILL : GREEN_BADGE_FILL;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void loadBaseFaviconSvg()
|
||||||
|
.then((baseSvg) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(
|
||||||
|
new Blob([buildBadgedFaviconSvg(baseSvg, badgeFill)], {
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
objectUrlRef.current = objectUrl;
|
||||||
|
applyFaviconHref(objectUrl);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
applyFaviconHref(BASE_FAVICON_PATH);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (objectUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(objectUrlRef.current);
|
||||||
|
objectUrlRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [badgeState]);
|
||||||
|
}
|
||||||
@@ -150,7 +150,7 @@ describe('ContactInfoPane', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads name-only channel stats and most active rooms', async () => {
|
it('loads name-only channel stats and most active channels', async () => {
|
||||||
getContactAnalytics.mockResolvedValue(
|
getContactAnalytics.mockResolvedValue(
|
||||||
createAnalytics(null, {
|
createAnalytics(null, {
|
||||||
lookup_type: 'name',
|
lookup_type: 'name',
|
||||||
@@ -188,7 +188,7 @@ describe('ContactInfoPane', () => {
|
|||||||
expect(screen.getByText('Name First In Use')).toBeInTheDocument();
|
expect(screen.getByText('Name First In Use')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Messages Per Hour')).toBeInTheDocument();
|
expect(screen.getByText('Messages Per Hour')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Messages Per Week')).toBeInTheDocument();
|
expect(screen.getByText('Messages Per Week')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Most Active Rooms')).toBeInTheDocument();
|
expect(screen.getByText('Most Active Channels')).toBeInTheDocument();
|
||||||
expect(screen.getByText('#ops')).toBeInTheDocument();
|
expect(screen.getByText('#ops')).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/Name-only analytics include channel messages only/i)
|
screen.getByText(/Name-only analytics include channel messages only/i)
|
||||||
|
|||||||
@@ -67,6 +67,29 @@ function renderSectionWithRefresh(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startsWithAccessibleName(name: string) {
|
||||||
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
return new RegExp(`^${escaped}(?:\\s|$)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCreateIntegrationDialog() {
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||||
|
return screen.findByRole('dialog', { name: 'Create Integration' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCreateIntegration(name: string) {
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'Create Integration' });
|
||||||
|
fireEvent.click(within(dialog).getByRole('button', { name: startsWithAccessibleName(name) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmCreateIntegration() {
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'Create Integration' });
|
||||||
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }));
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
@@ -76,35 +99,64 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('SettingsFanoutSection', () => {
|
describe('SettingsFanoutSection', () => {
|
||||||
it('shows add integration menu with all integration types', async () => {
|
it('shows add integration dialog with all integration types', async () => {
|
||||||
renderSection();
|
renderSection();
|
||||||
await waitFor(() => {
|
const dialog = await openCreateIntegrationDialog();
|
||||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
const optionButtons = within(dialog)
|
||||||
|
.getAllByRole('button')
|
||||||
expect(screen.getByRole('menuitem', { name: 'Private MQTT' })).toBeInTheDocument();
|
.filter((button) => button.hasAttribute('aria-pressed'));
|
||||||
expect(screen.getByRole('menuitem', { name: 'MeshRank' })).toBeInTheDocument();
|
expect(optionButtons).toHaveLength(9);
|
||||||
expect(screen.getByRole('menuitem', { name: 'LetsMesh (US)' })).toBeInTheDocument();
|
expect(within(dialog).getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('menuitem', { name: 'LetsMesh (EU)' })).toBeInTheDocument();
|
expect(within(dialog).getByRole('button', { name: 'Create' })).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('menuitem', { name: 'Community MQTT/meshcoretomqtt' })
|
within(dialog).getByRole('button', { name: startsWithAccessibleName('Private MQTT') })
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByRole('menuitem', { name: 'Webhook' })).toBeInTheDocument();
|
expect(
|
||||||
expect(screen.getByRole('menuitem', { name: 'Apprise' })).toBeInTheDocument();
|
within(dialog).getByRole('button', { name: startsWithAccessibleName('MeshRank') })
|
||||||
expect(screen.getByRole('menuitem', { name: 'Amazon SQS' })).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByRole('menuitem', { name: 'Bot' })).toBeInTheDocument();
|
expect(
|
||||||
|
within(dialog).getByRole('button', { name: startsWithAccessibleName('LetsMesh (US)') })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole('button', { name: startsWithAccessibleName('LetsMesh (EU)') })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole('button', {
|
||||||
|
name: startsWithAccessibleName('Community MQTT/meshcoretomqtt'),
|
||||||
|
})
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole('button', { name: startsWithAccessibleName('Webhook') })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole('button', { name: startsWithAccessibleName('Apprise') })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole('button', { name: startsWithAccessibleName('Amazon SQS') })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole('button', { name: startsWithAccessibleName('Python Bot') })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByRole('heading', { level: 3 })).toBeInTheDocument();
|
||||||
|
|
||||||
|
const genericCommunityIndex = optionButtons.findIndex((button) =>
|
||||||
|
button.textContent?.startsWith('Community MQTT/meshcoretomqtt')
|
||||||
|
);
|
||||||
|
const meshRankIndex = optionButtons.findIndex((button) =>
|
||||||
|
button.textContent?.startsWith('MeshRank')
|
||||||
|
);
|
||||||
|
expect(genericCommunityIndex).toBeGreaterThan(-1);
|
||||||
|
expect(meshRankIndex).toBeGreaterThan(-1);
|
||||||
|
expect(genericCommunityIndex).toBeLessThan(meshRankIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows bot option in add integration menu when bots are enabled', async () => {
|
it('shows bot option in add integration dialog when bots are enabled', async () => {
|
||||||
renderSection();
|
renderSection();
|
||||||
await waitFor(() => {
|
const dialog = await openCreateIntegrationDialog();
|
||||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
expect(
|
||||||
});
|
within(dialog).getByRole('button', { name: startsWithAccessibleName('Python Bot') })
|
||||||
|
).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
|
||||||
expect(screen.getByRole('menuitem', { name: 'Bot' })).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows bots disabled banner when bots_disabled', async () => {
|
it('shows bots disabled banner when bots_disabled', async () => {
|
||||||
@@ -123,14 +175,12 @@ describe('SettingsFanoutSection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides bot option from add integration menu when bots_disabled', async () => {
|
it('hides bot option from add integration dialog when bots_disabled', async () => {
|
||||||
renderSection({ health: { ...baseHealth, bots_disabled: true } });
|
renderSection({ health: { ...baseHealth, bots_disabled: true } });
|
||||||
await waitFor(() => {
|
const dialog = await openCreateIntegrationDialog();
|
||||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
expect(
|
||||||
});
|
within(dialog).queryByRole('button', { name: startsWithAccessibleName('Python Bot') })
|
||||||
|
).not.toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
|
||||||
expect(screen.queryByRole('menuitem', { name: 'Bot' })).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lists existing configs after load', async () => {
|
it('lists existing configs after load', async () => {
|
||||||
@@ -305,12 +355,9 @@ describe('SettingsFanoutSection', () => {
|
|||||||
|
|
||||||
it('navigates to create view when clicking add button', async () => {
|
it('navigates to create view when clicking add button', async () => {
|
||||||
renderSection();
|
renderSection();
|
||||||
await waitFor(() => {
|
await openCreateIntegrationDialog();
|
||||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
selectCreateIntegration('Webhook');
|
||||||
});
|
confirmCreateIntegration();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('← Back to list')).toBeInTheDocument();
|
expect(screen.getByText('← Back to list')).toBeInTheDocument();
|
||||||
@@ -324,12 +371,9 @@ describe('SettingsFanoutSection', () => {
|
|||||||
|
|
||||||
it('new SQS draft shows queue url fields and sensible defaults', async () => {
|
it('new SQS draft shows queue url fields and sensible defaults', async () => {
|
||||||
renderSection();
|
renderSection();
|
||||||
await waitFor(() => {
|
await openCreateIntegrationDialog();
|
||||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
selectCreateIntegration('Amazon SQS');
|
||||||
});
|
confirmCreateIntegration();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Amazon SQS' }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('← Back to list')).toBeInTheDocument();
|
expect(screen.getByText('← Back to list')).toBeInTheDocument();
|
||||||
@@ -341,12 +385,9 @@ describe('SettingsFanoutSection', () => {
|
|||||||
|
|
||||||
it('backing out of a new draft does not create an integration', async () => {
|
it('backing out of a new draft does not create an integration', async () => {
|
||||||
renderSection();
|
renderSection();
|
||||||
await waitFor(() => {
|
await openCreateIntegrationDialog();
|
||||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
selectCreateIntegration('Webhook');
|
||||||
});
|
confirmCreateIntegration();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
|
|
||||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('← Back to list'));
|
fireEvent.click(screen.getByText('← Back to list'));
|
||||||
@@ -420,12 +461,9 @@ describe('SettingsFanoutSection', () => {
|
|||||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdWebhook]);
|
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdWebhook]);
|
||||||
|
|
||||||
renderSection();
|
renderSection();
|
||||||
await waitFor(() =>
|
await openCreateIntegrationDialog();
|
||||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
|
selectCreateIntegration('Webhook');
|
||||||
);
|
confirmCreateIntegration();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
|
|
||||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save as Disabled' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Save as Disabled' }));
|
||||||
@@ -453,8 +491,9 @@ describe('SettingsFanoutSection', () => {
|
|||||||
renderSection();
|
renderSection();
|
||||||
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
await openCreateIntegrationDialog();
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
|
selectCreateIntegration('Webhook');
|
||||||
|
confirmCreateIntegration();
|
||||||
await waitFor(() => expect(screen.getByLabelText('Name')).toHaveValue('Webhook #3'));
|
await waitFor(() => expect(screen.getByLabelText('Name')).toHaveValue('Webhook #3'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -656,21 +695,21 @@ describe('SettingsFanoutSection', () => {
|
|||||||
mockedApi.getFanoutConfigs.mockResolvedValue([communityConfig]);
|
mockedApi.getFanoutConfigs.mockResolvedValue([communityConfig]);
|
||||||
renderSection();
|
renderSection();
|
||||||
|
|
||||||
await waitFor(() =>
|
const group = await screen.findByRole('group', { name: 'Integration Community Feed' });
|
||||||
expect(screen.getByText('Broker: mqtt-us-v1.letsmesh.net:443')).toBeInTheDocument()
|
expect(
|
||||||
);
|
within(group).getByText(
|
||||||
expect(screen.getByText('mesh2mqtt/{IATA}/node/{PUBLIC_KEY}')).toBeInTheDocument();
|
(_, element) => element?.textContent === 'Broker: mqtt-us-v1.letsmesh.net:443'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(within(group).getByText('mesh2mqtt/{IATA}/node/{PUBLIC_KEY}')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Region: LAX')).not.toBeInTheDocument();
|
expect(screen.queryByText('Region: LAX')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('MeshRank preset pre-fills the broker settings and asks for the topic template', async () => {
|
it('MeshRank preset pre-fills the broker settings and asks for the topic template', async () => {
|
||||||
renderSection();
|
renderSection();
|
||||||
await waitFor(() =>
|
await openCreateIntegrationDialog();
|
||||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
|
selectCreateIntegration('MeshRank');
|
||||||
);
|
confirmCreateIntegration();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name: 'MeshRank' }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||||
|
|
||||||
@@ -707,12 +746,9 @@ describe('SettingsFanoutSection', () => {
|
|||||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
||||||
|
|
||||||
renderSection();
|
renderSection();
|
||||||
await waitFor(() =>
|
await openCreateIntegrationDialog();
|
||||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
|
selectCreateIntegration('MeshRank');
|
||||||
);
|
confirmCreateIntegration();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name: 'MeshRank' }));
|
|
||||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText('Packet Topic Template'), {
|
fireEvent.change(screen.getByLabelText('Packet Topic Template'), {
|
||||||
@@ -774,12 +810,9 @@ describe('SettingsFanoutSection', () => {
|
|||||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
||||||
|
|
||||||
renderSection();
|
renderSection();
|
||||||
await waitFor(() =>
|
await openCreateIntegrationDialog();
|
||||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
|
selectCreateIntegration('LetsMesh (US)');
|
||||||
);
|
confirmCreateIntegration();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name: 'LetsMesh (US)' }));
|
|
||||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||||
|
|
||||||
expect(screen.getByLabelText('Name')).toHaveValue('LetsMesh (US)');
|
expect(screen.getByLabelText('Name')).toHaveValue('LetsMesh (US)');
|
||||||
@@ -842,12 +875,9 @@ describe('SettingsFanoutSection', () => {
|
|||||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
||||||
|
|
||||||
renderSection();
|
renderSection();
|
||||||
await waitFor(() =>
|
await openCreateIntegrationDialog();
|
||||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
|
selectCreateIntegration('LetsMesh (EU)');
|
||||||
);
|
confirmCreateIntegration();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name: 'LetsMesh (EU)' }));
|
|
||||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'user@example.com' } });
|
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'user@example.com' } });
|
||||||
@@ -880,12 +910,9 @@ describe('SettingsFanoutSection', () => {
|
|||||||
|
|
||||||
it('generic Community MQTT entry still opens the full editor', async () => {
|
it('generic Community MQTT entry still opens the full editor', async () => {
|
||||||
renderSection();
|
renderSection();
|
||||||
await waitFor(() =>
|
await openCreateIntegrationDialog();
|
||||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
|
selectCreateIntegration('Community MQTT/meshcoretomqtt');
|
||||||
);
|
confirmCreateIntegration();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Community MQTT/meshcoretomqtt' }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||||
|
|
||||||
@@ -909,9 +936,12 @@ describe('SettingsFanoutSection', () => {
|
|||||||
mockedApi.getFanoutConfigs.mockResolvedValue([privateConfig]);
|
mockedApi.getFanoutConfigs.mockResolvedValue([privateConfig]);
|
||||||
renderSection();
|
renderSection();
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('Broker: broker.local:1883')).toBeInTheDocument());
|
const group = await screen.findByRole('group', { name: 'Integration Private Broker' });
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('meshcore/dm:<pubkey>, meshcore/gm:<channel>, meshcore/raw/...')
|
within(group).getByText((_, element) => element?.textContent === 'Broker: broker.local:1883')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(group).getByText('meshcore/dm:<pubkey>, meshcore/gm:<channel>, meshcore/raw/...')
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -929,7 +959,8 @@ describe('SettingsFanoutSection', () => {
|
|||||||
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
||||||
renderSection();
|
renderSection();
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('https://example.com/hook')).toBeInTheDocument());
|
const group = await screen.findByRole('group', { name: 'Integration Webhook Feed' });
|
||||||
|
expect(within(group).getByText('https://example.com/hook')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('apprise list shows compact target summary', async () => {
|
it('apprise list shows compact target summary', async () => {
|
||||||
@@ -950,9 +981,10 @@ describe('SettingsFanoutSection', () => {
|
|||||||
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
||||||
renderSection();
|
renderSection();
|
||||||
|
|
||||||
await waitFor(() =>
|
const group = await screen.findByRole('group', { name: 'Integration Apprise Feed' });
|
||||||
expect(screen.getByText(/discord:\/\/abc, mailto:\/\/one@example.com/)).toBeInTheDocument()
|
expect(
|
||||||
);
|
within(group).getByText(/discord:\/\/abc, mailto:\/\/one@example.com/)
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sqs list shows queue url summary', async () => {
|
it('sqs list shows queue url summary', async () => {
|
||||||
@@ -972,11 +1004,10 @@ describe('SettingsFanoutSection', () => {
|
|||||||
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
||||||
renderSection();
|
renderSection();
|
||||||
|
|
||||||
await waitFor(() =>
|
const group = await screen.findByRole('group', { name: 'Integration Queue Feed' });
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events')
|
within(group).getByText('https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events')
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument();
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('groups integrations by type and sorts entries alphabetically within each group', async () => {
|
it('groups integrations by type and sorts entries alphabetically within each group', async () => {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
import { NewMessageModal } from '../components/NewMessageModal';
|
import { NewMessageModal } from '../components/NewMessageModal';
|
||||||
import type { Contact } from '../types';
|
|
||||||
import { toast } from '../components/ui/sonner';
|
import { toast } from '../components/ui/sonner';
|
||||||
|
|
||||||
// Mock sonner (toast)
|
// Mock sonner (toast)
|
||||||
@@ -18,24 +17,6 @@ vi.mock('../components/ui/sonner', () => ({
|
|||||||
toast: { success: vi.fn(), error: vi.fn() },
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockContact: Contact = {
|
|
||||||
public_key: 'aa'.repeat(32),
|
|
||||||
name: 'Alice',
|
|
||||||
type: 1,
|
|
||||||
flags: 0,
|
|
||||||
direct_path: null,
|
|
||||||
direct_path_len: -1,
|
|
||||||
direct_path_hash_mode: 0,
|
|
||||||
last_advert: null,
|
|
||||||
lat: null,
|
|
||||||
lon: null,
|
|
||||||
last_seen: null,
|
|
||||||
on_radio: false,
|
|
||||||
last_contacted: null,
|
|
||||||
last_read_at: null,
|
|
||||||
first_seen: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockToast = toast as unknown as {
|
const mockToast = toast as unknown as {
|
||||||
success: ReturnType<typeof vi.fn>;
|
success: ReturnType<typeof vi.fn>;
|
||||||
error: ReturnType<typeof vi.fn>;
|
error: ReturnType<typeof vi.fn>;
|
||||||
@@ -43,7 +24,6 @@ const mockToast = toast as unknown as {
|
|||||||
|
|
||||||
describe('NewMessageModal form reset', () => {
|
describe('NewMessageModal form reset', () => {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const onSelectConversation = vi.fn();
|
|
||||||
const onCreateContact = vi.fn().mockResolvedValue(undefined);
|
const onCreateContact = vi.fn().mockResolvedValue(undefined);
|
||||||
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
|
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
|
||||||
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
|
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
|
||||||
@@ -56,10 +36,8 @@ describe('NewMessageModal form reset', () => {
|
|||||||
return render(
|
return render(
|
||||||
<NewMessageModal
|
<NewMessageModal
|
||||||
open={open}
|
open={open}
|
||||||
contacts={[mockContact]}
|
|
||||||
undecryptedCount={5}
|
undecryptedCount={5}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSelectConversation={onSelectConversation}
|
|
||||||
onCreateContact={onCreateContact}
|
onCreateContact={onCreateContact}
|
||||||
onCreateChannel={onCreateChannel}
|
onCreateChannel={onCreateChannel}
|
||||||
onCreateHashtagChannel={onCreateHashtagChannel}
|
onCreateHashtagChannel={onCreateHashtagChannel}
|
||||||
@@ -75,7 +53,7 @@ describe('NewMessageModal form reset', () => {
|
|||||||
it('clears name after successful Create', async () => {
|
it('clears name after successful Create', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { unmount } = renderModal();
|
const { unmount } = renderModal();
|
||||||
await switchToTab(user, 'Hashtag');
|
await switchToTab(user, 'Hashtag Channel');
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText('channel-name') as HTMLInputElement;
|
const input = screen.getByPlaceholderText('channel-name') as HTMLInputElement;
|
||||||
await user.type(input, 'testchan');
|
await user.type(input, 'testchan');
|
||||||
@@ -91,14 +69,14 @@ describe('NewMessageModal form reset', () => {
|
|||||||
|
|
||||||
// Re-render to simulate reopening — state should be reset
|
// Re-render to simulate reopening — state should be reset
|
||||||
renderModal();
|
renderModal();
|
||||||
await switchToTab(user, 'Hashtag');
|
await switchToTab(user, 'Hashtag Channel');
|
||||||
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe('');
|
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears name when Cancel is clicked', async () => {
|
it('clears name when Cancel is clicked', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderModal();
|
renderModal();
|
||||||
await switchToTab(user, 'Hashtag');
|
await switchToTab(user, 'Hashtag Channel');
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText('channel-name') as HTMLInputElement;
|
const input = screen.getByPlaceholderText('channel-name') as HTMLInputElement;
|
||||||
await user.type(input, 'mychannel');
|
await user.type(input, 'mychannel');
|
||||||
@@ -127,13 +105,13 @@ describe('NewMessageModal form reset', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('new-room tab', () => {
|
describe('new-channel tab', () => {
|
||||||
it('clears name and key after successful Create', async () => {
|
it('clears name and key after successful Create', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderModal();
|
renderModal();
|
||||||
await switchToTab(user, 'Room');
|
await switchToTab(user, 'Private Channel');
|
||||||
|
|
||||||
await user.type(screen.getByPlaceholderText('Room name'), 'MyRoom');
|
await user.type(screen.getByPlaceholderText('Channel name'), 'MyRoom');
|
||||||
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
|
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||||
@@ -148,9 +126,9 @@ describe('NewMessageModal form reset', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
onCreateChannel.mockRejectedValueOnce(new Error('Bad key'));
|
onCreateChannel.mockRejectedValueOnce(new Error('Bad key'));
|
||||||
renderModal();
|
renderModal();
|
||||||
await switchToTab(user, 'Room');
|
await switchToTab(user, 'Private Channel');
|
||||||
|
|
||||||
await user.type(screen.getByPlaceholderText('Room name'), 'MyRoom');
|
await user.type(screen.getByPlaceholderText('Channel name'), 'MyRoom');
|
||||||
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
|
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
|
||||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||||
|
|
||||||
@@ -164,7 +142,7 @@ describe('NewMessageModal form reset', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('tab switching resets form', () => {
|
describe('tab switching resets form', () => {
|
||||||
it('clears contact fields when switching to room tab', async () => {
|
it('clears contact fields when switching to channel tab', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderModal();
|
renderModal();
|
||||||
await switchToTab(user, 'Contact');
|
await switchToTab(user, 'Contact');
|
||||||
@@ -172,24 +150,24 @@ describe('NewMessageModal form reset', () => {
|
|||||||
await user.type(screen.getByPlaceholderText('Contact name'), 'Bob');
|
await user.type(screen.getByPlaceholderText('Contact name'), 'Bob');
|
||||||
await user.type(screen.getByPlaceholderText('64-character hex public key'), 'deadbeef');
|
await user.type(screen.getByPlaceholderText('64-character hex public key'), 'deadbeef');
|
||||||
|
|
||||||
// Switch to Room tab — fields should reset
|
// Switch to Private Channel tab — fields should reset
|
||||||
await switchToTab(user, 'Room');
|
await switchToTab(user, 'Private Channel');
|
||||||
|
|
||||||
expect((screen.getByPlaceholderText('Room name') as HTMLInputElement).value).toBe('');
|
expect((screen.getByPlaceholderText('Channel name') as HTMLInputElement).value).toBe('');
|
||||||
expect((screen.getByPlaceholderText('Pre-shared key (hex)') as HTMLInputElement).value).toBe(
|
expect((screen.getByPlaceholderText('Pre-shared key (hex)') as HTMLInputElement).value).toBe(
|
||||||
''
|
''
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears room fields when switching to hashtag tab', async () => {
|
it('clears channel fields when switching to hashtag tab', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderModal();
|
renderModal();
|
||||||
await switchToTab(user, 'Room');
|
await switchToTab(user, 'Private Channel');
|
||||||
|
|
||||||
await user.type(screen.getByPlaceholderText('Room name'), 'SecretRoom');
|
await user.type(screen.getByPlaceholderText('Channel name'), 'SecretRoom');
|
||||||
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'ff'.repeat(16));
|
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'ff'.repeat(16));
|
||||||
|
|
||||||
await switchToTab(user, 'Hashtag');
|
await switchToTab(user, 'Hashtag Channel');
|
||||||
|
|
||||||
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe('');
|
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe('');
|
||||||
});
|
});
|
||||||
@@ -199,7 +177,7 @@ describe('NewMessageModal form reset', () => {
|
|||||||
it('resets tryHistorical when switching tabs', async () => {
|
it('resets tryHistorical when switching tabs', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderModal();
|
renderModal();
|
||||||
await switchToTab(user, 'Hashtag');
|
await switchToTab(user, 'Hashtag Channel');
|
||||||
|
|
||||||
// Check the "Try decrypting" checkbox
|
// Check the "Try decrypting" checkbox
|
||||||
const checkbox = screen.getByRole('checkbox', { name: /Try decrypting/ });
|
const checkbox = screen.getByRole('checkbox', { name: /Try decrypting/ });
|
||||||
@@ -210,7 +188,7 @@ describe('NewMessageModal form reset', () => {
|
|||||||
|
|
||||||
// Switch tab and come back
|
// Switch tab and come back
|
||||||
await switchToTab(user, 'Contact');
|
await switchToTab(user, 'Contact');
|
||||||
await switchToTab(user, 'Hashtag');
|
await switchToTab(user, 'Hashtag Channel');
|
||||||
|
|
||||||
// The streaming message should be gone (tryHistorical was reset)
|
// The streaming message should be gone (tryHistorical was reset)
|
||||||
expect(screen.queryByText(/Messages will stream in/)).toBeNull();
|
expect(screen.queryByText(/Messages will stream in/)).toBeNull();
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ describe('RawPacketFeedView', () => {
|
|||||||
expect(screen.queryByText('Identity not resolvable')).not.toBeInTheDocument();
|
expect(screen.queryByText('Identity not resolvable')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens a packet detail modal from the raw feed and decrypts room messages when a key is loaded', () => {
|
it('opens a packet detail modal from the raw feed and decrypts channel messages when a key is loaded', () => {
|
||||||
renderView({
|
renderView({
|
||||||
packets: [
|
packets: [
|
||||||
{
|
{
|
||||||
@@ -392,7 +392,7 @@ describe('RawPacketFeedView', () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not guess a room name when multiple loaded channels collide on the group hash', () => {
|
it('does not guess a channel name when multiple loaded channels collide on the group hash', () => {
|
||||||
renderView({
|
renderView({
|
||||||
packets: [
|
packets: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildBadgedFaviconSvg,
|
||||||
|
deriveFaviconBadgeState,
|
||||||
|
getFavoriteUnreadCount,
|
||||||
|
getUnreadTitle,
|
||||||
|
getTotalUnreadCount,
|
||||||
|
useFaviconBadge,
|
||||||
|
useUnreadTitle,
|
||||||
|
} from '../hooks/useFaviconBadge';
|
||||||
|
import type { Favorite } from '../types';
|
||||||
|
import { getStateKey } from '../utils/conversationState';
|
||||||
|
|
||||||
|
function getIconHref(rel: 'icon' | 'shortcut icon'): string | null {
|
||||||
|
return (
|
||||||
|
document.head.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`)?.getAttribute('href') ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useFaviconBadge', () => {
|
||||||
|
const baseSvg =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="1000" height="1000"/></svg>';
|
||||||
|
const originalCreateObjectURL = URL.createObjectURL;
|
||||||
|
const originalRevokeObjectURL = URL.revokeObjectURL;
|
||||||
|
let objectUrlCounter = 0;
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
let createObjectURLMock: ReturnType<typeof vi.fn>;
|
||||||
|
let revokeObjectURLMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.head.innerHTML = `
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
|
`;
|
||||||
|
document.title = 'RemoteTerm for MeshCore';
|
||||||
|
objectUrlCounter = 0;
|
||||||
|
fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
text: async () => baseSvg,
|
||||||
|
});
|
||||||
|
createObjectURLMock = vi.fn(() => `blob:generated-${++objectUrlCounter}`);
|
||||||
|
revokeObjectURLMock = vi.fn();
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
Object.defineProperty(URL, 'createObjectURL', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: createObjectURLMock,
|
||||||
|
});
|
||||||
|
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: revokeObjectURLMock,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
Object.defineProperty(URL, 'createObjectURL', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: originalCreateObjectURL,
|
||||||
|
});
|
||||||
|
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: originalRevokeObjectURL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives badge priority from unread counts, mentions, and favorites', () => {
|
||||||
|
const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }];
|
||||||
|
|
||||||
|
expect(deriveFaviconBadgeState({}, {}, favorites)).toBe('none');
|
||||||
|
expect(
|
||||||
|
deriveFaviconBadgeState(
|
||||||
|
{
|
||||||
|
[getStateKey('channel', 'fav-chan')]: 3,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
favorites
|
||||||
|
)
|
||||||
|
).toBe('green');
|
||||||
|
expect(
|
||||||
|
deriveFaviconBadgeState(
|
||||||
|
{
|
||||||
|
[getStateKey('contact', 'abc')]: 12,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
favorites
|
||||||
|
)
|
||||||
|
).toBe('red');
|
||||||
|
expect(
|
||||||
|
deriveFaviconBadgeState(
|
||||||
|
{
|
||||||
|
[getStateKey('channel', 'fav-chan')]: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[getStateKey('channel', 'fav-chan')]: true,
|
||||||
|
},
|
||||||
|
favorites
|
||||||
|
)
|
||||||
|
).toBe('red');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a dot-only badge into the base svg markup', () => {
|
||||||
|
const svg = buildBadgedFaviconSvg(baseSvg, '#16a34a');
|
||||||
|
|
||||||
|
expect(svg).toContain('<circle cx="750" cy="750" r="220" fill="#ffffff"/>');
|
||||||
|
expect(svg).toContain('<circle cx="750" cy="750" r="180" fill="#16a34a"/>');
|
||||||
|
expect(svg).not.toContain('<text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives the unread count and page title', () => {
|
||||||
|
expect(getTotalUnreadCount({})).toBe(0);
|
||||||
|
expect(getTotalUnreadCount({ a: 2, b: 5 })).toBe(7);
|
||||||
|
expect(getFavoriteUnreadCount({}, [])).toBe(0);
|
||||||
|
expect(
|
||||||
|
getFavoriteUnreadCount(
|
||||||
|
{
|
||||||
|
[getStateKey('channel', 'fav-chan')]: 7,
|
||||||
|
[getStateKey('contact', 'fav-contact')]: 3,
|
||||||
|
[getStateKey('channel', 'other-chan')]: 9,
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{ type: 'channel', id: 'fav-chan' },
|
||||||
|
{ type: 'contact', id: 'fav-contact' },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
).toBe(10);
|
||||||
|
expect(getUnreadTitle({}, [])).toBe('RemoteTerm for MeshCore');
|
||||||
|
expect(
|
||||||
|
getUnreadTitle(
|
||||||
|
{
|
||||||
|
[getStateKey('channel', 'fav-chan')]: 7,
|
||||||
|
[getStateKey('channel', 'other-chan')]: 9,
|
||||||
|
},
|
||||||
|
[{ type: 'channel', id: 'fav-chan' }]
|
||||||
|
)
|
||||||
|
).toBe('(7) RemoteTerm');
|
||||||
|
expect(
|
||||||
|
getUnreadTitle(
|
||||||
|
{
|
||||||
|
[getStateKey('channel', 'fav-chan')]: 120,
|
||||||
|
},
|
||||||
|
[{ type: 'channel', id: 'fav-chan' }]
|
||||||
|
)
|
||||||
|
).toBe('(99+) RemoteTerm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches between the base favicon and generated blob badges', async () => {
|
||||||
|
const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }];
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({
|
||||||
|
unreadCounts,
|
||||||
|
mentions,
|
||||||
|
currentFavorites,
|
||||||
|
}: {
|
||||||
|
unreadCounts: Record<string, number>;
|
||||||
|
mentions: Record<string, boolean>;
|
||||||
|
currentFavorites: Favorite[];
|
||||||
|
}) => useFaviconBadge(unreadCounts, mentions, currentFavorites),
|
||||||
|
{
|
||||||
|
initialProps: {
|
||||||
|
unreadCounts: {},
|
||||||
|
mentions: {},
|
||||||
|
currentFavorites: favorites,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getIconHref('icon')).toBe('/favicon.svg');
|
||||||
|
expect(getIconHref('shortcut icon')).toBe('/favicon.svg');
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({
|
||||||
|
unreadCounts: {
|
||||||
|
[getStateKey('channel', 'fav-chan')]: 1,
|
||||||
|
},
|
||||||
|
mentions: {},
|
||||||
|
currentFavorites: favorites,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getIconHref('icon')).toBe('blob:generated-1');
|
||||||
|
expect(getIconHref('shortcut icon')).toBe('blob:generated-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({
|
||||||
|
unreadCounts: {
|
||||||
|
[getStateKey('contact', 'dm-key')]: 12,
|
||||||
|
},
|
||||||
|
mentions: {},
|
||||||
|
currentFavorites: favorites,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getIconHref('icon')).toBe('blob:generated-2');
|
||||||
|
expect(getIconHref('shortcut icon')).toBe('blob:generated-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({
|
||||||
|
unreadCounts: {},
|
||||||
|
mentions: {},
|
||||||
|
currentFavorites: favorites,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getIconHref('icon')).toBe('/favicon.svg');
|
||||||
|
expect(getIconHref('shortcut icon')).toBe('/favicon.svg');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(createObjectURLMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:generated-1');
|
||||||
|
expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:generated-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes unread counts into the page title', () => {
|
||||||
|
const { rerender, unmount } = renderHook(
|
||||||
|
({
|
||||||
|
unreadCounts,
|
||||||
|
favorites,
|
||||||
|
}: {
|
||||||
|
unreadCounts: Record<string, number>;
|
||||||
|
favorites: Favorite[];
|
||||||
|
}) => useUnreadTitle(unreadCounts, favorites),
|
||||||
|
{
|
||||||
|
initialProps: {
|
||||||
|
unreadCounts: {},
|
||||||
|
favorites: [{ type: 'channel', id: 'fav-chan' }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(document.title).toBe('RemoteTerm for MeshCore');
|
||||||
|
|
||||||
|
rerender({
|
||||||
|
unreadCounts: {
|
||||||
|
[getStateKey('channel', 'fav-chan')]: 4,
|
||||||
|
[getStateKey('contact', 'dm-key')]: 2,
|
||||||
|
},
|
||||||
|
favorites: [{ type: 'channel', id: 'fav-chan' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.title).toBe('(4) RemoteTerm');
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(document.title).toBe('RemoteTerm for MeshCore');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -67,8 +67,8 @@ export function describeCiphertextStructure(
|
|||||||
case PayloadType.GroupText:
|
case PayloadType.GroupText:
|
||||||
return `Encrypted message content (${byteLength} bytes). Contains encrypted plaintext with this structure:
|
return `Encrypted message content (${byteLength} bytes). Contains encrypted plaintext with this structure:
|
||||||
• Timestamp (4 bytes) - send time as unix timestamp
|
• Timestamp (4 bytes) - send time as unix timestamp
|
||||||
• Flags (1 byte) - room-message flags byte
|
• Flags (1 byte) - channel-message flags byte
|
||||||
• Message (remaining bytes) - UTF-8 room message text`;
|
• Message (remaining bytes) - UTF-8 channel message text`;
|
||||||
case PayloadType.TextMessage:
|
case PayloadType.TextMessage:
|
||||||
return `Encrypted message data (${byteLength} bytes). Contains encrypted plaintext with this structure:
|
return `Encrypted message data (${byteLength} bytes). Contains encrypted plaintext with this structure:
|
||||||
• Timestamp (4 bytes) - send time as unix timestamp
|
• Timestamp (4 bytes) - send time as unix timestamp
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { Locator, Page } from '@playwright/test';
|
import type { Locator, Page } from '@playwright/test';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
|
|
||||||
|
function escapeRegex(text: string): string {
|
||||||
|
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
export function createCaptureServer(urlFactory: (port: number) => string) {
|
export function createCaptureServer(urlFactory: (port: number) => string) {
|
||||||
const requests: { body: string; headers: http.IncomingHttpHeaders }[] = [];
|
const requests: { body: string; headers: http.IncomingHttpHeaders }[] = [];
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
@@ -38,6 +42,15 @@ export async function openFanoutSettings(page: Page): Promise<void> {
|
|||||||
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
|
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function startIntegrationDraft(page: Page, integrationName: string): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: 'Add Integration' }).click();
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'Create Integration' });
|
||||||
|
await dialog
|
||||||
|
.getByRole('button', { name: new RegExp(`^${escapeRegex(integrationName)}(?:\\s|$)`) })
|
||||||
|
.click();
|
||||||
|
await dialog.getByRole('button', { name: 'Create' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
export function fanoutHeader(page: Page, name: string): Locator {
|
export function fanoutHeader(page: Page, name: string): Locator {
|
||||||
const nameButton = page.getByRole('button', { name, exact: true });
|
const nameButton = page.getByRole('button', { name, exact: true });
|
||||||
return page
|
return page
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import {
|
|||||||
deleteFanoutConfig,
|
deleteFanoutConfig,
|
||||||
getFanoutConfigs,
|
getFanoutConfigs,
|
||||||
} from '../helpers/api';
|
} from '../helpers/api';
|
||||||
import { createCaptureServer, fanoutHeader, openFanoutSettings } from '../helpers/fanout';
|
import {
|
||||||
|
createCaptureServer,
|
||||||
|
fanoutHeader,
|
||||||
|
openFanoutSettings,
|
||||||
|
startIntegrationDraft,
|
||||||
|
} from '../helpers/fanout';
|
||||||
|
|
||||||
test.describe('Apprise integration settings', () => {
|
test.describe('Apprise integration settings', () => {
|
||||||
let createdAppriseId: string | null = null;
|
let createdAppriseId: string | null = null;
|
||||||
@@ -35,9 +40,7 @@ test.describe('Apprise integration settings', () => {
|
|||||||
await openFanoutSettings(page);
|
await openFanoutSettings(page);
|
||||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||||
|
|
||||||
// Open add menu and pick Apprise
|
await startIntegrationDraft(page, 'Apprise');
|
||||||
await page.getByRole('button', { name: 'Add Integration' }).click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Apprise' }).click();
|
|
||||||
|
|
||||||
// Should navigate to the detail/edit view with a numbered default name
|
// Should navigate to the detail/edit view with a numbered default name
|
||||||
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Apprise #\d+/);
|
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Apprise #\d+/);
|
||||||
|
|||||||
+22
-20
@@ -1,9 +1,10 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import {
|
import {
|
||||||
ensureFlightlessChannel,
|
ensureFlightlessChannel,
|
||||||
createFanoutConfig,
|
|
||||||
deleteFanoutConfig,
|
deleteFanoutConfig,
|
||||||
|
getFanoutConfigs,
|
||||||
} from '../helpers/api';
|
} from '../helpers/api';
|
||||||
|
import { openFanoutSettings, startIntegrationDraft } from '../helpers/fanout';
|
||||||
|
|
||||||
const BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
const BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||||
if channel_name == "#flightless" and "!e2etest" in message_text.lower():
|
if channel_name == "#flightless" and "!e2etest" in message_text.lower():
|
||||||
@@ -28,32 +29,35 @@ test.describe('Bot functionality', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('create a bot via API, verify it in UI, trigger it, and verify response', async ({
|
test('create a bot via UI, trigger it, and verify response', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// --- Step 1: Create and enable bot via fanout API ---
|
await openFanoutSettings(page);
|
||||||
const bot = await createFanoutConfig({
|
|
||||||
type: 'bot',
|
|
||||||
name: 'E2E Test Bot',
|
|
||||||
config: { code: BOT_CODE },
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
createdBotId = bot.id;
|
|
||||||
|
|
||||||
// --- Step 2: Verify bot appears in settings UI ---
|
|
||||||
await page.goto('/');
|
|
||||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByText('Settings').click();
|
await startIntegrationDraft(page, 'Python Bot');
|
||||||
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
|
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Python Bot #\d+/);
|
||||||
|
|
||||||
|
await page.locator('#fanout-edit-name').fill('E2E Test Bot');
|
||||||
|
|
||||||
|
const codeEditor = page.getByLabel('Bot code editor');
|
||||||
|
await codeEditor.click();
|
||||||
|
await codeEditor.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
|
||||||
|
await codeEditor.fill(BOT_CODE);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Save as Enabled/i }).click();
|
||||||
|
await expect(page.getByText('Integration saved and enabled')).toBeVisible();
|
||||||
|
|
||||||
// The bot name should be visible in the integration list
|
|
||||||
await expect(page.getByText('E2E Test Bot')).toBeVisible();
|
await expect(page.getByText('E2E Test Bot')).toBeVisible();
|
||||||
|
|
||||||
// Exit settings page mode
|
const configs = await getFanoutConfigs();
|
||||||
|
const createdBot = configs.find((config) => config.name === 'E2E Test Bot');
|
||||||
|
if (createdBot) {
|
||||||
|
createdBotId = createdBot.id;
|
||||||
|
}
|
||||||
|
|
||||||
await page.getByRole('button', { name: /Back to Chat/i }).click();
|
await page.getByRole('button', { name: /Back to Chat/i }).click();
|
||||||
|
|
||||||
// --- Step 3: Trigger the bot ---
|
|
||||||
await page.getByText('#flightless', { exact: true }).first().click();
|
await page.getByText('#flightless', { exact: true }).first().click();
|
||||||
|
|
||||||
const triggerMessage = `!e2etest ${Date.now()}`;
|
const triggerMessage = `!e2etest ${Date.now()}`;
|
||||||
@@ -61,8 +65,6 @@ test.describe('Bot functionality', () => {
|
|||||||
await input.fill(triggerMessage);
|
await input.fill(triggerMessage);
|
||||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||||
|
|
||||||
// --- Step 4: Verify bot response appears ---
|
|
||||||
// Bot has ~2s delay before responding, plus radio send time
|
|
||||||
await expect(page.getByText('[BOT] e2e-ok')).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText('[BOT] e2e-ok')).toBeVisible({ timeout: 30_000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { createChannel, getChannels, getMessages } from '../helpers/api';
|
|||||||
* Timeout is 3 minutes to allow for intermittent traffic.
|
* Timeout is 3 minutes to allow for intermittent traffic.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ROOMS = [
|
const CHANNELS = [
|
||||||
'#flightless', '#bot', '#snoco', '#skagit', '#edmonds', '#bachelorette',
|
'#flightless', '#bot', '#snoco', '#skagit', '#edmonds', '#bachelorette',
|
||||||
'#emergency', '#furry', '#public', '#puppy', '#foobar', '#capitolhill',
|
'#emergency', '#furry', '#public', '#puppy', '#foobar', '#capitolhill',
|
||||||
'#hamradio', '#icewatch', '#saucefamily', '#scvsar', '#startrek', '#metalmusic',
|
'#hamradio', '#icewatch', '#saucefamily', '#scvsar', '#startrek', '#metalmusic',
|
||||||
@@ -39,14 +39,14 @@ test.describe('Incoming mesh messages', () => {
|
|||||||
test.setTimeout(180_000);
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
// Ensure all rooms exist — create any that are missing
|
// Ensure all channels exist — create any that are missing
|
||||||
const existing = await getChannels();
|
const existing = await getChannels();
|
||||||
const existingNames = new Set(existing.map((c) => c.name));
|
const existingNames = new Set(existing.map((c) => c.name));
|
||||||
|
|
||||||
for (const room of ROOMS) {
|
for (const channel of CHANNELS) {
|
||||||
if (!existingNames.has(room)) {
|
if (!existingNames.has(channel)) {
|
||||||
try {
|
try {
|
||||||
await createChannel(room);
|
await createChannel(channel);
|
||||||
} catch {
|
} catch {
|
||||||
// May already exist from a concurrent creation, ignore
|
// May already exist from a concurrent creation, ignore
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ test.describe('Incoming mesh messages', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('receive an incoming message in any room', { tag: '@mesh-traffic' }, async ({ page }) => {
|
test('receive an incoming message in any channel', { tag: '@mesh-traffic' }, async ({ page }) => {
|
||||||
// Nudge echo bot on #flightless — may generate an incoming packet quickly
|
// Nudge echo bot on #flightless — may generate an incoming packet quickly
|
||||||
await nudgeEchoBot();
|
await nudgeEchoBot();
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import {
|
|||||||
deleteFanoutConfig,
|
deleteFanoutConfig,
|
||||||
getFanoutConfigs,
|
getFanoutConfigs,
|
||||||
} from '../helpers/api';
|
} from '../helpers/api';
|
||||||
import { createCaptureServer, fanoutHeader, openFanoutSettings } from '../helpers/fanout';
|
import {
|
||||||
|
createCaptureServer,
|
||||||
|
fanoutHeader,
|
||||||
|
openFanoutSettings,
|
||||||
|
startIntegrationDraft,
|
||||||
|
} from '../helpers/fanout';
|
||||||
|
|
||||||
test.describe('Webhook integration settings', () => {
|
test.describe('Webhook integration settings', () => {
|
||||||
let createdWebhookId: string | null = null;
|
let createdWebhookId: string | null = null;
|
||||||
@@ -35,9 +40,7 @@ test.describe('Webhook integration settings', () => {
|
|||||||
await openFanoutSettings(page);
|
await openFanoutSettings(page);
|
||||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||||
|
|
||||||
// Open add menu and pick Webhook
|
await startIntegrationDraft(page, 'Webhook');
|
||||||
await page.getByRole('button', { name: 'Add Integration' }).click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Webhook' }).click();
|
|
||||||
|
|
||||||
// Should navigate to the detail/edit view with a numbered default name
|
// Should navigate to the detail/edit view with a numbered default name
|
||||||
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Webhook #\d+/);
|
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Webhook #\d+/);
|
||||||
@@ -77,8 +80,7 @@ test.describe('Webhook integration settings', () => {
|
|||||||
await openFanoutSettings(page);
|
await openFanoutSettings(page);
|
||||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Add Integration' }).click();
|
await startIntegrationDraft(page, 'Webhook');
|
||||||
await page.getByRole('menuitem', { name: 'Webhook' }).click();
|
|
||||||
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Webhook #\d+/);
|
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Webhook #\d+/);
|
||||||
|
|
||||||
await page.locator('#fanout-edit-name').fill('Unsaved Webhook Draft');
|
await page.locator('#fanout-edit-name').fill('Unsaved Webhook Draft');
|
||||||
|
|||||||
Reference in New Issue
Block a user