From dc957f6bb77c9d89b52f22b605f79f7be110f546 Mon Sep 17 00:00:00 2001 From: citrons Date: Mon, 9 Jun 2025 12:58:52 -0500 Subject: replies --- client/application.go | 10 ++- client/channel_window.go | 166 +++++++++++++++++++++++++++++++++++++++++--- client/empty_window.go | 2 + client/object/object.go | 14 ++-- client/ui.go | 6 ++ client/window/window.go | 3 + proto/strfail.go | 2 + server/channel/command.go | 8 ++- server/validate/validate.go | 2 +- 9 files changed, 190 insertions(+), 23 deletions(-) diff --git a/client/application.go b/client/application.go index e45ed5a..6b88281 100644 --- a/client/application.go +++ b/client/application.go @@ -117,8 +117,14 @@ func (a *application) Unsub(id string) { a.Request(proto.NewCmd("u", id), nil) } -func (a *application) GetInfo(id string, callback func(proto.Command)) { - a.Request(proto.NewCmd("i", id), callback) +func (a *application) GetInfo(id string, callback func(*proto.Object)) { + a.Request(proto.NewCmd("i", id), func(cmd proto.Command) { + if cmd.Kind == "i" && len(cmd.Args) > 0 { + callback(&cmd.Args[0]) + } else { + callback(nil) + } + }) } func (a *application) auth(name string, authCallback func(success bool)) { diff --git a/client/channel_window.go b/client/channel_window.go index 9a78b34..3239b47 100644 --- a/client/channel_window.go +++ b/client/channel_window.go @@ -4,9 +4,12 @@ import ( "citrons.xyz/talk/client/buffer" "citrons.xyz/talk/client/window" "citrons.xyz/talk/client/clipboard" + "citrons.xyz/talk/client/object" "citrons.xyz/talk/proto" "citrons.xyz/talk/tui" + "github.com/rivo/uniseg" "strconv" + "strings" "time" ) @@ -18,6 +21,8 @@ type channelWindow struct { window.DefaultWindow location channelLocation watchedUsers map[string]bool + messageCache object.ObjCache + replyingTo string loadingHistory bool endOfHistory bool } @@ -34,6 +39,7 @@ func (cl channelLocation) CreateWindow() window.Window { cw := &channelWindow { location: cl, watchedUsers: make(map[string]bool), } + cw.messageCache = object.NewCache(cw) globalApp.cache.Watch(cl.id) cw.loadMoreHistory() return cw @@ -69,6 +75,10 @@ func (cw *channelWindow) put(msg proto.Object) { if msg.Kind == "membership" { cw.endOfHistory = false } + cw.messageCache.Update(msg.Id, msg) + if !cw.messageCache.IsWatched(msg.Id) { + cw.messageCache.Watch(msg.Id) + } cw.Buf.Add(channelMsg {msg, cw}) } @@ -106,11 +116,20 @@ func (cw *channelWindow) Send(text string) { } } } - msg := proto.Object {"m", "", map[string]string {"": text}} + fields := map[string]string {"": text} + if cw.replyingTo != "" { + fields["reply"] = cw.replyingTo + cw.replyingTo = "" + } + msg := proto.Object {"m", "", fields} globalApp.Request(proto.NewCmd("p", cw.location.id, msg), cb) cw.In.SetText("") } +func (cw *channelWindow) replyTo(id string) { + cw.replyingTo = id +} + func (cw *channelWindow) leaveChannel() { globalApp.Request(proto.NewCmd("leave", cw.location.id), nil) globalApp.windowCache.Evict(cw.location) @@ -154,7 +173,12 @@ func (cw *channelWindow) loadMoreHistory() { cw.endOfHistory = true } for i := len(response.Args) - 1; i >= 0; i-- { - cw.Buf.AddTop(channelMsg {response.Args[i], cw}) + m := response.Args[i] + cw.messageCache.Update(m.Id, m) + if !cw.messageCache.IsWatched(m.Id) { + cw.messageCache.Watch(m.Id) + } + cw.Buf.AddTop(channelMsg {m, cw}) } case "fail": cw.endOfHistory = true @@ -163,6 +187,29 @@ func (cw *channelWindow) loadMoreHistory() { globalApp.Request(proto.NewCmd("history", cw.location.id, rq), cb) } +func (cw *channelWindow) GetInfo( + messageId string, callback func(*proto.Object)) { + cb := func (response proto.Command) { + if response.Kind == "history" && len(response.Args) != 0 { + callback(&response.Args[0]) + } else { + callback(nil) + } + } + rq := proto.Object {"at", "", map[string]string {"": messageId}} + globalApp.Request(proto.NewCmd("history", cw.location.id, rq), cb) +} + +func (cw *channelWindow) Sub(messageId string) { + cw.GetInfo(messageId, func(m *proto.Object) { + if m != nil { + cw.messageCache.Update(messageId, *m) + } + }) +} + +func (cw *channelWindow) Unsub(messageId string) {} + type userListMsg struct { index int channelName string @@ -240,6 +287,23 @@ func (cw *channelWindow) ShowStatusLine() { } } +func (cw *channelWindow) ShowComposingReply() { + msg := cw.messageCache.Get(cw.replyingTo) + if msg != nil { + mouse := tui.Push("reply message content", tui.Box { + Width: tui.Fill, Height: 1, NoWrap: true, + Style: &tui.Style {Fg: 248, Bg: tui.Black}, + }) + tui.Text("> ", nil) + tui.Text(msg.Fields[""], nil) + tui.Pop() + if mouse.Pressed && mouse.Button == 0 { + cw.replyingTo = "" + globalApp.redraw = true + } + } +} + func (m channelMsg) Id() string { return "buffer." + m.Object.Id } @@ -254,33 +318,103 @@ func (m channelMsg) showDate(bg int32) { tui.Pop() } -func (m channelMsg) showName(bg int32) { +func (m channelMsg) showName(bg int32, uid string, abbreviate bool) { nameStyle := tui.Style {Bg: bg, Fg: tui.White} - if m.Fields["f"] == globalApp.uid { + if uid == globalApp.uid { nameStyle.Fg = tui.Cyan } - if m.window.isGone(m.Fields["f"]) { + if m.window.isGone(uid) { nameStyle.Italic = true } else { nameStyle.Bold = true } - mouse := tui.Push(m.Id() + ".name", tui.Box { + mouse := tui.Push(m.Id() + "." + uid, tui.Box { Width: tui.TextSize, Height: 1, NoWrap: true, }) + switch { case mouse.Button == 0 && mouse.Pressed: fallthrough case tui.MenuOption("who?"): - globalApp.cmdWindow.who(m.Fields["f"]) + globalApp.cmdWindow.who(uid) globalApp.redraw = true } - tui.Text(m.window.username(m.Fields["f"]), &nameStyle) + + name := m.window.username(uid) + var dotdotdot bool + if abbreviate && uniseg.StringWidth(name) > 12 { + dotdotdot = true + var sb strings.Builder + g := uniseg.NewGraphemes(name) + width := 0 + for g.Next() { + width = width + g.Width() + if width > 11 { + break + } + sb.WriteString(g.Str()) + } + name = sb.String() + } + tui.Text(name, &nameStyle) + if dotdotdot { + tui.Text("…", nil) + } + + tui.Pop() +} + +func (m channelMsg) showReply(bg int32, replyTo *proto.Object) { + tui.Push(m.Id() + ".reply message", tui.Box { + Width: tui.Children, Height: 1, Dir: tui.Right, + }) + + tui.Push("", tui.Box { + Width: tui.TextSize, Height: 1, NoWrap: true, + }) + tui.Text(" re ", &tui.Style {Fg: tui.Blue, Bg: bg}) + tui.Pop() + + var kind string + if replyTo != nil { + kind = replyTo.Kind + } + switch kind { + case "m": + m.showName(bg, replyTo.Fields["f"], true) + tui.Push("", tui.Box { + Width: tui.TextSize, Height: 1, NoWrap: true, + }) + tui.Text(": ", &tui.Style {Fg: tui.BrightBlack, Bg: bg}) + text := strings.TrimSpace(replyTo.Fields[""]) + tui.Text(text, &tui.Style {Bg: bg, Fg: 248}) + tui.Pop() + default: + tui.Push("", tui.Box { + Width: tui.TextSize, Height: 1, NoWrap: true, + }) + tui.Text("...", nil) + tui.Pop() + } + tui.Pop() } func (m channelMsg) Show(odd bool) { var bg int32 = colorDefault[odd] + var ( + isReply bool + replyTo *proto.Object + ) + if m.Fields["reply"] != "" { + isReply = true + if !m.window.messageCache.IsWatched(m.Fields["reply"]) { + m.window.messageCache.Watch(m.Fields["reply"]) + } + replyTo = m.window.messageCache.Get(m.Fields["reply"]) + } + switch m.Kind { case "join", "leave": tui.Push("", tui.Box { @@ -300,17 +434,24 @@ func (m channelMsg) Show(odd bool) { tui.Text(m.Kind, &tui.Style {Fg: tui.Blue, Bg: bg}) tui.Text(": ", &tui.Style {Fg: tui.BrightBlack, Bg: bg}) tui.Pop() - m.showName(bg) + m.showName(bg, m.Fields["f"], false) tui.Pop() tui.Pop() case "m": tui.Push("", tui.Box {Width: tui.Fill, Height: 1, Dir: tui.Left}) m.showDate(bg) - m.showName(bg) + + tui.Push("", tui.Box {Width: tui.Children, Height: 1, Dir: tui.Right}) + m.showName(bg, m.Fields["f"], isReply) + if isReply { + m.showReply(bg, replyTo) + } + tui.Pop() + tui.Pop() - tui.Push(m.Id() + ".content", tui.Box { + mouse := tui.Push(m.Id() + ".content", tui.Box { Width: tui.Fill, Height: tui.TextSize, Margins: [4]int {1, 0, 0, 0}, }) @@ -318,7 +459,10 @@ func (m channelMsg) Show(odd bool) { switch { case tui.MenuOption("copy"): clipboard.Get().Copy(m.Fields[""]) + case mouse.Button == 0 && mouse.Pressed: + fallthrough case tui.MenuOption("reply"): + m.window.replyTo(m.Object.Id) } tui.Pop() default: diff --git a/client/empty_window.go b/client/empty_window.go index e40ab96..690049c 100644 --- a/client/empty_window.go +++ b/client/empty_window.go @@ -27,3 +27,5 @@ func (w emptyWindow) Input() *tui.TextInput { func (w emptyWindow) Send(text string) {} func (w emptyWindow) ShowStatusLine() {} + +func (w emptyWindow) ShowComposingReply() {} diff --git a/client/object/object.go b/client/object/object.go index 2e0d83b..a3404cc 100644 --- a/client/object/object.go +++ b/client/object/object.go @@ -17,7 +17,7 @@ type cacheEntry struct { type ObjStream interface { Sub(id string) - GetInfo(id string, callback func(proto.Command)) + GetInfo(id string, callback func(*proto.Object)) Unsub(id string) } @@ -36,13 +36,7 @@ func (oc *ObjCache) Get(id string) *proto.Object { func (oc *ObjCache) Fetch(id string, callback func(*proto.Object)) { entry := oc.objects[id] if !entry.cached { - oc.stream.GetInfo(id, func(cmd proto.Command) { - if cmd.Kind == "i" && len(cmd.Args) > 0 { - callback(&cmd.Args[0]) - } else { - callback(nil) - } - }) + oc.stream.GetInfo(id, callback) } else { callback(&entry.o) } @@ -70,6 +64,10 @@ func (oc *ObjCache) Unwatch(id string) { } } +func (oc *ObjCache) IsWatched(id string) bool { + return oc.objects[id].refCount > 0 +} + func (oc *ObjCache) Update(id string, new proto.Object) { entry := oc.objects[id] if entry.cached && new.Kind == entry.o.Kind { diff --git a/client/ui.go b/client/ui.go index 65983c3..e5917fa 100644 --- a/client/ui.go +++ b/client/ui.go @@ -180,6 +180,12 @@ func (a *application) showWindow() { tui.Pop() + tui.Push("replying to", tui.Box {Width: tui.Fill, Height: tui.Children}) + if prompt == win { + win.ShowComposingReply() + } + tui.Pop() + tui.Push("input container", tui.Box { Width: tui.Fill, Height: tui.Children, Dir: tui.Right, }) diff --git a/client/window/window.go b/client/window/window.go index 014bb7a..80777cc 100644 --- a/client/window/window.go +++ b/client/window/window.go @@ -14,6 +14,7 @@ type Window interface { Location() Location Kill() Buffer() *buffer.Buffer + ShowComposingReply() } type Prompt interface { @@ -70,3 +71,5 @@ func (w *DefaultWindow) Input() *tui.TextInput { func (w *DefaultWindow) Send(text string) {} func (w *DefaultWindow) ShowStatusLine() {} + +func (w *DefaultWindow) ShowComposingReply() {} diff --git a/proto/strfail.go b/proto/strfail.go index eb3d1c6..1ce160c 100644 --- a/proto/strfail.go +++ b/proto/strfail.go @@ -18,6 +18,8 @@ func Strfail(fail Object) string { return "message or status is too long" case "not-in-channel": return "you are not a member of this channel: " + fail.Fields[""] + case "bad-reply": + return "this is not a message you can reply to" default: return "unknown error" } diff --git a/server/channel/command.go b/server/channel/command.go index e645055..05a1c7a 100644 --- a/server/channel/command.go +++ b/server/channel/command.go @@ -17,9 +17,15 @@ func (c *Channel) SendRequest(r session.Request) { m := r.Cmd.Args[0] switch m.Kind { case "m": - for k, _ := range m.Fields { + for k, v := range m.Fields { switch k { case "": + case "reply": + _, ok := c.byId[v] + if !ok { + r.Reply(proto.Fail{"bad-reply", "", nil}.Cmd()) + return + } default: r.ReplyInvalid() return diff --git a/server/validate/validate.go b/server/validate/validate.go index 9b3ff0d..7aa7db0 100644 --- a/server/validate/validate.go +++ b/server/validate/validate.go @@ -6,7 +6,7 @@ import ( ) func Name(name string) bool { - if len(Fold(name)) == 0 || len(name) > 256 { + if len(Fold(name)) == 0 || len(name) > 64 { return false } for _, r := range name { -- cgit v1.2.3