CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/account/avatar/avatar.tsx
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
import { Tooltip } from "antd";
7
import { CSSProperties, useState } from "react";
8
import { isChatBot } from "@cocalc/frontend/account/chatbot";
9
import {
10
React,
11
redux,
12
useAsyncEffect,
13
useTypedRedux,
14
} from "@cocalc/frontend/app-framework";
15
import { Gap } from "@cocalc/frontend/components";
16
import { LanguageModelVendorAvatar } from "@cocalc/frontend/components/language-model-icon";
17
import { ProjectTitle } from "@cocalc/frontend/projects/project-title";
18
import { DEFAULT_COLOR } from "@cocalc/frontend/users/store";
19
import { webapp_client } from "@cocalc/frontend/webapp-client";
20
import { service2model } from "@cocalc/util/db-schema/llm-utils";
21
import { ensure_bound, startswith, trunc_middle } from "@cocalc/util/misc";
22
import { avatar_fontcolor } from "./font-color";
23
24
const CIRCLE_OUTER_STYLE: CSSProperties = {
25
textAlign: "center",
26
cursor: "pointer",
27
} as const;
28
29
const CIRCLE_INNER_STYLE: CSSProperties = {
30
display: "block",
31
borderRadius: "50%",
32
fontFamily: "sans-serif",
33
} as const;
34
35
interface Props {
36
account_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]".
37
size?: number; // in pixels
38
max_age_s?: number; // if given fade the avatar out over time.
39
project_id?: string; // if given, showing avatar info for a project (or specific file)
40
path?: string; // if given, showing avatar for a specific file
41
42
// if given; is most recent activity
43
activity?: { project_id: string; path: string; last_used: Date };
44
// When defined, fade out over time; click goes to that file.
45
no_tooltip?: boolean; // if true, do not show a tooltip with full name info
46
no_loading?: boolean; // if true, do not show a loading indicator (show nothing)
47
48
first_name?: string; // optional name to use
49
last_name?: string;
50
style?: CSSProperties;
51
}
52
53
export function Avatar(props) {
54
if (isChatBot(props.account_id)) {
55
return (
56
<LanguageModelVendorAvatar
57
model={service2model(props.account_id)}
58
size={props.size ?? 30}
59
style={props.style}
60
/>
61
);
62
} else {
63
return <Avatar0 {...props} />;
64
}
65
}
66
67
const Avatar0: React.FC<Props> = (props) => {
68
// we use the user_map to display the username and face:
69
const user_map = useTypedRedux("users", "user_map");
70
const [image, set_image] = useState<string | undefined>(undefined);
71
const [background_color, set_background_color] =
72
useState<string>(DEFAULT_COLOR);
73
74
useAsyncEffect(
75
async (isMounted) => {
76
if (!props.account_id) return;
77
const image = await redux.getStore("users").get_image(props.account_id);
78
if (isMounted()) {
79
if (startswith(image, "https://api.adorable.io")) {
80
// Adorable is gone -- see https://github.com/sagemathinc/cocalc/issues/5054
81
set_image(undefined);
82
} else {
83
set_image(image);
84
}
85
}
86
const background_color = await redux
87
.getStore("users")
88
.get_color(props.account_id);
89
if (isMounted()) {
90
set_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/5013
94
[props.account_id, user_map.getIn([props.account_id, "profile"])],
95
);
96
97
function click_avatar() {
98
if (props.activity == null) {
99
return;
100
}
101
const { project_id, path } = props.activity;
102
switch (viewing_what()) {
103
case "projects":
104
redux.getActions("projects").open_project({
105
project_id,
106
target: "files",
107
switch_to: true,
108
});
109
return;
110
case "project":
111
redux.getProjectActions(project_id).open_file({ path });
112
return;
113
case "file":
114
const actions = redux.getEditorActions(project_id, path);
115
// actions could be undefined, if file is closed
116
const gotoUser = actions?.["gotoUser"];
117
if (gotoUser != null) {
118
// This is at least implemented for the whiteboard (which doesn't
119
// have a good notion of lines), but should be done more
120
// generally, replacing the stuff below about cursor_line...
121
gotoUser(props.account_id);
122
return;
123
}
124
var line = get_cursor_line();
125
if (line != null) {
126
redux.getProjectActions(project_id).goto_line(path, line);
127
}
128
return;
129
}
130
}
131
132
function letter() {
133
if (props.first_name) {
134
return props.first_name.toUpperCase()[0];
135
}
136
if (!props.account_id) return "?";
137
const first_name = user_map.getIn([props.account_id, "first_name"]);
138
if (first_name) {
139
return first_name.toUpperCase()[0];
140
} else {
141
return "?";
142
}
143
}
144
145
function get_name() {
146
if (props.first_name != null || props.last_name != null) {
147
return trunc_middle(
148
`${props.first_name ?? ""} ${props.last_name ?? ""}`.trim(),
149
30,
150
);
151
}
152
if (!props.account_id) return "Unknown";
153
return trunc_middle(
154
redux.getStore("users").get_name(props.account_id)?.trim(),
155
30,
156
);
157
}
158
159
function viewing_what() {
160
if (props.path != null && props.project_id != null) {
161
return "file";
162
} else if (props.project_id != null) {
163
return "project";
164
} else {
165
return "projects";
166
}
167
}
168
169
function render_line() {
170
if (props.activity == null) {
171
return;
172
}
173
const line = get_cursor_line();
174
if (line != null) {
175
return (
176
<span>
177
<Gap /> (Line {line})
178
</span>
179
);
180
}
181
}
182
183
function get_cursor_line() {
184
if (props.activity == null || props.account_id == null) {
185
return;
186
}
187
const { project_id, path } = props.activity;
188
let cursors = redux
189
.getProjectStore(project_id)
190
.get_users_cursors(path, props.account_id);
191
if (cursors == null) {
192
return;
193
}
194
// TODO -- will just assume immutable.js when react/typescript rewrite is done.
195
if (cursors.toJS != null) {
196
cursors = cursors.toJS();
197
}
198
const line = cursors[0] != null ? cursors[0]["y"] : undefined;
199
if (line != null) {
200
return line + 1;
201
} else {
202
return undefined;
203
}
204
}
205
206
function render_tooltip_content() {
207
const name = get_name();
208
if (props.activity == null) {
209
return <span>{name}</span>;
210
}
211
switch (viewing_what()) {
212
case "projects":
213
return (
214
<span>
215
{name} last seen at{" "}
216
<ProjectTitle project_id={props.activity.project_id} />
217
</span>
218
);
219
case "project":
220
return (
221
<span>
222
{name} last seen at {props.activity.path}
223
</span>
224
);
225
case "file":
226
return (
227
<span>
228
{name} {render_line()}
229
</span>
230
);
231
}
232
}
233
234
function render_inside() {
235
if (image) {
236
return <img style={{ borderRadius: "50%", width: "100%" }} src={image} />;
237
} else {
238
return render_letter();
239
}
240
}
241
242
function render_letter() {
243
const color = avatar_fontcolor(background_color);
244
const style = {
245
backgroundColor: background_color, // the onecolor library doesn't provide magenta in some browsers
246
color,
247
};
248
return <span style={{ ...style, ...CIRCLE_INNER_STYLE }}>{letter()}</span>;
249
}
250
251
const { max_age_s = 600 } = props;
252
253
function fade() {
254
if (props.activity == null || !max_age_s) {
255
return 1;
256
}
257
const { last_used } = props.activity;
258
// don't fade out completely as then just see an empty face, which looks broken...
259
return ensure_bound(
260
1 -
261
(webapp_client.server_time().valueOf() - last_used.valueOf()) /
262
(max_age_s * 1000),
263
0,
264
0.85,
265
);
266
}
267
268
const { size = 30 } = props;
269
if (size == null) {
270
throw Error("bug");
271
}
272
const outer_style = {
273
height: `${size}px`,
274
width: `${size}px`,
275
lineHeight: `${size}px`,
276
fontSize: `${0.7 * size}px`,
277
opacity: fade(),
278
};
279
280
const elt = (
281
<div style={{ display: "inline-block", cursor: "pointer", ...props.style }}>
282
<div
283
style={{ ...outer_style, ...CIRCLE_OUTER_STYLE }}
284
onClick={click_avatar}
285
>
286
{render_inside()}
287
</div>
288
</div>
289
);
290
if (props.no_tooltip) {
291
return elt;
292
} else {
293
return <Tooltip title={render_tooltip_content()}>{elt}</Tooltip>;
294
}
295
};
296
297
298