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