diff --git a/src/client/webapp/elements/displays/display-guild-overview.tsx b/src/client/webapp/elements/displays/display-guild-overview.tsx index e5b2c5c..7c7c5b2 100644 --- a/src/client/webapp/elements/displays/display-guild-overview.tsx +++ b/src/client/webapp/elements/displays/display-guild-overview.tsx @@ -9,9 +9,8 @@ import Globals from '../../globals'; import Display from '../components/display'; import TextInput from '../components/input-text'; import ImageEditInput from '../components/input-image-edit'; -import { useResourceSubscription } from '../require/guild-subscriptions'; import CombinedGuild from '../../guild-combined'; -import { guildMetaState, isLoaded } from '../require/atoms-2'; +import { guildMetaState, guildResourceState, isFailed, isLoaded } from '../require/atoms-2'; import { useRecoilValue } from 'recoil'; interface GuildOverviewDisplayProps { @@ -22,8 +21,7 @@ const GuildOverviewDisplay: FC = (props: GuildOvervie const guildMeta = useRecoilValue(guildMetaState(guild.id)); - // TODO: Use the one from guild.tsx (for both of these?) - const [ iconResourceResult, iconResourceError ] = useResourceSubscription(guild, isLoaded(guildMeta) ? guildMeta.value.iconResourceId : null, guild); + const iconResource = useRecoilValue(guildResourceState({ guildId: guild.id, resourceId: isLoaded(guildMeta) ? guildMeta.value.iconResourceId : null })); const [ savedName, setSavedName ] = useState(null); const [ savedIconBuff, setSavedIconBuff ] = useState(null); @@ -47,22 +45,22 @@ const GuildOverviewDisplay: FC = (props: GuildOvervie }, [ guildMeta ]); useEffect(() => { - if (iconResourceResult && iconResourceResult.value) { - if (iconBuff === savedIconBuff) setIconBuff(iconResourceResult.value.data); - setSavedIconBuff(iconResourceResult.value.data); + if (isLoaded(iconResource)) { + if (iconBuff === savedIconBuff) setIconBuff(iconResource.value.data); + setSavedIconBuff(iconResource.value.data); } - }, [ iconResourceResult ]); + }, [ iconResource ]); const changes = useMemo(() => { return name !== savedName || iconBuff?.toString('hex') !== savedIconBuff?.toString('hex') }, [ name, savedName, iconBuff, savedIconBuff ]); const errorMessage = useMemo(() => { - if (iconResourceError) return 'Unable to load icon'; + if (isFailed(iconResource)) return 'Unable to load icon'; if (!iconInputValid && iconInputMessage) return iconInputMessage; if (!nameInputValid && nameInputMessage) return nameInputMessage; return null; - }, [ iconResourceError, iconInputValid, iconInputMessage, nameInputValid, nameInputMessage ]); + }, [ iconResource, iconInputValid, iconInputMessage, nameInputValid, nameInputMessage ]); const infoMessage = useMemo(() => { if (iconInputValid && iconInputMessage) return iconInputMessage; diff --git a/src/client/webapp/elements/overlays/overlay-personalize.tsx b/src/client/webapp/elements/overlays/overlay-personalize.tsx index b522769..c0c6beb 100644 --- a/src/client/webapp/elements/overlays/overlay-personalize.tsx +++ b/src/client/webapp/elements/overlays/overlay-personalize.tsx @@ -11,11 +11,10 @@ import SubmitOverlayLower from '../components/submit-overlay-lower'; import { useAsyncSubmitButton } from '../require/react-helper'; import Button from '../components/button'; import Overlay from '../components/overlay'; -import { useResourceSubscription } from '../require/guild-subscriptions'; -import { useSetRecoilState } from 'recoil'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import { Member } from '../../data-types'; import CombinedGuild from '../../guild-combined'; -import { overlayState } from '../require/atoms-2'; +import { guildResourceState, isFailed, isLoaded, overlayState } from '../require/atoms-2'; interface PersonalizeOverlayProps { guild: CombinedGuild; @@ -25,11 +24,10 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl const { guild, selfMember } = props; const setOverlay = useSetRecoilState(overlayState); + const avatarResource = useRecoilValue(guildResourceState({ guildId: guild.id, resourceId: selfMember.avatarResourceId })); const rootRef = useRef(null); - const [ avatarResourceResult, avatarResourceError ] = useResourceSubscription(guild, selfMember.avatarResourceId, guild); - const displayNameInputRef = createRef(); const [ savedDisplayName, setSavedDisplayName ] = useState(selfMember.displayName); @@ -45,22 +43,22 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl const [ avatarInputMessage, setAvatarInputMessage ] = useState(null); useEffect(() => { - if (avatarResourceResult && avatarResourceResult.value) { - if (avatarBuff === savedAvatarBuff) setAvatarBuff(avatarResourceResult.value.data); - setSavedAvatarBuff(avatarResourceResult.value.data); + if (isLoaded(avatarResource)) { + if (avatarBuff === savedAvatarBuff) setAvatarBuff(avatarResource.value.data); + setSavedAvatarBuff(avatarResource.value.data); } - }, [ avatarResourceResult ]); + }, [ avatarResource ]); useEffect(() => { displayNameInputRef.current?.focus(); }, []); const validationErrorMessage = useMemo(() => { - if (avatarResourceError) return 'Unable to load avatar'; + if (isFailed(avatarResource)) return 'Unable to load avatar'; if ( !avatarInputValid && avatarInputMessage) return avatarInputMessage; if (!displayNameInputValid && displayNameInputMessage) return displayNameInputMessage; return null; - }, [ avatarResourceError, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]); + }, [ avatarResource, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]); const infoMessage = useMemo(() => { if (avatarInputValid && avatarInputMessage) return avatarInputMessage; diff --git a/src/client/webapp/elements/require/atoms-2.ts b/src/client/webapp/elements/require/atoms-2.ts index 27238ce..3b9967b 100644 --- a/src/client/webapp/elements/require/atoms-2.ts +++ b/src/client/webapp/elements/require/atoms-2.ts @@ -4,13 +4,14 @@ import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); import { ReactNode, useEffect } from "react"; -import { atom, atomFamily, GetCallback, GetRecoilValue, RecoilState, RecoilValue, RecoilValueReadOnly, selector, selectorFamily, useSetRecoilState } from "recoil"; -import { Changes, Channel, GuildMetadata, Member, Resource } from "../../data-types"; +import { atom, atomFamily, GetRecoilValue, RecoilState, RecoilValue, RecoilValueReadOnly, selector, selectorFamily, useSetRecoilState } from "recoil"; +import { Changes, Channel, GuildMetadata, Member, Resource, ShouldNeverHappenError } from "../../data-types"; import CombinedGuild from "../../guild-combined"; import GuildsManager from "../../guilds-manager"; import { AutoVerifierChangesType } from '../../auto-verifier'; import { Conflictable, Connectable } from '../../guild-types'; import { IDQuery } from '../../auto-verifier-with-args'; +import ElementsUtil from './elements-util'; // General typescript type that infers the arguments of a function type Arguments = T extends (...args: infer A) => unknown ? A : never; @@ -117,6 +118,7 @@ function createFetchValueFunc( fetchFunc: (guild: CombinedGuild) => Promise>, ): () => Promise { const fetchValueFunc = async () => { + // TODO: Look into using getCallback in case guild is null https://recoiljs.org/docs/api-reference/core/selector#returning-objects-with-callbacks const guild = await getPromise(guildState(guildId)); if (guild === null) return; // Can't send a request without an associated guild @@ -336,9 +338,12 @@ function singleGuildSubscriptionEffect< >( guildId: number, fetchFunc: (guild: CombinedGuild) => Promise>, - eventMapping: SingleEventMappingParams + eventMapping: SingleEventMappingParams, + skipFunc?: () => boolean ) { return (params: RecoilLoadableAtomEffectParams) => { + if (skipFunc && skipFunc()) return; // Don't run if this atom should be skipped for some reason (e.g. null resourceId) + const { node, trigger, setSelf, getPromise } = params; const fetchValueFunc = createFetchValueFunc(getPromise, guildId, node, setSelf, fetchFunc); @@ -405,25 +410,44 @@ export const guildMetaState = atomFamily, number>({ dangerouslyAllowMutability: true }); -export const guildResourceState = atomFamily, { guildId: number, resourceId: string }>({ - key: 'guildResourceState', +// Note: this is also useful for resources that may have a null/unloaded resourceId +// Note: this function has somewhat dirty types +export const guildResourceState = atomFamily, { guildId: number, resourceId: string | null }>({ + key: 'guildPotentialResourceState', default: DEF_UNLOADED_VALUE, - effects_UNSTABLE: (param: { guildId: number, resourceId: string }) => [ + effects_UNSTABLE: (param: { guildId: number, resourceId: string | null }) => [ singleGuildSubscriptionEffect( param.guildId, - async (guild: CombinedGuild) => await guild.fetchResource(param.resourceId), + async (guild: CombinedGuild) => { + if (param.resourceId === null) { // Should never happen because of skipFunc (last argument) + throw new ShouldNeverHappenError('null resourceId'); + } else { + return await guild.fetchResource(param.resourceId); + } + }, { updatedEventName: 'update-resource', updatedEventArgsMap: (updatedResource: Resource) => updatedResource, updatedEventCondition: (resource: Resource) => resource.id === param.resourceId, conflictEventName: 'conflict-resource', conflictEventArgsMap: (_query: IDQuery, _changeType: AutoVerifierChangesType, _oldResource: Resource, newResource: Resource) => newResource, - conflictEventCondition: (resource: Resource) => resource.id === param.resourceId - } + conflictEventCondition: (resource: Resource) => resource.id === param.resourceId, + }, + () => param.resourceId === null // Never load/bind if resourceId === null ) - ], - dangerouslyAllowMutability: true + ] }); +// NOTE: This is a Loadable Recoil selectorFamily so do useRecoilValueLoadable +export const guildResourceSoftImgSrcState = selectorFamily({ + key: 'guildResourceSoftImgSrcState', + get: (param: { guildId: number, resourceId: string | null }) => async ({ get }) => { + const resource = get(guildResourceState(param)); + if (isFailed(resource)) return './img/error.png'; // TODO: Use BaseElements + if (!isLoaded(resource)) return './img/loading.svg'; // TODO: Use BaseElements + const imgSrc = await ElementsUtil.getImageSrcFromBufferFailSoftly(resource.value.data); + return imgSrc; + } +}) const guildMembersState = atomFamily, number>({ key: 'guildMembersState', @@ -543,8 +567,18 @@ function createCurrentGuildStateGetter(subSelectorFamily: (guildId: number) = return value; } } +function createCurrentGuildHardValueWithParamStateGetter(subSelectorFamily: (param: P) => RecoilValueReadOnly | RecoilState, guildIdToParam: (guildId: number) => P, unloadedValue: T) { + return ({ get }: { get: GetRecoilValue }) => { + // Use the unloaded value if the current guild hasn't been selected yet or doesn't exist + const currGuildId = get(currGuildIdState); + if (currGuildId === null) return unloadedValue; + const value = get(subSelectorFamily(guildIdToParam(currGuildId))); + if (value === null) return unloadedValue; + return value; + } +} function createCurrentGuildLoadableStateGetter(subSelectorFamily: (guildId: number) => RecoilValueReadOnly> | RecoilState>) { - return ({ get }: { get: GetRecoilValue; getCallback: GetCallback }) => { + return ({ get }: { get: GetRecoilValue }) => { // Use the unloaded value if the current guild hasn't been selected yet or doesn't exist const currGuildId = get(currGuildIdState); if (currGuildId === null) return DEF_UNLOADED_VALUE; @@ -553,6 +587,16 @@ function createCurrentGuildLoadableStateGetter(subSelectorFamily: (guildId: n return value; } } +function createCurrentGuildLoadableWithParamStateGetter(subSelectorFamily: (param: P) => RecoilValueReadOnly> | RecoilState>, guildIdToParam: (guildId: number) => P) { + return ({ get }: { get: GetRecoilValue }) => { + // Use the unloaded value if the current guild hasn't been selected yet or doesn't exist + const currGuildId = get(currGuildIdState); + if (currGuildId === null) return DEF_UNLOADED_VALUE; + const value = get(subSelectorFamily(guildIdToParam(currGuildId))); + if (value === null) return DEF_UNLOADED_VALUE; + return value; + } +} // Note: These will all update in parallel when the guild changes. They will always reference the same guild. // What's great about these is that they all change at once when the guild is changed so there are not extraneous renders! @@ -566,6 +610,16 @@ export const currGuildMetaState = selector>({ get: createCurrentGuildLoadableStateGetter(guildMetaState), dangerouslyAllowMutability: true }); +export const currGuildResourceState = selectorFamily, string | null>({ + key: 'currGuildResourceState', + get: (resourceId: string | null) => createCurrentGuildLoadableWithParamStateGetter(guildResourceState, (guildId: number) => ({ guildId, resourceId })), + dangerouslyAllowMutability: true +}); +export const currGuildResourceSoftImgSrcState = selectorFamily({ + key: 'currGuildResourceSoftImgSrcState', + get: (resourceId: string | null) => createCurrentGuildHardValueWithParamStateGetter(guildResourceSoftImgSrcState, (guildId: number) => ({ guildId, resourceId }), './img/loading.svg'), + dangerouslyAllowMutability: true +}) export const currGuildMembersState = selector>({ key: 'currGuildMembersState', get: createCurrentGuildLoadableStateGetter(guildMembersState), diff --git a/src/client/webapp/elements/require/guild-subscriptions.ts b/src/client/webapp/elements/require/guild-subscriptions.ts index 0164a4b..3ba5287 100644 --- a/src/client/webapp/elements/require/guild-subscriptions.ts +++ b/src/client/webapp/elements/require/guild-subscriptions.ts @@ -732,7 +732,7 @@ function useGuildMetadataSubscription(guild: CombinedGuild) { * fetchError: Any error from fetching * ] */ -export function useResourceSubscription(guild: CombinedGuild, resourceId: string | null, resourceIdGuild: CombinedGuild | null) { +function useResourceSubscription(guild: CombinedGuild, resourceId: string | null, resourceIdGuild: CombinedGuild | null) { const fetchResourceFunc = useCallback(async () => { //LOG.silly('fetching resource for subscription (resourceId: ' + resourceId + ')'); // Note: Returning null skips the load. This will prevent a null resourceResult