Skip to content

Commit

Permalink
add global contact search
Browse files Browse the repository at this point in the history
  • Loading branch information
futurepaul committed Jan 11, 2024
1 parent a0722c5 commit 24a4ea5
Show file tree
Hide file tree
Showing 2 changed files with 238 additions and 11 deletions.
188 changes: 178 additions & 10 deletions src/routes/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {
createResource,
createSignal,
For,
Match,
onMount,
Show,
Suspense
Suspense,
Switch
} from "solid-js";

import close from "~/assets/icons/close.svg";
Expand All @@ -19,6 +21,7 @@ import {
ContactEditor,
ContactFormValues,
LabelCircle,
LoadingShimmer,
NavBar,
showToast
} from "~/components";
Expand All @@ -31,6 +34,13 @@ import {
} from "~/components/layout";
import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore";
import {
actuallyFetchNostrProfile,
hexpubFromNpub,
profileToPseudoContact,
PseudoContact,
searchProfiles
} from "~/utils";

export function Search() {
return (
Expand Down Expand Up @@ -65,7 +75,6 @@ function ActualSearch() {

async function contactsFetcher() {
try {
console.log("getting contacts");
const contacts: TagItem[] =
state.mutiny_wallet?.get_contacts_sorted();
return contacts || [];
Expand All @@ -78,11 +87,10 @@ function ActualSearch() {
const [contacts] = createResource(contactsFetcher);

const filteredContacts = createMemo(() => {
const s = searchValue().toLowerCase();
return (
contacts()?.filter((c) => {
const s = searchValue().toLowerCase();
return (
//
c.ln_address &&
(c.name.toLowerCase().includes(s) ||
c.ln_address?.toLowerCase().includes(s) ||
Expand All @@ -92,6 +100,14 @@ function ActualSearch() {
);
});

const foundNpubs = createMemo(() => {
return (
filteredContacts()
?.map((c) => c.npub)
.filter((n) => !!n) || []
);
});

const showSendButton = createMemo(() => {
if (searchValue() === "") {
return false;
Expand All @@ -104,12 +120,10 @@ function ActualSearch() {
let success = false;
actions.handleIncomingString(
text,
(error) => {
// showToast(error);
console.log("error", error);
(_error) => {
// noop
},
(result) => {
console.log("result", result);
(_result) => {
success = true;
}
);
Expand Down Expand Up @@ -258,7 +272,7 @@ function ActualSearch() {
Continue
</Button>
</Show>
<div class="flex h-full flex-col gap-3 overflow-y-scroll">
<div class="relative flex h-full max-h-[100svh] flex-col gap-3 overflow-y-scroll">
<div class="sticky top-0 z-50 bg-m-grey-900/90 py-2 backdrop-blur-sm">
<h2 class="text-xl font-semibold">Contacts</h2>
</div>
Expand Down Expand Up @@ -290,8 +304,162 @@ function ActualSearch() {
</For>
</Show>
<ContactEditor createContact={createContact} />

<Show when={!!searchValue()}>
<h2 class="py-2 text-xl font-semibold">Global Search</h2>
<Suspense fallback={<LoadingShimmer />}>
<GlobalSearch
searchValue={searchValue()}
sendToContact={sendToContact}
foundNpubs={foundNpubs()}
/>
</Suspense>
</Show>
<div class="h-4" />
</div>
</>
);
}

function GlobalSearch(props: {
searchValue: string;
sendToContact: (contact: TagItem) => void;
foundNpubs: (string | undefined)[];
}) {
const hexpubs = createMemo(() => {
const hexpubs: string[] = [];
for (const npub of props.foundNpubs) {
hexpubFromNpub(npub)
.then((h) => {
if (h) {
hexpubs.push(h);
}
})
.catch((e) => {
console.error(e);
});
}
return hexpubs;
});

async function searchFetcher(args: { value?: string; hexpubs?: string[] }) {
try {
// Handling case when value starts with "npub"
if (args.value?.startsWith("npub")) {
const hexpub = await hexpubFromNpub(args.value);
if (!hexpub) return [];

const profile = await actuallyFetchNostrProfile(hexpub);
if (!profile) return [];

const contact = profileToPseudoContact(profile);
return contact.ln_address ? [contact] : [];
}

// Handling case for other values (name, nip-05, whatever else primal searches)
const contacts = await searchProfiles(args.value!.toLowerCase());
return contacts.filter(
(c) => c.ln_address && !args.hexpubs?.includes(c.hexpub)
);
} catch (e) {
console.error(e);
return [];
}
}

const searchArgs = createMemo(() => {
if (props.searchValue) {
return {
value: props.searchValue,
hexpubs: hexpubs()
};
} else {
return {
value: "",
hexpubs: undefined
};
}
});

const [searchResults] = createResource(searchArgs, searchFetcher);

return (
<Switch>
<Match
when={
!!props.searchValue &&
searchResults.state === "ready" &&
searchResults()?.length === 0
}
>
<p class="text-neutral-500">
No results found for "{props.searchValue}"
</p>
</Match>
<Match when={true}>
<For each={searchResults()}>
{(contact) => (
<SingleContact
contact={contact}
sendToContact={props.sendToContact}
/>
)}
</For>
</Match>
</Switch>
);
}

function SingleContact(props: {
contact: PseudoContact;
sendToContact: (contact: TagItem) => void;
}) {
const [state, _actions] = useMegaStore();
async function createContactFromSearchResult(contact: PseudoContact) {
try {
const contactId = await state.mutiny_wallet?.create_new_contact(
contact.name,
contact.hexpub ? contact.hexpub : undefined,
contact.ln_address ? contact.ln_address : undefined,
undefined,
contact.image_url ? contact.image_url : undefined
);

if (!contactId) {
throw new Error("no contact id returned");
}

const tagItem = await state.mutiny_wallet?.get_tag_item(contactId);

if (!tagItem) {
throw new Error("no contact returned");
}

props.sendToContact(tagItem);
} catch (e) {
console.error(e);
}
}

return (
<button
onClick={() => createContactFromSearchResult(props.contact)}
class="flex items-center gap-2"
>
<LabelCircle
name={props.contact.name}
image_url={props.contact.image_url}
contact
label={false}
/>
<div class="flex flex-col items-start">
<h2 class="overflow-hidden overflow-ellipsis text-base font-semibold">
{props.contact.name}
</h2>
<h3 class="overflow-hidden overflow-ellipsis text-sm font-normal text-neutral-500">
{props.contact.ln_address}
</h3>
</div>
</button>
);
}
61 changes: 60 additions & 1 deletion src/utils/fetchZaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type SimpleZapItem = {
content?: string;
};

type NostrProfile = {
export type NostrProfile = {
id: string;
pubkey: string;
created_at: number;
Expand Down Expand Up @@ -288,6 +288,10 @@ export const fetchNostrProfile: ResourceFetcher<
string,
NostrProfile | undefined
> = async (hexpub, _info) => {
return await actuallyFetchNostrProfile(hexpub);
};

export async function actuallyFetchNostrProfile(hexpub: string) {
try {
if (!PRIMAL_API)
throw new Error("Missing PRIMAL_API environment variable");
Expand Down Expand Up @@ -315,4 +319,59 @@ export const fetchNostrProfile: ResourceFetcher<
console.error("Failed to load profile: ", e);
throw new Error("Failed to load profile");
}
}

// Search results from primal have some of the stuff we want for a TagItem contact
export type PseudoContact = {
name: string;
hexpub: string;
ln_address?: string;
image_url?: string;
};

export async function searchProfiles(query: string): Promise<PseudoContact[]> {
console.log("searching profiles...");
const response = await fetch(PRIMAL_API, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify([
"user_search",
{ query: query.trim(), limit: 10 }
])
});

if (!response.ok) {
throw new Error(`Failed to search`);
}

const data = await response.json();

const users: PseudoContact[] = [];

for (const object of data) {
if (object.kind === 0) {
try {
const profile = object as NostrProfile;
const contact = profileToPseudoContact(profile);
users.push(contact);
} catch (e) {
console.error("Failed to parse content: ", object.content);
}
}
}

return users;
}

export function profileToPseudoContact(profile: NostrProfile): PseudoContact {
const content = JSON.parse(profile.content);
const contact: Partial<PseudoContact> = {
hexpub: profile.pubkey
};
contact.name = content.display_name || content.name || profile.pubkey;
contact.ln_address = content.lud16 || undefined;
contact.image_url = content.picture || undefined;
return contact as PseudoContact;
}

0 comments on commit 24a4ea5

Please sign in to comment.