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