Path: blob/master/src/packages/frontend/chat/chat-log.tsx
5837 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6Render all the messages in the chat.7*/89// cSpell:ignore: timespan1011import { Alert, Button } from "antd";12import { Set as immutableSet } from "immutable";13import { MutableRefObject, useEffect, useMemo, useRef } from "react";14import { Virtuoso, VirtuosoHandle } from "react-virtuoso";1516import { chatBotName, isChatBot } from "@cocalc/frontend/account/chatbot";17import { useRedux, useTypedRedux } from "@cocalc/frontend/app-framework";18import { Icon } from "@cocalc/frontend/components";19import useVirtuosoScrollHook from "@cocalc/frontend/components/virtuoso-scroll-hook";20import { HashtagBar } from "@cocalc/frontend/editors/task-editor/hashtag-bar";21import { DivTempHeight } from "@cocalc/frontend/jupyter/cell-list";22import {23cmp,24hoursToTimeIntervalHuman,25parse_hashtags,26plural,27} from "@cocalc/util/misc";28import type { ChatActions } from "./actions";29import Composing from "./composing";30import { filterMessages } from "./filter-messages";31import Message from "./message";32import type {33ChatMessageTyped,34ChatMessages,35CostEstimate,36Mode,37NumChildren,38} from "./types";39import {40getRootMessage,41getSelectedHashtagsSearch,42getThreadRootDate,43newest_content,44} from "./utils";4546interface Props {47project_id: string; // used to render links more effectively48path: string;49mode: Mode;50scrollToBottomRef?: MutableRefObject<(force?: boolean) => void>;51setLastVisible?: (x: Date | null) => void;52fontSize?: number;53actions: ChatActions;54search;55filterRecentH?;56selectedHashtags;57disableFilters?: boolean;58selectedThread?: string;59scrollToIndex?: null | number | undefined;60// scrollToDate = string ms from epoch61scrollToDate?: null | undefined | string;62selectedDate?: string;63costEstimate?;64}6566export function ChatLog({67project_id,68path,69scrollToBottomRef,70mode,71setLastVisible,72fontSize,73actions,74search: search0,75filterRecentH,76selectedHashtags: selectedHashtags0,77disableFilters,78selectedThread,79scrollToIndex,80scrollToDate,81selectedDate,82costEstimate,83}: Props) {84const storeMessages = useRedux(85["messages"],86project_id,87path,88) as ChatMessages;89const singleThreadView = selectedThread != null;90const messages = useMemo(() => {91if (!selectedThread || storeMessages == null) {92return storeMessages;93}94return storeMessages.filter((message) => {95if (message == null) return false;96const replyTo = message.get("reply_to");97if (replyTo != null) {98return `${new Date(replyTo).valueOf()}` === selectedThread;99}100const dateValue = message.get("date")?.valueOf();101return dateValue != null ? `${dateValue}` === selectedThread : false;102}) as ChatMessages;103}, [storeMessages, selectedThread]);104// see similar code in task list:105const { selectedHashtags, selectedHashtagsSearch } = useMemo(() => {106return getSelectedHashtagsSearch(selectedHashtags0);107}, [selectedHashtags0]);108const search = (search0 + " " + selectedHashtagsSearch).trim();109110const user_map = useTypedRedux("users", "user_map");111const account_id = useTypedRedux("account", "account_id");112const {113dates: sortedDates,114numFolded,115numChildren,116} = useMemo<{117dates: string[];118numFolded: number;119numChildren: NumChildren;120}>(() => {121const { dates, numFolded, numChildren } = getSortedDates(122messages,123search,124account_id!,125filterRecentH,126singleThreadView,127);128// TODO: This is an ugly hack because I'm tired and need to finish this.129// The right solution would be to move this filtering to the store.130// The timeout is because you can't update a component while rendering another one.131setTimeout(() => {132setLastVisible?.(133dates.length == 0134? null135: new Date(parseFloat(dates[dates.length - 1])),136);137}, 1);138return { dates, numFolded, numChildren };139}, [messages, search, project_id, path, filterRecentH, singleThreadView]);140141useEffect(() => {142scrollToBottomRef?.current?.(true);143}, [search]);144145useEffect(() => {146if (scrollToIndex == null) {147return;148}149if (scrollToIndex == -1) {150scrollToBottomRef?.current?.(true);151} else {152virtuosoRef.current?.scrollToIndex({ index: scrollToIndex });153}154actions.clearScrollRequest();155}, [scrollToIndex]);156157useEffect(() => {158if (scrollToDate == null) {159return;160}161// linear search, which should be fine given that this is not a tight inner loop162const index = sortedDates.indexOf(scrollToDate);163if (index == -1) {164// didn't find it?165const message = messages.get(scrollToDate);166if (message == null) {167// the message really doesn't exist. Weird. Give up.168actions.clearScrollRequest();169return;170}171let tryAgain = false;172// we clear all filters and ALSO make sure173// if message is in a folded thread, then that thread is not folded.174if (account_id && isFolded(messages, message, account_id)) {175// this actually unfolds it, since it was folded.176const date = new Date(177getThreadRootDate({ date: parseFloat(scrollToDate), messages }),178);179actions.toggleFoldThread(date);180tryAgain = true;181}182if (messages.size > sortedDates.length && (search || filterRecentH)) {183// there was a search, so clear it just to be sure -- it could still hide184// the folded threaded185actions.clearAllFilters();186tryAgain = true;187}188if (tryAgain) {189// we have to wait a while for full re-render to happen190setTimeout(() => {191actions.scrollToDate(parseFloat(scrollToDate));192}, 10);193} else {194// totally give up195actions.clearScrollRequest();196}197return;198}199virtuosoRef.current?.scrollToIndex({ index });200actions.clearScrollRequest();201}, [scrollToDate]);202203const visibleHashtags = useMemo(() => {204let X = immutableSet<string>([]);205if (disableFilters) {206return X;207}208for (const date of sortedDates) {209const message = messages.get(date);210const value = newest_content(message);211for (const x of parse_hashtags(value)) {212const tag = value.slice(x[0] + 1, x[1]).toLowerCase();213X = X.add(tag);214}215}216return X;217}, [messages, sortedDates]);218219const virtuosoRef = useRef<VirtuosoHandle>(null);220const manualScrollRef = useRef<boolean>(false);221222useEffect(() => {223if (scrollToBottomRef == null) return;224scrollToBottomRef.current = (force?: boolean) => {225if (manualScrollRef.current && !force) return;226manualScrollRef.current = false;227const doScroll = () =>228virtuosoRef.current?.scrollToIndex({ index: Number.MAX_SAFE_INTEGER });229230doScroll();231// sometimes scrolling to bottom is requested before last entry added,232// so we do it again in the next render loop. This seems needed mainly233// for side chat when there is little vertical space.234setTimeout(doScroll, 1);235};236}, [scrollToBottomRef != null]);237238return (239<>240{visibleHashtags.size > 0 && (241<HashtagBar242style={{ margin: "5px 15px 15px 15px" }}243actions={{244set_hashtag_state: (tag, state) => {245actions.setHashtagState(tag, state);246},247}}248selected_hashtags={selectedHashtags0}249hashtags={visibleHashtags}250/>251)}252{messages != null && (253<NotShowing254num={messages.size - numFolded - sortedDates.length}255showing={sortedDates.length}256search={search}257filterRecentH={filterRecentH}258actions={actions}259/>260)}261<MessageList262{...{263virtuosoRef,264sortedDates,265messages,266search,267account_id,268user_map,269project_id,270path,271fontSize,272selectedHashtags,273actions,274costEstimate,275manualScrollRef,276mode,277selectedDate,278numChildren,279singleThreadView,280}}281/>282<Composing283projectId={project_id}284path={path}285accountId={account_id}286userMap={user_map}287/>288</>289);290}291292function isNextMessageSender(293index: number,294dates: string[],295messages: ChatMessages,296): boolean {297if (index + 1 === dates.length) {298return false;299}300const currentMessage = messages.get(dates[index]);301const nextMessage = messages.get(dates[index + 1]);302return (303currentMessage != null &&304nextMessage != null &&305currentMessage.get("sender_id") === nextMessage.get("sender_id")306);307}308309function isPrevMessageSender(310index: number,311dates: string[],312messages: ChatMessages,313): boolean {314if (index === 0) {315return false;316}317const currentMessage = messages.get(dates[index]);318const prevMessage = messages.get(dates[index - 1]);319return (320currentMessage != null &&321prevMessage != null &&322currentMessage.get("sender_id") === prevMessage.get("sender_id")323);324}325326function isThread(message: ChatMessageTyped, numChildren: NumChildren) {327if (message.get("reply_to") != null) {328return true;329}330return (numChildren[message.get("date").valueOf()] ?? 0) > 0;331}332333function isFolded(334messages: ChatMessages,335message: ChatMessageTyped,336account_id: string,337) {338if (account_id == null) {339return false;340}341const rootMsg = getRootMessage({ message: message.toJS(), messages });342return rootMsg?.get("folding")?.includes(account_id) ?? false;343}344345// messages is an immutablejs map from346// - timestamps (ms since epoch as string)347// to348// - message objects {date: , event:, history, sender_id, reply_to}349//350// It was very easy to sort these before reply_to, which complicates things.351export function getSortedDates(352messages: ChatMessages,353search: string | undefined,354account_id: string,355filterRecentH?: number,356disableFolding?: boolean,357): {358dates: string[];359numFolded: number;360numChildren: NumChildren;361} {362let numFolded = 0;363let m = messages;364if (m == null) {365return {366dates: [],367numFolded: 0,368numChildren: {},369};370}371372// we assume filterMessages contains complete threads. It does373// right now, but that's an assumption in this function.374m = filterMessages({ messages: m, filter: search, filterRecentH });375376// Do a linear pass through all messages to divide into threads, so that377// getSortedDates is O(n) instead of O(n^2) !378const numChildren: NumChildren = {};379for (const [_, message] of m) {380const parent = message.get("reply_to");381if (parent != null) {382const d = new Date(parent).valueOf();383numChildren[d] = (numChildren[d] ?? 0) + 1;384}385}386387const v: [date: number, reply_to: number | undefined][] = [];388for (const [date, message] of m) {389if (message == null) continue;390391// If we search for a message, we treat all threads as unfolded392if (!disableFolding && !search) {393const is_thread = isThread(message, numChildren);394const is_folded = is_thread && isFolded(messages, message, account_id);395const is_thread_body = is_thread && message.get("reply_to") != null;396const folded = is_thread && is_folded && is_thread_body;397if (folded) {398numFolded++;399continue;400}401}402403const reply_to = message.get("reply_to");404v.push([405typeof date === "string" ? parseInt(date) : date,406reply_to != null ? new Date(reply_to).valueOf() : undefined,407]);408}409v.sort(cmpMessages);410const dates = v.map((z) => `${z[0]}`);411return { dates, numFolded, numChildren };412}413414/*415Compare messages as follows:416- if message has a parent it is a reply, so we use the parent instead for the417compare418- except in special cases:419- one of them is the parent and other is a child of that parent420- both have same parent421*/422function cmpMessages([a_time, a_parent], [b_time, b_parent]): number {423// special case:424// same parent:425if (a_parent !== undefined && a_parent == b_parent) {426return cmp(a_time, b_time);427}428// one of them is the parent and other is a child of that parent429if (a_parent == b_time) {430// b is the parent of a, so b is first.431return 1;432}433if (b_parent == a_time) {434// a is the parent of b, so a is first.435return -1;436}437// general case.438return cmp(a_parent ?? a_time, b_parent ?? b_time);439}440441export function getUserName(userMap, accountId: string): string {442if (isChatBot(accountId)) {443return chatBotName(accountId);444}445if (userMap == null) return "Unknown";446const account = userMap.get(accountId);447if (account == null) return "Unknown";448return account.get("first_name", "") + " " + account.get("last_name", "");449}450451interface NotShowingProps {452num: number;453search: string;454filterRecentH: number;455actions;456showing;457}458459function NotShowing({460num,461search,462filterRecentH,463actions,464showing,465}: NotShowingProps) {466if (num <= 0) return null;467468const timespan =469filterRecentH > 0 ? hoursToTimeIntervalHuman(filterRecentH) : null;470471return (472<Alert473style={{ margin: "5px" }}474showIcon475type="warning"476message={477<div style={{ display: "flex", alignItems: "center" }}>478<b style={{ flex: 1 }}>479WARNING: Hiding {num} {plural(num, "message")} in threads480{search.trim()481? ` that ${482num != 1 ? "do" : "does"483} not match search for '${search.trim()}'`484: ""}485{timespan486? ` ${487search.trim() ? "and" : "that"488} were not sent in the past ${timespan}`489: ""}490. Showing {showing} {plural(showing, "message")}.491</b>492<Button493onClick={() => {494actions.clearAllFilters();495}}496>497<Icon name="close-circle-filled" style={{ color: "#888" }} /> Clear498</Button>499</div>500}501/>502);503}504505export function MessageList({506messages,507account_id,508virtuosoRef,509sortedDates,510user_map,511project_id,512path,513fontSize,514selectedHashtags,515actions,516costEstimate,517manualScrollRef,518mode,519selectedDate,520numChildren,521singleThreadView,522}: {523messages: ChatMessages;524account_id: string;525user_map;526mode;527sortedDates;528virtuosoRef?;529project_id?: string;530path?: string;531fontSize?: number;532selectedHashtags?;533actions?;534costEstimate?: CostEstimate;535manualScrollRef?;536selectedDate?: string;537numChildren?: NumChildren;538singleThreadView?: boolean;539}) {540const virtuosoHeightsRef = useRef<{ [index: number]: number }>({});541const virtuosoScroll = useVirtuosoScrollHook({542cacheId: `${project_id}${path}`,543initialState: { index: Math.max(sortedDates.length - 1, 0), offset: 0 }, // starts scrolled to the newest message.544});545546return (547<Virtuoso548ref={virtuosoRef}549totalCount={sortedDates.length + 1}550itemSize={(el) => {551// see comment in jupyter/cell-list.tsx552const h = el.getBoundingClientRect().height;553const data = el.getAttribute("data-item-index");554if (data != null) {555const index = parseInt(data);556virtuosoHeightsRef.current[index] = h;557}558return h;559}}560itemContent={(index) => {561if (sortedDates.length == index) {562return <div style={{ height: "25vh" }} />;563}564const date = sortedDates[index];565const message: ChatMessageTyped | undefined = messages.get(date);566if (message == null) {567// shouldn't happen, but make code robust to such a possibility.568// if it happens, fix it.569console.warn("empty message", { date, index, sortedDates });570return <div style={{ height: "30px" }} />;571}572573// only do threading if numChildren is defined. It's not defined,574// e.g., when viewing past versions via TimeTravel.575const is_thread = numChildren != null && isThread(message, numChildren);576// optimization: only threads can be folded, so don't waste time577// checking on folding state if it isn't a thread.578const is_folded =579!singleThreadView &&580is_thread &&581isFolded(messages, message, account_id);582const is_thread_body = is_thread && message.get("reply_to") != null;583const h = virtuosoHeightsRef.current?.[index];584585return (586<div587style={{588overflow: "hidden",589paddingTop: index == 0 ? "20px" : undefined,590}}591>592<DivTempHeight height={h ? `${h}px` : undefined}>593<Message594messages={messages}595numChildren={numChildren?.[message.get("date").valueOf()]}596key={date}597index={index}598account_id={account_id}599user_map={user_map}600message={message}601selected={date == selectedDate}602project_id={project_id}603path={path}604font_size={fontSize}605selectedHashtags={selectedHashtags}606actions={actions}607is_thread={is_thread}608is_folded={is_folded}609is_thread_body={is_thread_body}610is_prev_sender={isPrevMessageSender(611index,612sortedDates,613messages,614)}615show_avatar={!isNextMessageSender(index, sortedDates, messages)}616mode={mode}617get_user_name={(account_id: string | undefined) =>618// ATTN: this also works for LLM chat bot IDs, not just account UUIDs619typeof account_id === "string"620? getUserName(user_map, account_id)621: "Unknown name"622}623scroll_into_view={624virtuosoRef625? () => virtuosoRef.current?.scrollIntoView({ index })626: undefined627}628allowReply={629!singleThreadView &&630messages.getIn([sortedDates[index + 1], "reply_to"]) == null631}632costEstimate={costEstimate}633threadViewMode={singleThreadView}634/>635</DivTempHeight>636</div>637);638}}639rangeChanged={640manualScrollRef641? ({ endIndex }) => {642// manually scrolling if NOT at the bottom.643manualScrollRef.current = endIndex < sortedDates.length - 1;644}645: undefined646}647{...virtuosoScroll}648/>649);650}651652653