an experimental irc client
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
36pub const Buffer = union(enum) {
37 client: *Client,
38 channel: *Channel,
39};
40
41pub const Command = enum {
42 RPL_WELCOME, // 001
43 RPL_YOURHOST, // 002
44 RPL_CREATED, // 003
45 RPL_MYINFO, // 004
46 RPL_ISUPPORT, // 005
47
48 RPL_ENDOFWHO, // 315
49 RPL_TOPIC, // 332
50 RPL_WHOREPLY, // 352
51 RPL_NAMREPLY, // 353
52 RPL_WHOSPCRPL, // 354
53 RPL_ENDOFNAMES, // 366
54
55 RPL_LOGGEDIN, // 900
56 RPL_SASLSUCCESS, // 903
57
58 // Named commands
59 AUTHENTICATE,
60 AWAY,
61 BATCH,
62 BOUNCER,
63 CAP,
64 CHATHISTORY,
65 JOIN,
66 MARKREAD,
67 NOTICE,
68 PART,
69 PRIVMSG,
70
71 unknown,
72
73 const map = std.StaticStringMap(Command).initComptime(.{
74 .{ "001", .RPL_WELCOME },
75 .{ "002", .RPL_YOURHOST },
76 .{ "003", .RPL_CREATED },
77 .{ "004", .RPL_MYINFO },
78 .{ "005", .RPL_ISUPPORT },
79
80 .{ "315", .RPL_ENDOFWHO },
81 .{ "332", .RPL_TOPIC },
82 .{ "352", .RPL_WHOREPLY },
83 .{ "353", .RPL_NAMREPLY },
84 .{ "354", .RPL_WHOSPCRPL },
85 .{ "366", .RPL_ENDOFNAMES },
86
87 .{ "900", .RPL_LOGGEDIN },
88 .{ "903", .RPL_SASLSUCCESS },
89
90 .{ "AUTHENTICATE", .AUTHENTICATE },
91 .{ "AWAY", .AWAY },
92 .{ "BATCH", .BATCH },
93 .{ "BOUNCER", .BOUNCER },
94 .{ "CAP", .CAP },
95 .{ "CHATHISTORY", .CHATHISTORY },
96 .{ "JOIN", .JOIN },
97 .{ "MARKREAD", .MARKREAD },
98 .{ "NOTICE", .NOTICE },
99 .{ "PART", .PART },
100 .{ "PRIVMSG", .PRIVMSG },
101 });
102
103 pub fn parse(cmd: []const u8) Command {
104 return map.get(cmd) orelse .unknown;
105 }
106};
107
108pub const Channel = struct {
109 client: *Client,
110 name: []const u8,
111 topic: ?[]const u8 = null,
112 members: std.ArrayList(Member),
113 in_flight: struct {
114 who: bool = false,
115 names: bool = false,
116 } = .{},
117
118 messages: std.ArrayList(Message),
119 history_requested: bool = false,
120 who_requested: bool = false,
121 at_oldest: bool = false,
122 last_read: i64 = 0,
123 has_unread: bool = false,
124 has_unread_highlight: bool = false,
125
126 has_mouse: bool = false,
127
128 view: vxfw.SplitView,
129 member_view: vxfw.ListView,
130 text_field: vxfw.TextField,
131
132 scroll: struct {
133 /// Line offset from the bottom message
134 offset: u16 = 0,
135 /// Message offset into the list of messages. We use this to lock the viewport if we have a
136 /// scroll. Otherwise, when offset == 0 this is effectively ignored (and should be 0)
137 msg_offset: ?u16 = null,
138
139 /// Pending scroll we have to handle while drawing. This could be up or down. By convention
140 /// we say positive is a scroll up.
141 pending: i17 = 0,
142 } = .{},
143
144 message_view: struct {
145 mouse: ?vaxis.Mouse = null,
146 hovered_message: ?Message = null,
147 } = .{},
148
149 completer: Completer,
150 completer_shown: bool = false,
151
152 // Gutter (left side where time is printed) width
153 const gutter_width = 6;
154
155 pub const Member = struct {
156 user: *User,
157
158 /// Highest channel membership prefix (or empty space if no prefix)
159 prefix: u8,
160
161 channel: *Channel,
162 has_mouse: bool = false,
163
164 pub fn compare(_: void, lhs: Member, rhs: Member) bool {
165 return if (lhs.prefix != ' ' and rhs.prefix == ' ')
166 true
167 else if (lhs.prefix == ' ' and rhs.prefix != ' ')
168 false
169 else
170 std.ascii.orderIgnoreCase(lhs.user.nick, rhs.user.nick).compare(.lt);
171 }
172
173 pub fn widget(self: *Member) vxfw.Widget {
174 return .{
175 .userdata = self,
176 .eventHandler = Member.eventHandler,
177 .drawFn = Member.draw,
178 };
179 }
180
181 fn eventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
182 const self: *Member = @ptrCast(@alignCast(ptr));
183 switch (event) {
184 .mouse => |mouse| {
185 if (!self.has_mouse) {
186 self.has_mouse = true;
187 try ctx.setMouseShape(.pointer);
188 }
189 switch (mouse.type) {
190 .press => {
191 if (mouse.button == .left) {
192 // Open a private message with this user
193 const client = self.channel.client;
194 const ch = try client.getOrCreateChannel(self.user.nick);
195 try client.requestHistory(.after, ch);
196 client.app.selectChannelName(client, ch.name);
197 return ctx.consumeAndRedraw();
198 }
199 if (mouse.button == .right) {
200 // Insert nick at cursor
201 try self.channel.text_field.insertSliceAtCursor(self.user.nick);
202 return ctx.consumeAndRedraw();
203 }
204 },
205 else => {},
206 }
207 },
208 .mouse_enter => {
209 self.has_mouse = true;
210 try ctx.setMouseShape(.pointer);
211 },
212 .mouse_leave => {
213 self.has_mouse = false;
214 try ctx.setMouseShape(.default);
215 },
216 else => {},
217 }
218 }
219
220 pub fn draw(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
221 const self: *Member = @ptrCast(@alignCast(ptr));
222 var style: vaxis.Style = if (self.user.away)
223 .{ .fg = .{ .index = 8 } }
224 else
225 .{ .fg = self.user.color };
226 if (self.has_mouse) style.reverse = true;
227 var prefix = try ctx.arena.alloc(u8, 1);
228 prefix[0] = self.prefix;
229 const text: vxfw.RichText = .{
230 .text = &.{
231 .{ .text = prefix, .style = style },
232 .{ .text = self.user.nick, .style = style },
233 },
234 .softwrap = false,
235 };
236 var surface = try text.draw(ctx);
237 surface.widget = self.widget();
238 return surface;
239 }
240 };
241
242 pub fn init(
243 self: *Channel,
244 gpa: Allocator,
245 client: *Client,
246 name: []const u8,
247 unicode: *const vaxis.Unicode,
248 ) Allocator.Error!void {
249 self.* = .{
250 .name = try gpa.dupe(u8, name),
251 .members = std.ArrayList(Channel.Member).init(gpa),
252 .messages = std.ArrayList(Message).init(gpa),
253 .client = client,
254 .view = .{
255 .lhs = self.contentWidget(),
256 .rhs = self.member_view.widget(),
257 .width = 16,
258 .constrain = .rhs,
259 },
260 .member_view = .{
261 .children = .{
262 .builder = .{
263 .userdata = self,
264 .buildFn = Channel.buildMemberList,
265 },
266 },
267 .draw_cursor = false,
268 },
269 .text_field = vxfw.TextField.init(gpa, unicode),
270 .completer = Completer.init(gpa),
271 };
272
273 self.text_field.userdata = self;
274 self.text_field.onSubmit = Channel.onSubmit;
275 }
276
277 fn onSubmit(ptr: ?*anyopaque, ctx: *vxfw.EventContext, input: []const u8) anyerror!void {
278 const self: *Channel = @ptrCast(@alignCast(ptr orelse unreachable));
279
280 // Copy the input into a temporary buffer
281 var buf: [1024]u8 = undefined;
282 @memcpy(buf[0..input.len], input);
283 const local = buf[0..input.len];
284 // Free the text field. We do this here because the command may destroy our channel
285 self.text_field.clearAndFree();
286 self.completer_shown = false;
287
288 if (std.mem.startsWith(u8, local, "/")) {
289 try self.client.app.handleCommand(.{ .channel = self }, local);
290 } else {
291 try self.client.print("PRIVMSG {s} :{s}\r\n", .{ self.name, local });
292 }
293 ctx.redraw = true;
294 }
295
296 pub fn deinit(self: *Channel, alloc: std.mem.Allocator) void {
297 alloc.free(self.name);
298 self.members.deinit();
299 if (self.topic) |topic| {
300 alloc.free(topic);
301 }
302 for (self.messages.items) |msg| {
303 alloc.free(msg.bytes);
304 }
305 self.messages.deinit();
306 self.text_field.deinit();
307 self.completer.deinit();
308 }
309
310 pub fn compare(_: void, lhs: *Channel, rhs: *Channel) bool {
311 return std.ascii.orderIgnoreCase(lhs.name, rhs.name).compare(std.math.CompareOperator.lt);
312 }
313
314 pub fn compareRecentMessages(self: *Channel, lhs: Member, rhs: Member) bool {
315 var l: i64 = 0;
316 var r: i64 = 0;
317 var iter = std.mem.reverseIterator(self.messages.items);
318 while (iter.next()) |msg| {
319 if (msg.source()) |source| {
320 const bang = std.mem.indexOfScalar(u8, source, '!') orelse source.len;
321 const nick = source[0..bang];
322
323 if (l == 0 and msg.time() != null and std.mem.eql(u8, lhs.user.nick, nick)) {
324 l = msg.time().?.unixTimestamp();
325 } else if (r == 0 and msg.time() != null and std.mem.eql(u8, rhs.user.nick, nick))
326 r = msg.time().?.unixTimestamp();
327 }
328 if (l > 0 and r > 0) break;
329 }
330 return l < r;
331 }
332
333 pub fn nameWidget(self: *Channel, selected: bool) vxfw.Widget {
334 return .{
335 .userdata = self,
336 .eventHandler = Channel.typeErasedEventHandler,
337 .drawFn = if (selected)
338 Channel.typeErasedDrawNameSelected
339 else
340 Channel.typeErasedDrawName,
341 };
342 }
343
344 fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
345 const self: *Channel = @ptrCast(@alignCast(ptr));
346 switch (event) {
347 .mouse => |mouse| {
348 try ctx.setMouseShape(.pointer);
349 if (mouse.type == .press and mouse.button == .left) {
350 self.client.app.selectBuffer(.{ .channel = self });
351 try ctx.requestFocus(self.text_field.widget());
352 const buf = &self.client.app.title_buf;
353 const suffix = " - comlink";
354 if (self.name.len + suffix.len <= buf.len) {
355 const title = try std.fmt.bufPrint(buf, "{s}{s}", .{ self.name, suffix });
356 try ctx.setTitle(title);
357 } else {
358 const title = try std.fmt.bufPrint(
359 buf,
360 "{s}{s}",
361 .{ self.name[0 .. buf.len - suffix.len], suffix },
362 );
363 try ctx.setTitle(title);
364 }
365 return ctx.consumeAndRedraw();
366 }
367 },
368 .mouse_enter => {
369 try ctx.setMouseShape(.pointer);
370 self.has_mouse = true;
371 },
372 .mouse_leave => {
373 try ctx.setMouseShape(.default);
374 self.has_mouse = false;
375 },
376 else => {},
377 }
378 }
379
380 pub fn drawName(self: *Channel, ctx: vxfw.DrawContext, selected: bool) Allocator.Error!vxfw.Surface {
381 var style: vaxis.Style = .{};
382 if (selected) style.bg = .{ .index = 8 };
383 if (self.has_mouse) style.bg = .{ .index = 8 };
384 if (self.client.app.selectedBuffer()) |buffer| {
385 switch (buffer) {
386 .client => {},
387 .channel => |channel| {
388 if (channel == self and self.messageViewIsAtBottom()) {
389 self.has_unread = false;
390 self.has_unread_highlight = false;
391 }
392 },
393 }
394 }
395 if (self.has_unread) {
396 style.fg = .{ .index = 4 };
397 style.bold = true;
398 }
399 const prefix: vxfw.RichText.TextSpan = if (self.has_unread_highlight)
400 .{ .text = " ●︎", .style = .{ .fg = .{ .index = 1 } } }
401 else
402 .{ .text = " " };
403 const text: vxfw.RichText = if (std.mem.startsWith(u8, self.name, "#"))
404 .{
405 .text = &.{
406 prefix,
407 .{ .text = "#", .style = .{ .fg = .{ .index = 8 } } },
408 .{ .text = self.name[1..], .style = style },
409 },
410 .softwrap = false,
411 }
412 else
413 .{
414 .text = &.{
415 .{ .text = " " },
416 .{ .text = self.name, .style = style },
417 },
418 .softwrap = false,
419 };
420
421 var surface = try text.draw(ctx);
422 // Replace the widget reference so we can handle the events
423 surface.widget = self.nameWidget(selected);
424 return surface;
425 }
426
427 fn typeErasedDrawName(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
428 const self: *Channel = @ptrCast(@alignCast(ptr));
429 return self.drawName(ctx, false);
430 }
431
432 fn typeErasedDrawNameSelected(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
433 const self: *Channel = @ptrCast(@alignCast(ptr));
434 return self.drawName(ctx, true);
435 }
436
437 pub fn sortMembers(self: *Channel) void {
438 std.sort.insertion(Member, self.members.items, {}, Member.compare);
439 }
440
441 pub fn addMember(self: *Channel, user: *User, args: struct {
442 prefix: ?u8 = null,
443 sort: bool = true,
444 }) Allocator.Error!void {
445 for (self.members.items) |*member| {
446 if (user == member.user) {
447 // Update the prefix for an existing member if the prefix is
448 // known
449 if (args.prefix) |p| member.prefix = p;
450 return;
451 }
452 }
453
454 try self.members.append(.{
455 .user = user,
456 .prefix = args.prefix orelse ' ',
457 .channel = self,
458 });
459
460 if (args.sort) {
461 self.sortMembers();
462 }
463 }
464
465 pub fn removeMember(self: *Channel, user: *User) void {
466 for (self.members.items, 0..) |member, i| {
467 if (user == member.user) {
468 _ = self.members.orderedRemove(i);
469 return;
470 }
471 }
472 }
473
474 /// issue a MARKREAD command for this channel. The most recent message in the channel will be used as
475 /// the last read time
476 pub fn markRead(self: *Channel) !void {
477 self.has_unread = false;
478 self.has_unread_highlight = false;
479 const last_msg = self.messages.getLastOrNull() orelse return;
480 const time = last_msg.time() orelse return;
481 if (time.unixTimestamp() > self.last_read) {
482 const time_tag = last_msg.getTag("time") orelse return;
483 try self.client.print(
484 "MARKREAD {s} timestamp={s}\r\n",
485 .{
486 self.name,
487 time_tag,
488 },
489 );
490 }
491 }
492
493 pub fn contentWidget(self: *Channel) vxfw.Widget {
494 return .{
495 .userdata = self,
496 .captureHandler = Channel.captureEvent,
497 .drawFn = Channel.typeErasedViewDraw,
498 };
499 }
500
501 fn captureEvent(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
502 const self: *Channel = @ptrCast(@alignCast(ptr));
503 switch (event) {
504 .key_press => |key| {
505 if (key.matches(vaxis.Key.tab, .{})) {
506 ctx.redraw = true;
507 // if we already have a completion word, then we are
508 // cycling through the options
509 if (self.completer_shown) {
510 const line = self.completer.next(ctx);
511 self.text_field.clearRetainingCapacity();
512 try self.text_field.insertSliceAtCursor(line);
513 } else {
514 var completion_buf: [maximum_message_size]u8 = undefined;
515 const content = self.text_field.sliceToCursor(&completion_buf);
516 try self.completer.reset(content);
517 if (self.completer.kind == .nick) {
518 try self.completer.findMatches(self);
519 }
520 self.completer_shown = true;
521 }
522 return;
523 }
524 if (key.matches(vaxis.Key.tab, .{ .shift = true })) {
525 if (self.completer_shown) {
526 const line = self.completer.prev(ctx);
527 self.text_field.clearRetainingCapacity();
528 try self.text_field.insertSliceAtCursor(line);
529 }
530 return;
531 }
532 if (key.matches(vaxis.Key.page_up, .{})) {
533 self.scroll.pending += self.client.app.last_height / 2;
534 try self.doScroll(ctx);
535 return ctx.consumeAndRedraw();
536 }
537 if (key.matches(vaxis.Key.page_down, .{})) {
538 self.scroll.pending -|= self.client.app.last_height / 2;
539 try self.doScroll(ctx);
540 return ctx.consumeAndRedraw();
541 }
542 if (key.matches(vaxis.Key.home, .{})) {
543 self.scroll.pending -= self.scroll.offset;
544 self.scroll.msg_offset = null;
545 try self.doScroll(ctx);
546 return ctx.consumeAndRedraw();
547 }
548 if (!key.isModifier()) {
549 self.completer_shown = false;
550 }
551 },
552 else => {},
553 }
554 }
555
556 fn typeErasedViewDraw(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
557 const self: *Channel = @ptrCast(@alignCast(ptr));
558 if (!self.who_requested) {
559 try self.client.whox(self);
560 }
561
562 const max = ctx.max.size();
563 var children = std.ArrayList(vxfw.SubSurface).init(ctx.arena);
564
565 {
566 // Draw the topic
567 const topic: vxfw.Text = .{
568 .text = self.topic orelse "",
569 .softwrap = false,
570 };
571
572 const topic_sub: vxfw.SubSurface = .{
573 .origin = .{ .col = 0, .row = 0 },
574 .surface = try topic.draw(ctx),
575 };
576
577 try children.append(topic_sub);
578
579 // Draw a border below the topic
580 const bot = "─";
581 var writer = try std.ArrayList(u8).initCapacity(ctx.arena, bot.len * max.width);
582 try writer.writer().writeBytesNTimes(bot, max.width);
583
584 const border: vxfw.Text = .{
585 .text = writer.items,
586 .softwrap = false,
587 };
588
589 const topic_border: vxfw.SubSurface = .{
590 .origin = .{ .col = 0, .row = 1 },
591 .surface = try border.draw(ctx),
592 };
593 try children.append(topic_border);
594 }
595
596 const msg_view_ctx = ctx.withConstraints(.{ .height = 0, .width = 0 }, .{
597 .height = max.height - 4,
598 .width = max.width - 1,
599 });
600 const message_view = try self.drawMessageView(msg_view_ctx);
601 try children.append(.{
602 .origin = .{ .row = 2, .col = 0 },
603 .surface = message_view,
604 });
605
606 const scrollbar_ctx = ctx.withConstraints(
607 ctx.min,
608 .{ .width = 1, .height = max.height - 4 },
609 );
610
611 var scrollbars: Scrollbar = .{
612 // Estimate number of lines per message
613 .total = @intCast(self.messages.items.len * 3),
614 .view_size = max.height - 4,
615 .bottom = self.scroll.offset,
616 };
617 const scrollbar_surface = try scrollbars.draw(scrollbar_ctx);
618 try children.append(.{
619 .origin = .{ .col = max.width - 1, .row = 2 },
620 .surface = scrollbar_surface,
621 });
622
623 {
624 // Draw the character limit. 14 is length of message overhead "PRIVMSG :\r\n"
625 const max_limit = maximum_message_size -| self.name.len -| 14 -| self.name.len;
626 const limit = try std.fmt.allocPrint(
627 ctx.arena,
628 "{d}/{d}",
629 .{ self.text_field.buf.realLength(), max_limit },
630 );
631 const style: vaxis.Style = if (self.text_field.buf.realLength() > max_limit)
632 .{ .fg = .{ .index = 1 } }
633 else
634 .{ .dim = true };
635 const limit_text: vxfw.Text = .{ .text = limit, .style = style };
636 const limit_ctx = ctx.withConstraints(.{}, ctx.max);
637 const limit_s = try limit_text.draw(limit_ctx);
638
639 try children.append(.{
640 .origin = .{ .col = max.width -| limit_s.size.width, .row = max.height - 1 },
641 .surface = limit_s,
642 });
643
644 const text_field_ctx = ctx.withConstraints(
645 ctx.min,
646 .{ .height = 1, .width = max.width -| limit_s.size.width -| 1 },
647 );
648
649 // Draw the text field
650 try children.append(.{
651 .origin = .{ .col = 0, .row = max.height - 1 },
652 .surface = try self.text_field.draw(text_field_ctx),
653 });
654 }
655
656 if (self.completer_shown) {
657 const widest: u16 = @intCast(self.completer.widestMatch(ctx));
658 const completer_ctx = ctx.withConstraints(ctx.min, .{ .height = 10, .width = widest + 2 });
659 const surface = try self.completer.list_view.draw(completer_ctx);
660 const height: u16 = @intCast(@min(10, self.completer.options.items.len));
661 try children.append(.{
662 .origin = .{ .col = 0, .row = max.height -| 1 -| height },
663 .surface = surface,
664 });
665 }
666
667 return .{
668 .size = max,
669 .widget = self.contentWidget(),
670 .buffer = &.{},
671 .children = children.items,
672 };
673 }
674
675 fn handleMessageViewEvent(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
676 const self: *Channel = @ptrCast(@alignCast(ptr));
677 switch (event) {
678 .mouse => |mouse| {
679 if (self.message_view.mouse) |last_mouse| {
680 // We need to redraw if the column entered the gutter
681 if (last_mouse.col >= gutter_width and mouse.col < gutter_width)
682 ctx.redraw = true
683 // Or if the column exited the gutter
684 else if (last_mouse.col < gutter_width and mouse.col >= gutter_width)
685 ctx.redraw = true
686 // Or if the row changed
687 else if (last_mouse.row != mouse.row)
688 ctx.redraw = true
689 // Or if we did a middle click, and now released it
690 else if (last_mouse.button == .middle)
691 ctx.redraw = true;
692 } else {
693 // If we didn't have the mouse previously, we redraw
694 ctx.redraw = true;
695 }
696
697 // Save this mouse state for when we draw
698 self.message_view.mouse = mouse;
699
700 // A middle press on a hovered message means we copy the content
701 if (mouse.type == .press and
702 mouse.button == .middle and
703 self.message_view.hovered_message != null)
704 {
705 const msg = self.message_view.hovered_message orelse unreachable;
706 var iter = msg.paramIterator();
707 // Skip the target
708 _ = iter.next() orelse unreachable;
709 // Get the content
710 const content = iter.next() orelse unreachable;
711 try ctx.copyToClipboard(content);
712 return ctx.consumeAndRedraw();
713 }
714 if (mouse.button == .wheel_down) {
715 self.scroll.pending -|= 1;
716 ctx.consume_event = true;
717 }
718 if (mouse.button == .wheel_up) {
719 self.scroll.pending +|= 1;
720 ctx.consume_event = true;
721 }
722 if (self.scroll.pending != 0) {
723 try self.doScroll(ctx);
724 }
725 },
726 .mouse_leave => {
727 self.message_view.mouse = null;
728 self.message_view.hovered_message = null;
729 ctx.redraw = true;
730 },
731 .tick => {
732 try self.doScroll(ctx);
733 },
734 else => {},
735 }
736 }
737
738 /// Consumes any pending scrolls and schedules another tick if needed
739 fn doScroll(self: *Channel, ctx: *vxfw.EventContext) anyerror!void {
740 defer {
741 // At the end of this function, we anchor our msg_offset if we have any amount of
742 // scroll. This prevents new messages from automatically scrolling us
743 if (self.scroll.offset > 0 and self.scroll.msg_offset == null) {
744 self.scroll.msg_offset = @intCast(self.messages.items.len);
745 }
746 // If we have no offset, we reset our anchor
747 if (self.scroll.offset == 0) {
748 self.scroll.msg_offset = null;
749 }
750 }
751 const animation_tick: u32 = 30;
752 // No pending scroll. Return early
753 if (self.scroll.pending == 0) return;
754
755 // Scroll up
756 if (self.scroll.pending > 0) {
757 // Consume 1 line, and schedule a tick
758 self.scroll.offset += 1;
759 self.scroll.pending -= 1;
760 ctx.redraw = true;
761 return ctx.tick(animation_tick, self.messageViewWidget());
762 }
763
764 // From here, we only scroll down. First, we check if we are at the bottom already. If we
765 // are, we have nothing to do
766 if (self.scroll.offset == 0) {
767 // Already at bottom. Nothing to do
768 self.scroll.pending = 0;
769 return;
770 }
771
772 // Scroll down
773 if (self.scroll.pending < 0) {
774 // Consume 1 line, and schedule a tick
775 self.scroll.offset -= 1;
776 self.scroll.pending += 1;
777 ctx.redraw = true;
778 return ctx.tick(animation_tick, self.messageViewWidget());
779 }
780 }
781
782 fn messageViewWidget(self: *Channel) vxfw.Widget {
783 return .{
784 .userdata = self,
785 .eventHandler = Channel.handleMessageViewEvent,
786 .drawFn = Channel.typeErasedDrawMessageView,
787 };
788 }
789
790 fn typeErasedDrawMessageView(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
791 const self: *Channel = @ptrCast(@alignCast(ptr));
792 return self.drawMessageView(ctx);
793 }
794
795 pub fn messageViewIsAtBottom(self: *Channel) bool {
796 if (self.scroll.msg_offset) |msg_offset| {
797 return self.scroll.offset == 0 and
798 msg_offset == self.messages.items.len and
799 self.scroll.pending == 0;
800 }
801 return self.scroll.offset == 0 and
802 self.scroll.msg_offset == null and
803 self.scroll.pending == 0;
804 }
805
806 fn drawMessageView(self: *Channel, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
807 self.message_view.hovered_message = null;
808 const max = ctx.max.size();
809 if (max.width == 0 or
810 max.height == 0 or
811 self.messages.items.len == 0)
812 {
813 return .{
814 .size = max,
815 .widget = self.messageViewWidget(),
816 .buffer = &.{},
817 .children = &.{},
818 };
819 }
820
821 var children = std.ArrayList(vxfw.SubSurface).init(ctx.arena);
822
823 // Row is the row we are printing on. We add the offset to achieve our scroll location
824 var row: i17 = max.height + self.scroll.offset;
825 // Message offset
826 const offset = self.scroll.msg_offset orelse self.messages.items.len;
827
828 const messages = self.messages.items[0..offset];
829 var iter = std.mem.reverseIterator(messages);
830
831 var sender: []const u8 = "";
832 var maybe_instant: ?zeit.Instant = null;
833
834 {
835 assert(messages.len > 0);
836 // Initialize sender and maybe_instant to the last message values
837 const last_msg = iter.next() orelse unreachable;
838 // Reset iter index
839 iter.index += 1;
840 sender = last_msg.senderNick() orelse "";
841 maybe_instant = last_msg.localTime(&self.client.app.tz);
842 }
843
844 while (iter.next()) |msg| {
845 // Break if we have gone past the top of the screen
846 if (row < 0) break;
847
848 // Get the sender nickname of the *next* message. Next meaning next message in the
849 // iterator, which is chronologically the previous message since we are printing in
850 // reverse
851 const next_sender: []const u8 = blk: {
852 const next_msg = iter.next() orelse break :blk "";
853 // Fix the index of the iterator
854 iter.index += 1;
855 break :blk next_msg.senderNick() orelse "";
856 };
857
858 // Get the server time for the *next* message. We'll use this to decide printing of
859 // username and time
860 const maybe_next_instant: ?zeit.Instant = blk: {
861 const next_msg = iter.next() orelse break :blk null;
862 // Fix the index of the iterator
863 iter.index += 1;
864 break :blk next_msg.localTime(&self.client.app.tz);
865 };
866
867 defer {
868 // After this loop, we want to save these values for the next iteration
869 maybe_instant = maybe_next_instant;
870 sender = next_sender;
871 }
872
873 // Message content
874 const content: []const u8 = blk: {
875 var param_iter = msg.paramIterator();
876 // First param is the target, we don't need it
877 _ = param_iter.next() orelse unreachable;
878 break :blk param_iter.next() orelse "";
879 };
880
881 // Get the user ref for this sender
882 const user = try self.client.getOrCreateUser(sender);
883
884 const spans = try formatMessage(ctx.arena, user, content);
885
886 // Draw the message so we have it's wrapped height
887 const text: vxfw.RichText = .{ .text = spans };
888 const child_ctx = ctx.withConstraints(
889 .{ .width = 0, .height = 0 },
890 .{ .width = max.width -| gutter_width, .height = null },
891 );
892 const surface = try text.draw(child_ctx);
893
894 // See if our message contains the mouse. We'll highlight it if it does
895 const message_has_mouse: bool = blk: {
896 const mouse = self.message_view.mouse orelse break :blk false;
897 break :blk mouse.col >= gutter_width and
898 mouse.row < row and
899 mouse.row >= row - surface.size.height;
900 };
901
902 if (message_has_mouse) {
903 const last_mouse = self.message_view.mouse orelse unreachable;
904 // If we had a middle click, we highlight yellow to indicate we copied the text
905 const bg: vaxis.Color = if (last_mouse.button == .middle and last_mouse.type == .press)
906 .{ .index = 3 }
907 else
908 .{ .index = 8 };
909 // Set the style for the entire message
910 for (surface.buffer) |*cell| {
911 cell.style.bg = bg;
912 }
913 // Create a surface to highlight the entire area under the message
914 const hl_surface = try vxfw.Surface.init(
915 ctx.arena,
916 text.widget(),
917 .{ .width = max.width -| gutter_width, .height = surface.size.height },
918 );
919 const base: vaxis.Cell = .{ .style = .{ .bg = bg } };
920 @memset(hl_surface.buffer, base);
921
922 try children.append(.{
923 .origin = .{ .row = row - surface.size.height, .col = gutter_width },
924 .surface = hl_surface,
925 });
926
927 self.message_view.hovered_message = msg;
928 }
929
930 // Adjust the row we print on for the wrapped height of this message
931 row -= surface.size.height;
932 try children.append(.{
933 .origin = .{ .row = row, .col = gutter_width },
934 .surface = surface,
935 });
936
937 // If we have a time, print it in the gutter
938 if (maybe_instant) |instant| {
939 var style: vaxis.Style = .{ .dim = true };
940
941 // The time text we will print
942 const buf: []const u8 = blk: {
943 const time = instant.time();
944 // Check our next time. If *this* message occurs on a different day, we want to
945 // print the date
946 if (maybe_next_instant) |next_instant| {
947 const next_time = next_instant.time();
948 if (time.day != next_time.day) {
949 style = .{};
950 break :blk try std.fmt.allocPrint(
951 ctx.arena,
952 "{d:0>2}/{d:0>2}",
953 .{ @intFromEnum(time.month), time.day },
954 );
955 }
956 }
957
958 // if it is the first message, we also want to print the date
959 if (iter.index == 0) {
960 style = .{};
961 break :blk try std.fmt.allocPrint(
962 ctx.arena,
963 "{d:0>2}/{d:0>2}",
964 .{ @intFromEnum(time.month), time.day },
965 );
966 }
967
968 // Otherwise, we print clock time
969 break :blk try std.fmt.allocPrint(
970 ctx.arena,
971 "{d:0>2}:{d:0>2}",
972 .{ time.hour, time.minute },
973 );
974 };
975
976 // If the message has our nick, we'll highlight the time
977 if (std.mem.indexOf(u8, content, self.client.nickname())) |_| {
978 style.fg = .{ .index = 3 };
979 style.reverse = true;
980 }
981
982 const time_text: vxfw.Text = .{
983 .text = buf,
984 .style = style,
985 .softwrap = false,
986 };
987 try children.append(.{
988 .origin = .{ .row = row, .col = 0 },
989 .surface = try time_text.draw(child_ctx),
990 });
991 }
992
993 var printed_sender: bool = false;
994 // Check if we need to print the sender of this message. We do this when the timegap
995 // between this message and next message is > 5 minutes, or if the sender is
996 // different
997 if (sender.len > 0 and
998 printSender(sender, next_sender, maybe_instant, maybe_next_instant))
999 {
1000 // Back up one row to print
1001 row -= 1;
1002 // If we need to print the sender, it will be *this* messages sender
1003 const sender_text: vxfw.Text = .{
1004 .text = user.nick,
1005 .style = .{ .fg = user.color, .bold = true },
1006 };
1007 const sender_surface = try sender_text.draw(child_ctx);
1008 try children.append(.{
1009 .origin = .{ .row = row, .col = gutter_width },
1010 .surface = sender_surface,
1011 });
1012 if (self.message_view.mouse) |mouse| {
1013 if (mouse.row == row and
1014 mouse.col >= gutter_width and
1015 user.real_name != null)
1016 {
1017 const realname: vxfw.Text = .{
1018 .text = user.real_name orelse unreachable,
1019 .style = .{ .fg = .{ .index = 8 }, .italic = true },
1020 };
1021 try children.append(.{
1022 .origin = .{
1023 .row = row,
1024 .col = gutter_width + sender_surface.size.width + 1,
1025 },
1026 .surface = try realname.draw(child_ctx),
1027 });
1028 }
1029 }
1030
1031 // Back up 1 more row for spacing
1032 row -= 1;
1033 printed_sender = true;
1034 }
1035
1036 // Check if we should print a "last read" line. If the next message we will print is
1037 // before the last_read, and this message is after the last_read then it is our border.
1038 // Before
1039 if (maybe_next_instant != null and maybe_instant != null) {
1040 const this = maybe_instant.?.unixTimestamp();
1041 const next = maybe_next_instant.?.unixTimestamp();
1042 if (this > self.last_read and next <= self.last_read) {
1043 const bot = "─";
1044 var writer = try std.ArrayList(u8).initCapacity(ctx.arena, bot.len * max.width);
1045 try writer.writer().writeBytesNTimes(bot, max.width);
1046
1047 const border: vxfw.Text = .{
1048 .text = writer.items,
1049 .style = .{ .fg = .{ .index = 1 } },
1050 .softwrap = false,
1051 };
1052
1053 // We don't need to backup a line if we printed the sender
1054 if (!printed_sender) row -= 1;
1055
1056 const unread: vxfw.SubSurface = .{
1057 .origin = .{ .col = 0, .row = row },
1058 .surface = try border.draw(ctx),
1059 };
1060 try children.append(unread);
1061 const new: vxfw.RichText = .{
1062 .text = &.{
1063 .{ .text = "", .style = .{ .fg = .{ .index = 1 } } },
1064 .{ .text = " New ", .style = .{ .fg = .{ .index = 1 }, .reverse = true } },
1065 },
1066 .softwrap = false,
1067 };
1068 const new_sub: vxfw.SubSurface = .{
1069 .origin = .{ .col = max.width - 6, .row = row },
1070 .surface = try new.draw(ctx),
1071 };
1072 try children.append(new_sub);
1073 }
1074 }
1075 }
1076
1077 // Request more history when we are within 5 messages of the top of the screen
1078 if (iter.index < 5 and !self.at_oldest) {
1079 try self.client.requestHistory(.before, self);
1080 }
1081
1082 return .{
1083 .size = max,
1084 .widget = self.messageViewWidget(),
1085 .buffer = &.{},
1086 .children = children.items,
1087 };
1088 }
1089
1090 fn buildMemberList(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget {
1091 const self: *const Channel = @ptrCast(@alignCast(ptr));
1092 if (idx < self.members.items.len) {
1093 return self.members.items[idx].widget();
1094 }
1095 return null;
1096 }
1097
1098 // Helper function which tells us if we should print the sender of a message, based on he
1099 // current message sender and time, and the (chronologically) previous message sent
1100 fn printSender(
1101 a_sender: []const u8,
1102 b_sender: []const u8,
1103 a_instant: ?zeit.Instant,
1104 b_instant: ?zeit.Instant,
1105 ) bool {
1106 // If sender is different, we always print the sender
1107 if (!std.mem.eql(u8, a_sender, b_sender)) return true;
1108
1109 if (a_instant != null and b_instant != null) {
1110 const a_ts = a_instant.?.timestamp_ns;
1111 const b_ts = b_instant.?.timestamp_ns;
1112 const delta: i64 = @intCast(a_ts - b_ts);
1113 return @abs(delta) > (5 * std.time.ns_per_min);
1114 }
1115
1116 // In any other case, we
1117 return false;
1118 }
1119};
1120
1121pub const User = struct {
1122 nick: []const u8,
1123 away: bool = false,
1124 color: vaxis.Color = .default,
1125 real_name: ?[]const u8 = null,
1126
1127 pub fn deinit(self: *const User, alloc: std.mem.Allocator) void {
1128 alloc.free(self.nick);
1129 if (self.real_name) |realname| alloc.free(realname);
1130 }
1131};
1132
1133/// an irc message
1134pub const Message = struct {
1135 bytes: []const u8,
1136
1137 pub const ParamIterator = struct {
1138 params: ?[]const u8,
1139 index: usize = 0,
1140
1141 pub fn next(self: *ParamIterator) ?[]const u8 {
1142 const params = self.params orelse return null;
1143 if (self.index >= params.len) return null;
1144
1145 // consume leading whitespace
1146 while (self.index < params.len) {
1147 if (params[self.index] != ' ') break;
1148 self.index += 1;
1149 }
1150
1151 const start = self.index;
1152 if (start >= params.len) return null;
1153
1154 // If our first byte is a ':', we return the rest of the string as a
1155 // single param (or the empty string)
1156 if (params[start] == ':') {
1157 self.index = params.len;
1158 if (start == params.len - 1) {
1159 return "";
1160 }
1161 return params[start + 1 ..];
1162 }
1163
1164 // Find the first index of space. If we don't have any, the reset of
1165 // the line is the last param
1166 self.index = std.mem.indexOfScalarPos(u8, params, self.index, ' ') orelse {
1167 defer self.index = params.len;
1168 return params[start..];
1169 };
1170
1171 return params[start..self.index];
1172 }
1173 };
1174
1175 pub const Tag = struct {
1176 key: []const u8,
1177 value: []const u8,
1178 };
1179
1180 pub const TagIterator = struct {
1181 tags: []const u8,
1182 index: usize = 0,
1183
1184 // tags are a list of key=value pairs delimited by semicolons.
1185 // key[=value] [; key[=value]]
1186 pub fn next(self: *TagIterator) ?Tag {
1187 if (self.index >= self.tags.len) return null;
1188
1189 // find next delimiter
1190 const end = std.mem.indexOfScalarPos(u8, self.tags, self.index, ';') orelse self.tags.len;
1191 var kv_delim = std.mem.indexOfScalarPos(u8, self.tags, self.index, '=') orelse end;
1192 // it's possible to have tags like this:
1193 // @bot;account=botaccount;+typing=active
1194 // where the first tag doesn't have a value. Guard against the
1195 // kv_delim being past the end position
1196 if (kv_delim > end) kv_delim = end;
1197
1198 defer self.index = end + 1;
1199
1200 return .{
1201 .key = self.tags[self.index..kv_delim],
1202 .value = if (end == kv_delim) "" else self.tags[kv_delim + 1 .. end],
1203 };
1204 }
1205 };
1206
1207 pub fn tagIterator(msg: Message) TagIterator {
1208 const src = msg.bytes;
1209 if (src[0] != '@') return .{ .tags = "" };
1210
1211 assert(src.len > 1);
1212 const n = std.mem.indexOfScalarPos(u8, src, 1, ' ') orelse src.len;
1213 return .{ .tags = src[1..n] };
1214 }
1215
1216 pub fn source(msg: Message) ?[]const u8 {
1217 const src = msg.bytes;
1218 var i: usize = 0;
1219
1220 // get past tags
1221 if (src[0] == '@') {
1222 assert(src.len > 1);
1223 i = std.mem.indexOfScalarPos(u8, src, 1, ' ') orelse return null;
1224 }
1225
1226 // consume whitespace
1227 while (i < src.len) : (i += 1) {
1228 if (src[i] != ' ') break;
1229 }
1230
1231 // Start of source
1232 if (src[i] == ':') {
1233 assert(src.len > i);
1234 i += 1;
1235 const end = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse src.len;
1236 return src[i..end];
1237 }
1238
1239 return null;
1240 }
1241
1242 pub fn command(msg: Message) Command {
1243 const src = msg.bytes;
1244 var i: usize = 0;
1245
1246 // get past tags
1247 if (src[0] == '@') {
1248 assert(src.len > 1);
1249 i = std.mem.indexOfScalarPos(u8, src, 1, ' ') orelse return .unknown;
1250 }
1251 // consume whitespace
1252 while (i < src.len) : (i += 1) {
1253 if (src[i] != ' ') break;
1254 }
1255
1256 // get past source
1257 if (src[i] == ':') {
1258 assert(src.len > i);
1259 i += 1;
1260 i = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse return .unknown;
1261 }
1262 // consume whitespace
1263 while (i < src.len) : (i += 1) {
1264 if (src[i] != ' ') break;
1265 }
1266
1267 assert(src.len > i);
1268 // Find next space
1269 const end = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse src.len;
1270 return Command.parse(src[i..end]);
1271 }
1272
1273 pub fn paramIterator(msg: Message) ParamIterator {
1274 const src = msg.bytes;
1275 var i: usize = 0;
1276
1277 // get past tags
1278 if (src[0] == '@') {
1279 i = std.mem.indexOfScalarPos(u8, src, 0, ' ') orelse return .{ .params = "" };
1280 }
1281 // consume whitespace
1282 while (i < src.len) : (i += 1) {
1283 if (src[i] != ' ') break;
1284 }
1285
1286 // get past source
1287 if (src[i] == ':') {
1288 assert(src.len > i);
1289 i += 1;
1290 i = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse return .{ .params = "" };
1291 }
1292 // consume whitespace
1293 while (i < src.len) : (i += 1) {
1294 if (src[i] != ' ') break;
1295 }
1296
1297 // get past command
1298 i = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse return .{ .params = "" };
1299
1300 assert(src.len > i);
1301 return .{ .params = src[i + 1 ..] };
1302 }
1303
1304 /// Returns the value of the tag 'key', if present
1305 pub fn getTag(self: Message, key: []const u8) ?[]const u8 {
1306 var tag_iter = self.tagIterator();
1307 while (tag_iter.next()) |tag| {
1308 if (!std.mem.eql(u8, tag.key, key)) continue;
1309 return tag.value;
1310 }
1311 return null;
1312 }
1313
1314 pub fn time(self: Message) ?zeit.Instant {
1315 const val = self.getTag("time") orelse return null;
1316
1317 // Return null if we can't parse the time
1318 const instant = zeit.instant(.{
1319 .source = .{ .iso8601 = val },
1320 .timezone = &zeit.utc,
1321 }) catch return null;
1322
1323 return instant;
1324 }
1325
1326 pub fn localTime(self: Message, tz: *const zeit.TimeZone) ?zeit.Instant {
1327 const utc = self.time() orelse return null;
1328 return utc.in(tz);
1329 }
1330
1331 pub fn compareTime(_: void, lhs: Message, rhs: Message) bool {
1332 const lhs_time = lhs.time() orelse return false;
1333 const rhs_time = rhs.time() orelse return false;
1334
1335 return lhs_time.timestamp_ns < rhs_time.timestamp_ns;
1336 }
1337
1338 /// Returns the NICK of the sender of the message
1339 pub fn senderNick(self: Message) ?[]const u8 {
1340 const src = self.source() orelse return null;
1341 if (std.mem.indexOfScalar(u8, src, '!')) |idx| return src[0..idx];
1342 if (std.mem.indexOfScalar(u8, src, '@')) |idx| return src[0..idx];
1343 return src;
1344 }
1345};
1346
1347pub const Client = struct {
1348 pub const Config = struct {
1349 user: []const u8,
1350 nick: []const u8,
1351 password: []const u8,
1352 real_name: []const u8,
1353 server: []const u8,
1354 port: ?u16,
1355 network_id: ?[]const u8 = null,
1356 network_nick: ?[]const u8 = null,
1357 name: ?[]const u8 = null,
1358 tls: bool = true,
1359 lua_table: i32,
1360 };
1361
1362 pub const Capabilities = struct {
1363 @"away-notify": bool = false,
1364 batch: bool = false,
1365 @"echo-message": bool = false,
1366 @"message-tags": bool = false,
1367 sasl: bool = false,
1368 @"server-time": bool = false,
1369
1370 @"draft/chathistory": bool = false,
1371 @"draft/no-implicit-names": bool = false,
1372 @"draft/read-marker": bool = false,
1373
1374 @"soju.im/bouncer-networks": bool = false,
1375 @"soju.im/bouncer-networks-notify": bool = false,
1376 };
1377
1378 /// ISupport are features only advertised via ISUPPORT that we care about
1379 pub const ISupport = struct {
1380 whox: bool = false,
1381 prefix: []const u8 = "",
1382 };
1383
1384 pub const Status = enum(u8) {
1385 disconnected,
1386 connecting,
1387 connected,
1388 };
1389
1390 alloc: std.mem.Allocator,
1391 app: *comlink.App,
1392 client: tls.Connection(std.net.Stream),
1393 stream: std.net.Stream,
1394 config: Config,
1395
1396 channels: std.ArrayList(*Channel),
1397 users: std.StringHashMap(*User),
1398
1399 should_close: bool = false,
1400 status: std.atomic.Value(Status),
1401
1402 caps: Capabilities = .{},
1403 supports: ISupport = .{},
1404
1405 batches: std.StringHashMap(*Channel),
1406 write_queue: *comlink.WriteQueue,
1407
1408 thread: ?std.Thread = null,
1409
1410 redraw: std.atomic.Value(bool),
1411 read_buf_mutex: std.Thread.Mutex,
1412 read_buf: std.ArrayList(u8),
1413
1414 has_mouse: bool,
1415 retry_delay_s: u8,
1416
1417 pub fn init(
1418 alloc: std.mem.Allocator,
1419 app: *comlink.App,
1420 wq: *comlink.WriteQueue,
1421 cfg: Config,
1422 ) !Client {
1423 return .{
1424 .alloc = alloc,
1425 .app = app,
1426 .client = undefined,
1427 .stream = undefined,
1428 .config = cfg,
1429 .channels = std.ArrayList(*Channel).init(alloc),
1430 .users = std.StringHashMap(*User).init(alloc),
1431 .batches = std.StringHashMap(*Channel).init(alloc),
1432 .write_queue = wq,
1433 .status = std.atomic.Value(Status).init(.disconnected),
1434 .redraw = std.atomic.Value(bool).init(false),
1435 .read_buf_mutex = .{},
1436 .read_buf = std.ArrayList(u8).init(alloc),
1437 .has_mouse = false,
1438 .retry_delay_s = 0,
1439 };
1440 }
1441
1442 /// Closes the connection
1443 pub fn close(self: *Client) void {
1444 self.should_close = true;
1445 if (self.status.load(.unordered) == .disconnected) return;
1446 if (self.config.tls) {
1447 self.client.close() catch {};
1448 }
1449 self.stream.close();
1450 }
1451
1452 pub fn deinit(self: *Client) void {
1453 if (self.thread) |thread| {
1454 thread.join();
1455 self.thread = null;
1456 }
1457 // id gets allocated in the main thread. We need to deallocate it here if
1458 // we have one
1459 if (self.config.network_id) |id| self.alloc.free(id);
1460 if (self.config.name) |name| self.alloc.free(name);
1461
1462 if (self.config.network_nick) |nick| self.alloc.free(nick);
1463
1464 for (self.channels.items) |channel| {
1465 channel.deinit(self.alloc);
1466 self.alloc.destroy(channel);
1467 }
1468 self.channels.deinit();
1469
1470 var user_iter = self.users.valueIterator();
1471 while (user_iter.next()) |user| {
1472 user.*.deinit(self.alloc);
1473 self.alloc.destroy(user.*);
1474 }
1475 self.users.deinit();
1476 self.alloc.free(self.supports.prefix);
1477 var batches = self.batches;
1478 var iter = batches.keyIterator();
1479 while (iter.next()) |key| {
1480 self.alloc.free(key.*);
1481 }
1482 batches.deinit();
1483 self.read_buf.deinit();
1484 }
1485
1486 fn retryWidget(self: *Client) vxfw.Widget {
1487 return .{
1488 .userdata = self,
1489 .eventHandler = Client.retryTickHandler,
1490 .drawFn = Client.typeErasedDrawNameSelected,
1491 };
1492 }
1493
1494 pub fn retryTickHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
1495 const self: *Client = @ptrCast(@alignCast(ptr));
1496 switch (event) {
1497 .tick => {
1498 const status = self.status.load(.unordered);
1499 switch (status) {
1500 .disconnected => {
1501 // Clean up a thread if we have one
1502 if (self.thread) |thread| {
1503 thread.join();
1504 self.thread = null;
1505 }
1506 self.status.store(.connecting, .unordered);
1507 self.thread = try std.Thread.spawn(.{}, Client.readThread, .{self});
1508 },
1509 .connecting => {},
1510 .connected => {
1511 // Reset the delay
1512 self.retry_delay_s = 0;
1513 return;
1514 },
1515 }
1516 // Increment the retry and try again
1517 self.retry_delay_s = @max(self.retry_delay_s <<| 1, 1);
1518 log.debug("retry in {d} seconds", .{self.retry_delay_s});
1519 try ctx.tick(@as(u32, self.retry_delay_s) * std.time.ms_per_s, self.retryWidget());
1520 },
1521 else => {},
1522 }
1523 }
1524
1525 pub fn view(self: *Client) vxfw.Widget {
1526 return .{
1527 .userdata = self,
1528 .eventHandler = Client.eventHandler,
1529 .drawFn = Client.typeErasedViewDraw,
1530 };
1531 }
1532
1533 fn eventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
1534 _ = ptr;
1535 _ = ctx;
1536 _ = event;
1537 }
1538
1539 fn typeErasedViewDraw(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
1540 const self: *Client = @ptrCast(@alignCast(ptr));
1541 const text: vxfw.Text = .{ .text = "content" };
1542 var surface = try text.draw(ctx);
1543 surface.widget = self.view();
1544 return surface;
1545 }
1546
1547 pub fn nameWidget(self: *Client, selected: bool) vxfw.Widget {
1548 return .{
1549 .userdata = self,
1550 .eventHandler = Client.typeErasedEventHandler,
1551 .drawFn = if (selected)
1552 Client.typeErasedDrawNameSelected
1553 else
1554 Client.typeErasedDrawName,
1555 };
1556 }
1557
1558 pub fn drawName(self: *Client, ctx: vxfw.DrawContext, selected: bool) Allocator.Error!vxfw.Surface {
1559 var style: vaxis.Style = .{};
1560 if (selected) style.reverse = true;
1561 if (self.has_mouse) style.bg = .{ .index = 8 };
1562 if (self.status.load(.unordered) == .disconnected) style.fg = .{ .index = 8 };
1563
1564 const name = self.config.name orelse self.config.server;
1565
1566 const text: vxfw.RichText = .{
1567 .text = &.{
1568 .{ .text = name, .style = style },
1569 },
1570 .softwrap = false,
1571 };
1572 var surface = try text.draw(ctx);
1573 // Replace the widget reference so we can handle the events
1574 surface.widget = self.nameWidget(selected);
1575 return surface;
1576 }
1577
1578 fn typeErasedDrawName(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
1579 const self: *Client = @ptrCast(@alignCast(ptr));
1580 return self.drawName(ctx, false);
1581 }
1582
1583 fn typeErasedDrawNameSelected(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
1584 const self: *Client = @ptrCast(@alignCast(ptr));
1585 return self.drawName(ctx, true);
1586 }
1587
1588 fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
1589 const self: *Client = @ptrCast(@alignCast(ptr));
1590 switch (event) {
1591 .mouse => |mouse| {
1592 try ctx.setMouseShape(.pointer);
1593 if (mouse.type == .press and mouse.button == .left) {
1594 self.app.selectBuffer(.{ .client = self });
1595 const buf = &self.app.title_buf;
1596 const suffix = " - comlink";
1597 const name = self.config.name orelse self.config.server;
1598 if (name.len + suffix.len <= buf.len) {
1599 const title = try std.fmt.bufPrint(buf, "{s}{s}", .{ name, suffix });
1600 try ctx.setTitle(title);
1601 } else {
1602 const title = try std.fmt.bufPrint(
1603 buf,
1604 "{s}{s}",
1605 .{ name[0 .. buf.len - suffix.len], suffix },
1606 );
1607 try ctx.setTitle(title);
1608 }
1609 return ctx.consumeAndRedraw();
1610 }
1611 },
1612 .mouse_enter => {
1613 try ctx.setMouseShape(.pointer);
1614 self.has_mouse = true;
1615 },
1616 .mouse_leave => {
1617 try ctx.setMouseShape(.default);
1618 self.has_mouse = false;
1619 },
1620 else => {},
1621 }
1622 }
1623
1624 pub fn drainFifo(self: *Client, ctx: *vxfw.EventContext) void {
1625 self.read_buf_mutex.lock();
1626 defer self.read_buf_mutex.unlock();
1627 var i: usize = 0;
1628 while (std.mem.indexOfPos(u8, self.read_buf.items, i, "\r\n")) |idx| {
1629 ctx.redraw = true;
1630 defer i = idx + 2;
1631 log.debug("[<-{s}] {s}", .{
1632 self.config.name orelse self.config.server,
1633 self.read_buf.items[i..idx],
1634 });
1635 self.handleEvent(self.read_buf.items[i..idx], ctx) catch |err| {
1636 log.err("error: {}", .{err});
1637 };
1638 }
1639 self.read_buf.replaceRangeAssumeCapacity(0, i, "");
1640 }
1641
1642 pub fn handleEvent(self: *Client, line: []const u8, ctx: *vxfw.EventContext) !void {
1643 const msg: Message = .{ .bytes = line };
1644 const client = self;
1645 switch (msg.command()) {
1646 .unknown => {},
1647 .CAP => {
1648 // syntax: <client> <ACK/NACK> :caps
1649 var iter = msg.paramIterator();
1650 _ = iter.next() orelse return; // client
1651 const ack_or_nak = iter.next() orelse return;
1652 const caps = iter.next() orelse return;
1653 var cap_iter = mem.splitScalar(u8, caps, ' ');
1654 while (cap_iter.next()) |cap| {
1655 if (mem.eql(u8, ack_or_nak, "ACK")) {
1656 client.ack(cap);
1657 if (mem.eql(u8, cap, "sasl"))
1658 try client.queueWrite("AUTHENTICATE PLAIN\r\n");
1659 } else if (mem.eql(u8, ack_or_nak, "NAK")) {
1660 log.debug("CAP not supported {s}", .{cap});
1661 }
1662 }
1663 },
1664 .AUTHENTICATE => {
1665 var iter = msg.paramIterator();
1666 while (iter.next()) |param| {
1667 // A '+' is the continuuation to send our
1668 // AUTHENTICATE info
1669 if (!mem.eql(u8, param, "+")) continue;
1670 var buf: [4096]u8 = undefined;
1671 const config = client.config;
1672 const sasl = try std.fmt.bufPrint(
1673 &buf,
1674 "{s}\x00{s}\x00{s}",
1675 .{ config.user, config.nick, config.password },
1676 );
1677
1678 // Create a buffer big enough for the base64 encoded string
1679 const b64_buf = try self.alloc.alloc(u8, Base64Encoder.calcSize(sasl.len));
1680 defer self.alloc.free(b64_buf);
1681 const encoded = Base64Encoder.encode(b64_buf, sasl);
1682 // Make our message
1683 const auth = try std.fmt.bufPrint(
1684 &buf,
1685 "AUTHENTICATE {s}\r\n",
1686 .{encoded},
1687 );
1688 try client.queueWrite(auth);
1689 if (config.network_id) |id| {
1690 const bind = try std.fmt.bufPrint(
1691 &buf,
1692 "BOUNCER BIND {s}\r\n",
1693 .{id},
1694 );
1695 try client.queueWrite(bind);
1696 }
1697 try client.queueWrite("CAP END\r\n");
1698 }
1699 },
1700 .RPL_WELCOME => {
1701 const now = try zeit.instant(.{});
1702 var now_buf: [30]u8 = undefined;
1703 const now_fmt = try now.time().bufPrint(&now_buf, .rfc3339);
1704
1705 const past = try now.subtract(.{ .days = 7 });
1706 var past_buf: [30]u8 = undefined;
1707 const past_fmt = try past.time().bufPrint(&past_buf, .rfc3339);
1708
1709 var buf: [128]u8 = undefined;
1710 const targets = try std.fmt.bufPrint(
1711 &buf,
1712 "CHATHISTORY TARGETS timestamp={s} timestamp={s} 50\r\n",
1713 .{ now_fmt, past_fmt },
1714 );
1715 try client.queueWrite(targets);
1716 // on_connect callback
1717 try lua.onConnect(self.app.lua, client);
1718 },
1719 .RPL_YOURHOST => {},
1720 .RPL_CREATED => {},
1721 .RPL_MYINFO => {},
1722 .RPL_ISUPPORT => {
1723 // syntax: <client> <token>[ <token>] :are supported
1724 var iter = msg.paramIterator();
1725 _ = iter.next() orelse return; // client
1726 while (iter.next()) |token| {
1727 if (mem.eql(u8, token, "WHOX"))
1728 client.supports.whox = true
1729 else if (mem.startsWith(u8, token, "PREFIX")) {
1730 const prefix = blk: {
1731 const idx = mem.indexOfScalar(u8, token, ')') orelse
1732 // default is "@+"
1733 break :blk try self.alloc.dupe(u8, "@+");
1734 break :blk try self.alloc.dupe(u8, token[idx + 1 ..]);
1735 };
1736 client.supports.prefix = prefix;
1737 }
1738 }
1739 },
1740 .RPL_LOGGEDIN => {},
1741 .RPL_TOPIC => {
1742 // syntax: <client> <channel> :<topic>
1743 var iter = msg.paramIterator();
1744 _ = iter.next() orelse return; // client ("*")
1745 const channel_name = iter.next() orelse return; // channel
1746 const topic = iter.next() orelse return; // topic
1747
1748 var channel = try client.getOrCreateChannel(channel_name);
1749 if (channel.topic) |old_topic| {
1750 self.alloc.free(old_topic);
1751 }
1752 channel.topic = try self.alloc.dupe(u8, topic);
1753 },
1754 .RPL_SASLSUCCESS => {},
1755 .RPL_WHOREPLY => {
1756 // syntax: <client> <channel> <username> <host> <server> <nick> <flags> :<hopcount> <real name>
1757 var iter = msg.paramIterator();
1758 _ = iter.next() orelse return; // client
1759 const channel_name = iter.next() orelse return; // channel
1760 if (mem.eql(u8, channel_name, "*")) return;
1761 _ = iter.next() orelse return; // username
1762 _ = iter.next() orelse return; // host
1763 _ = iter.next() orelse return; // server
1764 const nick = iter.next() orelse return; // nick
1765 const flags = iter.next() orelse return; // flags
1766
1767 const user_ptr = try client.getOrCreateUser(nick);
1768 if (mem.indexOfScalar(u8, flags, 'G')) |_| user_ptr.away = true;
1769 var channel = try client.getOrCreateChannel(channel_name);
1770
1771 const prefix = for (flags) |c| {
1772 if (std.mem.indexOfScalar(u8, client.supports.prefix, c)) |_| {
1773 break c;
1774 }
1775 } else ' ';
1776
1777 try channel.addMember(user_ptr, .{ .prefix = prefix });
1778 },
1779 .RPL_WHOSPCRPL => {
1780 // syntax: <client> <channel> <nick> <flags> :<realname>
1781 var iter = msg.paramIterator();
1782 _ = iter.next() orelse return;
1783 const channel_name = iter.next() orelse return; // channel
1784 const nick = iter.next() orelse return;
1785 const flags = iter.next() orelse return;
1786
1787 const user_ptr = try client.getOrCreateUser(nick);
1788 if (iter.next()) |real_name| {
1789 if (user_ptr.real_name) |old_name| {
1790 self.alloc.free(old_name);
1791 }
1792 user_ptr.real_name = try self.alloc.dupe(u8, real_name);
1793 }
1794 if (mem.indexOfScalar(u8, flags, 'G')) |_| user_ptr.away = true;
1795 var channel = try client.getOrCreateChannel(channel_name);
1796
1797 const prefix = for (flags) |c| {
1798 if (std.mem.indexOfScalar(u8, client.supports.prefix, c)) |_| {
1799 break c;
1800 }
1801 } else ' ';
1802
1803 try channel.addMember(user_ptr, .{ .prefix = prefix });
1804 },
1805 .RPL_ENDOFWHO => {
1806 // syntax: <client> <mask> :End of WHO list
1807 var iter = msg.paramIterator();
1808 _ = iter.next() orelse return; // client
1809 const channel_name = iter.next() orelse return; // channel
1810 if (mem.eql(u8, channel_name, "*")) return;
1811 var channel = try client.getOrCreateChannel(channel_name);
1812 channel.in_flight.who = false;
1813 },
1814 .RPL_NAMREPLY => {
1815 // syntax: <client> <symbol> <channel> :[<prefix>]<nick>{ [<prefix>]<nick>}
1816 var iter = msg.paramIterator();
1817 _ = iter.next() orelse return; // client
1818 _ = iter.next() orelse return; // symbol
1819 const channel_name = iter.next() orelse return; // channel
1820 const names = iter.next() orelse return;
1821 var channel = try client.getOrCreateChannel(channel_name);
1822 var name_iter = std.mem.splitScalar(u8, names, ' ');
1823 while (name_iter.next()) |name| {
1824 const nick, const prefix = for (client.supports.prefix) |ch| {
1825 if (name[0] == ch) {
1826 break .{ name[1..], name[0] };
1827 }
1828 } else .{ name, ' ' };
1829
1830 if (prefix != ' ') {
1831 log.debug("HAS PREFIX {s}", .{name});
1832 }
1833
1834 const user_ptr = try client.getOrCreateUser(nick);
1835
1836 try channel.addMember(user_ptr, .{ .prefix = prefix, .sort = false });
1837 }
1838
1839 channel.sortMembers();
1840 },
1841 .RPL_ENDOFNAMES => {
1842 // syntax: <client> <channel> :End of /NAMES list
1843 var iter = msg.paramIterator();
1844 _ = iter.next() orelse return; // client
1845 const channel_name = iter.next() orelse return; // channel
1846 var channel = try client.getOrCreateChannel(channel_name);
1847 channel.in_flight.names = false;
1848 },
1849 .BOUNCER => {
1850 var iter = msg.paramIterator();
1851 while (iter.next()) |param| {
1852 if (mem.eql(u8, param, "NETWORK")) {
1853 const id = iter.next() orelse continue;
1854 const attr = iter.next() orelse continue;
1855 // check if we already have this network
1856 for (self.app.clients.items, 0..) |cl, i| {
1857 if (cl.config.network_id) |net_id| {
1858 if (mem.eql(u8, net_id, id)) {
1859 if (mem.eql(u8, attr, "*")) {
1860 // * means the network was
1861 // deleted
1862 cl.deinit();
1863 _ = self.app.clients.swapRemove(i);
1864 }
1865 return;
1866 }
1867 }
1868 }
1869
1870 var cfg = client.config;
1871 cfg.network_id = try self.alloc.dupe(u8, id);
1872
1873 var attr_iter = std.mem.splitScalar(u8, attr, ';');
1874 while (attr_iter.next()) |kv| {
1875 const n = std.mem.indexOfScalar(u8, kv, '=') orelse continue;
1876 const key = kv[0..n];
1877 if (mem.eql(u8, key, "name"))
1878 cfg.name = try self.alloc.dupe(u8, kv[n + 1 ..])
1879 else if (mem.eql(u8, key, "nickname"))
1880 cfg.network_nick = try self.alloc.dupe(u8, kv[n + 1 ..]);
1881 }
1882 try self.app.connect(cfg);
1883 }
1884 }
1885 },
1886 .AWAY => {
1887 const src = msg.source() orelse return;
1888 var iter = msg.paramIterator();
1889 const n = std.mem.indexOfScalar(u8, src, '!') orelse src.len;
1890 const user = try client.getOrCreateUser(src[0..n]);
1891 // If there are any params, the user is away. Otherwise
1892 // they are back.
1893 user.away = if (iter.next()) |_| true else false;
1894 },
1895 .BATCH => {
1896 var iter = msg.paramIterator();
1897 const tag = iter.next() orelse return;
1898 switch (tag[0]) {
1899 '+' => {
1900 const batch_type = iter.next() orelse return;
1901 if (mem.eql(u8, batch_type, "chathistory")) {
1902 const target = iter.next() orelse return;
1903 var channel = try client.getOrCreateChannel(target);
1904 channel.at_oldest = true;
1905 const duped_tag = try self.alloc.dupe(u8, tag[1..]);
1906 try client.batches.put(duped_tag, channel);
1907 }
1908 },
1909 '-' => {
1910 const key = client.batches.getKey(tag[1..]) orelse return;
1911 var chan = client.batches.get(key) orelse @panic("key should exist here");
1912 chan.history_requested = false;
1913 _ = client.batches.remove(key);
1914 self.alloc.free(key);
1915 },
1916 else => {},
1917 }
1918 },
1919 .CHATHISTORY => {
1920 var iter = msg.paramIterator();
1921 const should_targets = iter.next() orelse return;
1922 if (!mem.eql(u8, should_targets, "TARGETS")) return;
1923 const target = iter.next() orelse return;
1924 // we only add direct messages, not more channels
1925 assert(target.len > 0);
1926 if (target[0] == '#') return;
1927
1928 var channel = try client.getOrCreateChannel(target);
1929 const user_ptr = try client.getOrCreateUser(target);
1930 const me_ptr = try client.getOrCreateUser(client.nickname());
1931 try channel.addMember(user_ptr, .{});
1932 try channel.addMember(me_ptr, .{});
1933 // we set who_requested so we don't try to request
1934 // who on DMs
1935 channel.who_requested = true;
1936 var buf: [128]u8 = undefined;
1937 const mark_read = try std.fmt.bufPrint(
1938 &buf,
1939 "MARKREAD {s}\r\n",
1940 .{channel.name},
1941 );
1942 try client.queueWrite(mark_read);
1943 try client.requestHistory(.after, channel);
1944 },
1945 .JOIN => {
1946 // get the user
1947 const src = msg.source() orelse return;
1948 const n = std.mem.indexOfScalar(u8, src, '!') orelse src.len;
1949 const user = try client.getOrCreateUser(src[0..n]);
1950
1951 // get the channel
1952 var iter = msg.paramIterator();
1953 const target = iter.next() orelse return;
1954 var channel = try client.getOrCreateChannel(target);
1955
1956 // If it's our nick, we request chat history
1957 if (mem.eql(u8, user.nick, client.nickname())) {
1958 try client.requestHistory(.after, channel);
1959 if (self.app.explicit_join) {
1960 self.app.selectChannelName(client, target);
1961 self.app.explicit_join = false;
1962 }
1963 } else try channel.addMember(user, .{});
1964 },
1965 .MARKREAD => {
1966 var iter = msg.paramIterator();
1967 const target = iter.next() orelse return;
1968 const timestamp = iter.next() orelse return;
1969 const equal = std.mem.indexOfScalar(u8, timestamp, '=') orelse return;
1970 const last_read = zeit.instant(.{
1971 .source = .{
1972 .iso8601 = timestamp[equal + 1 ..],
1973 },
1974 }) catch |err| {
1975 log.err("couldn't convert timestamp: {}", .{err});
1976 return;
1977 };
1978 var channel = try client.getOrCreateChannel(target);
1979 channel.last_read = last_read.unixTimestamp();
1980 const last_msg = channel.messages.getLastOrNull() orelse return;
1981 const time = last_msg.time() orelse return;
1982 if (time.unixTimestamp() > channel.last_read)
1983 channel.has_unread = true
1984 else
1985 channel.has_unread = false;
1986 },
1987 .PART => {
1988 // get the user
1989 const src = msg.source() orelse return;
1990 const n = std.mem.indexOfScalar(u8, src, '!') orelse src.len;
1991 const user = try client.getOrCreateUser(src[0..n]);
1992
1993 // get the channel
1994 var iter = msg.paramIterator();
1995 const target = iter.next() orelse return;
1996
1997 if (mem.eql(u8, user.nick, client.nickname())) {
1998 for (client.channels.items, 0..) |channel, i| {
1999 if (!mem.eql(u8, channel.name, target)) continue;
2000 client.app.prevChannel();
2001 var chan = client.channels.orderedRemove(i);
2002 chan.deinit(self.app.alloc);
2003 self.alloc.destroy(chan);
2004 break;
2005 }
2006 } else {
2007 const channel = try client.getOrCreateChannel(target);
2008 channel.removeMember(user);
2009 }
2010 },
2011 .PRIVMSG, .NOTICE => {
2012 // syntax: <target> :<message>
2013 const msg2: Message = .{
2014 .bytes = try self.app.alloc.dupe(u8, msg.bytes),
2015 };
2016 var iter = msg2.paramIterator();
2017 const target = blk: {
2018 const tgt = iter.next() orelse return;
2019 if (mem.eql(u8, tgt, client.nickname())) {
2020 // If the target is us, it likely has our
2021 // hostname in it.
2022 const source = msg2.source() orelse return;
2023 const n = mem.indexOfScalar(u8, source, '!') orelse source.len;
2024 break :blk source[0..n];
2025 } else break :blk tgt;
2026 };
2027
2028 // We handle batches separately. When we encounter a
2029 // PRIVMSG from a batch, we use the original target
2030 // from the batch start. We also never notify from a
2031 // batched message. Batched messages also require
2032 // sorting
2033 if (msg2.getTag("batch")) |tag| {
2034 const entry = client.batches.getEntry(tag) orelse @panic("TODO");
2035 var channel = entry.value_ptr.*;
2036 try channel.messages.append(msg2);
2037 std.sort.insertion(Message, channel.messages.items, {}, Message.compareTime);
2038 if (channel.scroll.msg_offset) |offset| {
2039 channel.scroll.msg_offset = offset + 1;
2040 }
2041 channel.at_oldest = false;
2042 const time = msg2.time() orelse return;
2043 if (time.unixTimestamp() > channel.last_read) {
2044 channel.has_unread = true;
2045 const content = iter.next() orelse return;
2046 if (std.mem.indexOf(u8, content, client.nickname())) |_| {
2047 channel.has_unread_highlight = true;
2048 }
2049 }
2050 } else {
2051 // standard handling
2052 var channel = try client.getOrCreateChannel(target);
2053 try channel.messages.append(msg2);
2054 const content = iter.next() orelse return;
2055 var has_highlight = false;
2056 {
2057 const sender: []const u8 = blk: {
2058 const src = msg2.source() orelse break :blk "";
2059 const l = std.mem.indexOfScalar(u8, src, '!') orelse
2060 std.mem.indexOfScalar(u8, src, '@') orelse
2061 src.len;
2062 break :blk src[0..l];
2063 };
2064 try lua.onMessage(self.app.lua, client, channel.name, sender, content);
2065 }
2066 if (std.mem.indexOf(u8, content, client.nickname())) |_| {
2067 var buf: [64]u8 = undefined;
2068 const title_or_err = if (msg2.source()) |source|
2069 std.fmt.bufPrint(&buf, "{s} - {s}", .{ channel.name, source })
2070 else
2071 std.fmt.bufPrint(&buf, "{s}", .{channel.name});
2072 const title = title_or_err catch title: {
2073 const len = @min(buf.len, channel.name.len);
2074 @memcpy(buf[0..len], channel.name[0..len]);
2075 break :title buf[0..len];
2076 };
2077 try ctx.sendNotification(title, content);
2078 has_highlight = true;
2079 }
2080 const time = msg2.time() orelse return;
2081 if (time.unixTimestamp() > channel.last_read) {
2082 channel.has_unread_highlight = has_highlight;
2083 channel.has_unread = true;
2084 }
2085 // If we get a message from the current user mark the channel as
2086 // read, since they must have just sent the message.
2087 const sender: []const u8 = blk: {
2088 const src = msg2.source() orelse break :blk "";
2089 const l = std.mem.indexOfScalar(u8, src, '!') orelse
2090 std.mem.indexOfScalar(u8, src, '@') orelse
2091 src.len;
2092 break :blk src[0..l];
2093 };
2094 if (std.mem.eql(u8, sender, client.nickname())) {
2095 self.app.markSelectedChannelRead();
2096 }
2097 }
2098 },
2099 }
2100 }
2101
2102 pub fn nickname(self: *Client) []const u8 {
2103 return self.config.network_nick orelse self.config.nick;
2104 }
2105
2106 pub fn ack(self: *Client, cap: []const u8) void {
2107 const info = @typeInfo(Capabilities);
2108 assert(info == .Struct);
2109
2110 inline for (info.Struct.fields) |field| {
2111 if (std.mem.eql(u8, field.name, cap)) {
2112 @field(self.caps, field.name) = true;
2113 return;
2114 }
2115 }
2116 }
2117
2118 pub fn read(self: *Client, buf: []u8) !usize {
2119 switch (self.config.tls) {
2120 true => return self.client.read(buf),
2121 false => return self.stream.read(buf),
2122 }
2123 }
2124
2125 pub fn readThread(self: *Client) !void {
2126 defer self.status.store(.disconnected, .unordered);
2127
2128 self.connect() catch |err| {
2129 log.warn("couldn't connect: {}", .{err});
2130 return;
2131 };
2132
2133 try self.queueWrite("CAP LS 302\r\n");
2134
2135 const cap_names = std.meta.fieldNames(Capabilities);
2136 for (cap_names) |cap| {
2137 try self.print("CAP REQ :{s}\r\n", .{cap});
2138 }
2139
2140 try self.print("NICK {s}\r\n", .{self.config.nick});
2141
2142 try self.print("USER {s} 0 * {s}\r\n", .{ self.config.user, self.config.real_name });
2143
2144 var buf: [4096]u8 = undefined;
2145 var retries: u8 = 0;
2146 while (true) {
2147 const n = self.read(&buf) catch |err| {
2148 // WouldBlock means our socket timeout expired
2149 switch (err) {
2150 error.WouldBlock => {},
2151 else => return err,
2152 }
2153
2154 if (retries == keepalive_retries) {
2155 log.debug("[{s}] connection closed", .{self.config.name orelse self.config.server});
2156 self.close();
2157 return;
2158 }
2159
2160 if (retries == 0) {
2161 try self.configureKeepalive(keepalive_interval);
2162 }
2163 retries += 1;
2164 try self.queueWrite("PING comlink\r\n");
2165 continue;
2166 };
2167 if (n == 0) return;
2168
2169 // If we did a connection retry, we reset the state
2170 if (retries > 0) {
2171 retries = 0;
2172 try self.configureKeepalive(keepalive_idle);
2173 }
2174 self.read_buf_mutex.lock();
2175 defer self.read_buf_mutex.unlock();
2176 try self.read_buf.appendSlice(buf[0..n]);
2177 }
2178 }
2179
2180 pub fn print(self: *Client, comptime fmt: []const u8, args: anytype) Allocator.Error!void {
2181 const msg = try std.fmt.allocPrint(self.alloc, fmt, args);
2182 self.write_queue.push(.{ .write = .{
2183 .client = self,
2184 .msg = msg,
2185 } });
2186 }
2187
2188 /// push a write request into the queue. The request should include the trailing
2189 /// '\r\n'. queueWrite will dupe the message and free after processing.
2190 pub fn queueWrite(self: *Client, msg: []const u8) Allocator.Error!void {
2191 self.write_queue.push(.{ .write = .{
2192 .client = self,
2193 .msg = try self.alloc.dupe(u8, msg),
2194 } });
2195 }
2196
2197 pub fn write(self: *Client, buf: []const u8) !void {
2198 assert(std.mem.endsWith(u8, buf, "\r\n"));
2199 if (self.status.load(.unordered) == .disconnected) {
2200 log.warn("disconnected: dropping write: {s}", .{buf[0 .. buf.len - 2]});
2201 return;
2202 }
2203 log.debug("[->{s}] {s}", .{ self.config.name orelse self.config.server, buf[0 .. buf.len - 2] });
2204 switch (self.config.tls) {
2205 true => try self.client.writeAll(buf),
2206 false => try self.stream.writeAll(buf),
2207 }
2208 }
2209
2210 pub fn connect(self: *Client) !void {
2211 if (self.config.tls) {
2212 const port: u16 = self.config.port orelse 6697;
2213 self.stream = try std.net.tcpConnectToHost(self.alloc, self.config.server, port);
2214 self.client = try tls.client(self.stream, .{
2215 .host = self.config.server,
2216 .root_ca = self.app.bundle,
2217 });
2218 } else {
2219 const port: u16 = self.config.port orelse 6667;
2220 self.stream = try std.net.tcpConnectToHost(self.alloc, self.config.server, port);
2221 }
2222 self.status.store(.connected, .unordered);
2223
2224 try self.configureKeepalive(keepalive_idle);
2225 }
2226
2227 pub fn configureKeepalive(self: *Client, seconds: i32) !void {
2228 const timeout = std.mem.toBytes(std.posix.timeval{
2229 .tv_sec = seconds,
2230 .tv_usec = 0,
2231 });
2232
2233 try std.posix.setsockopt(
2234 self.stream.handle,
2235 std.posix.SOL.SOCKET,
2236 std.posix.SO.RCVTIMEO,
2237 &timeout,
2238 );
2239 }
2240
2241 pub fn getOrCreateChannel(self: *Client, name: []const u8) Allocator.Error!*Channel {
2242 for (self.channels.items) |channel| {
2243 if (caseFold(name, channel.name)) return channel;
2244 }
2245 const channel = try self.alloc.create(Channel);
2246 try channel.init(self.alloc, self, name, self.app.unicode);
2247 try self.channels.append(channel);
2248
2249 std.sort.insertion(*Channel, self.channels.items, {}, Channel.compare);
2250 return channel;
2251 }
2252
2253 var color_indices = [_]u8{ 1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14 };
2254
2255 pub fn getOrCreateUser(self: *Client, nick: []const u8) Allocator.Error!*User {
2256 return self.users.get(nick) orelse {
2257 const color_u32 = std.hash.Fnv1a_32.hash(nick);
2258 const index = color_u32 % color_indices.len;
2259 const color_index = color_indices[index];
2260
2261 const color: vaxis.Color = .{
2262 .index = color_index,
2263 };
2264 const user = try self.alloc.create(User);
2265 user.* = .{
2266 .nick = try self.alloc.dupe(u8, nick),
2267 .color = color,
2268 };
2269 try self.users.put(user.nick, user);
2270 return user;
2271 };
2272 }
2273
2274 pub fn whox(self: *Client, channel: *Channel) !void {
2275 channel.who_requested = true;
2276 if (channel.name.len > 0 and
2277 channel.name[0] != '#')
2278 {
2279 const other = try self.getOrCreateUser(channel.name);
2280 const me = try self.getOrCreateUser(self.config.nick);
2281 try channel.addMember(other, .{});
2282 try channel.addMember(me, .{});
2283 return;
2284 }
2285 // Only use WHO if we have WHOX and away-notify. Without
2286 // WHOX, we can get rate limited on eg. libera. Without
2287 // away-notify, our list will become stale
2288 if (self.supports.whox and
2289 self.caps.@"away-notify" and
2290 !channel.in_flight.who)
2291 {
2292 channel.in_flight.who = true;
2293 try self.print(
2294 "WHO {s} %cnfr\r\n",
2295 .{channel.name},
2296 );
2297 } else {
2298 channel.in_flight.names = true;
2299 try self.print(
2300 "NAMES {s}\r\n",
2301 .{channel.name},
2302 );
2303 }
2304 }
2305
2306 /// fetch the history for the provided channel.
2307 pub fn requestHistory(
2308 self: *Client,
2309 cmd: ChatHistoryCommand,
2310 channel: *Channel,
2311 ) Allocator.Error!void {
2312 if (!self.caps.@"draft/chathistory") return;
2313 if (channel.history_requested) return;
2314
2315 channel.history_requested = true;
2316
2317 if (channel.messages.items.len == 0) {
2318 try self.print(
2319 "CHATHISTORY LATEST {s} * 50\r\n",
2320 .{channel.name},
2321 );
2322 channel.history_requested = true;
2323 return;
2324 }
2325
2326 switch (cmd) {
2327 .before => {
2328 assert(channel.messages.items.len > 0);
2329 const first = channel.messages.items[0];
2330 const time = first.getTag("time") orelse {
2331 log.warn("can't request history: no time tag", .{});
2332 return;
2333 };
2334 try self.print(
2335 "CHATHISTORY BEFORE {s} timestamp={s} 50\r\n",
2336 .{ channel.name, time },
2337 );
2338 channel.history_requested = true;
2339 },
2340 .after => {
2341 assert(channel.messages.items.len > 0);
2342 const last = channel.messages.getLast();
2343 const time = last.getTag("time") orelse {
2344 log.warn("can't request history: no time tag", .{});
2345 return;
2346 };
2347 try self.print(
2348 // we request 500 because we have no
2349 // idea how long we've been offline
2350 "CHATHISTORY AFTER {s} timestamp={s} 500\r\n",
2351 .{ channel.name, time },
2352 );
2353 channel.history_requested = true;
2354 },
2355 }
2356 }
2357};
2358
2359pub fn toVaxisColor(irc: u8) vaxis.Color {
2360 return switch (irc) {
2361 0 => .default, // white
2362 1 => .{ .index = 0 }, // black
2363 2 => .{ .index = 4 }, // blue
2364 3 => .{ .index = 2 }, // green
2365 4 => .{ .index = 1 }, // red
2366 5 => .{ .index = 3 }, // brown
2367 6 => .{ .index = 5 }, // magenta
2368 7 => .{ .index = 11 }, // orange
2369 8 => .{ .index = 11 }, // yellow
2370 9 => .{ .index = 10 }, // light green
2371 10 => .{ .index = 6 }, // cyan
2372 11 => .{ .index = 14 }, // light cyan
2373 12 => .{ .index = 12 }, // light blue
2374 13 => .{ .index = 13 }, // pink
2375 14 => .{ .index = 8 }, // grey
2376 15 => .{ .index = 7 }, // light grey
2377
2378 // 16 to 98 are specifically defined
2379 16 => .{ .index = 52 },
2380 17 => .{ .index = 94 },
2381 18 => .{ .index = 100 },
2382 19 => .{ .index = 58 },
2383 20 => .{ .index = 22 },
2384 21 => .{ .index = 29 },
2385 22 => .{ .index = 23 },
2386 23 => .{ .index = 24 },
2387 24 => .{ .index = 17 },
2388 25 => .{ .index = 54 },
2389 26 => .{ .index = 53 },
2390 27 => .{ .index = 89 },
2391 28 => .{ .index = 88 },
2392 29 => .{ .index = 130 },
2393 30 => .{ .index = 142 },
2394 31 => .{ .index = 64 },
2395 32 => .{ .index = 28 },
2396 33 => .{ .index = 35 },
2397 34 => .{ .index = 30 },
2398 35 => .{ .index = 25 },
2399 36 => .{ .index = 18 },
2400 37 => .{ .index = 91 },
2401 38 => .{ .index = 90 },
2402 39 => .{ .index = 125 },
2403 // TODO: finish these out https://modern.ircdocs.horse/formatting#color
2404
2405 99 => .default,
2406
2407 else => .{ .index = irc },
2408 };
2409}
2410/// generate TextSpans for the message content
2411fn formatMessage(
2412 arena: Allocator,
2413 user: *User,
2414 content: []const u8,
2415) Allocator.Error![]vxfw.RichText.TextSpan {
2416 const ColorState = enum {
2417 ground,
2418 fg,
2419 bg,
2420 };
2421 const LinkState = enum {
2422 h,
2423 t1,
2424 t2,
2425 p,
2426 s,
2427 colon,
2428 slash,
2429 consume,
2430 };
2431
2432 var spans = std.ArrayList(vxfw.RichText.TextSpan).init(arena);
2433
2434 var start: usize = 0;
2435 var i: usize = 0;
2436 var style: vaxis.Style = .{};
2437 while (i < content.len) : (i += 1) {
2438 const b = content[i];
2439 switch (b) {
2440 0x01 => { // https://modern.ircdocs.horse/ctcp
2441 if (i == 0 and
2442 content.len > 7 and
2443 mem.startsWith(u8, content[1..], "ACTION"))
2444 {
2445 // get the user of this message
2446 style.italic = true;
2447 const user_style: vaxis.Style = .{
2448 .fg = user.color,
2449 .italic = true,
2450 };
2451 try spans.append(.{
2452 .text = user.nick,
2453 .style = user_style,
2454 });
2455 i += 6; // "ACTION"
2456 } else {
2457 try spans.append(.{
2458 .text = content[start..i],
2459 .style = style,
2460 });
2461 }
2462 start = i + 1;
2463 },
2464 0x02 => {
2465 try spans.append(.{
2466 .text = content[start..i],
2467 .style = style,
2468 });
2469 style.bold = !style.bold;
2470 start = i + 1;
2471 },
2472 0x03 => {
2473 try spans.append(.{
2474 .text = content[start..i],
2475 .style = style,
2476 });
2477 i += 1;
2478 var state: ColorState = .ground;
2479 var fg_idx: ?u8 = null;
2480 var bg_idx: ?u8 = null;
2481 while (i < content.len) : (i += 1) {
2482 const d = content[i];
2483 switch (state) {
2484 .ground => {
2485 switch (d) {
2486 '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => {
2487 state = .fg;
2488 fg_idx = d - '0';
2489 },
2490 else => {
2491 style.fg = .default;
2492 style.bg = .default;
2493 start = i;
2494 break;
2495 },
2496 }
2497 },
2498 .fg => {
2499 switch (d) {
2500 '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => {
2501 const fg = fg_idx orelse 0;
2502 if (fg > 9) {
2503 style.fg = toVaxisColor(fg);
2504 start = i;
2505 break;
2506 } else {
2507 fg_idx = fg * 10 + (d - '0');
2508 }
2509 },
2510 else => {
2511 if (fg_idx) |fg| {
2512 style.fg = toVaxisColor(fg);
2513 start = i;
2514 }
2515 if (d == ',') state = .bg else break;
2516 },
2517 }
2518 },
2519 .bg => {
2520 switch (d) {
2521 '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => {
2522 const bg = bg_idx orelse 0;
2523 if (i - start == 2) {
2524 style.bg = toVaxisColor(bg);
2525 start = i;
2526 break;
2527 } else {
2528 bg_idx = bg * 10 + (d - '0');
2529 }
2530 },
2531 else => {
2532 if (bg_idx) |bg| {
2533 style.bg = toVaxisColor(bg);
2534 start = i;
2535 }
2536 break;
2537 },
2538 }
2539 },
2540 }
2541 }
2542 },
2543 0x0F => {
2544 try spans.append(.{
2545 .text = content[start..i],
2546 .style = style,
2547 });
2548 style = .{};
2549 start = i + 1;
2550 },
2551 0x16 => {
2552 try spans.append(.{
2553 .text = content[start..i],
2554 .style = style,
2555 });
2556 style.reverse = !style.reverse;
2557 start = i + 1;
2558 },
2559 0x1D => {
2560 try spans.append(.{
2561 .text = content[start..i],
2562 .style = style,
2563 });
2564 style.italic = !style.italic;
2565 start = i + 1;
2566 },
2567 0x1E => {
2568 try spans.append(.{
2569 .text = content[start..i],
2570 .style = style,
2571 });
2572 style.strikethrough = !style.strikethrough;
2573 start = i + 1;
2574 },
2575 0x1F => {
2576 try spans.append(.{
2577 .text = content[start..i],
2578 .style = style,
2579 });
2580
2581 style.ul_style = if (style.ul_style == .off) .single else .off;
2582 start = i + 1;
2583 },
2584 else => {
2585 if (b == 'h') {
2586 var state: LinkState = .h;
2587 const h_start = i;
2588 // consume until a space or EOF
2589 i += 1;
2590 while (i < content.len) : (i += 1) {
2591 const b1 = content[i];
2592 switch (state) {
2593 .h => {
2594 if (b1 == 't') state = .t1 else break;
2595 },
2596 .t1 => {
2597 if (b1 == 't') state = .t2 else break;
2598 },
2599 .t2 => {
2600 if (b1 == 'p') state = .p else break;
2601 },
2602 .p => {
2603 if (b1 == 's')
2604 state = .s
2605 else if (b1 == ':')
2606 state = .colon
2607 else
2608 break;
2609 },
2610 .s => {
2611 if (b1 == ':') state = .colon else break;
2612 },
2613 .colon => {
2614 if (b1 == '/') state = .slash else break;
2615 },
2616 .slash => {
2617 if (b1 == '/') {
2618 state = .consume;
2619 try spans.append(.{
2620 .text = content[start..h_start],
2621 .style = style,
2622 });
2623 start = h_start;
2624 } else break;
2625 },
2626 .consume => {
2627 switch (b1) {
2628 0x00...0x20, 0x7F => {
2629 try spans.append(.{
2630 .text = content[h_start..i],
2631 .style = .{
2632 .fg = .{ .index = 4 },
2633 },
2634 .link = .{
2635 .uri = content[h_start..i],
2636 },
2637 });
2638 start = i;
2639 // backup one
2640 i -= 1;
2641 break;
2642 },
2643 else => {
2644 if (i == content.len - 1) {
2645 start = i + 1;
2646 try spans.append(.{
2647 .text = content[h_start..],
2648 .style = .{
2649 .fg = .{ .index = 4 },
2650 },
2651 .link = .{
2652 .uri = content[h_start..],
2653 },
2654 });
2655 break;
2656 }
2657 },
2658 }
2659 },
2660 }
2661 }
2662 }
2663 },
2664 }
2665 }
2666 if (start < i and start < content.len) {
2667 try spans.append(.{
2668 .text = content[start..],
2669 .style = style,
2670 });
2671 }
2672 return spans.toOwnedSlice();
2673}
2674
2675const CaseMapAlgo = enum {
2676 ascii,
2677 rfc1459,
2678 rfc1459_strict,
2679};
2680
2681pub fn caseMap(char: u8, algo: CaseMapAlgo) u8 {
2682 switch (algo) {
2683 .ascii => {
2684 switch (char) {
2685 'A'...'Z' => return char + 0x20,
2686 else => return char,
2687 }
2688 },
2689 .rfc1459 => {
2690 switch (char) {
2691 'A'...'^' => return char + 0x20,
2692 else => return char,
2693 }
2694 },
2695 .rfc1459_strict => {
2696 switch (char) {
2697 'A'...']' => return char + 0x20,
2698 else => return char,
2699 }
2700 },
2701 }
2702}
2703
2704pub fn caseFold(a: []const u8, b: []const u8) bool {
2705 if (a.len != b.len) return false;
2706 var i: usize = 0;
2707 while (i < a.len) {
2708 const diff = std.mem.indexOfDiff(u8, a[i..], b[i..]) orelse return true;
2709 const a_diff = caseMap(a[diff], .rfc1459);
2710 const b_diff = caseMap(b[diff], .rfc1459);
2711 if (a_diff != b_diff) return false;
2712 i += diff + 1;
2713 }
2714 return true;
2715}
2716
2717pub const ChatHistoryCommand = enum {
2718 before,
2719 after,
2720};
2721
2722test "caseFold" {
2723 try testing.expect(caseFold("a", "A"));
2724 try testing.expect(caseFold("aBcDeFgH", "abcdefgh"));
2725}
2726
2727test "simple message" {
2728 const msg: Message = .{ .bytes = "JOIN" };
2729 try testing.expect(msg.command() == .JOIN);
2730}
2731
2732test "simple message with extra whitespace" {
2733 const msg: Message = .{ .bytes = "JOIN " };
2734 try testing.expect(msg.command() == .JOIN);
2735}
2736
2737test "well formed message with tags, source, params" {
2738 const msg: Message = .{ .bytes = "@key=value :example.chat JOIN abc def" };
2739
2740 var tag_iter = msg.tagIterator();
2741 const tag = tag_iter.next();
2742 try testing.expect(tag != null);
2743 try testing.expectEqualStrings("key", tag.?.key);
2744 try testing.expectEqualStrings("value", tag.?.value);
2745 try testing.expect(tag_iter.next() == null);
2746
2747 const source = msg.source();
2748 try testing.expect(source != null);
2749 try testing.expectEqualStrings("example.chat", source.?);
2750 try testing.expect(msg.command() == .JOIN);
2751
2752 var param_iter = msg.paramIterator();
2753 const p1 = param_iter.next();
2754 const p2 = param_iter.next();
2755 try testing.expect(p1 != null);
2756 try testing.expect(p2 != null);
2757 try testing.expectEqualStrings("abc", p1.?);
2758 try testing.expectEqualStrings("def", p2.?);
2759
2760 try testing.expect(param_iter.next() == null);
2761}
2762
2763test "message with tags, source, params and extra whitespace" {
2764 const msg: Message = .{ .bytes = "@key=value :example.chat JOIN abc def" };
2765
2766 var tag_iter = msg.tagIterator();
2767 const tag = tag_iter.next();
2768 try testing.expect(tag != null);
2769 try testing.expectEqualStrings("key", tag.?.key);
2770 try testing.expectEqualStrings("value", tag.?.value);
2771 try testing.expect(tag_iter.next() == null);
2772
2773 const source = msg.source();
2774 try testing.expect(source != null);
2775 try testing.expectEqualStrings("example.chat", source.?);
2776 try testing.expect(msg.command() == .JOIN);
2777
2778 var param_iter = msg.paramIterator();
2779 const p1 = param_iter.next();
2780 const p2 = param_iter.next();
2781 try testing.expect(p1 != null);
2782 try testing.expect(p2 != null);
2783 try testing.expectEqualStrings("abc", p1.?);
2784 try testing.expectEqualStrings("def", p2.?);
2785
2786 try testing.expect(param_iter.next() == null);
2787}
2788
2789test "param iterator: simple list" {
2790 var iter: Message.ParamIterator = .{ .params = "a b c" };
2791 var i: usize = 0;
2792 while (iter.next()) |param| {
2793 switch (i) {
2794 0 => try testing.expectEqualStrings("a", param),
2795 1 => try testing.expectEqualStrings("b", param),
2796 2 => try testing.expectEqualStrings("c", param),
2797 else => return error.TooManyParams,
2798 }
2799 i += 1;
2800 }
2801 try testing.expect(i == 3);
2802}
2803
2804test "param iterator: trailing colon" {
2805 var iter: Message.ParamIterator = .{ .params = "* LS :" };
2806 var i: usize = 0;
2807 while (iter.next()) |param| {
2808 switch (i) {
2809 0 => try testing.expectEqualStrings("*", param),
2810 1 => try testing.expectEqualStrings("LS", param),
2811 2 => try testing.expectEqualStrings("", param),
2812 else => return error.TooManyParams,
2813 }
2814 i += 1;
2815 }
2816 try testing.expect(i == 3);
2817}
2818
2819test "param iterator: colon" {
2820 var iter: Message.ParamIterator = .{ .params = "* LS :sasl multi-prefix" };
2821 var i: usize = 0;
2822 while (iter.next()) |param| {
2823 switch (i) {
2824 0 => try testing.expectEqualStrings("*", param),
2825 1 => try testing.expectEqualStrings("LS", param),
2826 2 => try testing.expectEqualStrings("sasl multi-prefix", param),
2827 else => return error.TooManyParams,
2828 }
2829 i += 1;
2830 }
2831 try testing.expect(i == 3);
2832}
2833
2834test "param iterator: colon and leading colon" {
2835 var iter: Message.ParamIterator = .{ .params = "* LS ::)" };
2836 var i: usize = 0;
2837 while (iter.next()) |param| {
2838 switch (i) {
2839 0 => try testing.expectEqualStrings("*", param),
2840 1 => try testing.expectEqualStrings("LS", param),
2841 2 => try testing.expectEqualStrings(":)", param),
2842 else => return error.TooManyParams,
2843 }
2844 i += 1;
2845 }
2846 try testing.expect(i == 3);
2847}