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 @@ -141,4 +141,43 @@ ); } -export { useFCNames }; +function useFarcasterAvatar(fid: ?string): ?string { + const neynarClientContext = React.useContext(NeynarClientContext); + const fcCache = neynarClientContext?.fcCache; + + const cachedAvatar = React.useMemo(() => { + if (!fid || !fcCache) { + return null; + } + const cachedUser = fcCache.getCachedFarcasterUserForFID(fid); + return cachedUser?.pfpURL ?? null; + }, [fcCache, fid]); + + const [farcasterAvatar, setFarcasterAvatar] = React.useState(null); + + React.useEffect(() => { + if (!fcCache || !fid || cachedAvatar) { + return; + } + void (async () => { + const [fetchedUser] = await fcCache.getFarcasterUsersForFIDs([fid]); + const avatarURL = fetchedUser?.pfpURL; + if (!avatarURL) { + return; + } + setFarcasterAvatar(avatarURL); + })(); + }, [fcCache, cachedAvatar, fid]); + + return React.useMemo(() => { + if (!fid) { + return null; + } else if (cachedAvatar) { + return cachedAvatar; + } else { + return farcasterAvatar; + } + }, [fid, cachedAvatar, farcasterAvatar]); +} + +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,11 +8,11 @@ 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, ClientEmojiAvatar, - GenericUserInfoWithAvatar, ResolvedClientAvatar, } from '../types/avatar-types.js'; import type { @@ -318,17 +318,20 @@ function useResolvedAvatar( avatarInfo: ClientAvatar, - userInfo: ?GenericUserInfoWithAvatar, + usernameAndFID: ?{ +username?: ?string, +farcasterID?: ?string, ... }, ): ResolvedClientAvatar { const ethAddress = React.useMemo( - () => getETHAddressForUserInfo(userInfo), - [userInfo], + () => getETHAddressForUserInfo(usernameAndFID), + [usernameAndFID], ); const ensAvatarURI = useENSAvatar(ethAddress); + const fid = usernameAndFID?.farcasterID; + const farcasterAvatarURL = useFarcasterAvatar(fid); + const resolvedAvatar = React.useMemo(() => { - if (avatarInfo.type !== 'ens') { + if (avatarInfo.type !== 'ens' && avatarInfo.type !== 'farcaster') { return avatarInfo; } @@ -337,10 +340,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 = 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] = @@ -178,7 +181,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,6 +7,7 @@ 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'; @@ -27,11 +28,13 @@ import { useStyles } from '../themes/colors.js'; type Props = - | { +userID: ?string, +disabled?: boolean } + | { +userID: ?string, +disabled?: boolean, +fid: ?string } | { +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 +56,12 @@ [userInfo], ); const fetchedENSAvatarURI = useENSAvatar(ethAddress); - const ensAvatarURI = fetchedENSAvatarURI ?? props.prefetchedAvatarURI; + const ensAvatarURI = fetchedENSAvatarURI ?? props.prefetchedENSAvatarURI; + + const fid = props.fid; + const fetchedFarcasterAvatarURL = useFarcasterAvatar(fid); + const farcasterAvatarURL = + fetchedFarcasterAvatarURL ?? props.prefetchedFarcasterAvatarURL; const { navigate } = useNavigation(); @@ -82,6 +90,11 @@ [nativeSetUserAvatar], ); + const setFarcasterUserAvatar = React.useCallback( + () => nativeSetUserAvatar({ type: 'farcaster' }), + [nativeSetUserAvatar], + ); + const removeUserAvatar = React.useCallback( () => nativeSetUserAvatar({ type: 'remove' }), [nativeSetUserAvatar], @@ -99,19 +112,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); @@ -131,7 +150,7 @@ const userAvatar = userID ? ( ) : ( - + ); const { disabled } = props; diff --git a/native/avatars/user-avatar.react.js b/native/avatars/user-avatar.react.js --- a/native/avatars/user-avatar.react.js +++ b/native/avatars/user-avatar.react.js @@ -10,29 +10,43 @@ GenericUserInfoWithAvatar, AvatarSize, } from 'lib/types/avatar-types.js'; +import { useCurrentUserFID } from 'lib/utils/farcaster-utils.js'; import Avatar from './avatar.react.js'; import { useSelector } from '../redux/redux-utils.js'; +// We have two variants for Props here because we want to be able to display a +// user avatar during the registration workflow, at which point the user will +// not have a user ID. type Props = | { +userID: ?string, +size: AvatarSize } - | { +userInfo: ?GenericUserInfoWithAvatar, +size: AvatarSize }; + | { +userInfo: ?GenericUserInfoWithAvatar, +size: AvatarSize, +fid: ?string }; function UserAvatar(props: Props): React.Node { - const { userID, userInfo: userInfoProp, size } = props; + const { userID, userInfo: userInfoProp, size, fid } = props; - const userInfo = useSelector(state => { + const currentUserFID = useCurrentUserFID(); + const userAvatarInfo = useSelector(state => { if (!userID) { - return userInfoProp; + return { + ...userInfoProp, + farcasterID: fid, + }; } else if (userID === state.currentUserInfo?.id) { - return state.currentUserInfo; + return { + ...state.currentUserInfo, + farcasterID: currentUserFID, + }; } else { - return state.userStore.userInfos[userID]; + return { + ...state.userStore.userInfos[userID], + farcasterID: state.auxUserStore.auxUserInfos[userID]?.fid, + }; } }); - const avatarInfo = getAvatarForUser(userInfo); + const avatar = getAvatarForUser(userAvatarInfo); - const resolvedUserAvatar = useResolvedAvatar(avatarInfo, userInfo); + const resolvedUserAvatar = useResolvedAvatar(avatar, userAvatarInfo); return ; } diff --git a/native/profile/profile-screen.react.js b/native/profile/profile-screen.react.js --- a/native/profile/profile-screen.react.js +++ b/native/profile/profile-screen.react.js @@ -25,6 +25,7 @@ import { thickThreadTypes } from 'lib/types/thread-types-enum.js'; import { type CurrentUserInfo } from 'lib/types/user-types.js'; import { getContentSigningKey } from 'lib/utils/crypto-utils.js'; +import { useCurrentUserFID } from 'lib/utils/farcaster-utils.js'; import { useDispatchActionPromise, type DispatchActionPromise, @@ -178,6 +179,7 @@ +stringForUser: ?string, +isAccountWithPassword: boolean, +onCreateDMThread: () => Promise, + +currentUserFID: ?string, }; class ProfileScreen extends React.PureComponent { @@ -301,7 +303,10 @@ - + ACCOUNT @@ -568,6 +573,7 @@ const isAccountWithPassword = useSelector(state => accountHasPassword(state.currentUserInfo), ); + const currentUserID = useCurrentUserFID(); const userID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, @@ -612,6 +618,7 @@ stringForUser={stringForUser} isAccountWithPassword={isAccountWithPassword} onCreateDMThread={onCreateDMThread} + currentUserFID={currentUserID} /> ); });