mirror of
https://github.com/aljazceru/mutiny-web.git
synced 2026-02-02 13:04:22 +01:00
428 lines
13 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|