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