Skip to content

Commit

Permalink
Merge pull request #3344 from tloncorp/james/land-1691-groups-group-i…
Browse files Browse the repository at this point in the history
…mage-broken-if-src-fails-to-load

web/tlon: proper fallbacks for broken-src image avatars (groups, ships, clubs)
  • Loading branch information
jamesacklin authored Mar 18, 2024
2 parents 31c973b + 0d827dd commit c9e2058
Show file tree
Hide file tree
Showing 4 changed files with 63 additions and 59 deletions.
49 changes: 20 additions & 29 deletions apps/tlon-web/src/components/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import classNames from 'classnames';
import { darken, lighten, parseToHsla } from 'color2k';
import _ from 'lodash';
import React, { CSSProperties, useMemo } from 'react';
import { isValidPatp } from 'urbit-ob';
import { isValidPatp, clan as shipType } from 'urbit-ob';

import { isValidUrl, normalizeUrbitColor } from '@/logic/utils';
import { useAvatar } from '@/state/avatar';
Expand Down Expand Up @@ -106,15 +106,15 @@ function Avatar({
const calm = useCalm();
const { previewColor, previewAvatar } = previewData ?? {};
const previewAvatarIsValid = useMemo(
() => previewAvatar && previewAvatar !== null && isValidUrl(previewAvatar),
() => previewAvatar && isValidUrl(previewAvatar),
[previewAvatar]
);
const { color, avatar } = contact || emptyContact;
const { hasLoaded, load } = useAvatar(
(previewAvatarIsValid ? previewAvatar : avatar) || ''
);
const showImage = useMemo(
() => loadImage || hasLoaded,
() => loadImage && hasLoaded,
[loadImage, hasLoaded]
);
const { classes, size: sigilSize } = useMemo(() => sizeMap[size], [size]);
Expand All @@ -132,33 +132,34 @@ function Avatar({
);
const citedShip = useMemo(() => cite(ship), [ship]);
const props: SigilProps = {
point: citedShip || '~zod',
point: citedShip,
size: sigilSize,
detail: icon ? 'none' : 'default',
space: 'none',
background: adjustedColor,
foreground: foregroundColor,
};
const invalidShip = useMemo(
() =>
!ship ||
ship === 'undefined' ||
!isValidPatp(ship) ||
citedShip.match(/[_^]/) ||
citedShip.length > 14,
[ship, citedShip]
() => !isValidPatp(ship) || ['comet', 'moon'].includes(shipType(ship)),
[ship]
);

const shouldShowImage = useMemo(
() =>
showImage &&
previewAvatarIsValid &&
!calm.disableRemoteContent &&
!calm.disableAvatars,
[showImage, previewAvatarIsValid, calm]
!calm.disableAvatars &&
((previewAvatarIsValid && showImage) ||
(!previewAvatarIsValid && avatar && showImage)),
[
previewAvatarIsValid,
showImage,
calm.disableRemoteContent,
calm.disableAvatars,
avatar,
]
);

if (shouldShowImage) {
if (shouldShowImage && previewAvatarIsValid) {
return (
<img
className={classNames(className, classes, 'object-cover')}
Expand All @@ -170,34 +171,24 @@ function Avatar({
);
}

if (
avatar &&
showImage &&
!calm.disableRemoteContent &&
!calm.disableAvatars
) {
if (shouldShowImage && !previewAvatarIsValid) {
return (
<img
className={classNames(className, classes, 'object-cover')}
src={avatar}
src={avatar ?? ''} // Defensively avoid null src
alt=""
style={style}
onLoad={load}
/>
);
}

// Fallback to sigil or other representation if the conditions for showing the image are not met
return (
<div
className={classNames(
'relative flex flex-none items-center justify-center rounded bg-black',
classes,
size === 'sidebar' && 'p-1.5',
size === 'xs' && 'p-1.5',
size === 'small' && 'p-2',
size === 'default' && 'p-3',
size === 'big' && 'p-4',
size === 'huge' && 'p-3',
className
)}
style={{ backgroundColor: adjustedColor, ...style }}
Expand Down
3 changes: 2 additions & 1 deletion apps/tlon-web/src/dms/MultiDmAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export default function MultiDmAvatar({
loadImage = true,
}: MultiDmAvatarProps) {
const { hasLoaded, load } = useAvatar(image || '');
const showImage = hasLoaded || loadImage;
const showImage = loadImage && hasLoaded;

if (image && isColor(image)) {
return (
Expand All @@ -69,6 +69,7 @@ export default function MultiDmAvatar({
);
}

// Fallback if the image isn't available or showImage is false
return (
<div
className={cn(
Expand Down
3 changes: 2 additions & 1 deletion apps/tlon-web/src/groups/GroupAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,11 @@ export default function GroupAvatar({
const calm = useCalm();
const showImage = useMemo(
() =>
loadImage &&
image &&
!calm.disableRemoteContent &&
!imageIsColor &&
(hasLoaded || loadImage),
hasLoaded,
[image, calm.disableRemoteContent, imageIsColor, hasLoaded, loadImage]
);
const dark = useIsDark();
Expand Down
67 changes: 39 additions & 28 deletions apps/tlon-web/src/state/avatar.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,44 @@
import produce from 'immer';
import { useCallback } from 'react';
import create from 'zustand';
import { useCallback, useEffect, useState } from 'react';

interface AvatarStore {
status: Record<string, boolean>;
loaded: (src: string) => void;
}
export function useAvatar(src: string) {
// This hook is used to load an image and determine if it has loaded
const [hasLoaded, setHasLoaded] = useState(false);

const useAvatarStore = create<AvatarStore>((set, get) => ({
status: {},
loaded: (src) => {
set(
produce((draft) => {
draft.status[src] = true;
})
);
},
}));
const load = useCallback(() => {
// Create a new image object
const img = new Image();

export function useAvatar(src: string) {
return useAvatarStore(
useCallback(
(store: AvatarStore) => ({
hasLoaded: store.status[src] || false,
load: () => store.loaded(src),
}),
[src]
)
);
// Set the hasLoaded state based on the image load
img.onload = () => {
setHasLoaded(true);
};

// Set the hasLoaded state to false if the image fails to load
img.onerror = () => {
setHasLoaded(false);
};

// Set the src of the image to the src passed to the hook
img.src = src;

// If the src is not defined, set hasLoaded to false
if (!src) {
setHasLoaded(false);
}
}, [src]);

// Load the image when the component mounts
useEffect(() => {
load();
}, [load]);

// Reset the hasLoaded state when the src changes
useEffect(() => {
setHasLoaded(false);
}, [src]);

// Return the hasLoaded state and the load function
return { hasLoaded, load };
}

export default useAvatarStore;
export default useAvatar;

0 comments on commit c9e2058

Please sign in to comment.