diff --git a/lib/hooks/fc-cache.js b/lib/hooks/fc-cache.js --- a/lib/hooks/fc-cache.js +++ b/lib/hooks/fc-cache.js @@ -8,6 +8,7 @@ type BaseFCInfo = { +fid?: ?string, +farcasterUsername?: ?string, + +farcasterAvatarURL?: ?string, ... }; export type UseFCNamesOptions = { @@ -32,7 +33,7 @@ if (farcasterUsername) { cachedResult = farcasterUsername; } else if (fid && fcCache) { - cachedResult = fcCache.getCachedFarcasterUserForFID(fid); + cachedResult = fcCache.getCachedFarcasterUserForFID(fid)?.username; } return { input: user, @@ -141,4 +142,49 @@ ); } -export { useFCNames }; +function useFarcasterAvatar(fid: ?string): ?string { + const neynarClientContext = React.useContext(NeynarClientContext); + const { fcCache } = neynarClientContext || {}; + + const cachedAvatar = React.useMemo(() => { + if (!fid || !fcCache) { + return null; + } + const cachedUser = fcCache.getCachedFarcasterUserForFID(fid); + return cachedUser?.pfpURL ?? null; + }, [fcCache, fid]); + + const [farcasterAvatars, setFarcasterAvatars] = React.useState< + $ReadOnlyMap, + >(new Map()); + + React.useEffect(() => { + if (!fcCache || !fid || cachedAvatar) { + return; + } + void (async () => { + const [fetchedUser] = await fcCache.getFarcasterUsersForFIDs([fid]); + const avatarURL = fetchedUser?.pfpURL; + if (!avatarURL) { + return; + } + setFarcasterAvatars(oldFarcasterAvatars => { + const newFarcasterAvatars = new Map(oldFarcasterAvatars); + newFarcasterAvatars.set(fid, avatarURL); + return newFarcasterAvatars; + }); + })(); + }, [fcCache, cachedAvatar, fid]); + + return React.useMemo(() => { + if (!fid) { + return null; + } else if (cachedAvatar !== undefined) { + return cachedAvatar; + } else { + return farcasterAvatars.get(fid); + } + }, [fid, cachedAvatar, farcasterAvatars]); +} + +export { useFCNames, useFarcasterAvatar }; diff --git a/lib/shared/avatar-utils.js b/lib/shared/avatar-utils.js --- a/lib/shared/avatar-utils.js +++ b/lib/shared/avatar-utils.js @@ -8,6 +8,7 @@ import { threadOtherMembers } from './thread-utils.js'; import genesis from '../facts/genesis.js'; import { useENSAvatar } from '../hooks/ens-cache.js'; +import { useFarcasterAvatar } from '../hooks/fc-cache.js'; import { getETHAddressForUserInfo } from '../shared/account-utils.js'; import type { ClientAvatar, @@ -21,6 +22,7 @@ } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { UserInfos } from '../types/user-types.js'; +import { useCurrentUserFID } from '../utils/farcaster-utils.js'; const defaultAnonymousUserEmojiAvatar: ClientEmojiAvatar = { color: selectedThreadColors[4], @@ -325,8 +327,12 @@ const ensAvatarURI = useENSAvatar(ethAddress); + const currentUserFID = useCurrentUserFID(); + const fid = userInfo?.farcasterID ?? currentUserFID; + const farcasterAvatarURL = useFarcasterAvatar(fid); + const resolvedAvatar = React.useMemo(() => { - if (avatarInfo.type !== 'ens') { + if (avatarInfo.type !== 'ens' && avatarInfo.type !== 'farcaster') { return avatarInfo; } @@ -335,10 +341,15 @@ type: 'image', uri: ensAvatarURI, }; + } else if (farcasterAvatarURL) { + return { + type: 'image', + uri: farcasterAvatarURL, + }; } return defaultAnonymousUserEmojiAvatar; - }, [ensAvatarURI, avatarInfo]); + }, [avatarInfo, ensAvatarURI, farcasterAvatarURL]); return resolvedAvatar; } diff --git a/lib/types/avatar-types.js b/lib/types/avatar-types.js --- a/lib/types/avatar-types.js +++ b/lib/types/avatar-types.js @@ -35,11 +35,18 @@ export const ensAvatarDBContentValidator: TInterface = tShape({ type: tString('ens') }); +export type FarcasterAvatarDBContent = { + +type: 'farcaster', +}; +export const farcasterAvatarDBContentValidator: TInterface = + tShape({ type: tString('farcaster') }); + export type AvatarDBContent = | EmojiAvatarDBContent | ImageAvatarDBContent | EncryptedImageAvatarDBContent - | ENSAvatarDBContent; + | ENSAvatarDBContent + | FarcasterAvatarDBContent; export type UpdateUserAvatarRemoveRequest = { +type: 'remove' }; @@ -83,16 +90,21 @@ export type ClientENSAvatar = ENSAvatarDBContent; const clientENSAvatarValidator = ensAvatarDBContentValidator; +export type ClientFarcasterAvatar = FarcasterAvatarDBContent; +const clientFarcasterAvatarValidator = farcasterAvatarDBContentValidator; + export type ClientAvatar = | ClientEmojiAvatar | ClientImageAvatar | ClientEncryptedImageAvatar - | ClientENSAvatar; + | ClientENSAvatar + | ClientFarcasterAvatar; export const clientAvatarValidator: TUnion = t.union([ clientEmojiAvatarValidator, clientImageAvatarValidator, clientENSAvatarValidator, clientEncryptedImageAvatarValidator, + clientFarcasterAvatarValidator, ]); export type ResolvedClientAvatar = @@ -107,6 +119,7 @@ export type GenericUserInfoWithAvatar = { +username?: ?string, +avatar?: ?ClientAvatar, + +farcasterID?: ?string, ... }; diff --git a/native/account/registration/avatar-selection.react.js b/native/account/registration/avatar-selection.react.js --- a/native/account/registration/avatar-selection.react.js +++ b/native/account/registration/avatar-selection.react.js @@ -19,6 +19,7 @@ type AccountSelection, type AvatarData, ensAvatarSelection, + farcasterAvatarSelection, } from './registration-types.js'; import EditUserAvatar from '../../avatars/edit-user-avatar.react.js'; import PrimaryButton from '../../components/primary-button.react.js'; @@ -49,7 +50,7 @@ }; function AvatarSelection(props: Props): React.Node { const { userSelections } = props.route.params; - const { accountSelection } = userSelections; + const { accountSelection, farcasterAvatarURL, farcasterID } = userSelections; const usernameOrETHAddress = accountSelection.accountType === 'username' ? accountSelection.username @@ -63,14 +64,16 @@ invariant(editUserAvatarContext, 'editUserAvatarContext should be set'); const { setRegistrationMode } = editUserAvatarContext; - const prefetchedAvatarURI = + const prefetchedENSAvatarURI = accountSelection.accountType === 'ethereum' ? accountSelection.avatarURI : undefined; let initialAvatarData = cachedSelections.avatarData; - if (!initialAvatarData && prefetchedAvatarURI) { + if (!initialAvatarData && prefetchedENSAvatarURI) { initialAvatarData = ensAvatarSelection; + } else if (!initialAvatarData && farcasterAvatarURL) { + initialAvatarData = farcasterAvatarSelection; } const [avatarData, setAvatarData] = @@ -165,8 +168,9 @@ () => ({ username: usernameOrETHAddress, avatar: clientAvatar, + farcasterID: farcasterID, }), - [usernameOrETHAddress, clientAvatar], + [usernameOrETHAddress, clientAvatar, farcasterID], ); const styles = useStyles(unboundStyles); @@ -178,7 +182,9 @@ diff --git a/native/account/registration/registration-types.js b/native/account/registration/registration-types.js --- a/native/account/registration/registration-types.js +++ b/native/account/registration/registration-types.js @@ -67,3 +67,9 @@ updateUserAvatarRequest: { type: 'ens' }, clientAvatar: { type: 'ens' }, }; + +export const farcasterAvatarSelection: AvatarData = { + needsUpload: false, + updateUserAvatarRequest: { type: 'farcaster' }, + clientAvatar: { type: 'farcaster' }, +}; diff --git a/native/avatars/avatar-hooks.js b/native/avatars/avatar-hooks.js --- a/native/avatars/avatar-hooks.js +++ b/native/avatars/avatar-hooks.js @@ -469,7 +469,7 @@ } type ShowAvatarActionSheetOptions = { - +id: 'emoji' | 'image' | 'camera' | 'ens' | 'cancel' | 'remove', + +id: 'emoji' | 'image' | 'camera' | 'ens' | 'farcaster' | 'cancel' | 'remove', +onPress?: () => mixed, }; function useShowAvatarActionSheet( @@ -491,6 +491,8 @@ return 'Open camera'; } else if (option.id === 'ens') { return 'Use ENS avatar'; + } else if (option.id === 'farcaster') { + return 'Use Farcaster avatar'; } else if (option.id === 'remove') { return 'Reset to default'; } else { diff --git a/native/avatars/edit-user-avatar.react.js b/native/avatars/edit-user-avatar.react.js --- a/native/avatars/edit-user-avatar.react.js +++ b/native/avatars/edit-user-avatar.react.js @@ -7,8 +7,10 @@ import { EditUserAvatarContext } from 'lib/components/edit-user-avatar-provider.react.js'; import { useENSAvatar } from 'lib/hooks/ens-cache.js'; +import { useFarcasterAvatar } from 'lib/hooks/fc-cache.js'; import { getETHAddressForUserInfo } from 'lib/shared/account-utils.js'; import type { GenericUserInfoWithAvatar } from 'lib/types/avatar-types.js'; +import { useCurrentUserFID } from 'lib/utils/farcaster-utils.js'; import { useNativeSetUserAvatar, @@ -31,7 +33,9 @@ | { +userInfo: ?GenericUserInfoWithAvatar, +disabled?: boolean, - +prefetchedAvatarURI: ?string, + +prefetchedENSAvatarURI: ?string, + +prefetchedFarcasterAvatarURL: ?string, + +fid: ?string, }; function EditUserAvatar(props: Props): React.Node { const editUserAvatarContext = React.useContext(EditUserAvatarContext); @@ -53,7 +57,13 @@ [userInfo], ); const fetchedENSAvatarURI = useENSAvatar(ethAddress); - const ensAvatarURI = fetchedENSAvatarURI ?? props.prefetchedAvatarURI; + const ensAvatarURI = fetchedENSAvatarURI ?? props.prefetchedENSAvatarURI; + + const currentUserFID = useCurrentUserFID(); + const fid = userInfo?.farcasterID ?? currentUserFID; + const fetchedFarcasterAvatarURL = useFarcasterAvatar(fid); + const farcasterAvatarURL = + fetchedFarcasterAvatarURL ?? props.prefetchedFarcasterAvatarURL; const { navigate } = useNavigation(); @@ -82,6 +92,11 @@ [nativeSetUserAvatar], ); + const setFarcasterUserAvatar = React.useCallback( + () => nativeSetUserAvatar({ type: 'farcaster' }), + [nativeSetUserAvatar], + ); + const removeUserAvatar = React.useCallback( () => nativeSetUserAvatar({ type: 'remove' }), [nativeSetUserAvatar], @@ -99,19 +114,25 @@ configOptions.push({ id: 'ens', onPress: setENSUserAvatar }); } + if (farcasterAvatarURL) { + configOptions.push({ id: 'farcaster', onPress: setFarcasterUserAvatar }); + } + if (hasCurrentAvatar) { configOptions.push({ id: 'remove', onPress: removeUserAvatar }); } return configOptions; }, [ - hasCurrentAvatar, - ensAvatarURI, - navigateToCamera, navigateToEmojiSelection, - removeUserAvatar, - setENSUserAvatar, selectFromGalleryAndUpdateUserAvatar, + navigateToCamera, + ensAvatarURI, + farcasterAvatarURL, + hasCurrentAvatar, + setENSUserAvatar, + setFarcasterUserAvatar, + removeUserAvatar, ]); const showAvatarActionSheet = useShowAvatarActionSheet(actionSheetConfig);