Files
mutiny-web/src/components/Activity.tsx
2024-04-10 17:17:15 -05:00

428 lines
13 KiB
TypeScript

import { TagItem } from "@mutinywallet/mutiny-wasm";
import { cache, createAsync, useNavigate } from "@solidjs/router";
import { Plus, Save, Search, Shuffle, Users } from "lucide-solid";
import {
createEffect,
createMemo,
createResource,
createSignal,
For,
Match,
Show,
Switch
} from "solid-js";
import {
ActivityDetailsModal,
Button,
ButtonCard,
ContactButton,
NiceP,
SimpleDialog
} from "~/components";
import { useI18n } from "~/i18n/context";
import { PrivacyLevel } from "~/routes";
import { useMegaStore } from "~/state/megaStore";
import {
actuallyFetchNostrProfile,
createDeepSignal,
hexpubFromNpub,
profileToPseudoContact,
PseudoContact,
timeAgo
} from "~/utils";
import { GenericItem } from "./GenericItem";
export type HackActivityType =
| "Lightning"
| "OnChain"
| "ChannelOpen"
| "ChannelClose";
export interface IActivityItem {
kind: HackActivityType;
id: string;
amount_sats: number;
inbound: boolean;
labels: string[];
contacts: TagItem[];
last_updated: number;
privacy_level: PrivacyLevel;
}
async function fetchContactForNpub(
npub: string
): Promise<PseudoContact | undefined> {
const hexpub = await hexpubFromNpub(npub);
if (!hexpub) {
return undefined;
}
const profile = await actuallyFetchNostrProfile(hexpub);
if (!profile) {
return undefined;
}
const pseudoContact = profileToPseudoContact(profile);
return pseudoContact;
}
export function UnifiedActivityItem(props: {
item: IActivityItem;
onClick: (id: string, kind: HackActivityType) => void;
onNewContactClick: (profile: PseudoContact) => void;
}) {
const navigate = useNavigate();
const click = () => {
props.onClick(
props.item.id,
props.item.kind as unknown as HackActivityType
);
};
const primaryContact = createMemo(() => {
if (props.item.contacts.length === 0) {
return undefined;
}
return props.item.contacts[0];
});
const getContact = cache(async (npub) => {
return await fetchContactForNpub(npub);
}, "profile");
const profileFromNostr = createAsync(async () => {
if (props.item.contacts.length === 0) {
if (props.item.labels) {
const npub = props.item.labels.find((l) =>
l.startsWith("npub")
);
if (npub) {
try {
const newContact = await getContact(npub);
return newContact;
} catch (e) {
console.error(e);
}
}
}
}
return undefined;
});
// TODO: figure out what other shit we should filter out
const message = () => {
const filtered = props.item.labels.filter(
(l) =>
l !== "SWAP" &&
!l.startsWith("LN Channel:") &&
!l.startsWith("npub")
);
if (filtered.length === 0) {
return undefined;
}
return filtered[0];
};
const shouldShowShuffle = () => {
return (
props.item.kind === "ChannelOpen" ||
props.item.kind === "ChannelClose" ||
(props.item.labels.length > 0 && props.item.labels[0] === "SWAP")
);
};
const verb = () => {
if (props.item.kind === "ChannelOpen") {
return "opened a";
}
if (props.item.kind === "ChannelClose") {
return "closed a";
}
if (props.item.labels.length > 0 && props.item.labels[0] === "SWAP") {
return "swapped to";
}
if (
props.item.labels.length > 0 &&
props.item.labels[0] === "Swept Force Close"
) {
return undefined;
}
return "sent";
};
const primaryName = () => {
return props.item.inbound ? primaryContact()?.name || "Unknown" : "You";
};
const secondaryName = () => {
if (props.item.labels.length > 0 && props.item.labels[0] === "SWAP") {
return "Lightning";
}
if (
props.item.kind === "ChannelOpen" ||
props.item.kind === "ChannelClose"
) {
return "Lightning channel";
}
if (!props.item.inbound) {
return primaryContact()?.name || "Unknown";
}
return "you";
};
const shouldShowGeneric = () => {
if (props.item.inbound && primaryName() === "Unknown") {
return true;
}
if (!props.item.inbound && secondaryName() === "Unknown") {
return true;
}
};
return (
<div class="pt-3 first-of-type:pt-0">
<GenericItem
primaryAvatarUrl={
primaryContact()?.image_url
? primaryContact()?.image_url
: profileFromNostr()?.primal_image_url || ""
}
icon={shouldShowShuffle() ? <Shuffle /> : undefined}
primaryOnClick={() =>
primaryContact()?.id
? navigate(`/chat/${primaryContact()?.id}`)
: profileFromNostr()
? props.onNewContactClick(profileFromNostr()!)
: undefined
}
amountOnClick={click}
primaryName={
props.item.inbound
? primaryContact()?.name
? primaryContact()!.name
: profileFromNostr()?.name || "Unknown"
: "You"
}
genericAvatar={shouldShowGeneric()}
verb={verb()}
message={message()}
secondaryName={secondaryName()}
amount={
props.item.amount_sats
? BigInt(props.item.amount_sats || 0)
: undefined
}
date={timeAgo(props.item.last_updated)}
accent={props.item.inbound ? "green" : undefined}
visibility={
props.item.privacy_level === "Public" ? "public" : "private"
}
/>
</div>
);
}
function NewContactModal(props: { profile: PseudoContact; close: () => void }) {
const i18n = useI18n();
const navigate = useNavigate();
const [state, _actions] = useMegaStore();
async function createContact() {
try {
const existingContact =
await state.mutiny_wallet?.get_contact_for_npub(
props.profile.hexpub
);
if (existingContact) {
navigate(`/chat/${existingContact.id}`);
return;
}
const contactId = await state.mutiny_wallet?.create_new_contact(
props.profile.name,
props.profile.hexpub,
props.profile.ln_address,
props.profile.lnurl,
props.profile.image_url
);
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");
}
navigate(`/chat/${contactId}`);
} catch (e) {
console.error(e);
}
}
return (
<SimpleDialog
title={i18n.t("activity.start_a_chat")}
open
setOpen={() => {
props.close();
}}
>
<NiceP>{i18n.t("activity.start_a_chat_are_you_sure")}</NiceP>
<ContactButton contact={props.profile} onClick={() => {}} />
<div class="flex-end flex w-full justify-end gap-2">
<Button
layout="small"
intent="red"
onClick={() => props.close()}
>
{i18n.t("modals.confirm_dialog.cancel")}
</Button>
<Button layout="small" intent="blue" onClick={createContact}>
{i18n.t("common.continue")}
</Button>
</div>
</SimpleDialog>
);
}
export function CombinedActivity() {
const [state, _actions] = useMegaStore();
const i18n = useI18n();
const [detailsOpen, setDetailsOpen] = createSignal(false);
const [detailsKind, setDetailsKind] = createSignal<HackActivityType>();
const [detailsId, setDetailsId] = createSignal("");
const navigate = useNavigate();
function openDetailsModal(id: string, kind: HackActivityType) {
console.log("Opening details modal: ", id, kind);
if (!id) {
console.warn("No id provided to openDetailsModal");
return;
}
setDetailsId(id);
setDetailsKind(kind);
setDetailsOpen(true);
}
async function getActivity() {
try {
console.log("refetching activity");
const activity = await state.mutiny_wallet?.get_activity(
50,
undefined
);
if (!activity) return [];
return activity as IActivityItem[];
} catch (e) {
console.error(e);
return [] as IActivityItem[];
}
}
const [activity, { refetch }] = createResource(getActivity, {
initialValue: [],
storage: createDeepSignal
});
createEffect(() => {
// Should re-run after every sync
if (!state.is_syncing) {
refetch();
}
});
const [newContact, setNewContact] = createSignal<PseudoContact>();
return (
<>
<Show when={detailsId() && detailsKind()}>
<ActivityDetailsModal
open={detailsOpen()}
kind={detailsKind()}
id={detailsId()}
setOpen={setDetailsOpen}
/>
</Show>
<Show when={newContact()}>
<NewContactModal
profile={newContact()!}
close={() => setNewContact(undefined)}
/>
</Show>
<Switch>
<Match when={activity().length === 0}>
<Show when={state.federations?.length === 0}>
<ButtonCard
onClick={() => navigate("/settings/federations")}
>
<div class="flex items-center gap-2">
<Users class="inline-block text-m-red" />
<NiceP>{i18n.t("home.federation")}</NiceP>
</div>
</ButtonCard>
</Show>
<ButtonCard onClick={() => navigate("/receive")}>
<div class="flex items-center gap-2">
<Plus class="inline-block text-m-red" />
<NiceP>{i18n.t("home.receive")}</NiceP>
</div>
</ButtonCard>
<ButtonCard onClick={() => navigate("/search")}>
<div class="flex items-center gap-2">
<Search class="inline-block text-m-red" />
<NiceP>{i18n.t("home.find")}</NiceP>
</div>
</ButtonCard>
<Show when={!state.has_backed_up}>
<ButtonCard
onClick={() => navigate("/settings/backup")}
>
<div class="flex items-center gap-2">
<Save class="inline-block text-m-red" />
<NiceP>{i18n.t("home.backup")}</NiceP>
</div>
</ButtonCard>
</Show>
</Match>
<Match when={activity().length >= 0}>
<Show when={!state.has_backed_up}>
<ButtonCard
onClick={() => navigate("/settings/backup")}
>
<div class="flex items-center gap-2">
<Save class="inline-block text-m-red" />
<NiceP>{i18n.t("home.backup")}</NiceP>
</div>
</ButtonCard>
</Show>
<div class="flex w-full flex-col divide-y divide-m-grey-800 overflow-x-clip">
<For each={activity()}>
{(activityItem) => (
<UnifiedActivityItem
item={activityItem}
onClick={openDetailsModal}
onNewContactClick={setNewContact}
/>
)}
</For>
</div>
</Match>
</Switch>
</>
);
}