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