Skip to content

Commit

Permalink
Merge pull request #4245 from tloncorp/lb/find-suggested-contacts
Browse files Browse the repository at this point in the history
native: find contact suggestions
  • Loading branch information
latter-bolden authored Dec 4, 2024
2 parents b14f71c + afa2b0e commit 5c5fb36
Show file tree
Hide file tree
Showing 9 changed files with 202 additions and 18 deletions.
28 changes: 22 additions & 6 deletions desk/app/groups-ui.hoon
Original file line number Diff line number Diff line change
Expand Up @@ -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=?
==
Expand Down Expand Up @@ -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=?]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions desk/mar/ships.hoon
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
++ grab
|%
++ noun ,(set ship)
++ json (ar:dejs:format ship:dejs:j)
--
--
2 changes: 2 additions & 0 deletions desk/mar/ui/add-contact-suggestions.hoon
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/= mark /mar/ships
mark
14 changes: 11 additions & 3 deletions packages/shared/src/api/contactsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
};

Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/db/keyValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,11 @@ export const finishingSelfHostedLogin = createStorageItem<boolean>({
defaultValue: false,
});

export const groupsUsedForSuggestions = createStorageItem<string[]>({
key: 'groupsUsedForSuggestions',
defaultValue: [],
});

export const postDraft = (opts: {
key: string;
type: 'caption' | 'text' | undefined; // matches GalleryDraftType
Expand Down
16 changes: 16 additions & 0 deletions packages/shared/src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
137 changes: 137 additions & 0 deletions packages/shared/src/store/contactActions.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand Down Expand Up @@ -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<string, any> = {};
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: {
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/store/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down
12 changes: 3 additions & 9 deletions packages/ui/src/components/ContactsScreenView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 }) => {
Expand Down

0 comments on commit 5c5fb36

Please sign in to comment.