Path: blob/master/src/packages/frontend/chat/message.tsx
5836 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, 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 CopyButton from "@cocalc/frontend/components/copy-button";22import MostlyStaticMarkdown from "@cocalc/frontend/editors/slate/mostly-static-markdown";23import { IS_TOUCH } from "@cocalc/frontend/feature";24import { modelToName } from "@cocalc/frontend/frame-editors/llm/llm-selector";25import { labels } from "@cocalc/frontend/i18n";26import { CancelText } from "@cocalc/frontend/i18n/components";27import { User } from "@cocalc/frontend/users";28import { isLanguageModelService } from "@cocalc/util/db-schema/llm-utils";29import { plural, unreachable } from "@cocalc/util/misc";30import { COLORS } from "@cocalc/util/theme";31import { ChatActions } from "./actions";32import { getUserName } from "./chat-log";33import { History, HistoryFooter, HistoryTitle } from "./history";34import ChatInput from "./input";35import { LLMCostEstimationChat } from "./llm-cost-estimation";36import { FeedbackLLM } from "./llm-msg-feedback";37import { RegenerateLLM } from "./llm-msg-regenerate";38import { SummarizeThread } from "./llm-msg-summarize";39import { Name } from "./name";40import { Time } from "./time";41import { ChatMessageTyped, Mode, SubmitMentionsFn } from "./types";42import {43getThreadRootDate,44is_editing,45message_colors,46newest_content,47sender_is_viewer,48} from "./utils";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 THREAD_STYLE_SINGLE: CSS = {59marginLeft: "15px",60marginRight: "15px",61paddingLeft: "15px",62} as const;6364const THREAD_STYLE: CSS = {65...THREAD_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;128threadViewMode?: boolean;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,154threadViewMode = false,155}: Props) {156const intl = useIntl();157158const showAISummarize = redux159.getStore("projects")160.hasLanguageModelEnabled(project_id, "chat-summarize");161162const hideTooltip =163useTypedRedux("account", "other_settings").get("hide_file_popovers") ??164false;165166const [edited_message, set_edited_message] = useState<string>(167newest_content(message),168);169// We have to use a ref because of trickiness involving170// stale closures when submitting the message.171const edited_message_ref = useRef(edited_message);172173const [show_history, set_show_history] = useState(false);174175const new_changes = useMemo(176() => edited_message !== newest_content(message),177[message] /* note -- edited_message is a function of message */,178);179180// date as ms since epoch or 0181const date: number = useMemo(() => {182return message?.get("date")?.valueOf() ?? 0;183}, [message.get("date")]);184185const showEditButton = Date.now() - date < SHOW_EDIT_BUTTON_MS;186187const generating = message.get("generating");188189const history_size = useMemo(190() => message.get("history")?.size ?? 0,191[message],192);193194const isEditing = useMemo(195() => is_editing(message, account_id),196[message, account_id],197);198199const editor_name = useMemo(() => {200return get_user_name(message.get("history")?.first()?.get("author_id"));201}, [message]);202203const reverseRowOrdering =204!is_thread_body && sender_is_viewer(account_id, message);205206const submitMentionsRef = useRef<SubmitMentionsFn>(null as any);207208const [replying, setReplying] = useState<boolean>(() => {209if (!allowReply) {210return false;211}212const replyDate = -getThreadRootDate({ date, messages });213const draft = actions?.syncdb?.get_one({214event: "draft",215sender_id: account_id,216date: replyDate,217});218if (draft == null) {219return false;220}221if (draft.get("active") <= 1720071100408) {222// before this point in time, drafts never ever got deleted when sending replies! So there's a massive223// clutter of reply drafts sitting in chats, and we don't want to resurrect them.224return false;225}226return true;227});228useEffect(() => {229if (!allowReply) {230setReplying(false);231}232}, [allowReply]);233234const [autoFocusReply, setAutoFocusReply] = useState<boolean>(false);235const [autoFocusEdit, setAutoFocusEdit] = useState<boolean>(false);236237const replyMessageRef = useRef<string>("");238const replyMentionsRef = useRef<SubmitMentionsFn | undefined>(undefined);239240const is_viewers_message = sender_is_viewer(account_id, message);241const verb = show_history ? "Hide" : "Show";242243const isLLMThread = useMemo(244() => actions?.isLanguageModelThread(message.get("date")),245[message, actions != null],246);247248const msgWrittenByLLM = useMemo(() => {249const author_id = message.get("history")?.first()?.get("author_id");250return typeof author_id === "string" && isLanguageModelService(author_id);251}, [message]);252253useLayoutEffect(() => {254if (replying) {255scroll_into_view?.();256}257}, [replying]);258259function render_editing_status(is_editing: boolean) {260let text;261262let other_editors = // @ts-ignore -- keySeq *is* a method of TypedMap263message.get("editing")?.remove(account_id).keySeq() ?? List();264if (is_editing) {265if (other_editors.size === 1) {266// This user and someone else is also editing267text = (268<>269{`WARNING: ${get_user_name(270other_editors.first(),271)} is also editing this! `}272<b>Simultaneous editing of messages is not supported.</b>273</>274);275} else if (other_editors.size > 1) {276// Multiple other editors277text = `${other_editors.size} other users are also editing this!`;278} else if (279history_size !== (message.get("history")?.size ?? 0) &&280new_changes281) {282text = `${editor_name} has updated this message. Esc to discard your changes and see theirs`;283} else {284if (IS_TOUCH) {285text = "You are now editing ...";286} else {287text = "You are now editing ... Shift+Enter to submit changes.";288}289}290} else {291if (other_editors.size === 1) {292// One person is editing293text = `${get_user_name(294other_editors.first(),295)} is editing this message`;296} else if (other_editors.size > 1) {297// Multiple editors298text = `${other_editors.size} people are editing this message`;299} else if (newest_content(message).trim() === "") {300text = `Deleted by ${editor_name}`;301}302}303304if (text == null) {305text = `Last edit by ${editor_name}`;306}307308if (309!is_editing &&310other_editors.size === 0 &&311newest_content(message).trim() !== ""312) {313const edit = "Last edit ";314const name = ` by ${editor_name}`;315const msg_date = message.get("history").first()?.get("date");316return (317<div318style={{319color: COLORS.GRAY_M,320fontSize: "14px" /* matches Reply button */,321}}322>323{edit}{" "}324{msg_date != null ? (325<TimeAgo date={new Date(msg_date)} />326) : (327"unknown time"328)}{" "}329{name}330</div>331);332}333return (334<div style={{ color: COLORS.GRAY_M }}>335{text}336{is_editing ? (337<span style={{ margin: "10px 10px 0 10px", display: "inline-block" }}>338<Button onClick={on_cancel}>Cancel</Button>339<Gap />340<Button onClick={saveEditedMessage} type="primary">341Save (shift+enter)342</Button>343</span>344) : undefined}345</div>346);347}348349function edit_message() {350if (project_id == null || path == null || actions == null) {351// no editing functionality or not in a project with a path.352return;353}354actions.setEditing(message, true);355setAutoFocusEdit(true);356scroll_into_view?.();357}358359function avatar_column() {360const sender_id = message.get("sender_id");361let style: CSSProperties = {};362if (!is_prev_sender) {363style.marginTop = "22px";364} else {365style.marginTop = "5px";366}367368if (!is_thread_body) {369if (sender_is_viewer(account_id, message)) {370style.marginLeft = AVATAR_MARGIN_LEFTRIGHT;371} else {372style.marginRight = AVATAR_MARGIN_LEFTRIGHT;373}374}375376return (377<Col key={0} xs={2}>378<div style={style}>379{sender_id != null && show_avatar ? (380<Avatar size={40} account_id={sender_id} />381) : undefined}382</div>383</Col>384);385}386387function renderEditControlRow() {388if (isEditing) {389return null;390}391const showEditingStatus =392(message.get("history")?.size ?? 0) > 1 ||393(message.get("editing")?.size ?? 0) > 0;394const showHistory = (message.get("history")?.size ?? 0) > 1;395const showLLMFeedback = isLLMThread && msgWrittenByLLM;396397// Show the bottom line of the message -- this uses a LOT of extra398// vertical space, so only do it if there is a good reason to.399// Getting rid of this might be nice.400const show =401showEditButton || showEditingStatus || showHistory || showLLMFeedback;402if (!show) {403// important to explicitly check this before rendering below, since otherwise we get a big BLANK space.404return null;405}406407return (408<div style={{ width: "100%", textAlign: "center" }}>409<Space direction="horizontal" size="small" wrap>410{showEditButton ? (411<Tip412title={413<>414Edit this message. You can edit <b>any</b> past message at any415time by double clicking on it. Fix other people's typos. All416versions are stored.417</>418}419placement="left"420>421<Button422disabled={replying}423style={{424color: is_viewers_message ? "white" : "#555",425}}426type="text"427size="small"428onClick={() => actions?.setEditing(message, true)}429>430<Icon name="pencil" /> Edit431</Button>432</Tip>433) : undefined}434{showEditingStatus && render_editing_status(isEditing)}435{showHistory && (436<Button437style={{438marginLeft: "5px",439color: is_viewers_message ? "white" : "#555",440}}441type="text"442size="small"443icon={<Icon name="history" />}444onClick={() => {445set_show_history(!show_history);446scroll_into_view?.();447}}448>449<Tip450title="Message History"451tip={`${verb} history of editing of this message. Any collaborator can edit any message by double clicking on it.`}452>453{verb} History454</Tip>455</Button>456)}457{showLLMFeedback && (458<>459<RegenerateLLM460actions={actions}461date={date}462model={isLLMThread}463/>464<FeedbackLLM actions={actions} message={message} />465</>466)}467</Space>468</div>469);470}471472function renderCopyMessageButton() {473return (474<Tip475placement={"top"}476title={intl.formatMessage({477id: "chat.message.copy_markdown.tooltip",478defaultMessage: "Copy message as markdown",479description:480"Tooltip for button to copy chat message as markdown text",481})}482>483<CopyButton484value={message_to_markdown(message)}485size="small"486noText={true}487style={{488color: is_viewers_message ? "white" : "#888",489fontSize: "12px",490marginTop: "-4px",491}}492/>493</Tip>494);495}496497function renderLinkMessageButton() {498return (499<Tip500placement={"top"}501title={intl.formatMessage({502id: "chat.message.copy_link.tooltip",503defaultMessage: "Select message. Copy URL to link to this message.",504description:505"Tooltip for button to copy URL link to specific chat message",506})}507>508<Button509onClick={() => {510actions?.setFragment(message.get("date"));511}}512size="small"513type={"text"}514style={{515color: is_viewers_message ? "white" : "#888",516fontSize: "12px",517marginTop: "-4px",518}}519>520<Icon name="link" />521</Button>522</Tip>523);524}525526function renderLLMFeedbackButtons() {527if (isLLMThread) return;528529const feedback = message.getIn(["feedback", account_id]);530const otherFeedback =531isLLMThread && msgWrittenByLLM ? 0 : (message.get("feedback")?.size ?? 0);532const showOtherFeedback = otherFeedback > 0;533534return (535<Tip536placement={"top"}537title={538!showOtherFeedback539? "Like this"540: () => {541return (542<div>543{Object.keys(message.get("feedback")?.toJS() ?? {}).map(544(account_id) => (545<div key={account_id} style={{ marginBottom: "2px" }}>546<Avatar size={24} account_id={account_id} />{" "}547<User account_id={account_id} />548</div>549),550)}551</div>552);553}554}555>556<Button557style={{558color: !feedback && is_viewers_message ? "white" : "#888",559fontSize: "12px",560marginTop: "-4px",561...(feedback ? {} : { position: "relative", top: "-5px" }),562}}563size="small"564type={feedback ? "dashed" : "text"}565onClick={() => {566actions?.feedback(message, feedback ? null : "positive");567}}568>569{showOtherFeedback ? (570<Badge count={otherFeedback} color="darkblue" size="small" />571) : (572""573)}574<Icon575name="thumbs-up"576style={{577color: showOtherFeedback ? "darkblue" : undefined,578}}579/>580</Button>581</Tip>582);583}584585function renderMessageBody({ lighten, message_class }) {586const value = newest_content(message);587588return (589<>590<span style={lighten}>591<Time message={message} edit={edit_message} />592<Space593size={"small"}594align="baseline"595style={{ float: "right", marginRight: "10px" }}596>597{renderLLMFeedbackButtons()}598{renderCopyMessageButton()}599{renderLinkMessageButton()}600</Space>601</span>602<MostlyStaticMarkdown603style={MARKDOWN_STYLE}604value={value}605className={message_class}606selectedHashtags={selectedHashtags}607toggleHashtag={608selectedHashtags != null && actions != null609? (tag) =>610actions?.setHashtagState(611tag,612selectedHashtags?.has(tag) ? undefined : 1,613)614: undefined615}616/>617{renderEditControlRow()}618</>619);620}621622function contentColumn() {623const mainXS = mode === "standalone" ? 20 : 22;624625const { background, color, lighten, message_class } = message_colors(626account_id,627message,628);629630const marginTop =631!is_prev_sender && is_viewers_message ? MARGIN_TOP_VIEWER : "5px";632633const messageStyle: CSSProperties = {634color,635background,636wordWrap: "break-word",637borderRadius: "5px",638marginTop,639fontSize: `${font_size}px`,640// no padding on bottom, since message itself is markdown, hence641// wrapped in <p>'s, which have a big 10px margin on their bottoms642// already.643padding: selected ? "6px 6px 0 6px" : "9px 9px 0 9px",644...(mode === "sidechat"645? { marginLeft: "5px", marginRight: "5px" }646: undefined),647...(selected ? { border: "3px solid #66bb6a" } : undefined),648} as const;649650return (651<Col key={1} xs={mainXS}>652<div653style={{ display: "flex" }}654onClick={() => {655actions?.setFragment(message.get("date"));656}}657>658{!is_prev_sender &&659!is_viewers_message &&660message.get("sender_id") ? (661<Name sender_name={get_user_name(message.get("sender_id"))} />662) : undefined}663{generating === true && actions ? (664<Button665style={{ color: COLORS.GRAY_M }}666onClick={() => {667actions?.languageModelStopGenerating(new Date(date));668}}669>670<Icon name="square" /> Stop Generating671</Button>672) : undefined}673</div>674<div675style={messageStyle}676className="smc-chat-message"677onDoubleClick={edit_message}678>679{isEditing680? renderEditMessage()681: renderMessageBody({ lighten, message_class })}682</div>683{renderHistory()}684{renderComposeReply()}685</Col>686);687}688689function renderHistory() {690if (!show_history) return;691return (692<div>693<HistoryTitle />694<History history={message.get("history")} user_map={user_map} />695<HistoryFooter />696</div>697);698}699700function saveEditedMessage(): void {701if (actions == null) return;702const mesg =703submitMentionsRef.current?.({ chat: `${date}` }) ??704edited_message_ref.current;705const value = newest_content(message);706if (mesg !== value) {707set_edited_message(mesg);708actions.sendEdit(message, mesg);709} else {710actions.setEditing(message, false);711}712}713714function on_cancel(): void {715set_edited_message(newest_content(message));716if (actions == null) return;717actions.setEditing(message, false);718actions.deleteDraft(date);719}720721function renderEditMessage() {722if (project_id == null || path == null || actions?.syncdb == null) {723// should never get into this position724// when null.725return;726}727return (728<div>729<ChatInput730fontSize={font_size}731autoFocus={autoFocusEdit}732cacheId={`${path}${project_id}${date}`}733input={newest_content(message)}734submitMentionsRef={submitMentionsRef}735on_send={saveEditedMessage}736height={"auto"}737syncdb={actions.syncdb}738date={date}739onChange={(value) => {740edited_message_ref.current = value;741}}742/>743<div style={{ marginTop: "10px", display: "flex" }}>744<Button745style={{ marginRight: "5px" }}746onClick={() => {747actions?.setEditing(message, false);748actions?.deleteDraft(date);749}}750>751{intl.formatMessage(labels.cancel)}752</Button>753<Button type="primary" onClick={saveEditedMessage}>754<Icon name="save" /> Save Edited Message755</Button>756</div>757</div>758);759}760761function sendReply(reply?: string) {762if (actions == null) return;763setReplying(false);764if (!reply && !replyMentionsRef.current?.(undefined, true)) {765reply = replyMessageRef.current;766}767actions.sendReply({768message: message.toJS(),769reply,770submitMentionsRef: replyMentionsRef,771});772actions.scrollToIndex(index);773}774775function renderComposeReply() {776if (!replying) return;777778if (project_id == null || path == null || actions?.syncdb == null) {779// should never get into this position780// when null.781return;782}783784const replyDate = -getThreadRootDate({ date, messages });785let input;786let moveCursorToEndOfLine = false;787if (isLLMThread) {788input = "";789} else {790const replying_to = message.get("history")?.first()?.get("author_id");791if (!replying_to || replying_to == account_id) {792input = "";793} else {794input = `<span class="user-mention" account-id=${replying_to} >@${editor_name}</span> `;795moveCursorToEndOfLine = autoFocusReply;796}797}798return (799<div style={{ marginLeft: mode === "standalone" ? "30px" : "0" }}>800<ChatInput801fontSize={font_size}802autoFocus={autoFocusReply}803moveCursorToEndOfLine={moveCursorToEndOfLine}804style={{805borderRadius: "8px",806height: "auto" /* for some reason the default 100% breaks things */,807}}808cacheId={`${path}${project_id}${date}-reply`}809input={input}810submitMentionsRef={replyMentionsRef}811on_send={sendReply}812height={"auto"}813syncdb={actions.syncdb}814date={replyDate}815onChange={(value) => {816replyMessageRef.current = value;817// replyMentionsRef does not submit mentions, only gives us the value818const input = replyMentionsRef.current?.(undefined, true) ?? value;819actions?.llmEstimateCost({820date: replyDate,821input,822message: message.toJS(),823});824}}825placeholder={"Reply to the above message..."}826/>827<div style={{ margin: "5px 0", display: "flex" }}>828<Button829style={{ marginRight: "5px" }}830onClick={() => {831setReplying(false);832actions?.deleteDraft(replyDate);833}}834>835<CancelText />836</Button>837<Tooltip title="Send Reply (shift+enter)">838<Button839onClick={() => {840sendReply();841}}842type="primary"843>844<Icon name="reply" /> Reply845</Button>846</Tooltip>847{costEstimate?.get("date") == replyDate && (848<LLMCostEstimationChat849costEstimate={costEstimate?.toJS()}850compact={false}851style={{ display: "inline-block", marginLeft: "10px" }}852/>853)}854</div>855</div>856);857}858859function getStyleBase(): CSS {860if (threadViewMode) {861return THREAD_STYLE_SINGLE;862}863if (!is_thread_body) {864if (is_thread) {865if (is_folded) {866return THREAD_STYLE_FOLDED;867} else {868return THREAD_STYLE_TOP;869}870} else {871return THREAD_STYLE_SINGLE;872}873} else if (allowReply) {874return THREAD_STYLE_BOTTOM;875} else {876return THREAD_STYLE;877}878}879880function getStyle(): CSS {881switch (mode) {882case "standalone":883return getStyleBase();884case "sidechat":885return {886...getStyleBase(),887marginLeft: "5px",888marginRight: "5px",889paddingLeft: "0",890};891default:892unreachable(mode);893return getStyleBase();894}895}896897function renderReplyRow() {898if (899threadViewMode ||900replying ||901generating ||902!allowReply ||903is_folded ||904actions == null905) {906return;907}908909return (910<div style={{ textAlign: "center", width: "100%" }}>911<Tip912placement={"bottom"}913title={914isLLMThread915? `Reply to ${modelToName(916isLLMThread,917)}, sending the thread as context.`918: "Reply to this thread."919}920>921<Button922type="text"923onClick={() => {924setReplying(true);925setAutoFocusReply(true);926}}927style={{ color: COLORS.GRAY_M }}928>929<Icon name="reply" /> Reply930{isLLMThread ? ` to ${modelToName(isLLMThread)}` : ""}931{isLLMThread ? (932<Avatar933account_id={isLLMThread}934size={16}935style={{ top: "-5px" }}936/>937) : undefined}938</Button>939</Tip>940{showAISummarize && is_thread ? (941<SummarizeThread message={message} actions={actions} />942) : undefined}943{is_thread && !threadViewMode && (944<Tip945placement={"bottom"}946title={947"Fold this thread to make the list of messages shorter. You can unfold it again at any time."948}949>950<Button951type="text"952style={{ color: COLORS.GRAY_M }}953onClick={() =>954actions?.toggleFoldThread(955new Date(getThreadRootDate({ date, messages })),956index,957)958}959>960<Icon name="vertical-align-middle" /> Fold…961</Button>962</Tip>963)}964</div>965);966}967968function renderFoldedRow() {969if (threadViewMode || !is_folded || !is_thread || is_thread_body) {970return;971}972973const label = numChildren ? (974<>975Show {numChildren + 1} {plural(numChildren + 1, "Message", "Messages")}…976</>977) : (978"View Messages…"979);980981return (982<Col xs={24}>983<Tip title={"Click to unfold this thread to show all messages."}>984<Button985onClick={() =>986actions?.toggleFoldThread(message.get("date"), index)987}988type="link"989block990style={{ color: "darkblue", textAlign: "center" }}991icon={<Icon name="expand-arrows" />}992>993{label}994</Button>995</Tip>996</Col>997);998}9991000function getThreadFoldOrBlank() {1001const xs = 2;1002if (threadViewMode || is_thread_body || (!is_thread_body && !is_thread)) {1003return BLANK_COLUMN(xs);1004} else {1005const style: CSS =1006mode === "standalone"1007? {1008color: COLORS.GRAY_M,1009marginTop: MARGIN_TOP_VIEWER,1010marginLeft: "5px",1011marginRight: "5px",1012}1013: {1014color: COLORS.GRAY_M,1015marginTop: "5px",1016width: "100%",1017textAlign: "center",1018};10191020const iconName = is_folded ? "expand" : "vertical-align-middle";10211022const button = (1023<Button1024type="text"1025style={style}1026onClick={() => actions?.toggleFoldThread(message.get("date"), index)}1027icon={1028<Icon1029name={iconName}1030style={{ fontSize: mode === "standalone" ? "22px" : "18px" }}1031/>1032}1033/>1034);10351036return (1037<Col1038xs={xs}1039key={"blankcolumn"}1040style={{ textAlign: reverseRowOrdering ? "left" : "right" }}1041>1042{hideTooltip ? (1043button1044) : (1045<Tip1046placement={"bottom"}1047title={1048is_folded ? (1049<>1050Unfold this thread{" "}1051{numChildren1052? ` to show ${numChildren} ${plural(1053numChildren,1054"reply",1055"replies",1056)}`1057: ""}1058</>1059) : (1060"Fold this thread to hide replies"1061)1062}1063>1064{button}1065</Tip>1066)}1067</Col>1068);1069}1070}10711072function renderCols(): React.JSX.Element[] | React.JSX.Element {1073// these columns should be filtered in the first place, this here is just an extra check1074if (1075(!threadViewMode && is_folded) ||1076(is_thread && is_folded && is_thread_body)1077) {1078return <></>;1079}10801081switch (mode) {1082case "standalone":1083const cols = [avatar_column(), contentColumn(), getThreadFoldOrBlank()];1084if (reverseRowOrdering) {1085cols.reverse();1086}1087return cols;10881089case "sidechat":1090return [getThreadFoldOrBlank(), contentColumn()];10911092default:1093unreachable(mode);1094return contentColumn();1095}1096}10971098return (1099<Row style={getStyle()}>1100{renderCols()}1101{renderFoldedRow()}1102{renderReplyRow()}1103</Row>1104);1105}11061107// Used for exporting chat to markdown file1108export function message_to_markdown(message): string {1109let value = newest_content(message);1110const user_map = redux.getStore("users").get("user_map");1111const sender = getUserName(user_map, message.get("sender_id"));1112const date = message.get("date").toString();1113return `*From:* ${sender} \n*Date:* ${date} \n\n${value}`;1114}111511161117