From 269dcde5570b71a9069e564e0257a6e6ac2a7dc7 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Tue, 25 Mar 2025 16:50:23 +0100 Subject: [PATCH 1/3] fix minor issue in dashboard style, and improve scrollbar style --- src/components/General/Button.ts | 2 +- src/routes/Home.ts | 71 ++++++++++++++++++-------------- src/routes/router.ts | 37 ++++++++++++++--- 3 files changed, 72 insertions(+), 38 deletions(-) diff --git a/src/components/General/Button.ts b/src/components/General/Button.ts index 526ced6..a1a5a64 100644 --- a/src/components/General/Button.ts +++ b/src/components/General/Button.ts @@ -186,8 +186,8 @@ export class StyledButton extends LitElement { } private _handleClick(e: MouseEvent) { + e.preventDefault(); if (this.disabled || this.loading) { - e.preventDefault(); return; } diff --git a/src/routes/Home.ts b/src/routes/Home.ts index dc0736d..40787d9 100644 --- a/src/routes/Home.ts +++ b/src/routes/Home.ts @@ -5,10 +5,10 @@ import { customElement, state } from 'lit/decorators.js'; import { map } from 'lit/directives/map.js'; import { html, literal } from 'lit/static-html.js'; -import '@widgets/BitcoinBlockWidget'; import '@components/AppGrid'; -import '@components/NostrAvatar'; import '@components/General/Card'; +import '@components/NostrAvatar'; +import '@components/Widgets/BitcoinBlockWidget'; @customElement('arx-eve-home') export class Home extends LitElement { @@ -95,60 +95,71 @@ export class Home extends LitElement { static override styles = [ css` + ::-webkit-scrollbar { + width: 12px; + height: 12px; + } + + ::-webkit-scrollbar-track { + background: var(--color-base-200); + border-radius: var(--radius-field); + } + + ::-webkit-scrollbar-thumb { + background: var(--color-base-300); + border-radius: var(--radius-field); + border: 2px solid var(--color-base-200); + transition: var(--transition); + } + + ::-webkit-scrollbar-thumb:hover { + background: var(--color-neutral); + } + .content-wrapper { + margin: auto; + height: 90vh; + overflow: hidden; display: flex; + align-items: center; + justify-content: center; gap: 20px; width: 100%; max-width: 1200px; - margin: auto; - } - - .home { - min-height: calc(100vh - var(--font-2xl)); - width: 100%; - position: absolute; - right: 0; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; } .home-container { flex: 1; padding: 0; + height: auto; } .widgets-container { + height: 75vh; + overflow: auto; width: 350px; - display: flex; - flex-direction: column; - gap: 20px; - - & > * { - flex: 1; - } + margin: 0 auto; } @media (max-width: 1024px) { .content-wrapper { + gap: 0; + height: auto; flex-direction: column; } + .home-container { + width: 100%; + } + .widgets-container { + overflow: unset; + height: auto; width: calc(100vw - 40px); - flex-direction: row; - overflow-x: auto; padding-bottom: 10px; } } - @media (max-width: 768px) { - .widgets-container { - flex-direction: column; - } - } - .welcome-section { display: flex; flex-direction: column; @@ -173,7 +184,6 @@ export class Home extends LitElement { override render() { return html` -
@@ -189,7 +199,6 @@ export class Home extends LitElement {
${map(this.widgets, (widget) => html`<${widget.content}>`)} -
`; diff --git a/src/routes/router.ts b/src/routes/router.ts index 235a129..65f68e7 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -1,12 +1,12 @@ +import '@components/InitialSetup'; import '@routes/404Page'; +import '@routes/Arbor/Home'; +import '@routes/Arbor/NewPost'; +import '@routes/Arbor/NewTopic'; +import '@routes/Arbor/TopicView'; import '@routes/Home'; import '@routes/Profile'; import '@routes/Settings'; -import '@routes/Arbor/Home'; -import '@routes/Arbor/NewTopic'; -import '@routes/Arbor/TopicView'; -import '@routes/Arbor/NewPost'; -import '@components/InitialSetup'; import { spread } from '@open-wc/lit-helpers'; import { LitElement, css } from 'lit'; @@ -92,6 +92,27 @@ export default class EveRouter extends LitElement { overflow: hidden; } + ::-webkit-scrollbar { + width: 12px; + height: 12px; + } + + ::-webkit-scrollbar-track { + background: var(--color-base-200); + border-radius: var(--radius-field); + } + + ::-webkit-scrollbar-thumb { + background: var(--color-base-300); + border-radius: var(--radius-field); + border: 2px solid var(--color-base-200); + transition: var(--transition); + } + + ::-webkit-scrollbar-thumb:hover { + background: var(--color-neutral); + } + .window { overflow: auto; } @@ -103,6 +124,10 @@ export default class EveRouter extends LitElement { margin: 0 auto; padding: 1rem; } + + .hide-overflow { + overflow: hidden; + } `; constructor() { @@ -268,7 +293,7 @@ export default class EveRouter extends LitElement { @go-forward=${this.goForward} title="Eve" > -
+
${keyed( this.currentRoute.params, From 9a125e3111154c8077237500f609adc30d0bfdc2 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Tue, 25 Mar 2025 19:00:41 +0100 Subject: [PATCH 2/3] fix repeat events bug with input and textarea --- src/components/DateTimeSettings.ts | 11 ++--- src/components/General/Input.ts | 68 +++++++++++++----------------- src/components/General/Prompt.ts | 8 ++-- src/components/General/Textarea.ts | 9 +--- src/components/Header.ts | 54 +++++++++++++----------- src/components/InitialSetup.ts | 29 +++++++------ src/routes/Arbor/NewPost.ts | 7 +-- src/routes/Arbor/NewTopic.ts | 15 ++++--- src/routes/Settings.ts | 22 +++++----- 9 files changed, 109 insertions(+), 114 deletions(-) diff --git a/src/components/DateTimeSettings.ts b/src/components/DateTimeSettings.ts index 2bebba0..0910025 100644 --- a/src/components/DateTimeSettings.ts +++ b/src/components/DateTimeSettings.ts @@ -1,9 +1,10 @@ import { StyledToggle } from '@/components/General/Toggle'; +import { ArxInputChangeEvent, type StyledInput } from '@components/General/Input'; import { LitElement, html } from 'lit'; import { customElement, state } from 'lit/decorators.js'; -import '@components/General/Input'; import '@components/General/Fieldset'; +import '@components/General/Input'; import '@components/General/Select'; interface DateTimeFormatOptions { @@ -71,10 +72,10 @@ export class DateTimeSettings extends LitElement { } private handleChange(key: keyof DateTimeFormatOptions, e: Event) { - const target = e.target as HTMLSelectElement | HTMLInputElement | StyledToggle; - let value: string | boolean | undefined = target.value; + let value = e instanceof ArxInputChangeEvent ? e.detail.value : (e.target as HTMLSelectElement).value; + const target = e.target as StyledInput | HTMLSelectElement | HTMLInputElement | StyledToggle; - if (key === 'hour12' && target instanceof StyledToggle) value = target.checked; + if (key === 'hour12' && target instanceof StyledToggle) value = target.checked.toString(); this.options = { ...this.options, @@ -126,7 +127,7 @@ export class DateTimeSettings extends LitElement { label="Locale" type="text" .value=${this.options.locale} - @input=${(e: Event) => this.handleChange('locale', e)} + @change=${(e: Event) => this.handleChange('locale', e)} > diff --git a/src/components/General/Input.ts b/src/components/General/Input.ts index 2936518..3b20e82 100644 --- a/src/components/General/Input.ts +++ b/src/components/General/Input.ts @@ -1,17 +1,35 @@ -import { LitElement, css, html } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { LitElement, type PropertyValues, css, html } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; import { when } from 'lit/directives/when.js'; +export class ArxInputChangeEvent extends CustomEvent<{ value: string }> { + constructor(value: string) { + super('change', { detail: { value } }); + } +} + @customElement('arx-input') export class StyledInput extends LitElement { @property() placeholder = ''; @property() value = ''; @property({ type: Boolean }) disabled = false; - @property() type = 'text'; + @property() type: 'text' | 'number' | 'password' = 'text'; @property() name = ''; @property({ type: Boolean }) required = false; @property() label = ''; + @query('input') private _input!: HTMLInputElement; + + @state() private _value = ''; + + protected override firstUpdated(_changedProperties: PropertyValues): void { + this._value = this.value; + } + + protected override updated(changedProperties: PropertyValues): void { + if (changedProperties.has('value')) this._value = this.value; + } + static override styles = css` :host { display: inline-block; @@ -98,7 +116,7 @@ export class StyledInput extends LitElement { return html` ${when(this.label, () => html``)} ` diff --git a/src/components/General/Textarea.ts b/src/components/General/Textarea.ts index 93e2791..e799a8f 100644 --- a/src/components/General/Textarea.ts +++ b/src/components/General/Textarea.ts @@ -1,6 +1,7 @@ import { LitElement, css, html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { when } from 'lit/directives/when.js'; +import { ArxInputChangeEvent } from './Input'; @customElement('arx-textarea') export class StyledTextarea extends LitElement { @@ -201,12 +202,6 @@ export class StyledTextarea extends LitElement { private _handleInput(e: InputEvent) { const textarea = e.target as HTMLTextAreaElement; this.value = textarea.value; - this.dispatchEvent( - new CustomEvent('input', { - detail: { value: this.value }, - bubbles: true, - composed: true, - }), - ); + this.dispatchEvent(new ArxInputChangeEvent(this.value)); } } diff --git a/src/components/Header.ts b/src/components/Header.ts index 4834ab3..e8745c9 100644 --- a/src/components/Header.ts +++ b/src/components/Header.ts @@ -1,4 +1,5 @@ import { ndk } from '@/ndk'; +import type { ArxInputChangeEvent } from '@components/General/Input'; import type { NDKEvent } from '@nostr-dev-kit/ndk'; import * as nip19 from '@nostr/tools/nip19'; import { LitElement, css, html } from 'lit'; @@ -8,8 +9,8 @@ import { keyed } from 'lit/directives/keyed.js'; import { map } from 'lit/directives/map.js'; import { when } from 'lit/directives/when.js'; -import '@components/HeaderSugestion'; import '@components/General/Input'; +import '@components/HeaderSugestion'; @customElement('arx-header') export class Header extends LitElement { @@ -49,10 +50,10 @@ export class Header extends LitElement { .nav-buttons { display: flex; gap: var(--space-xs, 0.5rem); - padding-right: var(--space-xs, 0.5rem); + padding: 0 var(--space-xs, 0.5rem); } - .nav-buttons button { + button { text-decoration: none; color: var(--color-primary-content); background: oklch(from var(--color-primary-content) l c h / 0.1); @@ -66,23 +67,21 @@ export class Header extends LitElement { align-items: center; justify-content: center; transition: all 0.2s ease; - } + &:hover { + background: oklch(from var(--color-primary-content) l c h / 0.2); + transform: translateY(-2px); + box-shadow: calc(var(--depth) * 2px) calc(var(--depth) * 2px) + calc(var(--depth) * 4px) + oklch(from var(--color-base-content) l c h / 0.15); + } + &:active { + transform: translateY(1px); + } - .nav-buttons button:hover { - background: oklch(from var(--color-primary-content) l c h / 0.2); - transform: translateY(-2px); - box-shadow: calc(var(--depth) * 2px) calc(var(--depth) * 2px) - calc(var(--depth) * 4px) - oklch(from var(--color-base-content) l c h / 0.15); - } - - .nav-buttons button:active { - transform: translateY(1px); - } - - .nav-buttons button.disabled { - opacity: 0.5; - pointer-events: none; + &.disabled { + opacity: 0.5; + pointer-events: none; + } } .search-container { @@ -153,7 +152,7 @@ export class Header extends LitElement { placeholder=${this.url} @keyup=${this._handleSearch} @focus=${this._handleFocus} - @input=${this._handleInput} + @change=${this._handleInput} > ${when( this.showSuggestions, @@ -175,16 +174,25 @@ export class Header extends LitElement { `, )}
+ `; } + private _goToWallet() { + window.location.hash = 'wallet'; + } + private _handleFocus() { this.showSuggestions = true; } - private _handleInput(e: InputEvent) { - this.searchQuery = (e.target as HTMLInputElement).value; + private _handleInput(e: ArxInputChangeEvent) { + this.searchQuery = e.detail.value; if (this._debounceTimeout) { clearTimeout(this._debounceTimeout); } @@ -245,8 +253,6 @@ export class Header extends LitElement { private _handleSearch(e: KeyboardEvent) { if (e.key !== 'Enter') return; - const target = e.target as HTMLInputElement; - this.searchQuery = target.value; this.showSuggestions = false; if (this.searchQuery.startsWith('npub1')) { diff --git a/src/components/InitialSetup.ts b/src/components/InitialSetup.ts index a947959..06210c9 100644 --- a/src/components/InitialSetup.ts +++ b/src/components/InitialSetup.ts @@ -1,4 +1,5 @@ import { ndk, setSigner } from '@/ndk'; +import type { ArxInputChangeEvent } from '@components/General/Input'; import { animate } from '@lit-labs/motion'; import { randomBytes } from '@noble/ciphers/webcrypto'; import { NDKEvent, NDKKind, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'; @@ -10,10 +11,10 @@ import { encodeBase64 } from '@std/encoding/base64'; import { LitElement, css, html } from 'lit'; import { customElement, state } from 'lit/decorators.js'; -import '@components/LoadingView'; import '@components/General/Button'; -import '@components/General/Input'; import '@components/General/Fieldset'; +import '@components/General/Input'; +import '@components/LoadingView'; @customElement('arx-initial-setup') export class InitialSetup extends LitElement { @@ -201,8 +202,8 @@ export class InitialSetup extends LitElement { }, 300); } - private onSeedPhraseInput(event: Event) { - this.seedPhrase = (event.target as HTMLInputElement).value; + private onSeedPhraseInput(event: ArxInputChangeEvent) { + this.seedPhrase = event.detail.value; } private generateSeedPhrase() { @@ -300,7 +301,7 @@ export class InitialSetup extends LitElement {

) { + this.userName = e.detail.value; } - private onProfileImageInput(e: Event) { - this.profileImage = (e.target as HTMLInputElement).value; + private onProfileImageInput(e: CustomEvent<{ value: string }>) { + this.profileImage = e.detail.value; } - private onLightningAddressInput(e: Event) { - this.lightningAddress = (e.target as HTMLInputElement).value; + private onLightningAddressInput(e: CustomEvent<{ value: string }>) { + this.lightningAddress = e.detail.value; } private renderPageFour() { @@ -409,7 +410,7 @@ export class InitialSetup extends LitElement { id="username" type="text" .value=${this.userName} - @input=${this.onUserNameInput} + @change=${this.onUserNameInput} placeholder="Enter your name" > @@ -418,7 +419,7 @@ export class InitialSetup extends LitElement { id="profile-image" type="text" .value=${this.profileImage} - @input=${this.onProfileImageInput} + @change=${this.onProfileImageInput} placeholder="Enter image URL" > @@ -464,7 +465,7 @@ export class InitialSetup extends LitElement { id="lightning-address" type="text" .value=${this.lightningAddress} - @input=${this.onLightningAddressInput} + @change=${this.onLightningAddressInput} placeholder="your@lightning.address" /> diff --git a/src/routes/Arbor/NewPost.ts b/src/routes/Arbor/NewPost.ts index 8a3f8b5..ab2a6c3 100644 --- a/src/routes/Arbor/NewPost.ts +++ b/src/routes/Arbor/NewPost.ts @@ -1,3 +1,4 @@ +import type { ArxInputChangeEvent } from '@/components/General/Input'; import { getSigner, ndk } from '@/ndk'; import { NDKEvent } from '@nostr-dev-kit/ndk'; import { LitElement, css, html } from 'lit'; @@ -106,8 +107,8 @@ export class ArborPostCreator extends LitElement { } } - private handleContentInput(e: InputEvent) { - this.postContent = (e.target as HTMLTextAreaElement).value; + private handleContentInput(e: ArxInputChangeEvent) { + this.postContent = e.detail.value; if (this.error && this.postContent.length >= 10) { this.error = null; } @@ -121,7 +122,7 @@ export class ArborPostCreator extends LitElement { diff --git a/src/routes/Arbor/NewTopic.ts b/src/routes/Arbor/NewTopic.ts index 23f3403..7228bee 100644 --- a/src/routes/Arbor/NewTopic.ts +++ b/src/routes/Arbor/NewTopic.ts @@ -1,11 +1,12 @@ import { getSigner, ndk } from '@/ndk'; +import type { ArxInputChangeEvent } from '@components/General/Input'; import { NDKEvent } from '@nostr-dev-kit/ndk'; import { LitElement, css, html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; +import '@components/General/Button'; import '@components/General/Input'; import '@components/General/Textarea'; -import '@components/General/Button'; @customElement('arx-arbor-topic-creator') export class ArborTopicCreator extends LitElement { @@ -86,12 +87,12 @@ export class ArborTopicCreator extends LitElement { } } - private handleTopicInput(e: InputEvent) { - this.newTopic = (e.target as HTMLInputElement).value; + private handleTopicInput(e: ArxInputChangeEvent) { + this.newTopic = e.detail.value; } - private handleContentInput(e: InputEvent) { - this.topicContent = (e.target as HTMLTextAreaElement).value; + private handleContentInput(e: ArxInputChangeEvent) { + this.topicContent = e.detail.value; } override render() { @@ -102,14 +103,14 @@ export class ArborTopicCreator extends LitElement { type="text" placeholder="New Topic" .value=${this.newTopic} - @input=${this.handleTopicInput} + @change=${this.handleTopicInput} ?disabled=${this.isCreating} > diff --git a/src/routes/Settings.ts b/src/routes/Settings.ts index cdcb210..6e3ddf1 100644 --- a/src/routes/Settings.ts +++ b/src/routes/Settings.ts @@ -1,16 +1,17 @@ import defaultAvatar from '@/default-avatar.png'; import { getSigner, getUserProfile, ndk } from '@/ndk'; +import type { ArxInputChangeEvent } from '@components/General/Input'; import { NDKEvent, type NDKUserProfile } from '@nostr-dev-kit/ndk'; import { LitElement, css, html } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { when } from 'lit/directives/when.js'; -import '@components/DateTimeSettings'; -import '@components/General/Input'; -import '@components/General/Button'; -import '@components/General/Fieldset'; -import '@components/General/Card'; import '@components/Breadcrumbs'; +import '@components/DateTimeSettings'; +import '@components/General/Button'; +import '@components/General/Card'; +import '@components/General/Fieldset'; +import '@components/General/Input'; @customElement('arx-settings') export class EveSettings extends LitElement { @@ -73,11 +74,10 @@ export class EveSettings extends LitElement { } } - private handleInputChange(e: Event) { - const target = e.target as HTMLInputElement; + private handleInputChange(field: string, e: ArxInputChangeEvent) { this.profile = { ...this.profile, - [target.name]: target.value, + [field]: e.detail.value, }; } @@ -144,7 +144,7 @@ export class EveSettings extends LitElement { type="text" name="name" .value=${this.profile.name} - @input=${this.handleInputChange} + @change=${(e: ArxInputChangeEvent) => this.handleInputChange('name', e)} placeholder="Your display name" > @@ -153,7 +153,7 @@ export class EveSettings extends LitElement { type="text" name="image" .value=${this.profile.picture} - @input=${this.handleInputChange} + @change=${(e: ArxInputChangeEvent) => this.handleInputChange('picture', e)} placeholder="https://example.com/your-image.jpg" > @@ -162,7 +162,7 @@ export class EveSettings extends LitElement { type="text" name="banner" .value=${this.profile.banner} - @input=${this.handleInputChange} + @change=${(e: ArxInputChangeEvent) => this.handleInputChange('banner', e)} placeholder="https://example.com/your-image.jpg" > From 985c1494b59a6474f4705b2c83a4c0da94a145f8 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Tue, 25 Mar 2025 22:30:00 +0100 Subject: [PATCH 3/3] cashu wallet (beta) (testnet) --- src/components/Arbor/ForumPost.ts | 54 ++++- src/components/WalletTransactionLine.ts | 100 ++++++++ src/components/Widgets/WalletWidget.ts | 82 +++++++ src/routes/Home.ts | 33 +-- src/routes/Wallet.ts | 78 ++++++ src/routes/router.ts | 6 + src/style.css | 2 + src/wallet.ts | 306 ++++++++++++++++++++++++ 8 files changed, 637 insertions(+), 24 deletions(-) create mode 100644 src/components/WalletTransactionLine.ts create mode 100644 src/components/Widgets/WalletWidget.ts create mode 100644 src/routes/Wallet.ts create mode 100644 src/wallet.ts diff --git a/src/components/Arbor/ForumPost.ts b/src/components/Arbor/ForumPost.ts index 280c5b1..3558b53 100644 --- a/src/components/Arbor/ForumPost.ts +++ b/src/components/Arbor/ForumPost.ts @@ -1,8 +1,12 @@ +import { wallet } from '@/wallet'; +import { StateController } from '@lit-app/state'; +import type { NDKUser } from '@nostr-dev-kit/ndk'; import formatDateTime from '@utils/formatDateTime'; import { LitElement, css, html } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; +import { ndk } from '@/ndk'; import '@components/MarkdownContent'; @customElement('arx-forum-post') @@ -14,6 +18,16 @@ export class ForumPost extends LitElement { @property({ type: String }) content = ''; @property({ type: Boolean }) isHighlighted = false; + @state() private zapAmountDialogOpen = false; + @state() public authorProfile: NDKUser | undefined = undefined; + + override connectedCallback(): void { + super.connectedCallback(); + this.authorProfile = ndk.getUser({ pubkey: this.npub }); + new StateController(this, wallet); + wallet.loadWallet(); + } + static override styles = [ css` .post { @@ -171,14 +185,24 @@ export class ForumPost extends LitElement { } private _handleZap() { - alert('Zapping is not yet implemented'); - this.dispatchEvent( - new CustomEvent('zap', { - detail: { postId: this.id, npub: this.npub }, - bubbles: true, - composed: true, - }), - ); + // setting to false and then to true forces the dialog to open, even when it wasn't closed correctly + this.zapAmountDialogOpen = false; + setTimeout(() => { + this.zapAmountDialogOpen = true; + }, 0); + } + + private async _doZap(e: Event) { + if (!(e instanceof CustomEvent)) return; + e.preventDefault(); + const zapAmount = Number.parseInt(e.detail.value); + if (Number.isNaN(zapAmount) || zapAmount <= 10) { + alert('Zap amount must be greater or equal to 10'); + return; + } + await wallet.nutZap(zapAmount, this.authorProfile!, this.id); + + this.zapAmountDialogOpen = false; } private _handleDownzap() { @@ -206,6 +230,16 @@ export class ForumPost extends LitElement { }; return html` + { + this.zapAmountDialogOpen = false; + }} + showInput + .open=${this.zapAmountDialogOpen} + >
`; + } +} diff --git a/src/components/Widgets/WalletWidget.ts b/src/components/Widgets/WalletWidget.ts new file mode 100644 index 0000000..492b895 --- /dev/null +++ b/src/components/Widgets/WalletWidget.ts @@ -0,0 +1,82 @@ +import satsComma from '@/utils/satsComma'; +import { wallet } from '@/wallet'; +import '@components/General/Fieldset'; +import '@components/LoadingView'; +import '@components/WalletTransactionLine'; +import { StateController } from '@lit-app/state'; +import { LitElement, css, html } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; + +@customElement('arx-wallet-widget') +export class WalletWidget extends LitElement { + @state() + public loading = false; + + override connectedCallback(): void { + super.connectedCallback(); + new StateController(this, wallet); + } + + override async firstUpdated() { + this.loading = true; + await wallet.loadWallet(); + this.loading = false; + } + + static override styles = [ + css` + :host { + display: block; + } + + .widget-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + border-bottom: var(--border) solid var(--color-base-300); + padding-bottom: 8px; + } + + .widget-title { + font-size: 1.2rem; + font-weight: 600; + margin: 0; + color: var(--color-base-content); + display: flex; + align-items: center; + gap: 8px; + } + + .bitcoin-icon { + color: var(--color-warning); + font-size: 1.4rem; + } + `, + ]; + + override render() { + return html` +
+

+ Bitcoin Wallet +

+
+ 'Loading...', + () => `${satsComma(wallet.balance)} sats`, + )}> + ${when( + wallet.sortedHistory.length > 0, + () => html` + Latest Transaction: + + `, + () => 'No transactions yet', + )} + + `; + } +} diff --git a/src/routes/Home.ts b/src/routes/Home.ts index 40787d9..3950b8e 100644 --- a/src/routes/Home.ts +++ b/src/routes/Home.ts @@ -9,6 +9,7 @@ import '@components/AppGrid'; import '@components/General/Card'; import '@components/NostrAvatar'; import '@components/Widgets/BitcoinBlockWidget'; +import '@components/Widgets/WalletWidget'; @customElement('arx-eve-home') export class Home extends LitElement { @@ -83,6 +84,10 @@ export class Home extends LitElement { title: 'Bitcoin Block', content: literal`arx-bitcoin-block-widget`, }, + { + title: 'Bitcoin Wallet', + content: literal`arx-wallet-widget`, + }, ]; async loadProperties() { @@ -184,21 +189,21 @@ export class Home extends LitElement { override render() { return html` -
- - - -
-

Welcome, ${this.username}

-
-
- +
+ + + +
+

Welcome, ${this.username}

+
-
- ${map(this.widgets, (widget) => html`<${widget.content}>`)} + + +
+ ${map(this.widgets, (widget) => html`<${widget.content}>`)}
`; diff --git a/src/routes/Wallet.ts b/src/routes/Wallet.ts new file mode 100644 index 0000000..8d3d54d --- /dev/null +++ b/src/routes/Wallet.ts @@ -0,0 +1,78 @@ +import { wallet } from '@/wallet'; +import type { ArxInputChangeEvent } from '@components/General/Input'; +import { StateController } from '@lit-app/state'; +import { LitElement, css, html } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import '@components/General/Card'; +import '@components/General/Input'; +import '@components/WalletTransactionLine'; + +@customElement('arx-wallet-route') +export class WalletRoute extends LitElement { + static override styles = css` + h1 { + text-align: center; + } + + .transaction-list { + background-color: var(--color-base-100); + border-radius: var(--radius-card); + padding: 20px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + } + + .transaction-list h3 { + color: var(--color-base-content); + margin-bottom: 20px; + } + + arx-wallet-transaction-line:last-child { + border-bottom: none; + } + `; + + private inputToken = ''; + + override async firstUpdated() { + await wallet.loadWallet(); + } + + async doReceiveToken() { + console.log('clicked', Date.now()); + await wallet.receiveToken(this.inputToken); + this.inputToken = ''; + } + + override connectedCallback(): void { + super.connectedCallback(); + new StateController(this, wallet); + } + + override render() { + return html` + +

Wallet

+
+

You have ${wallet.balance} sats

+ { + if (!e.detail.value) return; + this.inputToken = e.detail.value; + }} + type="text" + id="token" + > + +
+

Transaction History

+ ${wallet.sortedHistory.map( + (h) => html``, + )} +
+
+ `; + } +} diff --git a/src/routes/router.ts b/src/routes/router.ts index 65f68e7..ab7a796 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -7,6 +7,7 @@ import '@routes/Arbor/TopicView'; import '@routes/Home'; import '@routes/Profile'; import '@routes/Settings'; +import '@routes/Wallet'; import { spread } from '@open-wc/lit-helpers'; import { LitElement, css } from 'lit'; @@ -65,6 +66,11 @@ export default class EveRouter extends LitElement { params: {}, component: literal`arx-settings`, }, + { + pattern: 'wallet', + params: {}, + component: literal`arx-wallet-route`, + }, { pattern: '404', params: {}, diff --git a/src/style.css b/src/style.css index a6a8efb..bb02569 100644 --- a/src/style.css +++ b/src/style.css @@ -43,6 +43,8 @@ --border: 2px; --depth: 1; --noise: 1; + + --font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace; } body.dark { diff --git a/src/wallet.ts b/src/wallet.ts new file mode 100644 index 0000000..3d1fc09 --- /dev/null +++ b/src/wallet.ts @@ -0,0 +1,306 @@ +import { CashuMint, CashuWallet, type Proof, type SendOptions } from '@cashu/cashu-ts'; +import { bytesToHex } from '@noble/ciphers/utils'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { NDKEvent, type NDKEventId, NDKKind, type NDKTag, type NDKUser } from '@nostr-dev-kit/ndk'; +import { getSigner, ndk } from './ndk'; + +import { State, property } from '@lit-app/state'; + +export type WalletHistory = { + id: string; + amount: number; + created_at: number; + direction: 'in' | 'out'; + tokenEventId: NDKEventId; + nutZapEventId?: NDKEventId; + senderPubkey?: string; + recipientPubkey: string; +}; + +class Wallet extends State { + static mintUrl = 'https://testnut.cashu.space'; // TODO: this should be user controlled + + private walletEvent: NDKEvent | null = null; + private cashuWallet: CashuWallet = new CashuWallet(new CashuMint(Wallet.mintUrl)); + + private proofs: Proof[] = []; + private eventIdToProofIdMap: Map = new Map(); + + private isHandlingZapEvent = false; + private zapEventsQueue: NDKEvent[] = []; + private latestHistoryTimestamp = 0; + + @property() public balance = 0; + @property() public history: WalletHistory[] = []; + @property() public sortedHistory: WalletHistory[] = []; + + async loadWallet() { + if (this.walletEvent) return; + await getSigner(); + const user = await ndk.signer!.user()!; + const privateWalletEvent = await ndk.fetchEvent({ + kinds: [17375 as NDKKind], + authors: [user.pubkey], + }); + if (!privateWalletEvent) { + const walletPrivateKey = secp256k1.utils.randomPrivateKey(); + const walletPublicKey = secp256k1.getPublicKey(walletPrivateKey); + const newPrivateWalletEvent = new NDKEvent(ndk); + newPrivateWalletEvent.kind = NDKKind.CashuWallet; + newPrivateWalletEvent.content = await ndk.signer!.encrypt( + user, + JSON.stringify([ + ['privkey', bytesToHex(walletPrivateKey)], + ['mint', Wallet.mintUrl], + ]), + 'nip44', + ); + const publicWalletEvent = new NDKEvent(ndk); + publicWalletEvent.kind = NDKKind.CashuMintList; + publicWalletEvent.content = ''; + publicWalletEvent.tags = [ + ['mint', Wallet.mintUrl, 'sat'], + ['pubkey', bytesToHex(walletPublicKey)], + ]; + await newPrivateWalletEvent.sign(); + await newPrivateWalletEvent.publish(); + await publicWalletEvent.sign(); + await publicWalletEvent.publish(); + this.walletEvent = newPrivateWalletEvent; + } else this.walletEvent = privateWalletEvent; + + const walletHistory = await ndk.fetchEvents({ + kinds: [NDKKind.CashuWalletTx], + authors: [user.pubkey], + since: this.walletEvent?.created_at, + }); + + for (const event of walletHistory) { + await this.processHistoryEvent(event); + } + + const proofsEvents = await ndk.fetchEvents({ + kinds: [NDKKind.CashuToken], + authors: [user.pubkey], + }); + + for (const proofsEvent of proofsEvents) { + const { + mint, + proofs = [], + del = [], + } = JSON.parse(await ndk.signer!.decrypt(user, proofsEvent.content, 'nip44')) as { + mint: string; + proofs: Proof[]; + del?: NDKEventId[]; + }; + + if (mint !== this.cashuWallet.mint.mintUrl) continue; + + for (const eventId of del) { + const toDeletes = this.eventIdToProofIdMap.get(eventId); + if (toDeletes) + for (const proof of toDeletes) + this.proofs = this.proofs.filter((p) => p.secret !== proof[0] || p.C !== proof[1]); + this.eventIdToProofIdMap.delete(eventId); + } + + for (const proof of proofs) this.proofs.push(proof); + + this.eventIdToProofIdMap.set( + proofsEvent.id, + proofs.map((p) => [p.secret, p.C]), + ); + } + + ndk + .subscribe({ + kinds: [NDKKind.Nutzap], + '#p': [user.pubkey], + since: this.latestHistoryTimestamp, + }) + .on('event', async (event) => { + this.zapEventsQueue.push(event); + this.handleZapEvent(); + }); + + ndk + .subscribe({ + kinds: [NDKKind.CashuWalletTx], + authors: [user.pubkey], + since: this.latestHistoryTimestamp, + }) + .on('event', async (event) => { + await this.processHistoryEvent(event); + }); + + this.recalculateBalance(); + } + + private async processHistoryEvent(event: NDKEvent) { + const user = await ndk.signer!.user()!; + const parsedContent = JSON.parse(await ndk.signer!.decrypt(user, event.content, 'nip44')); + const direction = parsedContent.find((p: [string, string]) => p[0] === 'direction')?.[1]; + const amount = parsedContent.find((p: [string, string]) => p[0] === 'amount')?.[1]; + const tokenEventId = parsedContent.find((p: [string, string]) => p[0] === 'e')?.[1]; + const nutZapEventId = event.tags.find((t: NDKTag) => t[0] === 'e')?.[1]; + const senderPubkey = event.tags.find((t: NDKTag) => t[0] === 'p')?.[1]; + if (!nutZapEventId || !senderPubkey) return; + this.history.push({ + id: event.id, + amount: Number.parseInt(amount), + created_at: event.created_at ?? 0, + direction: direction as 'in' | 'out', + tokenEventId, + nutZapEventId: nutZapEventId as NDKEventId, + senderPubkey, + recipientPubkey: event.pubkey, + }); + this.latestHistoryTimestamp = Math.max(this.latestHistoryTimestamp, event.created_at ?? 0); + this.sortedHistory = [...this.history.sort((a, b) => b.created_at - a.created_at)]; + } + + private async handleZapEvent() { + if (this.isHandlingZapEvent) return; + const event = this.zapEventsQueue.shift(); + if (!event) return; + this.isHandlingZapEvent = true; + const proofs = JSON.parse(event.tags.find((t) => t[0] === 'proof')?.[1] ?? '[]') as Proof[]; + this.proofs.push(...proofs); + + const newProofsEvent = await this.createProofsEvent(); + await this.createHistoryEvent( + newProofsEvent, + this.proofs.reduce((acc, curr) => acc + curr.amount, 0), + 'in', + ); + this.recalculateBalance(); + this.isHandlingZapEvent = false; + } + + private async createProofsEvent(): Promise { + const newProofsEvent = new NDKEvent(ndk); + newProofsEvent.kind = NDKKind.CashuToken; + newProofsEvent.content = await ndk.signer?.encrypt( + await ndk.signer!.user()!, + JSON.stringify({ + mint: this.cashuWallet.mint.mintUrl, + proofs: this.proofs, + del: Array.from(this.eventIdToProofIdMap.keys()), + }), + 'nip44', + )!; + await newProofsEvent.sign(); + await newProofsEvent.publish(); + return newProofsEvent; + } + + recalculateBalance() { + this.proofs = this.proofs.filter( + (p, index, self) => index === self.findIndex((t) => t.secret === p.secret && t.C === p.C), + ); + this.balance = this.proofs.reduce((acc, curr) => acc + curr.amount, 0); + } + + private async createHistoryEvent(newProofsEvent: NDKEvent, amount: number, direction: 'in' | 'out') { + const historyEvent = new NDKEvent(ndk); + historyEvent.kind = NDKKind.CashuWalletTx; + historyEvent.content = await ndk.signer?.encrypt( + await ndk.signer!.user()!, + JSON.stringify([ + ['direction', direction], + ['amount', amount.toString()], + ['e', newProofsEvent.id], + ]), + 'nip44', + )!; + historyEvent.tags = [ + ['e', newProofsEvent.id], + ['p', newProofsEvent.pubkey], + ]; + await historyEvent.sign(); + await historyEvent.publish(); + } + + /** @throws Error if token is invalid */ + async receiveToken(token: string) { + const proofs = await this.cashuWallet.receive(token); + if (proofs.length === 0) throw new Error('Invalid token'); + for (const proof of proofs) this.proofs.push(proof); + const newProofsEvent = await this.createProofsEvent(); + const amount = proofs.reduce((acc, curr) => acc + curr.amount, 0); + await this.createHistoryEvent(newProofsEvent, amount, 'in'); + this.recalculateBalance(); + } + + private async createToken(amount: number, recipient?: NDKUser) { + if (amount < 10) throw new Error('Amount must be greater than 10'); + const sendOptions: SendOptions = { + includeFees: true, + }; + if (recipient) { + const mintListEvent = await ndk.fetchEvent({ + kinds: [NDKKind.CashuMintList], + authors: [recipient.pubkey], + }); + if (!mintListEvent) throw new Error('Recipient has no mint list'); + const pubkey = mintListEvent.tags.find((t) => t[0] === 'pubkey')?.[1]; + if (!pubkey) throw new Error('Recipient has no mint list'); + sendOptions.pubkey = pubkey; + } + await this.cashuWallet.getKeySets(); + const sendResult = await this.cashuWallet.send(amount, this.proofs, sendOptions); + this.proofs = sendResult.keep; + const proofsEvent = new NDKEvent(ndk); + proofsEvent.kind = NDKKind.CashuToken; + proofsEvent.content = await ndk.signer?.encrypt( + await ndk.signer!.user()!, + JSON.stringify({ + mint: this.cashuWallet.mint.mintUrl, + proofs: this.proofs, + del: Array.from(this.eventIdToProofIdMap.keys()), + }), + 'nip44', + )!; + await proofsEvent.sign(); + await proofsEvent.publish(); + this.recalculateBalance(); + return sendResult.send; + } + + async nutZap(amount: number, recipient: NDKUser, eventId?: NDKEventId, message?: string) { + const token = await this.createToken(amount, recipient); + const zapEvent = new NDKEvent(ndk); + zapEvent.kind = NDKKind.Nutzap; + zapEvent.content = message ?? ''; + zapEvent.tags = [ + ['proof', JSON.stringify(token)], + ['u', Wallet.mintUrl], + ['p', recipient.pubkey], + ]; + if (eventId) zapEvent.tags.push(['e', eventId]); + await zapEvent.sign(); + await zapEvent.publish(); + + const spendingHistoryEvent = new NDKEvent(ndk); + spendingHistoryEvent.kind = NDKKind.CashuWalletTx; + const historyContent = [ + ['direction', 'out'], + ['amount', amount.toString()], + ]; + if (eventId) historyContent.push(['e', eventId]); + spendingHistoryEvent.content = await ndk.signer?.encrypt( + await ndk.signer!.user()!, + JSON.stringify(historyContent), + 'nip44', + )!; + spendingHistoryEvent.tags = [ + ['e', zapEvent.id], + ['p', recipient.pubkey], + ]; + await spendingHistoryEvent.sign(); + await spendingHistoryEvent.publish(); + } +} + +export const wallet = new Wallet();