package main 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" ) type channelLocation struct { id string jumpTo string } type channelWindow struct { window.DefaultWindow location channelLocation watchedUsers map[string]bool messageCache object.ObjCache replyingTo string loadingHistory bool endOfHistory bool startOfHistory bool jumpedTo buffer.Message } type channelMsg struct { proto.Object window *channelWindow } func (cl channelLocation) CreateWindow() window.Window { if !globalApp.connected || !globalApp.authenticated { return nil } cw := &channelWindow { location: cl, watchedUsers: make(map[string]bool), } cw.messageCache = object.NewCache(cw) globalApp.cache.Watch(cl.id) if cl.jumpTo == "" { cw.startOfHistory = true } cw.Buf.SetSnap(cw.startOfHistory) cw.loadMoreHistory(false) return cw } func (cw *channelWindow) getChannel() *proto.Object { return globalApp.cache.Get(cw.location.id) } func (cw *channelWindow) watchUser(uid string) { if !cw.watchedUsers[uid] { globalApp.cache.Watch(uid) cw.watchedUsers[uid] = true } } func (cw *channelWindow) username(uid string) string { cw.watchUser(uid) u := globalApp.cache.Get(uid) if u != nil { return u.Fields[""] } return "..." } func (cw *channelWindow) isGone(uid string) bool { cw.watchUser(uid) u := globalApp.cache.Get(uid) return u != nil && u.Kind == "gone" } func (cw *channelWindow) put(msg proto.Object) { if !cw.startOfHistory { return } if msg.Kind == "membership" { cw.endOfHistory = false if cw.location.jumpTo != "" { cw.startOfHistory = false } } cw.addMessage(msg, true) } func (cw *channelWindow) Location() window.Location { return cw.location } func (cw *channelWindow) Kill() { globalApp.cache.Unwatch(cw.location.id) for u := range cw.watchedUsers { globalApp.cache.Unwatch(u) } } func (cw *channelWindow) Buffer() *buffer.Buffer { if cw.Buf.AtTop() { cw.loadMoreHistory(false) } if cw.Buf.AtBottom() { cw.loadMoreHistory(true) } cw.Buf.SetSnap(cw.startOfHistory) return &cw.Buf } func (cw *channelWindow) Send(text string) { if !globalApp.connected || !globalApp.authenticated { return } cb := func(response proto.Command) { switch response.Kind { case "fail": if len(response.Args) > 0 { globalApp.cmdWindow.fail(response.Args[0]) } } } 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("") if cw.location.jumpTo == "" { cw.Buf.ScrollBottom() } else { globalApp.goTo(channelLocation {id: cw.location.id}) } } 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) globalApp.removeFromHistory(cw.location) globalApp.channelList.remove(cw.location) } func (cw *channelWindow) renameChannel(newName string) { ch := proto.Object { "channel", cw.location.id, map[string]string {"": newName}, } globalApp.sendUpdate(ch, func(response proto.Command) { if response.Kind == "fail" { if len(response.Args) > 0 { f := proto.Fail(response.Args[0]) globalApp.cmdWindow.err(f.Error()) } } }) } func (cw *channelWindow) addMessage(m proto.Object, after bool) { cw.messageCache.Update(m.Id, m) if !cw.messageCache.IsWatched(m.Id) { cw.messageCache.Watch(m.Id) } cmsg := channelMsg {m, cw} if after { cw.Buf.Add(cmsg) } else { cw.Buf.AddTop(cmsg) } if m.Id == cw.location.jumpTo { cw.jumpedTo = cmsg cw.Buf.ScrollTo(cmsg.Id()) } } func (cw *channelWindow) loadMoreHistory(after bool) { if cw.loadingHistory { return } if !after && cw.endOfHistory { return } if after && cw.startOfHistory { return } cw.loadingHistory = true rq := proto.Object {Fields: make(map[string]string)} var last *buffer.List if !after { last = cw.Buf.Top() } else { last = cw.Buf.Bottom() } var lastId string if last != nil { lastId = last.Msg().(channelMsg).Object.Id } switch { case last == nil && cw.location.jumpTo != "": rq.Kind = "around" rq.Fields[""] = cw.location.jumpTo case last == nil: rq.Kind = "latest" case !after: rq.Kind = "before" rq.Fields[""] = lastId case after: rq.Kind = "after" rq.Fields[""] = lastId } cb := func(response proto.Command) { cw.loadingHistory = false switch response.Kind { case "history": if len(response.Args) == 0 { if !after { cw.endOfHistory = true } else { cw.startOfHistory = true } } if !after { for i := len(response.Args) - 1; i >= 0; i-- { cw.addMessage(response.Args[i], after) } } else { for i := 0; i < len(response.Args); i++ { cw.addMessage(response.Args[i], after) } } case "fail": if !after { cw.endOfHistory = true } else { cw.startOfHistory = true } } } 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 names []string } func (u userListMsg) Id() string { return "user list." + strconv.Itoa(u.index) } func (u userListMsg) Show(odd bool) { tui.Push("", tui.Box { Width: tui.Fill, Height: tui.Children, Dir: tui.Right, Style: &tui.Style {Bg: colorCmd[odd], Fg: tui.White}, }) nameStyle := &tui.Style {Bg: colorCmd[odd], Fg: tui.White, Bold: true} tui.Push("", tui.Box {Width: tui.TextSize, Height: tui.TextSize}) tui.Text("* ", nil) tui.Pop() tui.Push("", tui.Box {Width: tui.Fill, Height: tui.Children}) tui.Push("", tui.Box {Width: tui.Fill, Height: tui.TextSize}) tui.Text(strconv.Itoa(len(u.names)), nil) tui.Text(" users in ", nil) tui.Text(u.channelName, nameStyle) tui.Pop() tui.Push("names", tui.Box { Width: tui.Fill, Height: tui.TextSize, Margins: [4]int {1, 0, 0, 0}, }) for i, name := range u.names { tui.Text(name, nameStyle) if i < len(u.names) - 1 { tui.Text(", ", nil) } } tui.Pop() tui.Pop() tui.Pop() } func (cw *channelWindow) userList(callback func(userListMsg)) { cb := func(response proto.Command) { if response.Kind != "list" { return } var names []string for _, u := range response.Args { names = append(names, u.Fields[""]) } lastIndex++ globalApp.cache.Fetch(cw.location.id, func(ch *proto.Object) { var name string if ch != nil { name = ch.Fields[""] } callback(userListMsg {lastIndex, name, names}) }) } globalApp.Request(proto.NewCmd("list", cw.location.id), cb) } func (cw *channelWindow) ShowStatusLine() { if cw.location.jumpTo != "" { tui.Text("viewing history", &tui.Style { Fg: tui.Black, Bg: tui.White, Italic: true, }) tui.Text(": ", nil) } ch := cw.getChannel() if ch == nil { return } tui.Text(ch.Fields[""], nil) if cw.loadingHistory { tui.Text(" ...", nil) } } 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(strings.TrimSpace(msg.Fields[""]), nil) tui.Pop() if mouse.Pressed && mouse.Button == 0 { cw.replyingTo = "" globalApp.redraw = true } } } func (cw *channelWindow) OnNavigate() { if cw.jumpedTo != nil { cw.Buf.ScrollTo(cw.jumpedTo.Id()) } } func (m channelMsg) Id() string { return "buffer." + m.Object.Id } func (m channelMsg) showDate(bg int32) { unix, _ := strconv.Atoi(m.Fields["t"]) time := time.Unix(int64(unix), 0).Format(time.DateTime) tui.Push("", tui.Box {Width: tui.TextSize, Height: 1, NoWrap: true}) tui.Text(" " + time, &tui.Style {Fg: tui.BrightBlack, Bg: bg}) tui.Pop() tui.Push("", tui.Box {Width: tui.Fill, Height: 1}) tui.Pop() } func (m channelMsg) showName(bg int32, uid string, abbreviate bool) { nameStyle := tui.Style {Bg: bg, Fg: tui.White} if uid == globalApp.uid { nameStyle.Fg = tui.Cyan } if m.window.isGone(uid) { nameStyle.Italic = true } else { nameStyle.Bold = true } 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(uid) globalApp.redraw = true } 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) { mouse := tui.Push(m.Id() + ".reply message", tui.Box { Width: tui.Children, Height: 1, Dir: tui.Right, }) if replyTo != nil && mouse.Button == 0 && mouse.Pressed { globalApp.goTo(channelLocation { id: m.window.location.id, jumpTo: replyTo.Id, }) globalApp.redraw = true } 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] if m.Object.Id == m.window.location.jumpTo { bg = 17 } tui.Push("", tui.Box { Width: tui.Fill, Height: tui.Children, Style: &tui.Style {Bg: bg, Fg: tui.White}, }) 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 { Width: tui.Fill, Height: tui.Children, Dir: tui.Left, }) m.showDate(bg) tui.Push("", tui.Box {Width: tui.Children, Height: 1, Dir: tui.Right}) tui.Push("", tui.Box { Width: tui.TextSize, Height: tui.TextSize, NoWrap: true, }) if m.Kind == "join" { tui.Text("-> ", &tui.Style {Fg: tui.BrightBlack, Bg: bg}) } else { tui.Text("<- ", &tui.Style {Fg: tui.BrightBlack, Bg: bg}) } 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.Fields["f"], false) tui.Pop() tui.Pop() case "m": tui.Push("", tui.Box {Width: tui.Fill, Height: 1, Dir: tui.Left}) m.showDate(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() mouse := tui.Push(m.Id() + ".content", tui.Box { Width: tui.Fill, Height: tui.TextSize, Margins: [4]int {1, 0, 0, 0}, }) tui.Text(m.Fields[""], nil) 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: tui.Push("", tui.Box {Width: tui.Fill, Height: tui.TextSize}) tui.Text("[", nil) tui.Text(m.Kind, &tui.Style {Fg: tui.White, Bg: bg, Italic: true}) tui.Text("]", nil) tui.Pop() } tui.Pop() }