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/message.tsx
Views: 687
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import { Badge, Button, Col, Popconfirm, Row, Space, Tooltip } from "antd";6import { List, Map } from "immutable";7import { CSSProperties, useEffect, useLayoutEffect } from "react";8import { useIntl } from "react-intl";910import { Avatar } from "@cocalc/frontend/account/avatar/avatar";11import {12CSS,13redux,14useMemo,15useRef,16useState,17useTypedRedux,18} from "@cocalc/frontend/app-framework";19import { Gap, Icon, TimeAgo, Tip } from "@cocalc/frontend/components";20import MostlyStaticMarkdown from "@cocalc/frontend/editors/slate/mostly-static-markdown";21import { IS_TOUCH } from "@cocalc/frontend/feature";22import { modelToName } from "@cocalc/frontend/frame-editors/llm/llm-selector";23import { labels } from "@cocalc/frontend/i18n";24import { CancelText } from "@cocalc/frontend/i18n/components";25import { User } from "@cocalc/frontend/users";26import { isLanguageModelService } from "@cocalc/util/db-schema/llm-utils";27import { plural, unreachable } from "@cocalc/util/misc";28import { COLORS } from "@cocalc/util/theme";29import { ChatActions } from "./actions";30import { getUserName } from "./chat-log";31import { History, HistoryFooter, HistoryTitle } from "./history";32import ChatInput from "./input";33import { LLMCostEstimationChat } from "./llm-cost-estimation";34import { FeedbackLLM } from "./llm-msg-feedback";35import { RegenerateLLM } from "./llm-msg-regenerate";36import { SummarizeThread } from "./llm-msg-summarize";37import { Name } from "./name";38import { Time } from "./time";39import { ChatMessageTyped, Mode, SubmitMentionsFn } from "./types";40import {41getThreadRootDate,42is_editing,43message_colors,44newest_content,45sender_is_viewer,46} from "./utils";4748const DELETE_BUTTON = false;4950const BLANK_COLUMN = (xs) => <Col key={"blankcolumn"} xs={xs}></Col>;5152const MARKDOWN_STYLE = undefined;5354const BORDER = "2px solid #ccc";5556const SHOW_EDIT_BUTTON_MS = 15000;5758const TRHEAD_STYLE_SINGLE: CSS = {59marginLeft: "15px",60marginRight: "15px",61paddingLeft: "15px",62} as const;6364const THREAD_STYLE: CSS = {65...TRHEAD_STYLE_SINGLE,66borderLeft: BORDER,67borderRight: BORDER,68} as const;6970const THREAD_STYLE_BOTTOM: CSS = {71...THREAD_STYLE,72borderBottomLeftRadius: "10px",73borderBottomRightRadius: "10px",74borderBottom: BORDER,75marginBottom: "10px",76} as const;7778const THREAD_STYLE_TOP: CSS = {79...THREAD_STYLE,80borderTop: BORDER,81borderTopLeftRadius: "10px",82borderTopRightRadius: "10px",83marginTop: "10px",84} as const;8586const THREAD_STYLE_FOLDED: CSS = {87...THREAD_STYLE_TOP,88...THREAD_STYLE_BOTTOM,89} as const;9091const MARGIN_TOP_VIEWER = "17px";9293const AVATAR_MARGIN_LEFTRIGHT = "15px";9495interface Props {96index: number;97actions?: ChatActions;98get_user_name: (account_id?: string) => string;99messages;100message: ChatMessageTyped;101account_id: string;102user_map?: Map<string, any>;103project_id?: string; // improves relative links if given104path?: string;105font_size: number;106is_prev_sender?: boolean;107show_avatar?: boolean;108mode: Mode;109selectedHashtags?: Set<string>;110111scroll_into_view?: () => void; // call to scroll this message into view112113// if true, include a reply button - this should only be for messages114// that don't have an existing reply to them already.115allowReply?: boolean;116117is_thread?: boolean; // if true, there is a thread starting in a reply_to message118is_folded?: boolean; // if true, only show the reply_to root message119is_thread_body: boolean;120121costEstimate;122123selected?: boolean;124125// for the root of a folded thread, optionally give this number of a126// more informative message to the user.127numChildren?: number;128}129130export default function Message({131index,132actions,133get_user_name,134messages,135message,136account_id,137user_map,138project_id,139path,140font_size,141is_prev_sender,142show_avatar,143mode,144selectedHashtags,145scroll_into_view,146allowReply,147is_thread,148is_folded,149is_thread_body,150costEstimate,151selected,152numChildren,153}: Props) {154const intl = useIntl();155156const showAISummarize = redux157.getStore("projects")158.hasLanguageModelEnabled(project_id, "chat-summarize");159160const hideTooltip =161useTypedRedux("account", "other_settings").get("hide_file_popovers") ??162false;163164const [edited_message, set_edited_message] = useState<string>(165newest_content(message),166);167// We have to use a ref because of trickiness involving168// stale closures when submitting the message.169const edited_message_ref = useRef(edited_message);170171const [show_history, set_show_history] = useState(false);172173const new_changes = useMemo(174() => edited_message !== newest_content(message),175[message] /* note -- edited_message is a function of message */,176);177178// date as ms since epoch or 0179const date: number = useMemo(() => {180return message?.get("date")?.valueOf() ?? 0;181}, [message.get("date")]);182183const generating = message.get("generating");184185const history_size = useMemo(() => message.get("history").size, [message]);186187const isEditing = useMemo(188() => is_editing(message, account_id),189[message, account_id],190);191192const editor_name = useMemo(() => {193return get_user_name(message.get("history")?.first()?.get("author_id"));194}, [message]);195196const reverseRowOrdering =197!is_thread_body && sender_is_viewer(account_id, message);198199const submitMentionsRef = useRef<SubmitMentionsFn>();200201const [replying, setReplying] = useState<boolean>(() => {202if (!allowReply) {203return false;204}205const replyDate = -getThreadRootDate({ date, messages });206const draft = actions?.syncdb?.get_one({207event: "draft",208sender_id: account_id,209date: replyDate,210});211if (draft == null) {212return false;213}214if (draft.get("active") <= 1720071100408) {215// before this point in time, drafts never ever got deleted when sending replies! So there's a massive216// clutter of reply drafts sitting in chats, and we don't want to resurrect them.217return false;218}219return true;220});221useEffect(() => {222if (!allowReply) {223setReplying(false);224}225}, [allowReply]);226227const [autoFocusReply, setAutoFocusReply] = useState<boolean>(false);228const [autoFocusEdit, setAutoFocusEdit] = useState<boolean>(false);229230const replyMessageRef = useRef<string>("");231const replyMentionsRef = useRef<SubmitMentionsFn>();232233const is_viewers_message = sender_is_viewer(account_id, message);234const verb = show_history ? "Hide" : "Show";235236const isLLMThread = useMemo(237() => actions?.isLanguageModelThread(message.get("date")),238[message, actions != null],239);240241const msgWrittenByLLM = useMemo(() => {242const author_id = message.get("history")?.first()?.get("author_id");243return typeof author_id === "string" && isLanguageModelService(author_id);244}, [message]);245246useLayoutEffect(() => {247if (replying) {248scroll_into_view?.();249}250}, [replying]);251252function editing_status(is_editing: boolean) {253let text;254255let other_editors = // @ts-ignore -- keySeq *is* a method of TypedMap256message.get("editing")?.remove(account_id).keySeq() ?? List();257if (is_editing) {258if (other_editors.size === 1) {259// This user and someone else is also editing260text = (261<>262{`WARNING: ${get_user_name(263other_editors.first(),264)} is also editing this! `}265<b>Simultaneous editing of messages is not supported.</b>266</>267);268} else if (other_editors.size > 1) {269// Multiple other editors270text = `${other_editors.size} other users are also editing this!`;271} else if (history_size !== message.get("history").size && new_changes) {272text = `${editor_name} has updated this message. Esc to discard your changes and see theirs`;273} else {274if (IS_TOUCH) {275text = "You are now editing ...";276} else {277text = "You are now editing ... Shift+Enter to submit changes.";278}279}280} else {281if (other_editors.size === 1) {282// One person is editing283text = `${get_user_name(284other_editors.first(),285)} is editing this message`;286} else if (other_editors.size > 1) {287// Multiple editors288text = `${other_editors.size} people are editing this message`;289} else if (newest_content(message).trim() === "") {290text = `Deleted by ${editor_name}`;291}292}293294if (text == null) {295text = `Last edit by ${editor_name}`;296}297298if (299!is_editing &&300other_editors.size === 0 &&301newest_content(message).trim() !== ""302) {303const edit = "Last edit ";304const name = ` by ${editor_name}`;305const msg_date = message.get("history").first()?.get("date");306return (307<div308style={{309color: COLORS.GRAY_M,310fontSize: "14px" /* matches Reply button */,311}}312>313{edit}{" "}314{msg_date != null ? (315<TimeAgo date={new Date(msg_date)} />316) : (317"unknown time"318)}{" "}319{name}320</div>321);322}323return (324<div style={{ color: COLORS.GRAY_M }}>325{text}326{is_editing ? (327<span style={{ margin: "10px 10px 0 10px", display: "inline-block" }}>328<Button onClick={on_cancel}>Cancel</Button>329<Gap />330<Button onClick={saveEditedMessage} type="primary">331Save (shift+enter)332</Button>333</span>334) : undefined}335</div>336);337}338339function edit_message() {340if (project_id == null || path == null || actions == null) {341// no editing functionality or not in a project with a path.342return;343}344actions.setEditing(message, true);345setAutoFocusEdit(true);346scroll_into_view?.();347}348349function avatar_column() {350const sender_id = message.get("sender_id");351let style: CSSProperties = {};352if (!is_prev_sender) {353style.marginTop = "22px";354} else {355style.marginTop = "5px";356}357358if (!is_thread_body) {359if (sender_is_viewer(account_id, message)) {360style.marginLeft = AVATAR_MARGIN_LEFTRIGHT;361} else {362style.marginRight = AVATAR_MARGIN_LEFTRIGHT;363}364}365366return (367<Col key={0} xs={2}>368<div style={style}>369{sender_id != null && show_avatar ? (370<Avatar size={40} account_id={sender_id} />371) : undefined}372</div>373</Col>374);375}376377function contentColumn() {378let marginTop;379let value = newest_content(message);380381const { background, color, lighten, message_class } = message_colors(382account_id,383message,384);385386if (!is_prev_sender && is_viewers_message) {387marginTop = MARGIN_TOP_VIEWER;388} else {389marginTop = "5px";390}391392const message_style: CSSProperties = {393color,394background,395wordWrap: "break-word",396borderRadius: "5px",397marginTop,398fontSize: `${font_size}px`,399// no padding on bottom, since message itself is markdown, hence400// wrapped in <p>'s, which have a big 10px margin on their bottoms401// already.402padding: selected ? "6px 6px 0 6px" : "9px 9px 0 9px",403...(mode === "sidechat"404? { marginLeft: "5px", marginRight: "5px" }405: undefined),406...(selected ? { border: "3px solid #66bb6a" } : undefined),407maxHeight: is_folded ? "100px" : undefined,408overflowY: is_folded ? "auto" : undefined,409} as const;410411const mainXS = mode === "standalone" ? 20 : 22;412const showEditButton = Date.now() - date < SHOW_EDIT_BUTTON_MS;413const feedback = message.getIn(["feedback", account_id]);414const otherFeedback =415isLLMThread && msgWrittenByLLM ? 0 : (message.get("feedback")?.size ?? 0);416const showOtherFeedback = otherFeedback > 0;417418const editControlRow = () => {419if (isEditing) {420return null;421}422const showDeleteButton =423DELETE_BUTTON && newest_content(message).trim().length > 0;424const showEditingStatus =425(message.get("history")?.size ?? 0) > 1 ||426(message.get("editing")?.size ?? 0) > 0;427const showHistory = (message.get("history")?.size ?? 0) > 1;428const showLLMFeedback = isLLMThread && msgWrittenByLLM;429430// Show the bottom line of the message -- this uses a LOT of extra431// vertical space, so only do it if there is a good reason to.432// Getting rid of this might be nice.433const show =434showEditButton ||435showDeleteButton ||436showEditingStatus ||437showHistory ||438showLLMFeedback;439if (!show) {440// important to explicitly check this before rendering below, since otherwise we get a big BLANK space.441return null;442}443444return (445<div style={{ width: "100%", textAlign: "center" }}>446<Space direction="horizontal" size="small" wrap>447{showEditButton ? (448<Tooltip449title={450<>451Edit this message. You can edit <b>any</b> past message at452any time by double clicking on it. Fix other people's typos.453All versions are stored.454</>455}456placement="left"457>458<Button459disabled={replying}460style={{461color: is_viewers_message ? "white" : "#555",462}}463type="text"464size="small"465onClick={() => actions?.setEditing(message, true)}466>467<Icon name="pencil" /> Edit468</Button>469</Tooltip>470) : undefined}471{showDeleteButton && (472<Tooltip473title="Delete this message. You can delete any past message by anybody. The deleted message can be view in history."474placement="left"475>476<Popconfirm477title="Delete this message"478description="Are you sure you want to delete this message?"479onConfirm={() => {480actions?.setEditing(message, true);481setTimeout(() => actions?.sendEdit(message, ""), 1);482}}483>484<Button485disabled={replying}486style={{487color: is_viewers_message ? "white" : "#555",488}}489type="text"490size="small"491>492<Icon name="trash" /> Delete493</Button>494</Popconfirm>495</Tooltip>496)}497{showEditingStatus && editing_status(isEditing)}498{showHistory && (499<Button500style={{501marginLeft: "5px",502color: is_viewers_message ? "white" : "#555",503}}504type="text"505size="small"506icon={<Icon name="history" />}507onClick={() => {508set_show_history(!show_history);509scroll_into_view?.();510}}511>512<Tip513title="Message History"514tip={`${verb} history of editing of this message. Any collaborator can edit any message by double clicking on it.`}515>516{verb} History517</Tip>518</Button>519)}520{showLLMFeedback && (521<>522<RegenerateLLM523actions={actions}524date={date}525model={isLLMThread}526/>527<FeedbackLLM actions={actions} message={message} />528</>529)}530</Space>531</div>532);533};534535return (536<Col key={1} xs={mainXS}>537<div538style={{ display: "flex" }}539onClick={() => {540actions?.setFragment(message.get("date"));541}}542>543{!is_prev_sender &&544!is_viewers_message &&545message.get("sender_id") ? (546<Name sender_name={get_user_name(message.get("sender_id"))} />547) : undefined}548{generating === true && actions ? (549<Button550style={{ color: COLORS.GRAY_M }}551onClick={() => {552actions?.languageModelStopGenerating(new Date(date));553}}554>555<Icon name="square" /> Stop Generating556</Button>557) : undefined}558</div>559<div560style={message_style}561className="smc-chat-message"562onDoubleClick={edit_message}563>564{!isEditing && (565<span style={lighten}>566<Time message={message} edit={edit_message} />567{!isLLMThread && (568<Tooltip569title={570!showOtherFeedback571? undefined572: () => {573return (574<div>575{Object.keys(576message.get("feedback")?.toJS() ?? {},577).map((account_id) => (578<div579key={account_id}580style={{ marginBottom: "2px" }}581>582<Avatar size={24} account_id={account_id} />{" "}583<User account_id={account_id} />584</div>585))}586</div>587);588}589}590>591<Button592style={{593marginRight: "5px",594float: "right",595marginTop: "-4px",596color: !feedback && is_viewers_message ? "white" : "#888",597fontSize: "12px",598}}599size="small"600type={feedback ? "dashed" : "text"}601onClick={() => {602actions?.feedback(message, feedback ? null : "positive");603}}604>605{showOtherFeedback ? (606<Badge607count={otherFeedback}608color="darkblue"609size="small"610/>611) : (612""613)}614<Tooltip615title={showOtherFeedback ? undefined : "Like this"}616>617<Icon618name="thumbs-up"619style={{620color: showOtherFeedback ? "darkblue" : undefined,621}}622/>623</Tooltip>624</Button>625</Tooltip>626)}{" "}627<Tooltip title="Select message. Copy URL to link to this message.">628<Button629onClick={() => {630actions?.setFragment(message.get("date"));631}}632size="small"633type={"text"}634style={{635float: "right",636marginTop: "-4px",637color: is_viewers_message ? "white" : "#888",638fontSize: "12px",639}}640>641<Icon name="link" />642</Button>643</Tooltip>644</span>645)}646{!isEditing && (647<MostlyStaticMarkdown648style={MARKDOWN_STYLE}649value={value}650className={message_class}651selectedHashtags={selectedHashtags}652toggleHashtag={653selectedHashtags != null && actions != null654? (tag) =>655actions?.setHashtagState(656tag,657selectedHashtags?.has(tag) ? undefined : 1,658)659: undefined660}661/>662)}663{isEditing && renderEditMessage()}664{editControlRow()}665</div>666{show_history && (667<div>668<HistoryTitle />669<History history={message.get("history")} user_map={user_map} />670<HistoryFooter />671</div>672)}673{replying ? renderComposeReply() : undefined}674</Col>675);676}677678function saveEditedMessage(): void {679if (actions == null) return;680const mesg =681submitMentionsRef.current?.({ chat: `${date}` }) ??682edited_message_ref.current;683const value = newest_content(message);684if (mesg !== value) {685set_edited_message(mesg);686actions.sendEdit(message, mesg);687} else {688actions.setEditing(message, false);689}690}691692function on_cancel(): void {693set_edited_message(newest_content(message));694if (actions == null) return;695actions.setEditing(message, false);696actions.deleteDraft(date);697}698699function renderEditMessage() {700if (project_id == null || path == null || actions?.syncdb == null) {701// should never get into this position702// when null.703return;704}705return (706<div>707<ChatInput708fontSize={font_size}709autoFocus={autoFocusEdit}710cacheId={`${path}${project_id}${date}`}711input={newest_content(message)}712submitMentionsRef={submitMentionsRef}713on_send={saveEditedMessage}714height={"auto"}715syncdb={actions.syncdb}716date={date}717onChange={(value) => {718edited_message_ref.current = value;719}}720/>721<div style={{ marginTop: "10px", display: "flex" }}>722<Button723style={{ marginRight: "5px" }}724onClick={() => {725actions?.setEditing(message, false);726actions?.deleteDraft(date);727}}728>729{intl.formatMessage(labels.cancel)}730</Button>731<Button type="primary" onClick={saveEditedMessage}>732<Icon name="save" /> Save Edited Message733</Button>734</div>735</div>736);737}738739function sendReply(reply?: string) {740if (actions == null) return;741setReplying(false);742if (!reply && !replyMentionsRef.current?.(undefined, true)) {743reply = replyMessageRef.current;744}745actions.sendReply({746message: message.toJS(),747reply,748submitMentionsRef: replyMentionsRef,749});750actions.scrollToIndex(index);751}752753function renderComposeReply() {754if (project_id == null || path == null || actions?.syncdb == null) {755// should never get into this position756// when null.757return;758}759const replyDate = -getThreadRootDate({ date, messages });760let input;761let moveCursorToEndOfLine = false;762if (isLLMThread) {763input = "";764} else {765const replying_to = message.get("history")?.first()?.get("author_id");766if (!replying_to || replying_to == account_id) {767input = "";768} else {769input = `<span class="user-mention" account-id=${replying_to} >@${editor_name}</span> `;770moveCursorToEndOfLine = autoFocusReply;771}772}773return (774<div style={{ marginLeft: mode === "standalone" ? "30px" : "0" }}>775<ChatInput776fontSize={font_size}777autoFocus={autoFocusReply}778moveCursorToEndOfLine={moveCursorToEndOfLine}779style={{780borderRadius: "8px",781height: "auto" /* for some reason the default 100% breaks things */,782}}783cacheId={`${path}${project_id}${date}-reply`}784input={input}785submitMentionsRef={replyMentionsRef}786on_send={sendReply}787height={"auto"}788syncdb={actions.syncdb}789date={replyDate}790onChange={(value) => {791replyMessageRef.current = value;792// replyMentionsRef does not submit mentions, only gives us the value793const input = replyMentionsRef.current?.(undefined, true) ?? value;794actions?.llmEstimateCost({795date: replyDate,796input,797message: message.toJS(),798});799}}800placeholder={"Reply to the above message..."}801/>802<div style={{ margin: "5px 0", display: "flex" }}>803<Button804style={{ marginRight: "5px" }}805onClick={() => {806setReplying(false);807actions?.deleteDraft(replyDate);808}}809>810<CancelText />811</Button>812<Button813onClick={() => {814sendReply();815}}816type="primary"817>818<Icon name="reply" /> Reply (shift+enter)819</Button>820{costEstimate?.get("date") == replyDate && (821<LLMCostEstimationChat822costEstimate={costEstimate?.toJS()}823compact={false}824style={{ display: "inline-block", marginLeft: "10px" }}825/>826)}827</div>828</div>829);830}831832function getStyleBase(): CSS {833if (!is_thread_body) {834if (is_thread) {835if (is_folded) {836return THREAD_STYLE_FOLDED;837} else {838return THREAD_STYLE_TOP;839}840} else {841return TRHEAD_STYLE_SINGLE;842}843} else if (allowReply) {844return THREAD_STYLE_BOTTOM;845} else {846return THREAD_STYLE;847}848}849850function getStyle(): CSS {851switch (mode) {852case "standalone":853return getStyleBase();854case "sidechat":855return {856...getStyleBase(),857marginLeft: "5px",858marginRight: "5px",859paddingLeft: "0",860};861default:862unreachable(mode);863return getStyleBase();864}865}866867function renderReplyRow() {868if (replying || generating || !allowReply || is_folded || actions == null) {869return;870}871872return (873<div style={{ textAlign: "center", width: "100%" }}>874<Tooltip875title={876isLLMThread877? `Reply to ${modelToName(878isLLMThread,879)}, sending the thread as context.`880: "Reply to this thread."881}882>883<Button884type="text"885onClick={() => {886setReplying(true);887setAutoFocusReply(true);888}}889style={{ color: COLORS.GRAY_M }}890>891<Icon name="reply" /> Reply892{isLLMThread ? ` to ${modelToName(isLLMThread)}` : ""}893{isLLMThread ? (894<Avatar895account_id={isLLMThread}896size={16}897style={{ top: "-5px" }}898/>899) : undefined}900</Button>901</Tooltip>902{showAISummarize && is_thread ? (903<SummarizeThread message={message} actions={actions} />904) : undefined}905</div>906);907}908909function renderFoldedRow() {910if (!is_folded || !is_thread || is_thread_body) {911return;912}913914let label;915if (numChildren) {916label = (917<>918{numChildren} {plural(numChildren, "Reply", "Replies")}919</>920);921} else {922label = "View Replies";923}924925return (926<Col xs={24}>927<div style={{ textAlign: "center" }}>928<Button929onClick={() =>930actions?.toggleFoldThread(message.get("date"), index)931}932type="link"933style={{ color: "darkblue" }}934>935{label}936</Button>937</div>938</Col>939);940}941942function getThreadfoldOrBlank() {943const xs = 2;944if (is_thread_body || (!is_thread_body && !is_thread)) {945return BLANK_COLUMN(xs);946} else {947const style: CSS =948mode === "standalone"949? {950color: "#666",951marginTop: MARGIN_TOP_VIEWER,952marginLeft: "5px",953marginRight: "5px",954}955: {956color: "#666",957marginTop: "5px",958width: "100%",959textAlign: "center",960};961const iconname = is_folded962? mode === "standalone"963? reverseRowOrdering964? "right-circle-o"965: "left-circle-o"966: "right-circle-o"967: "down-circle-o";968const button = (969<Button970type="text"971style={style}972onClick={() => actions?.toggleFoldThread(message.get("date"), index)}973icon={974<Icon975name={iconname}976style={{ fontSize: mode === "standalone" ? "22px" : "18px" }}977/>978}979/>980);981return (982<Col983xs={xs}984key={"blankcolumn"}985style={{ textAlign: reverseRowOrdering ? "left" : "right" }}986>987{hideTooltip ? (988button989) : (990<Tooltip991title={992is_folded ? (993<>994Unfold this thread{" "}995{numChildren996? ` to show ${numChildren} ${plural(997numChildren,998"reply",999"replies",1000)}`1001: ""}1002</>1003) : (1004"Fold this thread to hide replies"1005)1006}1007>1008{button}1009</Tooltip>1010)}1011</Col>1012);1013}1014}10151016function renderCols(): JSX.Element[] | JSX.Element {1017// these columns should be filtered in the first place, this here is just an extra check1018if (is_thread && is_folded && is_thread_body) {1019return <></>;1020}10211022switch (mode) {1023case "standalone":1024const cols = [avatar_column(), contentColumn(), getThreadfoldOrBlank()];1025if (reverseRowOrdering) {1026cols.reverse();1027}1028return cols;10291030case "sidechat":1031return [getThreadfoldOrBlank(), contentColumn()];10321033default:1034unreachable(mode);1035return contentColumn();1036}1037}10381039return (1040<Row style={getStyle()}>1041{renderCols()}1042{renderFoldedRow()}1043{renderReplyRow()}1044</Row>1045);1046}10471048// Used for exporting chat to markdown file1049export function message_to_markdown(message): string {1050let value = newest_content(message);1051const user_map = redux.getStore("users").get("user_map");1052const sender = getUserName(user_map, message.get("sender_id"));1053const date = message.get("date").toString();1054return `*From:* ${sender} \n*Date:* ${date} \n\n${value}`;1055}105610571058