import * as React from "react"; import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events"; import { ConversationUIEvents, ChatMessage, ChatEvent } from "tc-shared/ui/frames/side/ConversationManager"; import {ChatBox} from "tc-shared/ui/frames/side/ChatBox"; import {generate_client} from "tc-shared/ui/htmltags"; import {useEffect, useRef, useState} from "react"; import {bbcode_chat} from "tc-shared/ui/frames/chat"; import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import {AvatarRenderer} from "tc-shared/ui/react-elements/Avatar"; import {format} from "tc-shared/ui/frames/side/chat_helper"; import {Translatable} from "tc-shared/ui/react-elements/i18n"; import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; import {XBBCodeRenderer} from "../../../../../vendor/xbbcode/src/react"; const cssStyle = require("./Conversations.scss"); const CMTextRenderer = (props: { text: string }) => { const refElement = useRef(); const elements: HTMLElement[] = []; bbcode_chat(props.text).forEach(e => elements.push(...e)); useEffect(() => { if(elements.length === 0) return; refElement.current.replaceWith(...elements); return () => { /* just so react is happy again */ elements[0].replaceWith(refElement.current); elements.forEach(e => e.remove()); }; }); return {props.text} }; const TimestampRenderer = (props: { timestamp: number }) => { const time = format.date.format_chat_time(new Date(props.timestamp)); const [ revision, setRevision ] = useState(0); useEffect(() => { if(!time.next_update) return; const id = setTimeout(() => setRevision(revision + 1), time.next_update); return () => clearTimeout(id); }); return <>{time.result}; }; const ChatEventMessageRenderer = (props: { message: ChatMessage, callbackDelete?: () => void, events: Registry, handler: ConnectionHandler }) => { let deleteButton; if(props.callbackDelete) { deleteButton = (
X
); } return (
{deleteButton}
{ /* Only for copy purposes */ }

{ /* Only for copy purposes */ }
); }; const TimestampEntry = (props: { timestamp: Date, refDiv: React.Ref }) => { const diff = format.date.date_format(props.timestamp, new Date()); let formatted; let update: boolean; if(diff == format.date.ColloquialFormat.YESTERDAY) { formatted = Yesterday; update = true; } else if(diff == format.date.ColloquialFormat.TODAY) { formatted = Today; update = true; } else if(diff == format.date.ColloquialFormat.GENERAL) { formatted = <>{format.date.format_date_general(props.timestamp, false)}; update = false; } const [ revision, setRevision ] = useState(0); useEffect(() => { if(!update) return; const nextHour = new Date(); nextHour.setUTCMilliseconds(0); nextHour.setUTCMinutes(0); nextHour.setUTCHours(nextHour.getUTCHours() + 1); const id = setTimeout(() => { setRevision(revision + 1); }, nextHour.getTime() - Date.now() + 10); return () => clearTimeout(id); }); return (
{formatted}
); }; const UnreadEntry = (props: { refDiv: React.Ref }) => (
Unread messages
); interface ConversationMessagesProperties { events: Registry; handler: ConnectionHandler; } interface ConversationMessagesState { mode: "normal" | "loading" | "error" | "private" | "no-permission" | "not-supported" | "unselected"; scrollOffset: number | "bottom"; errorMessage?: string; failedPermission?: string; } @ReactEventHandler(e => e.props.events) class ConversationMessages extends React.Component { private readonly refMessages = React.createRef(); private readonly refUnread = React.createRef(); private readonly refTimestamp = React.createRef(); private readonly refScrollToNewMessages = React.createRef(); private conversationId: number = -1; private chatEvents: ChatEvent[] = []; private viewElementIndex = 0; private viewEntries: React.ReactElement[] = []; private unreadTimestamp: undefined | number; private scrollIgnoreTimestamp: number = 0; private currentHistoryFrame = { begin: undefined, end: undefined }; constructor(props) { super(props); this.state = { scrollOffset: "bottom", mode: "unselected", } } private scrollToBottom() { requestAnimationFrame(() => { if(this.state.scrollOffset !== "bottom") return; if(!this.refMessages.current) return; this.scrollIgnoreTimestamp = Date.now(); this.refMessages.current.scrollTop = this.refMessages.current.scrollHeight; }); } private scrollToNewMessage() { requestAnimationFrame(() => { if(!this.refUnread.current) return; this.scrollIgnoreTimestamp = Date.now(); this.refMessages.current.scrollTop = this.refUnread.current.offsetTop - this.refTimestamp.current.clientHeight; }); } private scrollToNewMessagesShown() { const newMessageOffset = this.refUnread.current?.offsetTop; return this.state.scrollOffset !== "bottom" && this.refMessages.current?.clientHeight + this.state.scrollOffset < newMessageOffset; } render() { let contents = []; switch (this.state.mode) { case "error": contents.push(); break; case "unselected": contents.push(); break; case "loading": contents.push(); break; case "private": contents.push(); break; case "no-permission": contents.push(); break; case "not-supported": contents.push(); break; case "normal": if(this.viewEntries.length === 0) { contents.push(); } else { contents = this.viewEntries; } break; } return (
this.state.mode === "normal" && this.props.events.fire("action_clear_unread_flag", { chatId: this.conversationId })} onScroll={() => { if(this.scrollIgnoreTimestamp > Date.now()) return; const top = this.refMessages.current.scrollTop; const total = this.refMessages.current.scrollHeight - this.refMessages.current.clientHeight; const shouldFollow = top + 200 > total; this.setState({ scrollOffset: shouldFollow ? "bottom" : top }); }} > {contents}
this.setState({ scrollOffset: "bottom" }, () => this.scrollToNewMessage())} > Scroll to new messages
); } componentDidMount(): void { this.scrollToBottom(); } componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any): void { requestAnimationFrame(() => { this.refScrollToNewMessages.current.classList.toggle(cssStyle.shown, this.scrollToNewMessagesShown()); }); } /* builds the view from the messages */ private buildView() { this.viewEntries = []; let timeMarker = new Date(0); let unreadSet = false, timestampRefSet = false; for(let event of this.chatEvents) { const mdate = new Date(event.timestamp); if(mdate.getFullYear() !== timeMarker.getFullYear() || mdate.getMonth() !== timeMarker.getMonth() || mdate.getDate() !== timeMarker.getDate()) { timeMarker = new Date(mdate.getFullYear(), mdate.getMonth(), mdate.getDate(), 1); this.viewEntries.push(); timestampRefSet = true; } if(event.timestamp >= this.unreadTimestamp && !unreadSet) { this.viewEntries.push(); unreadSet = true; } switch (event.type) { case "message": this.viewEntries.push( this.props.events.fire("action_delete_message", { chatId: this.conversationId, uniqueId: event.uniqueId })} handler={this.props.handler} />); break; case "message-failed": /* TODO! */ break; } } } @EventHandler("notify_server_state") private handleNotifyServerState(event: ConversationUIEvents["notify_server_state"]) { if(event.state === "connected") return; this.setState({ mode: "unselected" }); } @EventHandler("action_select_conversation") private handleSelectConversation(event: ConversationUIEvents["action_select_conversation"]) { if(this.conversationId === event.chatId) return; this.conversationId = event.chatId; this.chatEvents = []; this.currentHistoryFrame = { begin: undefined, end: undefined }; if(this.conversationId < 0) { this.setState({ mode: "unselected" }); } else { this.props.events.fire("query_conversation_state", { chatId: this.conversationId }); this.setState({ mode: "loading" }); } } @EventHandler("notify_conversation_state") private handleConversationStateUpdate(event: ConversationUIEvents["notify_conversation_state"]) { if(event.id !== this.conversationId) return; if(event.mode === "no-permissions") { this.setState({ mode: "no-permission", failedPermission: event.failedPermission }); } else if(event.mode === "loading") { this.setState({ mode: "loading" }); } else if(event.mode === "normal") { this.unreadTimestamp = event.unreadTimestamp; this.chatEvents = event.events; this.buildView(); this.setState({ mode: "normal", scrollOffset: "bottom" }, () => this.scrollToBottom()); } else { this.setState({ mode: "error", errorMessage: event.errorMessage || tr("Unknown error/Invalid state") }); } } @EventHandler("notify_chat_event") private handleMessageReceived(event: ConversationUIEvents["notify_chat_event"]) { if(event.conversation !== this.conversationId) return; this.chatEvents.push(event.event); if(typeof this.unreadTimestamp === "undefined" && event.triggerUnread) this.unreadTimestamp = event.event.timestamp; this.buildView(); this.forceUpdate(() => this.scrollToBottom()); } @EventHandler("notify_chat_message_delete") private handleMessageDeleted(event: ConversationUIEvents["notify_chat_message_delete"]) { if(event.conversation !== this.conversationId) return; let limit = { current: event.criteria.limit }; this.chatEvents = this.chatEvents.filter(mEvent => { if(mEvent.type !== "message") return; const message = mEvent.message; if(message.sender_database_id !== event.criteria.cldbid) return true; if(event.criteria.end != 0 && message.timestamp > event.criteria.end) return true; if(event.criteria.begin != 0 && message.timestamp < event.criteria.begin) return true; return --limit.current < 0; }); this.buildView(); this.forceUpdate(() => this.scrollToBottom()); } @EventHandler("action_clear_unread_flag") private handleMessageUnread(event: ConversationUIEvents["action_clear_unread_flag"]) { if (event.chatId !== this.conversationId || this.unreadTimestamp === undefined) return; this.unreadTimestamp = undefined; this.buildView(); this.forceUpdate(); }; @EventHandler("notify_panel_show") private handlePanelShow() { if(this.refUnread.current) { this.scrollToNewMessage(); } else if(this.state.scrollOffset === "bottom") { this.scrollToBottom(); } else { requestAnimationFrame(() => { if(this.state.scrollOffset === "bottom") return; this.scrollIgnoreTimestamp = Date.now() + 250; this.refMessages.current.scrollTop = this.state.scrollOffset; }); } } } export const ConversationPanel = (props: { events: Registry, handler: ConnectionHandler }) => { const currentChat = useRef({ id: -1 }); const chatEnabled = useRef(false); const refChatBox = useRef(); let connected = false; const updateChatBox = () => { refChatBox.current.setState({ enabled: connected && currentChat.current.id >= 0 && chatEnabled.current }); }; props.events.reactUse("notify_server_state", event => { connected = event.state === "connected"; updateChatBox(); }); props.events.reactUse("action_select_conversation", event => { currentChat.current.id = event.chatId; updateChatBox(); }); props.events.reactUse("notify_conversation_state", event => { chatEnabled.current = event.mode === "normal" || event.mode === "private"; updateChatBox(); }); useEffect(() => { return refChatBox.current.events.on("notify_typing", () => props.events.fire("action_clear_unread_flag", { chatId: currentChat.current.id })); }); return
props.events.fire("action_send_message", { chatId: currentChat.current.id, text: text }) } />
};