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>
This commit is contained in:
28allday 2026-05-27 21:40:40 +01:00
parent 6e7aac090c
commit 7ab3f905ee
9 changed files with 467 additions and 26 deletions

View file

@ -15,6 +15,8 @@ LocalSend mobile and desktop apps on the same LAN, including their default
- **Send** — pick a peer, stage files (or whole folders) with a built-in file
picker, and upload them with progress, rate and ETA. Folders are sent
recursively with their structure preserved on the receiver.
- **Messages** — send a plain-text message to a peer (LocalSend-compatible) and
read messages others send you in a dedicated Messages tab.
- **Manage** — browse the receive folder, mark received files (or whole folders)
and delete the ones you no longer want, behind a confirmation prompt.
- **HTTPS** — generates a self-signed certificate whose fingerprint matches the
@ -91,11 +93,13 @@ omarchy-send --auto-accept --pin 2468
### Keys
- `1`/`2`/`3`/`4` or `tab` — switch between Devices / Transfers / Manage / Settings
- Peers: `enter` send to the selected peer · `r` refresh · `/` filter
- `1``5` or `tab` — switch between Devices / Transfers / Manage / Messages / Settings
- Peers: `enter` send to the selected peer · `m` message the selected peer · `r` refresh · `/` filter
- Send picker: `enter` stage a file · `a` add the current folder · `backspace` unstage · `S` send · `esc` back
- Incoming prompt: `y` accept · `n` reject
- Transfers: `c` clear finished
- Messages: `enter` read the full message · `d` delete it (incoming messages
arrive automatically, with a footer notice)
- Manage: `space` mark file/folder · `a` mark all · `d` delete marked (or the one
under the cursor) · `r` refresh · `/` filter — deletion asks to confirm first
- Settings: `e` edit (alias / receive dir / PIN) · `a` toggle auto-accept

View file

