diff --git a/eslint.config.js b/eslint.config.js index 1e9e1eb..e9523e4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -38,6 +38,7 @@ export default [ Timer: 'readonly', require: 'readonly', Bun: 'readonly', + fetch: 'readonly', }, }, plugins: { diff --git a/src/actions/serverActions.ts b/src/actions/serverActions.ts index 8955cf4..085de36 100644 --- a/src/actions/serverActions.ts +++ b/src/actions/serverActions.ts @@ -28,11 +28,27 @@ export function registerServerActions(registry: ActionRegistry) { execute: async (ctx: ActionContext) => { const { store } = ctx - // Open connect modal (assuming store has direct access to actions) + store.setDiscoverServerPrefill(null) store.openModal('connect') }, }) + registry.register({ + id: 'server.discover', + label: 'Browse Public Servers', + description: 'Browse IRC networks from the ObsidianIRC directory', + category: 'server', + keywords: ['discover', 'browse', 'directory', 'public', 'servers'], + priority: 95, + + isEnabled: () => true, + isVisible: () => true, + + execute: async (ctx: ActionContext) => { + ctx.store.openModal('discover') + }, + }) + // Connect with parameters (for programmatic use) registry.register({ id: 'server.connectWith', diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 0bcaabd..172a5f1 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -13,6 +13,7 @@ import { RemoveServerModal } from '../modals/RemoveServerModal' import { EmojiPickerModal } from '../modals/EmojiPickerModal' import { SetTopicModal } from '../modals/SetTopicModal' import { ChannelBrowserModal } from '../modals/ChannelBrowserModal' +import { DiscoverServerModal } from '../modals/DiscoverServerModal' import { useStore } from '../../store' import { useAppContext } from '../../context/AppContext' import { THEME } from '../../constants/theme' @@ -152,6 +153,8 @@ export function MainLayout() { {activeModal === 'connect' && } + {activeModal === 'discover' && } + {activeModal === 'removeServer' && } {activeModal === 'emojiPicker' && ( diff --git a/src/components/modals/ConnectServerModal.tsx b/src/components/modals/ConnectServerModal.tsx index 0022064..067d62d 100644 --- a/src/components/modals/ConnectServerModal.tsx +++ b/src/components/modals/ConnectServerModal.tsx @@ -19,20 +19,32 @@ export function ConnectServerModal({ width, height }: ConnectServerModalProps) { const { registry, ircClient, renderer } = useAppContext() const store = useStore() const closeModal = useStore((state) => state.closeModal) + const setDiscoverServerPrefill = useStore((state) => state.setDiscoverServerPrefill) + const discoverPrefill = useStore((state) => state.discoverServerPrefill) const [formError, setFormError] = useState('') // Prefill from --setup CLI args if present const prefill = globalThis.__CLI_PREFILL__ const restrictions = getRestrictions() - const defaultPort = String(restrictions.port ?? prefill?.port ?? 6697) + const defaultPort = String(restrictions.port ?? discoverPrefill?.port ?? prefill?.port ?? 6697) + + const handleClose = () => { + setDiscoverServerPrefill(null) + closeModal() + } const fields: FormField[] = [ - { key: 'name', label: 'Server Name', placeholder: 'My Server', defaultValue: prefill?.host }, + { + key: 'name', + label: 'Server Name', + placeholder: 'My Server', + defaultValue: discoverPrefill?.name ?? prefill?.host, + }, { key: 'host', label: 'Host', placeholder: 'irc.example.com', - defaultValue: restrictions.server ?? prefill?.host, + defaultValue: restrictions.server ?? discoverPrefill?.host ?? prefill?.host, // Lock the field so the user cannot type a different host readOnly: !!restrictions.server, }, @@ -82,6 +94,7 @@ export function ConnectServerModal({ width, height }: ConnectServerModalProps) { return } setFormError('') + setDiscoverServerPrefill(null) const context = { store, ircClient, renderer } const params = { @@ -106,7 +119,7 @@ export function ConnectServerModal({ width, height }: ConnectServerModalProps) { title="Add Server" fields={fields} onSubmit={handleSubmit} - onCancel={closeModal} + onCancel={handleClose} submitLabel="Connect" error={formError || undefined} /> diff --git a/src/components/modals/DiscoverServerModal.tsx b/src/components/modals/DiscoverServerModal.tsx new file mode 100644 index 0000000..04a0d1a --- /dev/null +++ b/src/components/modals/DiscoverServerModal.tsx @@ -0,0 +1,88 @@ +import { useEffect, useMemo, useState } from 'react' +import { useStore } from '../../store' +import { ListModal, type ListItem } from './ListModal' +import { + fetchDiscoverServers, + toDiscoverServerPrefill, + type DiscoverServerEntry, +} from '../../utils/discoverServers' + +interface DiscoverServerModalProps { + width: number + height: number +} + +export function DiscoverServerModal({ width, height }: DiscoverServerModalProps) { + const openModal = useStore((state) => state.openModal) + const closeModal = useStore((state) => state.closeModal) + const setDiscoverServerPrefill = useStore((state) => state.setDiscoverServerPrefill) + const [query, setQuery] = useState('') + const [servers, setServers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + + const loadServers = async () => { + try { + const nextServers = await fetchDiscoverServers() + if (cancelled) return + setServers(nextServers) + setError(null) + } catch (loadError) { + if (cancelled) return + setServers([]) + setError(loadError instanceof Error ? loadError.message : 'Failed to load discover servers') + } finally { + if (!cancelled) { + setLoading(false) + } + } + } + + loadServers() + + return () => { + cancelled = true + } + }, []) + + const items = useMemo(() => { + return servers.map((server) => ({ + id: server.id, + label: server.name, + sublabel: `${server.host}:${server.port}${server.description ? ` — ${server.description}` : ''}`, + fg: server.obsidian ? 'cyan' : undefined, + })) + }, [servers]) + + const emptyMessage = loading + ? 'Loading public IRC servers...' + : (error ?? 'No public servers found') + + const handleSelect = (item: ListItem) => { + const server = servers.find((entry) => entry.id === item.id) + if (!server) return + + setDiscoverServerPrefill(toDiscoverServerPrefill(server)) + openModal('connect') + } + + return ( + + ) +} diff --git a/src/components/modals/ListModal.tsx b/src/components/modals/ListModal.tsx index d86a181..688bb36 100644 --- a/src/components/modals/ListModal.tsx +++ b/src/components/modals/ListModal.tsx @@ -24,6 +24,8 @@ interface ListModalProps { onCancel: () => void placeholder?: string emptyMessage?: string + itemLayout?: 'inline' | 'stacked' + modalWidth?: number } export function ListModal({ @@ -37,22 +39,28 @@ export function ListModal({ onCancel, placeholder = 'Search...', emptyMessage = 'No results', + itemLayout = 'inline', + modalWidth: requestedModalWidth, }: ListModalProps) { const [selectedIndex, setSelectedIndex] = useState(0) const scrollBoxRef = useRef(null) + const itemHeight = itemLayout === 'stacked' ? 2 : 1 useEffect(() => { const box = scrollBoxRef.current if (!box) return const viewportHeight = box.height ?? 0 - if (selectedIndex < box.scrollTop) { - box.scrollTop = selectedIndex - } else if (selectedIndex >= box.scrollTop + viewportHeight) { - box.scrollTop = selectedIndex - viewportHeight + 1 + const viewportItems = Math.max(1, Math.floor(viewportHeight / itemHeight)) + const firstVisibleItem = Math.floor(box.scrollTop / itemHeight) + + if (selectedIndex < firstVisibleItem) { + box.scrollTop = selectedIndex * itemHeight + } else if (selectedIndex >= firstVisibleItem + viewportItems) { + box.scrollTop = (selectedIndex - viewportItems + 1) * itemHeight } - }, [selectedIndex]) + }, [itemHeight, selectedIndex]) - const modalWidth = Math.min(60, width - 4) + const modalWidth = Math.min(requestedModalWidth ?? 60, width - 4) const modalHeight = Math.min(20, height - 4) const fuse = useMemo( @@ -65,7 +73,15 @@ export function ListModal({ return fuse.search(query).map((r) => r.item) }, [query, items, fuse]) - const visibleItems = filteredItems.slice(0, modalHeight - 5) + const visibleItems = filteredItems.slice( + 0, + Math.max(1, Math.floor((modalHeight - 5) / itemHeight)) + ) + + const truncateText = (text: string, maxLength: number) => { + if (text.length <= maxLength) return text + return `${text.slice(0, Math.max(0, maxLength - 1))}…` + } useKeyboard((key) => { if (key.name === 'escape') { @@ -174,16 +190,33 @@ export function ListModal({ key={item.id} paddingLeft={2} paddingRight={2} + height={itemHeight} backgroundColor={index === selectedIndex ? THEME.selectedBackground : undefined} onMouseDown={() => onSelect(item)} > - - {item.icon && {item.icon}} - - {item.label} - - {item.sublabel && {item.sublabel}} - + {itemLayout === 'stacked' ? ( + + + {item.icon && {item.icon}} + + {truncateText(item.label, modalWidth - 6)} + + + + {truncateText(item.sublabel ?? '', modalWidth - 6)} + + + ) : ( + + {item.icon && {item.icon}} + + {item.label} + + {item.sublabel && {item.sublabel}} + + )} )) )} diff --git a/src/services/commands.ts b/src/services/commands.ts index c014379..3e971eb 100644 --- a/src/services/commands.ts +++ b/src/services/commands.ts @@ -92,6 +92,7 @@ export class CommandParser { '', 'IRC COMMANDS:', ' • /connect Connect to server', + ' • /discover Browse public IRC servers', ' • /join #channel Join channel', ' • /part [#channel] Leave channel', ' • /msg Send message to nick or channel', @@ -175,6 +176,19 @@ export class CommandParser { }, }) + this.register({ + name: 'discover', + aliases: [], + description: 'Browse public IRC servers', + usage: '/discover', + minArgs: 0, + maxArgs: 0, + execute: async (_args, ctx) => { + await this.registry.execute('server.discover', ctx) + return { success: true } + }, + }) + this.register({ name: 'join', aliases: ['j'], diff --git a/src/store/slices/uiSlice.ts b/src/store/slices/uiSlice.ts index 627413c..d78159a 100644 --- a/src/store/slices/uiSlice.ts +++ b/src/store/slices/uiSlice.ts @@ -6,6 +6,7 @@ import { getDatabase } from '@/services/database' export interface UISlice extends UIState { openModal: (modalId: string) => void closeModal: () => void + setDiscoverServerPrefill: (prefill: UIState['discoverServerPrefill']) => void setFocusedPane: (pane: 'servers' | 'chat' | 'users') => void setFocusedChannel: (channelId: string | null) => void toggleServerPane: () => void @@ -26,6 +27,7 @@ export interface UISlice extends UIState { export const createUISlice: StateCreator = (set, get) => ({ activeModal: null, + discoverServerPrefill: null, focusedPane: 'chat', focusedChannel: null, showServerPane: true, @@ -45,6 +47,7 @@ export const createUISlice: StateCreator = (set, get) openModal: (modalId) => set({ activeModal: modalId }), closeModal: () => set({ activeModal: null }), + setDiscoverServerPrefill: (prefill) => set({ discoverServerPrefill: prefill }), setFocusedPane: (pane) => set({ focusedPane: pane }), setFocusedChannel: (channelId) => set({ focusedChannel: channelId }), toggleServerPane: () => { diff --git a/src/types/index.ts b/src/types/index.ts index 8a4ae28..d10e51e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -120,6 +120,7 @@ export interface Action export interface UIState { activeModal: string | null + discoverServerPrefill: { name: string; host: string; port: number } | null focusedPane: 'servers' | 'chat' | 'users' focusedChannel: string | null showServerPane: boolean diff --git a/src/utils/discoverServers.ts b/src/utils/discoverServers.ts new file mode 100644 index 0000000..efb18cf --- /dev/null +++ b/src/utils/discoverServers.ts @@ -0,0 +1,129 @@ +const OBSIDIAN_SERVER_LIST_URL = + 'https://raw.githubusercontent.com/ObsidianIRC/server-list/refs/heads/main/servers.json' + +let cachedDiscoverServers: DiscoverServerEntry[] | null = null +let pendingDiscoverServers: Promise | null = null + +interface RawDiscoverServer { + name?: string + description?: string + wss?: string + ircs?: string + obsidian?: boolean +} + +export interface DiscoverServerEntry { + id: string + name: string + description?: string + host: string + port: number + ircs: string + wss?: string + obsidian: boolean +} + +export interface DiscoverServerPrefill { + name: string + host: string + port: number +} + +export function toDiscoverServerPrefill(entry: DiscoverServerEntry): DiscoverServerPrefill { + return { + name: entry.name, + host: entry.host, + port: entry.port, + } +} + +export function normalizeDiscoverServers(payload: unknown): DiscoverServerEntry[] { + if (!Array.isArray(payload)) { + throw new Error('Server list payload must be an array') + } + + return payload.flatMap((item) => { + const server = parseDiscoverServer(item) + return server ? [server] : [] + }) +} + +export async function fetchDiscoverServers( + fetchImpl: typeof fetch = fetch +): Promise { + if (cachedDiscoverServers) { + return cachedDiscoverServers + } + + if (!pendingDiscoverServers) { + pendingDiscoverServers = (async () => { + const response = await fetchImpl(OBSIDIAN_SERVER_LIST_URL) + if (!response.ok) { + throw new Error( + `Failed to load discover servers: ${response.status} ${response.statusText}` + ) + } + + const normalized = normalizeDiscoverServers(await response.json()) + cachedDiscoverServers = normalized + return normalized + })() + } + + try { + return await pendingDiscoverServers + } finally { + pendingDiscoverServers = null + } +} + +export function resetDiscoverServersCache(): void { + cachedDiscoverServers = null + pendingDiscoverServers = null +} + +function parseDiscoverServer(item: unknown): DiscoverServerEntry | null { + if (!item || typeof item !== 'object') { + return null + } + + const raw = item as RawDiscoverServer + if (!raw.name || !raw.ircs) { + return null + } + + const endpoint = parseIrcsUrl(raw.ircs) + return { + id: `${endpoint.host}:${endpoint.port}`, + name: raw.name, + description: raw.description, + host: endpoint.host, + port: endpoint.port, + ircs: raw.ircs, + wss: raw.wss, + obsidian: Boolean(raw.obsidian), + } +} + +function parseIrcsUrl(value: string): { host: string; port: number } { + let parsed: URL + try { + parsed = new URL(value) + } catch { + throw new Error(`Invalid ircs URL: ${value}`) + } + + if (parsed.protocol !== 'ircs:') { + throw new Error(`Expected ircs URL, received: ${value}`) + } + + const port = parsed.port ? parseInt(parsed.port, 10) : 6697 + if (!parsed.hostname || Number.isNaN(port)) { + throw new Error(`Invalid ircs URL: ${value}`) + } + + return { + host: parsed.hostname, + port, + } +} diff --git a/tests/unit/actions/serverActions.test.ts b/tests/unit/actions/serverActions.test.ts index 31ba29c..a07142b 100644 --- a/tests/unit/actions/serverActions.test.ts +++ b/tests/unit/actions/serverActions.test.ts @@ -17,6 +17,8 @@ describe('Server Actions', () => { servers: [], currentServerId: null, currentChannelId: null, + activeModal: null, + discoverServerPrefill: null, }) registry = new ActionRegistry() @@ -49,4 +51,25 @@ describe('Server Actions', () => { expect(state.currentServerId).toBe(state.servers[0]!.id) }) + + it('opens Add Server directly and clears discover prefill', async () => { + useStore.setState({ + discoverServerPrefill: { + name: 'Discover Network', + host: 'irc.discover.test', + port: 6697, + }, + }) + + await registry.execute('server.connect', context) + + expect(useStore.getState().activeModal).toBe('connect') + expect(useStore.getState().discoverServerPrefill).toBeNull() + }) + + it('opens the discover modal', async () => { + await registry.execute('server.discover', context) + + expect(useStore.getState().activeModal).toBe('discover') + }) }) diff --git a/tests/unit/commands/discoverCommand.test.ts b/tests/unit/commands/discoverCommand.test.ts new file mode 100644 index 0000000..81f5492 --- /dev/null +++ b/tests/unit/commands/discoverCommand.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { ActionRegistry } from '@/actions' +import { registerServerActions } from '@/actions/serverActions' +import { CommandParser } from '@/services/commands' +import { useStore } from '@/store' +import type { ActionContext } from '@/types' +import type { AppStore } from '@/store' + +const makeContext = (): ActionContext => ({ + store: useStore.getState(), + ircClient: null as any, + renderer: {} as any, +}) + +describe('/discover command', () => { + let parser: CommandParser + + beforeEach(() => { + useStore.setState({ activeModal: null, discoverServerPrefill: null }) + const registry = new ActionRegistry() + registerServerActions(registry) + parser = new CommandParser(registry) + }) + + it('opens the discover modal', async () => { + const result = await parser.parse('/discover', makeContext()) + + expect(result.success).toBe(true) + expect(useStore.getState().activeModal).toBe('discover') + }) + + it('rejects extra arguments', async () => { + const result = await parser.parse('/discover extra', makeContext()) + + expect(result.success).toBe(false) + }) +}) diff --git a/tests/unit/store/discoverPrefill.test.ts b/tests/unit/store/discoverPrefill.test.ts new file mode 100644 index 0000000..49ea5a2 --- /dev/null +++ b/tests/unit/store/discoverPrefill.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, beforeEach } from 'vitest' +import { useStore } from '@/store' + +describe('discover server prefill state', () => { + beforeEach(() => { + useStore.setState({ discoverServerPrefill: null, activeModal: null }) + }) + + it('stores discover prefill values until consumed', () => { + useStore.getState().setDiscoverServerPrefill({ + name: 'h4ks.com', + host: 'irc.h4ks.com', + port: 6697, + }) + + expect(useStore.getState().discoverServerPrefill).toEqual({ + name: 'h4ks.com', + host: 'irc.h4ks.com', + port: 6697, + }) + }) + + it('clears discover prefill values when reset', () => { + useStore.getState().setDiscoverServerPrefill({ + name: 'Ergo Test Network', + host: 'testnet.ergo.chat', + port: 6697, + }) + + useStore.getState().setDiscoverServerPrefill(null) + + expect(useStore.getState().discoverServerPrefill).toBeNull() + }) +}) diff --git a/tests/unit/utils/discoverServers.test.ts b/tests/unit/utils/discoverServers.test.ts new file mode 100644 index 0000000..3701a7a --- /dev/null +++ b/tests/unit/utils/discoverServers.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + fetchDiscoverServers, + normalizeDiscoverServers, + resetDiscoverServersCache, + toDiscoverServerPrefill, +} from '@/utils/discoverServers' + +describe('discoverServers', () => { + beforeEach(() => { + resetDiscoverServersCache() + }) + + it('normalizes the Obsidian server list payload', () => { + const servers = normalizeDiscoverServers([ + { + name: 'Example IRC Network', + description: 'A generic public IRC network.', + wss: 'wss://irc.example.net:443', + ircs: 'ircs://irc.example.net:6697', + obsidian: false, + }, + ]) + + expect(servers).toEqual([ + { + id: 'irc.example.net:6697', + name: 'Example IRC Network', + description: 'A generic public IRC network.', + host: 'irc.example.net', + port: 6697, + ircs: 'ircs://irc.example.net:6697', + wss: 'wss://irc.example.net:443', + obsidian: false, + }, + ]) + }) + + it('drops malformed entries instead of poisoning the full list', () => { + const servers = normalizeDiscoverServers([ + null, + {}, + { name: 'missing ircs' }, + { name: 'valid', ircs: 'ircs://irc.valid.test:7000' }, + ]) + + expect(servers).toHaveLength(1) + expect(servers[0]?.host).toBe('irc.valid.test') + expect(servers[0]?.port).toBe(7000) + }) + + it('maps a discover server into Add Server prefill values', () => { + const [server] = normalizeDiscoverServers([ + { + name: 'Test Network', + ircs: 'ircs://irc.test.net:6697', + }, + ]) + + expect(toDiscoverServerPrefill(server!)).toEqual({ + name: 'Test Network', + host: 'irc.test.net', + port: 6697, + }) + }) + + it('throws on fetch failure', async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + }) + + await expect(fetchDiscoverServers(fetchImpl as unknown as typeof fetch)).rejects.toThrow( + 'Failed to load discover servers: 503 Service Unavailable' + ) + }) + + it('loads and normalizes servers from fetch', async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + json: async () => [ + { + name: 'Ergo Test Network', + description: 'Testing IRCv3 things.', + ircs: 'ircs://testnet.ergo.chat:6697', + obsidian: true, + }, + ], + }) + + await expect(fetchDiscoverServers(fetchImpl as unknown as typeof fetch)).resolves.toEqual([ + { + id: 'testnet.ergo.chat:6697', + name: 'Ergo Test Network', + description: 'Testing IRCv3 things.', + host: 'testnet.ergo.chat', + port: 6697, + ircs: 'ircs://testnet.ergo.chat:6697', + wss: undefined, + obsidian: true, + }, + ]) + }) + + it('reuses cached discover servers after the first successful fetch', async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + json: async () => [ + { + name: 'Example IRC Network', + ircs: 'ircs://irc.example.net:6697', + }, + ], + }) + + const first = await fetchDiscoverServers(fetchImpl as unknown as typeof fetch) + const second = await fetchDiscoverServers(fetchImpl as unknown as typeof fetch) + + expect(first).toEqual(second) + expect(fetchImpl).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index c3e5a42..31194ed 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,6 @@ "noEmit": true, // Path mapping - "baseUrl": ".", "paths": { "@/*": ["./src/*"], "@irc/*": ["./ObsidianIRC/src/lib/*"]