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
This commit is contained in:
Mo
2022-02-03 18:14:28 -06:00
committed by GitHub
parent 4200baadba
commit 6970a375b7
9 changed files with 294 additions and 415 deletions

View File

@@ -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<UuidString, ExtensionState>;
hiddenExtensions: Record<UuidString, boolean>;
selectedActionId?: number;
menuItems: MenuItem[];
actionState: Record<number, ActionState>;
type State = {
menuSections: MenuSection[];
selectedActionIdentifier?: string;
};
type Props = {
application: WebApplication;
item: SNNote;
note: SNNote;
};
export class ActionsMenu
extends PureComponent<Props, ActionsMenuState>
implements ActionsMenuScope
{
application!: WebApplication;
item!: SNItem;
export class ActionsMenu extends PureComponent<Props, State> {
constructor(props: Props) {
super(props, props.application);
const extensions = props.application.actionsManager
this.state = {
menuSections: [],
};
this.loadExtensions();
}
private async loadExtensions(): Promise<void> {
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<MenuSection[]> {
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<UuidString, ExtensionState> = {};
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<MenuSection[]> {
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<SNActionsExtension, 'uuid'>
): 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 (
<div>
<div
key={item.uuid}
key={section.uuid}
className="sk-menu-panel-header"
onClick={($event) => {
this.toggleExtensionVisibility(item.uuid);
this.toggleSectionVisibility(section);
$event.stopPropagation();
}}
>
<div className="sk-menu-panel-column">
<div className="sk-menu-panel-header-title">{item.name}</div>
{item.hidden && <div></div>}
{item.deprecation && !item.hidden && (
<div className="sk-menu-panel-header-title">{section.name}</div>
{section.hidden && <div></div>}
{section.deprecation && !section.hidden && (
<div className="sk-menu-panel-header-subtitle">
{item.deprecation}
{section.deprecation}
</div>
)}
</div>
{item.loading && <div className="sk-spinner small loading" />}
{section.loading && <div className="sk-spinner small loading" />}
</div>
<div>
{item.error && !item.hidden && (
{section.error && !section.hidden && (
<MenuRow
faded={true}
label="Error loading actions"
@@ -330,25 +278,23 @@ export class ActionsMenu
/>
)}
{!item.actions.length && !item.hidden && (
{!section.rows?.length && !section.hidden && (
<MenuRow faded={true} label="No Actions Available" />
)}
{!item.hidden &&
!item.loading &&
!item.error &&
item.actions.map((action, index) => {
{!section.hidden &&
!section.loading &&
!section.error &&
section.rows?.map((action, index) => {
return (
<MenuRow
key={index}
action={this.executeAction as never}
actionArgs={[action, item.uuid]}
action={() => {
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 (
<div className="sn-component">
<div className="sk-menu-panel dropdown-menu">
{this.state.extensions.length == 0 && (
<a
href="https://standardnotes.com/plans"
rel="noopener"
target="blank"
className="no-decoration"
>
<MenuRow label="Download Actions" />
</a>
{this.state.menuSections.length == 0 && (
<MenuRow label="No Actions" />
)}
{this.state.menuItems.map((extension) =>
this.renderMenuItem(extension)
{this.state.menuSections.map((extension) =>
this.renderMenuSection(extension)
)}
</div>
</div>

View File

@@ -252,8 +252,7 @@ export class HistoryMenu extends PureComponent<Props, HistoryState> {
return (
<MenuRow
key={index}
action={this.openSessionRevision}
actionArgs={[revision]}
action={() => this.openSessionRevision(revision)}
label={revision.previewTitle()}
>
<div
@@ -298,8 +297,7 @@ export class HistoryMenu extends PureComponent<Props, HistoryState> {
return (
<MenuRow
key={index}
action={this.openRemoteRevision}
actionArgs={[revision]}
action={() => this.openRemoteRevision(revision)}
label={this.previewRemoteHistoryTitle(revision)}
/>
);

View File

@@ -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<Props> {
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<Props> {
return (
<MenuRow
action={row.action}
actionArgs={row.actionArgs}
label={row.label}
spinnerClass={row.spinnerClass}
subtitle={row.subtitle}

View File

@@ -1112,7 +1112,7 @@ export class NoteView extends PureComponent<Props, State> {
<div className="sk-label">Actions</div>
{this.state.showActionsMenu && (
<ActionsMenu
item={this.note}
note={this.note}
application={this.application}
/>
)}

View File

@@ -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<SNActionsExtension[]>([]);
const [isDeleting, setIsDeleting] = useState(false);
const [accounts, setAccounts] = useState<ListedAccount[]>([]);
const [requestingAccount, setRequestingAccount] = useState<boolean>();
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` +
` <i>Actions</i> 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 (
<PreferencesPane>
{items.length > 0 && (
{accounts.length > 0 && (
<PreferencesGroup>
<PreferencesSegment>
<Title>
Your {items.length === 1 ? 'Blog' : 'Blogs'} on Listed
Your {accounts.length === 1 ? 'Blog' : 'Blogs'} on Listed
</Title>
<div className="h-2 w-full" />
{items.map((item, index, array) => {
{accounts.map((item, index, array) => {
return (
<BlogItem
item={item}
<ListedAccountItem
account={item}
showSeparator={index !== array.length - 1}
disabled={isDeleting}
disconnect={disconnectListedBlog}
key={item.uuid}
key={item.authorId}
application={application}
/>
);
@@ -95,21 +97,19 @@ export const Listed = observer(({ application }: Props) => {
</a>
</Text>
</PreferencesSegment>
{items.length === 0 ? (
<PreferencesSegment>
<Subtitle>How to get started?</Subtitle>
<Text>
First, youll need to sign up for Listed. Once you have your
Listed account, follow the instructions to connect it with your
Standard Notes account.
</Text>
<LinkButton
className="min-w-20 mt-3"
link="https://listed.to"
label="Get started"
/>
</PreferencesSegment>
) : null}
<PreferencesSegment>
<Subtitle>Get Started</Subtitle>
<Text>Create a free Listed author account to get started.</Text>
<Button
className="mt-3"
type="normal"
disabled={requestingAccount}
label={
requestingAccount ? 'Creating account...' : 'Create New Author'
}
onClick={registerNewAccount}
/>
</PreferencesSegment>
</PreferencesGroup>
</PreferencesPane>
);

View File

@@ -1,107 +1,54 @@
import { Button } from '@/components/Button';
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
import { LinkButton, Subtitle } from '@/preferences/components';
import { WebApplication } from '@/ui_models/application';
import {
Action,
ButtonType,
SNActionsExtension,
SNItem,
} from '@standardnotes/snjs';
import { ListedAccount, ListedAccountInfo } from '@standardnotes/snjs';
import { FunctionalComponent } from 'preact';
import { useEffect, useState } from 'preact/hooks';
type Props = {
item: SNActionsExtension;
account: ListedAccount;
showSeparator: boolean;
disabled: boolean;
disconnect: (item: SNItem) => Promise<unknown>;
application: WebApplication;
};
export const BlogItem: FunctionalComponent<Props> = ({
item,
export const ListedAccountItem: FunctionalComponent<Props> = ({
account,
showSeparator,
disabled,
disconnect,
application,
}) => {
const [actions, setActions] = useState<Action[] | undefined>([]);
const [isLoadingActions, setIsLoadingActions] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [accountInfo, setAccountInfo] = useState<ListedAccountInfo>();
useEffect(() => {
const loadActions = async () => {
setIsLoadingActions(true);
application.actionsManager
.loadExtensionInContextOfItem(item, item)
.then((extension) => {
setActions(extension?.actions);
})
.catch((err) => application.alertService.alert(err))
.finally(() => {
setIsLoadingActions(false);
});
const loadAccount = async () => {
setIsLoading(true);
const info = await application.getListedAccountInfo(account);
setAccountInfo(info);
setIsLoading(false);
};
if (!actions || actions.length === 0) loadActions();
}, [application.actionsManager, application.alertService, item, actions]);
const handleDisconnect = () => {
setIsDisconnecting(true);
application.alertService
.confirm(
'Disconnecting will result in loss of access to your blog. Ensure your Listed author key is backed up before uninstalling.',
`Disconnect blog "${item?.name}"?`,
'Disconnect',
ButtonType.Danger
)
.then(async (shouldDisconnect) => {
if (shouldDisconnect) {
await disconnect(item as SNItem);
}
})
.catch((err) => {
console.error(err);
application.alertService.alert(err);
})
.finally(() => {
setIsDisconnecting(false);
});
};
loadAccount();
}, [account, application]);
return (
<>
<Subtitle>{item?.name}</Subtitle>
<Subtitle className="em">{accountInfo?.display_name}</Subtitle>
<div className="mb-2" />
<div className="flex">
{isLoadingActions ? (
<div className="sk-spinner small info"></div>
) : null}
{actions && actions?.length > 0 ? (
{isLoading ? <div className="sk-spinner small info"></div> : null}
{accountInfo && (
<>
<LinkButton
className="mr-2"
label="Open Blog"
link={
actions?.find((action: Action) => action.label === 'Open Blog')
?.url || ''
}
link={accountInfo.author_url}
/>
<LinkButton
className="mr-2"
label="Settings"
link={
actions?.find((action: Action) => action.label === 'Settings')
?.url || ''
}
/>
<Button
type="danger"
label={isDisconnecting ? 'Disconnecting...' : 'Disconnect'}
disabled={disabled}
onClick={handleDisconnect}
link={accountInfo.settings_url}
/>
</>
) : null}
)}
</div>
{showSeparator && <HorizontalSeparator classes="mt-5 mb-3" />}
</>

View File

@@ -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<UuidString, boolean> = {};
hiddenSections: Record<UuidString, boolean> = {};
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 = {};
};
}

View File

@@ -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",

View File

@@ -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"