Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add global contact search #811

Merged
merged 1 commit into from
Jan 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
Loading