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