-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathmessages.go
More file actions
337 lines (292 loc) · 8.54 KB
/
messages.go
File metadata and controls
337 lines (292 loc) · 8.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
package tui
import (
"fmt"
"regexp"
"strings"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/ansi"
"github.com/charmbracelet/glamour/styles"
"github.com/charmbracelet/lipgloss"
"github.com/chatwoot/cli/internal/sdk"
"github.com/muesli/termenv"
)
// mentionRe matches [@Name](mention://user/ID/Name) or [@Name](mention://team/ID/Name)
// and captures the display text (the part inside [ ]).
var mentionRe = regexp.MustCompile(`\[@([^\]]+)\]\(mention://(?:user|team)/\d+/[^)]+\)`)
// chatStyleConfig returns a glamour style tuned for chat bubbles: no document
// margin/indent and no block prefix/suffix newlines. Detected once at startup
// before bubbletea takes over the terminal (WithAutoStyle deadlocks).
var chatStyleConfig = func() glamour.TermRendererOption {
var cfg ansi.StyleConfig
if termenv.HasDarkBackground() {
cfg = styles.DarkStyleConfig
} else {
cfg = styles.LightStyleConfig
}
// Strip document-level whitespace — content lives inside a lipgloss box
zero := uint(0)
cfg.Document.Margin = &zero
cfg.Document.BlockPrefix = ""
cfg.Document.BlockSuffix = ""
return glamour.WithStyles(cfg)
}()
// MessagePane renders the messages for the selected conversation.
// Messages are only loaded when the user presses Enter.
type MessagePane struct {
messages []sdk.Message
conversationID int // which conversation messages belong to
loaded bool
loadingMore bool
hasMoreMessages bool
oldestMessageID int
width, height int
scrollOffset int
mdRenderer *glamour.TermRenderer
}
func NewMessagePane() MessagePane {
return MessagePane{}
}
func (p *MessagePane) SetSize(w, h int) {
p.width = w
p.height = h
// Recreate markdown renderer when width changes
boxContentW := w * 70 / 100
if boxContentW < 15 {
boxContentW = 15
}
textW := boxContentW - 2
if textW < 5 {
textW = 5
}
r, err := glamour.NewTermRenderer(
chatStyleConfig,
glamour.WithWordWrap(textW),
)
if err == nil {
p.mdRenderer = r
}
}
func (p *MessagePane) SetMessages(convID int, msgs []sdk.Message) {
p.conversationID = convID
p.messages = msgs
p.loaded = true
p.loadingMore = false
// Track oldest message ID for pagination
if len(msgs) > 0 {
p.oldestMessageID = msgs[0].ID
// Assume more messages if we got a full page (20+)
p.hasMoreMessages = len(msgs) >= 20
} else {
p.hasMoreMessages = false
}
p.scrollToBottom()
}
func (p *MessagePane) Clear() {
p.messages = nil
p.conversationID = 0
p.loaded = false
p.scrollOffset = 0
}
func (p *MessagePane) IsLoaded() bool {
return p.loaded
}
func (p *MessagePane) ConversationID() int {
return p.conversationID
}
func (p *MessagePane) PrependMessages(msgs []sdk.Message) {
if len(msgs) == 0 {
p.hasMoreMessages = false
p.loadingMore = false
return
}
// Calculate current scroll position in lines before prepending
linesBefore := p.countLines()
// Prepend older messages
p.messages = append(msgs, p.messages...)
p.oldestMessageID = msgs[0].ID
p.hasMoreMessages = len(msgs) >= 20
p.loadingMore = false
// Adjust scroll offset to maintain visual position
linesAfter := p.countLines()
p.scrollOffset += linesAfter - linesBefore
}
func (p *MessagePane) ShouldLoadMore() bool {
return p.loaded && !p.loadingMore && p.hasMoreMessages && p.scrollOffset < 10
}
func (p *MessagePane) OldestMessageID() int {
return p.oldestMessageID
}
func (p *MessagePane) SetLoadingMore() {
p.loadingMore = true
}
func (p *MessagePane) scrollToBottom() {
total := p.countLines()
if total > p.height {
p.scrollOffset = total - p.height
} else {
p.scrollOffset = 0
}
}
func (p *MessagePane) ScrollUp() {
if p.scrollOffset > 0 {
p.scrollOffset--
}
}
func (p *MessagePane) ScrollDown() {
total := p.countLines()
if total > p.height && p.scrollOffset < total-p.height {
p.scrollOffset++
}
}
func (p *MessagePane) countLines() int {
n := 0
for _, msg := range p.messages {
n += len(p.renderMessage(msg))
}
return n
}
// View renders the message pane content.
// CRITICAL: output is strictly bounded to p.height lines to prevent overflow.
func (p *MessagePane) View() string {
if !p.loaded {
logo := lipgloss.NewStyle().Foreground(lipgloss.Color("#1f93ff")).Render(chatwootLogo)
hint := lipgloss.NewStyle().Foreground(colorMuted).Render("Press Enter to load messages")
return lipgloss.NewStyle().
Width(p.width).Height(p.height).
Align(lipgloss.Center, lipgloss.Center).
Render(logo + "\n\n" + hint)
}
if len(p.messages) == 0 {
return lipgloss.NewStyle().
Foreground(colorMuted).
Width(p.width).Height(p.height).
Align(lipgloss.Center, lipgloss.Center).
Render("No messages")
}
// Render each message into lines, collect into flat slice.
var lines []string
for _, msg := range p.messages {
rendered := p.renderMessage(msg)
lines = append(lines, rendered...)
}
// Apply scroll and clamp to height
if p.scrollOffset > len(lines)-p.height {
p.scrollOffset = len(lines) - p.height
}
if p.scrollOffset < 0 {
p.scrollOffset = 0
}
end := p.scrollOffset + p.height
if end > len(lines) {
end = len(lines)
}
visible := lines[p.scrollOffset:end]
return strings.Join(visible, "\n")
}
// renderMessage returns lines for a bordered message box + 1 blank separator.
// Box width = 70% of pane width. Incoming = left, outgoing = right, activity = centered.
func (p *MessagePane) renderMessage(msg sdk.Message) []string {
paneW := p.width
boxContentW := paneW * 70 / 100
if boxContentW < 15 {
boxContentW = 15
}
// Box border adds 2 to visual width, padding(0,1) adds 2 more = +4 visual
// So boxContentW is what we pass to Width(), visual = boxContentW + 2 (border only, no padding on box)
// We use Padding(0,1) so inner text width = boxContentW - 2
textW := boxContentW - 2
if textW < 5 {
textW = 5
}
// Activity messages: no box, centered muted text
if msg.MessageType == 2 {
content := msg.Content
if content == "" {
content = "(activity)"
}
line := lipgloss.NewStyle().
Foreground(colorMuted).Italic(true).
Width(paneW).Align(lipgloss.Center).
Render(truncate(content, paneW-2))
return []string{line, ""}
}
sender := "Unknown"
if msg.Sender != nil {
sender = msg.Sender.Name
}
ts := formatTime(msg.CreatedAt)
statusIcon := msgStatus(msg.Status, msg.MessageType)
content := strings.TrimSpace(msg.Content)
if content == "" {
content = "(no content)"
}
// Strip mention URLs: [@Name](mention://...) → **@Name** (bold)
content = mentionRe.ReplaceAllString(content, "**@$1**")
// Render markdown
if p.mdRenderer != nil {
rendered, err := p.mdRenderer.Render(content)
if err == nil {
content = strings.TrimRight(rendered, "\n")
}
}
// Metadata line below box: [sender ·] #ID · time [· status]
// Skip sender for incoming messages (type 0) — visible in info pane
var metaParts []string
if msg.MessageType != 0 {
metaParts = append(metaParts, sender)
}
metaParts = append(metaParts, fmt.Sprintf("#%d", msg.ID), ts)
if statusIcon != "" {
metaParts = append(metaParts, statusIcon)
}
meta := strings.Join(metaParts, " · ")
metaStyle := lipgloss.NewStyle().Foreground(colorMuted)
// Build the box (content only)
var borderColor lipgloss.AdaptiveColor
if msg.Private {
borderColor = colorPrivate
} else if msg.MessageType == 1 {
borderColor = colorOutgoing
} else {
borderColor = colorBorder
}
boxStyle := lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(borderColor).
Padding(0, 1).
Width(boxContentW)
box := boxStyle.Render(content)
// Position: left or right
if msg.MessageType == 1 {
// Outgoing: right-aligned
boxLine := lipgloss.NewStyle().Width(paneW).Align(lipgloss.Right).Render(box)
boxLines := strings.Split(boxLine, "\n")
metaLine := lipgloss.NewStyle().Width(paneW).Align(lipgloss.Right).
Render(metaStyle.Render(meta))
result := append(boxLines, metaLine, "") // box + meta + separator
return result
}
// Incoming: left-aligned
boxLines := strings.Split(box, "\n")
metaLine := metaStyle.Render(meta)
result := append(boxLines, metaLine, "") // box + meta + separator
return result
}
func msgStatus(status string, messageType int) string {
// Only show status for outgoing messages
if messageType != 1 {
return ""
}
switch status {
case "sent":
return lipgloss.NewStyle().Foreground(colorMuted).Render("✔︎")
case "delivered":
return lipgloss.NewStyle().Foreground(colorMuted).Render("✔︎✔︎")
case "read":
return lipgloss.NewStyle().Foreground(colorAccent).Render("✔︎✔︎")
case "failed":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")).Render("●")
default:
return ""
}
}