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