The GeoCities marquee stops only for a link the keyboard focused: a link pressed with the pointer took the focus as well, which put the line back at its start and scrolled it, so the link slipped from under the pointer before the button came up and the click was lost. The next page's marquee also carries on from the very point the last one had reached, with the loop it showed: it worked its place out from when the loop began, which ran on while the crawl was paused under the pointer, and showed the feed as it was by then, of another width. The chat's message field rests on one line, as tall as the Send button: it rested on two, 54 to 62 pixels against the button's 38, and the button sat low beside it. Both take --chat-composer-height. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
6.2 KiB
TypeScript
189 lines
6.2 KiB
TypeScript
/**
|
|
* Renders the header of a user's profile, where the signed-in user also edits
|
|
* their own avatar and email.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { useRef, useState } from "react";
|
|
import { Link } from "react-router";
|
|
import { t } from "@lingui/core/macro";
|
|
import { Trans } from "@lingui/react/macro";
|
|
import { API_URL } from "../../config/api.ts";
|
|
import type { Wire } from "../../../shared/api.ts";
|
|
import type {
|
|
PublicUser,
|
|
SelfUser,
|
|
UpdateUserRequest,
|
|
} from "../../../shared/user.ts";
|
|
import { expectOk, parseAPIResponse } from "../../utils/shared/api.ts";
|
|
import { deserializeSelfUser } from "../../utils/shared/deserialize.ts";
|
|
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
|
import { Avatar } from "../shared/Avatar.tsx";
|
|
import { ErrorCard } from "../shared/ErrorCard.tsx";
|
|
import { FollowUserButton } from "../social/FollowButton.tsx";
|
|
import { InlineEdit } from "../form/InlineEdit.tsx";
|
|
import { ProfileOwnActions } from "./ProfileOwnActions.tsx";
|
|
import { RoleChip } from "./RoleSwitcher.tsx";
|
|
import { userPath } from "../../../shared/paths.ts";
|
|
import { splitHandle } from "../../../shared/validation.ts";
|
|
import { homeUrl } from "../../utils/auth/home.ts";
|
|
|
|
/** Passed to {@link ProfileHeader} by `UserPublicProfile`, once loaded. */
|
|
export interface ProfileHeaderProps {
|
|
user: PublicUser;
|
|
isOwnProfile: boolean;
|
|
/** Called with the user as a new avatar left them. */
|
|
onUserChange: (user: PublicUser) => void;
|
|
}
|
|
|
|
/**
|
|
* Renders the avatar, username, role and inviter, and for a user from another
|
|
* instance, a link to their profile there. Visitors get a follow button. On
|
|
* their own profile, users change their avatar (`PUT /api/me/avatar`) and
|
|
* email (`PATCH /api/me`), unless their account comes from another instance, and invite
|
|
* someone; their settings, the site's tools and logging out are in the
|
|
* header's avatar menu.
|
|
*/
|
|
export function ProfileHeader(
|
|
{ user, isOwnProfile, onUserChange }: ProfileHeaderProps,
|
|
): React.JSX.Element {
|
|
const { user: me, authFetch, updateSession } = useAuth();
|
|
const [uploading, setUploading] = useState(false);
|
|
const [avatarError, setAvatarError] = useState<string | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const { name, host } = splitHandle(user.username);
|
|
// A user from another instance changes their avatar and email there.
|
|
const editable = isOwnProfile && !host;
|
|
|
|
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setAvatarError(null);
|
|
setUploading(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
const res = await authFetch(`${API_URL}/api/me/avatar`, {
|
|
method: "PUT",
|
|
body: formData,
|
|
});
|
|
const body = parseAPIResponse<Wire<SelfUser>>(await res.json());
|
|
if (!body.success) {
|
|
setAvatarError(body.error.message);
|
|
return;
|
|
}
|
|
const self = deserializeSelfUser(body.data);
|
|
updateSession({ user: self });
|
|
onUserChange(self);
|
|
} catch {
|
|
setAvatarError(t`Upload failed`);
|
|
} finally {
|
|
setUploading(false);
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
}
|
|
};
|
|
|
|
const handleEmailSave = async (email: string) => {
|
|
await expectOk<Wire<PublicUser>>(
|
|
await authFetch(`${API_URL}/api/me`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email } satisfies UpdateUserRequest),
|
|
}),
|
|
);
|
|
if (me) updateSession({ user: { ...me, email } });
|
|
};
|
|
|
|
return (
|
|
<div className="profile-header">
|
|
<div className="profile-avatar-wrapper">
|
|
<Avatar
|
|
userId={user.id}
|
|
username={user.username}
|
|
hasAvatar={!!user.avatarMime}
|
|
size={128}
|
|
version={user.updatedAt?.getTime()}
|
|
/>
|
|
{editable && (
|
|
<label className="avatar-change-overlay" title={t`Change avatar`}>
|
|
{uploading ? "…" : "✎"}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/gif,image/webp"
|
|
onChange={handleAvatarUpload}
|
|
disabled={uploading}
|
|
style={{ display: "none" }}
|
|
/>
|
|
</label>
|
|
)}
|
|
</div>
|
|
<div className="profile-info">
|
|
<div className="profile-username-row">
|
|
<h1 className="profile-username">
|
|
{name}
|
|
{host && <span className="profile-username-host">@{host}</span>}
|
|
</h1>
|
|
<RoleChip role={user.role} />
|
|
</div>
|
|
{user.invitedByUsername
|
|
? (
|
|
<p className="profile-invited-by">
|
|
<Trans>invited by</Trans>{" "}
|
|
<Link
|
|
to={userPath(user.invitedByUsername)}
|
|
className="profile-invited-by-link"
|
|
>
|
|
@{user.invitedByUsername}
|
|
</Link>
|
|
</p>
|
|
)
|
|
// Without an inviter, a local user was here first; one from a
|
|
// trusted instance just came in.
|
|
: !host && (
|
|
<p className="profile-invited-by profile-invited-by--founding">
|
|
O.G.
|
|
</p>
|
|
)}
|
|
{host && (
|
|
<p className="profile-home">
|
|
<Trans>
|
|
Account on{" "}
|
|
<a href={homeUrl(user.username) ?? undefined}>{host}</a>
|
|
</Trans>
|
|
</p>
|
|
)}
|
|
{editable && (
|
|
<InlineEdit
|
|
value={me?.email ?? ""}
|
|
editable
|
|
label={t`Edit email`}
|
|
onSave={handleEmailSave}
|
|
kind="email"
|
|
required
|
|
className="profile-email"
|
|
>
|
|
<span className="profile-email-text">
|
|
{me?.email ?? t`Add email…`}
|
|
</span>
|
|
</InlineEdit>
|
|
)}
|
|
{avatarError && (
|
|
<ErrorCard
|
|
title={t`Failed to update avatar`}
|
|
message={avatarError}
|
|
/>
|
|
)}
|
|
{!isOwnProfile && (
|
|
<FollowUserButton
|
|
targetUserId={user.id}
|
|
targetUsername={user.username}
|
|
/>
|
|
)}
|
|
{isOwnProfile && <ProfileOwnActions />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|