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