this repo has no description
1const std = @import("std");
2const comlink = @import("comlink.zig");
3const lua = @import("lua.zig");
4const tls = @import("tls");
5const vaxis = @import("vaxis");
6const zeit = @import("zeit");
7
8const Completer = @import("completer.zig").Completer;
9const Scrollbar = @import("Scrollbar.zig");
10const testing = std.testing;
11const mem = std.mem;
12const vxfw = vaxis.vxfw;
13
14const Allocator = std.mem.Allocator;
15const Base64Encoder = std.base64.standard.Encoder;
16
17const assert = std.debug.assert;
18
19const log = std.log.scoped(.irc);
20
21/// maximum size message we can write
22pub const maximum_message_size = 512;
23
24/// maximum size message we can receive
25const max_raw_msg_size = 512 + 8191; // see modernircdocs
26
27/// Seconds of idle connection before we start pinging
28const keepalive_idle: i32 = 15;
29
30/// Seconds between pings
31const keepalive_interval: i32 = 5;
32
33/// Number of failed pings before we consider the connection failed
34const keepalive_retries: i32 = 3;
35
36// Gutter (left side where time is printed) width
37const gutter_width = 6;
38
39pub const Buffer = union(enum) {
40 client: *Client,
41 channel: *Channel,
42};
43
44pub const Command = enum {
45 RPL_WELCOME, // 001
46 RPL_YOURHOST, // 002
47 RPL_CREATED, // 003
48 RPL_MYINFO, // 004
49 RPL_ISUPPORT, // 005
50
51 RPL_TRYAGAIN, // 263
52
53 RPL_ENDOFWHO, // 315
54 RPL_LISTSTART, // 321
55 RPL_LIST, // 322
56 RPL_LISTEND, // 323
57 RPL_TOPIC, // 332
58 RPL_WHOREPLY, // 352
59 RPL_NAMREPLY, // 353
60 RPL_WHOSPCRPL, // 354
61 RPL_ENDOFNAMES, // 366
62
63 RPL_LOGGEDIN, // 900
64 RPL_SASLSUCCESS, // 903
65
66 // Named commands
67 AUTHENTICATE,
68 AWAY,
69 BATCH,
70 BOUNCER,
71 CAP,
72 CHATHISTORY,
73 JOIN,
74 MARKREAD,
75 NOTICE,
76 PART,
77 PONG,
78 PRIVMSG,
79 TAGMSG,
80
81 unknown,
82
83 const map = std.StaticStringMap(Command).initComptime(.{
84 .{ "001", .RPL_WELCOME },
85 .{ "002", .RPL_YOURHOST },
86 .{ "003", .RPL_CREATED },
87 .{ "004", .RPL_MYINFO },
88 .{ "005", .RPL_ISUPPORT },
89
90 .{ "263", .RPL_TRYAGAIN },
91
92 .{ "315", .RPL_ENDOFWHO },
93 .{ "321", .RPL_LISTSTART },
94 .{ "322", .RPL_LIST },
95 .{ "323", .RPL_LISTEND },
96 .{ "332", .RPL_TOPIC },
97 .{ "352", .RPL_WHOREPLY },
98 .{ "353", .RPL_NAMREPLY },
99 .{ "354", .RPL_WHOSPCRPL },
100 .{ "366", .RPL_ENDOFNAMES },
101
102 .{ "900", .RPL_LOGGEDIN },
103 .{ "903", .RPL_SASLSUCCESS },
104
105 .{ "AUTHENTICATE", .AUTHENTICATE },
106 .{ "AWAY", .AWAY },
107 .{ "BATCH", .BATCH },
108 .{ "BOUNCER", .BOUNCER },
109 .{ "CAP", .CAP },
110 .{ "CHATHISTORY", .CHATHISTORY },
111 .{ "JOIN", .JOIN },
112 .{ "MARKREAD", .MARKREAD },
113 .{ "NOTICE", .NOTICE },
114 .{ "PART", .PART },
115 .{ "PONG", .PONG },
116 .{ "PRIVMSG", .PRIVMSG },
117 .{ "TAGMSG", .TAGMSG },
118 });
119
120 pub fn parse(cmd: []const u8) Command {
121 return map.get(cmd) orelse .unknown;
122 }
123};
124
125pub const Channel = struct {
126 client: *Client,
127 name: []const u8,
128 topic: ?[]const u8 = null,
129 members: std.ArrayList(Member),
130 in_flight: struct {
131 who: bool = false,
132 names: bool = false,
133 } = .{},
134
135 messages: std.ArrayList(Message),
136 history_requested: bool = false,
137 who_requested: bool = false,
138 at_oldest: bool = false,
139 can_scroll_up: bool = false,
140 // The MARKREAD state of this channel
141 last_read: u32 = 0,
142 // The location of the last read indicator. This doesn't necessarily match the state of
143 // last_read
144 last_read_indicator: u32 = 0,
145 scroll_to_last_read: bool = false,
146 has_unread: bool = false,
147 has_unread_highlight: bool = false,
148
149 has_mouse: bool = false,
150
151 view: vxfw.SplitView,
152 member_view: vxfw.ListView,
153 text_field: vxfw.TextField,
154
155 scroll: struct {
156 /// Line offset from the bottom message
157 offset: u16 = 0,
158 /// Message offset into the list of messages. We use this to lock the viewport if we have a
159 /// scroll. Otherwise, when offset == 0 this is effectively ignored (and should be 0)
160 msg_offset: ?usize = null,
161
162 /// Pending scroll we have to handle while drawing. This could be up or down. By convention
163 /// we say positive is a scroll up.
164 pending: i17 = 0,
165 } = .{},
166
167 animation_end_ms: u64 = 0,
168
169 message_view: struct {
170 mouse: ?vaxis.Mouse = null,
171 hovered_message: ?Message = null,
172 } = .{},
173
174 completer: Completer,
175 completer_shown: bool = false,
176 typing_last_active: u32 = 0,
177 typing_last_sent: u32 = 0,
178
179 pub const Member = struct {
180 user: *User,
181
182 /// Highest channel membership prefix (or empty space if no prefix)
183 prefix: u8,
184
185 channel: *Channel,
186 has_mouse: bool = false,
187 typing: u32 = 0,
188
189 pub fn compare(_: void, lhs: Member, rhs: Member) bool {
190 if (lhs.prefix == rhs.prefix) {
191 return std.ascii.orderIgnoreCase(lhs.user.nick, rhs.user.nick).compare(.lt);
192 }
193 return lhs.prefix > rhs.prefix;
194 }
195
196 pub fn widget(self: *Member) vxfw.Widget {
197 return .{
198 .userdata = self,
199 .eventHandler = Member.eventHandler,
200 .drawFn = Member.draw,
201 };
202 }
203
204 fn eventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
205 const self: *Member = @ptrCast(@alignCast(ptr));
206 switch (event) {
207 .mouse => |mouse| {
208 if (!self.has_mouse) {
209 self.has_mouse = true;
210 try ctx.setMouseShape(.pointer);
211 }
212 switch (mouse.type) {
213 .press => {
214 if (mouse.button == .left) {
215 // Open a private message with this user
216 const client = self.channel.client;
217 const ch = try client.getOrCreateChannel(self.user.nick);
218 try client.requestHistory(.after, ch);
219 client.app.selectChannelName(client, ch.name);
220 return ctx.consumeAndRedraw();
221 }
222 if (mouse.button == .right) {
223 // Insert nick at cursor
224 try self.channel.text_field.insertSliceAtCursor(self.user.nick);
225 return ctx.consumeAndRedraw();
226 }
227 },
228 else => {},
229 }
230 },
231 .mouse_enter => {
232 self.has_mouse = true;
233 try ctx.setMouseShape(.pointer);
234 },
235 .mouse_leave => {
236 self.has_mouse = false;
237 try ctx.setMouseShape(.default);
238 },
239 else => {},
240 }
241 }
242
243 pub fn draw(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
244 const self: *Member = @ptrCast(@alignCast(ptr));
245 var style: vaxis.Style = if (self.user.away)
246 .{ .fg = .{ .index = 8 } }
247 else
248 .{ .fg = self.user.color };
249 if (self.has_mouse) style.reverse = true;
250 const prefix: []const u8 = switch (self.prefix) {
251 '~' => " ", // founder
252 '&' => " ", // protected
253 '@' => " ", // operator
254 '%' => " ", // half op
255 '+' => " ", // voice
256 else => try std.fmt.allocPrint(ctx.arena, "{c} ", .{self.prefix}),
257 };
258 const text: vxfw.RichText = .{
259 .text = &.{
260 .{ .text = prefix, .style = style },
261 .{ .text = self.user.nick, .style = style },
262 },
263 .softwrap = false,
264 };
265 var surface = try text.draw(ctx);
266 surface.widget = self.widget();
267 return surface;
268 }
269 };
270
271 pub fn init(
272 self: *Channel,
273 gpa: Allocator,
274 client: *Client,
275 name: []const u8,
276 unicode: *const vaxis.Unicode,
277 ) Allocator.Error!void {
278 self.* = .{
279 .name = try gpa.dupe(u8, name),
280 .members = std.ArrayList(Channel.Member).init(gpa),
281 .messages = std.ArrayList(Message).init(gpa),
282 .client = client,
283 .view = .{
284 .lhs = self.contentWidget(),
285 .rhs = self.member_view.widget(),
286 .width = 16,
287 .constrain = .rhs,
288 },
289 .member_view = .{
290 .children = .{
291 .builder = .{
292 .userdata = self,
293 .buildFn = Channel.buildMemberList,
294 },
295 },
296 .draw_cursor = false,
297 },
298 .text_field = vxfw.TextField.init(gpa, unicode),
299 .completer = Completer.init(gpa),
300 };
301
302 self.text_field.style = .{ .bg = client.app.blendBg(10) };
303 self.text_field.userdata = self;
304 self.text_field.onSubmit = Channel.onSubmit;
305 self.text_field.onChange = Channel.onChange;
306 }
307
308 fn onSubmit(ptr: ?*anyopaque, ctx: *vxfw.EventContext, input: []const u8) anyerror!void {
309 // Check the message is not just whitespace
310 for (input) |b| {
311 // Break on the first non-whitespace byte
312 if (!std.ascii.isWhitespace(b)) break;
313 } else return;
314
315 const self: *Channel = @ptrCast(@alignCast(ptr orelse unreachable));
316
317 // Copy the input into a temporary buffer
318 var buf: [1024]u8 = undefined;
319 @memcpy(buf[0..input.len], input);
320 const local = buf[0..input.len];
321 // Free the text field. We do this here because the command may destroy our channel
322 self.text_field.clearAndFree();
323 self.completer_shown = false;
324
325 if (std.mem.startsWith(u8, local, "/")) {
326 self.client.app.handleCommand(.{ .channel = self }, local) catch {
327 log.warn("invalid command: {s}", .{input});
328 return;
329 };
330 } else {
331 try self.client.print("PRIVMSG {s} :{s}\r\n", .{ self.name, local });
332 }
333 ctx.redraw = true;
334 }
335
336 pub fn insertMessage(self: *Channel, msg: Message) !void {
337 try self.messages.append(msg);
338 if (msg.timestamp_s > self.last_read) {
339 self.has_unread = true;
340 if (msg.containsPhrase(self.client.nickname())) {
341 self.has_unread_highlight = true;
342 }
343 }
344 }
345
346 fn onChange(ptr: ?*anyopaque, _: *vxfw.EventContext, input: []const u8) anyerror!void {
347 const self: *Channel = @ptrCast(@alignCast(ptr orelse unreachable));
348 if (!self.client.caps.@"message-tags") return;
349 if (std.mem.startsWith(u8, input, "/")) {
350 return;
351 }
352 if (input.len == 0) {
353 self.typing_last_sent = 0;
354 try self.client.print("@+typing=done TAGMSG {s}\r\n", .{self.name});
355 return;
356 }
357 const now: u32 = @intCast(std.time.timestamp());
358 // Send another typing message if it's been more than 3 seconds
359 if (self.typing_last_sent + 3 < now) {
360 try self.client.print("@+typing=active TAGMSG {s}\r\n", .{self.name});
361 self.typing_last_sent = now;
362 return;
363 }
364 }
365
366 pub fn deinit(self: *Channel, alloc: std.mem.Allocator) void {
367 alloc.free(self.name);
368 self.members.deinit();
369 if (self.topic) |topic| {
370 alloc.free(topic);
371 }
372 for (self.messages.items) |msg| {
373 alloc.free(msg.bytes);
374 }
375 self.messages.deinit();
376 self.text_field.deinit();
377 self.completer.deinit();
378 }
379
380 pub fn compare(_: void, lhs: *Channel, rhs: *Channel) bool {
381 return std.ascii.orderIgnoreCase(lhs.name, rhs.name).compare(std.math.CompareOperator.lt);
382 }
383
384 pub fn compareRecentMessages(self: *Channel, lhs: Member, rhs: Member) bool {
385 var l: u32 = 0;
386 var r: u32 = 0;
387 var iter = std.mem.reverseIterator(self.messages.items);
388 while (iter.next()) |msg| {
389 if (msg.source()) |source| {
390 const bang = std.mem.indexOfScalar(u8, source, '!') orelse source.len;
391 const nick = source[0..bang];
392
393 if (l == 0 and std.mem.eql(u8, lhs.user.nick, nick)) {
394 l = msg.timestamp_s;
395 } else if (r == 0 and std.mem.eql(u8, rhs.user.nick, nick))
396 r = msg.timestamp_s;
397 }
398 if (l > 0 and r > 0) break;
399 }
400 return l < r;
401 }
402
403 pub fn nameWidget(self: *Channel, selected: bool) vxfw.Widget {
404 return .{
405 .userdata = self,
406 .eventHandler = Channel.typeErasedEventHandler,
407 .drawFn = if (selected)
408 Channel.typeErasedDrawNameSelected
409 else
410 Channel.typeErasedDrawName,
411 };
412 }
413
414 pub fn doSelect(self: *Channel) void {
415 // Set the state of the last_read_indicator
416 self.last_read_indicator = self.last_read;
417 if (self.has_unread) {
418 self.scroll_to_last_read = true;
419 }
420 }
421
422 fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
423 const self: *Channel = @ptrCast(@alignCast(ptr));
424 switch (event) {
425 .mouse => |mouse| {
426 try ctx.setMouseShape(.pointer);
427 if (mouse.type == .press and mouse.button == .left) {
428 self.client.app.selectBuffer(.{ .channel = self });
429 try ctx.requestFocus(self.text_field.widget());
430 const buf = &self.client.app.title_buf;
431 const suffix = " - comlink";
432 if (self.name.len + suffix.len <= buf.len) {
433 const title = try std.fmt.bufPrint(buf, "{s}{s}", .{ self.name, suffix });
434 try ctx.setTitle(title);
435 } else {
436 const title = try std.fmt.bufPrint(
437 buf,
438 "{s}{s}",
439 .{ self.name[0 .. buf.len - suffix.len], suffix },
440 );
441 try ctx.setTitle(title);
442 }
443 return ctx.consumeAndRedraw();
444 }
445 },
446 .mouse_enter => {
447 try ctx.setMouseShape(.pointer);
448 self.has_mouse = true;
449 },
450 .mouse_leave => {
451 try ctx.setMouseShape(.default);
452 self.has_mouse = false;
453 },
454 else => {},
455 }
456 }
457
458 pub fn drawName(self: *Channel, ctx: vxfw.DrawContext, selected: bool) Allocator.Error!vxfw.Surface {
459 var style: vaxis.Style = .{};
460 if (selected) style.bg = .{ .index = 8 };
461 if (self.has_mouse) style.bg = .{ .index = 8 };
462 if (self.has_unread) {
463 style.fg = .{ .index = 4 };
464 style.bold = true;
465 }
466 const prefix: vxfw.RichText.TextSpan = if (self.has_unread_highlight)
467 .{ .text = " ●︎", .style = .{ .fg = .{ .index = 1 } } }
468 else
469 .{ .text = " " };
470 const text: vxfw.RichText = if (std.mem.startsWith(u8, self.name, "#"))
471 .{
472 .text = &.{
473 prefix,
474 .{ .text = " ", .style = .{ .fg = .{ .index = 8 } } },
475 .{ .text = self.name[1..], .style = style },
476 },
477 .softwrap = false,
478 }
479 else
480 .{
481 .text = &.{
482 prefix,
483 .{ .text = " " },
484 .{ .text = self.name, .style = style },
485 },
486 .softwrap = false,
487 };
488
489 var surface = try text.draw(ctx);
490 // Replace the widget reference so we can handle the events
491 surface.widget = self.nameWidget(selected);
492 return surface;
493 }
494
495 fn typeErasedDrawName(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
496 const self: *Channel = @ptrCast(@alignCast(ptr));
497 return self.drawName(ctx, false);
498 }
499
500 fn typeErasedDrawNameSelected(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
501 const self: *Channel = @ptrCast(@alignCast(ptr));
502 return self.drawName(ctx, true);
503 }
504
505 pub fn sortMembers(self: *Channel) void {
506 std.sort.insertion(Member, self.members.items, {}, Member.compare);
507 }
508
509 pub fn addMember(self: *Channel, user: *User, args: struct {
510 prefix: ?u8 = null,
511 sort: bool = true,
512 }) Allocator.Error!void {
513 for (self.members.items) |*member| {
514 if (user == member.user) {
515 // Update the prefix for an existing member if the prefix is
516 // known
517 if (args.prefix) |p| member.prefix = p;
518 return;
519 }
520 }
521
522 try self.members.append(.{
523 .user = user,
524 .prefix = args.prefix orelse ' ',
525 .channel = self,
526 });
527
528 if (args.sort) {
529 self.sortMembers();
530 }
531 }
532
533 pub fn removeMember(self: *Channel, user: *User) void {
534 for (self.members.items, 0..) |member, i| {
535 if (user == member.user) {
536 _ = self.members.orderedRemove(i);
537 return;
538 }
539 }
540 }
541
542 /// issue a MARKREAD command for this channel. The most recent message in the channel will be used as
543 /// the last read time
544 pub fn markRead(self: *Channel) Allocator.Error!void {
545 self.has_unread = false;
546 self.has_unread_highlight = false;
547 if (self.client.caps.@"draft/read-marker") {
548 const last_msg = self.messages.getLastOrNull() orelse return;
549 if (last_msg.timestamp_s > self.last_read) {
550 const time_tag = last_msg.getTag("time") orelse return;
551 try self.client.print(
552 "MARKREAD {s} timestamp={s}\r\n",
553 .{
554 self.name,
555 time_tag,
556 },
557 );
558 }
559 } else self.last_read = @intCast(std.time.timestamp());
560 }
561
562 pub fn contentWidget(self: *Channel) vxfw.Widget {
563 return .{
564 .userdata = self,
565 .captureHandler = Channel.captureEvent,
566 .drawFn = Channel.typeErasedViewDraw,
567 };
568 }
569
570 fn captureEvent(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
571 const self: *Channel = @ptrCast(@alignCast(ptr));
572 switch (event) {
573 .key_press => |key| {
574 if (key.matches(vaxis.Key.tab, .{})) {
575 ctx.redraw = true;
576 // if we already have a completion word, then we are
577 // cycling through the options
578 if (self.completer_shown) {
579 const line = self.completer.next(ctx);
580 self.text_field.clearRetainingCapacity();
581 try self.text_field.insertSliceAtCursor(line);
582 } else {
583 var completion_buf: [maximum_message_size]u8 = undefined;
584 const content = self.text_field.sliceToCursor(&completion_buf);
585 try self.completer.reset(content);
586 if (self.completer.kind == .nick) {
587 try self.completer.findMatches(self);
588 }
589 self.completer_shown = true;
590 }
591 return;
592 }
593 if (key.matches(vaxis.Key.tab, .{ .shift = true })) {
594 if (self.completer_shown) {
595 const line = self.completer.prev(ctx);
596 self.text_field.clearRetainingCapacity();
597 try self.text_field.insertSliceAtCursor(line);
598 }
599 return;
600 }
601 if (key.matches(vaxis.Key.page_up, .{})) {
602 self.scroll.pending += self.client.app.last_height / 2;
603 self.animation_end_ms = @intCast(std.time.milliTimestamp() + 200);
604 try self.doScroll(ctx);
605 return ctx.consumeAndRedraw();
606 }
607 if (key.matches(vaxis.Key.page_down, .{})) {
608 self.animation_end_ms = @intCast(std.time.milliTimestamp() + 200);
609 self.scroll.pending -|= self.client.app.last_height / 2;
610 try self.doScroll(ctx);
611 return ctx.consumeAndRedraw();
612 }
613 if (key.matches(vaxis.Key.home, .{})) {
614 self.animation_end_ms = @intCast(std.time.milliTimestamp() + 200);
615 self.scroll.pending -= self.scroll.offset;
616 self.scroll.msg_offset = null;
617 try self.doScroll(ctx);
618 return ctx.consumeAndRedraw();
619 }
620 if (!key.isModifier()) {
621 self.completer_shown = false;
622 }
623 },
624 else => {},
625 }
626 }
627
628 fn typeErasedViewDraw(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
629 const self: *Channel = @ptrCast(@alignCast(ptr));
630 if (!self.who_requested) {
631 try self.client.whox(self);
632 }
633
634 const max = ctx.max.size();
635 var children = std.ArrayList(vxfw.SubSurface).init(ctx.arena);
636
637 {
638 const spans = try formatMessage(ctx.arena, undefined, self.topic orelse "");
639 // Draw the topic
640 const topic: vxfw.RichText = .{
641 .text = spans,
642 .softwrap = false,
643 };
644
645 const topic_sub: vxfw.SubSurface = .{
646 .origin = .{ .col = 0, .row = 0 },
647 .surface = try topic.draw(ctx),
648 };
649
650 try children.append(topic_sub);
651
652 // Draw a border below the topic
653 const bot = "─";
654 var writer = try std.ArrayList(u8).initCapacity(ctx.arena, bot.len * max.width);
655 try writer.writer().writeBytesNTimes(bot, max.width);
656
657 const border: vxfw.Text = .{
658 .text = writer.items,
659 .softwrap = false,
660 };
661
662 const topic_border: vxfw.SubSurface = .{
663 .origin = .{ .col = 0, .row = 1 },
664 .surface = try border.draw(ctx),
665 };
666 try children.append(topic_border);
667 }
668
669 const msg_view_ctx = ctx.withConstraints(.{ .height = 0, .width = 0 }, .{
670 .height = max.height - 4,
671 .width = max.width - 1,
672 });
673 const message_view = try self.drawMessageView(msg_view_ctx);
674 try children.append(.{
675 .origin = .{ .row = 2, .col = 0 },
676 .surface = message_view,
677 });
678
679 const scrollbar_ctx = ctx.withConstraints(
680 ctx.min,
681 .{ .width = 1, .height = max.height - 4 },
682 );
683
684 var scrollbars: Scrollbar = .{
685 // Estimate number of lines per message
686 .total = @intCast(self.messages.items.len * 3),
687 .view_size = max.height - 4,
688 .bottom = self.scroll.offset,
689 };
690 const scrollbar_surface = try scrollbars.draw(scrollbar_ctx);
691 try children.append(.{
692 .origin = .{ .col = max.width - 1, .row = 2 },
693 .surface = scrollbar_surface,
694 });
695
696 // Draw typers
697 typing: {
698 var buf: [3]*User = undefined;
699 const typers = self.getTypers(&buf);
700
701 const typer_style: vaxis.Style = .{ .fg = self.client.app.blendBg(50) };
702
703 switch (typers.len) {
704 0 => break :typing,
705 1 => {
706 const text = try std.fmt.allocPrint(
707 ctx.arena,
708 "{s} is typing...",
709 .{typers[0].nick},
710 );
711 const typer: vxfw.Text = .{ .text = text, .style = typer_style };
712 const typer_ctx = ctx.withConstraints(.{}, ctx.max);
713 try children.append(.{
714 .origin = .{ .col = 0, .row = max.height - 2 },
715 .surface = try typer.draw(typer_ctx),
716 });
717 },
718 2 => {
719 const text = try std.fmt.allocPrint(
720 ctx.arena,
721 "{s} and {s} are typing...",
722 .{ typers[0].nick, typers[1].nick },
723 );
724 const typer: vxfw.Text = .{ .text = text, .style = typer_style };
725 const typer_ctx = ctx.withConstraints(.{}, ctx.max);
726 try children.append(.{
727 .origin = .{ .col = 0, .row = max.height - 2 },
728 .surface = try typer.draw(typer_ctx),
729 });
730 },
731 else => {
732 const text = "Several people are typing...";
733 const typer: vxfw.Text = .{ .text = text, .style = typer_style };
734 const typer_ctx = ctx.withConstraints(.{}, ctx.max);
735 try children.append(.{
736 .origin = .{ .col = 0, .row = max.height - 2 },
737 .surface = try typer.draw(typer_ctx),
738 });
739 },
740 }
741 }
742
743 {
744 // Draw the character limit. 14 is length of message overhead "PRIVMSG :\r\n"
745 const max_limit = maximum_message_size -| self.name.len -| 14 -| self.name.len;
746 const limit = try std.fmt.allocPrint(
747 ctx.arena,
748 " {d}/{d}",
749 .{ self.text_field.buf.realLength(), max_limit },
750 );
751 const style: vaxis.Style = if (self.text_field.buf.realLength() > max_limit)
752 .{ .fg = .{ .index = 1 }, .reverse = true }
753 else
754 .{ .bg = self.client.app.blendBg(30) };
755 const limit_text: vxfw.Text = .{ .text = limit, .style = style };
756 const limit_ctx = ctx.withConstraints(.{ .width = @intCast(limit.len) }, ctx.max);
757 const limit_s = try limit_text.draw(limit_ctx);
758
759 try children.append(.{
760 .origin = .{ .col = max.width -| limit_s.size.width, .row = max.height - 1 },
761 .surface = limit_s,
762 });
763
764 const text_field_ctx = ctx.withConstraints(
765 ctx.min,
766 .{ .height = 1, .width = max.width -| limit_s.size.width },
767 );
768
769 // Draw the text field
770 try children.append(.{
771 .origin = .{ .col = 0, .row = max.height - 1 },
772 .surface = try self.text_field.draw(text_field_ctx),
773 });
774 // Write some placeholder text if we don't have anything in the text field
775 if (self.text_field.buf.realLength() == 0) {
776 const text = try std.fmt.allocPrint(ctx.arena, "Message {s}", .{self.name});
777 var text_style = self.text_field.style;
778 text_style.italic = true;
779 text_style.dim = true;
780 var ghost_text_ctx = text_field_ctx;
781 ghost_text_ctx.max.width = text_field_ctx.max.width.? -| 2;
782 const ghost_text: vxfw.Text = .{ .text = text, .style = text_style };
783 try children.append(.{
784 .origin = .{ .col = 2, .row = max.height - 1 },
785 .surface = try ghost_text.draw(ghost_text_ctx),
786 });
787 }
788 }
789
790 if (self.completer_shown) {
791 const widest: u16 = @intCast(self.completer.widestMatch(ctx));
792 const height: u16 = @intCast(@min(10, self.completer.options.items.len));
793 const completer_ctx = ctx.withConstraints(ctx.min, .{ .height = height, .width = widest + 2 });
794 const surface = try self.completer.list_view.draw(completer_ctx);
795 try children.append(.{
796 .origin = .{ .col = 0, .row = max.height -| 1 -| height },
797 .surface = surface,
798 });
799 }
800
801 return .{
802 .size = max,
803 .widget = self.contentWidget(),
804 .buffer = &.{},
805 .children = children.items,
806 };
807 }
808
809 fn handleMessageViewEvent(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
810 const self: *Channel = @ptrCast(@alignCast(ptr));
811 switch (event) {
812 .mouse => |mouse| {
813 if (self.message_view.mouse) |last_mouse| {
814 // We need to redraw if the column entered the gutter
815 if (last_mouse.col >= gutter_width and mouse.col < gutter_width)
816 ctx.redraw = true
817 // Or if the column exited the gutter
818 else if (last_mouse.col < gutter_width and mouse.col >= gutter_width)
819 ctx.redraw = true
820 // Or if the row changed
821 else if (last_mouse.row != mouse.row)
822 ctx.redraw = true
823 // Or if we did a middle click, and now released it
824 else if (last_mouse.button == .middle)
825 ctx.redraw = true;
826 } else {
827 // If we didn't have the mouse previously, we redraw
828 ctx.redraw = true;
829 }
830
831 // Save this mouse state for when we draw
832 self.message_view.mouse = mouse;
833
834 // A middle press on a hovered message means we copy the content
835 if (mouse.type == .press and
836 mouse.button == .middle and
837 self.message_view.hovered_message != null)
838 {
839 const msg = self.message_view.hovered_message orelse unreachable;
840 var iter = msg.paramIterator();
841 // Skip the target
842 _ = iter.next() orelse unreachable;
843 // Get the content
844 const content = iter.next() orelse unreachable;
845 try ctx.copyToClipboard(content);
846 return ctx.consumeAndRedraw();
847 }
848 if (mouse.button == .wheel_down) {
849 self.scroll.pending -|= 1;
850 ctx.consume_event = true;
851 }
852 if (mouse.button == .wheel_up) {
853 self.scroll.pending +|= 1;
854 ctx.consume_event = true;
855 }
856 if (self.scroll.pending != 0) {
857 try self.doScroll(ctx);
858 }
859 },
860 .mouse_leave => {
861 self.message_view.mouse = null;
862 self.message_view.hovered_message = null;
863 ctx.redraw = true;
864 },
865 .tick => {
866 try self.doScroll(ctx);
867 },
868 else => {},
869 }
870 }
871
872 /// Consumes any pending scrolls and schedules another tick if needed
873 fn doScroll(self: *Channel, ctx: *vxfw.EventContext) anyerror!void {
874 defer {
875 // At the end of this function, we anchor our msg_offset if we have any amount of
876 // scroll. This prevents new messages from automatically scrolling us
877 if (self.scroll.offset > 0 and self.scroll.msg_offset == null) {
878 self.scroll.msg_offset = @intCast(self.messages.items.len);
879 }
880 // If we have no offset, we reset our anchor
881 if (self.scroll.offset == 0) {
882 self.scroll.msg_offset = null;
883 }
884 }
885 // No pending scroll. Return early
886 if (self.scroll.pending == 0) return;
887
888 const animation_tick: u32 = 8;
889 const now_ms: u64 = @intCast(std.time.milliTimestamp());
890
891 // Scroll up
892 if (self.scroll.pending > 0) {
893 // Check if we can scroll up. If we can't, we are done
894 if (!self.can_scroll_up) {
895 self.scroll.pending = 0;
896 return;
897 }
898
899 // At this point, we always redraw
900 ctx.redraw = true;
901
902 // If we are past the end of the animation, or on the last tick, consume the rest of the
903 // pending scroll
904 if (self.animation_end_ms <= now_ms) {
905 self.scroll.offset += @intCast(self.scroll.pending);
906 self.scroll.pending = 0;
907 return;
908 }
909
910 // Calculate the amount to scroll this tick. We use 8ms ticks.
911 // Total time = end_ms - now_ms
912 // Lines / ms = self.scroll.pending / total time
913 // Lines this tick = 8 ms * lines / ms
914 // All together: (8 ms * self.scroll.pending ) / (end_ms - now_ms)
915 const delta_scroll = (@as(u64, animation_tick) * @as(u64, @intCast(self.scroll.pending))) /
916 (self.animation_end_ms - now_ms);
917
918 // Ensure we always scroll at least one line
919 const resolved_scroll = @max(1, delta_scroll);
920
921 // Consume 1 line, and schedule a tick
922 self.scroll.offset += @intCast(resolved_scroll);
923 self.scroll.pending -|= @intCast(resolved_scroll);
924 ctx.redraw = true;
925 return ctx.tick(animation_tick, self.messageViewWidget());
926 }
927
928 // From here, we only scroll down. First, we check if we are at the bottom already. If we
929 // are, we have nothing to do
930 if (self.scroll.offset == 0) {
931 // Already at bottom. Nothing to do
932 self.scroll.pending = 0;
933 return;
934 }
935
936 // Scroll down
937 if (self.scroll.pending < 0) {
938 const pending: u16 = @intCast(@abs(self.scroll.pending));
939
940 // At this point, we always redraw
941 ctx.redraw = true;
942
943 // If we are past the end of the animation, or on the last tick, consume the rest of the
944 // pending scroll
945 if (self.animation_end_ms <= now_ms) {
946 self.scroll.offset -|= pending;
947 self.scroll.pending = 0;
948 return;
949 }
950
951 // Calculate the amount to scroll this tick. We use 8ms ticks.
952 // Total time = end_ms - now_ms
953 // Lines / ms = self.scroll.pending / total time
954 // Lines this tick = 8 ms * lines / ms
955 // All together: (8 ms * self.scroll.pending ) / (end_ms - now_ms)
956 const delta_scroll = (@as(u64, animation_tick) * @as(u64, @intCast(pending))) /
957 (self.animation_end_ms - now_ms);
958
959 // Ensure we always scroll at least one line
960 const resolved_scroll = @max(1, delta_scroll);
961 self.scroll.offset -|= @intCast(resolved_scroll);
962 self.scroll.pending += @intCast(resolved_scroll);
963 ctx.redraw = true;
964 return ctx.tick(animation_tick, self.messageViewWidget());
965 }
966 }
967
968 fn messageViewWidget(self: *Channel) vxfw.Widget {
969 return .{
970 .userdata = self,
971 .eventHandler = Channel.handleMessageViewEvent,
972 .drawFn = Channel.typeErasedDrawMessageView,
973 };
974 }
975
976 fn typeErasedDrawMessageView(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
977 const self: *Channel = @ptrCast(@alignCast(ptr));
978 return self.drawMessageView(ctx);
979 }
980
981 pub fn messageViewIsAtBottom(self: *Channel) bool {
982 if (self.scroll.msg_offset) |msg_offset| {
983 return self.scroll.offset == 0 and
984 msg_offset == self.messages.items.len and
985 self.scroll.pending == 0;
986 }
987 return self.scroll.offset == 0 and
988 self.scroll.msg_offset == null and
989 self.scroll.pending == 0;
990 }
991
992 fn drawMessageView(self: *Channel, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
993 self.message_view.hovered_message = null;
994 const max = ctx.max.size();
995 if (max.width == 0 or max.height == 0 or self.messages.items.len == 0) {
996 return .{
997 .size = max,
998 .widget = self.messageViewWidget(),
999 .buffer = &.{},
1000 .children = &.{},
1001 };
1002 }
1003
1004 var children = std.ArrayList(vxfw.SubSurface).init(ctx.arena);
1005
1006 // Row is the row we are printing on. We add the offset to achieve our scroll location
1007 var row: i17 = max.height + self.scroll.offset;
1008 // Message offset
1009 const offset = self.scroll.msg_offset orelse self.messages.items.len;
1010
1011 const messages = self.messages.items[0..offset];
1012 var iter = std.mem.reverseIterator(messages);
1013
1014 assert(messages.len > 0);
1015 // Initialize sender and maybe_instant to the last message values
1016 const last_msg = iter.next() orelse unreachable;
1017 // Reset iter index
1018 iter.index += 1;
1019 var sender = last_msg.senderNick() orelse "";
1020 var this_instant = last_msg.localTime(&self.client.app.tz);
1021
1022 // True when we *don't* need to scroll to last message. False if we do. We will turn this
1023 // true when we have it the last message
1024 var did_scroll_to_last_read = !self.scroll_to_last_read;
1025 // We track whether we need to reposition the viewport based on the position of the
1026 // last_read scroll
1027 var needs_reposition = true;
1028 while (iter.next()) |msg| {
1029 if (row >= 0 and did_scroll_to_last_read) {
1030 needs_reposition = false;
1031 }
1032 // Break if we have gone past the top of the screen
1033 if (row < 0 and did_scroll_to_last_read) break;
1034
1035 // Get the sender nickname of the *next* message. Next meaning next message in the
1036 // iterator, which is chronologically the previous message since we are printing in
1037 // reverse
1038 const next_sender: []const u8 = blk: {
1039 const next_msg = iter.next() orelse break :blk "";
1040 // Fix the index of the iterator
1041 iter.index += 1;
1042 break :blk next_msg.senderNick() orelse "";
1043 };
1044
1045 // Get the server time for the *next* message. We'll use this to decide printing of
1046 // username and time
1047 const maybe_next_instant: ?zeit.Instant = blk: {
1048 const next_msg = iter.next() orelse break :blk null;
1049 // Fix the index of the iterator
1050 iter.index += 1;
1051 break :blk next_msg.localTime(&self.client.app.tz);
1052 };
1053
1054 defer {
1055 // After this loop, we want to save these values for the next iteration
1056 if (maybe_next_instant) |next_instant| {
1057 this_instant = next_instant;
1058 }
1059 sender = next_sender;
1060 }
1061
1062 // Message content
1063 const content: []const u8 = blk: {
1064 var param_iter = msg.paramIterator();
1065 // First param is the target, we don't need it
1066 _ = param_iter.next() orelse unreachable;
1067 break :blk param_iter.next() orelse "";
1068 };
1069
1070 // Get the user ref for this sender
1071 const user = try self.client.getOrCreateUser(sender);
1072
1073 const spans = try formatMessage(ctx.arena, user, content);
1074
1075 // Draw the message so we have it's wrapped height
1076 const text: vxfw.RichText = .{ .text = spans };
1077 const child_ctx = ctx.withConstraints(
1078 .{ .width = max.width -| gutter_width, .height = 1 },
1079 .{ .width = max.width -| gutter_width, .height = null },
1080 );
1081 const surface = try text.draw(child_ctx);
1082 // Adjust the row we print on for the wrapped height of this message
1083 row -= surface.size.height;
1084 if (self.client.app.yellow != null and msg.containsPhrase(self.client.nickname())) {
1085 const bg = self.client.app.blendYellow(30);
1086 for (surface.buffer) |*cell| {
1087 if (cell.style.bg != .default) continue;
1088 cell.style.bg = bg;
1089 }
1090 const left_hl = try vxfw.Surface.init(
1091 ctx.arena,
1092 self.messageViewWidget(),
1093 .{ .height = surface.size.height, .width = 1 },
1094 );
1095 const left_hl_cell: vaxis.Cell = .{
1096 .char = .{ .grapheme = "▕", .width = 1 },
1097 .style = .{ .fg = .{ .index = 3 } },
1098 };
1099 @memset(left_hl.buffer, left_hl_cell);
1100 try children.append(.{
1101 .origin = .{ .row = row, .col = gutter_width - 1 },
1102 .surface = left_hl,
1103 });
1104 }
1105
1106 // See if our message contains the mouse. We'll highlight it if it does
1107 const message_has_mouse: bool = blk: {
1108 const mouse = self.message_view.mouse orelse break :blk false;
1109 break :blk mouse.col >= gutter_width and
1110 mouse.row < row + surface.size.height and
1111 mouse.row >= row;
1112 };
1113
1114 if (message_has_mouse) {
1115 const last_mouse = self.message_view.mouse orelse unreachable;
1116 // If we had a middle click, we highlight yellow to indicate we copied the text
1117 const bg: vaxis.Color = if (last_mouse.button == .middle and last_mouse.type == .press)
1118 .{ .index = 3 }
1119 else
1120 .{ .index = 8 };
1121 // Set the style for the entire message
1122 for (surface.buffer) |*cell| {
1123 cell.style.bg = bg;
1124 }
1125 // Create a surface to highlight the entire area under the message
1126 const hl_surface = try vxfw.Surface.init(
1127 ctx.arena,
1128 text.widget(),
1129 .{ .width = max.width -| gutter_width, .height = surface.size.height },
1130 );
1131 const base: vaxis.Cell = .{ .style = .{ .bg = bg } };
1132 @memset(hl_surface.buffer, base);
1133
1134 try children.append(.{
1135 .origin = .{ .row = row, .col = gutter_width },
1136 .surface = hl_surface,
1137 });
1138
1139 self.message_view.hovered_message = msg;
1140 }
1141
1142 try children.append(.{
1143 .origin = .{ .row = row, .col = gutter_width },
1144 .surface = surface,
1145 });
1146
1147 var style: vaxis.Style = .{ .dim = true };
1148
1149 // The time text we will print
1150 const buf: []const u8 = blk: {
1151 const time = this_instant.time();
1152 // Check our next time. If *this* message occurs on a different day, we want to
1153 // print the date
1154 if (maybe_next_instant) |next_instant| {
1155 const next_time = next_instant.time();
1156 if (time.day != next_time.day) {
1157 style = .{};
1158 break :blk try std.fmt.allocPrint(
1159 ctx.arena,
1160 "{d:0>2}/{d:0>2}",
1161 .{ @intFromEnum(time.month), time.day },
1162 );
1163 }
1164 }
1165
1166 // if it is the first message, we also want to print the date
1167 if (iter.index == 0) {
1168 style = .{};
1169 break :blk try std.fmt.allocPrint(
1170 ctx.arena,
1171 "{d:0>2}/{d:0>2}",
1172 .{ @intFromEnum(time.month), time.day },
1173 );
1174 }
1175
1176 // Otherwise, we print clock time
1177 break :blk try std.fmt.allocPrint(
1178 ctx.arena,
1179 "{d:0>2}:{d:0>2}",
1180 .{ time.hour, time.minute },
1181 );
1182 };
1183
1184 // If the message has our nick, we'll highlight the time
1185 if (self.client.app.yellow == null and msg.containsPhrase(self.client.nickname())) {
1186 style.fg = .{ .index = 3 };
1187 style.reverse = true;
1188 }
1189
1190 const time_text: vxfw.Text = .{
1191 .text = buf,
1192 .style = style,
1193 .softwrap = false,
1194 };
1195 const time_ctx = ctx.withConstraints(
1196 .{ .width = 0, .height = 1 },
1197 .{ .width = max.width -| gutter_width, .height = null },
1198 );
1199 try children.append(.{
1200 .origin = .{ .row = row, .col = 0 },
1201 .surface = try time_text.draw(time_ctx),
1202 });
1203
1204 var printed_sender: bool = false;
1205 // Check if we need to print the sender of this message. We do this when the timegap
1206 // between this message and next message is > 5 minutes, or if the sender is
1207 // different
1208 if (sender.len > 0 and
1209 printSender(sender, next_sender, this_instant, maybe_next_instant))
1210 {
1211 // Back up one row to print
1212 row -= 1;
1213 // If we need to print the sender, it will be *this* messages sender
1214 const sender_text: vxfw.Text = .{
1215 .text = user.nick,
1216 .style = .{ .fg = user.color, .bold = true },
1217 };
1218 const sender_ctx = ctx.withConstraints(
1219 .{ .width = 0, .height = 1 },
1220 .{ .width = max.width -| gutter_width, .height = null },
1221 );
1222 const sender_surface = try sender_text.draw(sender_ctx);
1223 try children.append(.{
1224 .origin = .{ .row = row, .col = gutter_width },
1225 .surface = sender_surface,
1226 });
1227 if (self.message_view.mouse) |mouse| {
1228 if (mouse.row == row and
1229 mouse.col >= gutter_width and
1230 user.real_name != null)
1231 {
1232 const realname: vxfw.Text = .{
1233 .text = user.real_name orelse unreachable,
1234 .style = .{ .fg = .{ .index = 8 }, .italic = true },
1235 };
1236 try children.append(.{
1237 .origin = .{
1238 .row = row,
1239 .col = gutter_width + sender_surface.size.width + 1,
1240 },
1241 .surface = try realname.draw(child_ctx),
1242 });
1243 }
1244 }
1245
1246 // Back up 1 more row for spacing
1247 row -= 1;
1248 printed_sender = true;
1249 }
1250
1251 // Check if we should print a "last read" line. If the next message we will print is
1252 // before the last_read, and this message is after the last_read then it is our border.
1253 // Before
1254 const next_instant = maybe_next_instant orelse continue;
1255 const this = this_instant.unixTimestamp();
1256 const next = next_instant.unixTimestamp();
1257
1258 // If this message is before last_read, we did any scroll_to_last_read. Set the flag to
1259 // true
1260 if (this <= self.last_read) did_scroll_to_last_read = true;
1261
1262 if (this > self.last_read_indicator and next <= self.last_read_indicator) {
1263 const bot = "━";
1264 var writer = try std.ArrayList(u8).initCapacity(ctx.arena, bot.len * max.width);
1265 try writer.writer().writeBytesNTimes(bot, max.width);
1266
1267 const border: vxfw.Text = .{
1268 .text = writer.items,
1269 .style = .{ .fg = .{ .index = 1 } },
1270 .softwrap = false,
1271 };
1272
1273 // We don't need to backup a line if we printed the sender
1274 if (!printed_sender) row -= 1;
1275
1276 const unread: vxfw.SubSurface = .{
1277 .origin = .{ .col = 0, .row = row },
1278 .surface = try border.draw(ctx),
1279 };
1280 try children.append(unread);
1281 const new: vxfw.RichText = .{
1282 .text = &.{
1283 .{ .text = "", .style = .{ .fg = .{ .index = 1 } } },
1284 .{ .text = " New ", .style = .{ .fg = .{ .index = 1 }, .reverse = true } },
1285 },
1286 .softwrap = false,
1287 };
1288 const new_sub: vxfw.SubSurface = .{
1289 .origin = .{ .col = max.width - 6, .row = row },
1290 .surface = try new.draw(ctx),
1291 };
1292 try children.append(new_sub);
1293 }
1294 }
1295
1296 // Request more history when we are within 5 messages of the top of the screen
1297 if (iter.index < 5 and !self.at_oldest) {
1298 try self.client.requestHistory(.before, self);
1299 }
1300
1301 // If we scroll_to_last_read, we probably need to reposition all of our children. We also
1302 // check that we have messages, and if we do that the top message is outside the viewport.
1303 // If we don't have messages, or the top message is within the viewport, we don't have to
1304 // reposition
1305 if (needs_reposition and
1306 children.items.len > 0 and
1307 children.getLast().origin.row < 0)
1308 {
1309 // We will adjust the origin of each item so that the last item we added has an origin
1310 // of 0
1311 const adjustment: u16 = @intCast(@abs(children.getLast().origin.row));
1312 for (children.items) |*item| {
1313 item.origin.row += adjustment;
1314 }
1315 // Our scroll offset gets adjusted as well
1316 self.scroll.offset += adjustment;
1317 // We will set the msg offset too to prevent any bumping of the scroll state when we get
1318 // a new message
1319 self.scroll.msg_offset = self.messages.items.len;
1320 }
1321
1322 // Set the can_scroll_up flag. this is true if we drew past the top of the screen
1323 self.can_scroll_up = row <= 0;
1324 if (row > 0) {
1325 // If we didn't draw past the top of the screen, we must have reached the end of
1326 // history. Draw an indicator letting the user know this
1327 const bot = "━";
1328 var writer = try std.ArrayList(u8).initCapacity(ctx.arena, bot.len * max.width);
1329 try writer.writer().writeBytesNTimes(bot, max.width);
1330
1331 const border: vxfw.Text = .{
1332 .text = writer.items,
1333 .style = .{ .fg = .{ .index = 8 } },
1334 .softwrap = false,
1335 };
1336
1337 const unread: vxfw.SubSurface = .{
1338 .origin = .{ .col = 0, .row = row },
1339 .surface = try border.draw(ctx),
1340 };
1341 try children.append(unread);
1342 const no_more_history: vxfw.Text = .{
1343 .text = " Perhaps the archives are incomplete ",
1344 .style = .{ .fg = .{ .index = 8 } },
1345 .softwrap = false,
1346 };
1347 const no_history_surf = try no_more_history.draw(ctx);
1348 const new_sub: vxfw.SubSurface = .{
1349 .origin = .{ .col = (max.width -| no_history_surf.size.width) / 2, .row = row },
1350 .surface = no_history_surf,
1351 };
1352 try children.append(new_sub);
1353 }
1354
1355 if (did_scroll_to_last_read) {
1356 self.scroll_to_last_read = false;
1357 }
1358
1359 if (self.has_unread and
1360 self.client.app.has_focus and
1361 self.messageViewIsAtBottom())
1362 {
1363 try self.markRead();
1364 }
1365
1366 return .{
1367 .size = max,
1368 .widget = self.messageViewWidget(),
1369 .buffer = &.{},
1370 .children = children.items,
1371 };
1372 }
1373
1374 fn buildMemberList(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget {
1375 const self: *const Channel = @ptrCast(@alignCast(ptr));
1376 if (idx < self.members.items.len) {
1377 return self.members.items[idx].widget();
1378 }
1379 return null;
1380 }
1381
1382 // Helper function which tells us if we should print the sender of a message, based on he
1383 // current message sender and time, and the (chronologically) previous message sent
1384 fn printSender(
1385 a_sender: []const u8,
1386 b_sender: []const u8,
1387 a_instant: ?zeit.Instant,
1388 b_instant: ?zeit.Instant,
1389 ) bool {
1390 // If sender is different, we always print the sender
1391 if (!std.mem.eql(u8, a_sender, b_sender)) return true;
1392
1393 if (a_instant != null and b_instant != null) {
1394 const a_ts = a_instant.?.timestamp;
1395 const b_ts = b_instant.?.timestamp;
1396 const delta: i64 = @intCast(a_ts - b_ts);
1397 return @abs(delta) > (5 * std.time.ns_per_min);
1398 }
1399
1400 // In any other case, we
1401 return false;
1402 }
1403
1404 fn getTypers(self: *Channel, buf: []*User) []*User {
1405 const now: u32 = @intCast(std.time.timestamp());
1406 var i: usize = 0;
1407 for (self.members.items) |member| {
1408 if (i == buf.len) {
1409 return buf[0..i];
1410 }
1411 // The spec says we should consider people as typing if the last typing message was
1412 // received within 6 seconds from now
1413 if (member.typing + 6 >= now) {
1414 buf[i] = member.user;
1415 i += 1;
1416 }
1417 }
1418 return buf[0..i];
1419 }
1420
1421 fn typingCount(self: *Channel) usize {
1422 const now: u32 = @intCast(std.time.timestamp());
1423
1424 var n: usize = 0;
1425 for (self.members.items) |member| {
1426 // The spec says we should consider people as typing if the last typing message was
1427 // received within 6 seconds from now
1428 if (member.typing + 6 >= now) {
1429 n += 1;
1430 }
1431 }
1432 return n;
1433 }
1434};
1435
1436pub const User = struct {
1437 nick: []const u8,
1438 away: bool = false,
1439 color: vaxis.Color = .default,
1440 real_name: ?[]const u8 = null,
1441
1442 pub fn deinit(self: *const User, alloc: std.mem.Allocator) void {
1443 alloc.free(self.nick);
1444 if (self.real_name) |realname| alloc.free(realname);
1445 }
1446};
1447
1448/// an irc message
1449pub const Message = struct {
1450 bytes: []const u8,
1451 timestamp_s: u32 = 0,
1452
1453 pub fn init(bytes: []const u8) Message {
1454 var msg: Message = .{ .bytes = bytes };
1455 if (msg.getTag("time")) |time_str| {
1456 const inst = zeit.instant(.{ .source = .{ .iso8601 = time_str } }) catch |err| {
1457 log.warn("couldn't parse time: '{s}', error: {}", .{ time_str, err });
1458 msg.timestamp_s = @intCast(std.time.timestamp());
1459 return msg;
1460 };
1461 msg.timestamp_s = @intCast(inst.unixTimestamp());
1462 } else {
1463 msg.timestamp_s = @intCast(std.time.timestamp());
1464 }
1465 return msg;
1466 }
1467
1468 pub fn dupe(self: Message, alloc: std.mem.Allocator) Allocator.Error!Message {
1469 return .{
1470 .bytes = try alloc.dupe(u8, self.bytes),
1471 .timestamp_s = self.timestamp_s,
1472 };
1473 }
1474
1475 pub const ParamIterator = struct {
1476 params: ?[]const u8,
1477 index: usize = 0,
1478
1479 pub fn next(self: *ParamIterator) ?[]const u8 {
1480 const params = self.params orelse return null;
1481 if (self.index >= params.len) return null;
1482
1483 // consume leading whitespace
1484 while (self.index < params.len) {
1485 if (params[self.index] != ' ') break;
1486 self.index += 1;
1487 }
1488
1489 const start = self.index;
1490 if (start >= params.len) return null;
1491
1492 // If our first byte is a ':', we return the rest of the string as a
1493 // single param (or the empty string)
1494 if (params[start] == ':') {
1495 self.index = params.len;
1496 if (start == params.len - 1) {
1497 return "";
1498 }
1499 return params[start + 1 ..];
1500 }
1501
1502 // Find the first index of space. If we don't have any, the reset of
1503 // the line is the last param
1504 self.index = std.mem.indexOfScalarPos(u8, params, self.index, ' ') orelse {
1505 defer self.index = params.len;
1506 return params[start..];
1507 };
1508
1509 return params[start..self.index];
1510 }
1511 };
1512
1513 pub const Tag = struct {
1514 key: []const u8,
1515 value: []const u8,
1516 };
1517
1518 pub const TagIterator = struct {
1519 tags: []const u8,
1520 index: usize = 0,
1521
1522 // tags are a list of key=value pairs delimited by semicolons.
1523 // key[=value] [; key[=value]]
1524 pub fn next(self: *TagIterator) ?Tag {
1525 if (self.index >= self.tags.len) return null;
1526
1527 // find next delimiter
1528 const end = std.mem.indexOfScalarPos(u8, self.tags, self.index, ';') orelse self.tags.len;
1529 var kv_delim = std.mem.indexOfScalarPos(u8, self.tags, self.index, '=') orelse end;
1530 // it's possible to have tags like this:
1531 // @bot;account=botaccount;+typing=active
1532 // where the first tag doesn't have a value. Guard against the
1533 // kv_delim being past the end position
1534 if (kv_delim > end) kv_delim = end;
1535
1536 defer self.index = end + 1;
1537
1538 return .{
1539 .key = self.tags[self.index..kv_delim],
1540 .value = if (end == kv_delim) "" else self.tags[kv_delim + 1 .. end],
1541 };
1542 }
1543 };
1544
1545 pub fn tagIterator(msg: Message) TagIterator {
1546 const src = msg.bytes;
1547 if (src[0] != '@') return .{ .tags = "" };
1548
1549 assert(src.len > 1);
1550 const n = std.mem.indexOfScalarPos(u8, src, 1, ' ') orelse src.len;
1551 return .{ .tags = src[1..n] };
1552 }
1553
1554 pub fn source(msg: Message) ?[]const u8 {
1555 const src = msg.bytes;
1556 var i: usize = 0;
1557
1558 // get past tags
1559 if (src[0] == '@') {
1560 assert(src.len > 1);
1561 i = std.mem.indexOfScalarPos(u8, src, 1, ' ') orelse return null;
1562 }
1563
1564 // consume whitespace
1565 while (i < src.len) : (i += 1) {
1566 if (src[i] != ' ') break;
1567 }
1568
1569 // Start of source
1570 if (src[i] == ':') {
1571 assert(src.len > i);
1572 i += 1;
1573 const end = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse src.len;
1574 return src[i..end];
1575 }
1576
1577 return null;
1578 }
1579
1580 pub fn command(msg: Message) Command {
1581 const src = msg.bytes;
1582 var i: usize = 0;
1583
1584 // get past tags
1585 if (src[0] == '@') {
1586 assert(src.len > 1);
1587 i = std.mem.indexOfScalarPos(u8, src, 1, ' ') orelse return .unknown;
1588 }
1589 // consume whitespace
1590 while (i < src.len) : (i += 1) {
1591 if (src[i] != ' ') break;
1592 }
1593
1594 // get past source
1595 if (src[i] == ':') {
1596 assert(src.len > i);
1597 i += 1;
1598 i = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse return .unknown;
1599 }
1600 // consume whitespace
1601 while (i < src.len) : (i += 1) {
1602 if (src[i] != ' ') break;
1603 }
1604
1605 assert(src.len > i);
1606 // Find next space
1607 const end = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse src.len;
1608 return Command.parse(src[i..end]);
1609 }
1610
1611 pub fn containsPhrase(self: Message, phrase: []const u8) bool {
1612 switch (self.command()) {
1613 .PRIVMSG, .NOTICE => {},
1614 else => return false,
1615 }
1616 var iter = self.paramIterator();
1617 // We only handle PRIVMSG and NOTICE which have syntax <target> :<content>. Skip the target
1618 _ = iter.next() orelse return false;
1619
1620 const content = iter.next() orelse return false;
1621 return std.mem.indexOf(u8, content, phrase) != null;
1622 }
1623
1624 pub fn paramIterator(msg: Message) ParamIterator {
1625 const src = msg.bytes;
1626 var i: usize = 0;
1627
1628 // get past tags
1629 if (src[0] == '@') {
1630 i = std.mem.indexOfScalarPos(u8, src, 0, ' ') orelse return .{ .params = "" };
1631 }
1632 // consume whitespace
1633 while (i < src.len) : (i += 1) {
1634 if (src[i] != ' ') break;
1635 }
1636
1637 // get past source
1638 if (src[i] == ':') {
1639 assert(src.len > i);
1640 i += 1;
1641 i = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse return .{ .params = "" };
1642 }
1643 // consume whitespace
1644 while (i < src.len) : (i += 1) {
1645 if (src[i] != ' ') break;
1646 }
1647
1648 // get past command
1649 i = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse return .{ .params = "" };
1650
1651 assert(src.len > i);
1652 return .{ .params = src[i + 1 ..] };
1653 }
1654
1655 /// Returns the value of the tag 'key', if present
1656 pub fn getTag(self: Message, key: []const u8) ?[]const u8 {
1657 var tag_iter = self.tagIterator();
1658 while (tag_iter.next()) |tag| {
1659 if (!std.mem.eql(u8, tag.key, key)) continue;
1660 return tag.value;
1661 }
1662 return null;
1663 }
1664
1665 pub fn time(self: Message) zeit.Instant {
1666 return zeit.instant(.{
1667 .source = .{ .unix_timestamp = self.timestamp_s },
1668 }) catch unreachable;
1669 }
1670
1671 pub fn localTime(self: Message, tz: *const zeit.TimeZone) zeit.Instant {
1672 const utc = self.time();
1673 return utc.in(tz);
1674 }
1675
1676 pub fn compareTime(_: void, lhs: Message, rhs: Message) bool {
1677 return lhs.timestamp_s < rhs.timestamp_s;
1678 }
1679
1680 /// Returns the NICK of the sender of the message
1681 pub fn senderNick(self: Message) ?[]const u8 {
1682 const src = self.source() orelse return null;
1683 if (std.mem.indexOfScalar(u8, src, '!')) |idx| return src[0..idx];
1684 if (std.mem.indexOfScalar(u8, src, '@')) |idx| return src[0..idx];
1685 return src;
1686 }
1687};
1688
1689pub const Client = struct {
1690 pub const Config = struct {
1691 user: []const u8,
1692 nick: []const u8,
1693 password: []const u8,
1694 real_name: []const u8,
1695 server: []const u8,
1696 port: ?u16,
1697 network_id: ?[]const u8 = null,
1698 network_nick: ?[]const u8 = null,
1699 name: ?[]const u8 = null,
1700 tls: bool = true,
1701 lua_table: i32,
1702 };
1703
1704 pub const Capabilities = struct {
1705 @"away-notify": bool = false,
1706 batch: bool = false,
1707 @"echo-message": bool = false,
1708 @"message-tags": bool = false,
1709 sasl: bool = false,
1710 @"server-time": bool = false,
1711
1712 @"draft/chathistory": bool = false,
1713 @"draft/no-implicit-names": bool = false,
1714 @"draft/read-marker": bool = false,
1715
1716 @"soju.im/bouncer-networks": bool = false,
1717 @"soju.im/bouncer-networks-notify": bool = false,
1718 };
1719
1720 /// ISupport are features only advertised via ISUPPORT that we care about
1721 pub const ISupport = struct {
1722 whox: bool = false,
1723 prefix: []const u8 = "",
1724 };
1725
1726 pub const Status = enum(u8) {
1727 disconnected,
1728 connecting,
1729 connected,
1730 };
1731
1732 alloc: std.mem.Allocator,
1733 app: *comlink.App,
1734 client: tls.Connection(std.net.Stream),
1735 stream: std.net.Stream,
1736 config: Config,
1737
1738 channels: std.ArrayList(*Channel),
1739 users: std.StringHashMap(*User),
1740
1741 status: std.atomic.Value(Status),
1742
1743 caps: Capabilities = .{},
1744 supports: ISupport = .{},
1745
1746 batches: std.StringHashMap(*Channel),
1747 write_queue: *comlink.WriteQueue,
1748
1749 thread: ?std.Thread = null,
1750
1751 redraw: std.atomic.Value(bool),
1752 read_buf_mutex: std.Thread.Mutex,
1753 read_buf: std.ArrayList(u8),
1754
1755 has_mouse: bool,
1756 retry_delay_s: u8,
1757
1758 text_field: vxfw.TextField,
1759 completer_shown: bool,
1760
1761 list_modal: ListModal,
1762 messages: std.ArrayListUnmanaged(Message),
1763 scroll: struct {
1764 /// Line offset from the bottom message
1765 offset: u16 = 0,
1766 /// Message offset into the list of messages. We use this to lock the viewport if we have a
1767 /// scroll. Otherwise, when offset == 0 this is effectively ignored (and should be 0)
1768 msg_offset: ?usize = null,
1769
1770 /// Pending scroll we have to handle while drawing. This could be up or down. By convention
1771 /// we say positive is a scroll up.
1772 pending: i17 = 0,
1773 } = .{},
1774 can_scroll_up: bool = false,
1775 message_view: struct {
1776 mouse: ?vaxis.Mouse = null,
1777 hovered_message: ?Message = null,
1778 } = .{},
1779
1780 pub fn init(
1781 self: *Client,
1782 alloc: std.mem.Allocator,
1783 app: *comlink.App,
1784 wq: *comlink.WriteQueue,
1785 cfg: Config,
1786 ) !void {
1787 self.* = .{
1788 .alloc = alloc,
1789 .app = app,
1790 .client = undefined,
1791 .stream = undefined,
1792 .config = cfg,
1793 .channels = std.ArrayList(*Channel).init(alloc),
1794 .users = std.StringHashMap(*User).init(alloc),
1795 .batches = std.StringHashMap(*Channel).init(alloc),
1796 .write_queue = wq,
1797 .status = std.atomic.Value(Status).init(.disconnected),
1798 .redraw = std.atomic.Value(bool).init(false),
1799 .read_buf_mutex = .{},
1800 .read_buf = std.ArrayList(u8).init(alloc),
1801 .has_mouse = false,
1802 .retry_delay_s = 0,
1803 .text_field = .init(alloc, app.unicode),
1804 .completer_shown = false,
1805 .list_modal = undefined,
1806 .messages = .empty,
1807 };
1808 self.list_modal.init(alloc, self);
1809 self.text_field.style = .{ .bg = self.app.blendBg(10) };
1810 self.text_field.userdata = self;
1811 self.text_field.onSubmit = Client.onSubmit;
1812 }
1813
1814 fn onSubmit(ptr: ?*anyopaque, ctx: *vxfw.EventContext, input: []const u8) anyerror!void {
1815 // Check the message is not just whitespace
1816 for (input) |b| {
1817 // Break on the first non-whitespace byte
1818 if (!std.ascii.isWhitespace(b)) break;
1819 } else return;
1820
1821 const self: *Client = @ptrCast(@alignCast(ptr orelse unreachable));
1822
1823 // Copy the input into a temporary buffer
1824 var buf: [1024]u8 = undefined;
1825 @memcpy(buf[0..input.len], input);
1826 const local = buf[0..input.len];
1827 // Free the text field. We do this here because the command may destroy our channel
1828 self.text_field.clearAndFree();
1829 self.completer_shown = false;
1830
1831 if (std.mem.startsWith(u8, local, "/")) {
1832 try self.app.handleCommand(.{ .client = self }, local);
1833 }
1834 ctx.redraw = true;
1835 }
1836
1837 /// Closes the connection
1838 pub fn close(self: *Client) void {
1839 if (self.status.load(.acquire) == .disconnected) return;
1840 if (self.config.tls) {
1841 self.client.close() catch {};
1842 }
1843 std.posix.shutdown(self.stream.handle, .both) catch {};
1844 self.stream.close();
1845 }
1846
1847 pub fn deinit(self: *Client) void {
1848 if (self.thread) |thread| {
1849 thread.join();
1850 self.thread = null;
1851 }
1852 // id gets allocated in the main thread. We need to deallocate it here if
1853 // we have one
1854 if (self.config.network_id) |id| self.alloc.free(id);
1855 if (self.config.name) |name| self.alloc.free(name);
1856
1857 if (self.config.network_nick) |nick| self.alloc.free(nick);
1858
1859 for (self.channels.items) |channel| {
1860 channel.deinit(self.alloc);
1861 self.alloc.destroy(channel);
1862 }
1863 self.channels.deinit();
1864
1865 self.list_modal.deinit(self.alloc);
1866 for (self.messages.items) |msg| {
1867 self.alloc.free(msg.bytes);
1868 }
1869 self.messages.deinit(self.alloc);
1870
1871 var user_iter = self.users.valueIterator();
1872 while (user_iter.next()) |user| {
1873 user.*.deinit(self.alloc);
1874 self.alloc.destroy(user.*);
1875 }
1876 self.users.deinit();
1877 self.alloc.free(self.supports.prefix);
1878 var batches = self.batches;
1879 var iter = batches.keyIterator();
1880 while (iter.next()) |key| {
1881 self.alloc.free(key.*);
1882 }
1883 batches.deinit();
1884 self.read_buf.deinit();
1885 }
1886
1887 fn retryWidget(self: *Client) vxfw.Widget {
1888 return .{
1889 .userdata = self,
1890 .eventHandler = Client.retryTickHandler,
1891 .drawFn = Client.typeErasedDrawNameSelected,
1892 };
1893 }
1894
1895 pub fn retryTickHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
1896 const self: *Client = @ptrCast(@alignCast(ptr));
1897 switch (event) {
1898 .tick => {
1899 const status = self.status.load(.acquire);
1900 switch (status) {
1901 .disconnected => {
1902 // Clean up a thread if we have one
1903 if (self.thread) |thread| {
1904 thread.join();
1905 self.thread = null;
1906 }
1907 self.status.store(.connecting, .release);
1908 self.thread = try std.Thread.spawn(.{}, Client.readThread, .{self});
1909 },
1910 .connecting => {},
1911 .connected => {
1912 // Reset the delay
1913 self.retry_delay_s = 0;
1914 return;
1915 },
1916 }
1917 // Increment the retry and try again
1918 self.retry_delay_s = @max(self.retry_delay_s <<| 1, 1);
1919 log.debug("retry in {d} seconds", .{self.retry_delay_s});
1920 try ctx.tick(@as(u32, self.retry_delay_s) * std.time.ms_per_s, self.retryWidget());
1921 },
1922 else => {},
1923 }
1924 }
1925
1926 pub fn view(self: *Client) vxfw.Widget {
1927 return .{
1928 .userdata = self,
1929 .eventHandler = Client.eventHandler,
1930 .drawFn = Client.typeErasedViewDraw,
1931 };
1932 }
1933
1934 fn eventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
1935 _ = ptr;
1936 _ = ctx;
1937 _ = event;
1938 }
1939
1940 fn typeErasedViewDraw(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
1941 const self: *Client = @ptrCast(@alignCast(ptr));
1942 const max = ctx.max.size();
1943
1944 var children = std.ArrayList(vxfw.SubSurface).init(ctx.arena);
1945 {
1946 const message_view_ctx = ctx.withConstraints(ctx.min, .{
1947 .height = max.height - 2,
1948 .width = max.width,
1949 });
1950 const s = try self.drawMessageView(message_view_ctx);
1951 try children.append(.{
1952 .origin = .{ .col = 0, .row = 0 },
1953 .surface = s,
1954 });
1955 }
1956
1957 {
1958 // Draw the character limit. 14 is length of message overhead "PRIVMSG :\r\n"
1959 const max_limit = 510;
1960 const limit = try std.fmt.allocPrint(
1961 ctx.arena,
1962 " {d}/{d}",
1963 .{ self.text_field.buf.realLength(), max_limit },
1964 );
1965 const style: vaxis.Style = if (self.text_field.buf.realLength() > max_limit)
1966 .{ .fg = .{ .index = 1 }, .reverse = true }
1967 else
1968 .{ .bg = self.app.blendBg(30) };
1969 const limit_text: vxfw.Text = .{ .text = limit, .style = style };
1970 const limit_ctx = ctx.withConstraints(.{ .width = @intCast(limit.len) }, ctx.max);
1971 const limit_s = try limit_text.draw(limit_ctx);
1972
1973 try children.append(.{
1974 .origin = .{ .col = max.width -| limit_s.size.width, .row = max.height - 1 },
1975 .surface = limit_s,
1976 });
1977
1978 const text_field_ctx = ctx.withConstraints(
1979 ctx.min,
1980 .{ .height = 1, .width = max.width -| limit_s.size.width },
1981 );
1982
1983 // Draw the text field
1984 try children.append(.{
1985 .origin = .{ .col = 0, .row = max.height - 1 },
1986 .surface = try self.text_field.draw(text_field_ctx),
1987 });
1988 // Write some placeholder text if we don't have anything in the text field
1989 if (self.text_field.buf.realLength() == 0) {
1990 const text = try std.fmt.allocPrint(ctx.arena, "Message {s}", .{self.serverName()});
1991 var text_style = self.text_field.style;
1992 text_style.italic = true;
1993 text_style.dim = true;
1994 var ghost_text_ctx = text_field_ctx;
1995 ghost_text_ctx.max.width = text_field_ctx.max.width.? -| 2;
1996 const ghost_text: vxfw.Text = .{ .text = text, .style = text_style };
1997 try children.append(.{
1998 .origin = .{ .col = 2, .row = max.height - 1 },
1999 .surface = try ghost_text.draw(ghost_text_ctx),
2000 });
2001 }
2002 }
2003 return .{
2004 .widget = self.view(),
2005 .size = max,
2006 .buffer = &.{},
2007 .children = children.items,
2008 };
2009 }
2010
2011 pub fn serverName(self: *Client) []const u8 {
2012 return self.config.name orelse self.config.server;
2013 }
2014
2015 pub fn nameWidget(self: *Client, selected: bool) vxfw.Widget {
2016 return .{
2017 .userdata = self,
2018 .eventHandler = Client.typeErasedEventHandler,
2019 .drawFn = if (selected)
2020 Client.typeErasedDrawNameSelected
2021 else
2022 Client.typeErasedDrawName,
2023 };
2024 }
2025
2026 pub fn drawName(self: *Client, ctx: vxfw.DrawContext, selected: bool) Allocator.Error!vxfw.Surface {
2027 var style: vaxis.Style = .{};
2028 if (selected) style.reverse = true;
2029 if (self.has_mouse) style.bg = .{ .index = 8 };
2030 if (self.status.load(.acquire) == .disconnected) style.fg = .{ .index = 8 };
2031
2032 const name = self.config.name orelse self.config.server;
2033
2034 const text: vxfw.RichText = .{
2035 .text = &.{
2036 .{ .text = name, .style = style },
2037 },
2038 .softwrap = false,
2039 };
2040 var surface = try text.draw(ctx);
2041 // Replace the widget reference so we can handle the events
2042 surface.widget = self.nameWidget(selected);
2043 return surface;
2044 }
2045
2046 fn typeErasedDrawName(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
2047 const self: *Client = @ptrCast(@alignCast(ptr));
2048 return self.drawName(ctx, false);
2049 }
2050
2051 fn typeErasedDrawNameSelected(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
2052 const self: *Client = @ptrCast(@alignCast(ptr));
2053 return self.drawName(ctx, true);
2054 }
2055
2056 fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
2057 const self: *Client = @ptrCast(@alignCast(ptr));
2058 switch (event) {
2059 .mouse => |mouse| {
2060 try ctx.setMouseShape(.pointer);
2061 if (mouse.type == .press and mouse.button == .left) {
2062 self.app.selectBuffer(.{ .client = self });
2063 const buf = &self.app.title_buf;
2064 const suffix = " - comlink";
2065 const name = self.config.name orelse self.config.server;
2066 if (name.len + suffix.len <= buf.len) {
2067 const title = try std.fmt.bufPrint(buf, "{s}{s}", .{ name, suffix });
2068 try ctx.setTitle(title);
2069 } else {
2070 const title = try std.fmt.bufPrint(
2071 buf,
2072 "{s}{s}",
2073 .{ name[0 .. buf.len - suffix.len], suffix },
2074 );
2075 try ctx.setTitle(title);
2076 }
2077 return ctx.consumeAndRedraw();
2078 }
2079 },
2080 .mouse_enter => {
2081 try ctx.setMouseShape(.pointer);
2082 self.has_mouse = true;
2083 },
2084 .mouse_leave => {
2085 try ctx.setMouseShape(.default);
2086 self.has_mouse = false;
2087 },
2088 else => {},
2089 }
2090 }
2091
2092 pub fn drainFifo(self: *Client, ctx: *vxfw.EventContext) void {
2093 self.read_buf_mutex.lock();
2094 defer self.read_buf_mutex.unlock();
2095 var i: usize = 0;
2096 while (std.mem.indexOfPos(u8, self.read_buf.items, i, "\r\n")) |idx| {
2097 defer i = idx + 2;
2098 log.debug("[<-{s}] {s}", .{
2099 self.config.name orelse self.config.server,
2100 self.read_buf.items[i..idx],
2101 });
2102 self.handleEvent(self.read_buf.items[i..idx], ctx) catch |err| {
2103 log.err("error: {}", .{err});
2104 };
2105 }
2106 self.read_buf.replaceRangeAssumeCapacity(0, i, "");
2107 }
2108
2109 // Checks if any channel has an expired typing status. The typing status is considered expired
2110 // if the last typing status received is more than 6 seconds ago. In this case, we set the last
2111 // typing time to 0 and redraw.
2112 pub fn checkTypingStatus(self: *Client, ctx: *vxfw.EventContext) void {
2113 // We only care about typing tags if we have the message-tags cap
2114 if (!self.caps.@"message-tags") return;
2115 const now: u32 = @intCast(std.time.timestamp());
2116 for (self.channels.items) |channel| {
2117 // If the last_active is set, and it is more than 6 seconds ago, we will redraw
2118 if (channel.typing_last_active != 0 and channel.typing_last_active + 6 < now) {
2119 channel.typing_last_active = 0;
2120 ctx.redraw = true;
2121 }
2122 }
2123 }
2124
2125 pub fn handleEvent(self: *Client, line: []const u8, ctx: *vxfw.EventContext) !void {
2126 const msg = Message.init(line);
2127 const client = self;
2128 switch (msg.command()) {
2129 .unknown => {
2130 const msg2 = try msg.dupe(self.alloc);
2131 try self.messages.append(self.alloc, msg2);
2132 },
2133 .PONG => {},
2134 .CAP => {
2135 const msg2 = try msg.dupe(self.alloc);
2136 try self.messages.append(self.alloc, msg2);
2137 // syntax: <client> <ACK/NACK> :caps
2138 var iter = msg.paramIterator();
2139 _ = iter.next() orelse return; // client
2140 const ack_or_nak = iter.next() orelse return;
2141 const caps = iter.next() orelse return;
2142 var cap_iter = mem.splitScalar(u8, caps, ' ');
2143 while (cap_iter.next()) |cap| {
2144 if (mem.eql(u8, ack_or_nak, "ACK")) {
2145 client.ack(cap);
2146 if (mem.eql(u8, cap, "sasl"))
2147 try client.queueWrite("AUTHENTICATE PLAIN\r\n");
2148 } else if (mem.eql(u8, ack_or_nak, "NAK")) {
2149 log.debug("CAP not supported {s}", .{cap});
2150 } else if (mem.eql(u8, ack_or_nak, "DEL")) {
2151 client.del(cap);
2152 }
2153 }
2154 },
2155 .AUTHENTICATE => {
2156 var iter = msg.paramIterator();
2157 while (iter.next()) |param| {
2158 // A '+' is the continuuation to send our
2159 // AUTHENTICATE info
2160 if (!mem.eql(u8, param, "+")) continue;
2161 var buf: [4096]u8 = undefined;
2162 const config = client.config;
2163 const sasl = try std.fmt.bufPrint(
2164 &buf,
2165 "{s}\x00{s}\x00{s}",
2166 .{ config.user, config.user, config.password },
2167 );
2168
2169 // Create a buffer big enough for the base64 encoded string
2170 const b64_buf = try self.alloc.alloc(u8, Base64Encoder.calcSize(sasl.len));
2171 defer self.alloc.free(b64_buf);
2172 const encoded = Base64Encoder.encode(b64_buf, sasl);
2173 // Make our message
2174 const auth = try std.fmt.bufPrint(
2175 &buf,
2176 "AUTHENTICATE {s}\r\n",
2177 .{encoded},
2178 );
2179 try client.queueWrite(auth);
2180 if (config.network_id) |id| {
2181 const bind = try std.fmt.bufPrint(
2182 &buf,
2183 "BOUNCER BIND {s}\r\n",
2184 .{id},
2185 );
2186 try client.queueWrite(bind);
2187 }
2188 try client.queueWrite("CAP END\r\n");
2189 }
2190 },
2191 .RPL_WELCOME => {
2192 const msg2 = try msg.dupe(self.alloc);
2193 try self.messages.append(self.alloc, msg2);
2194 const now = try zeit.instant(.{});
2195 var now_buf: [30]u8 = undefined;
2196 const now_fmt = try now.time().bufPrint(&now_buf, .rfc3339);
2197
2198 const past = try now.subtract(.{ .days = 7 });
2199 var past_buf: [30]u8 = undefined;
2200 const past_fmt = try past.time().bufPrint(&past_buf, .rfc3339);
2201
2202 var buf: [128]u8 = undefined;
2203 const targets = try std.fmt.bufPrint(
2204 &buf,
2205 "CHATHISTORY TARGETS timestamp={s} timestamp={s} 50\r\n",
2206 .{ now_fmt, past_fmt },
2207 );
2208 try client.queueWrite(targets);
2209 // on_connect callback
2210 try lua.onConnect(self.app.lua, client);
2211 },
2212 .RPL_YOURHOST => {
2213 const msg2 = try msg.dupe(self.alloc);
2214 try self.messages.append(self.alloc, msg2);
2215 },
2216 .RPL_CREATED => {
2217 const msg2 = try msg.dupe(self.alloc);
2218 try self.messages.append(self.alloc, msg2);
2219 },
2220 .RPL_MYINFO => {
2221 const msg2 = try msg.dupe(self.alloc);
2222 try self.messages.append(self.alloc, msg2);
2223 },
2224 .RPL_ISUPPORT => {
2225 const msg2 = try msg.dupe(self.alloc);
2226 try self.messages.append(self.alloc, msg2);
2227 // syntax: <client> <token>[ <token>] :are supported
2228 var iter = msg.paramIterator();
2229 _ = iter.next() orelse return; // client
2230 while (iter.next()) |token| {
2231 if (mem.eql(u8, token, "WHOX"))
2232 client.supports.whox = true
2233 else if (mem.startsWith(u8, token, "PREFIX")) {
2234 const prefix = blk: {
2235 const idx = mem.indexOfScalar(u8, token, ')') orelse
2236 // default is "@+"
2237 break :blk try self.alloc.dupe(u8, "@+");
2238 break :blk try self.alloc.dupe(u8, token[idx + 1 ..]);
2239 };
2240 client.supports.prefix = prefix;
2241 }
2242 }
2243 },
2244 .RPL_LOGGEDIN => {
2245 const msg2 = try msg.dupe(self.alloc);
2246 try self.messages.append(self.alloc, msg2);
2247 },
2248 .RPL_TOPIC => {
2249 // syntax: <client> <channel> :<topic>
2250 var iter = msg.paramIterator();
2251 _ = iter.next() orelse return; // client ("*")
2252 const channel_name = iter.next() orelse return; // channel
2253 const topic = iter.next() orelse return; // topic
2254
2255 var channel = try client.getOrCreateChannel(channel_name);
2256 if (channel.topic) |old_topic| {
2257 self.alloc.free(old_topic);
2258 }
2259 channel.topic = try self.alloc.dupe(u8, topic);
2260 },
2261 .RPL_TRYAGAIN => {
2262 const msg2 = try msg.dupe(self.alloc);
2263 try self.messages.append(self.alloc, msg2);
2264 if (self.list_modal.expecting_response) {
2265 self.list_modal.expecting_response = false;
2266 try self.list_modal.finish(ctx);
2267 }
2268 },
2269 .RPL_LISTSTART => try self.list_modal.reset(),
2270 .RPL_LIST => {
2271 // We might not always get a RPL_LISTSTART, so we check if we have a list already
2272 // and if it needs reseting
2273 if (self.list_modal.finished) {
2274 try self.list_modal.reset();
2275 }
2276 self.list_modal.expecting_response = false;
2277 try self.list_modal.addMessage(self.alloc, msg);
2278 },
2279 .RPL_LISTEND => try self.list_modal.finish(ctx),
2280 .RPL_SASLSUCCESS => {
2281 const msg2 = try msg.dupe(self.alloc);
2282 try self.messages.append(self.alloc, msg2);
2283 },
2284 .RPL_WHOREPLY => {
2285 // syntax: <client> <channel> <username> <host> <server> <nick> <flags> :<hopcount> <real name>
2286 var iter = msg.paramIterator();
2287 _ = iter.next() orelse return; // client
2288 const channel_name = iter.next() orelse return; // channel
2289 if (mem.eql(u8, channel_name, "*")) return;
2290 _ = iter.next() orelse return; // username
2291 _ = iter.next() orelse return; // host
2292 _ = iter.next() orelse return; // server
2293 const nick = iter.next() orelse return; // nick
2294 const flags = iter.next() orelse return; // flags
2295
2296 const user_ptr = try client.getOrCreateUser(nick);
2297 if (mem.indexOfScalar(u8, flags, 'G')) |_| user_ptr.away = true;
2298 var channel = try client.getOrCreateChannel(channel_name);
2299
2300 const prefix = for (flags) |c| {
2301 if (std.mem.indexOfScalar(u8, client.supports.prefix, c)) |_| {
2302 break c;
2303 }
2304 } else ' ';
2305
2306 try channel.addMember(user_ptr, .{ .prefix = prefix });
2307 },
2308 .RPL_WHOSPCRPL => {
2309 // syntax: <client> <channel> <nick> <flags> :<realname>
2310 var iter = msg.paramIterator();
2311 _ = iter.next() orelse return;
2312 const channel_name = iter.next() orelse return; // channel
2313 const nick = iter.next() orelse return;
2314 const flags = iter.next() orelse return;
2315
2316 const user_ptr = try client.getOrCreateUser(nick);
2317 if (iter.next()) |real_name| {
2318 if (user_ptr.real_name) |old_name| {
2319 self.alloc.free(old_name);
2320 }
2321 user_ptr.real_name = try self.alloc.dupe(u8, real_name);
2322 }
2323 if (mem.indexOfScalar(u8, flags, 'G')) |_| user_ptr.away = true;
2324 var channel = try client.getOrCreateChannel(channel_name);
2325
2326 const prefix = for (flags) |c| {
2327 if (std.mem.indexOfScalar(u8, client.supports.prefix, c)) |_| {
2328 break c;
2329 }
2330 } else ' ';
2331
2332 try channel.addMember(user_ptr, .{ .prefix = prefix });
2333 },
2334 .RPL_ENDOFWHO => {
2335 // syntax: <client> <mask> :End of WHO list
2336 var iter = msg.paramIterator();
2337 _ = iter.next() orelse return; // client
2338 const channel_name = iter.next() orelse return; // channel
2339 if (mem.eql(u8, channel_name, "*")) return;
2340 var channel = try client.getOrCreateChannel(channel_name);
2341 channel.in_flight.who = false;
2342 ctx.redraw = true;
2343 },
2344 .RPL_NAMREPLY => {
2345 // syntax: <client> <symbol> <channel> :[<prefix>]<nick>{ [<prefix>]<nick>}
2346 var iter = msg.paramIterator();
2347 _ = iter.next() orelse return; // client
2348 _ = iter.next() orelse return; // symbol
2349 const channel_name = iter.next() orelse return; // channel
2350 const names = iter.next() orelse return;
2351 var channel = try client.getOrCreateChannel(channel_name);
2352 var name_iter = std.mem.splitScalar(u8, names, ' ');
2353 while (name_iter.next()) |name| {
2354 const nick, const prefix = for (client.supports.prefix) |ch| {
2355 if (name[0] == ch) {
2356 break .{ name[1..], name[0] };
2357 }
2358 } else .{ name, ' ' };
2359
2360 if (prefix != ' ') {
2361 log.debug("HAS PREFIX {s}", .{name});
2362 }
2363
2364 const user_ptr = try client.getOrCreateUser(nick);
2365
2366 try channel.addMember(user_ptr, .{ .prefix = prefix, .sort = false });
2367 }
2368
2369 channel.sortMembers();
2370 },
2371 .RPL_ENDOFNAMES => {
2372 // syntax: <client> <channel> :End of /NAMES list
2373 var iter = msg.paramIterator();
2374 _ = iter.next() orelse return; // client
2375 const channel_name = iter.next() orelse return; // channel
2376 var channel = try client.getOrCreateChannel(channel_name);
2377 channel.in_flight.names = false;
2378 ctx.redraw = true;
2379 },
2380 .BOUNCER => {
2381 const msg2 = try msg.dupe(self.alloc);
2382 try self.messages.append(self.alloc, msg2);
2383 var iter = msg.paramIterator();
2384 while (iter.next()) |param| {
2385 if (mem.eql(u8, param, "NETWORK")) {
2386 const id = iter.next() orelse continue;
2387 const attr = iter.next() orelse continue;
2388 // check if we already have this network
2389 for (self.app.clients.items, 0..) |cl, i| {
2390 if (cl.config.network_id) |net_id| {
2391 if (mem.eql(u8, net_id, id)) {
2392 if (mem.eql(u8, attr, "*")) {
2393 // * means the network was
2394 // deleted
2395 cl.deinit();
2396 _ = self.app.clients.swapRemove(i);
2397 }
2398 return;
2399 }
2400 }
2401 }
2402
2403 var cfg = client.config;
2404 cfg.network_id = try self.alloc.dupe(u8, id);
2405
2406 var attr_iter = std.mem.splitScalar(u8, attr, ';');
2407 while (attr_iter.next()) |kv| {
2408 const n = std.mem.indexOfScalar(u8, kv, '=') orelse continue;
2409 const key = kv[0..n];
2410 if (mem.eql(u8, key, "name"))
2411 cfg.name = try self.alloc.dupe(u8, kv[n + 1 ..])
2412 else if (mem.eql(u8, key, "nickname"))
2413 cfg.network_nick = try self.alloc.dupe(u8, kv[n + 1 ..]);
2414 }
2415 try self.app.connect(cfg);
2416 ctx.redraw = true;
2417 }
2418 }
2419 },
2420 .AWAY => {
2421 const src = msg.source() orelse return;
2422 var iter = msg.paramIterator();
2423 const n = std.mem.indexOfScalar(u8, src, '!') orelse src.len;
2424 const user = try client.getOrCreateUser(src[0..n]);
2425 // If there are any params, the user is away. Otherwise
2426 // they are back.
2427 user.away = if (iter.next()) |_| true else false;
2428 ctx.redraw = true;
2429 },
2430 .BATCH => {
2431 var iter = msg.paramIterator();
2432 const tag = iter.next() orelse return;
2433 switch (tag[0]) {
2434 '+' => {
2435 const batch_type = iter.next() orelse return;
2436 if (mem.eql(u8, batch_type, "chathistory")) {
2437 const target = iter.next() orelse return;
2438 var channel = try client.getOrCreateChannel(target);
2439 channel.at_oldest = true;
2440 const duped_tag = try self.alloc.dupe(u8, tag[1..]);
2441 try client.batches.put(duped_tag, channel);
2442 }
2443 },
2444 '-' => {
2445 const key = client.batches.getKey(tag[1..]) orelse return;
2446 var chan = client.batches.get(key) orelse @panic("key should exist here");
2447 chan.history_requested = false;
2448 _ = client.batches.remove(key);
2449 self.alloc.free(key);
2450 ctx.redraw = true;
2451 },
2452 else => {},
2453 }
2454 },
2455 .CHATHISTORY => {
2456 var iter = msg.paramIterator();
2457 const should_targets = iter.next() orelse return;
2458 if (!mem.eql(u8, should_targets, "TARGETS")) return;
2459 const target = iter.next() orelse return;
2460 // we only add direct messages, not more channels
2461 assert(target.len > 0);
2462 if (target[0] == '#') return;
2463
2464 var channel = try client.getOrCreateChannel(target);
2465 const user_ptr = try client.getOrCreateUser(target);
2466 const me_ptr = try client.getOrCreateUser(client.nickname());
2467 try channel.addMember(user_ptr, .{});
2468 try channel.addMember(me_ptr, .{});
2469 // we set who_requested so we don't try to request
2470 // who on DMs
2471 channel.who_requested = true;
2472 var buf: [128]u8 = undefined;
2473 const mark_read = try std.fmt.bufPrint(
2474 &buf,
2475 "MARKREAD {s}\r\n",
2476 .{channel.name},
2477 );
2478 try client.queueWrite(mark_read);
2479 try client.requestHistory(.after, channel);
2480 },
2481 .JOIN => {
2482 // get the user
2483 const src = msg.source() orelse return;
2484 const n = std.mem.indexOfScalar(u8, src, '!') orelse src.len;
2485 const user = try client.getOrCreateUser(src[0..n]);
2486
2487 // get the channel
2488 var iter = msg.paramIterator();
2489 const target = iter.next() orelse return;
2490 var channel = try client.getOrCreateChannel(target);
2491
2492 const trimmed_nick = std.mem.trimRight(u8, user.nick, "_");
2493 // If it's our nick, we request chat history
2494 if (mem.eql(u8, trimmed_nick, client.nickname())) {
2495 try client.requestHistory(.after, channel);
2496 if (self.app.explicit_join) {
2497 self.app.selectChannelName(client, target);
2498 self.app.explicit_join = false;
2499 }
2500 } else try channel.addMember(user, .{});
2501 ctx.redraw = true;
2502 },
2503 .MARKREAD => {
2504 var iter = msg.paramIterator();
2505 const target = iter.next() orelse return;
2506 const timestamp = iter.next() orelse return;
2507 const equal = std.mem.indexOfScalar(u8, timestamp, '=') orelse return;
2508 const last_read = zeit.instant(.{
2509 .source = .{
2510 .iso8601 = timestamp[equal + 1 ..],
2511 },
2512 }) catch |err| {
2513 log.err("couldn't convert timestamp: {}", .{err});
2514 return;
2515 };
2516 var channel = try client.getOrCreateChannel(target);
2517 channel.last_read = @intCast(last_read.unixTimestamp());
2518 const last_msg = channel.messages.getLastOrNull() orelse return;
2519 channel.has_unread = last_msg.timestamp_s > channel.last_read;
2520 if (!channel.has_unread) {
2521 channel.has_unread_highlight = false;
2522 }
2523 ctx.redraw = true;
2524 },
2525 .PART => {
2526 // get the user
2527 const src = msg.source() orelse return;
2528 const n = std.mem.indexOfScalar(u8, src, '!') orelse src.len;
2529 const user = try client.getOrCreateUser(src[0..n]);
2530
2531 // get the channel
2532 var iter = msg.paramIterator();
2533 const target = iter.next() orelse return;
2534
2535 if (mem.eql(u8, user.nick, client.nickname())) {
2536 for (client.channels.items, 0..) |channel, i| {
2537 if (!mem.eql(u8, channel.name, target)) continue;
2538 client.app.prevChannel();
2539 var chan = client.channels.orderedRemove(i);
2540 chan.deinit(self.app.alloc);
2541 self.alloc.destroy(chan);
2542 break;
2543 }
2544 } else {
2545 const channel = try client.getOrCreateChannel(target);
2546 channel.removeMember(user);
2547 }
2548 ctx.redraw = true;
2549 },
2550 .PRIVMSG, .NOTICE => {
2551 ctx.redraw = true;
2552 // syntax: <target> :<message>
2553 const msg2 = Message.init(try self.app.alloc.dupe(u8, msg.bytes));
2554
2555 // We handle batches separately. When we encounter a PRIVMSG from a batch, we use
2556 // the original target from the batch start. We also never notify from a batched
2557 // message. Batched messages also require sorting
2558 if (msg2.getTag("batch")) |tag| {
2559 const entry = client.batches.getEntry(tag) orelse @panic("TODO");
2560 var channel = entry.value_ptr.*;
2561 try channel.insertMessage(msg2);
2562 std.sort.insertion(Message, channel.messages.items, {}, Message.compareTime);
2563 // We are probably adding at the top. Add to our msg_offset if we have one to
2564 // prevent scroll
2565 if (channel.scroll.msg_offset) |offset| {
2566 channel.scroll.msg_offset = offset + 1;
2567 }
2568 channel.at_oldest = false;
2569 return;
2570 }
2571
2572 var iter = msg2.paramIterator();
2573 const target = blk: {
2574 const tgt = iter.next() orelse return;
2575 if (mem.eql(u8, tgt, client.nickname())) {
2576 // If the target is us, we use the sender nick as the identifier
2577 break :blk msg2.senderNick() orelse unreachable;
2578 } else break :blk tgt;
2579 };
2580 // Get the channel
2581 var channel = try client.getOrCreateChannel(target);
2582 // Add the message to the channel. We don't need to sort because these come
2583 // chronologically
2584 try channel.insertMessage(msg2);
2585
2586 // Get values for our lua callbacks
2587 const content = iter.next() orelse return;
2588 const sender = msg2.senderNick() orelse "";
2589
2590 // Do the lua callback
2591 try lua.onMessage(self.app.lua, client, channel.name, sender, content);
2592
2593 // Send a notification if this has our nick
2594 if (msg2.containsPhrase(client.nickname())) {
2595 var buf: [64]u8 = undefined;
2596 const title_or_err = if (sender.len > 0)
2597 std.fmt.bufPrint(&buf, "{s} - {s}", .{ channel.name, sender })
2598 else
2599 std.fmt.bufPrint(&buf, "{s}", .{channel.name});
2600 const title = title_or_err catch title: {
2601 const len = @min(buf.len, channel.name.len);
2602 @memcpy(buf[0..len], channel.name[0..len]);
2603 break :title buf[0..len];
2604 };
2605 try ctx.sendNotification(title, content);
2606 }
2607
2608 if (client.caps.@"message-tags") {
2609 // Set the typing time to 0. We only need to do this when the server
2610 // supports message-tags
2611 for (channel.members.items) |*member| {
2612 if (!std.mem.eql(u8, member.user.nick, sender)) {
2613 continue;
2614 }
2615 member.typing = 0;
2616 break;
2617 }
2618 }
2619 },
2620 .TAGMSG => {
2621 const msg2 = Message.init(msg.bytes);
2622 // We only care about typing tags
2623 const typing = msg2.getTag("+typing") orelse return;
2624
2625 var iter = msg2.paramIterator();
2626 const target = blk: {
2627 const tgt = iter.next() orelse return;
2628 if (mem.eql(u8, tgt, client.nickname())) {
2629 // If the target is us, it likely has our
2630 // hostname in it.
2631 const source = msg2.source() orelse return;
2632 const n = mem.indexOfScalar(u8, source, '!') orelse source.len;
2633 break :blk source[0..n];
2634 } else break :blk tgt;
2635 };
2636 const sender: []const u8 = blk: {
2637 const src = msg2.source() orelse break :blk "";
2638 const l = std.mem.indexOfScalar(u8, src, '!') orelse
2639 std.mem.indexOfScalar(u8, src, '@') orelse
2640 src.len;
2641 break :blk src[0..l];
2642 };
2643 const sender_trimmed = std.mem.trimRight(u8, sender, "_");
2644 if (std.mem.eql(u8, sender_trimmed, client.nickname())) {
2645 // We never considuer ourselves as typing
2646 return;
2647 }
2648 const channel = try client.getOrCreateChannel(target);
2649
2650 for (channel.members.items) |*member| {
2651 if (!std.mem.eql(u8, member.user.nick, sender)) {
2652 continue;
2653 }
2654 if (std.mem.eql(u8, "done", typing)) {
2655 member.typing = 0;
2656 ctx.redraw = true;
2657 return;
2658 }
2659 if (std.mem.eql(u8, "active", typing)) {
2660 member.typing = msg2.timestamp_s;
2661 channel.typing_last_active = member.typing;
2662 ctx.redraw = true;
2663 return;
2664 }
2665 }
2666 },
2667 }
2668 }
2669
2670 pub fn nickname(self: *Client) []const u8 {
2671 return self.config.network_nick orelse self.config.nick;
2672 }
2673
2674 pub fn del(self: *Client, cap: []const u8) void {
2675 const info = @typeInfo(Capabilities);
2676 assert(info == .@"struct");
2677
2678 inline for (info.@"struct".fields) |field| {
2679 if (std.mem.eql(u8, field.name, cap)) {
2680 @field(self.caps, field.name) = false;
2681 return;
2682 }
2683 }
2684 }
2685
2686 pub fn ack(self: *Client, cap: []const u8) void {
2687 const info = @typeInfo(Capabilities);
2688 assert(info == .@"struct");
2689
2690 inline for (info.@"struct".fields) |field| {
2691 if (std.mem.eql(u8, field.name, cap)) {
2692 @field(self.caps, field.name) = true;
2693 return;
2694 }
2695 }
2696 }
2697
2698 pub fn read(self: *Client, buf: []u8) !usize {
2699 switch (self.config.tls) {
2700 true => return self.client.read(buf),
2701 false => return self.stream.read(buf),
2702 }
2703 }
2704
2705 fn warn(self: *Client, comptime fmt: []const u8, args: anytype) void {
2706 self.read_buf.appendSlice(":comlink WARN ") catch {};
2707 self.read_buf.writer().print(fmt, args) catch {};
2708 self.read_buf.appendSlice("\r\n") catch {};
2709 }
2710
2711 pub fn readThread(self: *Client) void {
2712 defer self.status.store(.disconnected, .release);
2713
2714 // We push this off to another function that can enforces it only fails for allocation
2715 // errors
2716 self._readThread() catch |err| {
2717 switch (err) {
2718 error.OutOfMemory => {},
2719 }
2720 log.err("out of memory", .{});
2721 };
2722 }
2723
2724 fn _readThread(self: *Client) Allocator.Error!void {
2725 self.connect() catch |err| {
2726 self.warn("* CONNECTION_ERROR :Error while connecting to server: {}", .{err});
2727 return;
2728 };
2729 try self.queueWrite("CAP LS 302\r\n");
2730
2731 const cap_names = std.meta.fieldNames(Capabilities);
2732 for (cap_names) |cap| {
2733 try self.print("CAP REQ :{s}\r\n", .{cap});
2734 }
2735
2736 try self.print("NICK {s}\r\n", .{self.config.nick});
2737
2738 const real_name = if (self.config.real_name.len > 0)
2739 self.config.real_name
2740 else
2741 self.config.nick;
2742 try self.print("USER {s} 0 * :{s}\r\n", .{ self.config.user, real_name });
2743
2744 var buf: [4096]u8 = undefined;
2745 var retries: u8 = 0;
2746 while (true) {
2747 const n = self.read(&buf) catch |err| {
2748 // WouldBlock means our socket timeout expired
2749 switch (err) {
2750 error.WouldBlock => {},
2751 else => {
2752 self.warn("* CONNECTION_ERROR :{}", .{err});
2753 return;
2754 },
2755 }
2756
2757 if (retries == keepalive_retries) {
2758 log.debug("[{s}] connection closed", .{self.config.name orelse self.config.server});
2759 self.close();
2760 return;
2761 }
2762
2763 if (retries == 0) {
2764 self.configureKeepalive(keepalive_interval) catch |err2| {
2765 self.warn("* INTERNAL_ERROR :Couldn't configure socket: {}", .{err2});
2766 return;
2767 };
2768 }
2769 retries += 1;
2770 try self.queueWrite("PING comlink\r\n");
2771 continue;
2772 };
2773 if (n == 0) return;
2774
2775 // If we did a connection retry, we reset the state
2776 if (retries > 0) {
2777 retries = 0;
2778 self.configureKeepalive(keepalive_idle) catch |err2| {
2779 self.warn("* INTERNAL_ERROR :Couldn't configure socket: {}", .{err2});
2780 return;
2781 };
2782 }
2783 self.read_buf_mutex.lock();
2784 defer self.read_buf_mutex.unlock();
2785 try self.read_buf.appendSlice(buf[0..n]);
2786 }
2787 }
2788
2789 pub fn print(self: *Client, comptime fmt: []const u8, args: anytype) Allocator.Error!void {
2790 const msg = try std.fmt.allocPrint(self.alloc, fmt, args);
2791 self.write_queue.push(.{ .write = .{
2792 .client = self,
2793 .msg = msg,
2794 } });
2795 }
2796
2797 /// push a write request into the queue. The request should include the trailing
2798 /// '\r\n'. queueWrite will dupe the message and free after processing.
2799 pub fn queueWrite(self: *Client, msg: []const u8) Allocator.Error!void {
2800 self.write_queue.push(.{ .write = .{
2801 .client = self,
2802 .msg = try self.alloc.dupe(u8, msg),
2803 } });
2804 }
2805
2806 pub fn write(self: *Client, buf: []const u8) !void {
2807 assert(std.mem.endsWith(u8, buf, "\r\n"));
2808 if (self.status.load(.acquire) == .disconnected) {
2809 log.warn("disconnected: dropping write: {s}", .{buf[0 .. buf.len - 2]});
2810 return;
2811 }
2812 log.debug("[->{s}] {s}", .{ self.config.name orelse self.config.server, buf[0 .. buf.len - 2] });
2813 switch (self.config.tls) {
2814 true => try self.client.writeAll(buf),
2815 false => try self.stream.writeAll(buf),
2816 }
2817 }
2818
2819 pub fn connect(self: *Client) !void {
2820 if (self.config.tls) {
2821 const port: u16 = self.config.port orelse 6697;
2822 self.stream = try tcpConnectToHost(self.alloc, self.config.server, port);
2823 self.client = try tls.client(self.stream, .{
2824 .host = self.config.server,
2825 .root_ca = .{ .bundle = self.app.bundle },
2826 });
2827 } else {
2828 const port: u16 = self.config.port orelse 6667;
2829 self.stream = try std.net.tcpConnectToHost(self.alloc, self.config.server, port);
2830 }
2831 self.status.store(.connected, .release);
2832
2833 try self.configureKeepalive(keepalive_idle);
2834 }
2835
2836 pub fn configureKeepalive(self: *Client, seconds: i32) !void {
2837 const timeout = std.mem.toBytes(std.posix.timeval{
2838 .sec = seconds,
2839 .usec = 0,
2840 });
2841
2842 try std.posix.setsockopt(
2843 self.stream.handle,
2844 std.posix.SOL.SOCKET,
2845 std.posix.SO.RCVTIMEO,
2846 &timeout,
2847 );
2848 }
2849
2850 pub fn getOrCreateChannel(self: *Client, name: []const u8) Allocator.Error!*Channel {
2851 for (self.channels.items) |channel| {
2852 if (caseFold(name, channel.name)) return channel;
2853 }
2854 const channel = try self.alloc.create(Channel);
2855 try channel.init(self.alloc, self, name, self.app.unicode);
2856 try self.channels.append(channel);
2857
2858 std.sort.insertion(*Channel, self.channels.items, {}, Channel.compare);
2859 return channel;
2860 }
2861
2862 var color_indices = [_]u8{ 1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14 };
2863
2864 pub fn getOrCreateUser(self: *Client, nick: []const u8) Allocator.Error!*User {
2865 return self.users.get(nick) orelse {
2866 const color_u32 = std.hash.Fnv1a_32.hash(nick);
2867 const index = color_u32 % color_indices.len;
2868 const color_index = color_indices[index];
2869
2870 const color: vaxis.Color = .{
2871 .index = color_index,
2872 };
2873 const user = try self.alloc.create(User);
2874 user.* = .{
2875 .nick = try self.alloc.dupe(u8, nick),
2876 .color = color,
2877 };
2878 try self.users.put(user.nick, user);
2879 return user;
2880 };
2881 }
2882
2883 pub fn whox(self: *Client, channel: *Channel) !void {
2884 channel.who_requested = true;
2885 if (channel.name.len > 0 and
2886 channel.name[0] != '#')
2887 {
2888 const other = try self.getOrCreateUser(channel.name);
2889 const me = try self.getOrCreateUser(self.config.nick);
2890 try channel.addMember(other, .{});
2891 try channel.addMember(me, .{});
2892 return;
2893 }
2894 // Only use WHO if we have WHOX and away-notify. Without
2895 // WHOX, we can get rate limited on eg. libera. Without
2896 // away-notify, our list will become stale
2897 if (self.supports.whox and
2898 self.caps.@"away-notify" and
2899 !channel.in_flight.who)
2900 {
2901 channel.in_flight.who = true;
2902 try self.print(
2903 "WHO {s} %cnfr\r\n",
2904 .{channel.name},
2905 );
2906 } else {
2907 channel.in_flight.names = true;
2908 try self.print(
2909 "NAMES {s}\r\n",
2910 .{channel.name},
2911 );
2912 }
2913 }
2914
2915 /// fetch the history for the provided channel.
2916 pub fn requestHistory(
2917 self: *Client,
2918 cmd: ChatHistoryCommand,
2919 channel: *Channel,
2920 ) Allocator.Error!void {
2921 if (!self.caps.@"draft/chathistory") return;
2922 if (channel.history_requested) return;
2923
2924 channel.history_requested = true;
2925
2926 if (channel.messages.items.len == 0) {
2927 try self.print(
2928 "CHATHISTORY LATEST {s} * 50\r\n",
2929 .{channel.name},
2930 );
2931 channel.history_requested = true;
2932 return;
2933 }
2934
2935 switch (cmd) {
2936 .before => {
2937 assert(channel.messages.items.len > 0);
2938 const first = channel.messages.items[0];
2939 const time = first.getTag("time") orelse {
2940 log.warn("can't request history: no time tag", .{});
2941 return;
2942 };
2943 try self.print(
2944 "CHATHISTORY BEFORE {s} timestamp={s} 50\r\n",
2945 .{ channel.name, time },
2946 );
2947 channel.history_requested = true;
2948 },
2949 .after => {
2950 assert(channel.messages.items.len > 0);
2951 const last = channel.messages.getLast();
2952 const time = last.getTag("time") orelse {
2953 log.warn("can't request history: no time tag", .{});
2954 return;
2955 };
2956 try self.print(
2957 // we request 500 because we have no
2958 // idea how long we've been offline
2959 "CHATHISTORY AFTER {s} timestamp={s} 500\r\n",
2960 .{ channel.name, time },
2961 );
2962 channel.history_requested = true;
2963 },
2964 }
2965 }
2966
2967 fn messageViewWidget(self: *Client) vxfw.Widget {
2968 return .{
2969 .userdata = self,
2970 .eventHandler = Client.handleMessageViewEvent,
2971 .drawFn = Client.typeErasedDrawMessageView,
2972 };
2973 }
2974
2975 fn handleMessageViewEvent(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
2976 const self: *Client = @ptrCast(@alignCast(ptr));
2977 switch (event) {
2978 .mouse => |mouse| {
2979 if (self.message_view.mouse) |last_mouse| {
2980 // We need to redraw if the column entered the gutter
2981 if (last_mouse.col >= gutter_width and mouse.col < gutter_width)
2982 ctx.redraw = true
2983 // Or if the column exited the gutter
2984 else if (last_mouse.col < gutter_width and mouse.col >= gutter_width)
2985 ctx.redraw = true
2986 // Or if the row changed
2987 else if (last_mouse.row != mouse.row)
2988 ctx.redraw = true
2989 // Or if we did a middle click, and now released it
2990 else if (last_mouse.button == .middle)
2991 ctx.redraw = true;
2992 } else {
2993 // If we didn't have the mouse previously, we redraw
2994 ctx.redraw = true;
2995 }
2996
2997 // Save this mouse state for when we draw
2998 self.message_view.mouse = mouse;
2999
3000 // A middle press on a hovered message means we copy the content
3001 if (mouse.type == .press and
3002 mouse.button == .middle and
3003 self.message_view.hovered_message != null)
3004 {
3005 const msg = self.message_view.hovered_message orelse unreachable;
3006 try ctx.copyToClipboard(msg.bytes);
3007 return ctx.consumeAndRedraw();
3008 }
3009 if (mouse.button == .wheel_down) {
3010 self.scroll.pending -|= 1;
3011 ctx.consume_event = true;
3012 ctx.redraw = true;
3013 }
3014 if (mouse.button == .wheel_up) {
3015 self.scroll.pending +|= 1;
3016 ctx.consume_event = true;
3017 ctx.redraw = true;
3018 }
3019 if (self.scroll.pending != 0) {
3020 try self.doScroll(ctx);
3021 }
3022 },
3023 .mouse_leave => {
3024 self.message_view.mouse = null;
3025 self.message_view.hovered_message = null;
3026 ctx.redraw = true;
3027 },
3028 .tick => {
3029 try self.doScroll(ctx);
3030 },
3031 else => {},
3032 }
3033 }
3034
3035 fn typeErasedDrawMessageView(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
3036 const self: *Client = @ptrCast(@alignCast(ptr));
3037 return self.drawMessageView(ctx);
3038 }
3039
3040 fn drawMessageView(self: *Client, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
3041 self.message_view.hovered_message = null;
3042 const max = ctx.max.size();
3043 if (max.width == 0 or max.height == 0 or self.messages.items.len == 0) {
3044 return .{
3045 .size = max,
3046 .widget = self.messageViewWidget(),
3047 .buffer = &.{},
3048 .children = &.{},
3049 };
3050 }
3051
3052 var children = std.ArrayList(vxfw.SubSurface).init(ctx.arena);
3053
3054 // Row is the row we are printing on. We add the offset to achieve our scroll location
3055 var row: i17 = max.height + self.scroll.offset;
3056 // Message offset
3057 const offset = self.scroll.msg_offset orelse self.messages.items.len;
3058
3059 const messages = self.messages.items[0..offset];
3060 var iter = std.mem.reverseIterator(messages);
3061
3062 assert(messages.len > 0);
3063 // Initialize sender and maybe_instant to the last message values
3064 const last_msg = iter.next() orelse unreachable;
3065 // Reset iter index
3066 iter.index += 1;
3067 var this_instant = last_msg.localTime(&self.app.tz);
3068
3069 while (iter.next()) |msg| {
3070 // Break if we have gone past the top of the screen
3071 if (row < 0) break;
3072
3073 // Get the server time for the *next* message. We'll use this to decide printing of
3074 // username and time
3075 const maybe_next_instant: ?zeit.Instant = blk: {
3076 const next_msg = iter.next() orelse break :blk null;
3077 // Fix the index of the iterator
3078 iter.index += 1;
3079 break :blk next_msg.localTime(&self.app.tz);
3080 };
3081
3082 defer {
3083 // After this loop, we want to save these values for the next iteration
3084 if (maybe_next_instant) |next_instant| {
3085 this_instant = next_instant;
3086 }
3087 }
3088
3089 // Draw the message so we have it's wrapped height
3090 const text: vxfw.Text = .{ .text = msg.bytes };
3091 const child_ctx = ctx.withConstraints(
3092 .{ .width = max.width -| gutter_width, .height = 1 },
3093 .{ .width = max.width -| gutter_width, .height = null },
3094 );
3095 const surface = try text.draw(child_ctx);
3096
3097 // See if our message contains the mouse. We'll highlight it if it does
3098 const message_has_mouse: bool = blk: {
3099 const mouse = self.message_view.mouse orelse break :blk false;
3100 break :blk mouse.col >= gutter_width and
3101 mouse.row < row and
3102 mouse.row >= row - surface.size.height;
3103 };
3104
3105 if (message_has_mouse) {
3106 const last_mouse = self.message_view.mouse orelse unreachable;
3107 // If we had a middle click, we highlight yellow to indicate we copied the text
3108 const bg: vaxis.Color = if (last_mouse.button == .middle and last_mouse.type == .press)
3109 .{ .index = 3 }
3110 else
3111 .{ .index = 8 };
3112 // Set the style for the entire message
3113 for (surface.buffer) |*cell| {
3114 cell.style.bg = bg;
3115 }
3116 // Create a surface to highlight the entire area under the message
3117 const hl_surface = try vxfw.Surface.init(
3118 ctx.arena,
3119 text.widget(),
3120 .{ .width = max.width -| gutter_width, .height = surface.size.height },
3121 );
3122 const base: vaxis.Cell = .{ .style = .{ .bg = bg } };
3123 @memset(hl_surface.buffer, base);
3124
3125 try children.append(.{
3126 .origin = .{ .row = row - surface.size.height, .col = gutter_width },
3127 .surface = hl_surface,
3128 });
3129
3130 self.message_view.hovered_message = msg;
3131 }
3132
3133 // Adjust the row we print on for the wrapped height of this message
3134 row -= surface.size.height;
3135 try children.append(.{
3136 .origin = .{ .row = row, .col = gutter_width },
3137 .surface = surface,
3138 });
3139
3140 var style: vaxis.Style = .{ .dim = true };
3141 // The time text we will print
3142 const buf: []const u8 = blk: {
3143 const time = this_instant.time();
3144 // Check our next time. If *this* message occurs on a different day, we want to
3145 // print the date
3146 if (maybe_next_instant) |next_instant| {
3147 const next_time = next_instant.time();
3148 if (time.day != next_time.day) {
3149 style = .{};
3150 break :blk try std.fmt.allocPrint(
3151 ctx.arena,
3152 "{d:0>2}/{d:0>2}",
3153 .{ @intFromEnum(time.month), time.day },
3154 );
3155 }
3156 }
3157
3158 // if it is the first message, we also want to print the date
3159 if (iter.index == 0) {
3160 style = .{};
3161 break :blk try std.fmt.allocPrint(
3162 ctx.arena,
3163 "{d:0>2}/{d:0>2}",
3164 .{ @intFromEnum(time.month), time.day },
3165 );
3166 }
3167
3168 // Otherwise, we print clock time
3169 break :blk try std.fmt.allocPrint(
3170 ctx.arena,
3171 "{d:0>2}:{d:0>2}",
3172 .{ time.hour, time.minute },
3173 );
3174 };
3175
3176 const time_text: vxfw.Text = .{
3177 .text = buf,
3178 .style = style,
3179 .softwrap = false,
3180 };
3181 const time_ctx = ctx.withConstraints(
3182 .{ .width = 0, .height = 1 },
3183 .{ .width = max.width -| gutter_width, .height = null },
3184 );
3185 try children.append(.{
3186 .origin = .{ .row = row, .col = 0 },
3187 .surface = try time_text.draw(time_ctx),
3188 });
3189 }
3190
3191 // Set the can_scroll_up flag. this is true if we drew past the top of the screen
3192 self.can_scroll_up = row <= 0;
3193 if (row > 0) {
3194 row -= 1;
3195 // If we didn't draw past the top of the screen, we must have reached the end of
3196 // history. Draw an indicator letting the user know this
3197 const bot = "━";
3198 var writer = try std.ArrayList(u8).initCapacity(ctx.arena, bot.len * max.width);
3199 try writer.writer().writeBytesNTimes(bot, max.width);
3200
3201 const border: vxfw.Text = .{
3202 .text = writer.items,
3203 .style = .{ .fg = .{ .index = 8 } },
3204 .softwrap = false,
3205 };
3206 const border_ctx = ctx.withConstraints(.{}, .{ .height = 1, .width = max.width });
3207
3208 const unread: vxfw.SubSurface = .{
3209 .origin = .{ .col = 0, .row = row },
3210 .surface = try border.draw(border_ctx),
3211 };
3212
3213 try children.append(unread);
3214 const no_more_history: vxfw.Text = .{
3215 .text = " Perhaps the archives are incomplete ",
3216 .style = .{ .fg = .{ .index = 8 } },
3217 .softwrap = false,
3218 };
3219 const no_history_surf = try no_more_history.draw(border_ctx);
3220 const new_sub: vxfw.SubSurface = .{
3221 .origin = .{ .col = (max.width -| no_history_surf.size.width) / 2, .row = row },
3222 .surface = no_history_surf,
3223 };
3224 try children.append(new_sub);
3225 }
3226 return .{
3227 .size = max,
3228 .widget = self.messageViewWidget(),
3229 .buffer = &.{},
3230 .children = children.items,
3231 };
3232 }
3233
3234 /// Consumes any pending scrolls and schedules another tick if needed
3235 fn doScroll(self: *Client, ctx: *vxfw.EventContext) anyerror!void {
3236 defer {
3237 // At the end of this function, we anchor our msg_offset if we have any amount of
3238 // scroll. This prevents new messages from automatically scrolling us
3239 if (self.scroll.offset > 0 and self.scroll.msg_offset == null) {
3240 self.scroll.msg_offset = @intCast(self.messages.items.len);
3241 }
3242 // If we have no offset, we reset our anchor
3243 if (self.scroll.offset == 0) {
3244 self.scroll.msg_offset = null;
3245 }
3246 }
3247 const animation_tick: u32 = 30;
3248 // No pending scroll. Return early
3249 if (self.scroll.pending == 0) return;
3250
3251 // Scroll up
3252 if (self.scroll.pending > 0) {
3253 // Check if we can scroll up. If we can't, we are done
3254 if (!self.can_scroll_up) {
3255 self.scroll.pending = 0;
3256 return;
3257 }
3258 // Consume 1 line, and schedule a tick
3259 self.scroll.offset += 1;
3260 self.scroll.pending -= 1;
3261 ctx.redraw = true;
3262 return ctx.tick(animation_tick, self.messageViewWidget());
3263 }
3264
3265 // From here, we only scroll down. First, we check if we are at the bottom already. If we
3266 // are, we have nothing to do
3267 if (self.scroll.offset == 0) {
3268 // Already at bottom. Nothing to do
3269 self.scroll.pending = 0;
3270 return;
3271 }
3272
3273 // Scroll down
3274 if (self.scroll.pending < 0) {
3275 // Consume 1 line, and schedule a tick
3276 self.scroll.offset -= 1;
3277 self.scroll.pending += 1;
3278 ctx.redraw = true;
3279 return ctx.tick(animation_tick, self.messageViewWidget());
3280 }
3281 }
3282};
3283
3284pub fn toVaxisColor(irc: u8) vaxis.Color {
3285 return switch (irc) {
3286 0 => .default, // white
3287 1 => .{ .index = 0 }, // black
3288 2 => .{ .index = 4 }, // blue
3289 3 => .{ .index = 2 }, // green
3290 4 => .{ .index = 1 }, // red
3291 5 => .{ .index = 3 }, // brown
3292 6 => .{ .index = 5 }, // magenta
3293 7 => .{ .index = 11 }, // orange
3294 8 => .{ .index = 11 }, // yellow
3295 9 => .{ .index = 10 }, // light green
3296 10 => .{ .index = 6 }, // cyan
3297 11 => .{ .index = 14 }, // light cyan
3298 12 => .{ .index = 12 }, // light blue
3299 13 => .{ .index = 13 }, // pink
3300 14 => .{ .index = 8 }, // grey
3301 15 => .{ .index = 7 }, // light grey
3302
3303 // 16 to 98 are specifically defined
3304 16 => .{ .index = 52 },
3305 17 => .{ .index = 94 },
3306 18 => .{ .index = 100 },
3307 19 => .{ .index = 58 },
3308 20 => .{ .index = 22 },
3309 21 => .{ .index = 29 },
3310 22 => .{ .index = 23 },
3311 23 => .{ .index = 24 },
3312 24 => .{ .index = 17 },
3313 25 => .{ .index = 54 },
3314 26 => .{ .index = 53 },
3315 27 => .{ .index = 89 },
3316 28 => .{ .index = 88 },
3317 29 => .{ .index = 130 },
3318 30 => .{ .index = 142 },
3319 31 => .{ .index = 64 },
3320 32 => .{ .index = 28 },
3321 33 => .{ .index = 35 },
3322 34 => .{ .index = 30 },
3323 35 => .{ .index = 25 },
3324 36 => .{ .index = 18 },
3325 37 => .{ .index = 91 },
3326 38 => .{ .index = 90 },
3327 39 => .{ .index = 125 },
3328 // TODO: finish these out https://modern.ircdocs.horse/formatting#color
3329
3330 99 => .default,
3331
3332 else => .{ .index = irc },
3333 };
3334}
3335/// generate TextSpans for the message content
3336fn formatMessage(
3337 arena: Allocator,
3338 user: *User,
3339 content: []const u8,
3340) Allocator.Error![]vxfw.RichText.TextSpan {
3341 const ColorState = enum {
3342 ground,
3343 fg,
3344 bg,
3345 };
3346 const LinkState = enum {
3347 h,
3348 t1,
3349 t2,
3350 p,
3351 s,
3352 colon,
3353 slash,
3354 consume,
3355 };
3356
3357 var spans = std.ArrayList(vxfw.RichText.TextSpan).init(arena);
3358
3359 var start: usize = 0;
3360 var i: usize = 0;
3361 var style: vaxis.Style = .{};
3362 while (i < content.len) : (i += 1) {
3363 const b = content[i];
3364 switch (b) {
3365 0x01 => { // https://modern.ircdocs.horse/ctcp
3366 if (i == 0 and
3367 content.len > 7 and
3368 mem.startsWith(u8, content[1..], "ACTION"))
3369 {
3370 // get the user of this message
3371 style.italic = true;
3372 const user_style: vaxis.Style = .{
3373 .fg = user.color,
3374 .italic = true,
3375 };
3376 try spans.append(.{
3377 .text = user.nick,
3378 .style = user_style,
3379 });
3380 i += 6; // "ACTION"
3381 } else {
3382 try spans.append(.{
3383 .text = content[start..i],
3384 .style = style,
3385 });
3386 }
3387 start = i + 1;
3388 },
3389 0x02 => {
3390 try spans.append(.{
3391 .text = content[start..i],
3392 .style = style,
3393 });
3394 style.bold = !style.bold;
3395 start = i + 1;
3396 },
3397 0x03 => {
3398 try spans.append(.{
3399 .text = content[start..i],
3400 .style = style,
3401 });
3402 i += 1;
3403 var state: ColorState = .ground;
3404 var fg_idx: ?u8 = null;
3405 var bg_idx: ?u8 = null;
3406 while (i < content.len) : (i += 1) {
3407 const d = content[i];
3408 switch (state) {
3409 .ground => {
3410 switch (d) {
3411 '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => {
3412 state = .fg;
3413 fg_idx = d - '0';
3414 },
3415 else => {
3416 style.fg = .default;
3417 style.bg = .default;
3418 start = i;
3419 break;
3420 },
3421 }
3422 },
3423 .fg => {
3424 switch (d) {
3425 '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => {
3426 const fg = fg_idx orelse 0;
3427 if (fg > 9) {
3428 style.fg = toVaxisColor(fg);
3429 start = i;
3430 break;
3431 } else {
3432 fg_idx = fg * 10 + (d - '0');
3433 }
3434 },
3435 else => {
3436 if (fg_idx) |fg| {
3437 style.fg = toVaxisColor(fg);
3438 start = i;
3439 }
3440 if (d == ',') state = .bg else break;
3441 },
3442 }
3443 },
3444 .bg => {
3445 switch (d) {
3446 '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => {
3447 const bg = bg_idx orelse 0;
3448 if (i - start == 2) {
3449 style.bg = toVaxisColor(bg);
3450 start = i;
3451 break;
3452 } else {
3453 bg_idx = bg * 10 + (d - '0');
3454 }
3455 },
3456 else => {
3457 if (bg_idx) |bg| {
3458 style.bg = toVaxisColor(bg);
3459 start = i;
3460 }
3461 break;
3462 },
3463 }
3464 },
3465 }
3466 }
3467 },
3468 0x0F => {
3469 try spans.append(.{
3470 .text = content[start..i],
3471 .style = style,
3472 });
3473 style = .{};
3474 start = i + 1;
3475 },
3476 0x16 => {
3477 try spans.append(.{
3478 .text = content[start..i],
3479 .style = style,
3480 });
3481 style.reverse = !style.reverse;
3482 start = i + 1;
3483 },
3484 0x1D => {
3485 try spans.append(.{
3486 .text = content[start..i],
3487 .style = style,
3488 });
3489 style.italic = !style.italic;
3490 start = i + 1;
3491 },
3492 0x1E => {
3493 try spans.append(.{
3494 .text = content[start..i],
3495 .style = style,
3496 });
3497 style.strikethrough = !style.strikethrough;
3498 start = i + 1;
3499 },
3500 0x1F => {
3501 try spans.append(.{
3502 .text = content[start..i],
3503 .style = style,
3504 });
3505
3506 style.ul_style = if (style.ul_style == .off) .single else .off;
3507 start = i + 1;
3508 },
3509 else => {
3510 if (b == 'h') {
3511 var state: LinkState = .h;
3512 const h_start = i;
3513 // consume until a space or EOF
3514 i += 1;
3515 while (i < content.len) : (i += 1) {
3516 const b1 = content[i];
3517 switch (state) {
3518 .h => {
3519 if (b1 == 't') state = .t1 else break;
3520 },
3521 .t1 => {
3522 if (b1 == 't') state = .t2 else break;
3523 },
3524 .t2 => {
3525 if (b1 == 'p') state = .p else break;
3526 },
3527 .p => {
3528 if (b1 == 's')
3529 state = .s
3530 else if (b1 == ':')
3531 state = .colon
3532 else
3533 break;
3534 },
3535 .s => {
3536 if (b1 == ':') state = .colon else break;
3537 },
3538 .colon => {
3539 if (b1 == '/') state = .slash else break;
3540 },
3541 .slash => {
3542 if (b1 == '/') {
3543 state = .consume;
3544 try spans.append(.{
3545 .text = content[start..h_start],
3546 .style = style,
3547 });
3548 start = h_start;
3549 } else break;
3550 },
3551 .consume => {
3552 switch (b1) {
3553 0x00...0x20, 0x7F => {
3554 try spans.append(.{
3555 .text = content[h_start..i],
3556 .style = .{
3557 .fg = .{ .index = 4 },
3558 },
3559 .link = .{
3560 .uri = content[h_start..i],
3561 },
3562 });
3563 start = i;
3564 // backup one
3565 i -= 1;
3566 break;
3567 },
3568 else => {
3569 if (i == content.len - 1) {
3570 start = i + 1;
3571 try spans.append(.{
3572 .text = content[h_start..],
3573 .style = .{
3574 .fg = .{ .index = 4 },
3575 },
3576 .link = .{
3577 .uri = content[h_start..],
3578 },
3579 });
3580 break;
3581 }
3582 },
3583 }
3584 },
3585 }
3586 }
3587 }
3588 },
3589 }
3590 }
3591 if (start < i and start < content.len) {
3592 try spans.append(.{
3593 .text = content[start..],
3594 .style = style,
3595 });
3596 }
3597 return spans.toOwnedSlice();
3598}
3599
3600const CaseMapAlgo = enum {
3601 ascii,
3602 rfc1459,
3603 rfc1459_strict,
3604};
3605
3606pub fn caseMap(char: u8, algo: CaseMapAlgo) u8 {
3607 switch (algo) {
3608 .ascii => {
3609 switch (char) {
3610 'A'...'Z' => return char + 0x20,
3611 else => return char,
3612 }
3613 },
3614 .rfc1459 => {
3615 switch (char) {
3616 'A'...'^' => return char + 0x20,
3617 else => return char,
3618 }
3619 },
3620 .rfc1459_strict => {
3621 switch (char) {
3622 'A'...']' => return char + 0x20,
3623 else => return char,
3624 }
3625 },
3626 }
3627}
3628
3629pub fn caseFold(a: []const u8, b: []const u8) bool {
3630 if (a.len != b.len) return false;
3631 var i: usize = 0;
3632 while (i < a.len) {
3633 const diff = std.mem.indexOfDiff(u8, a[i..], b[i..]) orelse return true;
3634 const a_diff = caseMap(a[diff], .rfc1459);
3635 const b_diff = caseMap(b[diff], .rfc1459);
3636 if (a_diff != b_diff) return false;
3637 i += diff + 1;
3638 }
3639 return true;
3640}
3641
3642pub const ChatHistoryCommand = enum {
3643 before,
3644 after,
3645};
3646
3647pub const ListModal = struct {
3648 client: *Client,
3649 /// the individual items we received
3650 items: std.ArrayListUnmanaged(Item),
3651 /// the list view
3652 list_view: vxfw.ListView,
3653 text_field: vxfw.TextField,
3654
3655 filtered_items: std.ArrayList(Item),
3656
3657 finished: bool,
3658 is_shown: bool,
3659 expecting_response: bool,
3660
3661 focus: enum { text_field, list },
3662
3663 const name_width = 24;
3664 const count_width = 8;
3665
3666 // Item is a single RPL_LIST response
3667 const Item = struct {
3668 name: []const u8,
3669 topic: []const u8,
3670 count_str: []const u8,
3671 count: u32,
3672
3673 fn deinit(self: Item, alloc: Allocator) void {
3674 alloc.free(self.name);
3675 alloc.free(self.topic);
3676 alloc.free(self.count_str);
3677 }
3678
3679 fn widget(self: *Item) vxfw.Widget {
3680 return .{
3681 .userdata = self,
3682 .drawFn = Item.draw,
3683 };
3684 }
3685
3686 fn lessThan(_: void, lhs: Item, rhs: Item) bool {
3687 return lhs.count > rhs.count;
3688 }
3689
3690 fn draw(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
3691 const self: *Item = @ptrCast(@alignCast(ptr));
3692
3693 var children: std.ArrayListUnmanaged(vxfw.SubSurface) = try .initCapacity(ctx.arena, 3);
3694
3695 const name_ctx = ctx.withConstraints(.{ .width = name_width, .height = 1 }, ctx.max);
3696 const count_ctx = ctx.withConstraints(.{ .width = count_width, .height = 1 }, ctx.max);
3697 const topic_ctx = ctx.withConstraints(.{
3698 .width = ctx.max.width.? -| name_width -| count_width - 2,
3699 .height = 1,
3700 }, ctx.max);
3701
3702 const name: vxfw.Text = .{ .text = self.name, .softwrap = false };
3703 const count: vxfw.Text = .{ .text = self.count_str, .softwrap = false, .text_align = .right };
3704 const spans = try formatMessage(ctx.arena, undefined, self.topic);
3705 const topic: vxfw.RichText = .{ .text = spans, .softwrap = false };
3706
3707 children.appendAssumeCapacity(.{
3708 .origin = .{ .col = 0, .row = 0 },
3709 .surface = try name.draw(name_ctx),
3710 });
3711 children.appendAssumeCapacity(.{
3712 .origin = .{ .col = name_width, .row = 0 },
3713 .surface = try topic.draw(topic_ctx),
3714 });
3715 children.appendAssumeCapacity(.{
3716 .origin = .{ .col = ctx.max.width.? -| count_width, .row = 0 },
3717 .surface = try count.draw(count_ctx),
3718 });
3719
3720 return .{
3721 .size = .{ .width = ctx.max.width.?, .height = 1 },
3722 .widget = self.widget(),
3723 .buffer = &.{},
3724 .children = children.items,
3725 };
3726 }
3727 };
3728
3729 fn init(self: *ListModal, gpa: Allocator, client: *Client) void {
3730 self.* = .{
3731 .client = client,
3732 .filtered_items = std.ArrayList(Item).init(gpa),
3733 .items = .empty,
3734 .list_view = .{
3735 .children = .{
3736 .builder = .{
3737 .userdata = self,
3738 .buildFn = ListModal.getItem,
3739 },
3740 },
3741 },
3742 .text_field = .init(gpa, client.app.unicode),
3743 .finished = true,
3744 .is_shown = false,
3745 .focus = .text_field,
3746 .expecting_response = false,
3747 };
3748 self.text_field.style.bg = client.app.blendBg(10);
3749 self.text_field.userdata = self;
3750 self.text_field.onChange = ListModal.onChange;
3751 }
3752
3753 fn reset(self: *ListModal) !void {
3754 self.items.clearRetainingCapacity();
3755 self.filtered_items.clearAndFree();
3756 self.text_field.clearAndFree();
3757 self.finished = false;
3758 self.focus = .text_field;
3759 self.is_shown = false;
3760 }
3761
3762 fn show(self: *ListModal, ctx: *vxfw.EventContext) !void {
3763 self.is_shown = true;
3764 switch (self.focus) {
3765 .text_field => try ctx.requestFocus(self.text_field.widget()),
3766 .list => try ctx.requestFocus(self.list_view.widget()),
3767 }
3768 return ctx.consumeAndRedraw();
3769 }
3770
3771 pub fn widget(self: *ListModal) vxfw.Widget {
3772 return .{
3773 .userdata = self,
3774 .captureHandler = ListModal.captureHandler,
3775 .drawFn = ListModal._draw,
3776 };
3777 }
3778
3779 fn deinit(self: *ListModal, alloc: std.mem.Allocator) void {
3780 for (self.items.items) |item| {
3781 item.deinit(alloc);
3782 }
3783 self.items.deinit(alloc);
3784 self.filtered_items.deinit();
3785 self.text_field.deinit();
3786 self.* = undefined;
3787 }
3788
3789 fn addMessage(self: *ListModal, alloc: Allocator, msg: Message) !void {
3790 var iter = msg.paramIterator();
3791 // client, we skip this one
3792 _ = iter.next() orelse return;
3793 const channel = iter.next() orelse {
3794 log.warn("got RPL_LIST without channel", .{});
3795 return;
3796 };
3797 const count = iter.next() orelse {
3798 log.warn("got RPL_LIST without count", .{});
3799 return;
3800 };
3801 const topic = iter.next() orelse {
3802 log.warn("got RPL_LIST without topic", .{});
3803 return;
3804 };
3805 const item: Item = .{
3806 .name = try alloc.dupe(u8, channel),
3807 .count_str = try alloc.dupe(u8, count),
3808 .topic = try alloc.dupe(u8, topic),
3809 .count = try std.fmt.parseUnsigned(u32, count, 10),
3810 };
3811 try self.items.append(alloc, item);
3812 }
3813
3814 fn finish(self: *ListModal, ctx: *vxfw.EventContext) !void {
3815 self.finished = true;
3816 self.is_shown = true;
3817 std.mem.sort(Item, self.items.items, {}, Item.lessThan);
3818 self.filtered_items.clearRetainingCapacity();
3819 try self.filtered_items.appendSlice(self.items.items);
3820 try ctx.requestFocus(self.text_field.widget());
3821 }
3822
3823 fn onChange(ptr: ?*anyopaque, ctx: *vxfw.EventContext, input: []const u8) anyerror!void {
3824 const self: *ListModal = @ptrCast(@alignCast(ptr orelse unreachable));
3825 self.filtered_items.clearRetainingCapacity();
3826 for (self.items.items) |item| {
3827 if (std.mem.indexOf(u8, item.name, input)) |_| {
3828 try self.filtered_items.append(item);
3829 } else if (std.mem.indexOf(u8, item.topic, input)) |_| {
3830 try self.filtered_items.append(item);
3831 }
3832 }
3833 return ctx.consumeAndRedraw();
3834 }
3835
3836 fn captureHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
3837 const self: *ListModal = @ptrCast(@alignCast(ptr));
3838 switch (event) {
3839 .key_press => |key| {
3840 switch (self.focus) {
3841 .text_field => {
3842 if (key.matches(vaxis.Key.enter, .{})) {
3843 try ctx.requestFocus(self.list_view.widget());
3844 self.focus = .list;
3845 return ctx.consumeAndRedraw();
3846 } else if (key.matches(vaxis.Key.escape, .{})) {
3847 self.close(ctx);
3848 return;
3849 } else if (key.matches(vaxis.Key.up, .{})) {
3850 self.list_view.prevItem(ctx);
3851 return ctx.consumeAndRedraw();
3852 } else if (key.matches(vaxis.Key.down, .{})) {
3853 self.list_view.nextItem(ctx);
3854 return ctx.consumeAndRedraw();
3855 }
3856 },
3857 .list => {
3858 if (key.matches(vaxis.Key.escape, .{})) {
3859 try ctx.requestFocus(self.text_field.widget());
3860 self.focus = .text_field;
3861 return ctx.consumeAndRedraw();
3862 } else if (key.matches(vaxis.Key.enter, .{})) {
3863 if (self.filtered_items.items.len > 0) {
3864 // join the selected room, and deinit the view
3865 var buf: [128]u8 = undefined;
3866 const item = self.filtered_items.items[self.list_view.cursor];
3867 const cmd = try std.fmt.bufPrint(&buf, "/join {s}", .{item.name});
3868 try self.client.app.handleCommand(.{ .client = self.client }, cmd);
3869 }
3870 self.close(ctx);
3871 return;
3872 }
3873 },
3874 }
3875 },
3876 else => {},
3877 }
3878 }
3879
3880 fn close(self: *ListModal, ctx: *vxfw.EventContext) void {
3881 self.is_shown = false;
3882 const selected = self.client.app.selectedBuffer() orelse unreachable;
3883 self.client.app.selectBuffer(selected);
3884 return ctx.consumeAndRedraw();
3885 }
3886
3887 fn getItem(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget {
3888 const self: *const ListModal = @ptrCast(@alignCast(ptr));
3889 if (idx < self.filtered_items.items.len) {
3890 return self.filtered_items.items[idx].widget();
3891 }
3892 return null;
3893 }
3894
3895 fn _draw(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
3896 const self: *ListModal = @ptrCast(@alignCast(ptr));
3897 return self.draw(ctx);
3898 }
3899
3900 fn draw(self: *ListModal, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
3901 const max = ctx.max.size();
3902 var children: std.ArrayListUnmanaged(vxfw.SubSurface) = .empty;
3903
3904 try children.append(ctx.arena, .{
3905 .origin = .{ .col = 0, .row = 0 },
3906 .surface = try self.text_field.draw(ctx),
3907 });
3908 const list_ctx = ctx.withConstraints(
3909 ctx.min,
3910 .{ .width = max.width, .height = max.height - 2 },
3911 );
3912 try children.append(ctx.arena, .{
3913 .origin = .{ .col = 0, .row = 2 },
3914 .surface = try self.list_view.draw(list_ctx),
3915 });
3916
3917 return .{
3918 .size = max,
3919 .widget = self.widget(),
3920 .buffer = &.{},
3921 .children = children.items,
3922 };
3923 }
3924};
3925
3926/// All memory allocated with `allocator` will be freed before this function returns.
3927pub fn tcpConnectToHost(allocator: mem.Allocator, name: []const u8, port: u16) std.net.TcpConnectToHostError!std.net.Stream {
3928 const list = try std.net.getAddressList(allocator, name, port);
3929 defer list.deinit();
3930
3931 if (list.addrs.len == 0) return error.UnknownHostName;
3932
3933 for (list.addrs) |addr| {
3934 const stream = std.net.tcpConnectToAddress(addr) catch |err| {
3935 log.warn("error connecting to host: {}", .{err});
3936 continue;
3937 };
3938 return stream;
3939 }
3940 return std.posix.ConnectError.ConnectionRefused;
3941}
3942
3943test "caseFold" {
3944 try testing.expect(caseFold("a", "A"));
3945 try testing.expect(caseFold("aBcDeFgH", "abcdefgh"));
3946}
3947
3948test "simple message" {
3949 const msg: Message = .{ .bytes = "JOIN" };
3950 try testing.expect(msg.command() == .JOIN);
3951}
3952
3953test "simple message with extra whitespace" {
3954 const msg: Message = .{ .bytes = "JOIN " };
3955 try testing.expect(msg.command() == .JOIN);
3956}
3957
3958test "well formed message with tags, source, params" {
3959 const msg: Message = .{ .bytes = "@key=value :example.chat JOIN abc def" };
3960
3961 var tag_iter = msg.tagIterator();
3962 const tag = tag_iter.next();
3963 try testing.expect(tag != null);
3964 try testing.expectEqualStrings("key", tag.?.key);
3965 try testing.expectEqualStrings("value", tag.?.value);
3966 try testing.expect(tag_iter.next() == null);
3967
3968 const source = msg.source();
3969 try testing.expect(source != null);
3970 try testing.expectEqualStrings("example.chat", source.?);
3971 try testing.expect(msg.command() == .JOIN);
3972
3973 var param_iter = msg.paramIterator();
3974 const p1 = param_iter.next();
3975 const p2 = param_iter.next();
3976 try testing.expect(p1 != null);
3977 try testing.expect(p2 != null);
3978 try testing.expectEqualStrings("abc", p1.?);
3979 try testing.expectEqualStrings("def", p2.?);
3980
3981 try testing.expect(param_iter.next() == null);
3982}
3983
3984test "message with tags, source, params and extra whitespace" {
3985 const msg: Message = .{ .bytes = "@key=value :example.chat JOIN abc def" };
3986
3987 var tag_iter = msg.tagIterator();
3988 const tag = tag_iter.next();
3989 try testing.expect(tag != null);
3990 try testing.expectEqualStrings("key", tag.?.key);
3991 try testing.expectEqualStrings("value", tag.?.value);
3992 try testing.expect(tag_iter.next() == null);
3993
3994 const source = msg.source();
3995 try testing.expect(source != null);
3996 try testing.expectEqualStrings("example.chat", source.?);
3997 try testing.expect(msg.command() == .JOIN);
3998
3999 var param_iter = msg.paramIterator();
4000 const p1 = param_iter.next();
4001 const p2 = param_iter.next();
4002 try testing.expect(p1 != null);
4003 try testing.expect(p2 != null);
4004 try testing.expectEqualStrings("abc", p1.?);
4005 try testing.expectEqualStrings("def", p2.?);
4006
4007 try testing.expect(param_iter.next() == null);
4008}
4009
4010test "param iterator: simple list" {
4011 var iter: Message.ParamIterator = .{ .params = "a b c" };
4012 var i: usize = 0;
4013 while (iter.next()) |param| {
4014 switch (i) {
4015 0 => try testing.expectEqualStrings("a", param),
4016 1 => try testing.expectEqualStrings("b", param),
4017 2 => try testing.expectEqualStrings("c", param),
4018 else => return error.TooManyParams,
4019 }
4020 i += 1;
4021 }
4022 try testing.expect(i == 3);
4023}
4024
4025test "param iterator: trailing colon" {
4026 var iter: Message.ParamIterator = .{ .params = "* LS :" };
4027 var i: usize = 0;
4028 while (iter.next()) |param| {
4029 switch (i) {
4030 0 => try testing.expectEqualStrings("*", param),
4031 1 => try testing.expectEqualStrings("LS", param),
4032 2 => try testing.expectEqualStrings("", param),
4033 else => return error.TooManyParams,
4034 }
4035 i += 1;
4036 }
4037 try testing.expect(i == 3);
4038}
4039
4040test "param iterator: colon" {
4041 var iter: Message.ParamIterator = .{ .params = "* LS :sasl multi-prefix" };
4042 var i: usize = 0;
4043 while (iter.next()) |param| {
4044 switch (i) {
4045 0 => try testing.expectEqualStrings("*", param),
4046 1 => try testing.expectEqualStrings("LS", param),
4047 2 => try testing.expectEqualStrings("sasl multi-prefix", param),
4048 else => return error.TooManyParams,
4049 }
4050 i += 1;
4051 }
4052 try testing.expect(i == 3);
4053}
4054
4055test "param iterator: colon and leading colon" {
4056 var iter: Message.ParamIterator = .{ .params = "* LS ::)" };
4057 var i: usize = 0;
4058 while (iter.next()) |param| {
4059 switch (i) {
4060 0 => try testing.expectEqualStrings("*", param),
4061 1 => try testing.expectEqualStrings("LS", param),
4062 2 => try testing.expectEqualStrings(":)", param),
4063 else => return error.TooManyParams,
4064 }
4065 i += 1;
4066 }
4067 try testing.expect(i == 3);
4068}