package main import ( "fmt" "strings" "time" "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/ssh" ) // --- model --- type phase int const ( phaseAuth phase = iota phaseChat ) type model struct { app *app renderer *lipgloss.Renderer sessionID string phase phase width int height int // auth atHandle string pubkey ssh.PublicKey knownHandle string // from keystore, if recognized authURL string authErr string handle string // chat viewport viewport.Model input textarea.Model messages []chatMsg } func newModel(a *app, renderer *lipgloss.Renderer, sessionID, handle string, pubkey ssh.PublicKey, knownHandle string) model { return model{ app: a, renderer: renderer, sessionID: sessionID, phase: phaseAuth, atHandle: handle, pubkey: pubkey, knownHandle: knownHandle, } } func (m model) Init() tea.Cmd { if m.knownHandle != "" { if us := m.app.getUserSession(m.knownHandle); us != nil { return func() tea.Msg { return oauthResult{ handle: us.handle, did: us.did, oauthApp: us.oauthApp, sessData: us.sessData, } } } } handle := m.atHandle if m.knownHandle != "" { handle = m.knownHandle } return func() tea.Msg { authURL, resultCh, err := startOAuth(handle) if err != nil { return oauthResult{err: err} } return oauthStarted{authURL: authURL, resultCh: resultCh} } } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height if m.phase == phaseChat { m.viewport.Width = msg.Width m.viewport.Height = msg.Height - 5 m.input.SetWidth(msg.Width - 2) } return m, nil } switch m.phase { case phaseAuth: return m.updateAuth(msg) case phaseChat: return m.updateChat(msg) } return m, nil } func (m model) updateAuth(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: if msg.Type == tea.KeyCtrlC || msg.Type == tea.KeyEsc { m.app.removeSession(m.sessionID) return m, tea.Quit } case oauthStarted: m.authURL = msg.authURL return m, func() tea.Msg { return <-msg.resultCh } case oauthResult: if msg.err != nil { m.authErr = msg.err.Error() return m, nil } m.handle = msg.handle // save key -> handle mapping m.app.keys.save(m.pubkey, m.handle) // save user session for atproto posting if msg.sessData != nil { us := &userSession{ handle: msg.handle, did: msg.did, oauthApp: msg.oauthApp, sessData: msg.sessData, } m.app.setUserSession(m.handle, us) publishLexicon(us) } return m.enterChat() } return m, nil } func (m model) enterChat() (tea.Model, tea.Cmd) { m.phase = phaseChat m.app.assignColor(m.handle) m.app.promoteSession(m.sessionID, m.handle) vp := viewport.New(m.width, m.height-5) m.viewport = vp ta := textarea.New() ta.Placeholder = "type a message..." ta.ShowLineNumbers = false ta.SetWidth(m.width - 2) ta.SetHeight(1) ta.CharLimit = 500 ta.FocusedStyle.CursorLine = lipgloss.NewStyle() ta.Focus() m.input = ta m.messages = m.app.getHistory() m.refreshViewport() m.app.broadcast(chatMsg{ sender: m.handle, text: m.handle + " has joined", system: true, ts: time.Now(), }) return m, textarea.Blink } func (m model) updateChat(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { case tea.KeyCtrlC: m.app.removeProg(m.handle) m.app.broadcast(chatMsg{ sender: m.handle, text: m.handle + " has left", system: true, ts: time.Now(), }) return m, tea.Quit case tea.KeyEnter: text := strings.TrimSpace(m.input.Value()) if text == "" { return m, nil } if text == "/quit" { m.app.removeProg(m.handle) m.app.broadcast(chatMsg{ sender: m.handle, text: m.handle + " has left", system: true, ts: time.Now(), }) return m, tea.Quit } if text == "/who" { users := m.app.onlineUsers() m.messages = append(m.messages, chatMsg{ text: "online: " + strings.Join(users, ", "), system: true, ts: time.Now(), }) m.input.Reset() m.refreshViewport() return m, nil } m.app.broadcast(chatMsg{ sender: m.handle, text: text, ts: time.Now(), }) // post to atproto postToAtproto(m.app.getUserSession(m.handle), text, "general") m.input.Reset() return m, nil } case chatMsg: m.messages = append(m.messages, msg) m.refreshViewport() return m, nil } var cmd tea.Cmd m.input, cmd = m.input.Update(msg) if cmd != nil { cmds = append(cmds, cmd) } m.viewport, cmd = m.viewport.Update(msg) if cmd != nil { cmds = append(cmds, cmd) } return m, tea.Batch(cmds...) } func (m *model) refreshViewport() { var lines []string for _, msg := range m.messages { lines = append(lines, m.renderMessage(msg)) } m.viewport.SetContent(strings.Join(lines, "\n")) m.viewport.GotoBottom() } func (m *model) renderMessage(msg chatMsg) string { ts := msg.ts.Format("15:04") dim := lipgloss.NewStyle().Faint(true) tsRendered := dim.Render(ts) if msg.system { return fmt.Sprintf("%s %s %s", tsRendered, dim.Render("--"), dim.Italic(true).Render(msg.text)) } color := m.app.users[msg.sender] if color == "" { color = "#888888" } nameStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(color)) return fmt.Sprintf("%s %s %s", tsRendered, nameStyle.Render(msg.sender), msg.text) } // --- views --- func (m model) View() string { switch m.phase { case phaseAuth: return m.viewAuth() case phaseChat: return m.viewChat() } return "" } func (m model) viewAuth() string { w := m.width if w == 0 { w = 80 } h := m.height if h == 0 { h = 24 } dim := lipgloss.NewStyle().Faint(true) var content string if m.authErr != "" { content = lipgloss.JoinVertical(lipgloss.Left, lipgloss.NewStyle().Bold(true).Render("auth failed: "+m.authErr), "", dim.Render("ctrl+c to exit"), ) } else if m.authURL != "" { link := fmt.Sprintf("\033]8;;%s\a\033[4msign in\033[0m\033]8;;\a", m.authURL) welcomeLine := fmt.Sprintf("welcome, %s", lipgloss.NewStyle().Bold(true).Render(m.atHandle)) linkLine := fmt.Sprintf("click to %s", link) welcomeWidth := lipgloss.Width(welcomeLine) linkVisualWidth := len("click to sign in") pad := (welcomeWidth - linkVisualWidth) / 2 if pad < 0 { pad = 0 } paddedLink := strings.Repeat(" ", pad) + linkLine content = lipgloss.JoinVertical(lipgloss.Center, welcomeLine, "", paddedLink, ) } else if m.knownHandle != "" { content = lipgloss.NewStyle(). Background(lipgloss.Color("#FF0000")). Foreground(lipgloss.Color("#FFFFFF")). Bold(true). Render("chat.viruus.zip") } else { content = lipgloss.NewStyle(). Background(lipgloss.Color("#FF0000")). Foreground(lipgloss.Color("#FFFFFF")). Bold(true). Render("chat.viruus.zip") } return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, content) } func (m model) viewChat() string { w := m.width if w == 0 { w = 80 } dim := lipgloss.NewStyle().Faint(true) users := m.app.onlineUsers() header := fmt.Sprintf("whoossh #general %s", dim.Render(fmt.Sprintf("%d online", len(users)))) divider := dim.Render(strings.Repeat("─", w)) promptStyle := lipgloss.NewStyle().Bold(true) inputLine := fmt.Sprintf("%s %s", promptStyle.Render(m.handle+">"), m.input.View()) help := dim.Render("/quit /who ctrl+c") return lipgloss.JoinVertical(lipgloss.Left, header, divider, m.viewport.View(), divider, inputLine, help, ) }