this repo has no description
3
fork

Configure Feed

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

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