diff --git a/desk/app/groups-ui.hoon b/desk/app/groups-ui.hoon index 193615b82f..4646bef2b3 100644 --- a/desk/app/groups-ui.hoon +++ b/desk/app/groups-ui.hoon @@ -7,8 +7,9 @@ |% +$ card card:agent:gall +$ current-state - $: %2 + $: %3 hidden-contact-suggestions=(set ship) + manual-contact-suggestions=(set ship) pins=(list whom:u) first-load=? == @@ -90,18 +91,27 @@ =? old ?=(~ old) *current-state =? old ?=(%0 -.old) (state-0-to-1 old) =? old ?=(%1 -.old) (state-1-to-2 old) - ?> ?=(%2 -.old) + =? old ?=(%2 -.old) (state-2-to-3 old) + ?> ?=(%3 -.old) =. state old init :: - +$ versioned-state $@(~ $%(state-2 state-1 state-0)) - +$ state-2 current-state + +$ versioned-state $@(~ $%(state-3 state-2 state-1 state-0)) + +$ state-3 current-state + +$ state-2 + $: %2 + hidden-contact-suggestions=(set ship) + pins=(list whom:u) + first-load=? + == +$ state-1 $: %1 pins=(list whom:u) first-load=? == - :: + :: + ++ state-2-to-3 + |=(state-2 [%3 hidden-contact-suggestions ~ pins first-load]) ++ state-1-to-2 |=(state-1 [%2 ~ pins first-load]) +$ state-0 [%0 first-load=?] @@ -227,6 +237,12 @@ =. hidden-contact-suggestions (~(put in hidden-contact-suggestions) ship) cor + :: + %ui-add-contact-suggestions + =+ ship-list=!<((list @p) vase) + =. manual-contact-suggestions + (~(gas in manual-contact-suggestions) ship-list) + cor :: %ui-vita-toggle =+ !<(=vita-enabled:u vase) @@ -269,7 +285,7 @@ == ++ get-suggested-contacts =+ .^(chat-running=? (scry %gu %chat /$)) - =| suggestions=(set ship) + =/ suggestions=(set ship) manual-contact-suggestions =? suggestions chat-running =+ .^ [dms=(map ship dm:c) *] (scry %gx %chat /full/noun) diff --git a/desk/mar/ships.hoon b/desk/mar/ships.hoon index e8a882d113..c04d494559 100644 --- a/desk/mar/ships.hoon +++ b/desk/mar/ships.hoon @@ -10,5 +10,6 @@ ++ grab |% ++ noun ,(set ship) + ++ json (ar:dejs:format ship:dejs:j) -- -- diff --git a/desk/mar/ui/add-contact-suggestions.hoon b/desk/mar/ui/add-contact-suggestions.hoon new file mode 100644 index 0000000000..5e6a1552f7 --- /dev/null +++ b/desk/mar/ui/add-contact-suggestions.hoon @@ -0,0 +1,2 @@ +/= mark /mar/ships +mark diff --git a/packages/shared/src/api/contactsApi.ts b/packages/shared/src/api/contactsApi.ts index 9f720645e0..84eabbdb05 100644 --- a/packages/shared/src/api/contactsApi.ts +++ b/packages/shared/src/api/contactsApi.ts @@ -47,11 +47,19 @@ export const removeContactSuggestion = async (contactId: string) => { }); }; -export const addContacts = async (contactIds: string[]) => { +export const addContactSuggestions = async (contactIds: string[]) => { + return poke({ + app: 'groups-ui', + mark: 'ui-add-contact-suggestions', + json: contactIds, + }); +}; + +export const syncUserProfiles = async (userIds: string[]) => { return poke({ app: 'contacts', - mark: 'contact-action', - json: { heed: contactIds }, + mark: 'contact-action-1', + json: { meet: userIds }, }); }; diff --git a/packages/shared/src/db/keyValue.ts b/packages/shared/src/db/keyValue.ts index 77da3c9889..3678ebf8d2 100644 --- a/packages/shared/src/db/keyValue.ts +++ b/packages/shared/src/db/keyValue.ts @@ -278,6 +278,11 @@ export const finishingSelfHostedLogin = createStorageItem({ defaultValue: false, }); +export const groupsUsedForSuggestions = createStorageItem({ + key: 'groupsUsedForSuggestions', + defaultValue: [], +}); + export const postDraft = (opts: { key: string; type: 'caption' | 'text' | undefined; // matches GalleryDraftType diff --git a/packages/shared/src/db/queries.ts b/packages/shared/src/db/queries.ts index 4a7f93516a..6ce7821306 100644 --- a/packages/shared/src/db/queries.ts +++ b/packages/shared/src/db/queries.ts @@ -153,6 +153,22 @@ export const getGroupPreviews = createReadQuery( ['groups'] ); +// TODO: inefficient, should optimize +export const getGroupsWithMemberThreshold = createReadQuery( + 'getGroupsWithMemberThreshold', + async (threshold: number, ctx: QueryCtx) => { + const allJoinedWithMembers = await ctx.db.query.groups.findMany({ + where: eq($groups.currentUserIsMember, true), + with: { + members: true, + }, + }); + + return allJoinedWithMembers.filter((g) => g.members.length <= threshold); + }, + ['groups'] +); + export const getGroups = createReadQuery( 'getGroups', async ( diff --git a/packages/shared/src/store/contactActions.ts b/packages/shared/src/store/contactActions.ts index edbd8658eb..93a5bf333c 100644 --- a/packages/shared/src/store/contactActions.ts +++ b/packages/shared/src/store/contactActions.ts @@ -1,6 +1,7 @@ import * as api from '../api'; import * as db from '../db'; import { createDevLogger } from '../debug'; +import { syncContacts, syncGroup } from './sync'; const logger = createDevLogger('ContactActions', false); @@ -71,6 +72,142 @@ export async function removeContactSuggestion(contactId: string) { } } +export async function addContactSuggestions(contactIds: string[]) { + // optimistic update + const contacts = await db.getContacts(); + const toUpdate = contacts.filter( + (c) => contactIds.includes(c.id) && !c.isContact + ); + const optimisticUpdates = toUpdate.map((contact) => + db.updateContact({ id: contact.id, isContactSuggestion: true }) + ); + await Promise.all(optimisticUpdates); + + try { + await api.addContactSuggestions(contactIds); + } catch (e) { + // Intentionally unhandled, make a best effort to persist the suggestions + // failure is acceptable + } +} + +export async function findContactSuggestions() { + const runContext: Record = {}; + const currentUserId = api.getCurrentUserId(); + const GROUP_SIZE_LIMIT = 32; // arbitrary + const MAX_SUGGESTIONS = 6; // arbitrary + + try { + // first see if we have any joined groups and seem to be a somewhat + // new user + const groups = await db.getGroups({ includeUnjoined: false }); + runContext.joinedGroups = groups.length; + const hasFewGroups = groups.length < 4; + runContext.hasFewGroups = hasFewGroups; + + if (groups.length > 0 && hasFewGroups) { + logger.crumb('Found joined groups'); + // if yes, see if we have new groups and if some are small enough that + // grabbing suggestions at random might be worthwhile + const groupSyncs = groups.map((group) => syncGroup(group.id)); // sync member lists + await Promise.all(groupSyncs); + + const groupchats = + await db.getGroupsWithMemberThreshold(GROUP_SIZE_LIMIT); + runContext.groupsWithinSizeLimit = groupchats.length; + const groupsFromLastRun = await db.groupsUsedForSuggestions.getValue(); + const haveSomeNewGroups = groupchats.some( + (gc) => !groupsFromLastRun.includes(gc.id) + ); + runContext.haveSomeNewGroups = haveSomeNewGroups; + if (groupchats.length > 0 && haveSomeNewGroups) { + logger.crumb('Found groups under size limit'); + // if some are, load the profiles of all(?) members + const allRelevantMembers = groupchats + .reduce((acc, group) => { + return acc.concat(group.members.map((mem) => mem.contactId)); + }, [] as string[]) + .filter((mem) => mem !== currentUserId); + + logger.crumb(`Found ${allRelevantMembers.length} relevant members`); + + await api.syncUserProfiles(allRelevantMembers); + // hack: we don't track when the profiles actually populate, so wait a bit then resync + await new Promise((resolve) => setTimeout(resolve, 5000)); + await syncContacts(); + + logger.crumb('Synced profiles and contacts'); + + const contacts = await db.getContacts(); + const memberSet = new Set(allRelevantMembers); + const memberContacts = contacts.filter( + (c) => memberSet.has(c.id) && !c.isContact && !c.isContactSuggestion + ); + runContext.relevantMembers = memberContacts.length; + + // welcome to my suggestion ranking algorithm + const contactScores = memberContacts.map((contact) => { + let score = 0; + if (contact.nickname) { + score += 10; + } + + if (contact.pinnedGroups.length > 0) { + score += 5; + } + + if (contact.avatarImage) { + score += 3; + } + + if (contact.bio) { + score += 2; + } + + if (contact.status) { + score += 1; + } + + return { userId: contact.id, score }; + }); + + contactScores + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score); + logger.crumb('Scored relevant members'); + + const suggestions = contactScores + .slice(0, MAX_SUGGESTIONS) + .map((s) => s.userId); + runContext.suggestions = suggestions.length; + + logger.crumb(`Found ${suggestions.length} suggestions`); + db.groupsUsedForSuggestions.setValue(groupchats.map((g) => g.id)); + + if (suggestions.length > 0) { + await addContactSuggestions(suggestions); + logger.trackEvent('Client Contact Suggestions', { + ...runContext, + suggestionsFound: true, + }); + return true; + } + } + } + logger.trackEvent('Client Contact Suggestions', { + ...runContext, + suggestionsFound: false, + }); + } catch (e) { + logger.trackError('Client Contact Suggestions Failure', { + errorMessage: e.message, + errorStack: e.stack, + }); + } + logger.log('No suggestions added'); + return false; +} + export async function updateContactMetadata( contactId: string, metadata: { diff --git a/packages/shared/src/store/sync.ts b/packages/shared/src/store/sync.ts index bed4de2db6..034e4ace09 100644 --- a/packages/shared/src/store/sync.ts +++ b/packages/shared/src/store/sync.ts @@ -12,6 +12,7 @@ import { INFINITE_ACTIVITY_QUERY_KEY, resetActivityFetchers, } from '../store/useActivityFetchers'; +import { findContactSuggestions } from './contactActions'; import { useLureState } from './lure'; import { getSyncing, updateIsSyncing, updateSession } from './session'; import { SyncCtx, SyncPriority, syncQueue } from './syncQueue'; @@ -1174,6 +1175,10 @@ export const syncStart = async (alreadySubscribed?: boolean) => { }); updateIsSyncing(false); + + // finding contacts is a bit of an outlier here, but it's work we need to do + // that can roughly be batched whenever we sync + findContactSuggestions(); }; export const setupHighPrioritySubscriptions = async (ctx?: SyncCtx) => { diff --git a/packages/ui/src/components/ContactsScreenView.tsx b/packages/ui/src/components/ContactsScreenView.tsx index a93ff9aa30..f4a5b4b424 100644 --- a/packages/ui/src/components/ContactsScreenView.tsx +++ b/packages/ui/src/components/ContactsScreenView.tsx @@ -25,12 +25,6 @@ interface Section { export function ContactsScreenView(props: Props) { const currentUserId = useCurrentUserId(); const userContact = useContact(currentUserId); - const trimmedSuggested = useMemo(() => { - if (props.suggestions.length < 4 || props.contacts.length === 0) { - return props.suggestions; - } - return props.suggestions.slice(0, 4); - }, [props.contacts, props.suggestions]); const sortedContacts = useSortedContacts({ contacts: props.contacts, @@ -52,15 +46,15 @@ export function ContactsScreenView(props: Props) { }); } - if (trimmedSuggested.length > 0) { + if (props.suggestions.length > 0) { result.push({ title: 'Suggested from %pals and DMs', - data: trimmedSuggested, + data: props.suggestions, }); } return result; - }, [userContact, sortedContacts, trimmedSuggested]); + }, [userContact, sortedContacts, props.suggestions]); const renderItem = useCallback( ({ item }: { item: db.Contact }) => {