Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/frontend/chat/chat-log.tsx
Views: 687
/*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*/89import { Alert, Button } from "antd";10import { Set as immutableSet } from "immutable";11import { MutableRefObject, useEffect, useMemo, useRef } from "react";12import { Virtuoso, VirtuosoHandle } from "react-virtuoso";13import { chatBotName, isChatBot } from "@cocalc/frontend/account/chatbot";14import { useRedux, useTypedRedux } from "@cocalc/frontend/app-framework";15import { Icon } from "@cocalc/frontend/components";16import useVirtuosoScrollHook from "@cocalc/frontend/components/virtuoso-scroll-hook";17import { HashtagBar } from "@cocalc/frontend/editors/task-editor/hashtag-bar";18import {19cmp,20hoursToTimeIntervalHuman,21parse_hashtags,22plural,23} from "@cocalc/util/misc";24import type { ChatActions } from "./actions";25import Composing from "./composing";26import Message from "./message";27import type { ChatMessageTyped, ChatMessages, Mode } from "./types";28import { getSelectedHashtagsSearch, newest_content } from "./utils";29import { getRootMessage, getThreadRootDate } from "./utils";30import { DivTempHeight } from "@cocalc/frontend/jupyter/cell-list";31import { filterMessages } from "./filter-messages";3233interface Props {34project_id: string; // used to render links more effectively35path: string;36mode: Mode;37scrollToBottomRef?: MutableRefObject<(force?: boolean) => void>;38setLastVisible?: (x: Date | null) => void;39fontSize?: number;40actions: ChatActions;41search;42filterRecentH?;43selectedHashtags;44disableFilters?: boolean;45scrollToIndex?: null | number | undefined;46// scrollToDate = string ms from epoch47scrollToDate?: null | undefined | string;48selectedDate?: string;49costEstimate?;50}5152export function ChatLog({53project_id,54path,55scrollToBottomRef,56mode,57setLastVisible,58fontSize,59actions,60search: search0,61filterRecentH,62selectedHashtags: selectedHashtags0,63disableFilters,64scrollToIndex,65scrollToDate,66selectedDate,67costEstimate,68}: Props) {69const messages = useRedux(["messages"], project_id, path) as ChatMessages;70// see similar code in task list:71const { selectedHashtags, selectedHashtagsSearch } = useMemo(() => {72return getSelectedHashtagsSearch(selectedHashtags0);73}, [selectedHashtags0]);74const search = (search0 + " " + selectedHashtagsSearch).trim();7576const user_map = useTypedRedux("users", "user_map");77const account_id = useTypedRedux("account", "account_id");78const {79dates: sortedDates,80numFolded,81numChildren,82} = useMemo<{83dates: string[];84numFolded: number;85numChildren;86}>(() => {87const { dates, numFolded, numChildren } = getSortedDates(88messages,89search,90account_id!,91filterRecentH,92);93// TODO: This is an ugly hack because I'm tired and need to finish this.94// The right solution would be to move this filtering to the store.95// The timeout is because you can't update a component while rendering another one.96setTimeout(() => {97setLastVisible?.(98dates.length == 099? null100: new Date(parseFloat(dates[dates.length - 1])),101);102}, 1);103return { dates, numFolded, numChildren };104}, [messages, search, project_id, path, filterRecentH]);105106useEffect(() => {107scrollToBottomRef?.current?.(true);108}, [search]);109110useEffect(() => {111if (scrollToIndex == null) {112return;113}114if (scrollToIndex == -1) {115scrollToBottomRef?.current?.(true);116} else {117virtuosoRef.current?.scrollToIndex({ index: scrollToIndex });118}119actions.clearScrollRequest();120}, [scrollToIndex]);121122useEffect(() => {123if (scrollToDate == null) {124return;125}126// linear search, which should be fine given that this is not a tight inner loop127const index = sortedDates.indexOf(scrollToDate);128if (index == -1) {129// didn't find it?130const message = messages.get(scrollToDate);131if (message == null) {132// the message really doesn't exist. Weird. Give up.133actions.clearScrollRequest();134return;135}136let tryAgain = false;137// we clear all filters and ALSO make sure138// if message is in a folded thread, then that thread is not folded.139if (account_id && isFolded(messages, message, account_id)) {140// this actually unfolds it, since it was folded.141const date = new Date(142getThreadRootDate({ date: parseFloat(scrollToDate), messages }),143);144actions.toggleFoldThread(date);145tryAgain = true;146}147if (messages.size > sortedDates.length && (search || filterRecentH)) {148// there was a search, so clear it just to be sure -- it could still hide149// the folded threaded150actions.clearAllFilters();151tryAgain = true;152}153if (tryAgain) {154// we have to wait a while for full re-render to happen155setTimeout(() => {156actions.scrollToDate(parseFloat(scrollToDate));157}, 10);158} else {159// totally give up160actions.clearScrollRequest();161}162return;163}164virtuosoRef.current?.scrollToIndex({ index });165actions.clearScrollRequest();166}, [scrollToDate]);167168const visibleHashtags = useMemo(() => {169let X = immutableSet<string>([]);170if (disableFilters) {171return X;172}173for (const date of sortedDates) {174const message = messages.get(date);175const value = newest_content(message);176for (const x of parse_hashtags(value)) {177const tag = value.slice(x[0] + 1, x[1]).toLowerCase();178X = X.add(tag);179}180}181return X;182}, [messages, sortedDates]);183184const virtuosoRef = useRef<VirtuosoHandle>(null);185const manualScrollRef = useRef<boolean>(false);186187useEffect(() => {188if (scrollToBottomRef == null) return;189scrollToBottomRef.current = (force?: boolean) => {190if (manualScrollRef.current && !force) return;191manualScrollRef.current = false;192const doScroll = () =>193virtuosoRef.current?.scrollToIndex({ index: Number.MAX_SAFE_INTEGER });194195doScroll();196// sometimes scrolling to bottom is requested before last entry added,197// so we do it again in the next render loop. This seems needed mainly198// for side chat when there is little vertical space.199setTimeout(doScroll, 1);200};201}, [scrollToBottomRef != null]);202203return (204<>205{visibleHashtags.size > 0 && (206<HashtagBar207style={{ margin: "3px 0" }}208actions={{209set_hashtag_state: (tag, state) => {210actions.setHashtagState(tag, state);211},212}}213selected_hashtags={selectedHashtags0}214hashtags={visibleHashtags}215/>216)}217{messages != null && (218<NotShowing219num={messages.size - numFolded - sortedDates.length}220showing={sortedDates.length}221search={search}222filterRecentH={filterRecentH}223actions={actions}224/>225)}226<MessageList227{...{228virtuosoRef,229sortedDates,230messages,231search,232account_id,233user_map,234project_id,235path,236fontSize,237selectedHashtags,238actions,239costEstimate,240manualScrollRef,241mode,242selectedDate,243numChildren,244}}245/>246<Composing247projectId={project_id}248path={path}249accountId={account_id}250userMap={user_map}251/>252</>253);254}255256function isNextMessageSender(257index: number,258dates: string[],259messages: ChatMessages,260): boolean {261if (index + 1 === dates.length) {262return false;263}264const currentMessage = messages.get(dates[index]);265const nextMessage = messages.get(dates[index + 1]);266return (267currentMessage != null &&268nextMessage != null &&269currentMessage.get("sender_id") === nextMessage.get("sender_id")270);271}272273function isPrevMessageSender(274index: number,275dates: string[],276messages: ChatMessages,277): boolean {278if (index === 0) {279return false;280}281const currentMessage = messages.get(dates[index]);282const prevMessage = messages.get(dates[index - 1]);283return (284currentMessage != null &&285prevMessage != null &&286currentMessage.get("sender_id") === prevMessage.get("sender_id")287);288}289290function isThread(291message: ChatMessageTyped,292numChildren: { [date: number]: number },293) {294if (message.get("reply_to") != null) {295return true;296}297return (numChildren[message.get("date").valueOf()] ?? 0) > 0;298}299300function isFolded(301messages: ChatMessages,302message: ChatMessageTyped,303account_id: string,304) {305if (account_id == null) {306return false;307}308const rootMsg = getRootMessage({ message: message.toJS(), messages });309return rootMsg?.get("folding")?.includes(account_id) ?? false;310}311312// messages is an immutablejs map from313// - timestamps (ms since epoch as string)314// to315// - message objects {date: , event:, history, sender_id, reply_to}316//317// It was very easy to sort these before reply_to, which complicates things.318export function getSortedDates(319messages: ChatMessages,320search: string | undefined,321account_id: string,322filterRecentH?: number,323): {324dates: string[];325numFolded: number;326numChildren: { [date: number]: number };327} {328let numFolded = 0;329let m = messages;330if (m == null) {331return {332dates: [],333numFolded: 0,334numChildren: {},335};336}337338// we assume filterMessages contains complete threads. It does339// right now, but that's an assumption in this function.340m = filterMessages({ messages: m, filter: search, filterRecentH });341342// Do a linear pass through all messages to divide into threads, so that343// getSortedDates is O(n) instead of O(n^2) !344const numChildren: { [date: number]: number } = {};345for (const [_, message] of m) {346const parent = message.get("reply_to");347if (parent != null) {348const d = new Date(parent).valueOf();349numChildren[d] = (numChildren[d] ?? 0) + 1;350}351}352353const v: [date: number, reply_to: number | undefined][] = [];354for (const [date, message] of m) {355if (message == null) continue;356357// If we search for a message, we treat all threads as unfolded358if (!search) {359const is_thread = isThread(message, numChildren);360const is_folded = is_thread && isFolded(messages, message, account_id);361const is_thread_body = is_thread && message.get("reply_to") != null;362const folded = is_thread && is_folded && is_thread_body;363if (folded) {364numFolded++;365continue;366}367}368369const reply_to = message.get("reply_to");370v.push([371typeof date === "string" ? parseInt(date) : date,372reply_to != null ? new Date(reply_to).valueOf() : undefined,373]);374}375v.sort(cmpMessages);376const dates = v.map((z) => `${z[0]}`);377return { dates, numFolded, numChildren };378}379380/*381Compare messages as follows:382- if message has a parent it is a reply, so we use the parent instead for the383compare384- except in special cases:385- one of them is the parent and other is a child of that parent386- both have same parent387*/388function cmpMessages([a_time, a_parent], [b_time, b_parent]): number {389// special case:390// same parent:391if (a_parent !== undefined && a_parent == b_parent) {392return cmp(a_time, b_time);393}394// one of them is the parent and other is a child of that parent395if (a_parent == b_time) {396// b is the parent of a, so b is first.397return 1;398}399if (b_parent == a_time) {400// a is the parent of b, so a is first.401return -1;402}403// general case.404return cmp(a_parent ?? a_time, b_parent ?? b_time);405}406407export function getUserName(userMap, accountId: string): string {408if (isChatBot(accountId)) {409return chatBotName(accountId);410}411if (userMap == null) return "Unknown";412const account = userMap.get(accountId);413if (account == null) return "Unknown";414return account.get("first_name", "") + " " + account.get("last_name", "");415}416417interface NotShowingProps {418num: number;419search: string;420filterRecentH: number;421actions;422showing;423}424425function NotShowing({426num,427search,428filterRecentH,429actions,430showing,431}: NotShowingProps) {432if (num <= 0) return null;433434const timespan =435filterRecentH > 0 ? hoursToTimeIntervalHuman(filterRecentH) : null;436437return (438<Alert439style={{ margin: "5px" }}440showIcon441type="warning"442message={443<div style={{ display: "flex", alignItems: "center" }}>444<b style={{ flex: 1 }}>445WARNING: Hiding {num} {plural(num, "message")} in threads446{search.trim()447? ` that ${448num != 1 ? "do" : "does"449} not match search for '${search.trim()}'`450: ""}451{timespan452? ` ${453search.trim() ? "and" : "that"454} were not sent in the past ${timespan}`455: ""}456. Showing {showing} {plural(showing, "message")}.457</b>458<Button459onClick={() => {460actions.clearAllFilters();461}}462>463<Icon name="close-circle-filled" style={{ color: "#888" }} /> Clear464</Button>465</div>466}467/>468);469}470471export function MessageList({472messages,473account_id,474virtuosoRef,475sortedDates,476user_map,477project_id,478path,479fontSize,480selectedHashtags,481actions,482costEstimate,483manualScrollRef,484mode,485selectedDate,486numChildren,487}: {488messages;489account_id;490user_map;491mode;492sortedDates;493virtuosoRef?;494search?;495project_id?;496path?;497fontSize?;498selectedHashtags?;499actions?;500costEstimate?;501manualScrollRef?;502selectedDate?: string;503numChildren?;504}) {505const virtuosoHeightsRef = useRef<{ [index: number]: number }>({});506const virtuosoScroll = useVirtuosoScrollHook({507cacheId: `${project_id}${path}`,508initialState: { index: Math.max(sortedDates.length - 1, 0), offset: 0 }, // starts scrolled to the newest message.509});510511return (512<Virtuoso513ref={virtuosoRef}514totalCount={sortedDates.length}515itemSize={(el) => {516// see comment in jupyter/cell-list.tsx517const h = el.getBoundingClientRect().height;518const data = el.getAttribute("data-item-index");519if (data != null) {520const index = parseInt(data);521virtuosoHeightsRef.current[index] = h;522}523return h;524}}525itemContent={(index) => {526const date = sortedDates[index];527const message: ChatMessageTyped | undefined = messages.get(date);528if (message == null) {529// shouldn't happen, but make code robust to such a possibility.530// if it happens, fix it.531console.warn("empty message", { date, index, sortedDates });532return <div style={{ height: "30px" }} />;533}534535// only do threading if numChildren is defined. It's not defined,536// e.g., when viewing past versions via TimeTravel.537const is_thread = numChildren != null && isThread(message, numChildren);538// optimization: only threads can be folded, so don't waste time539// checking on folding state if it isn't a thread.540const is_folded = is_thread && isFolded(messages, message, account_id);541const is_thread_body = is_thread && message.get("reply_to") != null;542const h = virtuosoHeightsRef.current?.[index];543544return (545<div546style={{547overflow: "hidden",548paddingTop: index == 0 ? "20px" : undefined,549}}550>551<DivTempHeight height={h ? `${h}px` : undefined}>552<Message553messages={messages}554numChildren={numChildren?.[message.get("date").valueOf()]}555key={date}556index={index}557account_id={account_id}558user_map={user_map}559message={message}560selected={date == selectedDate}561project_id={project_id}562path={path}563font_size={fontSize}564selectedHashtags={selectedHashtags}565actions={actions}566is_thread={is_thread}567is_folded={is_folded}568is_thread_body={is_thread_body}569is_prev_sender={isPrevMessageSender(570index,571sortedDates,572messages,573)}574show_avatar={!isNextMessageSender(index, sortedDates, messages)}575mode={mode}576get_user_name={(account_id: string | undefined) =>577// ATTN: this also works for LLM chat bot IDs, not just account UUIDs578typeof account_id === "string"579? getUserName(user_map, account_id)580: "Unknown name"581}582scroll_into_view={583virtuosoRef584? () => virtuosoRef.current?.scrollIntoView({ index })585: undefined586}587allowReply={588messages.getIn([sortedDates[index + 1], "reply_to"]) == null589}590costEstimate={costEstimate}591/>592</DivTempHeight>593</div>594);595}}596rangeChanged={597manualScrollRef598? ({ endIndex }) => {599// manually scrolling if NOT at the bottom.600manualScrollRef.current = endIndex < sortedDates.length - 1;601}602: undefined603}604{...virtuosoScroll}605/>606);607}608609610