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/account/avatar/avatar.tsx
Views: 687
/*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";7import { isChatBot } from "@cocalc/frontend/account/chatbot";8import {9React,10redux,11useAsyncEffect,12useTypedRedux,13} from "@cocalc/frontend/app-framework";14import { Gap } from "@cocalc/frontend/components";15import { LanguageModelVendorAvatar } from "@cocalc/frontend/components/language-model-icon";16import { ProjectTitle } from "@cocalc/frontend/projects/project-title";17import { DEFAULT_COLOR } from "@cocalc/frontend/users/store";18import { webapp_client } from "@cocalc/frontend/webapp-client";19import { service2model } from "@cocalc/util/db-schema/llm-utils";20import { ensure_bound, startswith, trunc_middle } from "@cocalc/util/misc";21import { avatar_fontcolor } from "./font-color";2223const CIRCLE_OUTER_STYLE: CSSProperties = {24textAlign: "center",25cursor: "pointer",26} as const;2728const CIRCLE_INNER_STYLE: CSSProperties = {29display: "block",30borderRadius: "50%",31fontFamily: "sans-serif",32} as const;3334interface Props {35account_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]".36size?: number; // in pixels37max_age_s?: number; // if given fade the avatar out over time.38project_id?: string; // if given, showing avatar info for a project (or specific file)39path?: string; // if given, showing avatar for a specific file4041// if given; is most recent activity42activity?: { project_id: string; path: string; last_used: Date };43// When defined, fade out over time; click goes to that file.44no_tooltip?: boolean; // if true, do not show a tooltip with full name info45no_loading?: boolean; // if true, do not show a loading indicator (show nothing)4647first_name?: string; // optional name to use48last_name?: string;49style?: CSSProperties;50}5152export function Avatar(props) {53if (isChatBot(props.account_id)) {54return (55<LanguageModelVendorAvatar56model={service2model(props.account_id)}57size={props.size ?? 30}58style={props.style}59/>60);61} else {62return <Avatar0 {...props} />;63}64}6566const Avatar0: React.FC<Props> = (props) => {67// we use the user_map to display the username and face:68const user_map = useTypedRedux("users", "user_map");69const [image, set_image] = useState<string | undefined>(undefined);70const [background_color, set_background_color] =71useState<string>(DEFAULT_COLOR);7273useAsyncEffect(74async (isMounted) => {75if (!props.account_id) return;76const image = await redux.getStore("users").get_image(props.account_id);77if (isMounted()) {78if (startswith(image, "https://api.adorable.io")) {79// Adorable is gone -- see https://github.com/sagemathinc/cocalc/issues/505480set_image(undefined);81} else {82set_image(image);83}84}85const background_color = await redux86.getStore("users")87.get_color(props.account_id);88if (isMounted()) {89set_background_color(background_color);90}91}, // Update image and/or color if the account_id changes *or* the profile itself changes:92// https://github.com/sagemathinc/cocalc/issues/501393[props.account_id, user_map.getIn([props.account_id, "profile"])],94);9596function click_avatar() {97if (props.activity == null) {98return;99}100const { project_id, path } = props.activity;101switch (viewing_what()) {102case "projects":103redux.getActions("projects").open_project({104project_id,105target: "files",106switch_to: true,107});108return;109case "project":110redux.getProjectActions(project_id).open_file({ path });111return;112case "file":113const actions = redux.getEditorActions(project_id, path);114// actions could be undefined, if file is closed115const gotoUser = actions?.["gotoUser"];116if (gotoUser != null) {117// This is at least implemented for the whiteboard (which doesn't118// have a good notion of lines), but should be done more119// generally, replacing the stuff below about cursor_line...120gotoUser(props.account_id);121return;122}123var line = get_cursor_line();124if (line != null) {125redux.getProjectActions(project_id).goto_line(path, line);126}127return;128}129}130131function letter() {132if (props.first_name) {133return props.first_name.toUpperCase()[0];134}135if (!props.account_id) return "?";136const first_name = user_map.getIn([props.account_id, "first_name"]);137if (first_name) {138return first_name.toUpperCase()[0];139} else {140return "?";141}142}143144function get_name() {145if (props.first_name != null || props.last_name != null) {146return trunc_middle(147`${props.first_name ?? ""} ${props.last_name ?? ""}`.trim(),14830,149);150}151if (!props.account_id) return "Unknown";152return trunc_middle(153redux.getStore("users").get_name(props.account_id)?.trim(),15430,155);156}157158function viewing_what() {159if (props.path != null && props.project_id != null) {160return "file";161} else if (props.project_id != null) {162return "project";163} else {164return "projects";165}166}167168function render_line() {169if (props.activity == null) {170return;171}172const line = get_cursor_line();173if (line != null) {174return (175<span>176<Gap /> (Line {line})177</span>178);179}180}181182function get_cursor_line() {183if (props.activity == null || props.account_id == null) {184return;185}186const { project_id, path } = props.activity;187let cursors = redux188.getProjectStore(project_id)189.get_users_cursors(path, props.account_id);190if (cursors == null) {191return;192}193// TODO -- will just assume immutable.js when react/typescript rewrite is done.194if (cursors.toJS != null) {195cursors = cursors.toJS();196}197const line = cursors[0] != null ? cursors[0]["y"] : undefined;198if (line != null) {199return line + 1;200} else {201return undefined;202}203}204205function render_tooltip_content() {206const name = get_name();207if (props.activity == null) {208return <span>{name}</span>;209}210switch (viewing_what()) {211case "projects":212return (213<span>214{name} last seen at{" "}215<ProjectTitle project_id={props.activity.project_id} />216</span>217);218case "project":219return (220<span>221{name} last seen at {props.activity.path}222</span>223);224case "file":225return (226<span>227{name} {render_line()}228</span>229);230}231}232233function render_inside() {234if (image) {235return <img style={{ borderRadius: "50%", width: "100%" }} src={image} />;236} else {237return render_letter();238}239}240241function render_letter() {242const color = avatar_fontcolor(background_color);243const style = {244backgroundColor: background_color, // the onecolor library doesn't provide magenta in some browsers245color,246};247return <span style={{ ...style, ...CIRCLE_INNER_STYLE }}>{letter()}</span>;248}249250const { max_age_s = 600 } = props;251252function fade() {253if (props.activity == null || !max_age_s) {254return 1;255}256const { last_used } = props.activity;257// don't fade out completely as then just see an empty face, which looks broken...258return ensure_bound(2591 -260(webapp_client.server_time().valueOf() - last_used.valueOf()) /261(max_age_s * 1000),2620,2630.85,264);265}266267const { size = 30 } = props;268if (size == null) {269throw Error("bug");270}271const outer_style = {272height: `${size}px`,273width: `${size}px`,274lineHeight: `${size}px`,275fontSize: `${0.7 * size}px`,276opacity: fade(),277};278279const elt = (280<div style={{ display: "inline-block", cursor: "pointer", ...props.style }}>281<div282style={{ ...outer_style, ...CIRCLE_OUTER_STYLE }}283onClick={click_avatar}284>285{render_inside()}286</div>287</div>288);289if (props.no_tooltip) {290return elt;291} else {292return <Tooltip title={render_tooltip_content()}>{elt}</Tooltip>;293}294};295296297298