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