chat over ssh, powered by atproto
at main 366 lines 7.9 kB view raw
1package main 2 3import ( 4 "fmt" 5 "strings" 6 "time" 7 8 "github.com/charmbracelet/bubbles/textarea" 9 "github.com/charmbracelet/bubbles/viewport" 10 tea "github.com/charmbracelet/bubbletea" 11 "github.com/charmbracelet/lipgloss" 12 "github.com/charmbracelet/ssh" 13) 14 15// --- model --- 16 17type phase int 18 19const ( 20 phaseAuth phase = iota 21 phaseChat 22) 23 24type model struct { 25 app *app 26 renderer *lipgloss.Renderer 27 sessionID string 28 phase phase 29 width int 30 height int 31 32 // auth 33 atHandle string 34 pubkey ssh.PublicKey 35 knownHandle string // from keystore, if recognized 36 authURL string 37 authErr string 38 handle string 39 40 // chat 41 viewport viewport.Model 42 input textarea.Model 43 messages []chatMsg 44} 45 46func newModel(a *app, renderer *lipgloss.Renderer, sessionID, handle string, pubkey ssh.PublicKey, knownHandle string) model { 47 return model{ 48 app: a, 49 renderer: renderer, 50 sessionID: sessionID, 51 phase: phaseAuth, 52 atHandle: handle, 53 pubkey: pubkey, 54 knownHandle: knownHandle, 55 } 56} 57 58func (m model) Init() tea.Cmd { 59 if m.knownHandle != "" { 60 if us := m.app.getUserSession(m.knownHandle); us != nil { 61 return func() tea.Msg { 62 return oauthResult{ 63 handle: us.handle, 64 did: us.did, 65 oauthApp: us.oauthApp, 66 sessData: us.sessData, 67 } 68 } 69 } 70 } 71 handle := m.atHandle 72 if m.knownHandle != "" { 73 handle = m.knownHandle 74 } 75 return func() tea.Msg { 76 authURL, resultCh, err := startOAuth(handle) 77 if err != nil { 78 return oauthResult{err: err} 79 } 80 return oauthStarted{authURL: authURL, resultCh: resultCh} 81 } 82} 83 84func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 85 switch msg := msg.(type) { 86 case tea.WindowSizeMsg: 87 m.width = msg.Width 88 m.height = msg.Height 89 if m.phase == phaseChat { 90 m.viewport.Width = msg.Width 91 m.viewport.Height = msg.Height - 5 92 m.input.SetWidth(msg.Width - 2) 93 } 94 return m, nil 95 } 96 97 switch m.phase { 98 case phaseAuth: 99 return m.updateAuth(msg) 100 case phaseChat: 101 return m.updateChat(msg) 102 } 103 return m, nil 104} 105 106func (m model) updateAuth(msg tea.Msg) (tea.Model, tea.Cmd) { 107 switch msg := msg.(type) { 108 case tea.KeyMsg: 109 if msg.Type == tea.KeyCtrlC || msg.Type == tea.KeyEsc { 110 m.app.removeSession(m.sessionID) 111 return m, tea.Quit 112 } 113 114 case oauthStarted: 115 m.authURL = msg.authURL 116 return m, func() tea.Msg { 117 return <-msg.resultCh 118 } 119 120 case oauthResult: 121 if msg.err != nil { 122 m.authErr = msg.err.Error() 123 return m, nil 124 } 125 m.handle = msg.handle 126 127 // save key -> handle mapping 128 m.app.keys.save(m.pubkey, m.handle) 129 130 // save user session for atproto posting 131 if msg.sessData != nil { 132 us := &userSession{ 133 handle: msg.handle, 134 did: msg.did, 135 oauthApp: msg.oauthApp, 136 sessData: msg.sessData, 137 } 138 m.app.setUserSession(m.handle, us) 139 publishLexicon(us) 140 } 141 142 return m.enterChat() 143 } 144 return m, nil 145} 146 147func (m model) enterChat() (tea.Model, tea.Cmd) { 148 m.phase = phaseChat 149 m.app.assignColor(m.handle) 150 m.app.promoteSession(m.sessionID, m.handle) 151 152 vp := viewport.New(m.width, m.height-5) 153 m.viewport = vp 154 155 ta := textarea.New() 156 ta.Placeholder = "type a message..." 157 ta.ShowLineNumbers = false 158 ta.SetWidth(m.width - 2) 159 ta.SetHeight(1) 160 ta.CharLimit = 500 161 ta.FocusedStyle.CursorLine = lipgloss.NewStyle() 162 ta.Focus() 163 m.input = ta 164 165 m.messages = m.app.getHistory() 166 m.refreshViewport() 167 168 m.app.broadcast(chatMsg{ 169 sender: m.handle, 170 text: m.handle + " has joined", 171 system: true, 172 ts: time.Now(), 173 }) 174 175 return m, textarea.Blink 176} 177 178func (m model) updateChat(msg tea.Msg) (tea.Model, tea.Cmd) { 179 var cmds []tea.Cmd 180 181 switch msg := msg.(type) { 182 case tea.KeyMsg: 183 switch msg.Type { 184 case tea.KeyCtrlC: 185 m.app.removeProg(m.handle) 186 m.app.broadcast(chatMsg{ 187 sender: m.handle, 188 text: m.handle + " has left", 189 system: true, 190 ts: time.Now(), 191 }) 192 return m, tea.Quit 193 case tea.KeyEnter: 194 text := strings.TrimSpace(m.input.Value()) 195 if text == "" { 196 return m, nil 197 } 198 if text == "/quit" { 199 m.app.removeProg(m.handle) 200 m.app.broadcast(chatMsg{ 201 sender: m.handle, 202 text: m.handle + " has left", 203 system: true, 204 ts: time.Now(), 205 }) 206 return m, tea.Quit 207 } 208 if text == "/who" { 209 users := m.app.onlineUsers() 210 m.messages = append(m.messages, chatMsg{ 211 text: "online: " + strings.Join(users, ", "), 212 system: true, 213 ts: time.Now(), 214 }) 215 m.input.Reset() 216 m.refreshViewport() 217 return m, nil 218 } 219 m.app.broadcast(chatMsg{ 220 sender: m.handle, 221 text: text, 222 ts: time.Now(), 223 }) 224 // post to atproto 225 postToAtproto(m.app.getUserSession(m.handle), text, "general") 226 m.input.Reset() 227 return m, nil 228 } 229 230 case chatMsg: 231 m.messages = append(m.messages, msg) 232 m.refreshViewport() 233 return m, nil 234 } 235 236 var cmd tea.Cmd 237 m.input, cmd = m.input.Update(msg) 238 if cmd != nil { 239 cmds = append(cmds, cmd) 240 } 241 m.viewport, cmd = m.viewport.Update(msg) 242 if cmd != nil { 243 cmds = append(cmds, cmd) 244 } 245 return m, tea.Batch(cmds...) 246} 247 248func (m *model) refreshViewport() { 249 var lines []string 250 for _, msg := range m.messages { 251 lines = append(lines, m.renderMessage(msg)) 252 } 253 m.viewport.SetContent(strings.Join(lines, "\n")) 254 m.viewport.GotoBottom() 255} 256 257func (m *model) renderMessage(msg chatMsg) string { 258 ts := msg.ts.Format("15:04") 259 dim := lipgloss.NewStyle().Faint(true) 260 tsRendered := dim.Render(ts) 261 262 if msg.system { 263 return fmt.Sprintf("%s %s %s", tsRendered, dim.Render("--"), dim.Italic(true).Render(msg.text)) 264 } 265 266 color := m.app.users[msg.sender] 267 if color == "" { 268 color = "#888888" 269 } 270 nameStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(color)) 271 return fmt.Sprintf("%s %s %s", tsRendered, nameStyle.Render(msg.sender), msg.text) 272} 273 274// --- views --- 275 276func (m model) View() string { 277 switch m.phase { 278 case phaseAuth: 279 return m.viewAuth() 280 case phaseChat: 281 return m.viewChat() 282 } 283 return "" 284} 285 286func (m model) viewAuth() string { 287 w := m.width 288 if w == 0 { 289 w = 80 290 } 291 h := m.height 292 if h == 0 { 293 h = 24 294 } 295 296 dim := lipgloss.NewStyle().Faint(true) 297 298 var content string 299 if m.authErr != "" { 300 content = lipgloss.JoinVertical(lipgloss.Left, 301 lipgloss.NewStyle().Bold(true).Render("auth failed: "+m.authErr), 302 "", 303 dim.Render("ctrl+c to exit"), 304 ) 305 } else if m.authURL != "" { 306 link := fmt.Sprintf("\033]8;;%s\a\033[4msign in\033[0m\033]8;;\a", m.authURL) 307 308 welcomeLine := fmt.Sprintf("welcome, %s", lipgloss.NewStyle().Bold(true).Render(m.atHandle)) 309 linkLine := fmt.Sprintf("click to %s", link) 310 welcomeWidth := lipgloss.Width(welcomeLine) 311 linkVisualWidth := len("click to sign in") 312 pad := (welcomeWidth - linkVisualWidth) / 2 313 if pad < 0 { 314 pad = 0 315 } 316 paddedLink := strings.Repeat(" ", pad) + linkLine 317 318 content = lipgloss.JoinVertical(lipgloss.Center, 319 welcomeLine, 320 "", 321 paddedLink, 322 ) 323 } else if m.knownHandle != "" { 324 content = lipgloss.NewStyle(). 325 Background(lipgloss.Color("#FF0000")). 326 Foreground(lipgloss.Color("#FFFFFF")). 327 Bold(true). 328 Render("chat.viruus.zip") 329 } else { 330 content = lipgloss.NewStyle(). 331 Background(lipgloss.Color("#FF0000")). 332 Foreground(lipgloss.Color("#FFFFFF")). 333 Bold(true). 334 Render("chat.viruus.zip") 335 } 336 337 return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, content) 338} 339 340func (m model) viewChat() string { 341 w := m.width 342 if w == 0 { 343 w = 80 344 } 345 346 dim := lipgloss.NewStyle().Faint(true) 347 348 users := m.app.onlineUsers() 349 header := fmt.Sprintf("whoossh #general %s", dim.Render(fmt.Sprintf("%d online", len(users)))) 350 351 divider := dim.Render(strings.Repeat("─", w)) 352 353 promptStyle := lipgloss.NewStyle().Bold(true) 354 inputLine := fmt.Sprintf("%s %s", promptStyle.Render(m.handle+">"), m.input.View()) 355 356 help := dim.Render("/quit /who ctrl+c") 357 358 return lipgloss.JoinVertical(lipgloss.Left, 359 header, 360 divider, 361 m.viewport.View(), 362 divider, 363 inputLine, 364 help, 365 ) 366}