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