diff --git a/packages/app/config/default.json b/packages/app/config/default.json index fd3f26ba..b4498dd6 100644 --- a/packages/app/config/default.json +++ b/packages/app/config/default.json @@ -40,5 +40,9 @@ "wss://nostr.wine/": { "read": true, "write": false }, "wss://eden.nostr.land/": { "read": true, "write": false } }, - "useIndexedDBEvents": false + "useIndexedDBEvents": false, + "alby": { + "clientId": "pohiJjPhQR", + "clientSecret": "GAl1YKLA3FveK1gLBYok" + } } diff --git a/packages/app/custom.d.ts b/packages/app/custom.d.ts index 1fa4a182..37accf55 100644 --- a/packages/app/custom.d.ts +++ b/packages/app/custom.d.ts @@ -85,6 +85,11 @@ declare const CONFIG: { profileLinkPrefix: NostrPrefix; defaultRelays: Record; useIndexedDBEvents: boolean; + // Alby wallet oAuth config + alby?: { + clientId: string; + clientSecret: string; + }; }; /** diff --git a/packages/app/src/Element/Event/Create/NoteCreator.tsx b/packages/app/src/Element/Event/Create/NoteCreator.tsx index 54dc5dde..eb610206 100644 --- a/packages/app/src/Element/Event/Create/NoteCreator.tsx +++ b/packages/app/src/Element/Event/Create/NoteCreator.tsx @@ -354,18 +354,18 @@ export function NoteCreator() { onChange={e => { note.update( v => - (v.selectedCustomRelays = - // set false if all relays selected - e.target.checked && + (v.selectedCustomRelays = + // set false if all relays selected + e.target.checked && note.selectedCustomRelays && note.selectedCustomRelays.length == a.length - 1 - ? undefined - : // otherwise return selectedCustomRelays with target relay added / removed - a.filter(el => - el === r - ? e.target.checked - : !note.selectedCustomRelays || note.selectedCustomRelays.includes(el), - )), + ? undefined + : // otherwise return selectedCustomRelays with target relay added / removed + a.filter(el => + el === r + ? e.target.checked + : !note.selectedCustomRelays || note.selectedCustomRelays.includes(el), + )), ); }} /> @@ -434,9 +434,9 @@ export function NoteCreator() { onChange={e => note.update( v => - (v.zapSplits = arr.map((vv, ii) => - ii === i ? { ...vv, weight: Number(e.target.value) } : vv, - )), + (v.zapSplits = arr.map((vv, ii) => + ii === i ? { ...vv, weight: Number(e.target.value) } : vv, + )), ) } /> diff --git a/packages/app/src/Element/Event/Markdown.tsx b/packages/app/src/Element/Event/Markdown.tsx index 50ff7b5b..5d6352a7 100644 --- a/packages/app/src/Element/Event/Markdown.tsx +++ b/packages/app/src/Element/Event/Markdown.tsx @@ -130,4 +130,4 @@ export const Markdown = forwardRef((props: Markdo ); }); -Markdown.displayName = "Markdown"; \ No newline at end of file +Markdown.displayName = "Markdown"; diff --git a/packages/app/src/Element/Event/NoteFooter.tsx b/packages/app/src/Element/Event/NoteFooter.tsx index 30822e13..be083e89 100644 --- a/packages/app/src/Element/Event/NoteFooter.tsx +++ b/packages/app/src/Element/Event/NoteFooter.tsx @@ -325,4 +325,4 @@ const AsyncFooterIcon = forwardRef((props: AsyncIconProps & { value: number }, r ); }); -AsyncFooterIcon.displayName = "AsyncFooterIcon"; \ No newline at end of file +AsyncFooterIcon.displayName = "AsyncFooterIcon"; diff --git a/packages/app/src/Element/Event/NoteReaction.tsx b/packages/app/src/Element/Event/NoteReaction.tsx index 065a12b2..f5cfac06 100644 --- a/packages/app/src/Element/Event/NoteReaction.tsx +++ b/packages/app/src/Element/Event/NoteReaction.tsx @@ -71,7 +71,7 @@ export default function NoteReaction(props: NoteReactionProps) { const opt = { showHeader: ev?.kind === EventKind.Repost || ev?.kind === EventKind.TextNote, showFooter: false, - truncate: true + truncate: true, }; return shouldNotBeRendered ? null : ( diff --git a/packages/app/src/Element/ProxyImg.tsx b/packages/app/src/Element/ProxyImg.tsx index 9bc50c4b..7b3fd3f6 100644 --- a/packages/app/src/Element/ProxyImg.tsx +++ b/packages/app/src/Element/ProxyImg.tsx @@ -11,55 +11,56 @@ type ProxyImgProps = HTMLProps & { missingImageElement?: ReactNode; }; -export const ProxyImg = forwardRef( - function ProxyImg({ size, className, promptToLoadDirectly, missingImageElement, sha256, ...props }: ProxyImgProps, ref) { - const { proxy } = useImgProxy(); - const [loadFailed, setLoadFailed] = useState(false); - const [bypass, setBypass] = useState(CONFIG.media.bypassImgProxyError); - const proxiedSrc = useMemo(() => proxy(props.src ?? "", size, sha256), [props.src, size, sha256]); - const [src, setSrc] = useState(proxiedSrc); +export const ProxyImg = forwardRef(function ProxyImg( + { size, className, promptToLoadDirectly, missingImageElement, sha256, ...props }: ProxyImgProps, + ref, +) { + const { proxy } = useImgProxy(); + const [loadFailed, setLoadFailed] = useState(false); + const [bypass, setBypass] = useState(CONFIG.media.bypassImgProxyError); + const proxiedSrc = useMemo(() => proxy(props.src ?? "", size, sha256), [props.src, size, sha256]); + const [src, setSrc] = useState(proxiedSrc); - useEffect(() => { - setLoadFailed(false); - setSrc(proxy(props.src, size, sha256)); - }, [props.src, size, sha256]); - - if (loadFailed && !bypass && (promptToLoadDirectly ?? true)) { - return ( -
{ - e.stopPropagation(); - setBypass(true); - }}> - -
- ); - } - - const handleImageError = e => { - if (props.onError) { - props.onError(e); - } else { - console.error("Failed to load image: ", props.src, e); - if (bypass && src === proxiedSrc) { - setSrc(props.src ?? ""); - } else { - setLoadFailed(true); - } - } - }; - - if (!src || loadFailed) return missingImageElement ??
Image not available
; + useEffect(() => { + setLoadFailed(false); + setSrc(proxy(props.src, size, sha256)); + }, [props.src, size, sha256]); + if (loadFailed && !bypass && (promptToLoadDirectly ?? true)) { return ( - +
{ + e.stopPropagation(); + setBypass(true); + }}> + +
); - }, -); \ No newline at end of file + } + + const handleImageError = e => { + if (props.onError) { + props.onError(e); + } else { + console.error("Failed to load image: ", props.src, e); + if (bypass && src === proxiedSrc) { + setSrc(props.src ?? ""); + } else { + setLoadFailed(true); + } + } + }; + + if (!src || loadFailed) return missingImageElement ??
Image not available
; + + return ( + + ); +}); diff --git a/packages/app/src/Element/Trending/TrendingHashtags.tsx b/packages/app/src/Element/Trending/TrendingHashtags.tsx index 37ecc5f8..d4e11a1e 100644 --- a/packages/app/src/Element/Trending/TrendingHashtags.tsx +++ b/packages/app/src/Element/Trending/TrendingHashtags.tsx @@ -42,7 +42,14 @@ export default function TrendingHashtags({ ); } else { - return ; + return ( + + ); } })} diff --git a/packages/app/src/Element/Trending/TrendingPosts.tsx b/packages/app/src/Element/Trending/TrendingPosts.tsx index bf9aa2ac..86d256de 100644 --- a/packages/app/src/Element/Trending/TrendingPosts.tsx +++ b/packages/app/src/Element/Trending/TrendingPosts.tsx @@ -18,7 +18,7 @@ import useCachedFetch from "@/Hooks/useCachedFetch"; import { System } from "@/index"; import { removeUndefined } from "@snort/shared"; -export default function TrendingNotes({ count = Infinity, small = false }: { count: number, small: boolean }) { +export default function TrendingNotes({ count = Infinity, small = false }: { count: number; small: boolean }) { const api = new NostrBandApi(); const { lang } = useLocale(); const trendingNotesUrl = api.trendingNotesUrl(lang); @@ -29,15 +29,17 @@ export default function TrendingNotes({ count = Infinity, small = false }: { cou isLoading, error, } = useCachedFetch<{ notes: Array<{ event: NostrEvent }> }, Array>(trendingNotesUrl, storageKey, data => { - return removeUndefined(data.notes.map(a => { - const ev = a.event; - if (!System.Optimizer.schnorrVerify(ev)) { - console.error(`Event with invalid sig\n\n${ev}\n\nfrom ${trendingNotesUrl}`); - return; - } - System.HandleEvent(ev as TaggedNostrEvent); - return ev; - })); + return removeUndefined( + data.notes.map(a => { + const ev = a.event; + if (!System.Optimizer.schnorrVerify(ev)) { + console.error(`Event with invalid sig\n\n${ev}\n\nfrom ${trendingNotesUrl}`); + return; + } + System.HandleEvent(ev as TaggedNostrEvent); + return ev; + }), + ); }); const login = useLogin(); diff --git a/packages/app/src/Pages/WalletPage.tsx b/packages/app/src/Pages/WalletPage.tsx index a9412362..c14c85bb 100644 --- a/packages/app/src/Pages/WalletPage.tsx +++ b/packages/app/src/Pages/WalletPage.tsx @@ -105,7 +105,11 @@ export default function WalletPage(props: { showHistory: boolean }) {
diff --git a/packages/app/src/Pages/settings/WalletSettings.tsx b/packages/app/src/Pages/settings/WalletSettings.tsx index 2ebe5d2a..d83deda0 100644 --- a/packages/app/src/Pages/settings/WalletSettings.tsx +++ b/packages/app/src/Pages/settings/WalletSettings.tsx @@ -10,16 +10,26 @@ import AlbyIcon from "@/Icons/Alby"; import Icon from "@/Icons/Icon"; import { getAlbyOAuth } from "./wallet/Alby"; -const WalletRow = (props: { logo: ReactNode; name: ReactNode; url: string; desc?: ReactNode }) => { +const WalletRow = (props: { + logo: ReactNode; + name: ReactNode; + url: string; + desc?: ReactNode; + onClick?: () => void; +}) => { const navigate = useNavigate(); return (
{ - if (props.url.startsWith("http")) { - window.location.href = props.url; + if (props.onClick) { + props.onClick(); } else { - navigate(props.url); + if (props.url.startsWith("http")) { + window.location.href = props.url; + } else { + navigate(props.url); + } } }}>
@@ -35,7 +45,6 @@ const WalletRow = (props: { logo: ReactNode; name: ReactNode; url: string; desc? }; const WalletSettings = () => { - const alby = getAlbyOAuth(); return ( <>

@@ -68,12 +77,18 @@ const WalletSettings = () => { url="/settings/wallet/cashu" desc={} /> - } - name="Alby" - url={alby.authUrl} - desc={} - /> + {CONFIG.alby && ( + } + name="Alby" + url={""} + onClick={() => { + const alby = getAlbyOAuth(); + window.location.href = alby.getAuthUrl(); + }} + desc={} + /> + )}

); diff --git a/packages/app/src/Pages/settings/wallet/Alby.tsx b/packages/app/src/Pages/settings/wallet/Alby.tsx index 235d4d86..90926d72 100644 --- a/packages/app/src/Pages/settings/wallet/Alby.tsx +++ b/packages/app/src/Pages/settings/wallet/Alby.tsx @@ -1,18 +1,40 @@ import PageSpinner from "@/Element/PageSpinner"; +import { WalletConfig, WalletKind, Wallets } from "@/Wallet"; +import AlbyWallet from "@/Wallet/AlbyWallet"; import { sha256 } from "@noble/hashes/sha256"; import { randomBytes } from "@noble/hashes/utils"; import { base64, base64urlnopad, hex } from "@scure/base"; +import { unixNow } from "@snort/shared"; import { useEffect, useState } from "react"; -import { useLocation } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; +import { v4 as uuid } from "uuid"; export default function AlbyOAuth() { + const navigate = useNavigate(); const location = useLocation(); const alby = getAlbyOAuth(); const [error, setError] = useState(""); async function setupWallet(token: string) { - const auth = await alby.getToken(token); - console.debug(auth); + try { + const auth = await alby.getToken(token); + console.debug(auth); + const connection = new AlbyWallet(auth, () => {}); + const info = await connection.getInfo(); + + const newWallet = { + id: uuid(), + kind: WalletKind.Alby, + active: true, + info, + data: JSON.stringify(auth), + } as WalletConfig; + Wallets.add(newWallet); + + navigate("/settings/wallet"); + } catch (e) { + setError((e as Error).message); + } } useEffect(() => { @@ -38,29 +60,30 @@ export default function AlbyOAuth() { } export function getAlbyOAuth() { - const clientId = "35EQp6crss"; - const clientSecret = "DTUPIqOjsjwxZXcJwF5C"; + const clientId = CONFIG.alby?.clientId ?? ""; + const clientSecret = CONFIG.alby?.clientSecret ?? ""; const redirectUrl = `${window.location.protocol}//${window.location.host}/settings/wallet/alby`; const scopes = ["invoices:create", "invoices:read", "transactions:read", "balance:read", "payments:send"]; const ec = new TextEncoder(); - const code_verifier = hex.encode(randomBytes(64)); - window.sessionStorage.setItem("alby-code", code_verifier); - - const params = new URLSearchParams(); - params.set("client_id", clientId); - params.set("response_type", "code"); - params.set("code_challenge", base64urlnopad.encode(sha256(code_verifier))); - params.set("code_challenge_method", "S256"); - params.set("redirect_uri", redirectUrl); - params.set("scope", scopes.join(" ")); - const tokenUrl = "https://api.getalby.com/oauth/token"; - const authUrl = `https://getalby.com/oauth?${params}`; return { tokenUrl, - authUrl, + getAuthUrl: () => { + const code_verifier = hex.encode(randomBytes(64)); + window.sessionStorage.setItem("alby-code", code_verifier); + + const params = new URLSearchParams(); + params.set("client_id", clientId); + params.set("response_type", "code"); + params.set("code_challenge", base64urlnopad.encode(sha256(code_verifier))); + params.set("code_challenge_method", "S256"); + params.set("redirect_uri", redirectUrl); + params.set("scope", scopes.join(" ")); + + return `https://getalby.com/oauth?${params}`; + }, getToken: async (token: string) => { const code = window.sessionStorage.getItem("alby-code"); if (!code) throw new Error("Alby code is missing!"); @@ -85,10 +108,19 @@ export function getAlbyOAuth() { const data = await req.json(); if (req.ok) { - return data.access_token as string; + return { ...data, created_at: unixNow() } as OAuthToken; } else { throw new Error(data.error_description as string); } }, }; } + +export interface OAuthToken { + access_token: string; + created_at: number; + expires_in: number; + refresh_token: string; + scope: string; + token_type: string; +} diff --git a/packages/app/src/Pages/settings/wallet/LNDHub.tsx b/packages/app/src/Pages/settings/wallet/LNDHub.tsx index 61761b05..4b61c518 100644 --- a/packages/app/src/Pages/settings/wallet/LNDHub.tsx +++ b/packages/app/src/Pages/settings/wallet/LNDHub.tsx @@ -16,7 +16,7 @@ const ConnectLNDHub = () => { async function tryConnect(config: string) { try { - const connection = new LNDHubWallet(config); + const connection = new LNDHubWallet(config, () => {}); await connection.login(); const info = await connection.getInfo(); diff --git a/packages/app/src/Pages/settings/wallet/NWC.tsx b/packages/app/src/Pages/settings/wallet/NWC.tsx index 68ac26c6..790a0ec7 100644 --- a/packages/app/src/Pages/settings/wallet/NWC.tsx +++ b/packages/app/src/Pages/settings/wallet/NWC.tsx @@ -16,7 +16,7 @@ const ConnectNostrWallet = () => { async function tryConnect(config: string) { try { - const connection = new NostrConnectWallet(config); + const connection = new NostrConnectWallet(config, () => {}); await connection.login(); const info = await connection.getInfo(); diff --git a/packages/app/src/Wallet/AlbyWallet.ts b/packages/app/src/Wallet/AlbyWallet.ts new file mode 100644 index 00000000..e9654856 --- /dev/null +++ b/packages/app/src/Wallet/AlbyWallet.ts @@ -0,0 +1,157 @@ +import { OAuthToken } from "@/Pages/settings/wallet/Alby"; +import { + InvoiceRequest, + LNWallet, + WalletError, + WalletErrorCode, + WalletInfo, + WalletInvoice, + WalletInvoiceState, + prToWalletInvoice, +} from "."; +import { unixNow, unwrap } from "@snort/shared"; + +export default class AlbyWallet implements LNWallet { + #token: OAuthToken; + constructor( + token: OAuthToken, + readonly onChange: () => void, + ) { + this.#token = token; + } + + isReady() { + return true; + } + canAutoLogin() { + return true; + } + canGetInvoices() { + return this.#token.scope.includes("invoices:read"); + } + canGetBalance() { + return this.#token.scope.includes("balance:read"); + } + + async getInfo() { + const me = await this.#fetch("/user/me"); + return { alias: me.lightning_address } as WalletInfo; + } + + async login() { + await this.#refreshToken(); + return true; + } + + close() { + return Promise.resolve(true); + } + + async getBalance() { + const bal = await this.#fetch("/balance"); + return bal.balance; + } + + async createInvoice(req: InvoiceRequest) { + const inv = await this.#fetch("/invoices", "POST", { + amount: req.amount, + memo: req.memo, + }); + + return unwrap(prToWalletInvoice(inv.payment_request)); + } + + async payInvoice(pr: string) { + const pay = await this.#fetch("/payments/bolt11", "POST", { + invoice: pr, + }); + + return { + ...prToWalletInvoice(pay.payment_request), + fees: pay.fee, + preimage: pay.payment_preimage, + state: WalletInvoiceState.Paid, + direction: "out", + } as WalletInvoice; + } + + async getInvoices() { + const invoices = await this.#fetch>("/invoices?page=1&items=20"); + return invoices.map(a => { + return { + ...prToWalletInvoice(a.payment_request), + memo: a.comment, + preimage: a.preimage, + state: a.settled ? WalletInvoiceState.Paid : WalletInvoiceState.Pending, + direction: a.type === "incoming" ? "in" : "out", + } as WalletInvoice; + }); + } + + async #fetch(path: string, method: "GET" | "POST" = "GET", body?: object) { + const req = await fetch(`https://api.getalby.com${path}`, { + method: method, + body: body ? JSON.stringify(body) : undefined, + headers: { + accept: "application/json", + authorization: `Bearer ${this.#token.access_token}`, + ...(body ? { "content-type": "application/json" } : {}), + }, + }); + const json = await req.text(); + if (req.ok) { + return JSON.parse(json) as T; + } else { + if (json.length > 0) { + throw new WalletError(WalletErrorCode.GeneralError, JSON.parse(json).message as string); + } else { + throw new WalletError(WalletErrorCode.GeneralError, `Error: ${json} (${req.status})`); + } + } + } + async #refreshToken() { + if (this.#token.created_at + this.#token.expires_in < unixNow()) { + // refresh + } + } +} + +interface GetBalanceResponse { + balance: number; + currency: string; + unit: string; +} + +interface CreateInvoiceResponse { + expires_at: string; + payment_hash: string; + payment_request: string; +} + +interface PayInvoiceResponse { + amount: number; + description?: string; + destination: string; + fee: number; + payment_hash: string; + payment_preimage: string; + payment_request: string; +} + +interface GetInvoiceResponse { + amount: number; + comment?: string; + created_at: string; + creation_date: number; + currency: string; + expires_at: string; + preimage: string; + payment_request: string; + settled: boolean; + settled_at: string; + type: "incoming" | "outgoing"; +} + +interface GetUserResponse { + lightning_address: string; +} diff --git a/packages/app/src/Wallet/index.ts b/packages/app/src/Wallet/index.ts index 88fed313..8c0ebd9f 100644 --- a/packages/app/src/Wallet/index.ts +++ b/packages/app/src/Wallet/index.ts @@ -5,6 +5,7 @@ import { unwrap } from "@/SnortUtils"; import LNDHubWallet from "./LNDHub"; import { NostrConnectWallet } from "./NostrWalletConnect"; import { WebLNWallet } from "./WebLN"; +import AlbyWallet from "./AlbyWallet"; export enum WalletKind { LNDHub = 1, @@ -12,6 +13,7 @@ export enum WalletKind { WebLN = 3, NWC = 4, Cashu = 5, + Alby = 6, } export enum WalletErrorCode { @@ -240,6 +242,9 @@ export class WalletStore extends ExternalStore { case WalletKind.NWC: { return new NostrConnectWallet(unwrap(cfg.data), () => this.notifyChange()); } + case WalletKind.Alby: { + return new AlbyWallet(JSON.parse(unwrap(cfg.data)), () => this.notifyChange()); + } } } }