feat: Base implementation of v2 of avatars (#12369)

* feat: Base implementation of v2 of avatars

* Make avatarUrl and logoUrl entirely optional

* Made necessary backwards compat changes

* fix: type errors

* Fix: OG image

* fix types

* Consistency with other behaviour, ux tweak

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Alex van Andel 2023-11-20 12:49:38 +00:00 committed by GitHub
parent c55b36f235
commit 4f26ca1a7b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 258 additions and 69 deletions

View File

@ -14,6 +14,6 @@ type UserAvatarProps = Omit<React.ComponentProps<typeof Avatar>, "alt" | "imageS
* It is aware of the user's organization to correctly show the avatar from the correct URL
*/
export function UserAvatar(props: UserAvatarProps) {
const { user, previewSrc, ...rest } = props;
return <Avatar {...rest} alt={user.name || ""} imageSrc={previewSrc ?? getUserAvatarUrl(user)} />;
const { user, previewSrc = getUserAvatarUrl(user), ...rest } = props;
return <Avatar {...rest} alt={user.name || "Nameless User"} imageSrc={previewSrc} />;
}

View File

@ -82,7 +82,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
description={markdownStrippedBio}
meeting={{
title: markdownStrippedBio,
profile: { name: `${profile.name}`, image: null },
profile: { name: `${profile.name}`, image: user.avatarUrl || null },
users: [{ username: `${user.username}`, name: `${user.name}` }],
}}
nextSeoProps={{
@ -245,7 +245,7 @@ export type UserPageProps = {
allowSEOIndexing: boolean;
username: string | null;
};
users: Pick<User, "away" | "name" | "username" | "bio" | "verified">[];
users: Pick<User, "away" | "name" | "username" | "bio" | "verified" | "avatarUrl">[];
themeBasis: string | null;
markdownStrippedBio: string;
safeBio: string;
@ -295,6 +295,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
metadata: true,
brandColor: true,
darkBrandColor: true,
avatarUrl: true,
organizationId: true,
organization: {
select: {
@ -363,6 +364,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
image: user.avatar,
theme: user.theme,
brandColor: user.brandColor,
avatarUrl: user.avatarUrl,
darkBrandColor: user.darkBrandColor,
allowSEOIndexing: user.allowSEOIndexing ?? true,
username: user.username,
@ -397,6 +399,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
name: user.name,
username: user.username,
bio: user.bio,
avatarUrl: user.avatarUrl,
away: user.away,
verified: user.verified,
})),

View File

@ -0,0 +1,61 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
const querySchema = z.object({
uuid: z.string().transform((objectKey) => objectKey.split(".")[0]),
});
const handleValidationError = (res: NextApiResponse, error: z.ZodError): void => {
const errors = error.errors.map((err) => ({
path: err.path.join("."),
errorCode: `error.validation.${err.code}`,
}));
res.status(400).json({
message: "VALIDATION_ERROR",
errors,
});
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const result = querySchema.safeParse(req.query);
if (!result.success) {
return handleValidationError(res, result.error);
}
const { uuid: objectKey } = result.data;
let img;
try {
const { data } = await prisma.avatar.findUniqueOrThrow({
where: {
objectKey,
},
select: {
data: true,
},
});
img = data;
} catch (e) {
// If anything goes wrong or avatar is not found, use default avatar
res.writeHead(302, {
Location: AVATAR_FALLBACK,
});
res.end();
return;
}
const decoded = img.toString().replace("data:image/png;base64,", "").replace("data:image/jpeg;base64,", "");
const imageResp = Buffer.from(decoded, "base64");
res.writeHead(200, {
"Content-Type": "image/png",
"Content-Length": imageResp.length,
});
res.end(imageResp);
}

View File

@ -9,8 +9,8 @@ import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import checkIfItFallbackImage from "@calcom/lib/checkIfItFallbackImage";
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import turndown from "@calcom/lib/turndownService";
@ -82,19 +82,12 @@ const ProfileView = () => {
const { t } = useLocale();
const utils = trpc.useContext();
const { update } = useSession();
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const [fetchedImgSrc, setFetchedImgSrc] = useState<string>("");
const { data: user, isLoading } = trpc.viewer.me.useQuery(undefined, {
onSuccess: async (userData) => {
try {
const res = await fetch(userData.avatar);
if (res.url) setFetchedImgSrc(res.url);
} catch (err) {
setFetchedImgSrc("");
}
},
const { data: avatarData } = trpc.viewer.avatar.useQuery(undefined, {
enabled: !isLoading && !user?.avatarUrl,
});
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async (res) => {
await update(res);
@ -226,7 +219,7 @@ const ProfileView = () => {
const defaultValues = {
username: user.username || "",
avatar: fetchedImgSrc || "",
avatar: getUserAvatarUrl(user),
name: user.name || "",
email: user.email || "",
bio: user.bio || "",
@ -243,6 +236,7 @@ const ProfileView = () => {
key={JSON.stringify(defaultValues)}
defaultValues={defaultValues}
isLoading={updateProfileMutation.isLoading}
isFallbackImg={!user.avatarUrl && !avatarData?.avatar}
user={user}
userOrganization={user.organization}
onSubmit={(values) => {
@ -387,6 +381,7 @@ const ProfileForm = ({
onSubmit,
extraField,
isLoading = false,
isFallbackImg,
user,
userOrganization,
}: {
@ -394,6 +389,7 @@ const ProfileForm = ({
onSubmit: (values: FormValues) => void;
extraField?: React.ReactNode;
isLoading: boolean;
isFallbackImg: boolean;
user: RouterOutputs["viewer"]["me"];
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
}) => {
@ -432,7 +428,7 @@ const ProfileForm = ({
control={formMethods.control}
name="avatar"
render={({ field: { value } }) => {
const showRemoveAvatarButton = !checkIfItFallbackImage(value);
const showRemoveAvatarButton = value === null ? false : !isFallbackImg;
const organization =
userOrganization && userOrganization.id
? {

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@ -0,0 +1,56 @@
import { expect } from "@playwright/test";
import path from "path";
import { prisma } from "@calcom/prisma";
import { test } from "../lib/fixtures";
test.describe("UploadAvatar", async () => {
test("can upload an image", async ({ page, users }) => {
const user = await users.create({});
await user.apiLogin();
await test.step("Can upload an initial picture", async () => {
await page.goto("/settings/my-account/profile");
await page.getByTestId("open-upload-avatar-dialog").click();
const [fileChooser] = await Promise.all([
// It is important to call waitForEvent before click to set up waiting.
page.waitForEvent("filechooser"),
// Opens the file chooser.
page.getByTestId("open-upload-image-filechooser").click(),
]);
await fileChooser.setFiles(`${path.dirname(__filename)}/../fixtures/cal.png`);
await page.getByTestId("upload-avatar").click();
await page.locator("input[name='name']").fill(user.email);
await page.getByText("Update").click();
await page.waitForSelector("text=Settings updated successfully");
const response = await prisma.avatar.findUniqueOrThrow({
where: {
teamId_userId: {
userId: user.id,
teamId: 0,
},
},
});
// todo: remove this; ideally the organization-avatar is updated the moment
// 'Settings updated succesfully' is saved.
await page.waitForLoadState("networkidle");
await expect(await page.getByTestId("organization-avatar").innerHTML()).toContain(response.objectKey);
const urlResponse = await page.request.get(`/api/avatar/${response.objectKey}.png`, {
maxRedirects: 0,
});
await expect(urlResponse?.status()).toBe(200);
});
});
});

View File

@ -268,6 +268,7 @@ function getTrpcHandlerData({
user: {
...getSampleUserInSession(),
...user,
avatarUrl: user.avatarUrl || null,
} satisfies TrpcSessionUser,
},
input: input,

View File

@ -10,6 +10,7 @@ import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { HOSTED_CAL_FEATURES, WEBAPP_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { IdentityProvider, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
@ -145,7 +146,7 @@ const useTabs = () => {
if (tab.href === "/settings/my-account") {
tab.name = user?.name || "my_account";
tab.icon = undefined;
tab.avatar = `${orgBranding?.fullDomain ?? WEBAPP_URL}/${session?.data?.user?.username}/avatar.png`;
tab.avatar = getUserAvatarUrl(user);
} else if (tab.href === "/settings/organizations") {
tab.name = orgBranding?.name || "organization";
tab.avatar = `${orgBranding?.fullDomain}/org/${orgBranding?.slug}/avatar.png`;

View File

@ -6,19 +6,28 @@ import type { User, Team } from "@calcom/prisma/client";
* Gives an organization aware avatar url for a user
* It ensures that the wrong avatar isn't fetched by ensuring that organizationId is always passed
*/
export const getUserAvatarUrl = (user: Pick<User, "username" | "organizationId">) => {
if (!user.username) return AVATAR_FALLBACK;
export const getUserAvatarUrl = (
user: (Pick<User, "username" | "organizationId"> & { avatarUrl?: string | null }) | undefined
) => {
if (user?.avatarUrl) {
return user.avatarUrl;
}
if (!user?.username) return AVATAR_FALLBACK;
// avatar.png automatically redirects to fallback avatar if user doesn't have one
return `${WEBAPP_URL}/${user.username}/avatar.png${
user.organizationId ? `?orgId=${user.organizationId}` : ""
}`;
};
export const getOrgAvatarUrl = (org: {
id: Team["id"];
slug: Team["slug"];
requestedSlug: string | null;
}) => {
export const getOrgAvatarUrl = (
org: Pick<Team, "id" | "slug"> & {
logoUrl?: string | null;
requestedSlug: string | null;
}
) => {
if (org.logoUrl) {
return org.logoUrl;
}
const slug = org.slug ?? org.requestedSlug;
return `${WEBAPP_URL}/org/${slug}/avatar.png`;
};

View File

@ -191,6 +191,7 @@ export const buildUser = <T extends Partial<UserPayload>>(user?: T): UserPayload
allowDynamicBooking: true,
availability: [],
avatar: "",
avatarUrl: "",
away: false,
backupCodes: null,
bio: null,

View File

@ -0,0 +1,19 @@
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "logoUrl" TEXT;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "avatarUrl" TEXT;
-- CreateTable
CREATE TABLE "avatars" (
"teamId" INTEGER NOT NULL DEFAULT 0,
"userId" INTEGER NOT NULL DEFAULT 0,
"data" TEXT NOT NULL,
"objectKey" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "avatars_objectKey_key" ON "avatars"("objectKey");
-- CreateIndex
CREATE UNIQUE INDEX "avatars_teamId_userId_key" ON "avatars"("teamId", "userId");

View File

@ -192,6 +192,7 @@ model User {
password String?
bio String?
avatar String?
avatarUrl String?
timeZone String @default("Europe/London")
weekStart String @default("Sunday")
// DEPRECATED - TO BE REMOVED
@ -279,6 +280,7 @@ model Team {
/// @zod.min(1)
slug String?
logo String?
logoUrl String?
appLogo String?
appIconLogo String?
bio String?
@ -1011,3 +1013,17 @@ model TempOrgRedirect {
@@unique([from, type, fromOrgId])
}
model Avatar {
// e.g. NULL(0), organization ID or team logo
teamId Int @default(0)
// Avatar, NULL(0) if team logo
userId Int @default(0)
// base64 string
data String
// different every time to pop the cache.
objectKey String @unique
@@unique([teamId, userId])
@@map(name: "avatars")
}

View File

@ -30,6 +30,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
email: true,
emailVerified: true,
bio: true,
avatarUrl: true,
timeZone: true,
weekStart: true,
startTime: true,

View File

@ -1,3 +1,4 @@
import prisma from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
type AvatarOptions = {
@ -7,7 +8,15 @@ type AvatarOptions = {
};
export const avatarHandler = async ({ ctx }: AvatarOptions) => {
const data = await prisma.user.findUnique({
where: {
id: ctx.user.id,
},
select: {
avatar: true,
},
});
return {
avatar: ctx.user.avatar,
avatar: data?.avatar,
};
};

View File

@ -25,6 +25,7 @@ export const meHandler = async ({ ctx }: MeOptions) => {
timeFormat: user.timeFormat,
timeZone: user.timeZone,
avatar: getUserAvatarUrl(user),
avatarUrl: user.avatarUrl,
createdDate: user.createdDate,
trialEndsAt: user.trialEndsAt,
defaultScheduleId: user.defaultScheduleId,

View File

@ -1,5 +1,6 @@
import type { Prisma } from "@prisma/client";
import type { GetServerSidePropsContext, NextApiResponse } from "next";
import { v4 as uuidv4 } from "uuid";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { getPremiumPlanProductId } from "@calcom/app-store/stripepayment/lib/utils";
@ -31,12 +32,35 @@ type UpdateProfileOptions = {
input: TUpdateProfileInputSchema;
};
const uploadAvatar = async ({ userId, avatar: data }: { userId: number; avatar: string }) => {
const objectKey = uuidv4();
await prisma.avatar.upsert({
where: {
teamId_userId: {
teamId: 0,
userId,
},
},
create: {
userId: userId,
data,
objectKey,
},
update: {
data,
objectKey,
},
});
return `/api/avatar/${objectKey}.png`;
};
export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) => {
const { user } = ctx;
const userMetadata = handleUserMetadata({ ctx, input });
const data: Prisma.UserUpdateInput = {
...input,
avatar: input.avatar ? await getAvatarToSet(input.avatar) : null,
metadata: userMetadata,
};
@ -114,6 +138,15 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
// when the email changes, the user needs to sign in again.
signOutUser = true;
}
// don't do anything if avatar is undefined.
if (typeof input.avatar !== "undefined") {
data.avatarUrl = input.avatar
? await uploadAvatar({
avatar: await resizeBase64Image(input.avatar),
userId: user.id,
})
: null;
}
const updatedUser = await prisma.user.update({
where: {
@ -129,6 +162,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
metadata: true,
name: true,
createdDate: true,
avatarUrl: true,
locale: true,
schedules: {
select: {
@ -186,28 +220,11 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
},
});
}
// Revalidate booking pages
// Disabled because the booking pages are currently not using getStaticProps
/*const res = ctx.res as NextApiResponse;
if (typeof res?.revalidate !== "undefined") {
const eventTypes = await prisma.eventType.findMany({
where: {
userId: user.id,
team: null,
},
select: {
id: true,
slug: true,
},
});
// waiting for this isn't needed
Promise.all(
eventTypes.map((eventType) => res?.revalidate(`/new-booker/${ctx.user.username}/${eventType.slug}`))
)
.then(() => console.info("Booking pages revalidated"))
.catch((e) => console.error(e));
}*/
return { ...input, signOutUser, passwordReset };
// don't return avatar, we don't need it anymore.
delete input.avatar;
return { ...input, signOutUser, passwordReset, avatarUrl: updatedUser.avatarUrl };
};
const cleanMetadataAllowedUpdateKeys = (metadata: TUpdateProfileInputSchema["metadata"]) => {
@ -230,17 +247,3 @@ const handleUserMetadata = ({ ctx, input }: UpdateProfileOptions) => {
// Required so we don't override and delete saved values
return { ...userMetadata, ...cleanMetadata };
};
async function getAvatarToSet(avatar: string | null | undefined) {
if (avatar === null || avatar === undefined) {
return avatar;
}
if (!avatar.startsWith("data:image")) {
// Non Base64 avatar currently could only be the dynamic avatar URL(i.e. /{USER}/avatar.png). If we allow setting that URL, we would get infinite redirects on /user/avatar.ts endpoint
log.warn("Non Base64 avatar, ignored it", { avatar });
// `undefined` would not ignore the avatar, but `null` would remove it. So, we return `undefined` here.
return undefined;
}
return await resizeBase64Image(avatar);
}

View File

@ -177,6 +177,7 @@ export const DialogTrigger = DialogPrimitive.Trigger;
export function DialogClose(
props: {
"data-testid"?: string;
dialogCloseProps?: React.ComponentProps<(typeof DialogPrimitive)["Close"]>;
children?: ReactNode;
onClick?: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
@ -188,7 +189,10 @@ export function DialogClose(
return (
<DialogPrimitive.Close asChild {...props.dialogCloseProps}>
{/* This will require the i18n string passed in */}
<Button data-testid="dialog-rejection" color={props.color || "minimal"} {...props}>
<Button
data-testid={props["data-testid"] || "dialog-rejection"}
color={props.color || "minimal"}
{...props}>
{props.children ? props.children : t("Close")}
</Button>
</DialogPrimitive.Close>

View File

@ -170,7 +170,11 @@ export default function ImageUploader({
}
}}>
<DialogTrigger asChild>
<Button color={triggerButtonColor ?? "secondary"} type="button" className="py-1 text-sm">
<Button
color={triggerButtonColor ?? "secondary"}
type="button"
data-testid="open-upload-avatar-dialog"
className="py-1 text-sm">
{buttonMsg}
</Button>
</DialogTrigger>
@ -190,7 +194,9 @@ export default function ImageUploader({
</div>
)}
{result && <CropContainer imageSrc={result as string} onCropComplete={setCroppedAreaPixels} />}
<label className="bg-subtle hover:bg-muted hover:text-emphasis border-subtle text-default mt-8 rounded-sm border px-3 py-1 text-xs font-medium leading-4 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
<label
data-testid="open-upload-image-filechooser"
className="bg-subtle hover:bg-muted hover:text-emphasis border-subtle text-default mt-8 rounded-sm border px-3 py-1 text-xs font-medium leading-4 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
<input
onInput={onInputFile}
type="file"
@ -205,8 +211,10 @@ export default function ImageUploader({
</div>
<DialogFooter className="relative">
<DialogClose color="minimal">{t("cancel")}</DialogClose>
<DialogClose color="primary" onClick={() => showCroppedImage(croppedAreaPixels)}>
<DialogClose
data-testid="upload-avatar"
color="primary"
onClick={() => showCroppedImage(croppedAreaPixels)}>
{t("save")}
</DialogClose>
</DialogFooter>