omarchy-send/internal/tui/view_messages.go
28allday 7ab3f905ee Send and read plain-text messages
Adds LocalSend-compatible text messaging alongside file transfer.

Wire format (matches the LocalSend app): a message is a single "file" with
fileType "text/plain" whose content rides in the prepare-upload `preview`
field. The receiver returns an empty token set, so nothing is uploaded — the
text is read straight from the preview.

- client: SendMessage builds that single-file prepare-upload (no body upload).
- server: detect a message (one text file with non-empty preview), surface it
  on a new Messages() channel instead of saving a file, and respond with an
  empty file set. Messages bypass the accept prompt (auto-received); the PIN
  gate still applies.
- app: bridge the server's messages channel to the TUI as MessageMsg.
- tui: a new Messages tab lists received messages (enter to read full, d to
  delete); press `m` on a device to compose and send one. Incoming messages
  show a footer notice.

Tests: end-to-end send→receive (text intact, sender preserved, nothing written
to disk) and unit coverage of the message-detection rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:40:40 +01:00

114 lines
3.2 KiB
Go

package tui
import (
"fmt"
"io"
"strings"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"omarchy-send/internal/server"
)
// msgItem adapts a received message to bubbles/list.Item.
type msgItem struct{ m server.ReceivedMessage }
func (i msgItem) Title() string { return i.m.From }
func (i msgItem) Description() string { return i.m.Text }
func (i msgItem) FilterValue() string { return i.m.From + " " + i.m.Text }
// msgItems builds list items from the model's messages (kept newest-first).
func (m Model) msgItems() []list.Item {
items := make([]list.Item, len(m.messages))
for i, msg := range m.messages {
items[i] = msgItem{m: msg}
}
return items
}
// Message list column widths.
const (
colFrom = 22
colTime = 7
)
// messageHeader is the dim column-header row above the message list.
func messageHeader() string {
h := lipgloss.NewStyle().Foreground(muted)
return " " +
h.Width(colFrom).Render("From") +
h.Width(colTime).Render("Time") +
h.Render("Message")
}
// msgDelegate renders one message as a row: ▌ from 15:04 preview…
type msgDelegate struct{}
func (msgDelegate) Height() int { return 1 }
func (msgDelegate) Spacing() int { return 0 }
func (msgDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil }
func (msgDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
it, ok := item.(msgItem)
if !ok {
return
}
from := truncate(nonEmpty(it.m.From, "unknown"), colFrom-2)
ts := it.m.Time.Format("15:04")
preview := truncate(oneLine(it.m.Text), 60)
fromSt := lipgloss.NewStyle().Width(colFrom)
timeSt := lipgloss.NewStyle().Width(colTime)
if index == m.Index() {
fmt.Fprint(w, lipgloss.NewStyle().Foreground(accent).Render("▌ ")+
fromSt.Foreground(accent).Bold(true).Render(from)+
timeSt.Foreground(accent).Render(ts)+
lipgloss.NewStyle().Foreground(accent).Render(preview))
return
}
fmt.Fprint(w, " "+
fromSt.Foreground(text).Render(from)+
timeSt.Foreground(dim).Render(ts)+
lipgloss.NewStyle().Foreground(muted).Render(preview))
}
// composeView is the message-compose card.
func (m Model) composeView() string {
to := ""
if m.composeTo != nil {
to = m.composeTo.Info.Alias
}
var b strings.Builder
b.WriteString(titleStyle.Render("Message to " + to))
b.WriteString("\n\n")
b.WriteString(m.composeInput.View())
return b.String()
}
// readMessageView shows the full text of the message being read.
func (m Model) readMessageView() string {
if m.readingMsg == nil {
return ""
}
var b strings.Builder
b.WriteString(titleStyle.Render("Message from " + nonEmpty(m.readingMsg.From, "unknown")))
b.WriteString(" ")
b.WriteString(headerStyle.Render(m.readingMsg.Time.Format("2006-01-02 15:04")))
b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Width(56).Foreground(text).Render(m.readingMsg.Text))
return b.String()
}
func nonEmpty(s, fallback string) string {
if s == "" {
return fallback
}
return s
}
// oneLine collapses newlines so a multi-line message previews on one row.
func oneLine(s string) string {
return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "\r", " "))
}