@ -28,6 +28,7 @@ type controller struct {
func (c controller) Announce() { c.disc.Announce() }
func (c controller) Send(p discovery.Peer, paths []string, pin string) { c.sender.Send(p, paths, pin) }
func (c controller) SendMessage(p discovery.Peer, text string) { c.sender.SendMessage(p, text) }
func (c controller) SetAutoAccept(v bool) { c.srv.SetAutoAccept(v) }
func (c controller) SetPIN(pin string) { c.srv.SetPIN(pin) }
func (c controller) SetReceiveDir(dir string) { c.srv.SetReceiveDir(dir) }
@ -113,7 +114,7 @@ func main() {
p := tea.NewProgram(tui.New(cfg, ctrl), tea.WithAltScreen())
app.BridgeDiscovery(ctx, disc.Events(), p.Send)
app.BridgeServer(ctx, srv.Accepts(), srv.Transfers(), p.Send)
app.BridgeServer(ctx, srv.Accepts(), srv.Transfers(), srv.Messages(), p.Send)
app.BridgeTransfers(ctx, sender.Events(), p.Send)
disc.Announce() // announce immediately so we appear without waiting a tick

View file

@ -26,9 +26,12 @@ type IncomingMsg struct{ Req server.AcceptRequest }
// TransferMsg reports progress/lifecycle of a transfer (either direction).
type TransferMsg struct{ Ev transfer.Event }
// BridgeServer forwards the server's accept requests and transfer events to the
// Tea program until ctx is cancelled.
func BridgeServer(ctx context.Context, accepts <-chan server.AcceptRequest, transfers <-chan transfer.Event, send func(tea.Msg)) {
// MessageMsg is delivered when a peer sends us a plain-text message.
type MessageMsg struct{ Msg server.ReceivedMessage }
// BridgeServer forwards the server's accept requests, transfer events, and
// received messages to the Tea program until ctx is cancelled.
func BridgeServer(ctx context.Context, accepts <-chan server.AcceptRequest, transfers <-chan transfer.Event, messages <-chan server.ReceivedMessage, send func(tea.Msg)) {
go func() {
for {
select {
@ -38,6 +41,8 @@ func BridgeServer(ctx context.Context, accepts <-chan server.AcceptRequest, tran
send(IncomingMsg{Req: req})
case ev := <-transfers:
send(TransferMsg{Ev: ev})
case m := <-messages:
send(MessageMsg{Msg: m})
}
}
}()

View file

@ -94,6 +94,33 @@ func (s *Sender) Send(peer discovery.Peer, paths []string, pin string) {
go s.send(peer, paths, pin)
}
// SendMessage sends a plain-text message to peer (LocalSend "send message":
// one text file whose content rides in the preview field, so nothing is
// uploaded). Errors are reported on Events() as an outgoing Error.
func (s *Sender) SendMessage(peer discovery.Peer, text string) {
go s.sendMessage(peer, text)
}
func (s *Sender) sendMessage(peer discovery.Peer, text string) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
id := randID()
files := map[string]protocol.FileMetadata{
id: {
ID: id,
FileName: "message.txt",
Size: int64(len(text)),
FileType: "text/plain",
Preview: text,
},
}
if _, err := s.prepareUpload(ctx, s.url(peer), files, ""); err != nil {
dbg.Logf("send message to %s failed: %v", peer.IP, err)
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: "message", Err: err})
}
}
func (s *Sender) send(peer discovery.Peer, paths []string, pin string) {
// A new transfer to a peer supersedes any still-running one to the same
// peer: cancel it so a half-finished old batch can't carry on once the user

View file

@ -0,0 +1,54 @@
package client
import (
"context"
"os"
"testing"
"time"
"omarchy-send/internal/discovery"
"omarchy-send/internal/protocol"
"omarchy-send/internal/server"
)
// A sent message must arrive on the receiver's Messages channel with its text
// and sender intact, and must NOT be written to disk as a file.
func TestSendMessageEndToEnd(t *testing.T) {
recvDir := t.TempDir()
recvInfo := protocol.DeviceInfo{
Alias: "recv", Version: protocol.ProtocolVersion, Port: 53990, Protocol: "http",
}
// AutoAccept is false on purpose: messages must bypass the accept prompt.
srv := server.New(server.Options{Info: recvInfo, ReceiveDir: recvDir, AutoAccept: false})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := srv.Start(ctx); err != nil {
t.Fatalf("server start: %v", err)
}
go func() {
for range srv.Transfers() {
}
}()
time.Sleep(50 * time.Millisecond)
sender := New(protocol.DeviceInfo{Alias: "sender", Fingerprint: "snd1", Version: "2.1", Protocol: "http"})
peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"}
sender.SendMessage(peer, "hello over the wire\nsecond line")
select {
case m := <-srv.Messages():
if m.Text != "hello over the wire\nsecond line" {
t.Fatalf("message text = %q", m.Text)
}
if m.From != "sender" {
t.Errorf("message from = %q, want \"sender\"", m.From)
}
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for the message")
}
// A message is not a file: nothing should land in the receive dir.
if entries, _ := os.ReadDir(recvDir); len(entries) != 0 {
t.Fatalf("message should not be saved; found %d entries in receive dir", len(entries))
}
}

View file

@ -0,0 +1,57 @@
package server
import (
"testing"
"omarchy-send/internal/protocol"
)
func TestMessageOf(t *testing.T) {
cases := []struct {
name string
files map[string]protocol.FileMetadata
wantOK bool
wantTxt string
}{
{
name: "text mime with preview is a message",
files: map[string]protocol.FileMetadata{"a": {FileType: "text/plain", Preview: "hi"}},
wantOK: true,
wantTxt: "hi",
},
{
name: "bare text enum with preview is a message",
files: map[string]protocol.FileMetadata{"a": {FileType: "text", Preview: "yo"}},
wantOK: true, wantTxt: "yo",
},
{
name: "text file without preview is a real file, not a message",
files: map[string]protocol.FileMetadata{"a": {FileType: "text/plain", Preview: ""}},
wantOK: false,
},
{
name: "non-text with preview is not a message",
files: map[string]protocol.FileMetadata{"a": {FileType: "image/jpeg", Preview: "data"}},
wantOK: false,
},
{
name: "two files is never a message",
files: map[string]protocol.FileMetadata{
"a": {FileType: "text/plain", Preview: "hi"},
"b": {FileType: "text/plain", Preview: "bye"},
},
wantOK: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
txt, ok := messageOf(c.files)
if ok != c.wantOK {
t.Fatalf("ok = %v, want %v", ok, c.wantOK)
}
if ok && txt != c.wantTxt {
t.Fatalf("text = %q, want %q", txt, c.wantTxt)
}
})
}
}

View file

@ -51,6 +51,15 @@ type Server struct {
accepts chan AcceptRequest
transfers chan transfer.Event
messages chan ReceivedMessage
}
// ReceivedMessage is a plain-text message received from a peer (LocalSend
// "send message": a single text file whose content rides in the preview field).
type ReceivedMessage struct {
From string
Text string
Time time.Time
}
// New returns a Server from the given options.
@ -63,6 +72,7 @@ func New(opts Options) *Server {
sessions: newSessionStore(),
accepts: make(chan AcceptRequest, 8),
transfers: make(chan transfer.Event, 256),
messages: make(chan ReceivedMessage, 32),
}
s.autoAccept.Store(opts.AutoAccept)
mux := http.NewServeMux()
@ -122,6 +132,9 @@ func (s *Server) Accepts() <-chan AcceptRequest { return s.accepts }
// Transfers returns the channel of incoming-transfer progress events.
func (s *Server) Transfers() <-chan transfer.Event { return s.transfers }
// Messages returns the channel of received plain-text messages.
func (s *Server) Messages() <-chan ReceivedMessage { return s.messages }
// Start binds the listener and serves in the background until ctx is cancelled.
func (s *Server) Start(ctx context.Context) error {
ln, err := net.Listen("tcp", s.http.Addr)
@ -185,6 +198,15 @@ func (s *Server) handlePrepareUpload(w http.ResponseWriter, r *http.Request) {
return
}
// A "message" is a single text file whose content rides in the preview
// field (LocalSend convention). It's received in full right here — surface
// it and return an empty file set so the sender uploads nothing.
if text, ok := messageOf(req.Files); ok {
s.emitMessage(ReceivedMessage{From: req.Info.Alias, Text: text, Time: time.Now()})
writeJSON(w, protocol.PrepareUploadResponse{SessionID: randToken(), Files: map[string]string{}})
return
}
if !s.askAccept(req, clientIP(r)) {
http.Error(w, "rejected", http.StatusForbidden)
return
@ -194,6 +216,35 @@ func (s *Server) handlePrepareUpload(w http.ResponseWriter, r *http.Request) {
writeJSON(w, protocol.PrepareUploadResponse{SessionID: sess.id, Files: tokens})
}
// messageOf reports whether files represents a plain-text message (exactly one
// text file with non-empty preview) and returns the message text.
func messageOf(files map[string]protocol.FileMetadata) (string, bool) {
if len(files) != 1 {
return "", false
}
for _, f := range files {
if f.Preview != "" && isTextType(f.FileType) {
return f.Preview, true
}
}
return "", false
}
// isTextType matches both the MIME form ("text/plain") that LocalSend sends and
// the bare enum form ("text") older clients may use.
func isTextType(fileType string) bool {
return fileType == "text" || strings.HasPrefix(fileType, "text/")
}
// emitMessage delivers a received message without blocking the HTTP handler.
func (s *Server) emitMessage(m ReceivedMessage) {
dbg.Logf("received message from %q: %q", m.From, m.Text)
select {
case s.messages <- m:
default:
}
}
// askAccept honours auto-accept, or raises an AcceptRequest and blocks for the
// user's decision (with a timeout so a never-answered prompt can't wedge a
// peer's HTTP connection forever).

View file

@ -32,6 +32,7 @@ import (
type Controller interface {
Announce()
Send(peer discovery.Peer, paths []string, pin string)
SendMessage(peer discovery.Peer, text string)
SetAutoAccept(bool)
SetAlias(string)
SetReceiveDir(string)
@ -44,10 +45,11 @@ const (
screenPeers screen = iota
screenTransfers
screenManage
screenMessages
screenSettings
screenPicker // entered from Peers; not part of the tab rotation
tabCount = 4 // Devices · Transfers · Manage · Settings
tabCount = 5 // Devices · Transfers · Manage · Messages · Settings
)
// xfer is one row in the transfers view.
@ -106,6 +108,15 @@ type Model struct {
sendPeer *discovery.Peer
sendPaths []string
// Messages tab + compose modal.
msgList list.Model
messages []server.ReceivedMessage
composing bool
composeInput textinput.Model
composeTo *discovery.Peer
readingMsg *server.ReceivedMessage // non-nil while reading a message full-screen
notice string // transient footer flash (e.g. "message sent")
width, height int
quitting bool
}
@ -125,6 +136,11 @@ func New(cfg config.Config, ctrl Controller) Model {
fl.SetShowHelp(false)
fl.SetShowStatusBar(false)
ml := list.New(nil, msgDelegate{}, 0, 0)
ml.SetShowTitle(false)
ml.SetShowHelp(false)
ml.SetShowStatusBar(false)
fp := filepicker.New()
if home, err := os.UserHomeDir(); err == nil {
fp.CurrentDirectory = home
@ -143,6 +159,11 @@ func New(cfg config.Config, ctrl Controller) Model {
pin.Placeholder = "PIN"
pin.CharLimit = 16
compose := textinput.New()
compose.Placeholder = "type a message"
compose.CharLimit = 2000
compose.Width = 48
mkInput := func(placeholder string, limit int) textinput.Model {
ti := textinput.New()
ti.Placeholder = placeholder
@ -157,20 +178,22 @@ func New(cfg config.Config, ctrl Controller) Model {
}
return Model{
cfg: cfg,
ctrl: ctrl,
ips: server.LocalIPs(),
screen: screenPeers,
peerList: l,
peers: make(map[string]discovery.Peer),
fileList: fl,
marked: marked,
picker: fp,
bar: progress.New(progress.WithDefaultGradient(), progress.WithWidth(22)),
xferIndex: make(map[string]*xfer),
autoAccept: cfg.AutoAccept,
pinInput: pin,
editInputs: editInputs,
cfg: cfg,
ctrl: ctrl,
ips: server.LocalIPs(),
screen: screenPeers,
peerList: l,
peers: make(map[string]discovery.Peer),
fileList: fl,
marked: marked,
picker: fp,
bar: progress.New(progress.WithDefaultGradient(), progress.WithWidth(22)),
xferIndex: make(map[string]*xfer),
autoAccept: cfg.AutoAccept,
pinInput: pin,
editInputs: editInputs,
msgList: ml,
composeInput: compose,
}
}
@ -187,6 +210,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.peerList.SetSize(iw, lh)
m.fileList.SetSize(iw, lh)
m.msgList.SetSize(iw, lh)
if ph := ih - 8; ph >= 3 {
m.picker.Height = ph
} else {
@ -220,7 +244,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.applyTransfer(msg.Ev)
return m, nil
case app.MessageMsg:
m.messages = append([]server.ReceivedMessage{msg.Msg}, m.messages...)
m.msgList.SetItems(m.msgItems())
m.notice = "✉ message from " + nonEmpty(msg.Msg.From, "a device")
return m, nil
case tea.KeyMsg:
m.notice = "" // any key clears a transient footer notice
if m.composing {
return m.updateCompose(msg)
}
if m.readingMsg != nil {
if k := msg.String(); k == "esc" || k == "q" || k == "enter" {
m.readingMsg = nil
}
return m, nil
}
if m.showPin {
return m.updatePin(msg)
}
@ -257,6 +297,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshManage()
return m, nil
case "4":
m.screen = screenMessages
return m, nil
case "5":
m.screen = screenSettings
return m, nil
case "tab":
@ -293,6 +336,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.screen == screenManage {
return m.requestDelete()
}
if m.screen == screenMessages {
m.deleteSelectedMessage()
}
return m, nil
case "m":
// Compose a message to the highlighted device.
if m.screen == screenPeers {
if it, ok := m.peerList.SelectedItem().(peerItem); ok {
peer := it.p
m.composeTo = &peer
m.composing = true
m.composeInput.SetValue("")
m.composeInput.Focus()
return m, textinput.Blink
}
}
return m, nil
case "a":
if m.screen == screenManage {
@ -328,6 +387,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.picker.Init()
}
}
if m.screen == screenMessages {
if it, ok := m.msgList.SelectedItem().(msgItem); ok {
msg := it.m
m.readingMsg = &msg
}
return m, nil
}
}
}
@ -346,9 +412,51 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.fileList, cmd = m.fileList.Update(msg)
return m, cmd
}
if m.pending == nil && m.screen == screenMessages {
var cmd tea.Cmd
m.msgList, cmd = m.msgList.Update(msg)
return m, cmd
}
return m, nil
}
// updateCompose handles the message-compose modal.
func (m Model) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.composing = false
m.composeTo = nil
return m, nil
case "enter":
text := strings.TrimSpace(m.composeInput.Value())
if text != "" && m.composeTo != nil && m.ctrl != nil {
m.ctrl.SendMessage(*m.composeTo, text)
m.notice = "✉ message sent to " + m.composeTo.Info.Alias
}
m.composing = false
m.composeTo = nil
return m, nil
}
var cmd tea.Cmd
m.composeInput, cmd = m.composeInput.Update(msg)
return m, cmd
}
// deleteSelectedMessage drops the highlighted message from the list.
func (m *Model) deleteSelectedMessage() {
it, ok := m.msgList.SelectedItem().(msgItem)
if !ok {
return
}
for i := range m.messages {
if m.messages[i] == it.m {
m.messages = append(m.messages[:i], m.messages[i+1:]...)
break
}
}
m.msgList.SetItems(m.msgItems())
}
// updatePicker handles the file picker / staging screen.
func (m Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
@ -588,6 +696,12 @@ func (m Model) View() string {
if m.confirmDel {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.confirmDeleteView()))
}
if m.composing {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.composeView()))
}
if m.readingMsg != nil {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.readMessageView()))
}
cw, ih := innerDims(w, h)
var body string
@ -614,6 +728,12 @@ func (m Model) View() string {
default:
body = m.manageView()
}
case screenMessages:
if len(m.messages) == 0 {
body, center = headerStyle.Render("No messages yet.\nSelect a device and press m to send one."), true
} else {
body = messageHeader() + "\n" + m.msgList.View()
}
case screenSettings:
if m.editing {
body, center = m.settingsEditView(), true
@ -667,7 +787,7 @@ func (m Model) tabBar() string {
}
return tabInactiveStyle.Render(label)
}
return " " + tab("Devices", screenPeers) + tab("Transfers", screenTransfers) + tab("Manage", screenManage) + tab("Settings", screenSettings)
return " " + tab("Devices", screenPeers) + tab("Transfers", screenTransfers) + tab("Manage", screenManage) + tab("Messages", screenMessages) + tab("Settings", screenSettings)
}
func (m Model) pickerView() string {
@ -967,6 +1087,12 @@ func (m Model) settingsEditView() string {
// footerText is the contextual help line shown at the bottom of the window.
func (m Model) footerText() string {
switch {
case m.notice != "":
return m.notice
case m.composing:
return "enter send · esc cancel"
case m.readingMsg != nil:
return "esc/enter close"
case m.confirmDel:
return "y/enter delete · n/esc cancel"
case m.editing:
@ -974,13 +1100,15 @@ func (m Model) footerText() string {
case m.screen == screenPicker:
return "enter stage file · a add folder · backspace unstage · S send · esc back"
case m.screen == screenPeers:
return "enter send-to · r refresh · / filter · 1-4 switch · q quit"
return "enter send-to · m message · r refresh · / filter · 1-5 switch · q quit"
case m.screen == screenTransfers:
return "c clear finished · 1-4 switch · q quit"
return "c clear finished · 1-5 switch · q quit"
case m.screen == screenManage:
return "space mark · a all · d delete · r refresh · / filter · 1-4 switch · q quit"
return "space mark · a all · d delete · r refresh · / filter · 1-5 switch · q quit"
case m.screen == screenMessages:
return "enter read · d delete · 1-5 switch · q quit"
case m.screen == screenSettings:
return "e edit · a auto-accept · i icons · 1-4 switch · q quit"
return "e edit · a auto-accept · i icons · 1-5 switch · q quit"
}
return "q quit"
}

View file

@ -0,0 +1,114 @@
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", " "))
}