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:
parent
6e7aac090c
commit
7ab3f905ee
9 changed files with 467 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
54
internal/client/message_test.go
Normal file
54
internal/client/message_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
57
internal/server/message_test.go
Normal file
57
internal/server/message_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -171,6 +192,8 @@ func New(cfg config.Config, ctrl Controller) Model {
|
|||
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"
|
||||
}
|
||||
|
|
|
|||
114
internal/tui/view_messages.go
Normal file
114
internal/tui/view_messages.go
Normal 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", " "))
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue