This commit is contained in:
Paul Miller
2023-07-08 17:34:08 -05:00
parent 3b6975a0d9
commit c33e542932
12 changed files with 433 additions and 110 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 B

BIN
src/assets/party.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

View File

@@ -1,10 +1,19 @@
import { NiceP } from "./layout"; import { NiceP } from "./layout";
import { For, Match, Show, Switch, createEffect, createSignal } from "solid-js"; import {
For,
Match,
Show,
Suspense,
Switch,
createEffect,
createSignal
} from "solid-js";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
import { Contact } from "@mutinywallet/mutiny-wasm"; import { Contact } from "@mutinywallet/mutiny-wasm";
import { ActivityItem, HackActivityType } from "./ActivityItem"; import { ActivityItem, HackActivityType } from "./ActivityItem";
import { DetailsIdModal } from "./DetailsModal"; import { DetailsIdModal } from "./DetailsModal";
import { LoadingShimmer } from "./BalanceBox";
export const THREE_COLUMNS = export const THREE_COLUMNS =
"grid grid-cols-[auto,1fr,auto] gap-4 py-2 px-2 border-b border-neutral-800 last:border-b-0"; "grid grid-cols-[auto,1fr,auto] gap-4 py-2 px-2 border-b border-neutral-800 last:border-b-0";
@@ -105,7 +114,7 @@ export function CombinedActivity(props: { limit?: number }) {
}); });
return ( return (
<> <Suspense fallback={<LoadingShimmer />}>
<Show when={detailsId() && detailsKind()}> <Show when={detailsId() && detailsKind()}>
<DetailsIdModal <DetailsIdModal
open={detailsOpen()} open={detailsOpen()}
@@ -117,7 +126,9 @@ export function CombinedActivity(props: { limit?: number }) {
<Switch> <Switch>
<Match when={state.activity.length === 0}> <Match when={state.activity.length === 0}>
<div class="w-full text-center pb-4"> <div class="w-full text-center pb-4">
<NiceP>{i18n.t("receive_some_sats_to_get_started")}</NiceP> <NiceP>
{i18n.t("receive_some_sats_to_get_started")}
</NiceP>
</div> </div>
</Match> </Match>
<Match <Match
@@ -143,6 +154,6 @@ export function CombinedActivity(props: { limit?: number }) {
</For> </For>
</Match> </Match>
</Switch> </Switch>
</> </Suspense>
); );
} }

View File

@@ -6,11 +6,12 @@ import { A } from "solid-start";
import { OnboardWarning } from "~/components/OnboardWarning"; import { OnboardWarning } from "~/components/OnboardWarning";
import { CombinedActivity } from "./Activity"; import { CombinedActivity } from "./Activity";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { Show } from "solid-js"; import { Match, Show, Switch, createMemo } from "solid-js";
import { ExternalLink } from "./layout/ExternalLink"; import { ExternalLink } from "./layout/ExternalLink";
import { BetaWarningModal } from "~/components/BetaWarningModal"; import { BetaWarningModal } from "~/components/BetaWarningModal";
import settings from "~/assets/icons/settings.svg"; import settings from "~/assets/icons/settings.svg";
import pixelLogo from "~/assets/mutiny-pixel-logo.png"; import pixelLogo from "~/assets/mutiny-pixel-logo.png";
import plusLogo from "~/assets/mutiny-plus-logo.png";
import { PendingNwc } from "./PendingNwc"; import { PendingNwc } from "./PendingNwc";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
@@ -23,12 +24,24 @@ export default function App() {
<DefaultMain> <DefaultMain>
<header class="w-full flex justify-between items-center mt-4 mb-2"> <header class="w-full flex justify-between items-center mt-4 mb-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<img <Switch>
id="mutiny-logo" <Match when={state.mutiny_plus}>
src={pixelLogo} <img
class="h-[25px] w-[75px]" id="mutiny-logo"
alt="Mutiny logo" src={plusLogo}
/> class="h-[25px] w-[86px]"
alt="Mutiny Plus logo"
/>
</Match>
<Match when={true}>
<img
id="mutiny-logo"
src={pixelLogo}
class="h-[25px] w-[75px]"
alt="Mutiny logo"
/>
</Match>
</Switch>
<Show <Show
when={ when={
!state.wallet_loading && !state.wallet_loading &&

View File

@@ -89,12 +89,11 @@ export function ImportExport(props: { emergency?: boolean }) {
import it into a new browser. It usually works! import it into a new browser. It usually works!
</NiceP> </NiceP>
<NiceP> <NiceP>
<strong class="font-semibold">Important caveats:</strong>{" "} <strong>Important caveats:</strong> after exporting don't do
after exporting don't do any operations in the original any operations in the original browser. If you do, you'll
browser. If you do, you'll need to export again. After a need to export again. After a successful import, a best
successful import, a best practice is to clear the state of practice is to clear the state of the original browser just
the original browser just to make sure you don't create to make sure you don't create conflicts.
conflicts.
</NiceP> </NiceP>
<div /> <div />
<VStack> <VStack>

View File

@@ -6,7 +6,6 @@ import {
For, For,
Match, Match,
Show, Show,
Suspense,
Switch, Switch,
createEffect, createEffect,
createResource, createResource,
@@ -53,9 +52,6 @@ export function PendingNwc() {
}); });
} }
} }
console.log(pendingItems);
return pendingItems; return pendingItems;
}); });
@@ -98,90 +94,84 @@ export function PendingNwc() {
}); });
return ( return (
<Suspense> <Show when={pendingRequests() && pendingRequests()!.length > 0}>
<Show when={pendingRequests() && pendingRequests()!.length > 0}> <Card title="Pending Requests">
<Card title="Pending Requests"> <div class="p-1" />
<div class="p-1" /> <VStack>
<VStack> <Show when={error()}>
<Show when={error()}> <InfoBox accent="red">{error()?.message}</InfoBox>
<InfoBox accent="red">{error()?.message}</InfoBox> </Show>
</Show> <For each={pendingRequests()}>
<For each={pendingRequests()}> {(pendingItem) => (
{(pendingItem) => ( <div class="grid grid-cols-[auto_minmax(0,_1fr)_minmax(0,_max-content)_auto] items-center pb-4 gap-4 border-b border-neutral-800 last:border-b-0">
<div class="grid grid-cols-[auto_minmax(0,_1fr)_minmax(0,_max-content)_auto] items-center pb-4 gap-4 border-b border-neutral-800 last:border-b-0"> <img
<img class="w-[1rem]"
class="w-[1rem]" src={bolt}
src={bolt} alt="onchain"
alt="onchain" />
/> <div class="flex flex-col">
<div class="flex flex-col"> <span class="text-base font-semibold truncate">
<span class="text-base font-semibold truncate"> {pendingItem.name_of_connection}
{pendingItem.name_of_connection} </span>
</span> <time class="text-sm text-neutral-500">
<time class="text-sm text-neutral-500"> Expires {timeAgo(pendingItem.date)}
Expires {timeAgo(pendingItem.date)} </time>
</time>
</div>
<div>
<ActivityAmount
amount={
pendingItem.amount_sats?.toString() ||
"0"
}
price={state.price}
/>
</div>
<div class="flex gap-2 w-[5rem]">
<Switch>
<Match
when={
paying() !== pendingItem.id
}
>
<button
onClick={() =>
payItem(pendingItem)
}
>
<img
class="h-[2.5rem] w-[2.5rem]"
src={greenCheck}
alt="Approve"
/>
</button>
<button
onClick={() =>
rejectItem(pendingItem)
}
>
<img
class="h-[2rem] w-[2rem]"
src={redClose}
alt="Reject"
/>
</button>
</Match>
<Match
when={
paying() === pendingItem.id
}
>
<LoadingSpinner wide />
</Match>
</Switch>
</div>
</div> </div>
)} <div>
</For> <ActivityAmount
</VStack> amount={
<A pendingItem.amount_sats?.toString() ||
href="/settings/connections" "0"
class="text-m-red active:text-m-red/80 font-semibold no-underline self-center" }
> price={state.price}
Configure />
</A> </div>
</Card> <div class="flex gap-2 w-[5rem]">
</Show> <Switch>
</Suspense> <Match
when={paying() !== pendingItem.id}
>
<button
onClick={() =>
payItem(pendingItem)
}
>
<img
class="h-[2.5rem] w-[2.5rem]"
src={greenCheck}
alt="Approve"
/>
</button>
<button
onClick={() =>
rejectItem(pendingItem)
}
>
<img
class="h-[2rem] w-[2rem]"
src={redClose}
alt="Reject"
/>
</button>
</Match>
<Match
when={paying() === pendingItem.id}
>
<LoadingSpinner wide />
</Match>
</Switch>
</div>
</div>
)}
</For>
</VStack>
<A
href="/settings/connections"
class="text-m-red active:text-m-red/80 font-semibold no-underline self-center"
>
Configure
</A>
</Card>
</Show>
); );
} }

