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