this repo has no description
3
fork

Configure Feed

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

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