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