this repo has no description
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

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