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