diff --git a/Cargo.lock b/Cargo.lock index 2035f995..b8627691 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2208,9 +2208,9 @@ dependencies = [ [[package]] name = "hdi" -version = "0.4.0-beta-dev.35" +version = "0.4.0-beta-dev.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "573077dc8ba0c02ec52b63505ffd09af58a4c724c3ee98a04375125109839c27" +checksum = "efe35d8a7150f89f54d6a2c61320bfaf70af9d5d0dd5726b2ce68e802fcab2e4" dependencies = [ "getrandom 0.2.14", "hdk_derive", @@ -2226,9 +2226,9 @@ dependencies = [ [[package]] name = "hdk" -version = "0.3.0-beta-dev.39" +version = "0.3.0-beta-dev.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bc83515fde323014ef26be88b617c0b5150426db0efac5471c9526147f5d235" +checksum = "700ef8595eac45e31a61b1f1af054c247156fddc52dd31ba3902acd8d17c5db3" dependencies = [ "getrandom 0.2.14", "hdi", @@ -2354,9 +2354,9 @@ dependencies = [ [[package]] name = "holochain" -version = "0.3.0-beta-dev.46" +version = "0.3.0-beta-dev.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf428d2c82e6d40f09222985c6532d08f9bd970c03c9e524fd71910b43b59521" +checksum = "b4bb959fa5b8b9065939d024317d252373430dfa70fe6836c418468201e8b4db" dependencies = [ "aitia", "anyhow", @@ -2461,9 +2461,9 @@ dependencies = [ [[package]] name = "holochain_cascade" -version = "0.3.0-beta-dev.45" +version = "0.3.0-beta-dev.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0e94385cd126e22b833bce2c55734acc08cc01aebbf7ddf102a1348d09dafad" +checksum = "be737e39dcafac62ddfd8ca79ce988742676e3c6296841900d505480826e1417" dependencies = [ "async-trait", "derive_more", @@ -2791,9 +2791,9 @@ dependencies = [ [[package]] name = "holochain_test_wasm_common" -version = "0.3.0-beta-dev.39" +version = "0.3.0-beta-dev.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9e50a4d610dbc0145196cb3163255e2cf17d77600a0eef4af583f9e6afeef72" +checksum = "5fe92b88750e40a8ab15c14c55670fce756fe1e0e09dd7dbfb5040953a3301e1" dependencies = [ "hdk", "serde", diff --git a/Cargo.toml b/Cargo.toml index 7c317f79..c7c5ed26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,10 +9,10 @@ members = ["zomes/coordinator/*", "zomes/integrity/*"] resolver = "2" [workspace.dependencies] -holochain = { version = "0.3.0-beta-dev.45", default-features = false, features = [ +holochain = { version = "=0.3.0-beta-dev.45", default-features = false, features = [ "test_utils", ] } -hdi = "0.4.0-beta-dev.34" -hdk = "0.3.0-beta-dev.38" -serde = "1" +hdi = "=0.4.0-beta-dev.34" +hdk = "=0.3.0-beta-dev.38" +serde = "1.0.193" diff --git a/tests/src/notification-lifecycle.test.ts b/tests/src/notification-lifecycle.test.ts index 58c3d396..582b9c3c 100644 --- a/tests/src/notification-lifecycle.test.ts +++ b/tests/src/notification-lifecycle.test.ts @@ -1,145 +1,143 @@ - -import { assert, test } from "vitest"; - -import { runScenario, dhtSync } from '@holochain/tryorama'; import { EntryRecord } from '@holochain-open-dev/utils'; +import { dhtSync, runScenario } from '@holochain/tryorama'; +import { assert, test } from 'vitest'; -import { Notification } from '../../ui/src/types.js'; import { sampleNotification } from '../../ui/src/mocks.js'; +import { toPromise } from '../../ui/src/signals.js'; +import { Notification } from '../../ui/src/types.js'; import { setup } from './setup.js'; -import { toPromise } from "../../ui/src/signals.js"; test('create notifications, read it, and dismiss it', async () => { - await runScenario(async scenario => { - const { alice, bob } = await setup(scenario); - - // Wait for the created entry to be propagated to the other node. - await dhtSync( - [alice.player, bob.player], - alice.player.cells[0].cell_id[0] - ); - - let unreadNotifications = await toPromise(bob.store.unreadNotifications); - assert.equal(unreadNotifications.size, 0); - console.log('heyyy0') - let readNotifications = await toPromise(bob.store.readNotifications); - console.log('heyyy0.1') - assert.equal(readNotifications.size, 0); - let dismissedNotifications = await toPromise(bob.store.dismissedNotifications); - console.log('heyyy0.2') - assert.equal(dismissedNotifications.size, 0); - - console.log('heyyy1') - - // Alice creates a Notification - const notification1: EntryRecord = await alice.store.client.createNotification(await sampleNotification(alice.store.client, { - recipients: [bob.player.agentPubKey] - })); - assert.ok(notification1); - const notification2: EntryRecord = await alice.store.client.createNotification(await sampleNotification(alice.store.client, { - recipients: [bob.player.agentPubKey] - })); - assert.ok(notification2); - console.log('heyyy2') - - // Wait for the created entry to be propagated to the other node. - await dhtSync( - [alice.player, bob.player], - alice.player.cells[0].cell_id[0] - ); - - unreadNotifications = await toPromise(bob.store.unreadNotifications); - assert.equal(unreadNotifications.size, 2); - readNotifications = await toPromise(bob.store.readNotifications); - assert.equal(readNotifications.size, 0); - dismissedNotifications = await toPromise(bob.store.dismissedNotifications); - assert.equal(dismissedNotifications.size, 0); - - await bob.store.client.markNotificationsAsRead([notification1.actionHash]); - console.log('heyyy3') - - unreadNotifications = await toPromise(bob.store.unreadNotifications); - assert.equal(unreadNotifications.size, 1); - readNotifications = await toPromise(bob.store.readNotifications); - assert.equal(readNotifications.size, 1); - dismissedNotifications = await toPromise(bob.store.dismissedNotifications); - assert.equal(dismissedNotifications.size, 0); - - await bob.store.client.markNotificationsAsRead([notification2.actionHash]); - - console.log('heyyy4') - unreadNotifications = await toPromise(bob.store.unreadNotifications); - assert.equal(unreadNotifications.size, 0); - readNotifications = await toPromise(bob.store.readNotifications); - assert.equal(readNotifications.size, 2); - dismissedNotifications = await toPromise(bob.store.dismissedNotifications); - assert.equal(dismissedNotifications.size, 0); - - console.log('heyyy5') - // Bob deletes the Notification - await bob.store.client.dismissNotifications([notification2.actionHash]); - - unreadNotifications = await toPromise(bob.store.unreadNotifications); - assert.equal(unreadNotifications.size, 0); - readNotifications = await toPromise(bob.store.readNotifications); - assert.equal(readNotifications.size, 1); - dismissedNotifications = await toPromise(bob.store.dismissedNotifications); - assert.equal(dismissedNotifications.size, 1); - - // Bob deletes the Notification - await bob.store.client.dismissNotifications([notification1.actionHash]); - console.log('heyyy6') - - unreadNotifications = await toPromise(bob.store.unreadNotifications); - assert.equal(unreadNotifications.size, 0); - readNotifications = await toPromise(bob.store.readNotifications); - assert.equal(readNotifications.size, 0); - dismissedNotifications = await toPromise(bob.store.dismissedNotifications); - assert.equal(dismissedNotifications.size, 2); - }); + await runScenario(async scenario => { + const { alice, bob } = await setup(scenario); + + // Wait for the created entry to be propagated to the other node. + await dhtSync([alice.player, bob.player], alice.player.cells[0].cell_id[0]); + + let unreadNotifications = await toPromise(bob.store.unreadNotifications); + assert.equal(unreadNotifications.size, 0); + let readNotifications = await toPromise(bob.store.readNotifications); + assert.equal(readNotifications.size, 0); + let dismissedNotifications = await toPromise( + bob.store.dismissedNotifications, + ); + assert.equal(dismissedNotifications.size, 0); + + // Alice creates a Notification + const notification1: EntryRecord = + await alice.store.client.createNotification( + await sampleNotification(alice.store.client, { + recipients: [bob.player.agentPubKey], + }), + ); + assert.ok(notification1); + const notification2: EntryRecord = + await alice.store.client.createNotification( + await sampleNotification(alice.store.client, { + recipients: [bob.player.agentPubKey], + }), + ); + assert.ok(notification2); + + // Wait for the created entry to be propagated to the other node. + await dhtSync([alice.player, bob.player], alice.player.cells[0].cell_id[0]); + + unreadNotifications = await toPromise(bob.store.unreadNotifications); + assert.equal(unreadNotifications.size, 2); + readNotifications = await toPromise(bob.store.readNotifications); + assert.equal(readNotifications.size, 0); + dismissedNotifications = await toPromise(bob.store.dismissedNotifications); + assert.equal(dismissedNotifications.size, 0); + + await bob.store.client.markNotificationsAsRead([notification1.actionHash]); + + unreadNotifications = await toPromise(bob.store.unreadNotifications); + assert.equal(unreadNotifications.size, 1); + readNotifications = await toPromise(bob.store.readNotifications); + assert.equal(readNotifications.size, 1); + dismissedNotifications = await toPromise(bob.store.dismissedNotifications); + assert.equal(dismissedNotifications.size, 0); + + await bob.store.client.markNotificationsAsRead([notification2.actionHash]); + + unreadNotifications = await toPromise(bob.store.unreadNotifications); + assert.equal(unreadNotifications.size, 0); + readNotifications = await toPromise(bob.store.readNotifications); + assert.equal(readNotifications.size, 2); + dismissedNotifications = await toPromise(bob.store.dismissedNotifications); + assert.equal(dismissedNotifications.size, 0); + + // Bob deletes the Notification + await bob.store.client.dismissNotifications([notification2.actionHash]); + + unreadNotifications = await toPromise(bob.store.unreadNotifications); + assert.equal(unreadNotifications.size, 0); + readNotifications = await toPromise(bob.store.readNotifications); + assert.equal(readNotifications.size, 1); + dismissedNotifications = await toPromise(bob.store.dismissedNotifications); + assert.equal(dismissedNotifications.size, 1); + + // Bob deletes the Notification + await bob.store.client.dismissNotifications([notification1.actionHash]); + + unreadNotifications = await toPromise(bob.store.unreadNotifications); + assert.equal(unreadNotifications.size, 0); + readNotifications = await toPromise(bob.store.readNotifications); + assert.equal(readNotifications.size, 0); + dismissedNotifications = await toPromise(bob.store.dismissedNotifications); + assert.equal(dismissedNotifications.size, 2); + }); }); test('create notifications and dismiss it directly', async () => { - await runScenario(async scenario => { - const { alice, bob } = await setup(scenario); - - let unreadNotifications = await toPromise(bob.store.unreadNotifications); - assert.equal(unreadNotifications.size, 0); - let readNotifications = await toPromise(bob.store.readNotifications); - assert.equal(readNotifications.size, 0); - let dismissedNotifications = await toPromise(bob.store.dismissedNotifications); - assert.equal(dismissedNotifications.size, 0); - - // Alice creates a Notification - const notification1: EntryRecord = await alice.store.client.createNotification(await sampleNotification(alice.store.client, { - recipients: [bob.player.agentPubKey] - })); - assert.ok(notification1); - const notification2: EntryRecord = await alice.store.client.createNotification(await sampleNotification(alice.store.client, { - recipients: [bob.player.agentPubKey] - })); - assert.ok(notification2); - - // Wait for the created entry to be propagated to the other node. - await dhtSync( - [alice.player, bob.player], - alice.player.cells[0].cell_id[0] - ); - - unreadNotifications = await toPromise(bob.store.unreadNotifications); - assert.equal(unreadNotifications.size, 2); - readNotifications = await toPromise(bob.store.readNotifications); - assert.equal(readNotifications.size, 0); - dismissedNotifications = await toPromise(bob.store.dismissedNotifications); - assert.equal(dismissedNotifications.size, 0); - - await bob.store.client.dismissNotifications([notification1.actionHash, notification2.actionHash]); - - unreadNotifications = await toPromise(bob.store.unreadNotifications); - assert.equal(unreadNotifications.size, 0); - readNotifications = await toPromise(bob.store.readNotifications); - assert.equal(readNotifications.size, 0); - dismissedNotifications = await toPromise(bob.store.dismissedNotifications); - assert.equal(dismissedNotifications.size, 2); - }); + await runScenario(async scenario => { + const { alice, bob } = await setup(scenario); + + let unreadNotifications = await toPromise(bob.store.unreadNotifications); + assert.equal(unreadNotifications.size, 0); + let readNotifications = await toPromise(bob.store.readNotifications); + assert.equal(readNotifications.size, 0); + let dismissedNotifications = await toPromise( + bob.store.dismissedNotifications, + ); + assert.equal(dismissedNotifications.size, 0); + + // Alice creates a Notification + const notification1: EntryRecord = + await alice.store.client.createNotification( + await sampleNotification(alice.store.client, { + recipients: [bob.player.agentPubKey], + }), + ); + assert.ok(notification1); + const notification2: EntryRecord = + await alice.store.client.createNotification( + await sampleNotification(alice.store.client, { + recipients: [bob.player.agentPubKey], + }), + ); + assert.ok(notification2); + + // Wait for the created entry to be propagated to the other node. + await dhtSync([alice.player, bob.player], alice.player.cells[0].cell_id[0]); + + unreadNotifications = await toPromise(bob.store.unreadNotifications); + assert.equal(unreadNotifications.size, 2); + readNotifications = await toPromise(bob.store.readNotifications); + assert.equal(readNotifications.size, 0); + dismissedNotifications = await toPromise(bob.store.dismissedNotifications); + assert.equal(dismissedNotifications.size, 0); + + await bob.store.client.dismissNotifications([ + notification1.actionHash, + notification2.actionHash, + ]); + + unreadNotifications = await toPromise(bob.store.unreadNotifications); + assert.equal(unreadNotifications.size, 0); + readNotifications = await toPromise(bob.store.readNotifications); + assert.equal(readNotifications.size, 0); + dismissedNotifications = await toPromise(bob.store.dismissedNotifications); + assert.equal(dismissedNotifications.size, 2); + }); }); diff --git a/ui/demo/index.html b/ui/demo/index.html index eabe175f..39d7592b 100644 --- a/ui/demo/index.html +++ b/ui/demo/index.html @@ -41,10 +41,13 @@ import '@holochain-open-dev/profiles/dist/elements/profile-prompt.js'; import { AppAgentWebsocket } from '@holochain/client'; import { ContextProvider } from '@lit/context'; + import { encode } from '@msgpack/msgpack'; + import '@shoelace-style/shoelace/dist/components/button/button.js'; import '@shoelace-style/shoelace/dist/components/spinner/spinner.js'; import '@shoelace-style/shoelace/dist/themes/light.css'; import { LitElement, css, html } from 'lit'; + import '../src/elements/my-notifications.ts'; import '../src/elements/notifications-context.ts'; import { NotificationsClient, NotificationsStore } from '../src/index.ts'; @@ -78,7 +81,33 @@ renderContent() { return html` - + + + this._notificationsStore.client.createNotification({ + notification_type: '', + notification_group: undefined, + persistent: true, + recipients: [ + this._notificationsStore.client.client.myPubKey, + ], + content: encode({ title: 'hey!', body: "what's up?" }), + })} + >Create persistent notification + + this._notificationsStore.client.createNotification({ + notification_type: '', + notification_group: undefined, + persistent: false, + recipients: [ + this._notificationsStore.client.client.myPubKey, + ], + content: encode({ title: 'hey!', body: "what's up?" }), + })} + >Create non-persistent notification `; } diff --git a/ui/src/elements/create-notification.ts b/ui/src/elements/create-notification.ts deleted file mode 100644 index 0197a477..00000000 --- a/ui/src/elements/create-notification.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { - hashProperty, - hashState, - notifyError, - onSubmit, - sharedStyles, - wrapPathInSvg, -} from '@holochain-open-dev/elements'; -import '@holochain-open-dev/elements/dist/elements/display-error.js'; -import { EntryRecord } from '@holochain-open-dev/utils'; -import { - ActionHash, - AgentPubKey, - DnaHash, - EntryHash, - Record, -} from '@holochain/client'; -import { consume } from '@lit/context'; -import { localized, msg } from '@lit/localize'; -import { mdiAlertCircleOutline, mdiDelete } from '@mdi/js'; -import SlAlert from '@shoelace-style/shoelace/dist/components/alert/alert.js'; -import '@shoelace-style/shoelace/dist/components/alert/alert.js'; -import '@shoelace-style/shoelace/dist/components/button/button.js'; -import '@shoelace-style/shoelace/dist/components/card/card.js'; -import '@shoelace-style/shoelace/dist/components/icon-button/icon-button.js'; -import '@shoelace-style/shoelace/dist/components/icon/icon.js'; -import { LitElement, html } from 'lit'; -import { customElement, property, query, state } from 'lit/decorators.js'; -import { repeat } from 'lit/directives/repeat.js'; - -import { notificationsStoreContext } from '../context.js'; -import { NotificationsStore } from '../notifications-store.js'; -import { Notification } from '../types.js'; - -/** - * @element create-notification - * @fires notification-created: detail will contain { notificationHash } - */ -@localized() -@customElement('create-notification') -export class CreateNotification extends LitElement { - /** - * REQUIRED. The notification type for this Notification - */ - @property() - notificationType!: string; - - /** - * OPTIONAl. The notification group for this Notification - */ - @property() - notificationGroup: string | undefined; - - /** - * REQUIRED. The persistent for this Notification - */ - @property() - persistent!: boolean; - - /** - * REQUIRED. The recipients for this Notification - */ - @property() - recipients!: Array; - - /** - * REQUIRED. The content for this Notification - */ - @property() - content!: string; - - /** - * @internal - */ - @consume({ context: notificationsStoreContext, subscribe: true }) - notificationsStore!: NotificationsStore; - - /** - * @internal - */ - @state() - committing = false; - - /** - * @internal - */ - @query('#create-form') - form!: HTMLFormElement; - - async createNotification(fields: Partial) { - if (this.notificationType === undefined) - throw new Error( - 'Cannot create a new Notification without its notification_type field', - ); - if (this.persistent === undefined) - throw new Error( - 'Cannot create a new Notification without its persistent field', - ); - if (this.recipients === undefined) - throw new Error( - 'Cannot create a new Notification without its recipients field', - ); - if (this.content === undefined) - throw new Error( - 'Cannot create a new Notification without its content field', - ); - - const notification: Notification = { - notification_type: this.notificationType!, - notification_group: this.notificationGroup!, - persistent: this.persistent!, - recipients: this.recipients!, - content: this.content!, - }; - - try { - this.committing = true; - const record: EntryRecord = - await this.notificationsStore.client.createNotification(notification); - - this.dispatchEvent( - new CustomEvent('notification-created', { - composed: true, - bubbles: true, - detail: { - notificationHash: record.actionHash, - }, - }), - ); - - this.form.reset(); - } catch (e: unknown) { - console.error(e); - notifyError(msg('Error creating the notification')); - } - this.committing = false; - } - - render() { - return html` - ${msg('Create Notification')} - -
this.createNotification(fields))} - > - ${msg('Create Notification')} -
-
`; - } - - static styles = [sharedStyles]; -} diff --git a/ui/src/elements/notifications-for-recipient.ts b/ui/src/elements/my-notifications.ts similarity index 54% rename from ui/src/elements/notifications-for-recipient.ts rename to ui/src/elements/my-notifications.ts index ae6b24eb..1768ba77 100644 --- a/ui/src/elements/notifications-for-recipient.ts +++ b/ui/src/elements/my-notifications.ts @@ -1,5 +1,4 @@ import { - hashProperty, renderAsyncStatus, sharedStyles, wrapPathInSvg, @@ -11,28 +10,28 @@ import { EntryRecord, slice } from '@holochain-open-dev/utils'; import { ActionHash, AgentPubKey, EntryHash, Record } from '@holochain/client'; import { consume } from '@lit/context'; import { localized, msg } from '@lit/localize'; -import { mdiInformationOutline } from '@mdi/js'; +import { mdiBell, mdiInformationOutline } from '@mdi/js'; +import '@shoelace-style/shoelace/dist/components/badge/badge.js'; +import '@shoelace-style/shoelace/dist/components/icon-button/icon-button.js'; import '@shoelace-style/shoelace/dist/components/icon/icon.js'; +import '@shoelace-style/shoelace/dist/components/skeleton/skeleton.js'; import '@shoelace-style/shoelace/dist/components/spinner/spinner.js'; import { LitElement, html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; +import { Signal } from 'signal-polyfill'; import { notificationsStoreContext } from '../context.js'; +import { SignalWatcher, watch } from '../lit-signals.js'; import { NotificationsStore } from '../notifications-store.js'; +import { effect } from '../signals.js'; import { Notification } from '../types.js'; /** - * @element notifications-for-recipient + * @element my-notifications */ @localized() -@customElement('notifications-for-recipient') -export class NotificationsForRecipient extends LitElement { - /** - * REQUIRED. The Recipient for which the Notifications should be fetched - */ - @property(hashProperty('recipient')) - recipient!: AgentPubKey; - +@customElement('my-notifications') +export class MyNotifications extends SignalWatcher(LitElement) { /** * @internal */ @@ -63,26 +62,43 @@ export class NotificationsForRecipient extends LitElement { `; } + // firstUpdated() { + // effect(() => { + // const unreadNotifications = + // this.notificationsStore.unreadNotifications.get(); + // this.requestUpdate(); + // }); + // } + + renderBadge() { + const unreadNotifications = + this.notificationsStore.unreadNotifications.get(); + + switch (unreadNotifications.status) { + case 'pending': + return html`
+ +
`; + case 'error': + return html``; + case 'completed': + return html`${unreadNotifications.value.size}`; + } + } + render() { - return html`${subscribe( - this.notificationsStore.notificationsForRecipient.get(this.recipient) - .live, - renderAsyncStatus({ - complete: map => this.renderList(Array.from(map.keys())), - pending: () => - html`
- -
`, - error: e => - html``, - }), - )}`; + return html` + + ${this.renderBadge()} + `; } static styles = [sharedStyles]; } +const s = new Signal.State(0); diff --git a/ui/src/elements/notification-detail.ts b/ui/src/elements/notification-detail.ts deleted file mode 100644 index cdfaf2a9..00000000 --- a/ui/src/elements/notification-detail.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { - hashProperty, - notifyError, - renderAsyncStatus, - sharedStyles, - wrapPathInSvg, -} from '@holochain-open-dev/elements'; -import '@holochain-open-dev/elements/dist/elements/display-error.js'; -import { subscribe } from '@holochain-open-dev/stores'; -import { EntryRecord } from '@holochain-open-dev/utils'; -import { ActionHash, EntryHash, Record } from '@holochain/client'; -import { consume } from '@lit/context'; -import { localized, msg } from '@lit/localize'; -import { mdiAlertCircleOutline, mdiDelete, mdiPencil } from '@mdi/js'; -import '@shoelace-style/shoelace/dist/components/alert/alert.js'; -import SlAlert from '@shoelace-style/shoelace/dist/components/alert/alert.js'; -import '@shoelace-style/shoelace/dist/components/button/button.js'; -import '@shoelace-style/shoelace/dist/components/card/card.js'; -import '@shoelace-style/shoelace/dist/components/icon-button/icon-button.js'; -import '@shoelace-style/shoelace/dist/components/spinner/spinner.js'; -import { LitElement, html } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; - -import { notificationsStoreContext } from '../context.js'; -import { NotificationsStore } from '../notifications-store.js'; -import { Notification } from '../types.js'; - -/** - * @element notification-detail - * @fires notification-deleted: detail will contain { notificationHash } - */ -@localized() -@customElement('notification-detail') -export class NotificationDetail extends LitElement { - /** - * REQUIRED. The hash of the Notification to show - */ - @property(hashProperty('notification-hash')) - notificationHash!: ActionHash; - - /** - * @internal - */ - @consume({ context: notificationsStoreContext, subscribe: true }) - notificationsStore!: NotificationsStore; - - async deleteNotification() { - try { - await this.notificationsStore.client.deleteNotification( - this.notificationHash, - ); - - this.dispatchEvent( - new CustomEvent('notification-deleted', { - bubbles: true, - composed: true, - detail: { - notificationHash: this.notificationHash, - }, - }), - ); - } catch (e: unknown) { - console.error(e); - notifyError(msg('Error deleting the notification')); - } - } - - renderDetail(entryRecord: EntryRecord) { - return html` - -
- ${msg('Notification')} - - this.deleteNotification()} - > -
- -
-
- `; - } - - render() { - return html`${subscribe( - this.notificationsStore.notifications.get(this.notificationHash).entry, - renderAsyncStatus({ - complete: notification => { - return this.renderDetail(notification); - }, - pending: () => - html`
- -
`, - error: e => - html``, - }), - )}`; - } - - static styles = [sharedStyles]; -} diff --git a/ui/src/elements/notification-summary.ts b/ui/src/elements/notification-summary.ts deleted file mode 100644 index 96020c00..00000000 --- a/ui/src/elements/notification-summary.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - hashProperty, - renderAsyncStatus, - sharedStyles, -} from '@holochain-open-dev/elements'; -import '@holochain-open-dev/elements/dist/elements/display-error.js'; -import { subscribe } from '@holochain-open-dev/stores'; -import { EntryRecord } from '@holochain-open-dev/utils'; -import { ActionHash, EntryHash, Record } from '@holochain/client'; -import { consume } from '@lit/context'; -import { localized, msg } from '@lit/localize'; -import '@shoelace-style/shoelace/dist/components/card/card.js'; -import '@shoelace-style/shoelace/dist/components/spinner/spinner.js'; -import { LitElement, html } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; - -import { notificationsStoreContext } from '../context.js'; -import { NotificationsStore } from '../notifications-store.js'; -import { Notification } from '../types.js'; - -/** - * @element notification-summary - * @fires notification-selected: detail will contain { notificationHash } - */ -@localized() -@customElement('notification-summary') -export class NotificationSummary extends LitElement { - /** - * REQUIRED. The hash of the Notification to show - */ - @property(hashProperty('notification-hash')) - notificationHash!: ActionHash; - - /** - * @internal - */ - @consume({ context: notificationsStoreContext, subscribe: true }) - notificationsStore!: NotificationsStore; - - renderSummary(entryRecord: EntryRecord) { - return html`
`; - } - - renderNotification() { - return html`${subscribe( - this.notificationsStore.notifications.get(this.notificationHash).entry, - renderAsyncStatus({ - complete: notification => this.renderSummary(notification), - pending: () => - html`
- -
`, - error: e => - html``, - }), - )}`; - } - - render() { - return html` - this.dispatchEvent( - new CustomEvent('notification-selected', { - composed: true, - bubbles: true, - detail: { - notificationHash: this.notificationHash, - }, - }), - )} - > - ${this.renderNotification()} - `; - } - - static styles = [sharedStyles]; -} diff --git a/ui/src/lit-signals.ts b/ui/src/lit-signals.ts new file mode 100644 index 00000000..286b6a2a --- /dev/null +++ b/ui/src/lit-signals.ts @@ -0,0 +1,134 @@ +import { TemplateResult } from 'lit'; + +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +import type { ReactiveElement } from 'lit'; +import { AsyncDirective } from 'lit/async-directive.js'; +import { DirectiveResult, directive } from 'lit/directive.js'; +import { Signal } from 'signal-polyfill'; + +import { effect } from './signals'; + +class SubscribeDirective extends AsyncDirective { + private __signal?: Signal.State | Signal.Computed; + private __unsubscribe?: () => void; + private __template?: (value: any) => TemplateResult; + + override render( + signal: Signal.State | Signal.Computed, + template: (value: T) => TemplateResult, + ) { + if (signal !== this.__signal) { + const oldUnsubscribe = this.__unsubscribe; + this.__signal = signal; + this.__template = template; + + this.__unsubscribe = effect(() => { + const value = this.__signal!.get(); + // The subscribe() callback is called synchronously during subscribe. + // Ignore the first call since we return the value below in that case. + this.setValue(template(value)); + }); + + if (oldUnsubscribe) { + oldUnsubscribe(); + } + } + + return template(this.__signal.get()); + } + + protected override disconnected(): void { + this.__unsubscribe?.(); + } + + protected override reconnected(): void { + // Since we disposed the subscription in disconnected() we need to + // resubscribe here. We don't ignore the synchronous callback call because + // the signal might have changed while the directive is disconnected. + // + // There are two possible reasons for a disconnect: + // 1. The host element was disconnected. + // 2. The directive was not rendered during a render + // In the first case the element will not schedule an update on reconnect, + // so we need the synchronous call here to set the current value. + // In the second case, we're probably reconnecting *because* of a render, + // so the synchronous call here will go before a render call, and we'll get + // two sets of the value (setValue() here and the return in render()), but + // this is ok because the value will be dirty-checked by lit-html. + + this.__unsubscribe = effect(() => { + const value = this.__signal!.get(); + // The subscribe() callback is called synchronously during subscribe. + // Ignore the first call since we return the value below in that case. + this.setValue(this.__template!(value)); + }); + } +} + +/** + * Renders a signal and subscribes to it, updating the part when the store + * changes. + */ +export const watch = directive(SubscribeDirective) as ( + signal: Signal.State | Signal.Computed, + template: (value: T) => TemplateResult, +) => DirectiveResult; + +type ReactiveElementConstructor = abstract new ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...args: any[] +) => ReactiveElement; + +/** + * Adds the ability for a LitElement or other ReactiveElement class to + * watch for access to Preact signals during the update lifecycle and + * trigger a new update when signals values change. + */ +export function SignalWatcher( + Base: T, +): T { + abstract class SignalWatcher extends Base { + private __dispose?: () => void; + private w = new Signal.subtle.Watcher(() => { + this.requestUpdate(); + }); + + override performUpdate() { + // ReactiveElement.performUpdate() also does this check, so we want to + // also bail early so we don't erroneously appear to not depend on any + // signals. + if (this.isUpdatePending === false) { + return; + } + // If we have a previous effect, dispose it + const lastDispose = this.__dispose; + + const c = new Signal.Computed(() => { + super.performUpdate(); + }); + this.w.watch(c); + this.__dispose = () => { + this.w.unwatch(c); + }; + c.get(); + lastDispose?.(); + } + + override connectedCallback(): void { + super.connectedCallback(); + // In order to listen for signals again after re-connection, we must + // re-render to capture all the current signal accesses. + this.requestUpdate(); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.__dispose?.(); + } + } + return SignalWatcher; +} diff --git a/ui/src/notifications-store.ts b/ui/src/notifications-store.ts index 7c1990de..79a659ef 100644 --- a/ui/src/notifications-store.ts +++ b/ui/src/notifications-store.ts @@ -1,4 +1,4 @@ -import { LazyHoloHashMap, slice } from '@holochain-open-dev/utils'; +import { HoloHashMap, LazyHoloHashMap, slice } from '@holochain-open-dev/utils'; import { ActionHash, encodeHashToBase64 } from '@holochain/client'; import { decode } from '@msgpack/msgpack'; diff --git a/ui/src/signals.ts b/ui/src/signals.ts index 351852ac..865f40e0 100644 --- a/ui/src/signals.ts +++ b/ui/src/signals.ts @@ -48,27 +48,28 @@ export type AsyncSignal = | AsyncState | AsyncComputed; +// This function would usually live in a library/framework, not application code +// NOTE: This scheduling logic is too basic to be useful. Do not copy/paste. +let pending = false; + +const w = new Signal.subtle.Watcher(() => { + if (!pending) { + pending = true; + queueMicrotask(() => { + pending = false; + for (const s of w.getPending()) s.get(); + w.watch(); + }); + } +}); // An effect effect Signal which evaluates to cb, which schedules a read of // itself on the microtask queue whenever one of its dependencies might change export function effect(cb: (() => () => void) | (() => void)) { - // This function would usually live in a library/framework, not application code - // NOTE: This scheduling logic is too basic to be useful. Do not copy/paste. - let pending = false; - - const w = new Signal.subtle.Watcher(() => { - if (!pending) { - pending = true; - queueMicrotask(() => { - pending = false; - for (const s of w.getPending()) s.get(); - w.watch(); - }); - } - }); let destructor: void | (() => void); const c = new Signal.Computed(() => { - destructor?.(); + const lastDestructor = destructor; destructor = cb(); + lastDestructor?.(); }); w.watch(c); c.get(); @@ -689,12 +690,13 @@ export function liveLinksStore< }; const fetch = async () => { if (!active) return; - const nlinks = await fetchLinks().finally(() => { - if (active) { - setTimeout(() => fetch(), pollIntervalMs); - } - }); - maybeSet(nlinks); + fetchLinks() + .then(maybeSet) + .finally(() => { + if (active) { + setTimeout(() => fetch(), pollIntervalMs); + } + }); }; fetch().catch(error => {