OverviewFilesCommitsIssuesDocsReleasesPeers

keet-tui / index.js

291 lines · javascript ref:
import { Program } from './lib/program.js'
import { KeetClient } from './lib/client.js'
import { mainView } from './lib/views/main.js'
import { splashView } from './lib/views/splash.js'
import { helpView } from './lib/views/help.js'
import { handleInputKey } from './lib/components/input-box.js'

// --- Parse args ---
const rawArgs = Pear.config.args || []
let port = 3117
for (let i = 0; i < rawArgs.length; i++) {
  if (rawArgs[i] === '--port' && rawArgs[i + 1]) {
    port = parseInt(rawArgs[++i])
  } else if (rawArgs[i].startsWith('--port=')) {
    port = parseInt(rawArgs[i].split('=')[1])
  }
}

const client = new KeetClient({ port })

// --- Model ---
const model = {
  // State
  screen: 'splash', // 'splash' | 'main'
  connected: false,
  error: null,
  port,

  // Identity
  identity: null,

  // Rooms
  rooms: [],
  selectedRoomIndex: 0,
  activeRoomId: null,

  // Chat
  messages: [],
  chatScroll: 0,
  inputBuffer: '',
  cursorPos: 0,
  lastMessageTimestamp: 0,

  // Members
  members: [],

  // Layout
  focus: 'chat', // 'rooms' | 'chat' | 'members'
  showHelp: false,
  width: 80,
  height: 24,

  // View dispatch
  view (w, h) {
    if (this.screen === 'splash') return splashView(this, w, h)
    if (this.showHelp) return helpView(this, w, h)
    return mainView(this, w, h)
  },

  // MVU update
  update (msg) {
    switch (msg.type) {
      case 'resize':
        this.width = msg.width
        this.height = msg.height
        break

      case 'connected':
        this.screen = 'main'
        this.connected = true
        this.error = null
        if (msg.identity) this.identity = msg.identity
        if (msg.rooms) {
          this.rooms = msg.rooms
          if (this.rooms.length > 0 && !this.activeRoomId) {
            this.activeRoomId = this.rooms[0].roomId
          }
        }
        break

      case 'connection-error':
        this.connected = false
        this.error = msg.error
        break

      case 'rooms-updated':
        this.rooms = msg.rooms || []
        // Keep selection in bounds
        if (this.selectedRoomIndex >= this.rooms.length) {
          this.selectedRoomIndex = Math.max(0, this.rooms.length - 1)
        }
        break

      case 'messages-updated':
        if (msg.messages && msg.messages.length > 0) {
          const lastTs = msg.messages[msg.messages.length - 1].timestamp || 0
          const hadNew = lastTs > this.lastMessageTimestamp
          this.messages = msg.messages
          if (hadNew && this.chatScroll === 0) {
            this.lastMessageTimestamp = lastTs
          }
        }
        break

      case 'members-updated':
        this.members = msg.members || []
        break

      case 'key':
        return this._handleKey(msg.key)
    }
    return null
  },

  _handleKey (key) {
    // Splash screen: any key retries connection
    if (this.screen === 'splash') {
      this.error = null
      return null
    }

    // ? toggles help overlay
    if (key.sequence === '?') {
      this.showHelp = !this.showHelp
      return null
    }

    // Any key dismisses help when shown
    if (this.showHelp) {
      this.showHelp = false
      return null
    }

    // Tab: cycle focus
    if (key.name === 'tab') {
      if (key.shift) {
        this.focus = this.focus === 'rooms' ? 'members'
          : this.focus === 'chat' ? 'rooms' : 'chat'
      } else {
        this.focus = this.focus === 'rooms' ? 'chat'
          : this.focus === 'chat' ? 'members' : 'rooms'
      }
      return null
    }

    // Focus-specific handling
    if (this.focus === 'rooms') return this._handleRoomKey(key)
    if (this.focus === 'chat') return this._handleChatKey(key)
    return null
  },

  _handleRoomKey (key) {
    if (key.name === 'up' || key.name === 'k') {
      if (this.selectedRoomIndex > 0) this.selectedRoomIndex--
      return null
    }
    if (key.name === 'down' || key.name === 'j') {
      if (this.selectedRoomIndex < this.rooms.length - 1) this.selectedRoomIndex++
      return null
    }
    if (key.name === 'return') {
      const room = this.rooms[this.selectedRoomIndex]
      if (room) {
        this.activeRoomId = room.roomId
        this.messages = []
        this.chatScroll = 0
        this.lastMessageTimestamp = 0
        this.focus = 'chat'
      }
      return null
    }
    return null
  },

  _handleChatKey (key) {
    // Page up/down for scrolling
    if (key.name === 'pageup') {
      this.chatScroll += 10
      return null
    }
    if (key.name === 'pagedown') {
      this.chatScroll = Math.max(0, this.chatScroll - 10)
      return null
    }

    // Input handling
    const result = handleInputKey(this, key)
    if (result) {
      if (result.send) {
        const roomId = this.activeRoomId
        const text = result.send
        this.inputBuffer = result.inputBuffer
        this.cursorPos = result.cursorPos
        // Fire and forget send
        if (roomId) {
          client.send(roomId, text).catch(() => {})
        }
        return null
      }
      if (result.inputBuffer !== undefined) this.inputBuffer = result.inputBuffer
      if (result.cursorPos !== undefined) this.cursorPos = result.cursorPos
    }
    return null
  }
}

// --- Room title cache ---
const titleCache = new Map()

async function enrichRoomTitles (rooms) {
  for (const room of rooms) {
    if (room.title && room.title !== '(untitled)') continue
    if (titleCache.has(room.roomId)) {
      room.title = titleCache.get(room.roomId)
      continue
    }
    try {
      const config = await client.roomConfig(room.roomId)
      if (config.ok && config.data && config.data.title) {
        room.title = config.data.title
        titleCache.set(room.roomId, config.data.title)
      }
    } catch {}
  }
}

// --- Polling ---
async function onPoll (program) {
  try {
    if (!model.connected) {
      const alive = await client.ping()
      if (!alive) {
        program.dispatch({ type: 'connection-error', error: 'Cannot reach daemon' })
        return
      }

      const [identity, roomsResult] = await Promise.all([
        client.identity(),
        client.rooms()
      ])

      const rooms = roomsResult.ok ? roomsResult.data : []
      await enrichRoomTitles(rooms)

      program.dispatch({
        type: 'connected',
        identity: identity.ok ? identity.data : null,
        rooms
      })
      return
    }

    // Refresh rooms
    const roomsResult = await client.rooms()
    if (roomsResult.ok) {
      await enrichRoomTitles(roomsResult.data)
      program.dispatch({ type: 'rooms-updated', rooms: roomsResult.data })
    }

    // Refresh messages for active room
    if (model.activeRoomId) {
      const msgsResult = await client.messages(model.activeRoomId, 50)
      if (msgsResult.ok) {
        program.dispatch({ type: 'messages-updated', messages: msgsResult.data })
      }

      // Refresh members
      const membersResult = await client.members(model.activeRoomId)
      if (membersResult.ok) {
        program.dispatch({ type: 'members-updated', members: membersResult.data })
      }
    }
  } catch (err) {
    if (model.connected) {
      model.connected = false
      model.screen = 'splash'
      model.error = err.message || 'Connection lost'
    } else {
      program.dispatch({ type: 'connection-error', error: err.message || 'Connection failed' })
    }
  }
}

// --- Start ---
const program = new Program(model, {
  pollInterval: 2000,
  onPoll
})

program.run()