Path: blob/master/src/packages/frontend/account/avatar/avatar.tsx
6022 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import { Tooltip } from "antd";6import { CSSProperties, useState } from "react";78import { isChatBot } from "@cocalc/frontend/account/chatbot";9import {10React,11redux,12useAsyncEffect,13useTypedRedux,14} from "@cocalc/frontend/app-framework";15import { Gap } from "@cocalc/frontend/components";16import { LanguageModelVendorAvatar } from "@cocalc/frontend/components/language-model-icon";17import { ProjectTitle } from "@cocalc/frontend/projects/project-title";18import { DEFAULT_COLOR } from "@cocalc/frontend/users/store";19import { webapp_client } from "@cocalc/frontend/webapp-client";20import { service2model } from "@cocalc/util/db-schema/llm-utils";21import { ensure_bound, startswith, trunc_middle } from "@cocalc/util/misc";22import { avatar_fontcolor } from "./font-color";2324const CIRCLE_OUTER_STYLE: CSSProperties = {25textAlign: "center",26cursor: "pointer",27} as const;2829const CIRCLE_INNER_STYLE: CSSProperties = {30display: "block",31borderRadius: "50%",32fontFamily: "sans-serif",33} as const;3435interface Props {36account_id?: string; // if not given useful as a placeholder in the UI (e.g., if we don't know account_id yet); uuid or "chatgpt" or "openai-[model]".37size?: number; // in pixels38max_age_s?: number; // if given fade the avatar out over time.39project_id?: string; // if given, showing avatar info for a project (or specific file)40path?: string; // if given, showing avatar for a specific file4142// if given; is most recent activity43activity?: { project_id: string; path: string; last_used: Date };44// When defined, fade out over time; click goes to that file.45no_tooltip?: boolean; // if true, do not show a tooltip with full name info46no_loading?: boolean; // if true, do not show a loading indicator (show nothing)4748first_name?: string; // optional name to use49last_name?: string;50style?: CSSProperties;51}5253export function Avatar(props) {54if (isChatBot(props.account_id)) {55return (56<LanguageModelVendorAvatar57model={service2model(props.account_id)}58size={props.size ?? 30}59style={props.style}60/>61);62} else {63return <Avatar0 {...props} />;64}65}6667const Avatar0: React.FC<Props> = (props) => {68// we use the user_map to display the username and face:69const user_map = useTypedRedux("users", "user_map");70const [image, set_image] = useState<string | undefined>(undefined);71const [background_color, set_background_color] =72useState<string>(DEFAULT_COLOR);7374useAsyncEffect(75async (isMounted) => {76if (!props.account_id) return;77const image = await redux.getStore("users").get_image(props.account_id);78if (isMounted()) {79if (startswith(image, "https://api.adorable.io")) {80// Adorable is gone -- see https://github.com/sagemathinc/cocalc/issues/505481set_image(undefined);82} else {83set_image(image);84}85}86const background_color = await redux87.getStore("users")88.get_color(props.account_id);89if (isMounted()) {90set_background_color(background_color);91}92}, // Update image and/or color if the account_id changes *or* the profile itself changes:93// https://github.com/sagemathinc/cocalc/issues/501394[props.account_id, user_map.getIn([props.account_id, "profile"])],95);9697function click_avatar() {98if (props.activity == null) {99return;100}101const { project_id, path } = props.activity;102switch (viewing_what()) {103case "projects":104redux.getActions("projects").open_project({105project_id,106target: "files",107switch_to: true,108});109return;110case "project":111redux.getProjectActions(project_id).open_file({ path });112return;113case "file":114const actions = redux.getEditorActions(project_id, path);115// actions could be undefined, if file is closed116const gotoUser = actions?.["gotoUser"];117if (gotoUser != null) {118// This is at least implemented for the whiteboard (which doesn't119// have a good notion of lines), but should be done more120// generally, replacing the stuff below about cursor_line...121gotoUser(props.account_id);122return;123}124var line = get_cursor_line();125if (line != null) {126redux.getProjectActions(project_id).goto_line(path, line);127}128return;129}130}131132function letter() {133if (props.first_name) {134return props.first_name.toUpperCase()[0];135}136if (!props.account_id) return "?";137const first_name = user_map.getIn([props.account_id, "first_name"]);138if (first_name) {139return first_name.toUpperCase()[0];140} else {141return "?";142}143}144145function get_name() {146if (props.first_name != null || props.last_name != null) {147return trunc_middle(148`${props.first_name ?? ""} ${props.last_name ?? ""}`.trim(),14930,150);151}152if (!props.account_id) return "Unknown";153return trunc_middle(154redux.getStore("users").get_name(props.account_id)?.trim(),15530,156);157}158159function viewing_what() {160if (props.path != null && props.project_id != null) {161return "file";162} else if (props.project_id != null) {163return "project";164} else {165return "projects";166}167}168169function render_line() {170if (props.activity == null) {171return;172}173const line = get_cursor_line();174if (line != null) {175return (176<span>177<Gap /> (Line {line})178</span>179);180}181}182183function get_cursor_line() {184if (props.activity == null || props.account_id == null) {185return;186}187const { project_id, path } = props.activity;188let cursors = redux189.getProjectStore(project_id)190.get_users_cursors(path, props.account_id);191if (cursors == null) {192return;193}194// TODO -- will just assume immutable.js when react/typescript rewrite is done.195if (cursors.toJS != null) {196cursors = cursors.toJS();197}198const line = cursors[0] != null ? cursors[0]["y"] : undefined;199if (line != null) {200return line + 1;201} else {202return undefined;203}204}205206function render_tooltip_content() {207const name = get_name();208if (props.activity == null) {209return <span>{name}</span>;210}211switch (viewing_what()) {212case "projects":213return (214<span>215{name} last seen at{" "}216<ProjectTitle project_id={props.activity.project_id} />217</span>218);219case "project":220return (221<span>222{name} last seen at {props.activity.path}223</span>224);225case "file":226return (227<span>228{name} {render_line()}229</span>230);231}232}233234function render_inside() {235if (image) {236return <img style={{ borderRadius: "50%", width: "100%" }} src={image} />;237} else {238return render_letter();239}240}241242function render_letter() {243const color = avatar_fontcolor(background_color);244const style = {245backgroundColor: background_color, // the onecolor library doesn't provide magenta in some browsers246color,247};248return <span style={{ ...style, ...CIRCLE_INNER_STYLE }}>{letter()}</span>;249}250251const { max_age_s = 600 } = props;252253function fade() {254if (props.activity == null || !max_age_s) {255return 1;256}257const { last_used } = props.activity;258// don't fade out completely as then just see an empty face, which looks broken...259return ensure_bound(2601 -261(webapp_client.server_time().valueOf() - last_used.valueOf()) /262(max_age_s * 1000),2630,2640.85,265);266}267268const { size = 30 } = props;269if (size == null) {270throw Error("bug");271}272const outer_style = {273height: `${size}px`,274width: `${size}px`,275lineHeight: `${size}px`,276fontSize: `${0.7 * size}px`,277opacity: fade(),278};279280// we put avatars inside <p>'s in some cases so do not use divs here.281const elt = (282<span283style={{284display: "inline-block",285...outer_style,286...CIRCLE_OUTER_STYLE,287...props.style,288}}289onClick={click_avatar}290>291{render_inside()}292</span>293);294if (props.no_tooltip) {295return elt;296} else {297return <Tooltip title={render_tooltip_content()}>{elt}</Tooltip>;298}299};300301302