OverviewFilesCommitsIssuesDocsReleasesPeers

keet-tui / lib / components / chat-view.js

130 lines · javascript ref:
import * as style from '../style.js'

const SYSTEM_EVENTS = {
  1: 'joined', 2: 'call started', 3: 'avatar changed',
  4: 'invite created', 5: 'left', 6: 'title changed',
  7: 'removed', 8: 'call ended', 9: 'call missed'
}

export function chatView (model, width, height) {
  const lines = []
  const room = model.rooms[model.selectedRoomIndex]

  // Header
  const title = room ? (room.title || room.roomId.slice(0, 16)) : 'No room selected'
  lines.push(style.bold(style.truncate(' ' + title, width)))
  lines.push(style.hline(width, 'rounded'))

  if (!room) {
    lines.push(style.dim(style.center('Select a room to start chatting', width)))
    while (lines.length < height) lines.push('')
    return lines
  }

  // Messages area (height - header(2) - separator(1) - input(2))
  const msgHeight = height - 5
  const messages = model.messages || []

  const wrapped = []
  for (const msg of messages) {
    const formatted = formatMessage(msg, width - 1)
    for (const line of formatted) {
      wrapped.push(line)
    }
  }

  // Scroll from bottom: show most recent messages
  const scrollPos = model.chatScroll || 0
  const start = Math.max(0, wrapped.length - msgHeight - scrollPos)
  const end = Math.max(0, wrapped.length - scrollPos)
  const visible = wrapped.slice(start, end)

  // Pad top if fewer messages than space, then fill with visible messages
  if (visible.length < msgHeight) {
    const pad = msgHeight - visible.length
    for (let i = 0; i < pad; i++) lines.push('')
  }
  for (const l of visible.slice(0, msgHeight)) lines.push(l)

  // Input separator
  lines.push(style.hline(width, 'rounded'))

  // Input line
  const prompt = style.cyan('> ')
  const inputText = model.inputBuffer || ''
  const cursorPos = model.cursorPos != null ? model.cursorPos : inputText.length
  const maxInput = width - 3

  let displayInput = inputText
  if (displayInput.length > maxInput) {
    const start = Math.max(0, cursorPos - maxInput + 1)
    displayInput = displayInput.slice(start, start + maxInput)
  }

  lines.push(prompt + style.padRight(displayInput, maxInput))

  while (lines.length < height) lines.push('')
  return lines
}

function formatSystemEvent (system) {
  if (typeof system === 'number') {
    return SYSTEM_EVENTS[system] || 'event ' + system
  }
  if (typeof system === 'object' && system !== null) {
    for (const [key, val] of Object.entries(system)) {
      if (val === null || val === false) continue
      const label = key.replace(/([A-Z])/g, ' $1').toLowerCase().trim()
      if (typeof val === 'object' && val.from && val.to) {
        return label + ': ' + val.from + ' \u2192 ' + val.to
      }
      if (typeof val === 'object' && val.memberId) {
        return label
      }
      return label
    }
  }
  return 'system event'
}

function formatMessage (msg, width) {
  if (msg.system) {
    const name = formatSystemEvent(msg.system)
    return [style.dim(style.center('--- ' + name + ' ---', width))]
  }

  const time = msg.timestamp
    ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
    : '??:??'

  const sender = msg.name || (msg.memberId ? msg.memberId.slice(0, 8) : '???')
  const deleted = msg.deleted ? style.dim(' [deleted]') : ''
  const header = style.brightBlack(time + ' ') + style.bold(style.cyan(sender)) + deleted

  const text = msg.text || ''
  if (!text) return [header]

  const lines = [header]
  // Split on newlines first, then wrap each line
  const paragraphs = text.split('\n')
  for (const para of paragraphs) {
    if (!para) {
      lines.push('')
      continue
    }
    const words = para.split(' ')
    let current = ' '
    for (const word of words) {
      if (current.length + word.length + 1 > width && current.length > 1) {
        lines.push(current)
        current = ' ' + word
      } else {
        current += (current.length > 1 ? ' ' : '') + word
      }
    }
    if (current.length > 1) lines.push(current)
  }

  return lines
}