View File

@@ -63,3 +63,7 @@ select {
background-size: 20px 20px; background-size: 20px 20px;
background-repeat: no-repeat; background-repeat: no-repeat;
} }
strong {
@apply font-semibold text-m-red;
}

View File

@@ -30,8 +30,6 @@ function Nwc() {
const profiles: NwcProfile[] = const profiles: NwcProfile[] =
await state.mutiny_wallet?.get_nwc_profiles(); await state.mutiny_wallet?.get_nwc_profiles();
console.log("profiles:", profiles);
return profiles; return profiles;
} catch (e) { } catch (e) {
console.error(e); console.error(e);

View File

@@ -0,0 +1,236 @@
import {
Match,
Show,
Suspense,
Switch,
createResource,
createSignal
} from "solid-js";
import { A } from "solid-start";
import { ConfirmDialog } from "~/components/Dialog";
import { InfoBox } from "~/components/InfoBox";
import NavBar from "~/components/NavBar";
import {
Button,
DefaultMain,
FancyCard,
LargeHeader,
MutinyWalletGuard,
NiceP,
SafeArea,
TinyText,
VStack
} from "~/components/layout";
import { BackLink } from "~/components/layout/BackLink";
import { useMegaStore } from "~/state/megaStore";
import eify from "~/utils/eify";
import party from "~/assets/party.gif";
import { LoadingShimmer } from "~/components/BalanceBox";
function Perks(props: { alreadySubbed?: boolean }) {
return (
<ul class="list-disc ml-8 font-light text-lg">
<Show when={props.alreadySubbed}>
<li>Smug satisfaction</li>
</Show>
<li>
Redshift <em>(coming soon)</em>
</li>
<li>
Gifting <em>(coming soon)</em>
</li>
<li>
Multi-device access <em>(coming soon)</em>
</li>
<li>... and more to come</li>
</ul>
);
}
function PlusCTA() {
const [state, actions] = useMegaStore();
const [subbing, setSubbing] = createSignal(false);
const [confirmOpen, setConfirmOpen] = createSignal(false);
const [restoring, setRestoring] = createSignal(false);
const [error, setError] = createSignal<Error>();
const [planDetails] = createResource(async () => {
try {
const plans = await state.mutiny_wallet?.get_subscription_plans();
console.log("plans:", plans);
if (!plans) return undefined;
return plans[0];
} catch (e) {
console.error(e);
}
});
async function handleConfirm() {
try {
setSubbing(true);
setError(undefined);
if (planDetails()?.id === undefined || planDetails()?.id === null)
throw new Error("No plans found");
const invoice = await state.mutiny_wallet?.subscribe_to_plan(
planDetails().id
);
if (!invoice?.bolt11) throw new Error("Couldn't subscribe");
await state.mutiny_wallet?.pay_subscription_invoice(
invoice?.bolt11
);
// "true" flag gives this a fallback to set a timestamp in case the subscription server is down
await actions.checkForSubscription(true);
} catch (e) {
console.error(e);
setError(eify(e));
} finally {
setConfirmOpen(false);
setSubbing(false);
}
}
async function restore() {
try {
setError(undefined);
setRestoring(true);
await actions.checkForSubscription();
if (!state.subscription_timestamp) {
setError(new Error("No existing subscription found"));
}
} catch (e) {
console.error(e);
setError(eify(e));
} finally {
setRestoring(false);
}
}
const hasEnough = () => {
if (!planDetails()) return false;
return (state.balance?.lightning || 0n) > planDetails().amount_sat;
};
return (
<Show when={planDetails()}>
<VStack>
<NiceP>
Join <strong class="text-white">Mutiny+</strong> for{" "}
{Number(planDetails().amount_sat).toLocaleString()} sats a
month.
</NiceP>
<Show when={error()}>
<InfoBox accent="red">{error()!.message}</InfoBox>
</Show>
<Show when={!hasEnough()}>
<TinyText>
You'll need at least{" "}
{Number(planDetails().amount_sat).toLocaleString()} sats
in your lightning balance to get started. Try before you
buy!
</TinyText>
</Show>
<div class="flex gap-2">
<Button
intent="red"
layout="flex"
onClick={() => setConfirmOpen(true)}
disabled={!hasEnough()}
>
Join
</Button>
<Button
intent="green"
layout="flex"
onClick={restore}
loading={restoring()}
>
Restore Subscription
</Button>
</div>
</VStack>
<ConfirmDialog
loading={subbing()}
open={confirmOpen()}
onConfirm={handleConfirm}
onCancel={() => setConfirmOpen(false)}
>
<p>
Ready to join <strong class="text-white">Mutiny+</strong>?
Click confirm to pay for your first month.
</p>
</ConfirmDialog>
</Show>
);
}
export default function Plus() {
const [state, _actions] = useMegaStore();
return (
<MutinyWalletGuard>
<SafeArea>
<DefaultMain>
<BackLink href="/settings" title="Settings" />
<LargeHeader>Mutiny+</LargeHeader>
<VStack>
<Switch>
<Match when={state.mutiny_plus}>
<img src={party} class="w-1/2 mx-auto" />
<NiceP>
You're part of the mutiny! Enjoy the
following perks:
</NiceP>
<Perks alreadySubbed />
<NiceP>
You'll get a renewal payment request around{" "}
<strong class="text-white">
{new Date(
state.subscription_timestamp! * 1000
).toLocaleDateString()}
</strong>
.
</NiceP>
<NiceP>
To cancel your subscription just don't pay.
You can also disable the Mutiny+{" "}
<A href="/settings/connections">
Wallet Connection.
</A>
</NiceP>
</Match>
<Match when={!state.mutiny_plus}>
<NiceP>
Mutiny is open source and self-hostable.{" "}
<strong>
But also you can pay for it.
</strong>
</NiceP>
<NiceP>
Paying for{" "}
<strong class="text-white">Mutiny+</strong>{" "}
helps support ongoing development and
unlocks early access to new features and
premium functionality:
</NiceP>
<Perks />
<FancyCard title="Subscribe">
<Suspense fallback={<LoadingShimmer />}>
<PlusCTA />
</Suspense>
</FancyCard>
</Match>
</Switch>
</VStack>
</DefaultMain>
<NavBar activeTab="settings" />
</SafeArea>
</MutinyWalletGuard>
);
}

View File

@@ -58,6 +58,15 @@ export default function Settings() {
<BackLink /> <BackLink />
<LargeHeader>Settings</LargeHeader> <LargeHeader>Settings</LargeHeader>
<VStack biggap> <VStack biggap>
<SettingsLinkList
header="Mutiny+"
links={[
{
href: "/settings/plus",
text: "Learn how to support Mutiny"
}
]}
/>
<SettingsLinkList <SettingsLinkList
header="General" header="General"
links={[ links={[

View File

@@ -24,7 +24,7 @@ import { ActivityItem } from "~/components/Activity";
const MegaStoreContext = createContext<MegaStore>(); const MegaStoreContext = createContext<MegaStore>();
type UserStatus = undefined | "new_here" | "waitlisted" | "approved" | "paid"; type UserStatus = undefined | "new_here" | "waitlisted" | "approved";
export type MegaStore = [ export type MegaStore = [
{ {
@@ -45,6 +45,8 @@ export type MegaStore = [
setup_error?: Error; setup_error?: Error;
is_pwa: boolean; is_pwa: boolean;
existing_tab_detected: boolean; existing_tab_detected: boolean;
subscription_timestamp?: number;
readonly mutiny_plus: boolean;
}, },
{ {
fetchUserStatus(): Promise<UserStatus>; fetchUserStatus(): Promise<UserStatus>;
@@ -58,6 +60,7 @@ export type MegaStore = [
listTags(): Promise<MutinyTagItem[]>; listTags(): Promise<MutinyTagItem[]>;
syncActivity(): Promise<void>; syncActivity(): Promise<void>;
checkBrowserCompat(): Promise<boolean>; checkBrowserCompat(): Promise<boolean>;
checkForSubscription(justPaid?: boolean): Promise<void>;
} }
]; ];
@@ -82,7 +85,17 @@ export const Provider: ParentComponent = (props) => {
activity: [] as ActivityItem[], activity: [] as ActivityItem[],
setup_error: undefined as Error | undefined, setup_error: undefined as Error | undefined,
is_pwa: window.matchMedia("(display-mode: standalone)").matches, is_pwa: window.matchMedia("(display-mode: standalone)").matches,
existing_tab_detected: false existing_tab_detected: false,
subscription_timestamp: undefined as number | undefined,
get mutiny_plus(): boolean {
// No subscription
if (!state.subscription_timestamp) return false;
// Expired
if (state.subscription_timestamp < Math.ceil(Date.now() / 1000))
return false;
else return true;
}
}); });
const actions = { const actions = {
@@ -127,6 +140,26 @@ export const Provider: ParentComponent = (props) => {
return "new_here"; return "new_here";
} }
}, },
async checkForSubscription(justPaid?: boolean): Promise<void> {
try {
const timestamp = await state.mutiny_wallet?.check_subscribed();
console.log("timestamp:", timestamp);
if (timestamp) {
localStorage.setItem(
"subscription_timestamp",
timestamp?.toString()
);
setState({ subscription_timestamp: Number(timestamp) });
}
} catch (e) {
if (justPaid) {
// we make a fake timestamp for 24 hours from now, in case the server is down
const timestamp = Math.ceil(Date.now() / 1000) + 86400;
setState({ subscription_timestamp: timestamp });
}
console.error(e);
}
},
async setupMutinyWallet( async setupMutinyWallet(
settings?: MutinyWalletSettingStrings settings?: MutinyWalletSettingStrings
): Promise<void> { ): Promise<void> {
@@ -136,12 +169,43 @@ export const Provider: ParentComponent = (props) => {
throw state.setup_error; throw state.setup_error;
} }
setState({ wallet_loading: true }); setState({ wallet_loading: true });
// This is where all the real setup happens
const mutinyWallet = await setupMutinyWallet(settings); const mutinyWallet = await setupMutinyWallet(settings);
// Get balance optimistically // Get balance optimistically
const balance = await mutinyWallet.get_balance(); const balance = await mutinyWallet.get_balance();
// Subscription stuff. Skip if it's not already in localstorage
let subscription_timestamp = undefined;
const stored_subscription_timestamp = localStorage.getItem(
"subscription_timestamp"
);
// If we have a stored timestamp, check if it's still valid
if (stored_subscription_timestamp) {
try {
const timestamp =
await mutinyWallet?.check_subscribed();
// Check that timestamp is a number
if (!timestamp || isNaN(Number(timestamp))) {
throw new Error("Timestamp is not a number");
}
subscription_timestamp = Number(timestamp);
localStorage.setItem(
"subscription_timestamp",
timestamp.toString()
);
} catch (e) {
console.error(e);
}
}
setState({ setState({
mutiny_wallet: mutinyWallet, mutiny_wallet: mutinyWallet,
wallet_loading: false, wallet_loading: false,
subscription_timestamp: subscription_timestamp,
balance balance
}); });
} catch (e) { } catch (e) {

View File

@@ -14,7 +14,6 @@ export async function generateGradient(str: string) {
} }
export async function gradientsPerContact(contacts: Contact[]) { export async function gradientsPerContact(contacts: Contact[]) {
console.log(contacts);
const gradients = new Map(); const gradients = new Map();
for (const contact of contacts) { for (const contact of contacts) {
const gradient = await generateGradient(contact.name); const gradient = await generateGradient(contact.name);