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