Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default [
Timer: 'readonly',
require: 'readonly',
Bun: 'readonly',
fetch: 'readonly',
},
},
plugins: {
Expand Down
18 changes: 17 additions & 1 deletion src/actions/serverActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,27 @@ export function registerServerActions(registry: ActionRegistry<AppStore>) {
execute: async (ctx: ActionContext<AppStore>) => {
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<AppStore>) => {
ctx.store.openModal('discover')
},
})

// Connect with parameters (for programmatic use)
registry.register({
id: 'server.connectWith',
Expand Down
3 changes: 3 additions & 0 deletions src/components/layout/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -152,6 +153,8 @@ export function MainLayout() {

{activeModal === 'connect' && <ConnectServerModal width={width} height={height} />}

{activeModal === 'discover' && <DiscoverServerModal width={width} height={height} />}

{activeModal === 'removeServer' && <RemoveServerModal width={width} height={height} />}

{activeModal === 'emojiPicker' && (
Expand Down
21 changes: 17 additions & 4 deletions src/components/modals/ConnectServerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -82,6 +94,7 @@ export function ConnectServerModal({ width, height }: ConnectServerModalProps) {
return
}
setFormError('')
setDiscoverServerPrefill(null)

const context = { store, ircClient, renderer }
const params = {
Expand All @@ -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}
/>
Expand Down
88 changes: 88 additions & 0 deletions src/components/modals/DiscoverServerModal.tsx
Original file line number Diff line number Diff line change
@@ -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<DiscoverServerEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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<ListItem[]>(() => {
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 (
<ListModal
width={width}
height={height}
modalWidth={76}
title="Browse Public Servers"
items={items}
query={query}
onQueryChange={setQuery}
onSelect={handleSelect}
onCancel={closeModal}
itemLayout="stacked"
placeholder="Search public IRC servers..."
emptyMessage={emptyMessage}
/>
)
}
61 changes: 47 additions & 14 deletions src/components/modals/ListModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ interface ListModalProps {
onCancel: () => void
placeholder?: string
emptyMessage?: string
itemLayout?: 'inline' | 'stacked'
modalWidth?: number
}

export function ListModal({
Expand All @@ -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<ScrollBoxRenderable | null>(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(
Expand All @@ -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') {
Expand Down Expand Up @@ -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)}
>
<box flexDirection="row" gap={1}>
{item.icon && <text fg={THEME.mutedText}>{item.icon}</text>}
<text fg={item.fg ?? (index === selectedIndex ? THEME.accent : THEME.foreground)}>
{item.label}
</text>
{item.sublabel && <text fg={THEME.mutedText}>{item.sublabel}</text>}
</box>
{itemLayout === 'stacked' ? (
<box flexDirection="column">
<box flexDirection="row" gap={1}>
{item.icon && <text fg={THEME.mutedText}>{item.icon}</text>}
<text
fg={item.fg ?? (index === selectedIndex ? THEME.accent : THEME.foreground)}
>
{truncateText(item.label, modalWidth - 6)}
</text>
</box>
<text fg={THEME.mutedText}>
{truncateText(item.sublabel ?? '', modalWidth - 6)}
</text>
</box>
) : (
<box flexDirection="row" gap={1}>
{item.icon && <text fg={THEME.mutedText}>{item.icon}</text>}
<text fg={item.fg ?? (index === selectedIndex ? THEME.accent : THEME.foreground)}>
{item.label}
</text>
{item.sublabel && <text fg={THEME.mutedText}>{item.sublabel}</text>}
</box>
)}
</box>
))
)}
Expand Down
14 changes: 14 additions & 0 deletions src/services/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export class CommandParser {
'',
'IRC COMMANDS:',
' • /connect <host> <port> <nick> Connect to server',
' • /discover Browse public IRC servers',
' • /join #channel Join channel',
' • /part [#channel] Leave channel',
' • /msg <target> <text> Send message to nick or channel',
Expand Down Expand Up @@ -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'],
Expand Down
3 changes: 3 additions & 0 deletions src/store/slices/uiSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,6 +27,7 @@ export interface UISlice extends UIState {

export const createUISlice: StateCreator<AppStore, [], [], UISlice> = (set, get) => ({
activeModal: null,
discoverServerPrefill: null,
focusedPane: 'chat',
focusedChannel: null,
showServerPane: true,
Expand All @@ -45,6 +47,7 @@ export const createUISlice: StateCreator<AppStore, [], [], UISlice> = (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: () => {
Expand Down
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export interface Action<TStore = unknown, TParams extends unknown[] = unknown[]>

export interface UIState {
activeModal: string | null
discoverServerPrefill: { name: string; host: string; port: number } | null
focusedPane: 'servers' | 'chat' | 'users'
focusedChannel: string | null
showServerPane: boolean
Expand Down
Loading