From 7ab3f905eeed379eea3208d2e8a47c10ceedde90 Mon Sep 17 00:00:00 2001 From: 28allday Date: Wed, 27 May 2026 21:40:40 +0100 Subject: [PATCH] Send and read plain-text messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 8 +- cmd/omarchy-send/main.go | 3 +- internal/app/events.go | 11 ++- internal/client/client.go | 27 +++++ internal/client/message_test.go | 54 ++++++++++ internal/server/message_test.go | 57 +++++++++++ internal/server/server.go | 51 ++++++++++ internal/tui/model.go | 168 ++++++++++++++++++++++++++++---- internal/tui/view_messages.go | 114 ++++++++++++++++++++++ 9 files changed, 467 insertions(+), 26 deletions(-) create mode 100644 internal/client/message_test.go create mode 100644 internal/server/message_test.go create mode 100644 internal/tui/view_messages.go diff --git a/README.md b/README.md index 90ccfc4..1a0ccbd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/omarchy-send/main.go b/cmd/omarchy-send/main.go index e49abab..750b4e1 100644 --- a/cmd/omarchy-send/main.go +++ b/cmd/omarchy-send/main.go @@ -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 diff --git a/internal/app/events.go b/internal/app/events.go index 22d35da..2292ee0 100644 --- a/internal/app/events.go +++ b/internal/app/events.go @@ -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}) } } }() diff --git a/internal/client/client.go b/internal/client/client.go index e5a7203..fa9f6b1 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -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 diff --git a/internal/client/message_test.go b/internal/client/message_test.go new file mode 100644 index 0000000..6f0cb7a --- /dev/null +++ b/internal/client/message_test.go @@ -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)) + } +} diff --git a/internal/server/message_test.go b/internal/server/message_test.go new file mode 100644 index 0000000..90621fd --- /dev/null +++ b/internal/server/message_test.go @@ -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) + } + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index a8ca115..3cfb36e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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). diff --git a/internal/tui/model.go b/internal/tui/model.go index 34eb130..13c9e58 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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" } diff --git a/internal/tui/view_messages.go b/internal/tui/view_messages.go new file mode 100644 index 0000000..831d6bd --- /dev/null +++ b/internal/tui/view_messages.go @@ -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", " ")) +}