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/chatroom.tsx
Views: 687
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import { Button, Divider, Input, Select, Space, Tooltip } from "antd";6import { debounce } from "lodash";7import { ButtonGroup, Col, Row, Well } from "@cocalc/frontend/antd-bootstrap";8import {9React,10redux,11useEditorRedux,12useEffect,13useRef,14useState,15} from "@cocalc/frontend/app-framework";16import { Icon, Loading } from "@cocalc/frontend/components";17import StaticMarkdown from "@cocalc/frontend/editors/slate/static-markdown";18import { FrameContext } from "@cocalc/frontend/frame-editors/frame-tree/frame-context";19import { hoursToTimeIntervalHuman } from "@cocalc/util/misc";20import { FormattedMessage } from "react-intl";21import type { ChatActions } from "./actions";22import type { ChatState } from "./store";23import { ChatLog } from "./chat-log";24import ChatInput from "./input";25import { LLMCostEstimationChat } from "./llm-cost-estimation";26import { SubmitMentionsFn } from "./types";27import { INPUT_HEIGHT, markChatAsReadIfUnseen } from "./utils";28import VideoChatButton from "./video/launch-button";29import Filter from "./filter";3031const FILTER_RECENT_NONE = {32value: 0,33label: (34<>35<Icon name="clock" />36</>37),38} as const;3940const PREVIEW_STYLE: React.CSSProperties = {41background: "#f5f5f5",42fontSize: "14px",43borderRadius: "10px 10px 10px 10px",44boxShadow: "#666 3px 3px 3px",45paddingBottom: "20px",46maxHeight: "40vh",47overflowY: "auto",48} as const;4950const GRID_STYLE: React.CSSProperties = {51maxWidth: "1200px",52display: "flex",53flexDirection: "column",54width: "100%",55margin: "auto",56} as const;5758const CHAT_LOG_STYLE: React.CSSProperties = {59padding: "0",60background: "white",61flex: "1 0 auto",62position: "relative",63} as const;6465interface Props {66actions: ChatActions;67project_id: string;68path: string;69is_visible?: boolean;70font_size: number;71desc?;72}7374export function ChatRoom({75actions,76project_id,77path,78is_visible,79font_size,80desc,81}: Props) {82const useEditor = useEditorRedux<ChatState>({ project_id, path });83const [input, setInput] = useState("");84const search = desc?.get("data-search") ?? "";85const filterRecentH: number = desc?.get("data-filterRecentH") ?? 0;86const selectedHashtags = desc?.get("data-selectedHashtags");87const scrollToIndex = desc?.get("data-scrollToIndex") ?? null;88const scrollToDate = desc?.get("data-scrollToDate") ?? null;89const fragmentId = desc?.get("data-fragmentId") ?? null;90const showPreview = desc?.get("data-showPreview") ?? null;91const costEstimate = desc?.get("data-costEstimate");92const messages = useEditor("messages");93const [filterRecentHCustom, setFilterRecentHCustom] = useState<string>("");94const [filterRecentOpen, setFilterRecentOpen] = useState<boolean>(false);9596const submitMentionsRef = useRef<SubmitMentionsFn>();97const scrollToBottomRef = useRef<any>(null);9899// The act of opening/displaying the chat marks it as seen...100useEffect(() => {101mark_as_read();102}, []);103104function mark_as_read() {105markChatAsReadIfUnseen(project_id, path);106}107108function on_send_button_click(e): void {109e.preventDefault();110on_send();111}112113function render_preview_message(): JSX.Element | undefined {114if (!showPreview) {115return;116}117if (input.length === 0) {118return;119}120121return (122<Row style={{ position: "absolute", bottom: "0px", width: "100%" }}>123<Col xs={0} sm={2} />124125<Col xs={10} sm={9}>126<Well style={PREVIEW_STYLE}>127<div128className="pull-right lighten"129style={{130marginRight: "-8px",131marginTop: "-10px",132cursor: "pointer",133fontSize: "13pt",134}}135onClick={() => actions.setShowPreview(false)}136>137<Icon name="times" />138</div>139<StaticMarkdown value={input} />140<div className="small lighten" style={{ marginTop: "15px" }}>141Preview (press Shift+Enter to send)142</div>143</Well>144</Col>145146<Col sm={1} />147</Row>148);149}150151function render_video_chat_button() {152if (project_id == null || path == null) return;153return <VideoChatButton actions={actions} />;154}155156function isValidFilterRecentCustom(): boolean {157const v = parseFloat(filterRecentHCustom);158return isFinite(v) && v >= 0;159}160161function renderFilterRecent() {162return (163<Tooltip title="Only show recent threads.">164<Select165open={filterRecentOpen}166onDropdownVisibleChange={(v) => setFilterRecentOpen(v)}167value={filterRecentH}168status={filterRecentH > 0 ? "warning" : undefined}169allowClear170onClear={() => {171actions.setFilterRecentH(0);172setFilterRecentHCustom("");173}}174popupMatchSelectWidth={false}175onSelect={(val: number) => actions.setFilterRecentH(val)}176options={[177FILTER_RECENT_NONE,178...[1, 6, 12, 24, 48, 24 * 7, 14 * 24, 28 * 24].map((value) => {179const label = hoursToTimeIntervalHuman(value);180return { value, label };181}),182]}183labelRender={({ label, value }) => {184if (!label) {185if (isValidFilterRecentCustom()) {186value = parseFloat(filterRecentHCustom);187label = hoursToTimeIntervalHuman(value);188} else {189({ label, value } = FILTER_RECENT_NONE);190}191}192return (193<Tooltip194title={195value === 0196? undefined197: `Only threads with messages sent in the past ${label}.`198}199>200{label}201</Tooltip>202);203}}204dropdownRender={(menu) => (205<>206{menu}207<Divider style={{ margin: "8px 0" }} />208<Input209placeholder="Number of hours"210allowClear211value={filterRecentHCustom}212status={213filterRecentHCustom == "" || isValidFilterRecentCustom()214? undefined215: "error"216}217onChange={debounce(218(e: React.ChangeEvent<HTMLInputElement>) => {219const v = e.target.value;220setFilterRecentHCustom(v);221const val = parseFloat(v);222if (isFinite(val) && val >= 0) {223actions.setFilterRecentH(val);224} else if (v == "") {225actions.setFilterRecentH(FILTER_RECENT_NONE.value);226}227},228150,229{ leading: true, trailing: true },230)}231onKeyDown={(e) => e.stopPropagation()}232onPressEnter={() => setFilterRecentOpen(false)}233addonAfter={<span style={{ paddingLeft: "5px" }}>hours</span>}234/>235</>236)}237/>238</Tooltip>239);240}241242function render_button_row() {243if (messages == null) {244return null;245}246return (247<Space style={{ width: "100%", marginTop: "3px" }} wrap>248<Filter249actions={actions}250search={search}251style={{252margin: 0,253width: "100%",254...(messages.size >= 2255? undefined256: { visibility: "hidden", height: 0 }),257}}258/>259{renderFilterRecent()}260<ButtonGroup style={{ marginLeft: "5px" }}>261{render_video_chat_button()}262</ButtonGroup>263</Space>264);265}266267function on_send(): void {268scrollToBottomRef.current?.(true);269actions.sendChat({ submitMentionsRef });270setTimeout(() => {271scrollToBottomRef.current?.(true);272}, 100);273setInput("");274}275276function render_body(): JSX.Element {277return (278<div className="smc-vfill" style={GRID_STYLE}>279{render_button_row()}280<div className="smc-vfill" style={CHAT_LOG_STYLE}>281<ChatLog282actions={actions}283project_id={project_id}284path={path}285scrollToBottomRef={scrollToBottomRef}286mode={"standalone"}287fontSize={font_size}288search={search}289filterRecentH={filterRecentH}290selectedHashtags={selectedHashtags}291scrollToIndex={scrollToIndex}292scrollToDate={scrollToDate}293selectedDate={fragmentId}294costEstimate={costEstimate}295/>296{render_preview_message()}297</div>298<div style={{ display: "flex", marginBottom: "5px", overflow: "auto" }}>299<div300style={{301flex: "1",302padding: "0px 5px 0px 2px",303}}304>305<ChatInput306fontSize={font_size}307autoFocus308cacheId={`${path}${project_id}-new`}309input={input}310on_send={on_send}311height={INPUT_HEIGHT}312onChange={(value) => {313setInput(value);314// submitMentionsRef will not actually submit mentions; we're only interested in the reply value315const input =316submitMentionsRef.current?.(undefined, true) ?? value;317actions?.llmEstimateCost({ date: 0, input });318}}319submitMentionsRef={submitMentionsRef}320syncdb={actions.syncdb}321date={0}322editBarStyle={{ overflow: "auto" }}323/>324</div>325<div326style={{327display: "flex",328flexDirection: "column",329padding: "0",330marginBottom: "0",331}}332>333<div style={{ flex: 1 }} />334{costEstimate?.get("date") == 0 && (335<LLMCostEstimationChat336costEstimate={costEstimate?.toJS()}337compact338style={{339flex: 0,340fontSize: "85%",341textAlign: "center",342margin: "0 0 5px 0",343}}344/>345)}346<Tooltip347title={348<FormattedMessage349id="chatroom.chat_input.send_button.tooltip"350defaultMessage={"Send message (shift+enter)"}351/>352}353>354<Button355onClick={on_send_button_click}356disabled={input.trim() === ""}357type="primary"358style={{ height: "47.5px" }}359icon={<Icon name="paper-plane" />}360>361<FormattedMessage362id="chatroom.chat_input.send_button.label"363defaultMessage={"Send"}364/>365</Button>366</Tooltip>367<div style={{ height: "5px" }} />368<Button369type={showPreview ? "dashed" : undefined}370onClick={() => actions.setShowPreview(!showPreview)}371style={{ height: "47.5px" }}372>373<FormattedMessage374id="chatroom.chat_input.preview_button.label"375defaultMessage={"Preview"}376/>377</Button>378</div>379</div>380</div>381);382}383384if (messages == null || input == null) {385return <Loading theme={"medium"} />;386}387// remove frameContext once the chatroom is part of a frame tree.388// we need this now, e.g., since some markdown editing components389// for input assume in a frame tree, e.g., to fix390// https://github.com/sagemathinc/cocalc/issues/7554391return (392<FrameContext.Provider393value={394{395project_id,396path,397isVisible: !!is_visible,398redux,399} as any400}401>402<div403onMouseMove={mark_as_read}404onClick={mark_as_read}405className="smc-vfill"406>407{render_body()}408</div>409</FrameContext.Provider>410);411}412413414