Path: blob/master/src/packages/frontend/chat/message.tsx
1496 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45// cSpell:ignore blankcolumn67import { Badge, Button, Col, Popconfirm, Row, Space, Tooltip } from "antd";8import { List, Map } from "immutable";9import { CSSProperties, useEffect, useLayoutEffect } from "react";10import { useIntl } from "react-intl";11import { Avatar } from "@cocalc/frontend/account/avatar/avatar";12import {13CSS,14redux,15useMemo,16useRef,17useState,18useTypedRedux,19} from "@cocalc/frontend/app-framework";20import { Gap, Icon, TimeAgo, Tip } from "@cocalc/frontend/components";21import MostlyStaticMarkdown from "@cocalc/frontend/editors/slate/mostly-static-markdown";22import { IS_TOUCH } from "@cocalc/frontend/feature";23import { modelToName } from "@cocalc/frontend/frame-editors/llm/llm-selector";24import { labels } from "@cocalc/frontend/i18n";25import { CancelText } from "@cocalc/frontend/i18n/components";26import { User } from "@cocalc/frontend/users";27import { isLanguageModelService } from "@cocalc/util/db-schema/llm-utils";28import { plural, unreachable } from "@cocalc/util/misc";29import { COLORS } from "@cocalc/util/theme";30import { ChatActions } from "./actions";31import { getUserName } from "./chat-log";32import { History, HistoryFooter, HistoryTitle } from "./history";33import ChatInput from "./input";34import { LLMCostEstimationChat } from "./llm-cost-estimation";35import { FeedbackLLM } from "./llm-msg-feedback";36import { RegenerateLLM } from "./llm-msg-regenerate";37import { SummarizeThread } from "./llm-msg-summarize";38import { Name } from "./name";39import { Time } from "./time";40import { ChatMessageTyped, Mode, SubmitMentionsFn } from "./types";41import {42getThreadRootDate,43is_editing,44message_colors,45newest_content,46sender_is_viewer,47} from "./utils";4849const DELETE_BUTTON = false;5051const BLANK_COLUMN = (xs) => <Col key={"blankcolumn"} xs={xs}></Col>;5253const MARKDOWN_STYLE = undefined;5455const BORDER = "2px solid #ccc";5657const SHOW_EDIT_BUTTON_MS = 15000;5859const THREAD_STYLE_SINGLE: CSS = {60marginLeft: "15px",61marginRight: "15px",62paddingLeft: "15px",63} as const;6465const THREAD_STYLE: CSS = {66...THREAD_STYLE_SINGLE,67borderLeft: BORDER,68borderRight: BORDER,69} as const;7071const THREAD_STYLE_BOTTOM: CSS = {72...THREAD_STYLE,73borderBottomLeftRadius: "10px",74borderBottomRightRadius: "10px",75borderBottom: BORDER,76marginBottom: "10px",77} as const;7879const THREAD_STYLE_TOP: CSS = {80...THREAD_STYLE,81borderTop: BORDER,82borderTopLeftRadius: "10px",83borderTopRightRadius: "10px",84marginTop: "10px",85} as const;8687const THREAD_STYLE_FOLDED: CSS = {88...THREAD_STYLE_TOP,89...THREAD_STYLE_BOTTOM,90} as const;9192const MARGIN_TOP_VIEWER = "17px";9394const AVATAR_MARGIN_LEFTRIGHT = "15px";9596interface Props {97index: number;98actions?: ChatActions;99get_user_name: (account_id?: string) => string;100messages;101message: ChatMessageTyped;102account_id: string;103user_map?: Map<string, any>;104project_id?: string; // improves relative links if given105path?: string;106font_size?: number;107is_prev_sender?: boolean;108show_avatar?: boolean;109mode: Mode;110selectedHashtags?: Set<string>;111112scroll_into_view?: () => void; // call to scroll this message into view113114// if true, include a reply button - this should only be for messages115// that don't have an existing reply to them already.116allowReply?: boolean;117118is_thread?: boolean; // if true, there is a thread starting in a reply_to message119is_folded?: boolean; // if true, only show the reply_to root message120is_thread_body: boolean;121122costEstimate;123124selected?: boolean;125126// for the root of a folded thread, optionally give this number of a127// more informative message to the user.128numChildren?: number;129}130131export default function Message({132index,133actions,134get_user_name,135messages,136message,137account_id,138user_map,139project_id,140path,141font_size,142is_prev_sender,143show_avatar,144mode,145selectedHashtags,146scroll_into_view,147allowReply,148is_thread,149is_folded,150is_thread_body,151costEstimate,152selected,153numChildren,154}: Props) {155const intl = useIntl();156157const showAISummarize = redux158.getStore("projects")159.hasLanguageModelEnabled(project_id, "chat-summarize");160161const hideTooltip =162useTypedRedux("account", "other_settings").get("hide_file_popovers") ??163false;164165const [edited_message, set_edited_message] = useState<string>(166newest_content(message),167);168// We have to use a ref because of trickiness involving169// stale closures when submitting the message.170const edited_message_ref = useRef(edited_message);171172const [show_history, set_show_history] = useState(false);173174const new_changes = useMemo(175() => edited_message !== newest_content(message),176[message] /* note -- edited_message is a function of message */,177);178179// date as ms since epoch or 0180const date: number = useMemo(() => {181return message?.get("date")?.valueOf() ?? 0;182}, [message.get("date")]);183184const showEditButton = Date.now() - date < SHOW_EDIT_BUTTON_MS;185186const generating = message.get("generating");187188const history_size = useMemo(189() => message.get("history")?.size ?? 0,190[message],191);192193const isEditing = useMemo(194() => is_editing(message, account_id),195[message, account_id],196);197198const editor_name = useMemo(() => {199return get_user_name(message.get("history")?.first()?.get("author_id"));200}, [message]);201202const reverseRowOrdering =203!is_thread_body && sender_is_viewer(account_id, message);204205const submitMentionsRef = useRef<SubmitMentionsFn>(null as any);206207const [replying, setReplying] = useState<boolean>(() => {208if (!allowReply) {209return false;210}211const replyDate = -getThreadRootDate({ date, messages });212const draft = actions?.syncdb?.get_one({213event: "draft",214sender_id: account_id,215date: replyDate,216});217if (draft == null) {218return false;219}220if (draft.get("active") <= 1720071100408) {221// before this point in time, drafts never ever got deleted when sending replies! So there's a massive222// clutter of reply drafts sitting in chats, and we don't want to resurrect them.223return false;224}225return true;226});227useEffect(() => {228if (!allowReply) {229setReplying(false);230}231}, [allowReply]);232233const [autoFocusReply, setAutoFocusReply] = useState<boolean>(false);234const [autoFocusEdit, setAutoFocusEdit] = useState<boolean>(false);235236const replyMessageRef = useRef<string>("");237const replyMentionsRef = useRef<SubmitMentionsFn | undefined>(undefined);238239const is_viewers_message = sender_is_viewer(account_id, message);240const verb = show_history ? "Hide" : "Show";241242const isLLMThread = useMemo(243() => actions?.isLanguageModelThread(message.get("date")),244[message, actions != null],245);246247const msgWrittenByLLM = useMemo(() => {248const author_id = message.get("history")?.first()?.get("author_id");249return typeof author_id === "string" && isLanguageModelService(author_id);250}, [message]);251252useLayoutEffect(() => {253if (replying) {254scroll_into_view?.();255}256}, [replying]);257258function render_editing_status(is_editing: boolean) {259let text;260261let other_editors = // @ts-ignore -- keySeq *is* a method of TypedMap262message.get("editing")?.remove(account_id).keySeq() ?? List();263if (is_editing) {264if (other_editors.size === 1) {265// This user and someone else is also editing266text = (267<>268{`WARNING: ${get_user_name(269other_editors.first(),270)} is also editing this! `}271<b>Simultaneous editing of messages is not supported.</b>272</>273);274} else if (other_editors.size > 1) {275// Multiple other editors276text = `${other_editors.size} other users are also editing this!`;277} else if (278history_size !== (message.get("history")?.size ?? 0) &&279new_changes280) {281text = `${editor_name} has updated this message. Esc to discard your changes and see theirs`;282} else {283if (IS_TOUCH) {284text = "You are now editing ...";285} else {286text = "You are now editing ... Shift+Enter to submit changes.";287}288}289} else {290if (other_editors.size === 1) {291// One person is editing292text = `${get_user_name(293other_editors.first(),294)} is editing this message`;295} else if (other_editors.size > 1) {296// Multiple editors297text = `${other_editors.size} people are editing this message`;298} else if (newest_content(message).trim() === "") {299text = `Deleted by ${editor_name}`;300}301}302303if (text == null) {304text = `Last edit by ${editor_name}`;305}306307if (308!is_editing &&309other_editors.size === 0 &&310newest_content(message).trim() !== ""311) {312const edit = "Last edit ";313const name = ` by ${editor_name}`;314const msg_date = message.get("history").first()?.get("date");315return (316<div317style={{318color: COLORS.GRAY_M,319fontSize: "14px" /* matches Reply button */,320}}321>322{edit}{" "}323{msg_date != null ? (324<TimeAgo date={new Date(msg_date)} />325) : (326"unknown time"327)}{" "}328{name}329</div>330);331}332return (333<div style={{ color: COLORS.GRAY_M }}>334{text}335{is_editing ? (336<span style={{ margin: "10px 10px 0 10px", display: "inline-block" }}>337<Button onClick={on_cancel}>Cancel</Button>338<Gap />339<Button onClick={saveEditedMessage} type="primary">340Save (shift+enter)341</Button>342</span>343) : undefined}344</div>345);346}347348function edit_message() {349if (project_id == null || path == null || actions == null) {350// no editing functionality or not in a project with a path.351return;352}353actions.setEditing(message, true);354setAutoFocusEdit(true);355scroll_into_view?.();356}357358function avatar_column() {359const sender_id = message.get("sender_id");360let style: CSSProperties = {};361if (!is_prev_sender) {362style.marginTop = "22px";363} else {364style.marginTop = "5px";365}366367if (!is_thread_body) {368if (sender_is_viewer(account_id, message)) {369style.marginLeft = AVATAR_MARGIN_LEFTRIGHT;370} else {371style.marginRight = AVATAR_MARGIN_LEFTRIGHT;372}373}374375return (376<Col key={0} xs={2}>377<div style={style}>378{sender_id != null && show_avatar ? (379<Avatar size={40} account_id={sender_id} />380) : undefined}381</div>382</Col>383);384}385386function renderEditControlRow() {387if (isEditing) {388return null;389}390const showDeleteButton =391DELETE_BUTTON && newest_content(message).trim().length > 0;392const showEditingStatus =393(message.get("history")?.size ?? 0) > 1 ||394(message.get("editing")?.size ?? 0) > 0;395const showHistory = (message.get("history")?.size ?? 0) > 1;396const showLLMFeedback = isLLMThread && msgWrittenByLLM;397398// Show the bottom line of the message -- this uses a LOT of extra399// vertical space, so only do it if there is a good reason to.400// Getting rid of this might be nice.401const show =402showEditButton ||403showDeleteButton ||404showEditingStatus ||405showHistory ||406showLLMFeedback;407if (!show) {408// important to explicitly check this before rendering below, since otherwise we get a big BLANK space.409return null;410}411412return (413<div style={{ width: "100%", textAlign: "center" }}>414<Space direction="horizontal" size="small" wrap>415{showEditButton ? (416<Tip417title={418<>419Edit this message. You can edit <b>any</b> past message at any420time by double clicking on it. Fix other people's typos. All421versions are stored.422</>423}424placement="left"425>426<Button427disabled={replying}428style={{429color: is_viewers_message ? "white" : "#555",430}}431type="text"432size="small"433onClick={() => actions?.setEditing(message, true)}434>435<Icon name="pencil" /> Edit436</Button>437</Tip>438) : undefined}439{showDeleteButton && (440<Tip441title="Delete this message. You can delete any past message by anybody. The deleted message can be view in history."442placement="left"443>444<Popconfirm445title="Delete this message"446description="Are you sure you want to delete this message?"447onConfirm={() => {448actions?.setEditing(message, true);449setTimeout(() => actions?.sendEdit(message, ""), 1);450}}451>452<Button453disabled={replying}454style={{455color: is_viewers_message ? "white" : "#555",456}}457type="text"458size="small"459>460<Icon name="trash" /> Delete461</Button>462</Popconfirm>463</Tip>464)}465{showEditingStatus && render_editing_status(isEditing)}466{showHistory && (467<Button468style={{469marginLeft: "5px",470color: is_viewers_message ? "white" : "#555",471}}472type="text"473size="small"474icon={<Icon name="history" />}475onClick={() => {476set_show_history(!show_history);477scroll_into_view?.();478}}479>480<Tip481title="Message History"482tip={`${verb} history of editing of this message. Any collaborator can edit any message by double clicking on it.`}483>484{verb} History485</Tip>486</Button>487)}488{showLLMFeedback && (489<>490<RegenerateLLM491actions={actions}492date={date}493model={isLLMThread}494/>495<FeedbackLLM actions={actions} message={message} />496</>497)}498</Space>499</div>500);501}502503function renderMessageBody({ lighten, message_class }) {504const value = newest_content(message);505506const feedback = message.getIn(["feedback", account_id]);507const otherFeedback =508isLLMThread && msgWrittenByLLM ? 0 : (message.get("feedback")?.size ?? 0);509const showOtherFeedback = otherFeedback > 0;510511return (512<>513<span style={lighten}>514<Time message={message} edit={edit_message} />515<Space516size={"small"}517align="baseline"518style={{ float: "right", marginRight: "10px" }}519>520{!isLLMThread && (521<Tip522placement={"top"}523title={524!showOtherFeedback525? "Like this"526: () => {527return (528<div>529{Object.keys(530message.get("feedback")?.toJS() ?? {},531).map((account_id) => (532<div533key={account_id}534style={{ marginBottom: "2px" }}535>536<Avatar size={24} account_id={account_id} />{" "}537<User account_id={account_id} />538</div>539))}540</div>541);542}543}544>545<Button546style={{547color: !feedback && is_viewers_message ? "white" : "#888",548fontSize: "12px",549marginTop: "-4px",550...(feedback ? {} : { position: "relative", top: "-5px" }),551}}552size="small"553type={feedback ? "dashed" : "text"}554onClick={() => {555actions?.feedback(message, feedback ? null : "positive");556}}557>558{showOtherFeedback ? (559<Badge560count={otherFeedback}561color="darkblue"562size="small"563/>564) : (565""566)}567<Icon568name="thumbs-up"569style={{570color: showOtherFeedback ? "darkblue" : undefined,571}}572/>573</Button>574</Tip>575)}576<Tip577placement={"top"}578title="Select message. Copy URL to link to this message."579>580<Button581onClick={() => {582actions?.setFragment(message.get("date"));583}}584size="small"585type={"text"}586style={{587color: is_viewers_message ? "white" : "#888",588fontSize: "12px",589marginTop: "-4px",590}}591>592<Icon name="link" />593</Button>594</Tip>595</Space>596</span>597<MostlyStaticMarkdown598style={MARKDOWN_STYLE}599value={value}600className={message_class}601selectedHashtags={selectedHashtags}602toggleHashtag={603selectedHashtags != null && actions != null604? (tag) =>605actions?.setHashtagState(606tag,607selectedHashtags?.has(tag) ? undefined : 1,608)609: undefined610}611/>612{renderEditControlRow()}613</>614);615}616617function contentColumn() {618const mainXS = mode === "standalone" ? 20 : 22;619620const { background, color, lighten, message_class } = message_colors(621account_id,622message,623);624625const marginTop =626!is_prev_sender && is_viewers_message ? MARGIN_TOP_VIEWER : "5px";627628const messageStyle: CSSProperties = {629color,630background,631wordWrap: "break-word",632borderRadius: "5px",633marginTop,634fontSize: `${font_size}px`,635// no padding on bottom, since message itself is markdown, hence636// wrapped in <p>'s, which have a big 10px margin on their bottoms637// already.638padding: selected ? "6px 6px 0 6px" : "9px 9px 0 9px",639...(mode === "sidechat"640? { marginLeft: "5px", marginRight: "5px" }641: undefined),642...(selected ? { border: "3px solid #66bb6a" } : undefined),643} as const;644645return (646<Col key={1} xs={mainXS}>647<div648style={{ display: "flex" }}649onClick={() => {650actions?.setFragment(message.get("date"));651}}652>653{!is_prev_sender &&654!is_viewers_message &&655message.get("sender_id") ? (656<Name sender_name={get_user_name(message.get("sender_id"))} />657) : undefined}658{generating === true && actions ? (659<Button660style={{ color: COLORS.GRAY_M }}661onClick={() => {662actions?.languageModelStopGenerating(new Date(date));663}}664>665<Icon name="square" /> Stop Generating666</Button>667) : undefined}668</div>669<div670style={messageStyle}671className="smc-chat-message"672onDoubleClick={edit_message}673>674{isEditing675? renderEditMessage()676: renderMessageBody({ lighten, message_class })}677</div>678{renderHistory()}679{renderComposeReply()}680</Col>681);682}683684function renderHistory() {685if (!show_history) return;686return (687<div>688<HistoryTitle />689<History history={message.get("history")} user_map={user_map} />690<HistoryFooter />691</div>692);693}694695function saveEditedMessage(): void {696if (actions == null) return;697const mesg =698submitMentionsRef.current?.({ chat: `${date}` }) ??699edited_message_ref.current;700const value = newest_content(message);701if (mesg !== value) {702set_edited_message(mesg);703actions.sendEdit(message, mesg);704} else {705actions.setEditing(message, false);706}707}708709function on_cancel(): void {710set_edited_message(newest_content(message));711if (actions == null) return;712actions.setEditing(message, false);713actions.deleteDraft(date);714}715716function renderEditMessage() {717if (project_id == null || path == null || actions?.syncdb == null) {718// should never get into this position719// when null.720return;721}722return (723<div>724<ChatInput725fontSize={font_size}726autoFocus={autoFocusEdit}727cacheId={`${path}${project_id}${date}`}728input={newest_content(message)}729submitMentionsRef={submitMentionsRef}730on_send={saveEditedMessage}731height={"auto"}732syncdb={actions.syncdb}733date={date}734onChange={(value) => {735edited_message_ref.current = value;736}}737/>738<div style={{ marginTop: "10px", display: "flex" }}>739<Button740style={{ marginRight: "5px" }}741onClick={() => {742actions?.setEditing(message, false);743actions?.deleteDraft(date);744}}745>746{intl.formatMessage(labels.cancel)}747</Button>748<Button type="primary" onClick={saveEditedMessage}>749<Icon name="save" /> Save Edited Message750</Button>751</div>752</div>753);754}755756function sendReply(reply?: string) {757if (actions == null) return;758setReplying(false);759if (!reply && !replyMentionsRef.current?.(undefined, true)) {760reply = replyMessageRef.current;761}762actions.sendReply({763message: message.toJS(),764reply,765submitMentionsRef: replyMentionsRef,766});767actions.scrollToIndex(index);768}769770function renderComposeReply() {771if (!replying) return;772773if (project_id == null || path == null || actions?.syncdb == null) {774// should never get into this position775// when null.776return;777}778779const replyDate = -getThreadRootDate({ date, messages });780let input;781let moveCursorToEndOfLine = false;782if (isLLMThread) {783input = "";784} else {785const replying_to = message.get("history")?.first()?.get("author_id");786if (!replying_to || replying_to == account_id) {787input = "";788} else {789input = `<span class="user-mention" account-id=${replying_to} >@${editor_name}</span> `;790moveCursorToEndOfLine = autoFocusReply;791}792}793return (794<div style={{ marginLeft: mode === "standalone" ? "30px" : "0" }}>795<ChatInput796fontSize={font_size}797autoFocus={autoFocusReply}798moveCursorToEndOfLine={moveCursorToEndOfLine}799style={{800borderRadius: "8px",801height: "auto" /* for some reason the default 100% breaks things */,802}}803cacheId={`${path}${project_id}${date}-reply`}804input={input}805submitMentionsRef={replyMentionsRef}806on_send={sendReply}807height={"auto"}808syncdb={actions.syncdb}809date={replyDate}810onChange={(value) => {811replyMessageRef.current = value;812// replyMentionsRef does not submit mentions, only gives us the value813const input = replyMentionsRef.current?.(undefined, true) ?? value;814actions?.llmEstimateCost({815date: replyDate,816input,817message: message.toJS(),818});819}}820placeholder={"Reply to the above message..."}821/>822<div style={{ margin: "5px 0", display: "flex" }}>823<Button824style={{ marginRight: "5px" }}825onClick={() => {826setReplying(false);827actions?.deleteDraft(replyDate);828}}829>830<CancelText />831</Button>832<Tooltip title="Send Reply (shift+enter)">833<Button834onClick={() => {835sendReply();836}}837type="primary"838>839<Icon name="reply" /> Reply840</Button>841</Tooltip>842{costEstimate?.get("date") == replyDate && (843<LLMCostEstimationChat844costEstimate={costEstimate?.toJS()}845compact={false}846style={{ display: "inline-block", marginLeft: "10px" }}847/>848)}849</div>850</div>851);852}853854function getStyleBase(): CSS {855if (!is_thread_body) {856if (is_thread) {857if (is_folded) {858return THREAD_STYLE_FOLDED;859} else {860return THREAD_STYLE_TOP;861}862} else {863return THREAD_STYLE_SINGLE;864}865} else if (allowReply) {866return THREAD_STYLE_BOTTOM;867} else {868return THREAD_STYLE;869}870}871872function getStyle(): CSS {873switch (mode) {874case "standalone":875return getStyleBase();876case "sidechat":877return {878...getStyleBase(),879marginLeft: "5px",880marginRight: "5px",881paddingLeft: "0",882};883default:884unreachable(mode);885return getStyleBase();886}887}888889function renderReplyRow() {890if (replying || generating || !allowReply || is_folded || actions == null) {891return;892}893894return (895<div style={{ textAlign: "center", width: "100%" }}>896<Tip897placement={"bottom"}898title={899isLLMThread900? `Reply to ${modelToName(901isLLMThread,902)}, sending the thread as context.`903: "Reply to this thread."904}905>906<Button907type="text"908onClick={() => {909setReplying(true);910setAutoFocusReply(true);911}}912style={{ color: COLORS.GRAY_M }}913>914<Icon name="reply" /> Reply915{isLLMThread ? ` to ${modelToName(isLLMThread)}` : ""}916{isLLMThread ? (917<Avatar918account_id={isLLMThread}919size={16}920style={{ top: "-5px" }}921/>922) : undefined}923</Button>924</Tip>925{showAISummarize && is_thread ? (926<SummarizeThread message={message} actions={actions} />927) : undefined}928{is_thread && (929<Tip930placement={"bottom"}931title={932"Fold this thread to make the list of messages shorter. You can unfold it again at any time."933}934>935<Button936type="text"937style={{ color: COLORS.GRAY_M }}938onClick={() =>939actions?.toggleFoldThread(940new Date(getThreadRootDate({ date, messages })),941index,942)943}944>945<Icon name="vertical-align-middle" /> Fold…946</Button>947</Tip>948)}949</div>950);951}952953function renderFoldedRow() {954if (!is_folded || !is_thread || is_thread_body) {955return;956}957958const label = numChildren ? (959<>960Show {numChildren + 1} {plural(numChildren + 1, "Message", "Messages")}…961</>962) : (963"View Messages…"964);965966return (967<Col xs={24}>968<Tip title={"Click to unfold this thread to show all messages."}>969<Button970onClick={() =>971actions?.toggleFoldThread(message.get("date"), index)972}973type="link"974block975style={{ color: "darkblue", textAlign: "center" }}976icon={<Icon name="expand-arrows" />}977>978{label}979</Button>980</Tip>981</Col>982);983}984985function getThreadFoldOrBlank() {986const xs = 2;987if (is_thread_body || (!is_thread_body && !is_thread)) {988return BLANK_COLUMN(xs);989} else {990const style: CSS =991mode === "standalone"992? {993color: COLORS.GRAY_M,994marginTop: MARGIN_TOP_VIEWER,995marginLeft: "5px",996marginRight: "5px",997}998: {999color: COLORS.GRAY_M,1000marginTop: "5px",1001width: "100%",1002textAlign: "center",1003};10041005const iconName = is_folded ? "expand" : "vertical-align-middle";10061007const button = (1008<Button1009type="text"1010style={style}1011onClick={() => actions?.toggleFoldThread(message.get("date"), index)}1012icon={1013<Icon1014name={iconName}1015style={{ fontSize: mode === "standalone" ? "22px" : "18px" }}1016/>1017}1018/>1019);10201021return (1022<Col1023xs={xs}1024key={"blankcolumn"}1025style={{ textAlign: reverseRowOrdering ? "left" : "right" }}1026>1027{hideTooltip ? (1028button1029) : (1030<Tip1031placement={"bottom"}1032title={1033is_folded ? (1034<>1035Unfold this thread{" "}1036{numChildren1037? ` to show ${numChildren} ${plural(1038numChildren,1039"reply",1040"replies",1041)}`1042: ""}1043</>1044) : (1045"Fold this thread to hide replies"1046)1047}1048>1049{button}1050</Tip>1051)}1052</Col>1053);1054}1055}10561057function renderCols(): React.JSX.Element[] | React.JSX.Element {1058// these columns should be filtered in the first place, this here is just an extra check1059if (is_folded || (is_thread && is_folded && is_thread_body)) {1060return <></>;1061}10621063switch (mode) {1064case "standalone":1065const cols = [avatar_column(), contentColumn(), getThreadFoldOrBlank()];1066if (reverseRowOrdering) {1067cols.reverse();1068}1069return cols;10701071case "sidechat":1072return [getThreadFoldOrBlank(), contentColumn()];10731074default:1075unreachable(mode);1076return contentColumn();1077}1078}10791080return (1081<Row style={getStyle()}>1082{renderCols()}1083{renderFoldedRow()}1084{renderReplyRow()}1085</Row>1086);1087}10881089// Used for exporting chat to markdown file1090export function message_to_markdown(message): string {1091let value = newest_content(message);1092const user_map = redux.getStore("users").get("user_map");1093const sender = getUserName(user_map, message.get("sender_id"));1094const date = message.get("date").toString();1095return `*From:* ${sender} \n*Date:* ${date} \n\n${value}`;1096}109710981099