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