diff --git a/lib/components/neynar-client-provider.react.js b/lib/components/neynar-client-provider.react.js
index f5c8114fd..5f7cdb30f 100644
--- a/lib/components/neynar-client-provider.react.js
+++ b/lib/components/neynar-client-provider.react.js
@@ -1,44 +1,47 @@
// @flow
import * as React from 'react';
+import { FCCache } from '../utils/fc-cache.js';
import { NeynarClient } from '../utils/neynar-client.js';
type NeynarClientContextType = {
+client: NeynarClient,
+ +fcCache: FCCache,
};
const NeynarClientContext: React.Context =
React.createContext();
type Props = {
+apiKey: ?string,
+children: React.Node,
};
function NeynarClientProvider(props: Props): React.Node {
const { apiKey, children } = props;
const neynarClient = React.useMemo(() => {
if (!apiKey) {
return null;
}
return new NeynarClient(apiKey);
}, [apiKey]);
const context = React.useMemo(() => {
if (!neynarClient) {
return null;
}
return {
client: neynarClient,
+ fcCache: new FCCache(neynarClient),
};
}, [neynarClient]);
return (
{children}
);
}
export { NeynarClientContext, NeynarClientProvider };
diff --git a/lib/types/farcaster-types.js b/lib/types/farcaster-types.js
index 0bdf98f2e..082285eda 100644
--- a/lib/types/farcaster-types.js
+++ b/lib/types/farcaster-types.js
@@ -1,29 +1,35 @@
// @flow
// This is a message that the rendered webpage
// (landing/connect-farcaster.react.js) uses to communicate back
// to the React Native WebView that is rendering it
// (native/components/farcaster-web-view.react.js)
export type FarcasterWebViewMessage =
| {
+type: 'farcaster_url',
+url: string,
}
| {
+type: 'farcaster_data',
+fid: string,
};
-export type NeynarUserWithViewerContext = {
+export type NeynarUser = {
+fid: number,
+ +username: string,
+ ...
+};
+
+export type NeynarUserWithViewerContext = $ReadOnly<{
+ ...NeynarUser,
+viewerContext: {
+following: boolean,
},
...
-};
+}>;
export type NeynarChannel = {
+id: string,
+name: string,
...
};
diff --git a/lib/utils/fc-cache.js b/lib/utils/fc-cache.js
new file mode 100644
index 000000000..04c860587
--- /dev/null
+++ b/lib/utils/fc-cache.js
@@ -0,0 +1,152 @@
+// @flow
+
+import { NeynarClient } from './neynar-client.js';
+import sleep from './sleep.js';
+
+const cacheTimeout = 24 * 60 * 60 * 1000; // one day
+const failedQueryCacheTimeout = 5 * 60 * 1000; // five minutes
+const queryTimeout = 10 * 1000; // ten seconds
+
+async function throwOnTimeout(identifier: string) {
+ await sleep(queryTimeout);
+ throw new Error(`Farcaster fetch for ${identifier} timed out`);
+}
+
+type FarcasterUsernameQueryCacheEntry = {
+ +fid: string,
+ +expirationTime: number,
+ +farcasterUsername: ?string | Promise,
+};
+
+class FCCache {
+ client: NeynarClient;
+
+ // Maps from FIDs to a cache entry for its Farcaster username
+ farcasterUsernameQueryCache: Map =
+ new Map();
+
+ constructor(client: NeynarClient) {
+ this.client = client;
+ }
+
+ getFarcasterUsernamesForFIDs(
+ fids: $ReadOnlyArray,
+ ): Promise> {
+ const cacheMatches = fids.map(fid =>
+ this.getCachedFarcasterUsernameEntryForFID(fid),
+ );
+ const cacheResultsPromise = Promise.all(
+ cacheMatches.map(match =>
+ Promise.resolve(match ? match.farcasterUsername : match),
+ ),
+ );
+ if (cacheMatches.every(Boolean)) {
+ return cacheResultsPromise;
+ }
+
+ const needFetch = [];
+ for (let i = 0; i < fids.length; i++) {
+ const fid = fids[i];
+ const cacheMatch = cacheMatches[i];
+ if (!cacheMatch) {
+ needFetch.push(fid);
+ }
+ }
+
+ const fetchFarcasterUsernamesPromise = (async () => {
+ let farcasterUsernames: $ReadOnlyArray;
+ try {
+ farcasterUsernames = await Promise.race([
+ this.client.getFarcasterUsernames(needFetch),
+ throwOnTimeout(`usernames for ${JSON.stringify(needFetch)}`),
+ ]);
+ } catch (e) {
+ console.log(e);
+ farcasterUsernames = new Array(needFetch.length).fill(null);
+ }
+
+ const resultMap = new Map();
+ for (let i = 0; i < needFetch.length; i++) {
+ const fid = needFetch[i];
+ const farcasterUsername = farcasterUsernames[i];
+ resultMap.set(fid, farcasterUsername);
+ }
+ return resultMap;
+ })();
+
+ for (let i = 0; i < needFetch.length; i++) {
+ const fid = needFetch[i];
+ const fetchFarcasterUsernamePromise = (async () => {
+ const resultMap = await fetchFarcasterUsernamesPromise;
+ return resultMap.get(fid) ?? null;
+ })();
+ this.farcasterUsernameQueryCache.set(fid, {
+ fid,
+ expirationTime: Date.now() + queryTimeout * 2,
+ farcasterUsername: fetchFarcasterUsernamePromise,
+ });
+ }
+
+ return (async () => {
+ const [resultMap, cacheResults] = await Promise.all([
+ fetchFarcasterUsernamesPromise,
+ cacheResultsPromise,
+ ]);
+ for (let i = 0; i < needFetch.length; i++) {
+ const fid = needFetch[i];
+ const farcasterUsername = resultMap.get(fid);
+ const timeout =
+ farcasterUsername === null ? failedQueryCacheTimeout : cacheTimeout;
+ this.farcasterUsernameQueryCache.set(fid, {
+ fid,
+ expirationTime: Date.now() + timeout,
+ farcasterUsername,
+ });
+ }
+
+ const results = [];
+ for (let i = 0; i < fids.length; i++) {
+ const cachedResult = cacheResults[i];
+ if (cachedResult) {
+ results.push(cachedResult);
+ } else {
+ results.push(resultMap.get(fids[i]));
+ }
+ }
+ return results;
+ })();
+ }
+
+ getCachedFarcasterUsernameEntryForFID(
+ fid: string,
+ ): ?FarcasterUsernameQueryCacheEntry {
+ const cacheResult = this.farcasterUsernameQueryCache.get(fid);
+ if (!cacheResult) {
+ return undefined;
+ }
+
+ const { expirationTime } = cacheResult;
+ if (expirationTime <= Date.now()) {
+ this.farcasterUsernameQueryCache.delete(fid);
+ return undefined;
+ }
+
+ return cacheResult;
+ }
+
+ getCachedFarcasterUsernameForFID(fid: string): ?string {
+ const cacheResult = this.getCachedFarcasterUsernameEntryForFID(fid);
+ if (!cacheResult) {
+ return undefined;
+ }
+
+ const { farcasterUsername } = cacheResult;
+ if (typeof farcasterUsername !== 'string') {
+ return undefined;
+ }
+
+ return farcasterUsername;
+ }
+}
+
+export { FCCache };
diff --git a/lib/utils/neynar-client.js b/lib/utils/neynar-client.js
index caa0a65f6..9b6eb5ce6 100644
--- a/lib/utils/neynar-client.js
+++ b/lib/utils/neynar-client.js
@@ -1,188 +1,234 @@
// @flow
import invariant from 'invariant';
import { getMessageForException } from './errors.js';
import type {
NeynarChannel,
+ NeynarUser,
NeynarUserWithViewerContext,
} from '../types/farcaster-types.js';
type FetchFollowersResponse = {
+result: {
+users: $ReadOnlyArray,
+next: {
+cursor: ?string,
},
},
};
type FetchFollowedFarcasterChannelsResponse = {
+channels: $ReadOnlyArray,
+next: {
+cursor: ?string,
},
};
type FetchFarcasterChannelByNameResponse = {
+channels: $ReadOnlyArray,
};
+type FetchUsersResponse = {
+ +users: $ReadOnlyArray,
+};
+
const neynarBaseURL = 'https://api.neynar.com/';
const neynarURLs = {
'1': `${neynarBaseURL}v1/farcaster/`,
'2': `${neynarBaseURL}v2/farcaster/`,
};
function getNeynarURL(
apiVersion: string,
apiCall: string,
params: { [string]: string },
): string {
const neynarURL = neynarURLs[apiVersion];
invariant(
neynarURL,
`could not find Neynar URL for apiVersion ${apiVersion}`,
);
return `${neynarURL}${apiCall}?${new URLSearchParams(params).toString()}`;
}
const fetchFollowerLimit = 150;
const fetchFollowedChannelsLimit = 100;
class NeynarClient {
apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
// We're using the term "friend" for a bidirectional follow
async fetchFriendFIDs(fid: string): Promise {
const fids = [];
let paginationCursor = null;
do {
const params: { [string]: string } = {
fid,
viewerFid: fid,
limit: fetchFollowerLimit.toString(),
...(paginationCursor ? { cursor: paginationCursor } : null),
};
const url = getNeynarURL('1', 'followers', params);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
api_key: this.apiKey,
},
});
const json: FetchFollowersResponse = await response.json();
const { users } = json.result;
for (const user of users) {
if (user.viewerContext.following) {
fids.push(user.fid.toString());
}
}
paginationCursor = json.result.next.cursor;
} catch (error) {
console.log(
'Failed to fetch friend FIDs:',
getMessageForException(error) ?? 'unknown',
);
throw error;
}
} while (paginationCursor);
return fids;
}
async fetchFollowedFarcasterChannels(fid: string): Promise {
const farcasterChannels = [];
let paginationCursor = null;
do {
const params: { [string]: string } = {
fid,
limit: fetchFollowedChannelsLimit.toString(),
...(paginationCursor ? { cursor: paginationCursor } : null),
};
const url = getNeynarURL('2', 'user/channels', params);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
api_key: this.apiKey,
},
});
const json: FetchFollowedFarcasterChannelsResponse =
await response.json();
const { channels, next } = json;
channels.forEach(channel => {
farcasterChannels.push(channel);
});
paginationCursor = next.cursor;
} catch (error) {
console.log(
'Failed to fetch followed Farcaster channels:',
getMessageForException(error) ?? 'unknown',
);
throw error;
}
} while (paginationCursor);
return farcasterChannels;
}
async fetchFarcasterChannelByName(
channelName: string,
): Promise {
const params: { [string]: string } = {
q: channelName,
};
const url = getNeynarURL('2', 'channel/search', params);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
api_key: this.apiKey,
},
});
const json: FetchFarcasterChannelByNameResponse = await response.json();
const { channels } = json;
for (const channel of channels) {
if (channel.name.toLowerCase() === channelName.toLowerCase()) {
return channel;
}
}
return null;
} catch (error) {
console.log(
'Failed to search Farcaster channel by name:',
getMessageForException(error) ?? 'unknown',
);
throw error;
}
}
+
+ async getFarcasterUsernames(
+ fids: $ReadOnlyArray,
+ ): Promise> {
+ const fidsLeft = [...fids];
+ const results: Array = [];
+ do {
+ // Neynar API allows querying 100 at a time
+ const batch = fidsLeft.splice(0, 100);
+ const url = getNeynarURL('2', 'user/bulk', { fids: batch.join(',') });
+ try {
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ Accept: 'application/json',
+ api_key: this.apiKey,
+ },
+ });
+
+ const json: FetchUsersResponse = await response.json();
+ const { users } = json;
+
+ const neynarUserMap = new Map();
+ for (const neynarUser of users) {
+ neynarUserMap.set(neynarUser.fid, neynarUser);
+ }
+
+ for (const fid of batch) {
+ const neynarUser = neynarUserMap.get(parseInt(fid));
+ results.push(neynarUser ? neynarUser.username : null);
+ }
+ } catch (error) {
+ console.log(
+ 'Failed to fetch Farcaster usernames:',
+ getMessageForException(error) ?? 'unknown',
+ );
+ throw error;
+ }
+ } while (fidsLeft.length > 0);
+ return results;
+ }
}
export { NeynarClient };