From 6970a375b7eb31901edbcd3bcc19e6394028c24c Mon Sep 17 00:00:00 2001 From: Mo Date: Thu, 3 Feb 2022 18:14:28 -0600 Subject: [PATCH] feat: native listed integration (#846) * feat(wip): native listed integration * feat(wip): wip * feat: simplified actions menu structure * feat: open settings alert upon succesful creation * fix: handle remove menu row api * chore(deps): snjs --- .../javascripts/components/ActionsMenu.tsx | 449 ++++++++---------- .../javascripts/components/HistoryMenu.tsx | 6 +- app/assets/javascripts/components/MenuRow.tsx | 17 +- .../components/NoteView/NoteView.tsx | 2 +- .../javascripts/preferences/panes/Listed.tsx | 112 ++--- .../preferences/panes/listed/BlogItem.tsx | 93 +--- .../ui_models/app_state/actions_menu_state.ts | 20 +- package.json | 2 +- yarn.lock | 8 +- 9 files changed, 294 insertions(+), 415 deletions(-) diff --git a/app/assets/javascripts/components/ActionsMenu.tsx b/app/assets/javascripts/components/ActionsMenu.tsx index 906b9afe9..40393a52e 100644 --- a/app/assets/javascripts/components/ActionsMenu.tsx +++ b/app/assets/javascripts/components/ActionsMenu.tsx @@ -1,203 +1,224 @@ import { WebApplication } from '@/ui_models/application'; import { - SNItem, Action, SNActionsExtension, UuidString, - CopyPayload, SNNote, + ListedAccount, } from '@standardnotes/snjs'; import { ActionResponse } from '@standardnotes/snjs'; import { render } from 'preact'; import { PureComponent } from './Abstract/PureComponent'; import { MenuRow } from './MenuRow'; import { RevisionPreviewModal } from './RevisionPreviewModal'; -type ActionsMenuScope = { - application: WebApplication; - item: SNItem; -}; -type ActionSubRow = { - onClick: () => void; - label: string; - subtitle: string; +type ActionRow = Action & { + running?: boolean; spinnerClass?: string; + subtitle?: string; }; -type ExtensionState = { - loading: boolean; - error: boolean; -}; - -type MenuItem = { +type MenuSection = { uuid: UuidString; name: string; - loading: boolean; - error: boolean; - hidden: boolean; + loading?: boolean; + error?: boolean; + hidden?: boolean; deprecation?: string; - actions: (Action & { - subrows?: ActionSubRow[]; - })[]; + extension?: SNActionsExtension; + rows?: ActionRow[]; + listedAccount?: ListedAccount; }; -type ActionState = { - error: boolean; - running: boolean; -}; - -type ActionsMenuState = { - extensions: SNActionsExtension[]; - extensionsState: Record; - hiddenExtensions: Record; - selectedActionId?: number; - menuItems: MenuItem[]; - actionState: Record; +type State = { + menuSections: MenuSection[]; + selectedActionIdentifier?: string; }; type Props = { application: WebApplication; - item: SNNote; + note: SNNote; }; -export class ActionsMenu - extends PureComponent - implements ActionsMenuScope -{ - application!: WebApplication; - item!: SNItem; - +export class ActionsMenu extends PureComponent { constructor(props: Props) { super(props, props.application); - const extensions = props.application.actionsManager + this.state = { + menuSections: [], + }; + + this.loadExtensions(); + } + + private async loadExtensions(): Promise { + const unresolvedListedSections = + await this.getNonresolvedListedMenuSections(); + const unresolvedGenericSections = + await this.getNonresolvedGenericMenuSections(); + this.setState( + { + menuSections: unresolvedListedSections.concat( + unresolvedGenericSections + ), + }, + () => { + this.state.menuSections.forEach((menuSection) => { + this.resolveMenuSection(menuSection); + }); + } + ); + } + + private async getNonresolvedGenericMenuSections(): Promise { + const genericExtensions = this.props.application.actionsManager .getExtensions() .sort((a, b) => { return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1; - }) - .map((extension) => { - return new SNActionsExtension( - CopyPayload(extension.payload, { - content: { - ...extension.payload.safeContent, - actions: [], - }, - }) - ); }); - const extensionsState: Record = {}; - extensions.map((extension) => { - extensionsState[extension.uuid] = { + + return genericExtensions.map((extension) => { + const menuSection: MenuSection = { + uuid: extension.uuid, + name: extension.name, + extension: extension, loading: true, - error: false, + hidden: this.appState.actionsMenu.hiddenSections[extension.uuid], }; - }); - - this.state = { - extensions, - extensionsState, - hiddenExtensions: {}, - menuItems: [], - actionState: {}, - }; - } - - componentDidMount() { - this.loadExtensions(); - this.autorun(() => { - this.rebuildMenuState({ - hiddenExtensions: this.appState.actionsMenu.hiddenExtensions, - }); + return menuSection; }); } - rebuildMenuState({ - extensions = this.state.extensions, - extensionsState = this.state.extensionsState, - selectedActionId = this.state.selectedActionId, - hiddenExtensions = this.appState.actionsMenu.hiddenExtensions, - } = {}) { - return this.setState({ - extensions, - extensionsState, - selectedActionId, - menuItems: extensions.map((extension) => { - const state = extensionsState[extension.uuid]; - const hidden = hiddenExtensions[extension.uuid]; - const item: MenuItem = { - uuid: extension.uuid, - name: extension.name, - loading: state?.loading ?? false, - error: state?.error ?? false, - hidden: hidden ?? false, - deprecation: extension.deprecation!, - actions: extension - .actionsWithContextForItem(this.props.item) - .map((action) => { - if (action.id === selectedActionId) { - return { - ...action, - subrows: this.subRowsForAction(action, extension), - }; - } else { - return action; - } - }), - }; - return item; - }), + private async getNonresolvedListedMenuSections(): Promise { + const listedAccountEntries = + await this.props.application.getListedAccounts(); + return listedAccountEntries.map((entry) => { + const menuSection: MenuSection = { + uuid: entry.authorId, + name: `Listed ${entry.authorId}`, + loading: true, + listedAccount: entry, + hidden: this.appState.actionsMenu.hiddenSections[entry.authorId], + }; + return menuSection; }); } - async loadExtensions() { - await Promise.all( - this.state.extensions.map(async (extension: SNActionsExtension) => { - this.setLoadingExtension(extension.uuid, true); - const updatedExtension = - await this.props.application.actionsManager.loadExtensionInContextOfItem( - extension, - this.props.item + private resolveMenuSection(menuSection: MenuSection): void { + if (menuSection.listedAccount) { + this.props.application + .getListedAccountInfo(menuSection.listedAccount, this.props.note.uuid) + .then((accountInfo) => { + if (!accountInfo) { + this.promoteMenuSection({ + ...menuSection, + loading: false, + }); + return; + } + const existingMenuSection = this.state.menuSections.find( + (item) => item.uuid === menuSection.listedAccount?.authorId + ) as MenuSection; + const resolvedMenuSection: MenuSection = { + ...existingMenuSection, + loading: false, + error: false, + name: accountInfo.display_name, + rows: accountInfo?.actions, + }; + this.promoteMenuSection(resolvedMenuSection); + }); + } else if (menuSection.extension) { + this.props.application.actionsManager + .loadExtensionInContextOfItem(menuSection.extension, this.props.note) + .then((resolvedExtension) => { + if (!resolvedExtension) { + this.promoteMenuSection({ + ...menuSection, + loading: false, + }); + return; + } + + const actions = resolvedExtension.actionsWithContextForItem( + this.props.note ); - if (updatedExtension) { - await this.updateExtension(updatedExtension!); - } else { - this.setErrorExtension(extension.uuid, true); - } - this.setLoadingExtension(extension.uuid, false); - }) - ); + + const resolvedMenuSection: MenuSection = { + ...menuSection, + rows: actions, + deprecation: resolvedExtension.deprecation, + loading: false, + error: false, + }; + this.promoteMenuSection(resolvedMenuSection); + }); + } } - executeAction = async (action: Action, extensionUuid: UuidString) => { - if (action.verb === 'nested') { - this.rebuildMenuState({ - selectedActionId: action.id, - }); - return; + private promoteMenuSection(newItem: MenuSection): void { + const menuSections = this.state.menuSections.map((menuSection) => { + if (menuSection.uuid === newItem.uuid) { + return newItem; + } else { + return menuSection; + } + }); + this.setState({ menuSections }); + } + + private promoteAction(newAction: Action, section: MenuSection): void { + const newSection: MenuSection = { + ...section, + rows: section.rows?.map((action) => { + if (action.url === newAction.url) { + return newAction; + } else { + return action; + } + }), + }; + this.promoteMenuSection(newSection); + } + + private idForAction(action: Action) { + return `${action.label}:${action.verb}:${action.desc}`; + } + + executeAction = async (action: Action, section: MenuSection) => { + const isLegacyNoteHistoryExt = action.verb === 'nested'; + if (isLegacyNoteHistoryExt) { + const showRevisionAction = action.subactions![0]; + action = showRevisionAction; } - const extension = this.props.application.findItem( - extensionUuid - ) as SNActionsExtension; - - this.updateActionState(action, { running: true, error: false }); + this.promoteAction( + { + ...action, + running: true, + }, + section + ); const response = await this.props.application.actionsManager.runAction( action, - this.props.item, - async () => { - /** @todo */ - return ''; - } + this.props.note ); - if (response.error) { - this.updateActionState(action, { error: true, running: false }); + + this.promoteAction( + { + ...action, + running: false, + }, + section + ); + + if (!response || response.error) { return; } - this.updateActionState(action, { running: false, error: false }); this.handleActionResponse(action, response); - await this.reloadExtension(extension); + this.resolveMenuSection(section); }; handleActionResponse(action: Action, result: ActionResponse) { @@ -216,113 +237,40 @@ export class ActionsMenu } } - private subRowsForAction( - parentAction: Action, - extension: Pick - ): ActionSubRow[] | undefined { - if (!parentAction.subactions) { - return undefined; - } - return parentAction.subactions.map((subaction) => { - return { - id: subaction.id, - onClick: () => { - this.executeAction(subaction, extension.uuid); - }, - label: subaction.label, - subtitle: subaction.desc, - spinnerClass: this.getActionState(subaction).running - ? 'info' - : undefined, - }; + public toggleSectionVisibility(menuSection: MenuSection) { + this.appState.actionsMenu.toggleSectionVisibility(menuSection.uuid); + this.promoteMenuSection({ + ...menuSection, + hidden: !menuSection.hidden, }); } - private updateActionState(action: Action, actionState: ActionState): void { - const state = this.state.actionState; - state[action.id] = actionState; - this.setState({ actionState: state }); - } - - private getActionState(action: Action): ActionState { - return this.state.actionState[action.id] || {}; - } - - private async updateExtension(extension: SNActionsExtension) { - const extensions = this.state.extensions.map((ext: SNActionsExtension) => { - if (extension.uuid === ext.uuid) { - return extension; - } - return ext; - }); - await this.rebuildMenuState({ - extensions, - }); - } - - private async reloadExtension(extension: SNActionsExtension) { - const extensionInContext = - await this.props.application.actionsManager.loadExtensionInContextOfItem( - extension, - this.props.item - ); - const extensions = this.state.extensions.map((ext: SNActionsExtension) => { - if (extension.uuid === ext.uuid) { - return extensionInContext!; - } - return ext; - }); - this.rebuildMenuState({ - extensions, - }); - } - - public toggleExtensionVisibility(extensionUuid: UuidString) { - this.appState.actionsMenu.toggleExtensionVisibility(extensionUuid); - } - - private setLoadingExtension(extensionUuid: UuidString, value = false) { - const { extensionsState } = this.state; - extensionsState[extensionUuid].loading = value; - this.rebuildMenuState({ - extensionsState, - }); - } - - private setErrorExtension(extensionUuid: UuidString, value = false) { - const { extensionsState } = this.state; - extensionsState[extensionUuid].error = value; - this.rebuildMenuState({ - extensionsState, - }); - } - - renderMenuItem(item: MenuItem) { + renderMenuSection(section: MenuSection) { return (
{ - this.toggleExtensionVisibility(item.uuid); + this.toggleSectionVisibility(section); $event.stopPropagation(); }} >
-
{item.name}
- {item.hidden &&
} - {item.deprecation && !item.hidden && ( +
{section.name}
+ {section.hidden &&
} + {section.deprecation && !section.hidden && (
- {item.deprecation} + {section.deprecation}
)}
- {item.loading &&
} + {section.loading &&
}
- {item.error && !item.hidden && ( + {section.error && !section.hidden && ( )} - {!item.actions.length && !item.hidden && ( + {!section.rows?.length && !section.hidden && ( )} - {!item.hidden && - !item.loading && - !item.error && - item.actions.map((action, index) => { + {!section.hidden && + !section.loading && + !section.error && + section.rows?.map((action, index) => { return ( { + this.executeAction(action, section); + }} label={action.label} - disabled={this.getActionState(action).running} - spinnerClass={ - this.getActionState(action).running ? 'info' : undefined - } - subRows={action.subrows} + disabled={action.running} + spinnerClass={action.running ? 'info' : undefined} subtitle={action.desc} > {action.access_type && ( @@ -370,18 +316,11 @@ export class ActionsMenu return (
- {this.state.extensions.length == 0 && ( - - - + {this.state.menuSections.length == 0 && ( + )} - {this.state.menuItems.map((extension) => - this.renderMenuItem(extension) + {this.state.menuSections.map((extension) => + this.renderMenuSection(extension) )}
diff --git a/app/assets/javascripts/components/HistoryMenu.tsx b/app/assets/javascripts/components/HistoryMenu.tsx index 821976677..252bce832 100644 --- a/app/assets/javascripts/components/HistoryMenu.tsx +++ b/app/assets/javascripts/components/HistoryMenu.tsx @@ -252,8 +252,7 @@ export class HistoryMenu extends PureComponent { return ( this.openSessionRevision(revision)} label={revision.previewTitle()} >
{ return ( this.openRemoteRevision(revision)} label={this.previewRemoteHistoryTitle(revision)} /> ); diff --git a/app/assets/javascripts/components/MenuRow.tsx b/app/assets/javascripts/components/MenuRow.tsx index a30a3c1d3..082102dae 100644 --- a/app/assets/javascripts/components/MenuRow.tsx +++ b/app/assets/javascripts/components/MenuRow.tsx @@ -1,8 +1,7 @@ import { Component } from 'preact'; -type RowProps = { - action?: (...args: any[]) => void; - actionArgs?: any[]; +export type MenuRowProps = { + action?: () => void; buttonAction?: () => void; buttonClass?: string; buttonText?: string; @@ -15,24 +14,21 @@ type RowProps = { label: string; spinnerClass?: string; stylekitClass?: string; - subRows?: RowProps[]; + subRows?: MenuRowProps[]; subtitle?: string; }; -type Props = RowProps; +type Props = MenuRowProps; export class MenuRow extends Component { onClick = ($event: Event) => { if (this.props.disabled || !this.props.action) { return; } + $event.stopPropagation(); - if (this.props.actionArgs) { - this.props.action(...this.props.actionArgs); - } else { - this.props.action(); - } + this.props.action(); }; clickAccessoryButton = ($event: Event) => { @@ -81,7 +77,6 @@ export class MenuRow extends Component { return ( {
Actions
{this.state.showActionsMenu && ( )} diff --git a/app/assets/javascripts/preferences/panes/Listed.tsx b/app/assets/javascripts/preferences/panes/Listed.tsx index 775219848..09dad1502 100644 --- a/app/assets/javascripts/preferences/panes/Listed.tsx +++ b/app/assets/javascripts/preferences/panes/Listed.tsx @@ -5,72 +5,74 @@ import { Title, Subtitle, Text, - LinkButton, } from '../components'; import { observer } from 'mobx-react-lite'; import { WebApplication } from '@/ui_models/application'; -import { ContentType, SNActionsExtension } from '@standardnotes/snjs'; -import { SNItem } from '@standardnotes/snjs/dist/@types/models/core/item'; +import { ButtonType, ListedAccount } from '@standardnotes/snjs'; import { useCallback, useEffect, useState } from 'preact/hooks'; -import { BlogItem } from './listed/BlogItem'; +import { ListedAccountItem } from './listed/BlogItem'; +import { Button } from '@/components/Button'; type Props = { application: WebApplication; }; export const Listed = observer(({ application }: Props) => { - const [items, setItems] = useState([]); - const [isDeleting, setIsDeleting] = useState(false); + const [accounts, setAccounts] = useState([]); + const [requestingAccount, setRequestingAccount] = useState(); - const reloadItems = useCallback(() => { - const components = application - .getItems(ContentType.ActionsExtension) - .filter((item) => - (item as SNActionsExtension).url.includes('listed') - ) as SNActionsExtension[]; - setItems(components); + const reloadAccounts = useCallback(async () => { + setAccounts(await application.getListedAccounts()); }, [application]); useEffect(() => { - reloadItems(); - }, [reloadItems]); + reloadAccounts(); + }, [reloadAccounts]); - const disconnectListedBlog = (item: SNItem) => { - return new Promise((resolve, reject) => { - setIsDeleting(true); - application - .deleteItem(item) - .then(() => { - reloadItems(); - setIsDeleting(false); - resolve(true); - }) - .catch((err) => { - application.alertService.alert(err); - setIsDeleting(false); - console.error(err); - reject(err); - }); - }); - }; + const registerNewAccount = useCallback(() => { + setRequestingAccount(true); + + const requestAccount = async () => { + const account = await application.requestNewListedAccount(); + if (account) { + const openSettings = await application.alertService.confirm( + `Your new Listed blog has been successfully created!` + + ` You can publish a new post to your blog from Standard Notes via the` + + ` Actions menu in the editor pane. Open your blog settings to begin setting it up.`, + undefined, + 'Open Settings', + ButtonType.Info, + 'Later' + ); + reloadAccounts(); + if (openSettings) { + const info = await application.getListedAccountInfo(account); + if (info) { + application.deviceInterface.openUrl(info?.settings_url); + } + } + } + setRequestingAccount(false); + }; + + requestAccount(); + }, [application, reloadAccounts]); return ( - {items.length > 0 && ( + {accounts.length > 0 && ( - Your {items.length === 1 ? 'Blog' : 'Blogs'} on Listed + Your {accounts.length === 1 ? 'Blog' : 'Blogs'} on Listed
- {items.map((item, index, array) => { + {accounts.map((item, index, array) => { return ( - ); @@ -95,21 +97,19 @@ export const Listed = observer(({ application }: Props) => { - {items.length === 0 ? ( - - How to get started? - - First, you’ll need to sign up for Listed. Once you have your - Listed account, follow the instructions to connect it with your - Standard Notes account. - - - - ) : null} + + Get Started + Create a free Listed author account to get started. +
{showSeparator && } diff --git a/app/assets/javascripts/ui_models/app_state/actions_menu_state.ts b/app/assets/javascripts/ui_models/app_state/actions_menu_state.ts index 8ba5dd61e..39b4f2afc 100644 --- a/app/assets/javascripts/ui_models/app_state/actions_menu_state.ts +++ b/app/assets/javascripts/ui_models/app_state/actions_menu_state.ts @@ -1,22 +1,22 @@ -import { UuidString } from "@standardnotes/snjs"; -import { action, makeObservable, observable } from "mobx"; +import { UuidString } from '@standardnotes/snjs'; +import { action, makeObservable, observable } from 'mobx'; export class ActionsMenuState { - hiddenExtensions: Record = {}; + hiddenSections: Record = {}; constructor() { makeObservable(this, { - hiddenExtensions: observable, - toggleExtensionVisibility: action, + hiddenSections: observable, + toggleSectionVisibility: action, reset: action, }); } - toggleExtensionVisibility = (uuid: UuidString): void => { - this.hiddenExtensions[uuid] = !this.hiddenExtensions[uuid]; - } + toggleSectionVisibility = (uuid: UuidString): void => { + this.hiddenSections[uuid] = !this.hiddenSections[uuid]; + }; reset = (): void => { - this.hiddenExtensions = {}; - } + this.hiddenSections = {}; + }; } diff --git a/package.json b/package.json index 41b1b274c..f8f59ab3e 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "@reach/tooltip": "^0.16.2", "@standardnotes/components": "1.4.4", "@standardnotes/features": "1.26.1", - "@standardnotes/snjs": "2.49.4", + "@standardnotes/snjs": "2.50.0", "@standardnotes/settings": "^1.11.2", "@standardnotes/sncrypto-web": "1.6.2", "mobx": "^6.3.5", diff --git a/yarn.lock b/yarn.lock index 73a18bf2a..0d25aaa95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2663,10 +2663,10 @@ buffer "^6.0.3" libsodium-wrappers "^0.7.9" -"@standardnotes/snjs@2.49.4": - version "2.49.4" - resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.49.4.tgz#ae443f0f3d8f72a4f7e65fe47e2030bcc6df9fab" - integrity sha512-RXyUNVvcT2TtGSYC32fDXgGUZdSiUvOFosLDWWGeY5kwOTnj1ZHrqmKaUlKCGPi1xiYANay42+EwLCLyyqOkzQ== +"@standardnotes/snjs@2.50.0": + version "2.50.0" + resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.50.0.tgz#709e72afa708d5eaba45bb42c045ad740a21d41d" + integrity sha512-bet4yQh5cEVIxjjz3aCO22qqHw+OuJauleusY945kYD/4Jpl1iy5dt9O7oMvWuyYwvRELV6YoehsaTuYZ4NAzg== dependencies: "@standardnotes/auth" "^3.15.3" "@standardnotes/common" "^1.8.0"