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>
114 lines
3.2 KiB
Go
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", " "))
|
|
}
|