Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"date-fns": "^4.1.0",
"dompurify": "^3.3.1",
"effect": "^3.19.16",
"facehash": "^0.0.7",
"json5": "^2.2.3",
"lucide-react": "^0.544.0",
"motion": "^11.18.2",
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/auth/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ type AuthTokenHandlers = {

type AuthUtils = {
getHeaders: () => Record<string, string> | null;
getAvatarUrl: () => Promise<string>;
getAvatarUrl: () => Promise<string | null>;
};

export type AuthContextType = AuthState &
Expand Down Expand Up @@ -282,7 +282,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const email = session?.user.email;

if (!email) {
return "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23e0e0e0'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='48' fill='%23666'%3E%3F%3C/text%3E%3C/svg%3E";
return null;
}

const address = email.trim().toLowerCase();
Expand All @@ -292,7 +292,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hash = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");

return `https://gravatar.com/avatar/${hash}`;
return `https://gravatar.com/avatar/${hash}?d=404`;
}, [session]);

const value = useMemo(
Expand Down
30 changes: 17 additions & 13 deletions apps/desktop/src/components/main/body/contacts/details.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Facehash } from "facehash";
import {
Building2,
CircleMinus,
Expand All @@ -17,7 +18,6 @@ import {
import { Textarea } from "@hypr/ui/components/ui/textarea";

import * as main from "../../../../store/tinybase/store/main";
import { getInitials } from "./shared";

export function DetailsColumn({
selectedHumanId,
Expand Down Expand Up @@ -211,13 +211,16 @@ export function DetailsColumn({
<>
<div className="px-6 py-4 border-b border-neutral-200">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-full bg-neutral-200 flex items-center justify-center">
<span className="text-lg font-medium text-neutral-600">
{getInitials(
selectedPersonData.name || selectedPersonData.email,
)}
</span>
</div>
<Facehash
name={String(
selectedPersonData.name ||
selectedPersonData.email ||
selectedHumanId,
)}
size={48}
interactive={false}
showInitial={false}
/>
<div className="flex-1">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
Expand Down Expand Up @@ -264,11 +267,12 @@ export function DetailsColumn({
className="flex items-center justify-between p-2 bg-neutral-50 rounded-md border border-neutral-200"
>
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-neutral-200 flex items-center justify-center">
<span className="text-xs font-medium text-neutral-600">
{getInitials(dup.name || dup.email)}
</span>
</div>
<Facehash
name={String(dup.name || dup.email || dup.id)}
size={32}
interactive={false}
showInitial={false}
/>
<div>
<div className="text-sm font-medium text-neutral-900">
{dup.name || "Unnamed Contact"}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Icon } from "@iconify-icon/react";
import { Facehash } from "facehash";
import { Building2, Mail } from "lucide-react";

import { commands as openerCommands } from "@hypr/plugin-opener2";
import { Button } from "@hypr/ui/components/ui/button";
import { Input } from "@hypr/ui/components/ui/input";

import * as main from "../../../../store/tinybase/store/main";
import { getInitials } from "./shared";

export function OrganizationDetailsColumn({
selectedOrganizationId,
Expand Down Expand Up @@ -77,14 +77,14 @@ export function OrganizationDetailsColumn({
onClick={() => onPersonClick?.(humanId)}
>
<div className="flex flex-col items-center text-center gap-3">
<div className="w-12 h-12 rounded-full bg-neutral-200 flex items-center justify-center shrink-0">
<span className="text-sm font-medium text-neutral-600">
{getInitials(
(human.name as string) ||
(human.email as string),
)}
</span>
</div>
<Facehash
name={String(
human.name || human.email || humanId,
)}
size={48}
interactive={false}
showInitial={false}
/>
<div className="w-full">
<div className="font-semibold text-sm truncate">
{human.name || human.email || "Unnamed"}
Expand Down
14 changes: 8 additions & 6 deletions apps/desktop/src/components/main/body/contacts/people.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Facehash } from "facehash";
import { CornerDownLeft, Pin } from "lucide-react";
import { Reorder } from "motion/react";
import React, { useCallback, useMemo, useState } from "react";

import { cn } from "@hypr/utils";

import * as main from "../../../../store/tinybase/store/main";
import { ColumnHeader, getInitials, type SortOption } from "./shared";
import { ColumnHeader, type SortOption } from "./shared";

export function PeopleColumn({
currentOrgId,
Expand Down Expand Up @@ -251,11 +252,12 @@ function PersonItem({
active ? "border-neutral-500 bg-neutral-100" : "border-transparent",
])}
>
<div className="shrink-0 w-8 h-8 rounded-full bg-neutral-200 flex items-center justify-center">
<span className="text-xs font-medium text-neutral-600">
{getInitials(personName || personEmail)}
</span>
</div>
<Facehash
name={personName || personEmail || humanId}
size={32}
interactive={false}
showInitial={false}
/>
<div className="flex-1 min-w-0">
<div className="font-medium truncate flex items-center gap-1">
{personName || personEmail || "Unnamed"}
Expand Down
25 changes: 20 additions & 5 deletions apps/desktop/src/components/main/sidebar/profile/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { Facehash } from "facehash";
import {
CalendarIcon,
ChevronUpIcon,
Expand All @@ -10,7 +11,7 @@ import {
UsersIcon,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useResizeObserver } from "usehooks-ts";

import { Kbd } from "@hypr/ui/components/ui/kbd";
Expand Down Expand Up @@ -288,6 +289,7 @@ function ProfileButton({
}) {
const auth = useAuth();
const name = useMyName(auth?.session?.user.email);
const [imgError, setImgError] = useState(false);

const profile = useQuery({
queryKey: ["profile"],
Expand All @@ -297,6 +299,13 @@ function ProfileButton({
},
});

const facehashName = useMemo(
() => auth?.session?.user.email || name || "user",
[auth?.session?.user.email, name],
);

const showFacehash = !profile.data || imgError;
Comment on lines 299 to +307
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 imgError state is never reset, permanently hiding Gravatar after any load failure

Once the Gravatar image fails to load (e.g., transient network error, or 404 from ?d=404), setImgError(true) is called at line 341, and imgError is never reset back to false. The showFacehash flag at line 307 (!profile.data || imgError) will then always be true, meaning the <img> element is never rendered again — so even if React Query refetches the profile URL (e.g., on window refocus), the Gravatar can never recover.

Root Cause and Impact

The imgError state at apps/desktop/src/components/main/sidebar/profile/index.tsx:292 is set to true on image load failure but has no mechanism to reset. Since showFacehash at line 307 short-circuits to true when imgError is set, the <img> tag is conditionally excluded from the DOM, preventing any future load attempt.

Scenario: A user has a valid Gravatar, but the image fails to load due to a transient network issue. After the network recovers, the Facehash avatar is permanently shown instead of the Gravatar for the lifetime of the component.

Fix: Reset imgError to false when profile.data changes, e.g., with a useEffect that watches profile.data.

(Refers to lines 292-307)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


return (
<button
className={cn([
Expand All @@ -313,17 +322,23 @@ function ProfileButton({
className={cn([
"flex size-8 shrink-0 items-center justify-center",
"overflow-hidden rounded-full",
"border border-t border-neutral-400",
"bg-linear-to-br from-indigo-400 to-purple-500",
"shadow-xs",
"transition-transform duration-300",
])}
>
{profile.data && (
{showFacehash ? (
<Facehash
name={facehashName}
size={32}
interactive={false}
showInitial={false}
/>
) : (
<img
src={profile.data}
src={profile.data!}
alt="Profile"
className="h-full w-full rounded-full"
onError={() => setImgError(true)}
/>
)}
</div>
Expand Down
17 changes: 7 additions & 10 deletions apps/desktop/src/components/main/sidebar/search/item.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import DOMPurify from "dompurify";
import { Facehash } from "facehash";
import { useCallback, useMemo } from "react";

import { cn } from "@hypr/utils";

import { type SearchResult } from "../../../../contexts/search/ui";
import * as main from "../../../../store/tinybase/store/main";
import { type TabInput, useTabs } from "../../../../store/zustand/tabs";
import { getInitials } from "../../body/contacts/shared";

export function SearchResultItem({
result,
Expand Down Expand Up @@ -88,15 +88,12 @@ function HumanSearchResultItem({
isSelected && "bg-neutral-100",
])}
>
<div
className={cn([
"shrink-0 w-8 h-8 rounded-full bg-neutral-200 flex items-center justify-center",
])}
>
<span className={cn(["text-xs font-medium text-neutral-600"])}>
{getInitials(result.title)}
</span>
</div>
<Facehash
name={result.title || result.id}
size={32}
interactive={false}
showInitial={false}
/>
<div className={cn(["flex-1 min-w-0"])}>
<div
className={cn([
Expand Down
23 changes: 23 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading