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