an experimental irc client

ui: implement typing display

+150
+1
src/app.zig
··· 264 264 try irc.Client.retryTickHandler(client, ctx, .tick); 265 265 } 266 266 client.drainFifo(ctx); 267 + client.checkTypingStatus(ctx); 267 268 } 268 269 try ctx.tick(8, self.widget()); 269 270 },
+149
src/irc.zig
··· 67 67 NOTICE, 68 68 PART, 69 69 PRIVMSG, 70 + TAGMSG, 70 71 71 72 unknown, 72 73 ··· 98 99 .{ "NOTICE", .NOTICE }, 99 100 .{ "PART", .PART }, 100 101 .{ "PRIVMSG", .PRIVMSG }, 102 + .{ "TAGMSG", .TAGMSG }, 101 103 }); 102 104 103 105 pub fn parse(cmd: []const u8) Command { ··· 148 150 149 151 completer: Completer, 150 152 completer_shown: bool = false, 153 + typing_last_active: u32 = 0, 151 154 152 155 // Gutter (left side where time is printed) width 153 156 const gutter_width = 6; ··· 160 163 161 164 channel: *Channel, 162 165 has_mouse: bool = false, 166 + typing: u32 = 0, 163 167 164 168 pub fn compare(_: void, lhs: Member, rhs: Member) bool { 165 169 return if (lhs.prefix != ' ' and rhs.prefix == ' ') ··· 620 624 .surface = scrollbar_surface, 621 625 }); 622 626 627 + // Draw typers 628 + typing: { 629 + var buf: [3]*User = undefined; 630 + const typers = self.getTypers(&buf); 631 + 632 + switch (typers.len) { 633 + 0 => break :typing, 634 + 1 => { 635 + const text = try std.fmt.allocPrint( 636 + ctx.arena, 637 + "{s} is typing...", 638 + .{typers[0].nick}, 639 + ); 640 + const typer: vxfw.Text = .{ .text = text, .style = .{ .dim = true } }; 641 + const typer_ctx = ctx.withConstraints(.{}, ctx.max); 642 + try children.append(.{ 643 + .origin = .{ .col = 0, .row = max.height - 2 }, 644 + .surface = try typer.draw(typer_ctx), 645 + }); 646 + }, 647 + 2 => { 648 + const text = try std.fmt.allocPrint( 649 + ctx.arena, 650 + "{s} and {s} are typing...", 651 + .{ typers[0].nick, typers[1].nick }, 652 + ); 653 + const typer: vxfw.Text = .{ .text = text, .style = .{ .dim = true } }; 654 + const typer_ctx = ctx.withConstraints(.{}, ctx.max); 655 + try children.append(.{ 656 + .origin = .{ .col = 0, .row = max.height - 2 }, 657 + .surface = try typer.draw(typer_ctx), 658 + }); 659 + }, 660 + else => { 661 + const text = "Several people are typing..."; 662 + const typer: vxfw.Text = .{ .text = text, .style = .{ .dim = true } }; 663 + const typer_ctx = ctx.withConstraints(.{}, ctx.max); 664 + try children.append(.{ 665 + .origin = .{ .col = 0, .row = max.height - 2 }, 666 + .surface = try typer.draw(typer_ctx), 667 + }); 668 + }, 669 + } 670 + } 671 + 623 672 { 624 673 // Draw the character limit. 14 is length of message overhead "PRIVMSG :\r\n" 625 674 const max_limit = maximum_message_size -| self.name.len -| 14 -| self.name.len; ··· 1116 1165 // In any other case, we 1117 1166 return false; 1118 1167 } 1168 + 1169 + fn getTypers(self: *Channel, buf: []*User) []*User { 1170 + const now: u32 = @intCast(std.time.timestamp()); 1171 + var i: usize = 0; 1172 + for (self.members.items) |member| { 1173 + if (i == buf.len) { 1174 + return buf[0..i]; 1175 + } 1176 + // The spec says we should consider people as typing if the last typing message was 1177 + // received within 6 seconds from now 1178 + if (member.typing + 6 >= now) { 1179 + buf[i] = member.user; 1180 + i += 1; 1181 + } 1182 + } 1183 + return buf[0..i]; 1184 + } 1185 + 1186 + fn typingCount(self: *Channel) usize { 1187 + const now: u32 = @intCast(std.time.timestamp()); 1188 + 1189 + var n: usize = 0; 1190 + for (self.members.items) |member| { 1191 + // The spec says we should consider people as typing if the last typing message was 1192 + // received within 6 seconds from now 1193 + if (member.typing + 6 >= now) { 1194 + n += 1; 1195 + } 1196 + } 1197 + return n; 1198 + } 1119 1199 }; 1120 1200 1121 1201 pub const User = struct { ··· 1639 1719 self.read_buf.replaceRangeAssumeCapacity(0, i, ""); 1640 1720 } 1641 1721 1722 + // Checks if any channel has an expired typing status. The typing status is considered expired 1723 + // if the last typing status received is more than 6 seconds ago. In this case, we set the last 1724 + // typing time to 0 and redraw. 1725 + pub fn checkTypingStatus(self: *Client, ctx: *vxfw.EventContext) void { 1726 + const now: u32 = @intCast(std.time.timestamp()); 1727 + for (self.channels.items) |channel| { 1728 + if (channel.typing_last_active > 0 and 1729 + channel.typing_last_active + 6 >= now) continue; 1730 + channel.typing_last_active = 0; 1731 + ctx.redraw = true; 1732 + } 1733 + } 1734 + 1642 1735 pub fn handleEvent(self: *Client, line: []const u8, ctx: *vxfw.EventContext) !void { 1643 1736 const msg: Message = .{ .bytes = line }; 1644 1737 const client = self; ··· 2093 2186 }; 2094 2187 if (std.mem.eql(u8, sender, client.nickname())) { 2095 2188 self.app.markSelectedChannelRead(); 2189 + } 2190 + 2191 + // Set the typing time to 0 2192 + for (channel.members.items) |*member| { 2193 + if (!std.mem.eql(u8, member.user.nick, sender)) { 2194 + continue; 2195 + } 2196 + member.typing = 0; 2197 + return; 2198 + } 2199 + } 2200 + }, 2201 + .TAGMSG => { 2202 + const msg2: Message = .{ .bytes = msg.bytes }; 2203 + // We only care about typing tags 2204 + const typing = msg2.getTag("+typing") orelse return; 2205 + 2206 + var iter = msg2.paramIterator(); 2207 + const target = blk: { 2208 + const tgt = iter.next() orelse return; 2209 + if (mem.eql(u8, tgt, client.nickname())) { 2210 + // If the target is us, it likely has our 2211 + // hostname in it. 2212 + const source = msg2.source() orelse return; 2213 + const n = mem.indexOfScalar(u8, source, '!') orelse source.len; 2214 + break :blk source[0..n]; 2215 + } else break :blk tgt; 2216 + }; 2217 + const sender: []const u8 = blk: { 2218 + const src = msg2.source() orelse break :blk ""; 2219 + const l = std.mem.indexOfScalar(u8, src, '!') orelse 2220 + std.mem.indexOfScalar(u8, src, '@') orelse 2221 + src.len; 2222 + break :blk src[0..l]; 2223 + }; 2224 + // if (std.mem.eql(u8, sender, client.nickname())) { 2225 + // // We never considuer ourselves as typing 2226 + // return; 2227 + // } 2228 + const channel = try client.getOrCreateChannel(target); 2229 + 2230 + for (channel.members.items) |*member| { 2231 + if (!std.mem.eql(u8, member.user.nick, sender)) { 2232 + continue; 2233 + } 2234 + if (std.mem.eql(u8, "done", typing)) { 2235 + member.typing = 0; 2236 + ctx.redraw = true; 2237 + return; 2238 + } 2239 + if (std.mem.eql(u8, "active", typing)) { 2240 + const time = msg2.time() orelse return; 2241 + member.typing = @intCast(time.unixTimestamp()); 2242 + channel.typing_last_active = member.typing; 2243 + ctx.redraw = true; 2244 + return; 2096 2245 } 2097 2246 } 2098 2247 },