chat over ssh, powered by atproto
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}