typing indicators
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user