typing indicators

This commit is contained in:
2025-12-23 13:57:13 +01:00
parent 3890e36078
commit e732eb7a20
3 changed files with 107 additions and 42 deletions
+35 -2
View File
@@ -2,14 +2,13 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useParams, useSearchParams } from "next/navigation"; import { useParams, useSearchParams } from "next/navigation";
import MessageBox from "@/components/channel/MessageBox";
import AppLayout from "@/components/app/AppLayout"; import AppLayout from "@/components/app/AppLayout";
import { Server as ServerType } from "@/types/types";
import auth from "@/lib/auth"; import auth from "@/lib/auth";
import useMessages from "@/hooks/use-messages"; import useMessages from "@/hooks/use-messages";
import useApp from "@/hooks/use-app"; import useApp from "@/hooks/use-app";
import { useEffectOnceWhenReady } from "@/hooks/use-once"; import { useEffectOnceWhenReady } from "@/hooks/use-once";
import ChannelView from "@/components/channel/ChannelView"; import ChannelView from "@/components/channel/ChannelView";
import { IndicatorContext } from "@/types/protocol";
export default function Server() { export default function Server() {
const { ip } = useParams<{ ip: string }>(); const { ip } = useParams<{ ip: string }>();
@@ -17,6 +16,7 @@ export default function Server() {
const serverRef = useRef<WebSocket | null>(null); const serverRef = useRef<WebSocket | null>(null);
const app = useApp(); const app = useApp();
const messageStore = useMessages(); const messageStore = useMessages();
const [indicators, setIndicators] = useState<IndicatorContext[]>([]);
useEffectOnceWhenReady( useEffectOnceWhenReady(
() => { () => {
@@ -26,12 +26,44 @@ export default function Server() {
wsRef: serverRef, wsRef: serverRef,
setServer: app.setServer, setServer: app.setServer,
messageStore: messageStore, messageStore: messageStore,
onIndicator: (i) => {
setIndicators((prev) => {
const combined = [...prev, { ...i }];
const uniqueByUser = combined.reduce<Record<string, typeof i>>(
(acc, curr) => {
const userId = curr.indicator.params.user_id;
if (!acc[userId] || curr.expires > acc[userId].expires) {
acc[userId] = curr;
}
return acc;
},
{}
);
return Object.values(uniqueByUser);
});
},
}); });
}, },
[ip, messageStore.messages], [ip, messageStore.messages],
[undefined, (v) => v] [undefined, (v) => v]
); );
useEffect(() => {
const interval = setInterval(() => {
setIndicators((prev) =>
prev
.map((item) => ({
...item,
expires: item.expires - 1,
}))
.filter((item) => item.expires > 0)
);
}, 1000);
return () => clearInterval(interval);
}, []);
useEffect(() => { useEffect(() => {
const channelId = searchParams.get("ch"); const channelId = searchParams.get("ch");
if (channelId) { if (channelId) {
@@ -54,6 +86,7 @@ export default function Server() {
)} )}
ip={ip} ip={ip}
serverRef={serverRef} serverRef={serverRef}
indicators={indicators}
/> />
</AppLayout> </AppLayout>
); );
+6 -2
View File
@@ -1,19 +1,22 @@
import { Channel, Message, Server } from "@/types/types"; import { Message } from "@/types/types";
import MessageBox from "./MessageBox"; import MessageBox from "./MessageBox";
import App from "@/types/app"; import App from "@/types/app";
import React, { Ref, useEffect, useRef } from "react"; import React from "react";
import PluginChannel from "./PluginChannel"; import PluginChannel from "./PluginChannel";
import { IndicatorContext } from "@/types/protocol";
export default function ChannelView({ export default function ChannelView({
app, app,
messages, messages,
ip, ip,
serverRef, serverRef,
indicators,
}: { }: {
app: App; app: App;
messages: Message[]; messages: Message[];
ip: string; ip: string;
serverRef: React.RefObject<WebSocket | null>; serverRef: React.RefObject<WebSocket | null>;
indicators: IndicatorContext[];
}) { }) {
if (!app.currentChannel) if (!app.currentChannel)
return ( return (
@@ -27,6 +30,7 @@ export default function ChannelView({
channelName={app.currentChannel.name} channelName={app.currentChannel.name}
messages={messages} messages={messages}
sendRequest={(req) => serverRef.current?.send(JSON.stringify(req))} sendRequest={(req) => serverRef.current?.send(JSON.stringify(req))}
indicators={indicators}
/> />
); );
+66 -38
View File
@@ -25,7 +25,7 @@ import {
ContextMenuItem, ContextMenuItem,
ContextMenuTrigger, ContextMenuTrigger,
} from "@/components/ui/context-menu"; } from "@/components/ui/context-menu";
import { ClientMessage } from "@/types/protocol"; import { ClientMessage, IndicatorContext } from "@/types/protocol";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
function MessageContainer({ function MessageContainer({
@@ -213,7 +213,7 @@ function MessageInput({
onEnter: () => void; onEnter: () => void;
onTyping?: () => void; onTyping?: () => void;
}) { }) {
const TYPING_INTERVAL = 2000; const TYPING_INTERVAL = 1000;
const inputRef = useRef<any>(null); const inputRef = useRef<any>(null);
const lastTypingSent = useRef(0); const lastTypingSent = useRef(0);
const stopTypingTimeout = useRef<NodeJS.Timeout | null>(null); const stopTypingTimeout = useRef<NodeJS.Timeout | null>(null);
@@ -303,12 +303,14 @@ export default function MessageBox({
channelName, channelName,
channelIcon, channelIcon,
messages, messages,
indicators,
}: { }: {
app: App; app: App;
sendRequest: (req: ClientMessage) => void; sendRequest: (req: ClientMessage) => void;
channelName?: string; channelName?: string;
channelIcon?: string; channelIcon?: string;
messages: Message[]; messages: Message[];
indicators: IndicatorContext[];
}) { }) {
const [text, setText] = useState(""); const [text, setText] = useState("");
@@ -406,45 +408,71 @@ export default function MessageBox({
<div ref={ref} /> <div ref={ref} />
</AnimatePresence> </AnimatePresence>
</div> </div>
<footer className="flex gap-3 pr-5 w-full h-max"> <footer>
<MessageInput <div className="relative h-1">
text={text} <AnimatePresence>
setText={setText} {indicators.length > 0 && (
onEnter={sendMessage} <motion.div
onTyping={() => { key={indicators
console.log("Typing.."); .map((i) => i.indicator.params.user_id)
if (!app.currentChannel) return; .join("-")}
sendRequest({ className="absolute inset-0 text-muted-foreground text-xs top-[-26px]"
type: "typing", initial={{ opacity: 0, y: -10 }}
params: { animate={{ opacity: 1, y: 0 }}
channel_id: app.currentChannel.id, exit={{ opacity: 0, y: -10 }}
}, transition={{ duration: 0.3 }}
}); >
}} {indicators
/> .map(
(indicator) =>
app.profiles[indicator.indicator.params.user_id]
?.display_name ?? null
)
.join(", ")}
{indicators.length > 0 && " is typing"}
</motion.div>
)}
</AnimatePresence>
</div>
<div className="flex gap-3 pr-5 w-full h-max">
<MessageInput
text={text}
setText={setText}
onEnter={sendMessage}
onTyping={() => {
if (!app.currentChannel) return;
sendRequest({
type: "typing",
params: {
channel_id: app.currentChannel.id,
},
});
}}
/>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="secondary" className="hidden md:block"> <Button variant="secondary" className="hidden md:block">
<SmilePlusIcon /> <SmilePlusIcon />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className=""> <DropdownMenuContent className="">
<EmojiPicker <EmojiPicker
onEmojiClick={(e) => setText(text + e.emoji)} onEmojiClick={(e) => setText(text + e.emoji)}
theme={ theme={
app.clientSettings.theme === "dark" app.clientSettings.theme === "dark"
? emoji.Theme.DARK ? emoji.Theme.DARK
: emoji.Theme.LIGHT : emoji.Theme.LIGHT
} }
/> />
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Button variant="secondary" onClick={sendMessage}> <Button variant="secondary" onClick={sendMessage}>
<SendIcon /> <SendIcon />
</Button> </Button>
</div>
</footer> </footer>
</div> </div>
); );