Add typing debounce/enable quoting

remotes/1693176582716663532/tmp_refs/heads/watchparty
Outrun Colors 2022-09-24 17:05:50 -05:00
parent ee204817ae
commit 695159052b
No known key found for this signature in database
GPG Key ID: 0426976DCEFE6073
14 changed files with 108 additions and 195 deletions

4
chat/global.d.ts vendored
View File

@ -2,7 +2,8 @@ declare var process: {
env: Record<string, any>;
};
declare interface ChatSpeakResponse {
declare interface IChatMessage {
id: string;
username: string;
avatar: string;
hat: string;
@ -11,6 +12,7 @@ declare interface ChatSpeakResponse {
text_censored: string;
text_html: string;
time: number;
quotes: null | string;
}
declare interface EmojiModSelection {

View File

@ -1,6 +1,6 @@
{
"name": "chat",
"version": "0.0.9",
"version": "0.0.10",
"main": "index.js",
"license": "MIT",
"dependencies": {

View File

@ -1,21 +0,0 @@
.Activity {
display: flex;
align-items: center;
padding: 1rem;
margin: 0 4rem;
}
.Activity > section {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
}
.Activity i {
margin-right: 3rem;
}
.Activity > section:not(:last-child) {
margin-right: 2rem;
}

View File

@ -1,39 +0,0 @@
import React from "react";
import "./Activity.css";
const ACTIVITIES = [
{
icon: "circle",
title: "Roulette",
description: "Round and round the wheel of fate turns.",
},
{
icon: "cards",
title: "Blackjack",
description: "Twenty one ways to change your life.",
},
{
icon: "dollar-sign",
title: "Slots",
description: "Today's your lucky day.",
},
{
icon: "dollar-sign",
title: "Racing",
description: "Make it all back at the track.",
},
{ icon: "dollar-sign", title: "Crossing", description: "Take a load off." },
];
export function Activity() {
return (
<div className="Activity">
{ACTIVITIES.map((activity) => (
<section key={activity.title}>
<i className={`fas fa-${activity.icon}`}></i>
<h4>{activity.title}</h4>
</section>
))}
</div>
);
}

View File

@ -1 +0,0 @@
export * from "./Activity";

View File

@ -1,56 +0,0 @@
.Chat {
display: flex;
align-items: stretch;
justify-content: center;
position: relative;
overflow: hidden;
width: 100%;
min-width: 350px;
max-width: 1200px;
}
.Chat-mobile-top {
display: none;
align-items: center;
justify-content: flex-end;
margin-bottom: 1rem;
}
.Chat-mobile-top i {
margin-right: 0.25rem;
position: relative;
top: -1px;
}
.Chat-mobile-top span {
font-size: 18px;
}
.Chat-side {
display: flex;
flex-direction: column;
width: 40%;
}
@media screen and (max-width: 1180px) {
.Chat-mobile-top {
display: flex;
}
.Chat-side {
display: none;
}
}
.Chat-drawer {
padding-right: 1rem;
}
.Chat-typing {
height: 18px;
display: inline-block;
}
.Chat-window {
width: 100%;
}

View File

@ -1,45 +0,0 @@
import React from "react";
import { useChat, useDrawer } from "../../hooks";
import { ActivityList } from "./ActivityList";
import { ChatMessageList } from "./ChatMessage";
import { QuotedMessage } from "./QuotedMessage";
import { UserInput } from "./UserInput";
import { UserList } from "./UserList";
import "./Chat.css";
export function Chat() {
const { reveal } = useDrawer();
const { online, quote } = useChat();
return (
<section className="Chat" id="chatWrapper">
<div className="Chat-window">
<div className="Chat-mobile-top">
<button
type="button"
className="btn btn-secondary"
onClick={() =>
reveal({
title: "Users in chat",
content: (
<div className="Chat-drawer">
<UserList fluid={true} />
</div>
),
})
}
>
<i className="far fa-user" /> <span>{online.length}</span>
</button>
</div>
<ChatMessageList />
{quote && <QuotedMessage />}
<UserInput />
</div>
<div className="Chat-side">
<UserList />
{process.env.FEATURES_ACTIVITY && <ActivityList />}
</div>
</section>
);
}

View File

@ -6,13 +6,17 @@ import { Username } from "./Username";
import { useChat, useRootContext } from "../../hooks";
import "./ChatMessage.css";
interface ChatMessageProps extends ChatSpeakResponse {
interface ChatMessageProps extends IChatMessage {
showUser?: boolean;
}
const TIMESTAMP_UPDATE_INTERVAL = 20000;
const SCROLL_TO_QUOTED_OVERFLOW = 250;
const QUOTED_MESSAGE_CONTEXTUAL_HIGHLIGHTING_DURATION = 2500;
const QUOTED_MESSAGE_CONTEXTUAL_SNIPPET_LENGTH = 30;
export function ChatMessage({
id,
avatar,
showUser = true,
namecolor,
@ -22,8 +26,10 @@ export function ChatMessage({
text_html,
text_censored,
time,
quotes,
}: ChatMessageProps) {
const message = {
id,
avatar,
namecolor,
username,
@ -32,6 +38,7 @@ export function ChatMessage({
text_html,
text_censored,
time,
quotes,
};
const {
username: loggedInUsername,
@ -39,17 +46,12 @@ export function ChatMessage({
censored,
themeColor,
} = useRootContext();
const { quote, deleteMessage, quoteMessage } = useChat();
const { quote, messageLookup, deleteMessage, quoteMessage } = useChat();
const content = censored ? text_censored : text_html;
const hasMention = content.includes(loggedInUsername);
const mentionStyle = hasMention
? { backgroundColor: `#${themeColor}55` }
: {};
const quoteStyle =
quote?.username === username && quote?.text === text && quote?.time === time
? { borderLeft: `2px solid #${themeColor}` }
: {};
const style = { ...mentionStyle, ...quoteStyle };
const [confirmedDelete, setConfirmedDelete] = useState(false);
const handleDeleteMessage = useCallback(() => {
if (confirmedDelete) {
@ -72,7 +74,7 @@ export function ChatMessage({
}, []);
return (
<div className={"ChatMessage"} style={style}>
<div className="ChatMessage" style={mentionStyle} id={id}>
{showUser && (
<div className="ChatMessage-top">
<Username
@ -84,6 +86,45 @@ export function ChatMessage({
<div className="ChatMessage-timestamp">{timestamp}</div>
</div>
)}
{quotes && (
<a
href="#"
onClick={() => {
const element = document.getElementById(quotes);
if (element) {
element.scrollIntoView();
element.style.background = `#${themeColor}33`;
setTimeout(() => {
element.style.background = "unset";
}, QUOTED_MESSAGE_CONTEXTUAL_HIGHLIGHTING_DURATION);
const [appContent] = Array.from(
document.getElementsByClassName("App-content")
);
if (appContent) {
appContent.scrollTop -= SCROLL_TO_QUOTED_OVERFLOW;
}
}
}}
>
Replying to @{messageLookup[quotes]?.username}:{" "}
<em>
"
{messageLookup[quotes]?.text.slice(
0,
QUOTED_MESSAGE_CONTEXTUAL_SNIPPET_LENGTH
)}
{messageLookup[quotes]?.text.length >=
QUOTED_MESSAGE_CONTEXTUAL_SNIPPET_LENGTH
? "..."
: ""}
"
</em>
</a>
)}
<div className="ChatMessage-bottom">
<div>
<span

View File

@ -12,7 +12,7 @@ import { EmojiDrawer, QuickEmojis } from "../emoji";
import "./UserInput.css";
export function UserInput() {
const { draft, sendMessage, updateDraft } = useChat();
const { messages, draft, sendMessage, updateDraft } = useChat();
const { reveal, hide, open } = useDrawer();
const builtChatInput = useRef<HTMLTextAreaElement>(null);
const { visible, addQuery } = useEmojis();
@ -29,7 +29,9 @@ export function UserInput() {
const emojiSegment = input.slice(openEmojiToken + 1, closeEmojiToken + 1);
updateDraft(input);
addQuery(openEmojiToken === -1 ? "" : emojiSegment);
addQuery(
openEmojiToken === -1 || emojiSegment.includes(" ") ? "" : emojiSegment
);
setTypingOffset(
emojiSegment.length * process.env.APPROXIMATE_CHARACTER_WIDTH
);
@ -68,7 +70,6 @@ export function UserInput() {
builtChatInput.current?.focus();
hide();
}, [hide]);
const handleSelectEmoji = useCallback((emoji: string) => {
updateDraft((prev) => `${prev} :${emoji}: `);
}, []);
@ -128,7 +129,7 @@ export function UserInput() {
role="button"
onClick={handleCloseEmojiDrawer}
className="UserInput-emoji"
style={{top: 6}}
style={{ top: 6 }}
>
X
</span>

View File

@ -1,5 +1,3 @@
export * from "./ActivityList";
export * from "./Chat";
export * from "./ChatHeading";
export * from "./ChatMessage";
export * from "./QuotedMessage";

View File

@ -1,3 +1,2 @@
export * from "./activity";
export * from "./chat";
export * from "./emoji";

View File

@ -10,6 +10,7 @@ import React, {
} from "react";
import { io, Socket } from "socket.io-client";
import lozad from "lozad";
import debounce from "lodash.debounce";
import { useRootContext } from "./useRootContext";
import { useWindowFocus } from "./useWindowFocus";
@ -25,12 +26,13 @@ enum ChatHandlers {
interface ChatProviderContext {
online: string[];
typing: string[];
messages: ChatSpeakResponse[];
messages: IChatMessage[];
draft: string;
quote: null | ChatSpeakResponse;
quote: null | IChatMessage;
messageLookup: Record<string, IChatMessage>;
updateDraft: React.Dispatch<React.SetStateAction<string>>;
sendMessage(): void;
quoteMessage(message: null | ChatSpeakResponse): void;
quoteMessage(message: null | IChatMessage): void;
deleteMessage(withText: string): void;
}
@ -40,38 +42,43 @@ const ChatContext = createContext<ChatProviderContext>({
messages: [],
draft: "",
quote: null,
messageLookup: {},
updateDraft() {},
sendMessage() {},
quoteMessage() {},
deleteMessage() {},
});
const MINIMUM_TYPING_UPDATE_INTERVAL = 250;
export function ChatProvider({ children }: PropsWithChildren) {
const { username, siteName } = useRootContext();
const socket = useRef<null | Socket>(null);
const [online, setOnline] = useState<string[]>([]);
const [typing, setTyping] = useState<string[]>([]);
const [messages, setMessages] = useState<ChatSpeakResponse[]>([]);
const [messages, setMessages] = useState<IChatMessage[]>([]);
const [draft, setDraft] = useState("");
const [quote, setQuote] = useState<null | ChatSpeakResponse>(null);
const lastDraft = useRef("");
const [quote, setQuote] = useState<null | IChatMessage>(null);
const focused = useWindowFocus();
const [notifications, setNotifications] = useState<number>(0);
const addMessage = useCallback((message: ChatSpeakResponse) => {
const [messageLookup, setMessageLookup] = useState({});
const addMessage = useCallback((message: IChatMessage) => {
setMessages((prev) => prev.concat(message));
if (message.username !== username && !document.hasFocus()) {
setNotifications((prev) => prev + 1);
}
}, []);
const sendMessage = useCallback(() => {
const message = quote
? `> ${quote.text}\n@${quote.username}<br /><br />${draft}`
: draft;
socket.current?.emit(ChatHandlers.SPEAK, message);
socket.current?.emit(ChatHandlers.SPEAK, {
message: draft,
quotes: quote?.id ?? null,
});
setQuote(null);
setDraft("");
}, [draft]);
}, [draft, quote]);
const requestDeleteMessage = useCallback((withText: string) => {
socket.current?.emit(ChatHandlers.DELETE, withText);
}, []);
@ -84,7 +91,7 @@ export function ChatProvider({ children }: PropsWithChildren) {
setQuote(null);
}
}, []);
const quoteMessage = useCallback((message: ChatSpeakResponse) => {
const quoteMessage = useCallback((message: IChatMessage) => {
setQuote(message);
try {
@ -98,6 +105,7 @@ export function ChatProvider({ children }: PropsWithChildren) {
messages,
draft,
quote,
messageLookup,
quoteMessage,
sendMessage,
deleteMessage: requestDeleteMessage,
@ -109,6 +117,7 @@ export function ChatProvider({ children }: PropsWithChildren) {
messages,
draft,
quote,
messageLookup,
sendMessage,
deleteMessage,
quoteMessage,
@ -128,8 +137,18 @@ export function ChatProvider({ children }: PropsWithChildren) {
}
});
const debouncedTypingUpdater = useMemo(
() =>
debounce(
() => socket.current?.emit(ChatHandlers.TYPING, lastDraft.current),
MINIMUM_TYPING_UPDATE_INTERVAL
),
[]
);
useEffect(() => {
socket.current?.emit(ChatHandlers.TYPING, draft);
lastDraft.current = draft;
debouncedTypingUpdater();
}, [draft]);
useEffect(() => {
@ -138,6 +157,15 @@ export function ChatProvider({ children }: PropsWithChildren) {
}
}, [focused]);
useEffect(() => {
setMessageLookup(
messages.reduce((prev, next) => {
prev[next.id] = next;
return prev;
}, {} as Record<string, IChatMessage>)
);
}, [messages]);
// Display e.g. [+2] Chat when notifications occur when you're away.
useEffect(() => {
const title = document.getElementsByTagName("title")[0];

View File

@ -1 +0,0 @@
.Activity{display:flex;align-items:center;padding:1rem;margin:0 4rem}.Activity>section{display:flex;align-items:center;justify-content:space-between;padding:0 1rem}.Activity i{margin-right:3rem}.Activity>section:not(:last-child){margin-right:2rem}.ActivityList{margin-left:2rem}.ActivityList h4{display:flex;align-items:center;justify-content:space-between}.ActivityList h4 hr{flex:1;margin-right:1rem}.ActivityList-activity{display:flex;align-items:center;justify-content:space-between;cursor:pointer}.ActivityList-activity-icon{margin-right:1rem}.ActivityList-activity{transition:background .4s ease-in-out;padding:0 1rem}.ActivityList-activity:hover{cursor:pointer;background:#ffffff05}.Username{display:inline-flex;align-items:center}.Username>a{font-weight:700;margin-left:8px}@keyframes fading-in{0%{opacity:0}to{opacity:1}}.ChatMessage{padding:.5rem 3rem .5rem .5rem;position:relative;animation:fading-in .3s ease-in-out forwards}.ChatMessage-top{display:flex;align-items:center}.ChatMessage-timestamp{margin-left:.5rem}.ChatMessage-bottom{display:flex;align-items:center;justify-content:space-between;padding-left:30px}.ChatMessage-content{margin-right:.5rem;word-break:break-all;display:inline-block}.ChatMessage-button{background:transparent!important}.ChatMessage-button__confirmed i{color:red!important}.ChatMessage-delete{position:absolute;top:4px;right:4px}.ChatMessageList{flex:1}.QuotedMessage{display:flex;align-items:center;justify-content:space-between}.QuotedMessage-content{margin-left:1rem;flex:1;max-width:420px;max-height:40px;overflow:hidden;text-overflow:ellipsis;margin-right:1rem}.BaseDrawer{flex:1;padding-right:2rem;overflow:hidden}.EmojiDrawer-options{display:flex;align-items:center;justify-content:space-between}.UserInput{position:relative}.UserInput-emoji{cursor:pointer;position:absolute;top:12px;right:12px;font-size:20px}.UserList{padding:1rem;border-left:1px dotted var(--primary);flex:1;position:absolute;top:0;left:0;width:100%;height:100%;overflow:auto}.UserList-heading{display:flex;align-items:center;justify-content:space-between}.UserList-heading h5{margin-right:2rem}.UserList ul::-webkit-scrollbar{display:none}.Chat{display:flex;align-items:stretch;justify-content:center;position:relative;overflow:hidden;width:100%;min-width:350px;max-width:1200px}.Chat-mobile-top{display:none;align-items:center;justify-content:flex-end;margin-bottom:1rem}.Chat-mobile-top i{margin-right:.25rem;position:relative;top:-1px}.Chat-mobile-top span{font-size:18px}.Chat-side{display:flex;flex-direction:column;width:40%}@media screen and (max-width: 1180px){.Chat-mobile-top{display:flex}.Chat-side{display:none}}.Chat-drawer{padding-right:1rem}.Chat-typing{height:18px;display:inline-block}.Chat-window{width:100%}.ChatHeading{flex:1;display:flex;align-items:center;justify-content:space-between}.ChatHeading i{margin-right:.5rem}.UsersTyping{height:18px;display:inline-block}html,body{overscroll-behavior-y:none}.App{position:fixed;width:100vw;display:flex;overflow:hidden}.App-wrapper{flex:1;overflow:hidden;display:flex;flex-direction:column;margin:0 auto;max-width:1000px}.App-heading{flex-basis:3rem;border-bottom:1px dashed var(--primary);display:flex;align-items:center}.App-heading small{opacity:.2}.App-side{height:100%;flex:1;background:var(--gray-500);position:relative}.App-content{position:relative;flex:3;height:60vh;max-height:1000px;overflow:auto;-ms-overflow-style:none;scrollbar-width:none;display:flex;flex-direction:column;border-bottom:1px dashed var(--primary)}.App-content::-webkit-scrollbar{display:none}.App-drawer{z-index:2;display:flex;background:var(--background);height:100%}.App-center,.App-bottom-wrapper{display:flex;align-items:flex-start}.App-bottom{flex:3}.App-bottom-dummy{flex:1}.App-bottom-extra{padding:1rem;height:64px}@media screen and (max-width: 1100px){.App-side,.App-bottom-dummy{display:none}.App-bottom-wrapper{padding-right:1rem;padding-left:1rem}}lite-youtube{min-width:min(80vw,500px)}

View File

@ -1,4 +1,5 @@
import time
import uuid
from files.helpers.jinja2 import timestamp
from files.helpers.wrappers import *
from files.helpers.sanitize import sanitize
@ -54,11 +55,17 @@ def speak(data, v):
global messages, total
if SITE == 'rdrama.net': text = data[:200].strip()
else: text = data[:1000].strip()
else: text = data['message'][:1000].strip()
if not text: return '', 403
text_html = sanitize(text, count_marseys=True)
print("\n\n\n\n\n\n\n\n\n")
print("\n\n\n\n\n\n\n\n\n")
print(data)
quotes = data['quotes']
data={
"id": str(uuid.uuid4()),
"quotes": quotes,
"avatar": v.profile_url,
"hat": v.hat_active,
"username": v.username,