this repo has no description
3
fork

Configure Feed

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

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