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