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