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