this repo has no description
3
fork

Configure Feed

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

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