diff --git a/lib/hooks/fc-cache.js b/lib/hooks/fc-cache.js index df439f959..ffab3ee6f 100644 --- a/lib/hooks/fc-cache.js +++ b/lib/hooks/fc-cache.js @@ -1,144 +1,184 @@ // @flow import * as React from 'react'; import { NeynarClientContext } from '../components/neynar-client-provider.react.js'; import { getFCNames } from '../utils/farcaster-helpers.js'; type BaseFCInfo = { +fid?: ?string, +farcasterUsername?: ?string, ... }; export type UseFCNamesOptions = { +allAtOnce?: ?boolean, }; function useFCNames( users: $ReadOnlyArray, options?: ?UseFCNamesOptions, ): T[] { const neynarClientContext = React.useContext(NeynarClientContext); const fcCache = neynarClientContext?.fcCache; const allAtOnce = options?.allAtOnce ?? false; const cachedInfo = React.useMemo( () => users.map(user => { if (!user) { return user; } const { fid, farcasterUsername } = user; let cachedResult = null; if (farcasterUsername) { cachedResult = farcasterUsername; } else if (fid && fcCache) { cachedResult = fcCache.getCachedFarcasterUserForFID(fid)?.username; } return { input: user, fid, cachedResult, }; }), [users, fcCache], ); const [fetchedFIDs, setFetchedFIDs] = React.useState<$ReadOnlySet>( new Set(), ); const [farcasterUsernames, setFarcasterUsernames] = React.useState< $ReadOnlyMap, >(new Map()); React.useEffect(() => { if (!fcCache) { return; } const needFetchUsers: $ReadOnlyArray<{ +fid: string, +farcasterUsername?: ?string, }> = cachedInfo .map(user => { if (!user) { return null; } const { fid, cachedResult } = user; if (cachedResult || !fid || fetchedFIDs.has(fid)) { return null; } return { fid }; }) .filter(Boolean); if (needFetchUsers.length === 0) { return; } const needFetchFIDs = needFetchUsers.map(({ fid }) => fid); setFetchedFIDs(oldFetchedFIDs => { const newFetchedFIDs = new Set(oldFetchedFIDs); for (const fid of needFetchFIDs) { newFetchedFIDs.add(fid); } return newFetchedFIDs; }); if (allAtOnce) { void (async () => { const withFarcasterUsernames = await getFCNames( fcCache, needFetchUsers, ); setFarcasterUsernames(oldFarcasterUsernames => { const newFarcasterUsernames = new Map(oldFarcasterUsernames); for (let i = 0; i < withFarcasterUsernames.length; i++) { const fid = needFetchFIDs[i]; const result = withFarcasterUsernames[i].farcasterUsername; if (result) { newFarcasterUsernames.set(fid, result); } } return newFarcasterUsernames; }); })(); return; } for (const fid of needFetchFIDs) { void (async () => { const [result] = await fcCache.getFarcasterUsersForFIDs([fid]); if (!result) { return; } setFarcasterUsernames(oldFarcasterUsernames => { const newFarcasterUsernames = new Map(oldFarcasterUsernames); newFarcasterUsernames.set(fid, result.username); return newFarcasterUsernames; }); })(); } }, [cachedInfo, fetchedFIDs, fcCache, allAtOnce]); return React.useMemo( () => cachedInfo.map(user => { if (!user) { return user; } const { input, fid, cachedResult } = user; if (cachedResult) { return { ...input, farcasterUsername: cachedResult }; } else if (!fid) { return input; } const farcasterUsername = farcasterUsernames.get(fid); if (farcasterUsername) { return { ...input, farcasterUsername }; } return input; }), [cachedInfo, farcasterUsernames], ); } -export { useFCNames }; +function useFarcasterAvatarURL(fid: ?string): ?string { + const neynarClientContext = React.useContext(NeynarClientContext); + const fcCache = neynarClientContext?.fcCache; + + const cachedAvatarURL = React.useMemo(() => { + if (!fid || !fcCache) { + return null; + } + const cachedUser = fcCache.getCachedFarcasterUserForFID(fid); + return cachedUser?.pfpURL ?? null; + }, [fcCache, fid]); + + const [farcasterAvatarURL, setFarcasterAvatarURL] = + React.useState(null); + + React.useEffect(() => { + if (!fcCache || !fid || cachedAvatarURL) { + return; + } + void (async () => { + const [fetchedUser] = await fcCache.getFarcasterUsersForFIDs([fid]); + const avatarURL = fetchedUser?.pfpURL; + if (!avatarURL) { + return; + } + setFarcasterAvatarURL(avatarURL); + })(); + }, [fcCache, cachedAvatarURL, fid]); + + return React.useMemo(() => { + if (!fid) { + return null; + } else if (cachedAvatarURL) { + return cachedAvatarURL; + } else { + return farcasterAvatarURL; + } + }, [fid, cachedAvatarURL, farcasterAvatarURL]); +} + +export { useFCNames, useFarcasterAvatarURL }; diff --git a/lib/shared/avatar-utils.js b/lib/shared/avatar-utils.js index 49c0a188e..322bc7451 100644 --- a/lib/shared/avatar-utils.js +++ b/lib/shared/avatar-utils.js @@ -1,357 +1,365 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import stringHash from 'string-hash'; import { selectedThreadColors } from './color-utils.js'; import { threadOtherMembers } from './thread-utils.js'; import genesis from '../facts/genesis.js'; import { useENSAvatar } from '../hooks/ens-cache.js'; +import { useFarcasterAvatarURL } 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 { ThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { UserInfos } from '../types/user-types.js'; const defaultAnonymousUserEmojiAvatar: ClientEmojiAvatar = { color: selectedThreadColors[4], emoji: '👤', type: 'emoji', }; const defaultEmojiAvatars: $ReadOnlyArray = [ { color: selectedThreadColors[0], emoji: '😀', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '😃', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😄', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😁', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '😆', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🙂', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '😉', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '😊', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '😇', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🥰', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '😍', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🤩', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🥳', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '😝', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😎', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🧐', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🥸', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🤗', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '😤', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🤯', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🤔', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🫡', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🤫', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😮', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '😲', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🤠', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🤑', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '👩‍🚀', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🥷', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '👻', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '👾', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🤖', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😺', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '😸', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '😹', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '😻', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🎩', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '👑', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🐶', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🐱', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🐭', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🐹', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🐰', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🐻', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🐼', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🐻‍❄️', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🐨', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🐯', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🦁', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🐸', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🐔', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🐧', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🐦', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🐤', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🦄', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🐝', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🦋', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🐬', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🐳', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🐋', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🦈', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🦭', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🐘', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🦛', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🐐', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🐓', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🦃', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🦩', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🦔', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🐅', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🐆', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🦓', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🦒', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🦘', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🐎', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🐕', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🐩', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🦮', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🐈', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🦚', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🦜', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🦢', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🕊️', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🐇', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🦦', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🐿️', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🐉', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🌴', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🌱', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '☘️', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🍀', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🍄', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🌿', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🪴', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🍁', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '💐', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🌷', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🌹', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🌸', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🌻', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '⭐', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🌟', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🍏', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🍎', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🍐', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🍊', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🍋', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🍓', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🫐', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🍈', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🍒', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🥭', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🍍', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🥝', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🍅', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🥦', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🥕', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🥐', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🥯', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🍞', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🥖', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🥨', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🧀', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🥞', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🧇', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🥓', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🍔', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🍟', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🍕', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🥗', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🍝', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🍜', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🍲', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🍛', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🍣', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🍱', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🥟', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🍤', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🍙', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🍚', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🍥', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🍦', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🧁', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🍭', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🍩', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🍪', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '☕️', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🍵', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '⚽️', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🏀', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🏈', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '⚾️', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🥎', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🎾', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🏐', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🏉', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🎱', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🏆', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🎨', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🎤', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🎧', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🎼', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🎹', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🥁', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🎷', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🎺', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🎸', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🪕', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🎻', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🎲', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '♟️', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🎮', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🚗', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🚙', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🚌', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🏎️', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🛻', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🚚', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🚛', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🚘', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🚀', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🚁', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🛶', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '⛵️', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🚤', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '⚓', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🏰', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🎡', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '💎', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🔮', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '💈', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🧸', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🎊', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🎉', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🪩', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🚂', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🚆', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🚊', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🛰️', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🏠', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '⛰️', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🏔️', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🗻', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🏛️', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '⛩️', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🧲', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🎁', type: 'emoji' }, ]; function getRandomDefaultEmojiAvatar(): ClientEmojiAvatar { const randomIndex = Math.floor(Math.random() * defaultEmojiAvatars.length); return defaultEmojiAvatars[randomIndex]; } function getDefaultAvatar(hashKey: string, color?: string): ClientEmojiAvatar { let key = hashKey; const barPosition = key.indexOf('|'); if (barPosition !== -1) { key = key.slice(barPosition + 1); } const avatarIndex = stringHash(key) % defaultEmojiAvatars.length; return { ...defaultEmojiAvatars[avatarIndex], color: color ? color : defaultEmojiAvatars[avatarIndex].color, }; } function getAvatarForUser( usernameAndAvatar: ?{ +username?: ?string, +avatar?: ?ClientAvatar, ... }, ): ClientAvatar { if (usernameAndAvatar?.avatar) { return usernameAndAvatar.avatar; } if (!usernameAndAvatar?.username) { return defaultAnonymousUserEmojiAvatar; } return getDefaultAvatar(usernameAndAvatar.username); } function getUserAvatarForThread( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ClientAvatar { if (threadInfo.type === threadTypes.GENESIS_PRIVATE) { invariant(viewerID, 'viewerID should be set for GENESIS_PRIVATE threads'); return getAvatarForUser(userInfos[viewerID]); } invariant( threadInfo.type === threadTypes.GENESIS_PERSONAL, 'threadInfo should be a GENESIS_PERSONAL type', ); const memberInfos = threadOtherMembers(threadInfo.members, viewerID) .map(member => userInfos[member.id] && userInfos[member.id]) .filter(Boolean); if (memberInfos.length === 0) { return defaultAnonymousUserEmojiAvatar; } return getAvatarForUser(memberInfos[0]); } function getAvatarForThread( thread: RawThreadInfo | ThreadInfo, containingThreadInfo: ?ThreadInfo, ): ClientAvatar { if (thread.avatar) { return thread.avatar; } if (containingThreadInfo && containingThreadInfo.id !== genesis().id) { return containingThreadInfo.avatar ? containingThreadInfo.avatar : getDefaultAvatar(containingThreadInfo.id, containingThreadInfo.color); } return getDefaultAvatar(thread.id, thread.color); } 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 = useFarcasterAvatarURL(fid); + const resolvedAvatar = React.useMemo(() => { - if (avatarInfo.type !== 'ens') { + if (avatarInfo.type !== 'ens' && avatarInfo.type !== 'farcaster') { return avatarInfo; } if (ensAvatarURI) { return { type: 'image', uri: ensAvatarURI, }; + } else if (farcasterAvatarURL) { + return { + type: 'image', + uri: farcasterAvatarURL, + }; } return defaultAnonymousUserEmojiAvatar; - }, [ensAvatarURI, avatarInfo]); + }, [avatarInfo, ensAvatarURI, farcasterAvatarURL]); return resolvedAvatar; } export { defaultAnonymousUserEmojiAvatar, defaultEmojiAvatars, getRandomDefaultEmojiAvatar, getDefaultAvatar, getAvatarForUser, getUserAvatarForThread, getAvatarForThread, useResolvedAvatar, }; diff --git a/lib/types/avatar-types.js b/lib/types/avatar-types.js index a2e917b1d..da2005bd6 100644 --- a/lib/types/avatar-types.js +++ b/lib/types/avatar-types.js @@ -1,113 +1,125 @@ // @flow import t, { type TUnion, type TInterface } from 'tcomb'; import type { CreateUpdatesResult } from './update-types.js'; import { validHexColorRegex } from '../shared/account-utils.js'; import { onlyOneEmojiRegex } from '../shared/emojis.js'; import { tRegex, tShape, tString } from '../utils/validation-utils.js'; export type EmojiAvatarDBContent = { +type: 'emoji', +emoji: string, +color: string, // hex, without "#" or "0x" }; export const emojiAvatarDBContentValidator: TInterface = tShape({ type: tString('emoji'), emoji: tRegex(onlyOneEmojiRegex), color: tRegex(validHexColorRegex), }); export type ImageAvatarDBContent = { +type: 'image', +uploadID: string, }; export type EncryptedImageAvatarDBContent = { +type: 'encrypted_image', +uploadID: string, }; export type ENSAvatarDBContent = { +type: 'ens', }; 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' }; export type NonKeyserverImageAvatarContent = { +type: 'non_keyserver_image', +blobURI: string, +encryptionKey: string, +thumbHash: ?string, }; export type UpdateUserAvatarRequest = | AvatarDBContent | NonKeyserverImageAvatarContent | UpdateUserAvatarRemoveRequest; export type ClientEmojiAvatar = EmojiAvatarDBContent; const clientEmojiAvatarValidator = emojiAvatarDBContentValidator; export type ClientImageAvatar = { +type: 'image', +uri: string, }; const clientImageAvatarValidator = tShape({ type: tString('image'), uri: t.String, }); export type ClientEncryptedImageAvatar = { +type: 'encrypted_image', +blobURI: string, +encryptionKey: string, +thumbHash: ?string, }; const clientEncryptedImageAvatarValidator = tShape({ type: tString('encrypted_image'), blobURI: t.String, encryptionKey: t.String, thumbHash: t.maybe(t.String), }); 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 = | ClientEmojiAvatar | ClientImageAvatar | ClientEncryptedImageAvatar; export type UpdateUserAvatarResponse = { +updates: CreateUpdatesResult, }; export type GenericUserInfoWithAvatar = { +username?: ?string, +avatar?: ?ClientAvatar, ... }; export type AvatarSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL'; diff --git a/native/account/registration/avatar-selection.react.js b/native/account/registration/avatar-selection.react.js index a1eb195ee..f6ec3e3f0 100644 --- a/native/account/registration/avatar-selection.react.js +++ b/native/account/registration/avatar-selection.react.js @@ -1,215 +1,220 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import { EditUserAvatarContext, type UserAvatarSelection, } from 'lib/components/edit-user-avatar-provider.react.js'; import RegistrationButtonContainer from './registration-button-container.react.js'; import RegistrationContainer from './registration-container.react.js'; import RegistrationContentContainer from './registration-content-container.react.js'; import { RegistrationContext } from './registration-context.js'; import type { RegistrationNavigationProp } from './registration-navigator.react.js'; import { type CoolOrNerdMode, 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'; import { useCurrentLeafRouteName } from '../../navigation/nav-selectors.js'; import { type NavigationRoute, RegistrationTermsRouteName, CreateSIWEBackupMessageRouteName, AvatarSelectionRouteName, EmojiAvatarSelectionRouteName, RegistrationUserAvatarCameraModalRouteName, } from '../../navigation/route-names.js'; import { useStyles } from '../../themes/colors.js'; export type AvatarSelectionParams = { +userSelections: { +coolOrNerdMode?: ?CoolOrNerdMode, +keyserverURL?: ?string, +accountSelection: AccountSelection, +farcasterID: ?string, +farcasterAvatarURL: ?string, }, }; type Props = { +navigation: RegistrationNavigationProp<'AvatarSelection'>, +route: NavigationRoute<'AvatarSelection'>, }; 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 : accountSelection.address; const registrationContext = React.useContext(RegistrationContext); invariant(registrationContext, 'registrationContext should be set'); const { cachedSelections, setCachedSelections } = registrationContext; const editUserAvatarContext = React.useContext(EditUserAvatarContext); 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] = React.useState(initialAvatarData); const setClientAvatarFromSelection = React.useCallback( (selection: UserAvatarSelection) => { if (selection.needsUpload) { const newAvatarData = { ...selection, clientAvatar: { type: 'image', uri: selection.mediaSelection.uri, }, }; setAvatarData(newAvatarData); setCachedSelections(oldUserSelections => ({ ...oldUserSelections, avatarData: newAvatarData, })); } else if (selection.updateUserAvatarRequest.type !== 'remove') { const clientRequest = selection.updateUserAvatarRequest; invariant( clientRequest.type !== 'image' && clientRequest.type !== 'encrypted_image' && clientRequest.type !== 'non_keyserver_image', 'image avatars need to be uploaded', ); const newAvatarData = { ...selection, clientAvatar: clientRequest, }; setAvatarData(newAvatarData); setCachedSelections(oldUserSelections => ({ ...oldUserSelections, avatarData: newAvatarData, })); } else { setAvatarData(undefined); setCachedSelections(oldUserSelections => ({ ...oldUserSelections, avatarData: undefined, })); } }, [setCachedSelections], ); const currentRouteName = useCurrentLeafRouteName(); const avatarSelectionHappening = currentRouteName === AvatarSelectionRouteName || currentRouteName === EmojiAvatarSelectionRouteName || currentRouteName === RegistrationUserAvatarCameraModalRouteName; React.useEffect(() => { if (!avatarSelectionHappening) { return undefined; } setRegistrationMode({ registrationMode: 'on', successCallback: setClientAvatarFromSelection, }); return () => { setRegistrationMode({ registrationMode: 'off' }); }; }, [ avatarSelectionHappening, setRegistrationMode, setClientAvatarFromSelection, ]); const { navigate } = props.navigation; const onProceed = React.useCallback(async () => { const newUserSelections = { ...userSelections, avatarData, }; if (userSelections.accountSelection.accountType === 'ethereum') { navigate<'CreateSIWEBackupMessage'>({ name: CreateSIWEBackupMessageRouteName, params: { userSelections: newUserSelections }, }); return; } navigate<'RegistrationTerms'>({ name: RegistrationTermsRouteName, params: { userSelections: newUserSelections }, }); }, [userSelections, avatarData, navigate]); const clientAvatar = avatarData?.clientAvatar; const userInfoOverride = React.useMemo( () => ({ username: usernameOrETHAddress, avatar: clientAvatar, }), [usernameOrETHAddress, clientAvatar], ); const styles = useStyles(unboundStyles); return ( Pick an avatar ); } const unboundStyles = { scrollViewContentContainer: { paddingHorizontal: 0, }, header: { fontSize: 24, color: 'panelForegroundLabel', paddingBottom: 16, paddingHorizontal: 16, }, stagedAvatarSection: { marginTop: 16, backgroundColor: 'panelForeground', paddingVertical: 24, alignItems: 'center', }, editUserAvatar: { alignItems: 'center', justifyContent: 'center', }, }; export default AvatarSelection; diff --git a/native/account/registration/registration-types.js b/native/account/registration/registration-types.js index cfff962a8..e828cf1ba 100644 --- a/native/account/registration/registration-types.js +++ b/native/account/registration/registration-types.js @@ -1,69 +1,75 @@ // @flow import type { UpdateUserAvatarRequest, ClientAvatar, } from 'lib/types/avatar-types.js'; import type { NativeMediaSelection } from 'lib/types/media-types.js'; import type { SIWEResult, SIWEBackupSecrets } from 'lib/types/siwe-types.js'; export type CoolOrNerdMode = 'cool' | 'nerd'; export type EthereumAccountSelection = { +accountType: 'ethereum', ...SIWEResult, +avatarURI: ?string, }; export type UsernameAccountSelection = { +accountType: 'username', +username: string, +password: string, }; export type AccountSelection = | EthereumAccountSelection | UsernameAccountSelection; export type AvatarData = | { +needsUpload: true, +mediaSelection: NativeMediaSelection, +clientAvatar: ClientAvatar, } | { +needsUpload: false, +updateUserAvatarRequest: UpdateUserAvatarRequest, +clientAvatar: ClientAvatar, }; export type RegistrationServerCallInput = { +coolOrNerdMode?: ?CoolOrNerdMode, +keyserverURL?: ?string, +farcasterID: ?string, +accountSelection: AccountSelection, +avatarData: ?AvatarData, +siweBackupSecrets?: ?SIWEBackupSecrets, +farcasterAvatarURL: ?string, +clearCachedSelections: () => void, +onNonceExpired: () => mixed, +onAlertAcknowledged?: () => mixed, }; export type CachedUserSelections = { +coolOrNerdMode?: CoolOrNerdMode, +keyserverURL?: string, +username?: string, +password?: string, +avatarData?: ?AvatarData, +ethereumAccount?: ?EthereumAccountSelection, +farcasterID?: string, +siweBackupSecrets?: ?SIWEBackupSecrets, +farcasterAvatarURL?: ?string, }; export const ensAvatarSelection: AvatarData = { needsUpload: false, 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 index 30232b0fd..9e9a053ed 100644 --- a/native/avatars/avatar-hooks.js +++ b/native/avatars/avatar-hooks.js @@ -1,612 +1,614 @@ // @flow import { useActionSheet } from '@expo/react-native-action-sheet'; import * as ImagePicker from 'expo-image-picker'; import invariant from 'invariant'; import * as React from 'react'; import { Platform } from 'react-native'; import filesystem from 'react-native-fs'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { uploadMultimedia, useBlobServiceUpload, } from 'lib/actions/upload-actions.js'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import { EditUserAvatarContext } from 'lib/components/edit-user-avatar-provider.react.js'; import { useLegacyAshoatKeyserverCall } from 'lib/keyserver-conn/legacy-keyserver-call.js'; import { extensionFromFilename, filenameFromPathOrURI, } from 'lib/media/file-utils.js'; import type { UpdateUserAvatarRequest } from 'lib/types/avatar-types.js'; import type { NativeMediaSelection, MediaLibrarySelection, MediaMissionFailure, } from 'lib/types/media-types.js'; import type { RawThreadInfo, ThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypeIsThick } from 'lib/types/thread-types-enum.js'; import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import CommIcon from '../components/comm-icon.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { encryptMedia } from '../media/encryption-utils.js'; import { getCompatibleMediaURI } from '../media/identifier-utils.js'; import type { MediaResult } from '../media/media-utils.js'; import { processMedia } from '../media/media-utils.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; import blobServiceUploadHandler from '../utils/blob-service-upload.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; // TODO: flip the switch const useBlobServiceUploads = false; function displayAvatarUpdateFailureAlert(): void { Alert.alert( 'Couldn’t save avatar', 'Please try again later', [{ text: 'OK' }], { cancelable: true }, ); } function useUploadProcessedMedia(): ( media: MediaResult, metadataUploadLocation: 'keyserver' | 'none', ) => Promise { const callUploadMultimedia = useLegacyAshoatKeyserverCall(uploadMultimedia); const callBlobServiceUpload = useBlobServiceUpload(); return React.useCallback( async (processedMedia, metadataUploadLocation) => { const useBlobService = metadataUploadLocation !== 'keyserver' || useBlobServiceUploads; if (!useBlobService) { const { uploadURI, filename, mime, dimensions } = processedMedia; const { id } = await callUploadMultimedia( { uri: uploadURI, name: filename, type: mime, }, dimensions, ); if (!id) { return undefined; } return { type: 'image', uploadID: id }; } const { result: encryptionResult } = await encryptMedia(processedMedia); if (!encryptionResult.success) { throw new Error('Avatar media encryption failed.'); } invariant( encryptionResult.mediaType === 'encrypted_photo', 'Invalid mediaType after encrypting avatar', ); const { uploadURI, filename, mime, blobHash, encryptionKey, dimensions, thumbHash, } = encryptionResult; const { id, uri } = await callBlobServiceUpload({ uploadInput: { blobInput: { type: 'uri', uri: uploadURI, filename, mimeType: mime, }, blobHash, encryptionKey, dimensions, thumbHash, loop: false, }, keyserverOrThreadID: metadataUploadLocation === 'keyserver' ? authoritativeKeyserverID : null, callbacks: { blobServiceUploadHandler }, }); if (metadataUploadLocation !== 'keyserver') { return { type: 'non_keyserver_image', blobURI: uri, thumbHash, encryptionKey, }; } if (!id) { return undefined; } return { type: 'encrypted_image', uploadID: id }; }, [callUploadMultimedia, callBlobServiceUpload], ); } function useProcessSelectedMedia(): NativeMediaSelection => Promise< MediaMissionFailure | MediaResult, > { const hasWiFi = useSelector(state => state.connectivity.hasWiFi); const staffCanSee = useStaffCanSee(); const processSelectedMedia = React.useCallback( async (selection: NativeMediaSelection) => { const { resultPromise } = processMedia(selection, { hasWiFi, finalFileHeaderCheck: staffCanSee, }); return await resultPromise; }, [hasWiFi, staffCanSee], ); return processSelectedMedia; } async function selectFromGallery(): Promise { try { const { assets, canceled } = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: true, allowsMultipleSelection: false, quality: 1, }); if (canceled || assets.length === 0) { return undefined; } const asset = assets.pop(); const { width, height, assetId: mediaNativeID } = asset; const assetFilename = asset.fileName || filenameFromPathOrURI(asset.uri) || ''; const uri = getCompatibleMediaURI( asset.uri, extensionFromFilename(assetFilename), ); const currentTime = Date.now(); const selection: MediaLibrarySelection = { step: 'photo_library', dimensions: { height, width }, uri, filename: assetFilename, mediaNativeID, selectTime: currentTime, sendTime: currentTime, retries: 0, }; return selection; } catch (e) { console.log(e); return undefined; } } function useUploadSelectedMedia( setProcessingOrUploadInProgress?: (inProgress: boolean) => mixed, ): ( selection: NativeMediaSelection, metadataUploadLocation: 'keyserver' | 'none', ) => Promise { const processSelectedMedia = useProcessSelectedMedia(); const uploadProcessedMedia = useUploadProcessedMedia(); return React.useCallback( async (selection: NativeMediaSelection, metadataUploadLocation) => { setProcessingOrUploadInProgress?.(true); const urisToBeDisposed: Set = new Set([selection.uri]); let processedMedia; try { processedMedia = await processSelectedMedia(selection); if (processedMedia.uploadURI) { urisToBeDisposed.add(processedMedia.uploadURI); } } catch (e) { urisToBeDisposed.forEach(filesystem.unlink); Alert.alert( 'Media processing failed', 'Unable to process selected media.', ); setProcessingOrUploadInProgress?.(false); return undefined; } if (!processedMedia.success) { urisToBeDisposed.forEach(filesystem.unlink); Alert.alert( 'Media processing failed', 'Unable to process selected media.', ); setProcessingOrUploadInProgress?.(false); return undefined; } let uploadedMedia: ?UpdateUserAvatarRequest; try { uploadedMedia = await uploadProcessedMedia( processedMedia, metadataUploadLocation, ); urisToBeDisposed.forEach(filesystem.unlink); setProcessingOrUploadInProgress?.(false); } catch { urisToBeDisposed.forEach(filesystem.unlink); Alert.alert( 'Media upload failed', 'Unable to upload selected media. Please try again.', ); setProcessingOrUploadInProgress?.(false); return undefined; } return uploadedMedia; }, [ processSelectedMedia, setProcessingOrUploadInProgress, uploadProcessedMedia, ], ); } function useNativeSetUserAvatar(): ( request: UpdateUserAvatarRequest, ) => Promise { const editUserAvatarContext = React.useContext(EditUserAvatarContext); invariant(editUserAvatarContext, 'editUserAvatarContext must be defined'); const { baseSetUserAvatar, getRegistrationModeEnabled, getRegistrationModeSuccessCallback, } = editUserAvatarContext; const nativeSetUserAvatar = React.useCallback( async (request: UpdateUserAvatarRequest) => { const registrationModeEnabled = getRegistrationModeEnabled(); if (registrationModeEnabled) { const successCallback = getRegistrationModeSuccessCallback(); invariant( successCallback, 'successCallback must be defined if registrationModeEnabled is true', ); successCallback({ needsUpload: false, updateUserAvatarRequest: request, }); return; } try { await baseSetUserAvatar(request); } catch { displayAvatarUpdateFailureAlert(); } }, [ getRegistrationModeEnabled, getRegistrationModeSuccessCallback, baseSetUserAvatar, ], ); return nativeSetUserAvatar; } function useNativeUpdateUserImageAvatar(): ( selection: NativeMediaSelection, ) => Promise { const editUserAvatarContext = React.useContext(EditUserAvatarContext); invariant(editUserAvatarContext, 'editUserAvatarContext must be defined'); const { baseSetUserAvatar, getRegistrationModeEnabled, getRegistrationModeSuccessCallback, setUserAvatarMediaUploadInProgress, } = editUserAvatarContext; const uploadSelectedMedia = useUploadSelectedMedia( setUserAvatarMediaUploadInProgress, ); const nativeUpdateUserImageAvatar = React.useCallback( async (selection: NativeMediaSelection) => { const registrationModeEnabled = getRegistrationModeEnabled(); if (registrationModeEnabled) { const successCallback = getRegistrationModeSuccessCallback(); invariant( successCallback, 'successCallback must be defined if registrationModeEnabled is true', ); successCallback({ needsUpload: true, mediaSelection: selection, }); return; } const imageAvatarUpdateRequest = await uploadSelectedMedia( selection, 'keyserver', ); if (!imageAvatarUpdateRequest) { return; } try { await baseSetUserAvatar(imageAvatarUpdateRequest); } catch { displayAvatarUpdateFailureAlert(); } }, [ getRegistrationModeEnabled, getRegistrationModeSuccessCallback, baseSetUserAvatar, uploadSelectedMedia, ], ); return nativeUpdateUserImageAvatar; } function useSelectFromGalleryAndUpdateUserAvatar(): () => Promise { const nativeUpdateUserImageAvatar = useNativeUpdateUserImageAvatar(); const selectFromGalleryAndUpdateUserAvatar = React.useCallback(async (): Promise => { const selection = await selectFromGallery(); if (!selection) { return; } await nativeUpdateUserImageAvatar(selection); }, [nativeUpdateUserImageAvatar]); return selectFromGalleryAndUpdateUserAvatar; } function useNativeSetThreadAvatar(): ( threadID: string, avatarRequest: UpdateUserAvatarRequest, ) => Promise { const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext must be defined'); const { baseSetThreadAvatar } = editThreadAvatarContext; const nativeSetThreadAvatar = React.useCallback( async ( threadID: string, avatarRequest: UpdateUserAvatarRequest, ): Promise => { try { await baseSetThreadAvatar(threadID, avatarRequest); } catch (e) { displayAvatarUpdateFailureAlert(); throw e; } }, [baseSetThreadAvatar], ); return nativeSetThreadAvatar; } function useNativeUpdateThreadImageAvatar(): ( selection: NativeMediaSelection, threadInfo: ThreadInfo | RawThreadInfo, ) => Promise { const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext must be defined'); const { baseSetThreadAvatar, updateThreadAvatarMediaUploadInProgress } = editThreadAvatarContext; const uploadSelectedMedia = useUploadSelectedMedia( updateThreadAvatarMediaUploadInProgress, ); const nativeUpdateThreadImageAvatar = React.useCallback( async ( selection: NativeMediaSelection, threadInfo: ThreadInfo | RawThreadInfo, ): Promise => { const metadataUploadLocation = threadTypeIsThick(threadInfo.type) ? 'none' : 'keyserver'; const imageAvatarUpdateRequest = await uploadSelectedMedia( selection, metadataUploadLocation, ); if (!imageAvatarUpdateRequest) { return; } try { await baseSetThreadAvatar(threadInfo.id, imageAvatarUpdateRequest); } catch { displayAvatarUpdateFailureAlert(); } }, [baseSetThreadAvatar, uploadSelectedMedia], ); return nativeUpdateThreadImageAvatar; } function useSelectFromGalleryAndUpdateThreadAvatar(): ( threadInfo: ThreadInfo | RawThreadInfo, ) => Promise { const nativeUpdateThreadImageAvatar = useNativeUpdateThreadImageAvatar(); const selectFromGalleryAndUpdateThreadAvatar = React.useCallback( async (threadInfo: ThreadInfo | RawThreadInfo): Promise => { const selection: ?MediaLibrarySelection = await selectFromGallery(); if (!selection) { return; } await nativeUpdateThreadImageAvatar(selection, threadInfo); }, [nativeUpdateThreadImageAvatar], ); return selectFromGalleryAndUpdateThreadAvatar; } type ShowAvatarActionSheetOptions = { - +id: 'emoji' | 'image' | 'camera' | 'ens' | 'cancel' | 'remove', + +id: 'emoji' | 'image' | 'camera' | 'ens' | 'farcaster' | 'cancel' | 'remove', +onPress?: () => mixed, }; function useShowAvatarActionSheet( options: $ReadOnlyArray, ): () => void { options = Platform.OS === 'ios' ? [...options, { id: 'cancel' }] : options; const insets = useSafeAreaInsets(); const { showActionSheetWithOptions } = useActionSheet(); const styles = useStyles(unboundStyles); const showAvatarActionSheet = React.useCallback(() => { const texts = options.map((option: ShowAvatarActionSheetOptions) => { if (option.id === 'emoji') { return 'Select emoji'; } else if (option.id === 'image') { return 'Select image'; } else if (option.id === 'camera') { 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 { return 'Cancel'; } }); const cancelButtonIndex = options.findIndex( option => option.id === 'cancel', ); const containerStyle = { paddingBotton: insets.bottom, }; const icons = options.map(option => { if (option.id === 'emoji') { return ( ); } else if (option.id === 'image') { return ( ); } else if (option.id === 'camera') { return ( ); } else if (option.id === 'ens') { return ( ); } else if (option.id === 'remove') { return ( ); } else { return undefined; } }); const onPressAction = (selectedIndex: ?number) => { if ( selectedIndex === null || selectedIndex === undefined || selectedIndex < 0 ) { return; } const option = options[selectedIndex]; if (option.onPress) { option.onPress(); } }; showActionSheetWithOptions( { options: texts, cancelButtonIndex, containerStyle, icons, }, onPressAction, ); }, [ insets.bottom, options, showActionSheetWithOptions, styles.bottomSheetIcon, ]); return showAvatarActionSheet; } const unboundStyles = { bottomSheetIcon: { color: '#000000', }, }; export { displayAvatarUpdateFailureAlert, selectFromGallery, useUploadSelectedMedia, useUploadProcessedMedia, useProcessSelectedMedia, useShowAvatarActionSheet, useSelectFromGalleryAndUpdateUserAvatar, useNativeSetUserAvatar, useNativeUpdateUserImageAvatar, useSelectFromGalleryAndUpdateThreadAvatar, useNativeSetThreadAvatar, useNativeUpdateThreadImageAvatar, }; diff --git a/native/avatars/edit-user-avatar.react.js b/native/avatars/edit-user-avatar.react.js index 5fe197287..fc6fb6b88 100644 --- a/native/avatars/edit-user-avatar.react.js +++ b/native/avatars/edit-user-avatar.react.js @@ -1,159 +1,178 @@ // @flow import { useNavigation } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { ActivityIndicator, TouchableOpacity, View } from 'react-native'; import { EditUserAvatarContext } from 'lib/components/edit-user-avatar-provider.react.js'; import { useENSAvatar } from 'lib/hooks/ens-cache.js'; +import { useFarcasterAvatarURL } from 'lib/hooks/fc-cache.js'; import { getETHAddressForUserInfo } from 'lib/shared/account-utils.js'; import type { GenericUserInfoWithAvatar } from 'lib/types/avatar-types.js'; import { useNativeSetUserAvatar, useSelectFromGalleryAndUpdateUserAvatar, useShowAvatarActionSheet, } from './avatar-hooks.js'; import EditAvatarBadge from './edit-avatar-badge.react.js'; import UserAvatar from './user-avatar.react.js'; import { EmojiUserAvatarCreationRouteName, UserAvatarCameraModalRouteName, EmojiAvatarSelectionRouteName, RegistrationUserAvatarCameraModalRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; 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); invariant(editUserAvatarContext, 'editUserAvatarContext should be set'); const { userAvatarSaveInProgress, getRegistrationModeEnabled } = editUserAvatarContext; const nativeSetUserAvatar = useNativeSetUserAvatar(); const selectFromGalleryAndUpdateUserAvatar = useSelectFromGalleryAndUpdateUserAvatar(); const currentUserInfo = useSelector(state => state.currentUserInfo); const userInfoProp = props.userInfo; const userInfo: ?GenericUserInfoWithAvatar = userInfoProp ?? currentUserInfo; const ethAddress = React.useMemo( () => getETHAddressForUserInfo(userInfo), [userInfo], ); const fetchedENSAvatarURI = useENSAvatar(ethAddress); - const ensAvatarURI = fetchedENSAvatarURI ?? props.prefetchedAvatarURI; + const ensAvatarURI = fetchedENSAvatarURI ?? props.prefetchedENSAvatarURI; + + const fid = props.fid; + const fetchedFarcasterAvatarURL = useFarcasterAvatarURL(fid); + const farcasterAvatarURL = + fetchedFarcasterAvatarURL ?? props.prefetchedFarcasterAvatarURL; const { navigate } = useNavigation(); const usernameOrEthAddress = userInfo?.username; const navigateToEmojiSelection = React.useCallback(() => { if (!getRegistrationModeEnabled()) { navigate(EmojiUserAvatarCreationRouteName); return; } navigate<'EmojiAvatarSelection'>({ name: EmojiAvatarSelectionRouteName, params: { usernameOrEthAddress }, }); }, [navigate, getRegistrationModeEnabled, usernameOrEthAddress]); const navigateToCamera = React.useCallback(() => { navigate( getRegistrationModeEnabled() ? RegistrationUserAvatarCameraModalRouteName : UserAvatarCameraModalRouteName, ); }, [navigate, getRegistrationModeEnabled]); const setENSUserAvatar = React.useCallback( () => nativeSetUserAvatar({ type: 'ens' }), [nativeSetUserAvatar], ); + const setFarcasterUserAvatar = React.useCallback( + () => nativeSetUserAvatar({ type: 'farcaster' }), + [nativeSetUserAvatar], + ); + const removeUserAvatar = React.useCallback( () => nativeSetUserAvatar({ type: 'remove' }), [nativeSetUserAvatar], ); const hasCurrentAvatar = !!userInfo?.avatar; const actionSheetConfig = React.useMemo(() => { const configOptions = [ { id: 'emoji', onPress: navigateToEmojiSelection }, { id: 'image', onPress: selectFromGalleryAndUpdateUserAvatar }, { id: 'camera', onPress: navigateToCamera }, ]; if (ensAvatarURI) { 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); const styles = useStyles(unboundStyles); let spinner; if (userAvatarSaveInProgress) { spinner = ( ); } const { userID } = props; const userAvatar = userID ? ( ) : ( - + ); const { disabled } = props; return ( {userAvatar} {spinner} {!disabled ? : null} ); } const unboundStyles = { spinnerContainer: { position: 'absolute', alignItems: 'center', justifyContent: 'center', top: 0, bottom: 0, left: 0, right: 0, }, }; export default EditUserAvatar; diff --git a/native/avatars/user-avatar.react.js b/native/avatars/user-avatar.react.js index 7cc18fbe0..85641b571 100644 --- a/native/avatars/user-avatar.react.js +++ b/native/avatars/user-avatar.react.js @@ -1,40 +1,55 @@ // @flow import * as React from 'react'; import { getAvatarForUser, useResolvedAvatar, } from 'lib/shared/avatar-utils.js'; import type { 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 yet have a user ID. In this case, we must pass the relevant avatar info +// into the component. 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 ; } export default UserAvatar; diff --git a/native/profile/profile-screen.react.js b/native/profile/profile-screen.react.js index 9dc03b85c..d7dfa3c72 100644 --- a/native/profile/profile-screen.react.js +++ b/native/profile/profile-screen.react.js @@ -1,619 +1,626 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, Platform, ScrollView } from 'react-native'; import uuid from 'uuid'; import { logOutActionTypes, useLogOut, usePrimaryDeviceLogOut, useSecondaryDeviceLogOut, } from 'lib/actions/user-actions.js'; import { useStringForUser } from 'lib/hooks/ens-cache.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { getOwnPrimaryDeviceID } from 'lib/selectors/user-selectors.js'; import { accountHasPassword } from 'lib/shared/account-utils.js'; import { type OutboundDMOperationSpecification, dmOperationSpecificationTypes, } from 'lib/shared/dm-ops/dm-op-utils.js'; import { useProcessAndSendDMOperation } from 'lib/shared/dm-ops/process-dm-ops.js'; import type { LogOutResult } from 'lib/types/account-types.js'; import type { DMCreateThreadOperation } from 'lib/types/dm-ops'; 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, } from 'lib/utils/redux-promise-utils.js'; import { usingCommServicesAccessToken } from 'lib/utils/services-utils.js'; import type { ProfileNavigationProp } from './profile.react.js'; import { deleteNativeCredentialsFor } from '../account/native-credentials.js'; import EditUserAvatar from '../avatars/edit-user-avatar.react.js'; import Action from '../components/action-row.react.js'; import Button from '../components/button.react.js'; import EditSettingButton from '../components/edit-setting-button.react.js'; import SingleLine from '../components/single-line.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { EditPasswordRouteName, DeleteAccountRouteName, BuildInfoRouteName, DevToolsRouteName, AppearancePreferencesRouteName, FriendListRouteName, BlockListRouteName, PrivacyPreferencesRouteName, DefaultNotificationsPreferencesRouteName, LinkedDevicesRouteName, BackupMenuRouteName, KeyserverSelectionListRouteName, TunnelbrokerMenuRouteName, FarcasterAccountSettingsRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; type ProfileRowProps = { +content: string, +onPress: () => void, +danger?: boolean, }; function ProfileRow(props: ProfileRowProps): React.Node { const { content, onPress, danger } = props; return ( ); } const unboundStyles = { avatarSection: { alignItems: 'center', paddingVertical: 16, }, container: { flex: 1, }, content: { flex: 1, }, deleteAccountButton: { paddingHorizontal: 24, paddingVertical: 12, }, editPasswordButton: { paddingTop: Platform.OS === 'android' ? 3 : 2, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, paddingRight: 12, }, loggedInLabel: { color: 'panelForegroundTertiaryLabel', fontSize: 16, }, logOutText: { color: 'link', fontSize: 16, paddingLeft: 6, }, row: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, paddedRow: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 10, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingVertical: 1, }, unpaddedSection: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, }, username: { color: 'panelForegroundLabel', flex: 1, }, value: { color: 'panelForegroundLabel', fontSize: 16, textAlign: 'right', }, }; type BaseProps = { +navigation: ProfileNavigationProp<'ProfileScreen'>, +route: NavigationRoute<'ProfileScreen'>, }; type Props = { ...BaseProps, +currentUserInfo: ?CurrentUserInfo, +primaryDeviceID: ?string, +logOutLoading: boolean, +colors: Colors, +styles: $ReadOnly, +dispatchActionPromise: DispatchActionPromise, +logOut: () => Promise, +logOutPrimaryDevice: () => Promise, +logOutSecondaryDevice: () => Promise, +staffCanSee: boolean, +stringForUser: ?string, +isAccountWithPassword: boolean, +onCreateDMThread: () => Promise, + +currentUserFID: ?string, }; class ProfileScreen extends React.PureComponent { get loggedOutOrLoggingOut(): boolean { return ( !this.props.currentUserInfo || this.props.currentUserInfo.anonymous || this.props.logOutLoading ); } render(): React.Node { let developerTools, defaultNotifications, keyserverSelection, tunnelbrokerMenu; const { staffCanSee } = this.props; if (staffCanSee) { developerTools = ( ); defaultNotifications = ( ); keyserverSelection = ( ); tunnelbrokerMenu = ( ); } let backupMenu; if (staffCanSee) { backupMenu = ( ); } let passwordEditionUI; if (accountHasPassword(this.props.currentUserInfo)) { passwordEditionUI = ( Password •••••••••••••••• ); } let linkedDevices; if (__DEV__) { linkedDevices = ( ); } let farcasterAccountSettings; if (usingCommServicesAccessToken || __DEV__) { farcasterAccountSettings = ( ); } let experimentalLogoutActions; if (__DEV__) { experimentalLogoutActions = ( <> ); } let dmActions; if (staffCanSee) { dmActions = ( <> ); } return ( USER AVATAR - + ACCOUNT Logged in as {this.props.stringForUser} {passwordEditionUI} PREFERENCES {defaultNotifications} {backupMenu} {tunnelbrokerMenu} {farcasterAccountSettings} {linkedDevices} {keyserverSelection} {developerTools} {experimentalLogoutActions} {dmActions} ); } onPressLogOut = () => { if (this.loggedOutOrLoggingOut) { return; } if (!this.props.isAccountWithPassword) { Alert.alert( 'Log out', 'Are you sure you want to log out?', [ { text: 'No', style: 'cancel' }, { text: 'Yes', onPress: this.logOutWithoutDeletingNativeCredentialsWrapper, style: 'destructive', }, ], { cancelable: true }, ); return; } const alertTitle = Platform.OS === 'ios' ? 'Keep Login Info in Keychain' : 'Keep Login Info'; const alertDescription = 'We will automatically fill out log-in forms with your credentials ' + 'in the app.'; Alert.alert( alertTitle, alertDescription, [ { text: 'Cancel', style: 'cancel' }, { text: 'Keep', onPress: this.logOutWithoutDeletingNativeCredentialsWrapper, }, { text: 'Remove', onPress: this.logOutAndDeleteNativeCredentialsWrapper, style: 'destructive', }, ], { cancelable: true }, ); }; onPressNewLogout = () => { void (async () => { if (this.loggedOutOrLoggingOut) { return; } const { primaryDeviceID } = this.props; const currentDeviceID = await getContentSigningKey(); const isPrimaryDevice = currentDeviceID === primaryDeviceID; let alertTitle, alertMessage, onPressAction; if (isPrimaryDevice) { alertTitle = 'Log out all devices?'; alertMessage = 'This device is your primary device, ' + 'so logging out will cause all of your other devices to log out too.'; onPressAction = this.logOutPrimaryDevice; } else { alertTitle = 'Log out?'; alertMessage = 'Are you sure you want to log out of this device?'; onPressAction = this.logOutSecondaryDevice; } Alert.alert( alertTitle, alertMessage, [ { text: 'No', style: 'cancel' }, { text: 'Yes', onPress: onPressAction, style: 'destructive', }, ], { cancelable: true }, ); })(); }; logOutWithoutDeletingNativeCredentialsWrapper = () => { if (this.loggedOutOrLoggingOut) { return; } this.logOut(); }; logOutAndDeleteNativeCredentialsWrapper = async () => { if (this.loggedOutOrLoggingOut) { return; } await this.deleteNativeCredentials(); this.logOut(); }; logOut() { void this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(), ); } logOutPrimaryDevice = async () => { if (this.loggedOutOrLoggingOut) { return; } void this.props.dispatchActionPromise( logOutActionTypes, this.props.logOutPrimaryDevice(), ); }; logOutSecondaryDevice = async () => { if (this.loggedOutOrLoggingOut) { return; } void this.props.dispatchActionPromise( logOutActionTypes, this.props.logOutSecondaryDevice(), ); }; async deleteNativeCredentials() { await deleteNativeCredentialsFor(); } onPressEditPassword = () => { this.props.navigation.navigate({ name: EditPasswordRouteName }); }; onPressDeleteAccount = () => { this.props.navigation.navigate({ name: DeleteAccountRouteName }); }; onPressFaracsterAccount = () => { this.props.navigation.navigate({ name: FarcasterAccountSettingsRouteName }); }; onPressDevices = () => { this.props.navigation.navigate({ name: LinkedDevicesRouteName }); }; onPressBuildInfo = () => { this.props.navigation.navigate({ name: BuildInfoRouteName }); }; onPressDevTools = () => { this.props.navigation.navigate({ name: DevToolsRouteName }); }; onPressAppearance = () => { this.props.navigation.navigate({ name: AppearancePreferencesRouteName }); }; onPressPrivacy = () => { this.props.navigation.navigate({ name: PrivacyPreferencesRouteName }); }; onPressDefaultNotifications = () => { this.props.navigation.navigate({ name: DefaultNotificationsPreferencesRouteName, }); }; onPressFriendList = () => { this.props.navigation.navigate({ name: FriendListRouteName }); }; onPressBlockList = () => { this.props.navigation.navigate({ name: BlockListRouteName }); }; onPressBackupMenu = () => { this.props.navigation.navigate({ name: BackupMenuRouteName }); }; onPressTunnelbrokerMenu = () => { this.props.navigation.navigate({ name: TunnelbrokerMenuRouteName }); }; onPressKeyserverSelection = () => { this.props.navigation.navigate({ name: KeyserverSelectionListRouteName }); }; onPressCreateThread = () => { void this.props.onCreateDMThread(); }; } const logOutLoadingStatusSelector = createLoadingStatusSelector(logOutActionTypes); const ConnectedProfileScreen: React.ComponentType = React.memo(function ConnectedProfileScreen(props: BaseProps) { const currentUserInfo = useSelector(state => state.currentUserInfo); const primaryDeviceID = useSelector(getOwnPrimaryDeviceID); const logOutLoading = useSelector(logOutLoadingStatusSelector) === 'loading'; const colors = useColors(); const styles = useStyles(unboundStyles); const callLogOut = useLogOut(); const callPrimaryDeviceLogOut = usePrimaryDeviceLogOut(); const callSecondaryDeviceLogOut = useSecondaryDeviceLogOut(); const dispatchActionPromise = useDispatchActionPromise(); const staffCanSee = useStaffCanSee(); const stringForUser = useStringForUser(currentUserInfo); const isAccountWithPassword = useSelector(state => accountHasPassword(state.currentUserInfo), ); + const currentUserID = useCurrentUserFID(); const userID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const processAndSendDMOperation = useProcessAndSendDMOperation(); const onCreateDMThread = React.useCallback(async () => { invariant(userID, 'userID should be set'); const op: DMCreateThreadOperation = { type: 'create_thread', threadID: uuid.v4(), creatorID: userID, time: Date.now(), threadType: thickThreadTypes.LOCAL, memberIDs: [], roleID: uuid.v4(), newMessageID: uuid.v4(), }; const specification: OutboundDMOperationSpecification = { type: dmOperationSpecificationTypes.OUTBOUND, op, recipients: { type: 'self_devices', }, }; await processAndSendDMOperation(specification); }, [processAndSendDMOperation, userID]); return ( ); }); export default ConnectedProfileScreen;