Path: blob/master/src/packages/frontend/chat/chatroom.tsx
1496 views
/*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 { FormattedMessage } from "react-intl";89import { Col, Row, Well } from "@cocalc/frontend/antd-bootstrap";10import {11React,12useEditorRedux,13useEffect,14useRef,15useState,16} from "@cocalc/frontend/app-framework";17import { Icon, Loading } from "@cocalc/frontend/components";18import StaticMarkdown from "@cocalc/frontend/editors/slate/static-markdown";19import { hoursToTimeIntervalHuman } from "@cocalc/util/misc";20import { EditorComponentProps } from "../frame-editors/frame-tree/types";21import { ChatLog } from "./chat-log";22import Filter from "./filter";23import ChatInput from "./input";24import { LLMCostEstimationChat } from "./llm-cost-estimation";25import type { ChatState } from "./store";26import { SubmitMentionsFn } from "./types";27import { INPUT_HEIGHT, markChatAsReadIfUnseen } from "./utils";2829const FILTER_RECENT_NONE = {30value: 0,31label: (32<>33<Icon name="clock" />34</>35),36} as const;3738const PREVIEW_STYLE: React.CSSProperties = {39background: "#f5f5f5",40fontSize: "14px",41borderRadius: "10px 10px 10px 10px",42boxShadow: "#666 3px 3px 3px",43paddingBottom: "20px",44maxHeight: "40vh",45overflowY: "auto",46} as const;4748const GRID_STYLE: React.CSSProperties = {49maxWidth: "1200px",50display: "flex",51flexDirection: "column",52width: "100%",53margin: "auto",54} as const;5556const CHAT_LOG_STYLE: React.CSSProperties = {57padding: "0",58background: "white",59flex: "1 0 auto",60position: "relative",61} as const;6263export function ChatRoom({64actions,65project_id,66path,67font_size,68desc,69}: EditorComponentProps) {70const useEditor = useEditorRedux<ChatState>({ project_id, path });71const [input, setInput] = useState("");72const search = desc?.get("data-search") ?? "";73const filterRecentH: number = desc?.get("data-filterRecentH") ?? 0;74const selectedHashtags = desc?.get("data-selectedHashtags");75const scrollToIndex = desc?.get("data-scrollToIndex") ?? null;76const scrollToDate = desc?.get("data-scrollToDate") ?? null;77const fragmentId = desc?.get("data-fragmentId") ?? null;78const showPreview = desc?.get("data-showPreview") ?? null;79const costEstimate = desc?.get("data-costEstimate");80const messages = useEditor("messages");81const [filterRecentHCustom, setFilterRecentHCustom] = useState<string>("");82const [filterRecentOpen, setFilterRecentOpen] = useState<boolean>(false);8384const submitMentionsRef = useRef<SubmitMentionsFn | undefined>(undefined);85const scrollToBottomRef = useRef<any>(null);8687// The act of opening/displaying the chat marks it as seen...88useEffect(() => {89mark_as_read();90}, []);9192function mark_as_read() {93markChatAsReadIfUnseen(project_id, path);94}9596function on_send_button_click(e): void {97e.preventDefault();98on_send();99}100101function render_preview_message(): React.JSX.Element | undefined {102if (!showPreview) {103return;104}105if (input.length === 0) {106return;107}108109return (110<Row style={{ position: "absolute", bottom: "0px", width: "100%" }}>111<Col xs={0} sm={2} />112113<Col xs={10} sm={9}>114<Well style={PREVIEW_STYLE}>115<div116className="pull-right lighten"117style={{118marginRight: "-8px",119marginTop: "-10px",120cursor: "pointer",121fontSize: "13pt",122}}123onClick={() => actions.setShowPreview(false)}124>125<Icon name="times" />126</div>127<StaticMarkdown value={input} />128<div className="small lighten" style={{ marginTop: "15px" }}>129Preview (press Shift+Enter to send)130</div>131</Well>132</Col>133134<Col sm={1} />135</Row>136);137}138139function isValidFilterRecentCustom(): boolean {140const v = parseFloat(filterRecentHCustom);141return isFinite(v) && v >= 0;142}143144function renderFilterRecent() {145if (messages == null || messages.size <= 5) {146return null;147}148return (149<Tooltip title="Only show recent threads.">150<Select151open={filterRecentOpen}152onDropdownVisibleChange={(v) => setFilterRecentOpen(v)}153value={filterRecentH}154status={filterRecentH > 0 ? "warning" : undefined}155allowClear156onClear={() => {157actions.setFilterRecentH(0);158setFilterRecentHCustom("");159}}160popupMatchSelectWidth={false}161onSelect={(val: number) => actions.setFilterRecentH(val)}162options={[163FILTER_RECENT_NONE,164...[1, 6, 12, 24, 48, 24 * 7, 14 * 24, 28 * 24].map((value) => {165const label = hoursToTimeIntervalHuman(value);166return { value, label };167}),168]}169labelRender={({ label, value }) => {170if (!label) {171if (isValidFilterRecentCustom()) {172value = parseFloat(filterRecentHCustom);173label = hoursToTimeIntervalHuman(value);174} else {175({ label, value } = FILTER_RECENT_NONE);176}177}178return (179<Tooltip180title={181value === 0182? undefined183: `Only threads with messages sent in the past ${label}.`184}185>186{label}187</Tooltip>188);189}}190dropdownRender={(menu) => (191<>192{menu}193<Divider style={{ margin: "8px 0" }} />194<Input195placeholder="Number of hours"196allowClear197value={filterRecentHCustom}198status={199filterRecentHCustom == "" || isValidFilterRecentCustom()200? undefined201: "error"202}203onChange={debounce(204(e: React.ChangeEvent<HTMLInputElement>) => {205const v = e.target.value;206setFilterRecentHCustom(v);207const val = parseFloat(v);208if (isFinite(val) && val >= 0) {209actions.setFilterRecentH(val);210} else if (v == "") {211actions.setFilterRecentH(FILTER_RECENT_NONE.value);212}213},214150,215{ leading: true, trailing: true },216)}217onKeyDown={(e) => e.stopPropagation()}218onPressEnter={() => setFilterRecentOpen(false)}219addonAfter={<span style={{ paddingLeft: "5px" }}>hours</span>}220/>221</>222)}223/>224</Tooltip>225);226}227228function render_button_row() {229if (messages == null || messages.size <= 5) {230return null;231}232return (233<Space style={{ marginTop: "5px", marginLeft: "15px" }} wrap>234<Filter235actions={actions}236search={search}237style={{238margin: 0,239width: "100%",240}}241/>242{renderFilterRecent()}243</Space>244);245}246247function on_send(): void {248scrollToBottomRef.current?.(true);249actions.sendChat({ submitMentionsRef });250setTimeout(() => {251scrollToBottomRef.current?.(true);252}, 100);253setInput("");254}255256function render_body(): React.JSX.Element {257return (258<div className="smc-vfill" style={GRID_STYLE}>259{render_button_row()}260<div className="smc-vfill" style={CHAT_LOG_STYLE}>261<ChatLog262actions={actions}263project_id={project_id}264path={path}265scrollToBottomRef={scrollToBottomRef}266mode={"standalone"}267fontSize={font_size}268search={search}269filterRecentH={filterRecentH}270selectedHashtags={selectedHashtags}271scrollToIndex={scrollToIndex}272scrollToDate={scrollToDate}273selectedDate={fragmentId}274costEstimate={costEstimate}275/>276{render_preview_message()}277</div>278<div style={{ display: "flex", marginBottom: "5px", overflow: "auto" }}>279<div280style={{281flex: "1",282padding: "0px 5px 0px 2px",283}}284>285<ChatInput286fontSize={font_size}287autoFocus288cacheId={`${path}${project_id}-new`}289input={input}290on_send={on_send}291height={INPUT_HEIGHT}292onChange={(value) => {293setInput(value);294// submitMentionsRef will not actually submit mentions; we're only interested in the reply value295const input =296submitMentionsRef.current?.(undefined, true) ?? value;297actions?.llmEstimateCost({ date: 0, input });298}}299submitMentionsRef={submitMentionsRef}300syncdb={actions.syncdb}301date={0}302editBarStyle={{ overflow: "auto" }}303/>304</div>305<div306style={{307display: "flex",308flexDirection: "column",309padding: "0",310marginBottom: "0",311}}312>313<div style={{ flex: 1 }} />314{costEstimate?.get("date") == 0 && (315<LLMCostEstimationChat316costEstimate={costEstimate?.toJS()}317compact318style={{319flex: 0,320fontSize: "85%",321textAlign: "center",322margin: "0 0 5px 0",323}}324/>325)}326<Tooltip327title={328<FormattedMessage329id="chatroom.chat_input.send_button.tooltip"330defaultMessage={"Send message (shift+enter)"}331/>332}333>334<Button335onClick={on_send_button_click}336disabled={input.trim() === ""}337type="primary"338style={{ height: "47.5px" }}339icon={<Icon name="paper-plane" />}340>341<FormattedMessage342id="chatroom.chat_input.send_button.label"343defaultMessage={"Send"}344/>345</Button>346</Tooltip>347<div style={{ height: "5px" }} />348<Button349type={showPreview ? "dashed" : undefined}350onClick={() => actions.setShowPreview(!showPreview)}351style={{ height: "47.5px" }}352>353<FormattedMessage354id="chatroom.chat_input.preview_button.label"355defaultMessage={"Preview"}356/>357</Button>358<div style={{ height: "5px" }} />359<Button360style={{ height: "47.5px" }}361onClick={() => {362actions?.frameTreeActions?.getVideoChat().startChatting();363}}364>365<Icon name="video-camera" /> Video366</Button>367</div>368</div>369</div>370);371}372373if (messages == null || input == null) {374return <Loading theme={"medium"} />;375}376return (377<div378onMouseMove={mark_as_read}379onClick={mark_as_read}380className="smc-vfill"381>382{render_body()}383</div>384);385}386387388