import * as React from "react"; import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events"; import {Ref, useEffect, useRef, useState} from "react"; import {AvatarRenderer} from "tc-shared/ui/react-elements/Avatar"; import {Translatable} from "tc-shared/ui/react-elements/i18n"; import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; import {Countdown} from "tc-shared/ui/react-elements/Countdown"; import { ChatEvent, ChatEventLocalAction, ChatEventLocalUserSwitch, ChatEventMessageSendFailed, ChatEventPartnerInstanceChanged, ChatEventQueryFailed, ChatEventPartnerAction, ChatHistoryState, ChatMessage, ConversationUIEvents } from "tc-shared/ui/frames/side/ConversationDefinitions"; import {TimestampRenderer} from "tc-shared/ui/react-elements/TimestampRenderer"; import {BBCodeRenderer} from "tc-shared/text/bbcode"; import {getGlobalAvatarManagerFactory} from "tc-shared/file/Avatars"; import {ColloquialFormat, date_format, format_date_general, formatDayTime} from "tc-shared/utils/DateUtils"; import {ClientTag} from "tc-shared/ui/tree/EntryTags"; import {ChatBox} from "tc-shared/ui/react-elements/ChatBox"; const cssStyle = require("./AbstractConversationRenderer.scss"); const ChatMessageTextRenderer = React.memo((props: { text: string }) => { if(typeof props.text !== "string") { debugger; } return ; }); const ChatEventMessageRenderer = React.memo((props: { message: ChatMessage, callbackDelete?: () => void, events: Registry, handlerId: string, refHTMLElement?: Ref }) => { let deleteButton; if(props.callbackDelete) { deleteButton = (
{""}
); } const avatar = getGlobalAvatarManagerFactory().getManager(props.handlerId)?.resolveClientAvatar({ clientUniqueId: props.message.sender_unique_id, database_id: props.message.sender_database_id }); return (
{deleteButton} { /* Only for copy purposes */}
{ /* Only for copy purposes */ }

{ /* Only for copy purposes */ }
); }); const TimestampEntry = (props: { timestamp: Date, refDiv: React.Ref }) => { const diff = date_format(props.timestamp, new Date()); let formatted; let update: boolean; if(diff == ColloquialFormat.YESTERDAY) { formatted = Yesterday; update = true; } else if(diff == ColloquialFormat.TODAY) { formatted = Today; update = true; } else if(diff == ColloquialFormat.GENERAL) { formatted = <>{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
); const LoadOderMessages = (props: { events: Registry, chatId: string, state: ChatHistoryState | "error", errorMessage?: string, retryTimestamp?: number, timestamp: number | undefined }) => { if(props.state === "none") return null; let innerMessage, onClick; if(props.state === "loading") { innerMessage = <>loading older messages ; } else if(props.state === "available") { const shouldThrottle = Date.now() < props.retryTimestamp; const [ revision, setRevision ] = useState(0); useEffect(() => { if(!shouldThrottle) return; const timeout = setTimeout(() => setRevision(revision + 1), props.retryTimestamp - Date.now()); return () => clearTimeout(timeout); }); if(shouldThrottle) { innerMessage = please wait  ; } else { onClick = props.state === "available" && props.timestamp ? () => props.events.fire("query_conversation_history", { chatId: props.chatId, timestamp: props.timestamp }) : undefined; innerMessage = Load older messages; } } else { innerMessage = ( <> History query failed ({props.errorMessage})
Try again in ); } return (
{innerMessage}
) }; const JumpToPresent = (props: { events: Registry, chatId: string }) => (
props.events.fire("action_jump_to_present", { chatId: props.chatId })} >
Jump to present
); const ChatEventLocalUserSwitchRenderer = (props: { event: ChatEventLocalUserSwitch, timestamp: number, refHTMLElement: Ref }) => { return (
{props.event.mode === "join" ? You joined at : You left at}   {formatDayTime(new Date(props.timestamp))}
) }; const ChatEventQueryFailedRenderer = (props: { event: ChatEventQueryFailed, refHTMLElement: Ref }) => { return (
failed to query history   ({props.event.message})
) }; const ChatEventMessageFailedRenderer = (props: { event: ChatEventMessageSendFailed, refHTMLElement: Ref }) => { if(props.event.error === "permission") return (
message send failed due to permission {" " + props.event.failedPermission}
); return (
failed to send message:   {props.event.errorMessage || tr("Unknown error")}
) }; const ChatEventPartnerInstanceChangedRenderer = (props: { event: ChatEventPartnerInstanceChanged, refHTMLElement: Ref }) => { return (
You're now chatting with   {props.event.newClient}
) }; const ChatEventLocalActionRenderer = (props: { event: ChatEventLocalAction, refHTMLElement: Ref }) => { switch (props.event.action) { case "disconnect": return (
You've disconnected from the server
); case "reconnect": return (
Chat reconnected
); } }; const ChatEventPartnerActionRenderer = (props: { event: ChatEventPartnerAction, refHTMLElement: Ref }) => { switch (props.event.action) { case "close": return (
Your chat partner has closed the conversation
); case "disconnect": return (
Your chat partner has disconnected
); case "reconnect": return (
Your chat partner has reconnected
); } return null; }; const PartnerTypingIndicator = (props: { events: Registry, chatId: string, timeout?: number }) => { const kTypingTimeout = props.timeout || 5000; const [ typingTimestamp, setTypingTimestamp ] = useState(0); props.events.reactUse("notify_partner_typing", event => { if(event.chatId !== props.chatId) return; setTypingTimestamp(Date.now()); }); props.events.reactUse("notify_chat_event", event => { if(event.chatId !== props.chatId) return; if(event.event.type === "message") { if(!event.event.isOwnMessage) setTypingTimestamp(0); } else if(event.event.type === "partner-action" || event.event.type === "local-action") { setTypingTimestamp(0); } }); const isTyping = Date.now() - kTypingTimeout < typingTimestamp; useEffect(() => { if(!isTyping) return; const timeout = setTimeout(() => { setTypingTimestamp(0); }, kTypingTimeout); return () => clearTimeout(timeout); }); return (
Partner is typing
) }; interface ConversationMessagesProperties { events: Registry; handlerId: string; noFirstMessageOverlay?: boolean messagesDeletable?: boolean; } interface ConversationMessagesState { mode: "normal" | "loading" | "error" | "private" | "no-permission" | "not-supported" | "unselected"; errorMessage?: string; failedPermission?: string; historyState: ChatHistoryState | "error"; historyErrorMessage?: string; historyRetryTimestamp?: number; isBrowsingHistory: boolean; } @ReactEventHandler(e => e.props.events) class ConversationMessages extends React.PureComponent { private readonly refMessages = React.createRef(); private readonly refUnread = React.createRef(); private readonly refTimestamp = React.createRef(); private readonly refScrollToNewMessages = React.createRef(); private readonly refScrollElement = React.createRef(); private readonly refFirstChatEvent = React.createRef(); private scrollElementPreviousOffset = 0; private scrollOffset: number | "bottom" | "element"; private currentChatId: "unselected" | string = "unselected"; private chatEvents: ChatEvent[] = []; private showSwitchEvents: boolean = false; private scrollEventUniqueId: string; private viewElementIndex = 0; private viewEntries: React.ReactElement[] = []; private unreadTimestamp: undefined | number; private chatFrameMaxMessageCount: number; private chatFrameMaxHistoryMessageCount: number; private historyRetryTimer: number; private ignoreNextScroll: boolean = false; private scrollHistoryAutoLoadThrottle: number = 0; constructor(props) { super(props); this.state = { mode: "unselected", historyState: "available", isBrowsingHistory: false, historyRetryTimestamp: 0 } } private scrollToBottom() { this.ignoreNextScroll = true; requestAnimationFrame(() => { this.ignoreNextScroll = false; if(this.scrollOffset !== "bottom") return; if(!this.refMessages.current) return; this.refMessages.current.scrollTop = this.refMessages.current.scrollHeight; }); } private scrollToNewMessage() { this.ignoreNextScroll = true; requestAnimationFrame(() => { this.ignoreNextScroll = false; if(!this.refUnread.current) return; this.refMessages.current.scrollTop = this.refUnread.current.offsetTop - this.refTimestamp.current.clientHeight; }); } private fixScroll() { if(this.scrollOffset === "element") { this.ignoreNextScroll = true; requestAnimationFrame(() => { this.ignoreNextScroll = false; if(!this.refMessages.current) return; let scrollTop; if(this.refScrollElement.current) { /* scroll to the element */ scrollTop = this.refScrollElement.current.offsetTop - this.scrollElementPreviousOffset; } else { /* just scroll to the bottom */ scrollTop = this.refMessages.current.scrollHeight; } this.refMessages.current.scrollTop = scrollTop; this.scrollOffset = scrollTop; this.scrollEventUniqueId = undefined; }); } else if(this.scrollOffset !== "bottom") { this.ignoreNextScroll = true; requestAnimationFrame(() => { if(this.scrollOffset === "bottom") return; this.ignoreNextScroll = false; this.refMessages.current.scrollTop = this.scrollOffset as any; }); } else if(this.refUnread.current) { this.scrollToNewMessage(); } else { this.scrollToBottom(); } } private scrollToNewMessagesShown() { const newMessageOffset = this.refUnread.current?.offsetTop; return typeof this.scrollOffset === "number" && this.refMessages.current?.clientHeight + this.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 && !this.props.noFirstMessageOverlay) { contents.push(); } else { contents = this.viewEntries; } break; } const firstMessageTimestamp = this.chatEvents[0]?.timestamp; return (
this.state.mode === "normal" && this.props.events.fire("action_clear_unread_flag", { chatId: this.currentChatId })} onScroll={() => { if(this.ignoreNextScroll) return; const top = this.refMessages.current.scrollTop; const total = this.refMessages.current.scrollHeight - this.refMessages.current.clientHeight; const shouldFollow = top + 200 > total; if(firstMessageTimestamp && top <= 20 && this.state.historyState === "available" && Math.max(this.scrollHistoryAutoLoadThrottle, this.state.historyRetryTimestamp) < Date.now()) { /* only load history when we're in an upwards scroll move */ if(this.scrollOffset === "bottom" || this.scrollOffset > top) { this.scrollHistoryAutoLoadThrottle = Date.now() + 500; /* don't spam events */ this.props.events.fire_react("query_conversation_history", { chatId: this.currentChatId, timestamp: firstMessageTimestamp }); } } this.scrollOffset = shouldFollow ? "bottom" : top; }} > {contents} {this.state.isBrowsingHistory ?
: undefined}
{ this.scrollOffset = "bottom"; this.scrollToNewMessage(); }} > Scroll to new messages
{this.state.isBrowsingHistory ? : undefined }
); } componentDidMount(): void { this.props.events.fire("query_selected_chat"); this.scrollToBottom(); } componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any): void { requestAnimationFrame(() => { this.refScrollToNewMessages.current?.classList.toggle(cssStyle.shown, this.scrollToNewMessagesShown()); }); } componentWillUnmount(): void { clearTimeout(this.historyRetryTimer); this.historyRetryTimer = undefined; } private sortEvents() { this.chatEvents.sort((a, b) => a.timestamp - b.timestamp); } /* builds the view from the messages */ private buildView() { this.viewEntries = []; let timeMarker = new Date(0); let unreadSet = false, timestampRefSet = false; let firstEvent = true; 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; } let reference = this.scrollEventUniqueId === event.uniqueId ? this.refScrollElement : firstEvent ? this.refFirstChatEvent : undefined; firstEvent = false; switch (event.type) { case "message": this.viewEntries.push( this.props.events.fire("action_delete_message", { chatId: this.currentChatId, uniqueId: event.uniqueId }) : undefined} handlerId={this.props.handlerId} refHTMLElement={reference} />); break; case "message-failed": this.viewEntries.push(); break; case "local-user-switch": this.viewEntries.push(); break; case "query-failed": this.viewEntries.push(); break; case "partner-instance-changed": this.viewEntries.push(); break; case "local-action": this.viewEntries.push(); break; case "partner-action": this.viewEntries.push(); break; } } } @EventHandler("notify_selected_chat") private handleNotifySelectedChat(event: ConversationUIEvents["notify_selected_chat"]) { if(this.currentChatId === event.chatId) { return; } this.currentChatId = event.chatId; this.chatEvents = []; if(this.currentChatId === "unselected") { this.setState({ mode: "unselected" }); } else { this.props.events.fire("query_conversation_state", { chatId: this.currentChatId }); this.setState({ mode: "loading" }); } } @EventHandler("notify_conversation_state") private handleConversationStateUpdate(event: ConversationUIEvents["notify_conversation_state"]) { if(event.chatId !== this.currentChatId) return; if(event.state === "no-permissions") { this.chatEvents = []; this.buildView(); this.setState({ mode: "no-permission", failedPermission: event.failedPermission }); } else if(event.state === "loading") { this.chatEvents = []; this.buildView(); this.setState({ mode: "loading" }); } else if(event.state === "normal") { this.chatFrameMaxMessageCount = event.chatFrameMaxMessageCount; this.chatFrameMaxHistoryMessageCount = event.chatFrameMaxMessageCount * 2; this.showSwitchEvents = event.showUserSwitchEvents; this.unreadTimestamp = event.unreadTimestamp; this.chatEvents = event.events.slice(0).filter(e => e.type !== "local-user-switch" || event.showUserSwitchEvents); this.sortEvents(); this.buildView(); this.scrollOffset = "bottom"; this.setState({ mode: "normal", isBrowsingHistory: false, historyState: event.historyState, historyErrorMessage: event.historyErrorMessage, historyRetryTimestamp: event.historyRetryTimestamp }, () => this.scrollToBottom()); } else if(event.state === "private") { this.chatEvents = []; this.buildView(); this.setState({ mode: event.crossChannelChatSupported ? "private" : "not-supported" }); } else { this.chatEvents = []; this.buildView(); this.setState({ mode: "error", errorMessage: 'errorMessage' in event ? event.errorMessage : tr("Unknown error/Invalid state") }); } } @EventHandler("notify_chat_event") private handleChatEvent(event: ConversationUIEvents["notify_chat_event"]) { if(event.chatId !== this.currentChatId || this.state.isBrowsingHistory) return; if(event.event.type === "local-user-switch" && !this.showSwitchEvents) return; this.chatEvents.push(event.event); this.sortEvents(); if(typeof this.unreadTimestamp === "undefined" && event.triggerUnread) this.unreadTimestamp = event.event.timestamp; const spliceCount = Math.max(0, this.chatEvents.length - this.chatFrameMaxMessageCount); this.chatEvents.splice(0, spliceCount); if(spliceCount > 0 && this.state.historyState === "none") this.setState({ historyState: "available" }); this.buildView(); this.forceUpdate(() => this.scrollToBottom()); } @EventHandler("notify_chat_message_delete") private handleMessageDeleted(event: ConversationUIEvents["notify_chat_message_delete"]) { if(event.chatId !== this.currentChatId) { return; } this.chatEvents = this.chatEvents.filter(mEvent => event.messageIds.indexOf(mEvent.uniqueId) === -1); this.buildView(); this.forceUpdate(() => this.scrollToBottom()); } @EventHandler("notify_unread_timestamp_changed") private handleUnreadTimestampChanged(event: ConversationUIEvents["notify_unread_timestamp_changed"]) { if (event.chatId !== this.currentChatId) return; const oldUnreadTimestamp = this.unreadTimestamp; if(this.chatEvents.last()?.timestamp > event.timestamp) { this.unreadTimestamp = event.timestamp; } else { this.unreadTimestamp = undefined; } if(oldUnreadTimestamp !== this.unreadTimestamp) { this.buildView(); this.forceUpdate(); } } @EventHandler("notify_panel_show") private handlePanelShow() { this.fixScroll(); } @EventHandler("query_conversation_history") private handleQueryConversationHistory(event: ConversationUIEvents["query_conversation_history"]) { if (event.chatId !== this.currentChatId) return; this.setState({ historyState: "loading" }); } @EventHandler("notify_conversation_history") private handleNotifyConversationHistory(event: ConversationUIEvents["notify_conversation_history"]) { if (event.chatId !== this.currentChatId) return; clearTimeout(this.historyRetryTimer); if(event.state === "error") { this.setState({ historyState: "error", historyErrorMessage: event.errorMessage, historyRetryTimestamp: event.retryTimestamp }); this.historyRetryTimer = setTimeout(() => { this.setState({ historyState: "available" }); this.historyRetryTimer = undefined; }, event.retryTimestamp - Date.now()) as any; } else { this.scrollElementPreviousOffset = this.refFirstChatEvent.current ? this.refFirstChatEvent.current.offsetTop - this.refFirstChatEvent.current.parentElement.scrollTop : 0; this.scrollEventUniqueId = this.chatEvents[0].uniqueId; this.chatEvents.push(...event.events); this.sortEvents(); const spliceCount = Math.max(0, this.chatEvents.length - this.chatFrameMaxHistoryMessageCount); this.chatEvents.splice(this.chatFrameMaxHistoryMessageCount, spliceCount); this.buildView(); this.setState({ isBrowsingHistory: true, historyState: event.hasMoreMessages ? "available" : "none", historyRetryTimestamp: event.retryTimestamp }, () => { this.scrollOffset = "element"; this.fixScroll(); }); } } } export const ConversationPanel = React.memo((props: { events: Registry, handlerId: string, messagesDeletable: boolean, noFirstMessageOverlay: boolean }) => { const currentChat = useRef({ id: "unselected" }); const chatEnabled = useRef(false); const refChatBox = useRef(); const updateChatBox = () => { refChatBox.current.setState({ enabled: currentChat.current.id !== "unselected" && chatEnabled.current }); }; props.events.reactUse("notify_selected_chat", event => { currentChat.current.id = event.chatId; updateChatBox(); }); props.events.reactUse("notify_conversation_state", event => { chatEnabled.current = event.state === "normal" && event.sendEnabled; updateChatBox(); }); props.events.reactUse("notify_send_enabled", event => { if(event.chatId !== currentChat.current.id) return; chatEnabled.current = event.enabled; updateChatBox(); }); props.events.reactUse("action_focus_chat", () => refChatBox.current?.events.fire("action_request_focus")); useEffect(() => { return refChatBox.current.events.on("notify_typing", () => props.events.fire("action_self_typing", { chatId: currentChat.current.id })); }); return
props.events.fire("action_send_message", { chatId: currentChat.current.id, text: text }) } />
});