Path: blob/master/src/packages/frontend/chat/chat-log.tsx
1496 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;58scrollToIndex?: null | number | undefined;59// scrollToDate = string ms from epoch60scrollToDate?: null | undefined | string;61selectedDate?: string;62costEstimate?;63}6465export function ChatLog({66project_id,67path,68scrollToBottomRef,69mode,70setLastVisible,71fontSize,72actions,73search: search0,74filterRecentH,75selectedHashtags: selectedHashtags0,76disableFilters,77scrollToIndex,78scrollToDate,79selectedDate,80costEstimate,81}: Props) {82const messages = useRedux(["messages"], project_id, path) as ChatMessages;83// see similar code in task list:84const { selectedHashtags, selectedHashtagsSearch } = useMemo(() => {85return getSelectedHashtagsSearch(selectedHashtags0);86}, [selectedHashtags0]);87const search = (search0 + " " + selectedHashtagsSearch).trim();8889const user_map = useTypedRedux("users", "user_map");90const account_id = useTypedRedux("account", "account_id");91const {92dates: sortedDates,93numFolded,94numChildren,95} = useMemo<{96dates: string[];97numFolded: number;98numChildren: NumChildren;99}>(() => {100const { dates, numFolded, numChildren } = getSortedDates(101messages,102search,103account_id!,104filterRecentH,105);106// TODO: This is an ugly hack because I'm tired and need to finish this.107// The right solution would be to move this filtering to the store.108// The timeout is because you can't update a component while rendering another one.109setTimeout(() => {110setLastVisible?.(111dates.length == 0112? null113: new Date(parseFloat(dates[dates.length - 1])),114);115}, 1);116return { dates, numFolded, numChildren };117}, [messages, search, project_id, path, filterRecentH]);118119useEffect(() => {120scrollToBottomRef?.current?.(true);121}, [search]);122123useEffect(() => {124if (scrollToIndex == null) {125return;126}127if (scrollToIndex == -1) {128scrollToBottomRef?.current?.(true);129} else {130virtuosoRef.current?.scrollToIndex({ index: scrollToIndex });131}132actions.clearScrollRequest();133}, [scrollToIndex]);134135useEffect(() => {136if (scrollToDate == null) {137return;138}139// linear search, which should be fine given that this is not a tight inner loop140const index = sortedDates.indexOf(scrollToDate);141if (index == -1) {142// didn't find it?143const message = messages.get(scrollToDate);144if (message == null) {145// the message really doesn't exist. Weird. Give up.146actions.clearScrollRequest();147return;148}149let tryAgain = false;150// we clear all filters and ALSO make sure151// if message is in a folded thread, then that thread is not folded.152if (account_id && isFolded(messages, message, account_id)) {153// this actually unfolds it, since it was folded.154const date = new Date(155getThreadRootDate({ date: parseFloat(scrollToDate), messages }),156);157actions.toggleFoldThread(date);158tryAgain = true;159}160if (messages.size > sortedDates.length && (search || filterRecentH)) {161// there was a search, so clear it just to be sure -- it could still hide162// the folded threaded163actions.clearAllFilters();164tryAgain = true;165}166if (tryAgain) {167// we have to wait a while for full re-render to happen168setTimeout(() => {169actions.scrollToDate(parseFloat(scrollToDate));170}, 10);171} else {172// totally give up173actions.clearScrollRequest();174}175return;176}177virtuosoRef.current?.scrollToIndex({ index });178actions.clearScrollRequest();179}, [scrollToDate]);180181const visibleHashtags = useMemo(() => {182let X = immutableSet<string>([]);183if (disableFilters) {184return X;185}186for (const date of sortedDates) {187const message = messages.get(date);188const value = newest_content(message);189for (const x of parse_hashtags(value)) {190const tag = value.slice(x[0] + 1, x[1]).toLowerCase();191X = X.add(tag);192}193}194return X;195}, [messages, sortedDates]);196197const virtuosoRef = useRef<VirtuosoHandle>(null);198const manualScrollRef = useRef<boolean>(false);199200useEffect(() => {201if (scrollToBottomRef == null) return;202scrollToBottomRef.current = (force?: boolean) => {203if (manualScrollRef.current && !force) return;204manualScrollRef.current = false;205const doScroll = () =>206virtuosoRef.current?.scrollToIndex({ index: Number.MAX_SAFE_INTEGER });207208doScroll();209// sometimes scrolling to bottom is requested before last entry added,210// so we do it again in the next render loop. This seems needed mainly211// for side chat when there is little vertical space.212setTimeout(doScroll, 1);213};214}, [scrollToBottomRef != null]);215216return (217<>218{visibleHashtags.size > 0 && (219<HashtagBar220style={{ margin: "5px 15px 15px 15px" }}221actions={{222set_hashtag_state: (tag, state) => {223actions.setHashtagState(tag, state);224},225}}226selected_hashtags={selectedHashtags0}227hashtags={visibleHashtags}228/>229)}230{messages != null && (231<NotShowing232num={messages.size - numFolded - sortedDates.length}233showing={sortedDates.length}234search={search}235filterRecentH={filterRecentH}236actions={actions}237/>238)}239<MessageList240{...{241virtuosoRef,242sortedDates,243messages,244search,245account_id,246user_map,247project_id,248path,249fontSize,250selectedHashtags,251actions,252costEstimate,253manualScrollRef,254mode,255selectedDate,256numChildren,257}}258/>259<Composing260projectId={project_id}261path={path}262accountId={account_id}263userMap={user_map}264/>265</>266);267}268269function isNextMessageSender(270index: number,271dates: string[],272messages: ChatMessages,273): boolean {274if (index + 1 === dates.length) {275return false;276}277const currentMessage = messages.get(dates[index]);278const nextMessage = messages.get(dates[index + 1]);279return (280currentMessage != null &&281nextMessage != null &&282currentMessage.get("sender_id") === nextMessage.get("sender_id")283);284}285286function isPrevMessageSender(287index: number,288dates: string[],289messages: ChatMessages,290): boolean {291if (index === 0) {292return false;293}294const currentMessage = messages.get(dates[index]);295const prevMessage = messages.get(dates[index - 1]);296return (297currentMessage != null &&298prevMessage != null &&299currentMessage.get("sender_id") === prevMessage.get("sender_id")300);301}302303function isThread(message: ChatMessageTyped, numChildren: NumChildren) {304if (message.get("reply_to") != null) {305return true;306}307return (numChildren[message.get("date").valueOf()] ?? 0) > 0;308}309310function isFolded(311messages: ChatMessages,312message: ChatMessageTyped,313account_id: string,314) {315if (account_id == null) {316return false;317}318const rootMsg = getRootMessage({ message: message.toJS(), messages });319return rootMsg?.get("folding")?.includes(account_id) ?? false;320}321322// messages is an immutablejs map from323// - timestamps (ms since epoch as string)324// to325// - message objects {date: , event:, history, sender_id, reply_to}326//327// It was very easy to sort these before reply_to, which complicates things.328export function getSortedDates(329messages: ChatMessages,330search: string | undefined,331account_id: string,332filterRecentH?: number,333): {334dates: string[];335numFolded: number;336numChildren: NumChildren;337} {338let numFolded = 0;339let m = messages;340if (m == null) {341return {342dates: [],343numFolded: 0,344numChildren: {},345};346}347348// we assume filterMessages contains complete threads. It does349// right now, but that's an assumption in this function.350m = filterMessages({ messages: m, filter: search, filterRecentH });351352// Do a linear pass through all messages to divide into threads, so that353// getSortedDates is O(n) instead of O(n^2) !354const numChildren: NumChildren = {};355for (const [_, message] of m) {356const parent = message.get("reply_to");357if (parent != null) {358const d = new Date(parent).valueOf();359numChildren[d] = (numChildren[d] ?? 0) + 1;360}361}362363const v: [date: number, reply_to: number | undefined][] = [];364for (const [date, message] of m) {365if (message == null) continue;366367// If we search for a message, we treat all threads as unfolded368if (!search) {369const is_thread = isThread(message, numChildren);370const is_folded = is_thread && isFolded(messages, message, account_id);371const is_thread_body = is_thread && message.get("reply_to") != null;372const folded = is_thread && is_folded && is_thread_body;373if (folded) {374numFolded++;375continue;376}377}378379const reply_to = message.get("reply_to");380v.push([381typeof date === "string" ? parseInt(date) : date,382reply_to != null ? new Date(reply_to).valueOf() : undefined,383]);384}385v.sort(cmpMessages);386const dates = v.map((z) => `${z[0]}`);387return { dates, numFolded, numChildren };388}389390/*391Compare messages as follows:392- if message has a parent it is a reply, so we use the parent instead for the393compare394- except in special cases:395- one of them is the parent and other is a child of that parent396- both have same parent397*/398function cmpMessages([a_time, a_parent], [b_time, b_parent]): number {399// special case:400// same parent:401if (a_parent !== undefined && a_parent == b_parent) {402return cmp(a_time, b_time);403}404// one of them is the parent and other is a child of that parent405if (a_parent == b_time) {406// b is the parent of a, so b is first.407return 1;408}409if (b_parent == a_time) {410// a is the parent of b, so a is first.411return -1;412}413// general case.414return cmp(a_parent ?? a_time, b_parent ?? b_time);415}416417export function getUserName(userMap, accountId: string): string {418if (isChatBot(accountId)) {419return chatBotName(accountId);420}421if (userMap == null) return "Unknown";422const account = userMap.get(accountId);423if (account == null) return "Unknown";424return account.get("first_name", "") + " " + account.get("last_name", "");425}426427interface NotShowingProps {428num: number;429search: string;430filterRecentH: number;431actions;432showing;433}434435function NotShowing({436num,437search,438filterRecentH,439actions,440showing,441}: NotShowingProps) {442if (num <= 0) return null;443444const timespan =445filterRecentH > 0 ? hoursToTimeIntervalHuman(filterRecentH) : null;446447return (448<Alert449style={{ margin: "5px" }}450showIcon451type="warning"452message={453<div style={{ display: "flex", alignItems: "center" }}>454<b style={{ flex: 1 }}>455WARNING: Hiding {num} {plural(num, "message")} in threads456{search.trim()457? ` that ${458num != 1 ? "do" : "does"459} not match search for '${search.trim()}'`460: ""}461{timespan462? ` ${463search.trim() ? "and" : "that"464} were not sent in the past ${timespan}`465: ""}466. Showing {showing} {plural(showing, "message")}.467</b>468<Button469onClick={() => {470actions.clearAllFilters();471}}472>473<Icon name="close-circle-filled" style={{ color: "#888" }} /> Clear474</Button>475</div>476}477/>478);479}480481export function MessageList({482messages,483account_id,484virtuosoRef,485sortedDates,486user_map,487project_id,488path,489fontSize,490selectedHashtags,491actions,492costEstimate,493manualScrollRef,494mode,495selectedDate,496numChildren,497}: {498messages: ChatMessages;499account_id: string;500user_map;501mode;502sortedDates;503virtuosoRef?;504project_id?: string;505path?: string;506fontSize?: number;507selectedHashtags?;508actions?;509costEstimate?: CostEstimate;510manualScrollRef?;511selectedDate?: string;512numChildren?: NumChildren;513}) {514const virtuosoHeightsRef = useRef<{ [index: number]: number }>({});515const virtuosoScroll = useVirtuosoScrollHook({516cacheId: `${project_id}${path}`,517initialState: { index: Math.max(sortedDates.length - 1, 0), offset: 0 }, // starts scrolled to the newest message.518});519520return (521<Virtuoso522ref={virtuosoRef}523totalCount={sortedDates.length}524itemSize={(el) => {525// see comment in jupyter/cell-list.tsx526const h = el.getBoundingClientRect().height;527const data = el.getAttribute("data-item-index");528if (data != null) {529const index = parseInt(data);530virtuosoHeightsRef.current[index] = h;531}532return h;533}}534itemContent={(index) => {535const date = sortedDates[index];536const message: ChatMessageTyped | undefined = messages.get(date);537if (message == null) {538// shouldn't happen, but make code robust to such a possibility.539// if it happens, fix it.540console.warn("empty message", { date, index, sortedDates });541return <div style={{ height: "30px" }} />;542}543544// only do threading if numChildren is defined. It's not defined,545// e.g., when viewing past versions via TimeTravel.546const is_thread = numChildren != null && isThread(message, numChildren);547// optimization: only threads can be folded, so don't waste time548// checking on folding state if it isn't a thread.549const is_folded = is_thread && isFolded(messages, message, account_id);550const is_thread_body = is_thread && message.get("reply_to") != null;551const h = virtuosoHeightsRef.current?.[index];552553return (554<div555style={{556overflow: "hidden",557paddingTop: index == 0 ? "20px" : undefined,558}}559>560<DivTempHeight height={h ? `${h}px` : undefined}>561<Message562messages={messages}563numChildren={numChildren?.[message.get("date").valueOf()]}564key={date}565index={index}566account_id={account_id}567user_map={user_map}568message={message}569selected={date == selectedDate}570project_id={project_id}571path={path}572font_size={fontSize}573selectedHashtags={selectedHashtags}574actions={actions}575is_thread={is_thread}576is_folded={is_folded}577is_thread_body={is_thread_body}578is_prev_sender={isPrevMessageSender(579index,580sortedDates,581messages,582)}583show_avatar={!isNextMessageSender(index, sortedDates, messages)}584mode={mode}585get_user_name={(account_id: string | undefined) =>586// ATTN: this also works for LLM chat bot IDs, not just account UUIDs587typeof account_id === "string"588? getUserName(user_map, account_id)589: "Unknown name"590}591scroll_into_view={592virtuosoRef593? () => virtuosoRef.current?.scrollIntoView({ index })594: undefined595}596allowReply={597messages.getIn([sortedDates[index + 1], "reply_to"]) == null598}599costEstimate={costEstimate}600/>601</DivTempHeight>602</div>603);604}}605rangeChanged={606manualScrollRef607? ({ endIndex }) => {608// manually scrolling if NOT at the bottom.609manualScrollRef.current = endIndex < sortedDates.length - 1;610}611: undefined612}613{...virtuosoScroll}614/>615);616}617618619