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