Merge branch 'release/10.4.0'

This commit is contained in:
Mo
2021-12-27 12:04:59 -06:00
103 changed files with 3989 additions and 3923 deletions

View File

@@ -11,7 +11,7 @@
"parserOptions": {
"project": "./app/assets/javascripts/tsconfig.json"
},
"ignorePatterns": [".eslintrc.js", "webpack.*.js", "webpack-defaults.js", "jest.config.js"],
"ignorePatterns": [".eslintrc.js", "webpack.*.js", "webpack-defaults.js", "jest.config.js", "__mocks__"],
"rules": {
"standard/no-callback-literal": 0, // Disable this as we have too many callbacks relying on literals
"no-throw-literal": 0,
@@ -19,8 +19,8 @@
"semi": 1,
"camelcase": "warn",
"sort-imports": "off",
"react-hooks/rules-of-hooks": "error", // Checks rules of Hooks
"react-hooks/exhaustive-deps": "error", // Checks effect dependencies
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
"eol-last": "error",
"no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }],
"no-trailing-spaces": "error"

73
.github/workflows/beta.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: Beta
on:
push:
branches: [ beta/* ]
workflow_dispatch:
jobs:
tsc:
name: Check types & lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Install dependencies
run: yarn install --pure-lockfile
- name: Typescript
run: yarn tsc
- name: ESLint
run: yarn lint --quiet
deploy:
runs-on: ubuntu-latest
needs: tsc
steps:
- uses: actions/checkout@v2
- name: Copy robots.txt
run: cp public/robots.txt.development public/robots.txt
- name: Publish to Registry
uses: elgohr/Publish-Docker-Github-Action@master
with:
name: standardnotes/web
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
tags: "beta,${{ github.sha }}"
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Download task definition
run: |
aws ecs describe-task-definition --task-definition app-beta-dev --query taskDefinition > task-definition.json
- name: Fill in the new image ID in the Amazon ECS task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: app-beta-dev
image: "standardnotes/web:${{ github.sha }}"
- name: Deploy Amazon ECS task definition
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: app-beta-dev
cluster: dev
wait-for-service-stability: true
notify_discord:
needs: deploy
runs-on: ubuntu-latest
steps:
- name: Run Discord Webhook
uses: johnnyhuy/actions-discord-git-webhook@main
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}

View File

@@ -73,15 +73,13 @@ jobs:
cluster: dev
wait-for-service-stability: true
notify_slack:
notify_discord:
needs: deploy
runs-on: ubuntu-latest
steps:
- name: Notify slack
uses: pullreminders/slack-action@master
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
- name: Run Discord Webhook
uses: johnnyhuy/actions-discord-git-webhook@main
with:
args: '{ \"channel\": \"${{ secrets.SLACK_NOTIFICATION_CHANNEL }}\", \"blocks\": [{\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"Successfully deployed <https://app-dev.standardnotes.com|[DEV] Web App>\"}}, {\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"Changes: <https://github.com/standardnotes/web/commit/${{ github.sha }}|${{ github.sha }}>\"}, \"accessory\": {\"type\": \"image\", \"image_url\": \"https://website-dev.standardnotes.com/assets/icon.png\", \"alt_text\": \"Standard Notes\"}}, { \"type\": \"section\", \"fields\": [{\"type\": \"mrkdwn\", \"text\": \"<https://github.com/standardnotes/web/actions/runs/${{ github.run_id }}|Build details>\"}]}]}'
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}

View File

@@ -73,15 +73,13 @@ jobs:
cluster: prod
wait-for-service-stability: true
notify_slack:
notify_discord:
needs: deploy
runs-on: ubuntu-latest
steps:
- name: Notify slack
uses: pullreminders/slack-action@master
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
- name: Run Discord Webhook
uses: johnnyhuy/actions-discord-git-webhook@main
with:
args: '{ \"channel\": \"${{ secrets.SLACK_NOTIFICATION_CHANNEL }}\", \"blocks\": [{\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"Successfully deployed <https://app-prod.standardnotes.com|[PROD] Web App>\"}}, {\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"Changes: <https://github.com/standardnotes/web/commit/${{ github.sha }}|${{ github.sha }}>\"}, \"accessory\": {\"type\": \"image\", \"image_url\": \"https://website-dev.standardnotes.com/assets/icon.png\", \"alt_text\": \"Standard Notes\"}}, { \"type\": \"section\", \"fields\": [{\"type\": \"mrkdwn\", \"text\": \"<https://github.com/standardnotes/web/actions/runs/${{ github.run_id }}|Build details>\"}]}]}'
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}

2
.gitignore vendored
View File

@@ -50,3 +50,5 @@ yarn-error.log
package-lock.json
codeqldb
coverage

View File

@@ -101,7 +101,7 @@ GEM
net-ssh (>= 2.6.5, < 6.0.0)
net-ssh (5.2.0)
newrelic_rpm (7.0.0)
nio4r (2.5.2)
nio4r (2.5.8)
nokogiri (1.11.1)
mini_portile2 (~> 2.5.0)
racc (~> 1.4)
@@ -111,7 +111,7 @@ GEM
racc (~> 1.4)
non-stupid-digest-assets (1.0.9)
sprockets (>= 2.0)
puma (4.3.5)
puma (4.3.9)
nio4r (~> 2.0)
racc (1.5.2)
rack (2.2.3)

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5999 8.00002C13.5999 8.44185 13.2417 8.80002 12.7999 8.80002H8.7999V12.8C8.7999 13.2419 8.44173 13.6 7.9999 13.6C7.55807 13.6 7.1999 13.2419 7.1999 12.8V8.80002H3.1999C2.75807 8.80002 2.3999 8.44185 2.3999 8.00002C2.3999 7.5582 2.75807 7.20002 3.1999 7.20002H7.1999V3.20002C7.1999 2.7582 7.55807 2.40002 7.9999 2.40002C8.44173 2.40002 8.7999 2.7582 8.7999 3.20002V7.20002H12.7999C13.2417 7.20002 13.5999 7.5582 13.5999 8.00002Z" />
</svg>

After

Width:  |  Height:  |  Size: 548 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6668 14.9999H3.33342V6.66658H16.6668V14.9999ZM16.6668 4.99992H10.0001L8.33342 3.33325H3.33342C2.40841 3.33325 1.66675 4.07492 1.66675 4.99992V14.9999C1.66675 15.4419 1.84234 15.8659 2.1549 16.1784C2.46746 16.491 2.89139 16.6666 3.33342 16.6666H16.6668C17.1088 16.6666 17.5327 16.491 17.8453 16.1784C18.1578 15.8659 18.3334 15.4419 18.3334 14.9999V6.66658C18.3334 5.74159 17.5834 4.99992 16.6668 4.99992Z"/>
</svg>

After

Width:  |  Height:  |  Size: 500 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.1667 5.83333H10.8334V7.41667H14.1667C15.5917 7.41667 16.7501 8.575 16.7501 10C16.7501 11.1917 15.9334 12.1917 14.8251 12.5L16.0417 13.7C17.4001 13.0083 18.3334 11.625 18.3334 10C18.3334 8.89493 17.8944 7.83512 17.113 7.05372C16.3316 6.27232 15.2718 5.83333 14.1667 5.83333ZM13.3334 9.16667H11.5084L13.1751 10.8333H13.3334V9.16667ZM1.66675 3.55833L4.25841 6.15C2.74175 6.76667 1.66675 8.25833 1.66675 10C1.66675 11.1051 2.10573 12.1649 2.88714 12.9463C3.66854 13.7277 4.72835 14.1667 5.83342 14.1667H9.16675V12.5833H5.83342C4.40841 12.5833 3.25008 11.425 3.25008 10C3.25008 8.675 4.25841 7.58333 5.55008 7.44167L7.27508 9.16667H6.66675V10.8333H8.94175L10.8334 12.725V14.1667H12.2751L15.6167 17.5L16.6667 16.45L2.72508 2.5L1.66675 3.55833Z"/>
</svg>

After

Width:  |  Height:  |  Size: 834 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.83325 4.16667H17.4999V5.83333H5.83325V4.16667ZM5.83325 10.8333V9.16667H17.4999V10.8333H5.83325ZM3.33325 3.75C3.66477 3.75 3.98272 3.8817 4.21714 4.11612C4.45156 4.35054 4.58325 4.66848 4.58325 5C4.58325 5.33152 4.45156 5.64946 4.21714 5.88388C3.98272 6.1183 3.66477 6.25 3.33325 6.25C3.00173 6.25 2.68379 6.1183 2.44937 5.88388C2.21495 5.64946 2.08325 5.33152 2.08325 5C2.08325 4.66848 2.21495 4.35054 2.44937 4.11612C2.68379 3.8817 3.00173 3.75 3.33325 3.75ZM3.33325 8.75C3.66477 8.75 3.98272 8.8817 4.21714 9.11612C4.45156 9.35054 4.58325 9.66848 4.58325 10C4.58325 10.3315 4.45156 10.6495 4.21714 10.8839C3.98272 11.1183 3.66477 11.25 3.33325 11.25C3.00173 11.25 2.68379 11.1183 2.44937 10.8839C2.21495 10.6495 2.08325 10.3315 2.08325 10C2.08325 9.66848 2.21495 9.35054 2.44937 9.11612C2.68379 8.8817 3.00173 8.75 3.33325 8.75ZM5.83325 15.8333V14.1667H17.4999V15.8333H5.83325ZM3.33325 13.75C3.66477 13.75 3.98272 13.8817 4.21714 14.1161C4.45156 14.3505 4.58325 14.6685 4.58325 15C4.58325 15.3315 4.45156 15.6495 4.21714 15.8839C3.98272 16.1183 3.66477 16.25 3.33325 16.25C3.00173 16.25 2.68379 16.1183 2.44937 15.8839C2.21495 15.6495 2.08325 15.3315 2.08325 15C2.08325 14.6685 2.21495 14.3505 2.44937 14.1161C2.68379 13.8817 3.00173 13.75 3.33325 13.75Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.83325 8.33337L9.99992 12.5L14.1666 8.33337H5.83325Z"/>
</svg>

After

Width:  |  Height:  |  Size: 147 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.91675 14.5834L12.0834 10.4167L7.91675 6.25004L7.91675 14.5834Z"/>
</svg>

After

Width:  |  Height:  |  Size: 158 B

View File

@@ -0,0 +1,11 @@
const {
ApplicationEvent,
ProtectionSessionDurations,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
} = require('@standardnotes/snjs');
module.exports = {
ApplicationEvent: ApplicationEvent,
ProtectionSessionDurations: ProtectionSessionDurations,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
};

View File

@@ -10,6 +10,13 @@ declare global {
_plans_url?: string;
// eslint-disable-next-line camelcase
_dashboard_url?: string;
// eslint-disable-next-line camelcase
_default_sync_server: string;
// eslint-disable-next-line camelcase
_enable_unfinished_features: boolean;
// eslint-disable-next-line camelcase
_websocket_url: string;
startApplication?: StartApplication;
}
}
@@ -26,7 +33,6 @@ import {
EditorGroupView,
EditorView,
TagsView,
NotesView,
FooterView,
ChallengeModal,
} from '@/views';
@@ -45,7 +51,6 @@ import {
import {
ActionsMenu,
ComponentModal,
EditorMenu,
InputModal,
MenuRow,
@@ -65,7 +70,7 @@ import { StartApplication } from './startApplication';
import { Bridge } from './services/bridge';
import { SessionsModalDirective } from './components/SessionsModal';
import { NoAccountWarningDirective } from './components/NoAccountWarning';
import { NoProtectionsdNoteWarningDirective } from './components/NoProtectionsNoteWarning';
import { ProtectedNoteOverlayDirective } from './components/ProtectedNoteOverlay';
import { SearchOptionsDirective } from './components/SearchOptions';
import { AccountMenuDirective } from './components/AccountMenu';
import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal';
@@ -81,7 +86,9 @@ import { PurchaseFlowDirective } from './purchaseFlow';
import { QuickSettingsMenuDirective } from './components/QuickSettingsMenu/QuickSettingsMenu';
import { ComponentViewDirective } from '@/components/ComponentView';
import { TagsListDirective } from '@/components/TagsList';
import { NotesViewDirective } from './components/NotesView';
import { PinNoteButtonDirective } from '@/components/PinNoteButton';
import { TagsSectionDirective } from './components/Tags/TagsSection';
function reloadHiddenFirefoxTab(): boolean {
/**
@@ -117,7 +124,7 @@ const startApplication: StartApplication = async function startApplication(
SNLog.onLog = console.log;
startErrorReporting();
angular.module('app', ['ngSanitize']);
angular.module('app', []);
// Config
angular
@@ -137,7 +144,6 @@ const startApplication: StartApplication = async function startApplication(
.directive('editorGroupView', () => new EditorGroupView())
.directive('editorView', () => new EditorView())
.directive('tagsView', () => new TagsView())
.directive('notesView', () => new NotesView())
.directive('footerView', () => new FooterView());
// Directives - Functional
@@ -159,7 +165,6 @@ const startApplication: StartApplication = async function startApplication(
.directive('accountSwitcher', () => new AccountSwitcher())
.directive('actionsMenu', () => new ActionsMenu())
.directive('challengeModal', () => new ChallengeModal())
.directive('componentModal', () => new ComponentModal())
.directive('componentView', ComponentViewDirective)
.directive('editorMenu', () => new EditorMenu())
.directive('inputModal', () => new InputModal())
@@ -174,7 +179,7 @@ const startApplication: StartApplication = async function startApplication(
.directive('accountMenu', AccountMenuDirective)
.directive('quickSettingsMenu', QuickSettingsMenuDirective)
.directive('noAccountWarning', NoAccountWarningDirective)
.directive('protectedNotePanel', NoProtectionsdNoteWarningDirective)
.directive('protectedNotePanel', ProtectedNoteOverlayDirective)
.directive('searchOptions', SearchOptionsDirective)
.directive('confirmSignout', ConfirmSignoutDirective)
.directive('multipleSelectedNotesPanel', MultipleSelectedNotesDirective)
@@ -183,9 +188,11 @@ const startApplication: StartApplication = async function startApplication(
.directive('notesListOptionsMenu', NotesListOptionsDirective)
.directive('icon', IconDirective)
.directive('noteTagsContainer', NoteTagsContainerDirective)
.directive('tags', TagsListDirective)
.directive('tagsList', TagsListDirective)
.directive('tagsSection', TagsSectionDirective)
.directive('preferences', PreferencesDirective)
.directive('purchaseFlow', PurchaseFlowDirective)
.directive('notesView', NotesViewDirective)
.directive('pinNoteButton', PinNoteButtonDirective);
// Filters
@@ -216,11 +223,11 @@ const startApplication: StartApplication = async function startApplication(
if (IsWebPlatform) {
startApplication(
(window as any)._default_sync_server as string,
window._default_sync_server,
new BrowserBridge(AppVersion),
(window as any)._enable_unfinished_features as boolean,
(window as any)._websocket_url as string
window._enable_unfinished_features,
window._websocket_url
);
} else {
(window as any).startApplication = startApplication;
window.startApplication = startApplication;
}

View File

@@ -1,180 +0,0 @@
import { isDesktopApplication } from '@/utils';
import { alertDialog } from '@Services/alertService';
import {
STRING_IMPORT_SUCCESS,
STRING_INVALID_IMPORT_FILE,
STRING_UNSUPPORTED_BACKUP_FILE_VERSION,
StringImportError
} from '@/strings';
import { BackupFile } from '@standardnotes/snjs';
import { useRef, useState } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application';
import { JSXInternal } from 'preact/src/jsx';
import TargetedEvent = JSXInternal.TargetedEvent;
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
type Props = {
application: WebApplication;
appState: AppState;
}
const DataBackup = observer(({
application,
appState
}: Props) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isImportDataLoading, setIsImportDataLoading] = useState(false);
const { isBackupEncrypted, isEncryptionEnabled, setIsBackupEncrypted } = appState.accountMenu;
const downloadDataArchive = () => {
application.getArchiveService().downloadBackup(isBackupEncrypted);
};
const readFile = async (file: File): Promise<any> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target!.result as string);
resolve(data);
} catch (e) {
application.alertService.alert(STRING_INVALID_IMPORT_FILE);
}
};
reader.readAsText(file);
});
};
const performImport = async (data: BackupFile) => {
setIsImportDataLoading(true);
const result = await application.importData(data);
setIsImportDataLoading(false);
if (!result) {
return;
}
let statusText = STRING_IMPORT_SUCCESS;
if ('error' in result) {
statusText = result.error;
} else if (result.errorCount) {
statusText = StringImportError(result.errorCount);
}
void alertDialog({
text: statusText
});
};
const importFileSelected = async (event: TargetedEvent<HTMLInputElement, Event>) => {
const { files } = (event.target as HTMLInputElement);
if (!files) {
return;
}
const file = files[0];
const data = await readFile(file);
if (!data) {
return;
}
const version = data.version || data.keyParams?.version || data.auth_params?.version;
if (!version) {
await performImport(data);
return;
}
if (
application.protocolService.supportedVersions().includes(version)
) {
await performImport(data);
} else {
setIsImportDataLoading(false);
void alertDialog({ text: STRING_UNSUPPORTED_BACKUP_FILE_VERSION });
}
};
// Whenever "Import Backup" is either clicked or key-pressed, proceed the import
const handleImportFile = (event: TargetedEvent<HTMLSpanElement, Event> | KeyboardEvent) => {
if (event instanceof KeyboardEvent) {
const { code } = event;
// Process only when "Enter" or "Space" keys are pressed
if (code !== 'Enter' && code !== 'Space') {
return;
}
// Don't proceed the event's default action
// (like scrolling in case the "space" key is pressed)
event.preventDefault();
}
(fileInputRef.current as HTMLInputElement).click();
};
return (
<>
{isImportDataLoading ? (
<div className="sk-spinner small info" />
) : (
<div className="sk-panel-section">
<div className="sk-panel-section-title">Data Backups</div>
<div className="sk-p">Download a backup of all your data.</div>
{isEncryptionEnabled && (
<form className="sk-panel-form sk-panel-row">
<div className="sk-input-group">
<label className="sk-horizontal-group tight">
<input
type="radio"
onChange={() => setIsBackupEncrypted(true)}
checked={isBackupEncrypted}
/>
<p className="sk-p">Encrypted</p>
</label>
<label className="sk-horizontal-group tight">
<input
type="radio"
onChange={() => setIsBackupEncrypted(false)}
checked={!isBackupEncrypted}
/>
<p className="sk-p">Decrypted</p>
</label>
</div>
</form>
)}
<div className="sk-panel-row" />
<div className="flex">
<button className="sn-button small info" onClick={downloadDataArchive}>Download Backup</button>
<button
type="button"
className="sn-button small flex items-center info ml-2"
tabIndex={0}
onClick={handleImportFile}
onKeyDown={handleImportFile}
>
<input
type="file"
ref={fileInputRef}
onChange={importFileSelected}
className="hidden"
/>
Import Backup
</button>
</div>
{isDesktopApplication() && (
<p className="mt-5">
Backups are automatically created on desktop and can be managed
via the "Backups" top-level menu.
</p>
)}
<div className="sk-panel-row" />
</div>
)}
</>
);
});
export default DataBackup;

View File

@@ -1,33 +0,0 @@
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
type Props = {
appState: AppState;
}
const Encryption = observer(({ appState }: Props) => {
const { isEncryptionEnabled, encryptionStatusString, notesAndTagsCount } = appState.accountMenu;
const getEncryptionStatusForNotes = () => {
const length = notesAndTagsCount;
return `${length}/${length} notes and tags encrypted`;
};
return (
<div className="sk-panel-section">
<div className="sk-panel-section-title">
Encryption
</div>
{isEncryptionEnabled && (
<div className="sk-panel-section-subtitle info">
{getEncryptionStatusForNotes()}
</div>
)}
<p className="sk-p">
{encryptionStatusString}
</p>
</div>
);
});
export default Encryption;

View File

@@ -1,80 +0,0 @@
import { useState } from 'preact/hooks';
import { storage, StorageKey } from '@Services/localStorage';
import { disableErrorReporting, enableErrorReporting, errorReportingId } from '@Services/errorReporting';
import { alertDialog } from '@Services/alertService';
import { observer } from 'mobx-react-lite';
import { AppState } from '@/ui_models/app_state';
type Props = {
appState: AppState;
}
const ErrorReporting = observer(({ appState }: Props) => {
const [isErrorReportingEnabled] = useState(() => storage.get(StorageKey.DisableErrorReporting) === false);
const [errorReportingIdValue] = useState(() => errorReportingId());
const toggleErrorReportingEnabled = () => {
if (isErrorReportingEnabled) {
disableErrorReporting();
} else {
enableErrorReporting();
}
if (!appState.sync.inProgress) {
window.location.reload();
}
};
const openErrorReportingDialog = () => {
alertDialog({
title: 'Data sent during automatic error reporting',
text: `
We use <a target="_blank" rel="noreferrer" href="https://www.bugsnag.com/">Bugsnag</a>
to automatically report errors that occur while the app is running. See
<a target="_blank" rel="noreferrer" href="https://docs.bugsnag.com/platforms/javascript/#sending-diagnostic-data">
this article, paragraph 'Browser' under 'Sending diagnostic data',
</a>
to see what data is included in error reports.
<br><br>
Error reports never include IP addresses and are fully
anonymized. We use error reports to be alerted when something in our
code is causing unexpected errors and crashes in your application
experience.
`
});
};
return (
<div className="sk-panel-section">
<div className="sk-panel-section-title">Error Reporting</div>
<div className="sk-panel-section-subtitle info">
Automatic error reporting is {isErrorReportingEnabled ? 'enabled' : 'disabled'}
</div>
<p className="sk-p">
Help us improve Standard Notes by automatically submitting
anonymized error reports.
</p>
{errorReportingIdValue && (
<>
<p className="sk-p selectable">
Your random identifier is <span className="font-bold">{errorReportingIdValue}</span>
</p>
<p className="sk-p">
Disabling error reporting will remove that identifier from your
local storage, and a new identifier will be created should you
decide to enable error reporting again in the future.
</p>
</>
)}
<div className="sk-panel-row">
<button className="sn-button small info" onClick={toggleErrorReportingEnabled}>
{isErrorReportingEnabled ? 'Disable' : 'Enable'} Error Reporting
</button>
</div>
<div className="sk-panel-row">
<a className="sk-a" onClick={openErrorReportingDialog}>What data is being sent?</a>
</div>
</div>
);
});
export default ErrorReporting;

View File

@@ -1,272 +0,0 @@
import {
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_CHANGE,
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_REMOVAL, STRING_E2E_ENABLED, STRING_ENC_NOT_ENABLED, STRING_LOCAL_ENC_ENABLED,
STRING_NON_MATCHING_PASSCODES,
StringUtils,
Strings
} from '@/strings';
import { WebApplication } from '@/ui_models/application';
import { preventRefreshing } from '@/utils';
import { JSXInternal } from 'preact/src/jsx';
import TargetedEvent = JSXInternal.TargetedEvent;
import { alertDialog } from '@Services/alertService';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { ApplicationEvent } from '@standardnotes/snjs';
import TargetedMouseEvent = JSXInternal.TargetedMouseEvent;
import { observer } from 'mobx-react-lite';
import { AppState } from '@/ui_models/app_state';
type Props = {
application: WebApplication;
appState: AppState;
};
const PasscodeLock = observer(({
application,
appState,
}: Props) => {
const keyStorageInfo = StringUtils.keyStorageInfo(application);
const passcodeAutoLockOptions = application.getAutolockService().getAutoLockIntervalOptions();
const { setIsEncryptionEnabled, setIsBackupEncrypted, setEncryptionStatusString } = appState.accountMenu;
const passcodeInputRef = useRef<HTMLInputElement>(null);
const [passcode, setPasscode] = useState<string | undefined>(undefined);
const [passcodeConfirmation, setPasscodeConfirmation] = useState<string | undefined>(undefined);
const [selectedAutoLockInterval, setSelectedAutoLockInterval] = useState<unknown>(null);
const [isPasscodeFocused, setIsPasscodeFocused] = useState(false);
const [showPasscodeForm, setShowPasscodeForm] = useState(false);
const [canAddPasscode, setCanAddPasscode] = useState(!application.isEphemeralSession());
const [hasPasscode, setHasPasscode] = useState(application.hasPasscode());
const handleAddPassCode = () => {
setShowPasscodeForm(true);
setIsPasscodeFocused(true);
};
const changePasscodePressed = () => {
handleAddPassCode();
};
const reloadAutoLockInterval = useCallback(async () => {
const interval = await application.getAutolockService().getAutoLockInterval();
setSelectedAutoLockInterval(interval);
}, [application]);
const refreshEncryptionStatus = useCallback(() => {
const hasUser = application.hasAccount();
const hasPasscode = application.hasPasscode();
setHasPasscode(hasPasscode);
const encryptionEnabled = hasUser || hasPasscode;
const encryptionStatusString = hasUser
? STRING_E2E_ENABLED
: hasPasscode
? STRING_LOCAL_ENC_ENABLED
: STRING_ENC_NOT_ENABLED;
setEncryptionStatusString(encryptionStatusString);
setIsEncryptionEnabled(encryptionEnabled);
setIsBackupEncrypted(encryptionEnabled);
}, [application, setEncryptionStatusString, setIsBackupEncrypted, setIsEncryptionEnabled]);
const selectAutoLockInterval = async (interval: number) => {
if (!(await application.authorizeAutolockIntervalChange())) {
return;
}
await application.getAutolockService().setAutoLockInterval(interval);
reloadAutoLockInterval();
};
const removePasscodePressed = async () => {
await preventRefreshing(
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_REMOVAL,
async () => {
if (await application.removePasscode()) {
await application
.getAutolockService()
.deleteAutolockPreference();
await reloadAutoLockInterval();
refreshEncryptionStatus();
}
}
);
};
const handlePasscodeChange = (event: TargetedEvent<HTMLInputElement>) => {
const { value } = event.target as HTMLInputElement;
setPasscode(value);
};
const handleConfirmPasscodeChange = (event: TargetedEvent<HTMLInputElement>) => {
const { value } = event.target as HTMLInputElement;
setPasscodeConfirmation(value);
};
const submitPasscodeForm = async (event: TargetedEvent<HTMLFormElement> | TargetedMouseEvent<HTMLButtonElement>) => {
event.preventDefault();
if (!passcode || passcode.length === 0) {
await alertDialog({
text: Strings.enterPasscode,
});
}
if (passcode !== passcodeConfirmation) {
await alertDialog({
text: STRING_NON_MATCHING_PASSCODES
});
setIsPasscodeFocused(true);
return;
}
await preventRefreshing(
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_CHANGE,
async () => {
const successful = application.hasPasscode()
? await application.changePasscode(passcode as string)
: await application.addPasscode(passcode as string);
if (!successful) {
setIsPasscodeFocused(true);
}
}
);
setPasscode(undefined);
setPasscodeConfirmation(undefined);
setShowPasscodeForm(false);
refreshEncryptionStatus();
};
useEffect(() => {
refreshEncryptionStatus();
}, [refreshEncryptionStatus]);
// `reloadAutoLockInterval` gets interval asynchronously, therefore we call `useEffect` to set initial
// value of `selectedAutoLockInterval`
useEffect(() => {
reloadAutoLockInterval();
}, [reloadAutoLockInterval]);
useEffect(() => {
if (isPasscodeFocused) {
passcodeInputRef.current!.focus();
setIsPasscodeFocused(false);
}
}, [isPasscodeFocused]);
// Add the required event observers
useEffect(() => {
const removeKeyStatusChangedObserver = application.addEventObserver(
async () => {
setCanAddPasscode(!application.isEphemeralSession());
setHasPasscode(application.hasPasscode());
setShowPasscodeForm(false);
},
ApplicationEvent.KeyStatusChanged
);
return () => {
removeKeyStatusChangedObserver();
};
}, [application]);
return (
<div className="sk-panel-section">
<div className="sk-panel-section-title">Passcode Lock</div>
{!hasPasscode && (
<div>
{canAddPasscode && (
<>
{!showPasscodeForm && (
<div className="sk-panel-row">
<button className="sn-button small info" onClick={handleAddPassCode}>
Add Passcode
</button>
</div>
)}
<p className="sk-p">
Add a passcode to lock the application and
encrypt on-device key storage.
</p>
{keyStorageInfo && (
<p>{keyStorageInfo}</p>
)}
</>
)}
{!canAddPasscode && (
<p className="sk-p">
Adding a passcode is not supported in temporary sessions. Please sign
out, then sign back in with the "Stay signed in" option checked.
</p>
)}
</div>
)}
{showPasscodeForm && (
<form className="sk-panel-form" onSubmit={submitPasscodeForm}>
<div className="sk-panel-row" />
<input
className="sk-input contrast"
type="password"
ref={passcodeInputRef}
value={passcode}
onChange={handlePasscodeChange}
placeholder="Passcode"
/>
<input
className="sk-input contrast"
type="password"
value={passcodeConfirmation}
onChange={handleConfirmPasscodeChange}
placeholder="Confirm Passcode"
/>
<button className="sn-button small info mt-2" onClick={submitPasscodeForm}>
Set Passcode
</button>
<button className="sn-button small outlined ml-2" onClick={() => setShowPasscodeForm(false)}>
Cancel
</button>
</form>
)}
{hasPasscode && !showPasscodeForm && (
<>
<div className="sk-panel-section-subtitle info">Passcode lock is enabled</div>
<div className="sk-notification contrast">
<div className="sk-notification-title">Options</div>
<div className="sk-notification-text">
<div className="sk-panel-row">
<div className="sk-horizontal-group">
<div className="sk-h4 sk-bold">Autolock</div>
{passcodeAutoLockOptions.map(option => {
return (
<a
className={`sk-a info ${option.value === selectedAutoLockInterval ? 'boxed' : ''}`}
onClick={() => selectAutoLockInterval(option.value)}>
{option.label}
</a>
);
})}
</div>
</div>
<div className="sk-p">The autolock timer begins when the window or tab loses focus.</div>
<div className="sk-panel-row" />
<a className="sk-a info sk-panel-row condensed" onClick={changePasscodePressed}>
Change Passcode
</a>
<a className="sk-a danger sk-panel-row condensed" onClick={removePasscodePressed}>
Remove Passcode
</a>
</div>
</div>
</>
)}
</div>
);
});
export default PasscodeLock;

View File

@@ -1,100 +0,0 @@
import { WebApplication } from '@/ui_models/application';
import { FunctionalComponent } from 'preact';
import { useCallback, useState } from 'preact/hooks';
import { useEffect } from 'preact/hooks';
import { ApplicationEvent } from '@standardnotes/snjs';
import { isSameDay } from '@/utils';
type Props = {
application: WebApplication;
};
const Protections: FunctionalComponent<Props> = ({ application }) => {
const enableProtections = () => {
application.clearProtectionSession();
};
const [hasProtections, setHasProtections] = useState(() => application.hasProtectionSources());
const getProtectionsDisabledUntil = useCallback((): string | null => {
const protectionExpiry = application.getProtectionSessionExpiryDate();
const now = new Date();
if (protectionExpiry > now) {
let f: Intl.DateTimeFormat;
if (isSameDay(protectionExpiry, now)) {
f = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric'
});
} else {
f = new Intl.DateTimeFormat(undefined, {
weekday: 'long',
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: 'numeric'
});
}
return f.format(protectionExpiry);
}
return null;
}, [application]);
const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(getProtectionsDisabledUntil());
useEffect(() => {
const removeProtectionSessionExpiryDateChangedObserver = application.addEventObserver(
async () => {
setProtectionsDisabledUntil(getProtectionsDisabledUntil());
},
ApplicationEvent.ProtectionSessionExpiryDateChanged
);
const removeKeyStatusChangedObserver = application.addEventObserver(
async () => {
setHasProtections(application.hasProtectionSources());
},
ApplicationEvent.KeyStatusChanged
);
return () => {
removeProtectionSessionExpiryDateChangedObserver();
removeKeyStatusChangedObserver();
};
}, [application, getProtectionsDisabledUntil]);
if (!hasProtections) {
return null;
}
return (
<div className="sk-panel-section">
<div className="sk-panel-section-title">Protections</div>
{protectionsDisabledUntil && (
<div className="sk-panel-section-subtitle info">
Protections are disabled until {protectionsDisabledUntil}
</div>
)}
{!protectionsDisabledUntil && (
<div className="sk-panel-section-subtitle info">
Protections are enabled
</div>
)}
<p className="sk-p">
Actions like viewing protected notes, exporting decrypted backups,
or revoking an active session, require additional authentication
like entering your account password or application passcode.
</p>
{protectionsDisabledUntil && (
<div className="sk-panel-row">
<button className="sn-button small info" onClick={enableProtections}>
Enable protections
</button>
</div>
)}
</div>
);
};
export default Protections;

View File

@@ -5,11 +5,14 @@ interface IProps {
expiredDate: string;
componentName: string;
featureStatus: FeatureStatus;
reloadStatus: () => void;
manageSubscription: () => void;
}
const statusString = (featureStatus: FeatureStatus, expiredDate: string, componentName: string) => {
const statusString = (
featureStatus: FeatureStatus,
expiredDate: string,
componentName: string
) => {
switch (featureStatus) {
case FeatureStatus.InCurrentPlanButExpired:
return `Your subscription expired on ${expiredDate}`;
@@ -25,9 +28,8 @@ const statusString = (featureStatus: FeatureStatus, expiredDate: string, compone
export const IsExpired: FunctionalComponent<IProps> = ({
expiredDate,
featureStatus,
reloadStatus,
componentName,
manageSubscription
manageSubscription,
}) => {
return (
<div className={'sn-component'}>
@@ -50,11 +52,13 @@ export const IsExpired: FunctionalComponent<IProps> = ({
</div>
</div>
<div className={'right'}>
<div className={'sk-app-bar-item'} onClick={() => manageSubscription()}>
<button className={'sn-button small success'}>Manage Subscription</button>
</div>
<div className={'sk-app-bar-item'} onClick={() => reloadStatus()}>
<button className={'sn-button small info'}>Reload</button>
<div
className={'sk-app-bar-item'}
onClick={() => manageSubscription()}
>
<button className={'sn-button small success'}>
Manage Subscription
</button>
</div>
</div>
</div>

View File

@@ -6,16 +6,16 @@ interface IProps {
}
export const IssueOnLoading: FunctionalComponent<IProps> = ({
componentName,
reloadIframe
}) => {
componentName,
reloadIframe,
}) => {
return (
<div className={'sn-component'}>
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
<div className={'left'}>
<div className={'sk-app-bar-item'}>
<div className={'sk-label.warning'}>
There was an issue loading {componentName}
There was an issue loading {componentName}.
</div>
</div>
</div>

View File

@@ -1,14 +1,6 @@
import { FunctionalComponent } from 'preact';
interface IProps {
isReloading: boolean;
reloadStatus: () => void;
}
export const OfflineRestricted: FunctionalComponent<IProps> = ({
isReloading,
reloadStatus
}) => {
export const OfflineRestricted: FunctionalComponent = () => {
return (
<div className={'sn-component'}>
<div className={'sk-panel static'}>
@@ -16,40 +8,29 @@ export const OfflineRestricted: FunctionalComponent<IProps> = ({
<div className={'sk-panel-section stretch'}>
<div className={'sk-panel-column'} />
<div className={'sk-h1 sk-bold'}>
You have restricted this extension to be used offline only.
You have restricted this component to not use a hosted version.
</div>
<div className={'sk-subtitle'}>
Offline extensions are not available in the Web app.
Locally-installed components are not available in the web
application.
</div>
<div className={'sk-panel-row'} />
<div className={'sk-panel-row'}>
<div className={'sk-panel-column'}>
<div className={'sk-p'}>
You can either:
To continue, choose from the following options:
</div>
<ul>
<li className={'sk-p'}>
<span className={'font-bold'}>
Enable the Hosted option for this extension by opening the 'Extensions' menu and{' '}
toggling 'Use hosted when local is unavailable' under this extension's options.{' '}
Then press Reload below.
</span>
</li>
<li className={'sk-p'}>
<span className={'font-bold'}>Use the Desktop application.</span>
Enable the Hosted option for this component by opening the
Preferences {'>'} General {'>'} Advanced Settings menu and{' '}
toggling 'Use hosted when local is unavailable' under this
component's options. Then press Reload.
</li>
<li className={'sk-p'}>Use the desktop application.</li>
</ul>
</div>
</div>
<div className={'sk-panel-row'}>
{isReloading ?
<div className={'sk-spinner info small'} />
:
<button className={'sn-button small info'} onClick={() => reloadStatus()}>
Reload
</button>
}
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,12 @@
import { ComponentAction, FeatureStatus, LiveItem, SNComponent, dateToLocalizedString } from '@standardnotes/snjs';
import {
ComponentAction,
FeatureStatus,
SNComponent,
dateToLocalizedString,
ComponentViewer,
ComponentViewerEvent,
ComponentViewerError,
} from '@standardnotes/snjs';
import { WebApplication } from '@/ui_models/application';
import { FunctionalComponent } from 'preact';
import { toDirective } from '@/components/utils';
@@ -11,15 +19,14 @@ import { IsDeprecated } from '@/components/ComponentView/IsDeprecated';
import { IsExpired } from '@/components/ComponentView/IsExpired';
import { IssueOnLoading } from '@/components/ComponentView/IssueOnLoading';
import { AppState } from '@/ui_models/app_state';
import { ComponentArea } from '@node_modules/@standardnotes/features';
import { openSubscriptionDashboard } from '@/hooks/manageSubscription';
interface IProps {
application: WebApplication;
appState: AppState;
componentUuid: string;
componentViewer: ComponentViewer;
requestReload?: (viewer: ComponentViewer) => void;
onLoad?: (component: SNComponent) => void;
templateComponent?: SNComponent;
manualDealloc?: boolean;
}
@@ -29,339 +36,237 @@ interface IProps {
*/
const MaxLoadThreshold = 4000;
const VisibilityChangeKey = 'visibilitychange';
const avoidFlickerTimeout = 7;
const MSToWaitAfterIframeLoadToAvoidFlicker = 35;
export const ComponentView: FunctionalComponent<IProps> = observer(
({
application,
onLoad,
componentUuid,
templateComponent
}) => {
const liveComponentRef = useRef<LiveItem<SNComponent> | null>(null);
({ application, onLoad, componentViewer, requestReload }) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const excessiveLoadingTimeout = useRef<
ReturnType<typeof setTimeout> | undefined
>(undefined);
const [isIssueOnLoading, setIsIssueOnLoading] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isReloading, setIsReloading] = useState(false);
const [loadTimeout, setLoadTimeout] = useState<ReturnType<typeof setTimeout> | undefined>(undefined);
const [featureStatus, setFeatureStatus] = useState<FeatureStatus | undefined>(FeatureStatus.Entitled);
const [hasIssueLoading, setHasIssueLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [featureStatus, setFeatureStatus] = useState<FeatureStatus>(
componentViewer.getFeatureStatus()
);
const [isComponentValid, setIsComponentValid] = useState(true);
const [error, setError] = useState<'offline-restricted' | 'url-missing' | undefined>(undefined);
const [isDeprecated, setIsDeprecated] = useState(false);
const [deprecationMessage, setDeprecationMessage] = useState<string | undefined>(undefined);
const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] = useState(false);
const [error, setError] = useState<ComponentViewerError | undefined>(
undefined
);
const [deprecationMessage, setDeprecationMessage] = useState<
string | undefined
>(undefined);
const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] =
useState(false);
const [didAttemptReload, setDidAttemptReload] = useState(false);
const [component, setComponent] = useState<SNComponent | undefined>(undefined);
const getComponent = useCallback((): SNComponent => {
return (templateComponent || liveComponentRef.current?.item) as SNComponent;
}, [templateComponent]);
const reloadIframe = () => {
setTimeout(() => {
setIsReloading(true);
setTimeout(() => {
setIsReloading(false);
});
});
};
const component = componentViewer.component;
const manageSubscription = useCallback(() => {
openSubscriptionDashboard(application);
}, [application]);
const reloadStatus = useCallback(() => {
if (!component) {
return;
useEffect(() => {
const loadTimeout = setTimeout(() => {
handleIframeTakingTooLongToLoad();
}, MaxLoadThreshold);
excessiveLoadingTimeout.current = loadTimeout;
return () => {
excessiveLoadingTimeout.current &&
clearTimeout(excessiveLoadingTimeout.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const reloadValidityStatus = useCallback(() => {
setFeatureStatus(componentViewer.getFeatureStatus());
if (!componentViewer.lockReadonly) {
componentViewer.setReadonly(featureStatus !== FeatureStatus.Entitled);
}
setIsComponentValid(componentViewer.shouldRender());
const offlineRestricted = component.offlineOnly && !isDesktopApplication();
const hasUrlError = function () {
if (isDesktopApplication()) {
return !component.local_url && !component.hasValidHostedUrl();
} else {
return !component.hasValidHostedUrl();
}
}();
setFeatureStatus(application.getFeatureStatus(component.identifier));
const readonlyState = application.componentManager.getReadonlyStateForComponent(component);
if (!readonlyState.lockReadonly) {
application.componentManager.setReadonlyStateForComponent(component, featureStatus !== FeatureStatus.Entitled);
}
setIsComponentValid(!offlineRestricted && !hasUrlError);
if (!isComponentValid) {
if (isLoading && !isComponentValid) {
setIsLoading(false);
}
if (offlineRestricted) {
setError('offline-restricted');
} else if (hasUrlError) {
setError('url-missing');
} else {
setError(undefined);
}
setIsDeprecated(component.isDeprecated);
setDeprecationMessage(component.package_info.deprecation_message);
}, [application, component, isComponentValid, featureStatus]);
setError(componentViewer.getError());
setDeprecationMessage(component.deprecationMessage);
}, [
componentViewer,
component.deprecationMessage,
featureStatus,
isComponentValid,
isLoading,
]);
useEffect(() => {
reloadValidityStatus();
}, [reloadValidityStatus]);
const dismissDeprecationMessage = () => {
setTimeout(() => {
setIsDeprecationMessageDismissed(true);
});
setIsDeprecationMessageDismissed(true);
};
const onVisibilityChange = useCallback(() => {
if (document.visibilityState === 'hidden') {
return;
}
if (isIssueOnLoading) {
reloadIframe();
if (hasIssueLoading) {
requestReload?.(componentViewer);
}
}, [isIssueOnLoading]);
}, [hasIssueLoading, componentViewer, requestReload]);
const handleIframeLoadTimeout = useCallback(async () => {
if (isLoading) {
setIsLoading(false);
setIsIssueOnLoading(true);
const handleIframeTakingTooLongToLoad = useCallback(async () => {
setIsLoading(false);
setHasIssueLoading(true);
if (!didAttemptReload) {
setDidAttemptReload(true);
reloadIframe();
} else {
document.addEventListener(
VisibilityChangeKey,
onVisibilityChange
);
}
if (!didAttemptReload) {
setDidAttemptReload(true);
requestReload?.(componentViewer);
} else {
document.addEventListener(VisibilityChangeKey, onVisibilityChange);
}
}, [didAttemptReload, isLoading, onVisibilityChange]);
const handleIframeLoad = useCallback(async (iframe: HTMLIFrameElement) => {
if (!component) {
return;
}
let desktopError = false;
if (isDesktopApplication()) {
try {
/** Accessing iframe.contentWindow.origin only allowed in desktop app. */
if (!iframe.contentWindow!.origin || iframe.contentWindow!.origin === 'null') {
desktopError = true;
}
// eslint-disable-next-line no-empty
} catch (e) {
}
}
loadTimeout && clearTimeout(loadTimeout);
await application.componentManager.registerComponentWindow(
component,
iframe.contentWindow!
);
setTimeout(() => {
setIsLoading(false);
setIsIssueOnLoading(desktopError ? true : false);
onLoad?.(component!);
}, avoidFlickerTimeout);
}, [application.componentManager, component, loadTimeout, onLoad]);
const loadComponent = useCallback(() => {
if (!component) {
throw Error('Component view is missing component');
}
if (!component.active && !component.isEditor() && component.area !== ComponentArea.Modal) {
/** Editors don't need to be active to be displayed */
throw Error('Component view component must be active');
}
setIsLoading(true);
if (loadTimeout) {
clearTimeout(loadTimeout);
}
const timeoutHandler = setTimeout(() => {
handleIframeLoadTimeout();
}, MaxLoadThreshold);
setLoadTimeout(timeoutHandler);
}, [component, handleIframeLoadTimeout, loadTimeout]);
}, [componentViewer, didAttemptReload, onVisibilityChange, requestReload]);
useEffect(() => {
reloadStatus();
if (!iframeRef.current) {
return;
}
iframeRef.current.onload = () => {
if (!component) {
return;
const iframe = iframeRef.current as HTMLIFrameElement;
iframe.onload = () => {
const contentWindow = iframe.contentWindow as Window;
let hasDesktopError = false;
const canAccessWindowOrigin = isDesktopApplication();
if (canAccessWindowOrigin) {
try {
if (!contentWindow.origin || contentWindow.origin === 'null') {
hasDesktopError = true;
}
} catch (e) {
console.error(e);
}
}
const iframe = application.componentManager.iframeForComponent(
component.uuid
);
if (!iframe) {
return;
}
excessiveLoadingTimeout.current &&
clearTimeout(excessiveLoadingTimeout.current);
componentViewer.setWindow(contentWindow);
setTimeout(() => {
loadComponent();
reloadStatus();
handleIframeLoad(iframe);
});
setIsLoading(false);
setHasIssueLoading(hasDesktopError);
onLoad?.(component);
}, MSToWaitAfterIframeLoadToAvoidFlicker);
};
}, [application.componentManager, component, handleIframeLoad, loadComponent, reloadStatus]);
const getUrl = () => {
const url = component ? application.componentManager.urlForComponent(component) : '';
return url as string;
};
}, [onLoad, component, componentViewer]);
useEffect(() => {
if (componentUuid) {
liveComponentRef.current = new LiveItem(componentUuid, application);
} else {
application.componentManager.addTemporaryTemplateComponent(templateComponent as SNComponent);
}
const removeFeaturesChangedObserver = componentViewer.addEventObserver(
(event) => {
if (event === ComponentViewerEvent.FeatureStatusUpdated) {
setFeatureStatus(componentViewer.getFeatureStatus());
}
}
);
return () => {
if (application.componentManager) {
/** Component manager Can be destroyed already via locking */
if (component) {
application.componentManager.onComponentIframeDestroyed(component.uuid);
}
if (templateComponent) {
application.componentManager.removeTemporaryTemplateComponent(templateComponent);
}
}
if (liveComponentRef.current) {
liveComponentRef.current.deinit();
}
document.removeEventListener(
VisibilityChangeKey,
onVisibilityChange
);
removeFeaturesChangedObserver();
};
}, [application, component, componentUuid, onVisibilityChange, templateComponent]);
}, [componentViewer]);
useEffect(() => {
// Set/update `component` based on `componentUuid` prop.
// It's a hint that the props were changed and we should rerender this component (and particularly, the iframe).
if (!component || component.uuid && componentUuid && component.uuid !== componentUuid) {
const latestComponentValue = getComponent();
setComponent(latestComponentValue);
}
}, [component, componentUuid, getComponent]);
useEffect(() => {
if (!component) {
return;
}
const unregisterComponentHandler = application.componentManager.registerHandler({
identifier: 'component-view-' + Math.random(),
areas: [component.area],
actionHandler: (component, action, data) => {
const removeActionObserver = componentViewer.addActionObserver(
(action, data) => {
switch (action) {
case (ComponentAction.SetSize):
application.componentManager.handleSetSizeEvent(component, data);
break;
case (ComponentAction.KeyDown):
case ComponentAction.KeyDown:
application.io.handleComponentKeyDown(data.keyboardModifier);
break;
case (ComponentAction.KeyUp):
case ComponentAction.KeyUp:
application.io.handleComponentKeyUp(data.keyboardModifier);
break;
case (ComponentAction.Click):
case ComponentAction.Click:
application.getAppState().notes.setContextMenuOpen(false);
break;
default:
return;
}
}
});
);
return () => {
unregisterComponentHandler();
removeActionObserver();
};
}, [application, component]);
}, [componentViewer, application]);
useEffect(() => {
const unregisterDesktopObserver = application.getDesktopService()
const unregisterDesktopObserver = application
.getDesktopService()
.registerUpdateObserver((component: SNComponent) => {
if (component.uuid === component.uuid && component.active) {
reloadIframe();
requestReload?.(componentViewer);
}
});
return () => {
unregisterDesktopObserver();
};
}, [application]);
if (!component) {
return null;
}
}, [application, requestReload, componentViewer]);
return (
<>
{isIssueOnLoading && (
{hasIssueLoading && (
<IssueOnLoading
componentName={component.name}
reloadIframe={reloadIframe}
reloadIframe={() => {
reloadValidityStatus(), requestReload?.(componentViewer);
}}
/>
)}
{featureStatus !== FeatureStatus.Entitled && (
<IsExpired
expiredDate={dateToLocalizedString(component.valid_until)}
reloadStatus={reloadStatus}
featureStatus={featureStatus!}
featureStatus={featureStatus}
componentName={component.name}
manageSubscription={manageSubscription}
/>
)}
{isDeprecated && !isDeprecationMessageDismissed && (
{deprecationMessage && !isDeprecationMessageDismissed && (
<IsDeprecated
deprecationMessage={deprecationMessage}
dismissDeprecationMessage={dismissDeprecationMessage}
/>
)}
{error == 'offline-restricted' && (
<OfflineRestricted isReloading={isReloading} reloadStatus={reloadStatus} />
{error === ComponentViewerError.OfflineRestricted && (
<OfflineRestricted />
)}
{error == 'url-missing' && (
{error === ComponentViewerError.MissingUrl && (
<UrlMissing componentName={component.name} />
)}
{component.uuid && !isReloading && isComponentValid && (
{component.uuid && isComponentValid && (
<iframe
ref={iframeRef}
data-component-id={component.uuid}
data-component-viewer-id={componentViewer.identifier}
frameBorder={0}
data-attr-id={`component-iframe-${component.uuid}`}
src={getUrl()}
sandbox='allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-modals allow-forms allow-downloads'
src={application.componentManager.urlForComponent(component) || ''}
sandbox="allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-modals allow-forms allow-downloads"
>
Loading
</iframe>
)}
{isLoading && (
<div className={'loading-overlay'} />
)}
{isLoading && <div className={'loading-overlay'} />}
</>
);
});
}
);
export const ComponentViewDirective = toDirective<IProps>(ComponentView, {
onLoad: '=',
componentUuid: '=',
templateComponent: '=',
manualDealloc: '='
componentViewer: '=',
requestReload: '=',
manualDealloc: '=',
});

View File

@@ -24,8 +24,10 @@ import MarkdownIcon from '../../icons/ic-markdown.svg';
import CodeIcon from '../../icons/ic-code.svg';
import AccessibilityIcon from '../../icons/ic-accessibility.svg';
import AddIcon from '../../icons/ic-add.svg';
import HelpIcon from '../../icons/ic-help.svg';
import KeyboardIcon from '../../icons/ic-keyboard.svg';
import ListBulleted from '../../icons/ic-list-bulleted.svg';
import ListedIcon from '../../icons/ic-listed.svg';
import SecurityIcon from '../../icons/ic-security.svg';
import SettingsIcon from '../../icons/ic-settings.svg';
@@ -53,11 +55,17 @@ import LockIcon from '../../icons/ic-lock.svg';
import ArrowsSortUpIcon from '../../icons/ic-arrows-sort-up.svg';
import ArrowsSortDownIcon from '../../icons/ic-arrows-sort-down.svg';
import WindowIcon from '../../icons/ic-window.svg';
import LinkOffIcon from '../../icons/ic-link-off.svg';
import MenuArrowDownAlt from '../../icons/ic-menu-arrow-down-alt.svg';
import MenuArrowRight from '../../icons/ic-menu-arrow-right.svg';
import { toDirective } from './utils';
import { FunctionalComponent } from 'preact';
const ICONS = {
'menu-arrow-down-alt': MenuArrowDownAlt,
'menu-arrow-right': MenuArrowRight,
'arrows-sort-up': ArrowsSortUpIcon,
'arrows-sort-down': ArrowsSortDownIcon,
lock: LockIcon,
@@ -94,8 +102,11 @@ const ICONS = {
more: MoreIcon,
tune: TuneIcon,
accessibility: AccessibilityIcon,
add: AddIcon,
help: HelpIcon,
keyboard: KeyboardIcon,
'list-bulleted': ListBulleted,
'link-off': LinkOffIcon,
listed: ListedIcon,
security: SecurityIcon,
settings: SettingsIcon,
@@ -111,7 +122,7 @@ const ICONS = {
'menu-arrow-down': MenuArrowDownIcon,
'menu-close': MenuCloseIcon,
window: WindowIcon,
'premium-feature': PremiumFeatureIcon
'premium-feature': PremiumFeatureIcon,
};
export type IconType = keyof typeof ICONS;

View File

@@ -5,7 +5,7 @@ import { observer } from 'mobx-react-lite';
type Props = { appState: AppState };
const NoAccountWarning = observer(({ appState }: Props) => {
export const NoAccountWarning = observer(({ appState }: Props) => {
const canShow = appState.noAccountWarning.show;
if (!canShow) {
return null;

View File

@@ -1,36 +0,0 @@
import { AppState } from '@/ui_models/app_state';
import { toDirective } from './utils';
type Props = { appState: AppState; onViewNote: () => void };
function NoProtectionsNoteWarning({ appState, onViewNote }: Props) {
return (
<div className="flex flex-col items-center justify-center text-center max-w-md">
<h1 className="text-2xl m-0 w-full">This note is protected</h1>
<p className="text-lg mt-2 w-full">
Add a passcode or create an account to require authentication to view
this note.
</p>
<div className="mt-4 flex gap-3">
<button
className="sn-button small info"
onClick={() => {
appState.accountMenu.setShow(true);
}}
>
Open account menu
</button>
<button className="sn-button small outlined" onClick={onViewNote}>
View note
</button>
</div>
</div>
);
}
export const NoProtectionsdNoteWarningDirective = toDirective<Props>(
NoProtectionsNoteWarning,
{
onViewNote: '&',
}
);

View File

@@ -0,0 +1,107 @@
import { KeyboardKey } from '@/services/ioService';
import { AppState } from '@/ui_models/app_state';
import { DisplayOptions } from '@/ui_models/app_state/notes_view_state';
import { SNNote } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { NotesListItem } from './NotesListItem';
type Props = {
appState: AppState;
notes: SNNote[];
selectedNotes: Record<string, SNNote>;
displayOptions: DisplayOptions;
paginate: () => void;
};
const FOCUSABLE_BUT_NOT_TABBABLE = -1;
const NOTES_LIST_SCROLL_THRESHOLD = 200;
export const NotesList: FunctionComponent<Props> = observer(
({ appState, notes, selectedNotes, displayOptions, paginate }) => {
const { selectPreviousNote, selectNextNote } = appState.notesView;
const { hideTags, hideDate, hideNotePreview, sortBy } = displayOptions;
const tagsStringForNote = (note: SNNote): string => {
if (hideTags) {
return '';
}
const selectedTag = appState.selectedTag;
if (!selectedTag) {
return '';
}
const tags = appState.getNoteTags(note);
if (!selectedTag.isSmartTag && tags.length === 1) {
return '';
}
return tags.map((tag) => `#${tag.title}`).join(' ');
};
const openNoteContextMenu = (posX: number, posY: number) => {
appState.notes.setContextMenuClickLocation({
x: posX,
y: posY,
});
appState.notes.reloadContextMenuLayout();
appState.notes.setContextMenuOpen(true);
};
const onContextMenu = async (note: SNNote, posX: number, posY: number) => {
await appState.notes.selectNote(note.uuid, true);
if (selectedNotes[note.uuid]) {
openNoteContextMenu(posX, posY);
}
};
const onScroll = (e: Event) => {
const offset = NOTES_LIST_SCROLL_THRESHOLD;
const element = e.target as HTMLElement;
if (
element.scrollTop + element.offsetHeight >=
element.scrollHeight - offset
) {
paginate();
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === KeyboardKey.Up) {
e.preventDefault();
selectPreviousNote();
} else if (e.key === KeyboardKey.Down) {
e.preventDefault();
selectNextNote();
}
};
return (
<div
className="infinite-scroll"
id="notes-scrollable"
onScroll={onScroll}
onKeyDown={onKeyDown}
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
>
{notes.map((note) => (
<NotesListItem
key={note.uuid}
note={note}
tags={tagsStringForNote(note)}
selected={!!selectedNotes[note.uuid]}
hideDate={hideDate}
hidePreview={hideNotePreview}
hideTags={hideTags}
sortedBy={sortBy}
onClick={() => {
appState.notes.selectNote(note.uuid, true);
}}
onContextMenu={(e: MouseEvent) => {
e.preventDefault();
onContextMenu(note, e.clientX, e.clientY);
}}
/>
))}
</div>
);
}
);

View File

@@ -0,0 +1,148 @@
import {
CollectionSort,
sanitizeHtmlString,
SNNote,
} from '@standardnotes/snjs';
import { FunctionComponent } from 'preact';
type Props = {
note: SNNote;
tags: string;
hideDate: boolean;
hidePreview: boolean;
hideTags: boolean;
onClick: () => void;
onContextMenu: (e: MouseEvent) => void;
selected: boolean;
sortedBy?: CollectionSort;
};
type NoteFlag = {
text: string;
class: 'info' | 'neutral' | 'warning' | 'success' | 'danger';
};
const flagsForNote = (note: SNNote) => {
const flags = [] as NoteFlag[];
if (note.pinned) {
flags.push({
text: 'Pinned',
class: 'info',
});
}
if (note.archived) {
flags.push({
text: 'Archived',
class: 'warning',
});
}
if (note.locked) {
flags.push({
text: 'Editing Disabled',
class: 'neutral',
});
}
if (note.trashed) {
flags.push({
text: 'Deleted',
class: 'danger',
});
}
if (note.conflictOf) {
flags.push({
text: 'Conflicted Copy',
class: 'danger',
});
}
if (note.errorDecrypting) {
if (note.waitingForKey) {
flags.push({
text: 'Waiting For Keys',
class: 'info',
});
} else {
flags.push({
text: 'Missing Keys',
class: 'danger',
});
}
}
if (note.deleted) {
flags.push({
text: 'Deletion Pending Sync',
class: 'danger',
});
}
return flags;
};
export const NotesListItem: FunctionComponent<Props> = ({
hideDate,
hidePreview,
hideTags,
note,
onClick,
onContextMenu,
selected,
sortedBy,
tags,
}) => {
const flags = flagsForNote(note);
const showModifiedDate = sortedBy === CollectionSort.UpdatedAt;
return (
<div
className={`note ${selected ? 'selected' : ''}`}
id={`note-${note.uuid}`}
onClick={onClick}
onContextMenu={onContextMenu}
>
{flags && flags.length > 0 ? (
<div className="note-flags flex flex-wrap">
{flags.map((flag) => (
<div className={`flag ${flag.class}`}>
<div className="label">{flag.text}</div>
</div>
))}
</div>
) : null}
<div className="name">{note.title}</div>
{!hidePreview && !note.hidePreview && !note.protected ? (
<div className="note-preview">
{note.preview_html ? (
<div
className="html-preview"
dangerouslySetInnerHTML={{
__html: sanitizeHtmlString(note.preview_html),
}}
></div>
) : null}
{!note.preview_html && note.preview_plain ? (
<div className="plain-preview">{note.preview_plain}</div>
) : null}
{!note.preview_html && !note.preview_plain ? (
<div className="default-preview">{note.text}</div>
) : null}
</div>
) : null}
{!hideDate || note.protected ? (
<div className="bottom-info faded">
{note.protected ? (
<span>Protected {hideDate ? '' : ' • '}</span>
) : null}
{!hideDate && showModifiedDate ? (
<span>Modified {note.updatedAtString || 'Now'}</span>
) : null}
{!hideDate && !showModifiedDate ? (
<span>{note.createdAtString || 'Now'}</span>
) : null}
</div>
) : null}
{!hideTags && (
<div className="tags-string">
<div className="faded">{tags}</div>
</div>
)}
</div>
);
};

View File

@@ -10,11 +10,11 @@ import { toDirective, useCloseOnClickOutside } from './utils';
type Props = {
application: WebApplication;
setShowMenuFalse: () => void;
closeDisplayOptionsMenu: () => void;
};
export const NotesListOptionsMenu: FunctionComponent<Props> = observer(
({ setShowMenuFalse, application }) => {
({ closeDisplayOptionsMenu, application }) => {
const menuClassName =
'sn-dropdown sn-dropdown--animated min-w-70 overflow-y-auto \
border-1 border-solid border-main text-sm z-index-dropdown-menu \
@@ -112,13 +112,13 @@ flex flex-col py-2 bottom-0 left-2 absolute';
useCloseOnClickOutside(menuRef as any, (open: boolean) => {
if (!open) {
setShowMenuFalse();
closeDisplayOptionsMenu();
}
});
return (
<div ref={menuRef} className={menuClassName}>
<Menu a11yLabel="Sort by" closeMenu={setShowMenuFalse}>
<Menu a11yLabel="Sort by" closeMenu={closeDisplayOptionsMenu}>
<div className="px-3 my-1 text-xs font-semibold color-text uppercase">
Sort by
</div>
@@ -246,7 +246,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
export const NotesListOptionsDirective = toDirective<Props>(
NotesListOptionsMenu,
{
setShowMenuFalse: '=',
closeDisplayOptionsMenu: '=',
state: '&',
}
);

View File

@@ -0,0 +1,256 @@
import {
PanelSide,
ResizeFinishCallback,
} from '@/directives/views/panelResizer';
import { KeyboardKey, KeyboardModifier } from '@/services/ioService';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { PANEL_NAME_NOTES } from '@/views/constants';
import { PrefKey } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
import { NoAccountWarning } from './NoAccountWarning';
import { NotesList } from './NotesList';
import { NotesListOptionsMenu } from './NotesListOptionsMenu';
import { PanelResizer } from './PanelResizer';
import { SearchOptions } from './SearchOptions';
import { toDirective } from './utils';
type Props = {
application: WebApplication;
appState: AppState;
};
const NotesView: FunctionComponent<Props> = observer(
({ application, appState }) => {
const notesViewPanelRef = useRef<HTMLDivElement>(null);
const {
completedFullSync,
createNewNote,
displayOptions,
noteFilterText,
optionsSubtitle,
panelTitle,
renderedNotes,
selectedNotes,
setNoteFilterText,
showDisplayOptionsMenu,
toggleDisplayOptionsMenu,
searchBarElement,
selectNextNote,
selectPreviousNote,
onFilterEnter,
handleFilterTextChanged,
onSearchInputBlur,
clearFilterText,
paginate,
} = appState.notesView;
useEffect(() => {
handleFilterTextChanged();
}, [noteFilterText, handleFilterTextChanged]);
useEffect(() => {
/**
* In the browser we're not allowed to override cmd/ctrl + n, so we have to
* use Control modifier as well. These rules don't apply to desktop, but
* probably better to be consistent.
*/
const newNoteKeyObserver = application.io.addKeyObserver({
key: 'n',
modifiers: [KeyboardModifier.Meta, KeyboardModifier.Ctrl],
onKeyDown: (event) => {
event.preventDefault();
createNewNote();
},
});
const nextNoteKeyObserver = application.io.addKeyObserver({
key: KeyboardKey.Down,
elements: [
document.body,
...(searchBarElement ? [searchBarElement] : []),
],
onKeyDown: () => {
if (searchBarElement === document.activeElement) {
searchBarElement?.blur();
}
selectNextNote();
},
});
const previousNoteKeyObserver = application.io.addKeyObserver({
key: KeyboardKey.Up,
element: document.body,
onKeyDown: () => {
selectPreviousNote();
},
});
const searchKeyObserver = application.io.addKeyObserver({
key: 'f',
modifiers: [KeyboardModifier.Meta, KeyboardModifier.Shift],
onKeyDown: () => {
if (searchBarElement) {
searchBarElement.focus();
}
},
});
return () => {
newNoteKeyObserver();
nextNoteKeyObserver();
previousNoteKeyObserver();
searchKeyObserver();
};
}, [
application.io,
createNewNote,
searchBarElement,
selectNextNote,
selectPreviousNote,
]);
const onNoteFilterTextChange = (e: Event) => {
setNoteFilterText((e.target as HTMLInputElement).value);
};
const onNoteFilterKeyUp = (e: KeyboardEvent) => {
if (e.key === KeyboardKey.Enter) {
onFilterEnter();
}
};
const panelResizeFinishCallback: ResizeFinishCallback = (
_w,
_l,
_mw,
isCollapsed
) => {
appState.noteTags.reloadTagsContainerMaxWidth();
appState.panelDidResize(PANEL_NAME_NOTES, isCollapsed);
};
const panelWidthEventCallback = () => {
appState.noteTags.reloadTagsContainerMaxWidth();
};
return (
<div
id="notes-column"
className="sn-component section notes"
aria-label="Notes"
ref={notesViewPanelRef}
>
<div className="content">
<div id="notes-title-bar" className="section-title-bar">
<div className="p-4">
<div className="section-title-bar-header">
<div className="sk-h2 font-semibold title">{panelTitle}</div>
<button
className="sk-button contrast wide"
title="Create a new note in the selected tag"
aria-label="Create new note"
onClick={() => createNewNote()}
>
<div className="sk-label">
<i className="ion-plus add-button" aria-hidden></i>
</div>
</button>
</div>
<div className="filter-section" role="search">
<input
type="text"
id="search-bar"
className="filter-bar"
placeholder="Search"
title="Searches notes in the currently selected tag"
value={noteFilterText}
onChange={onNoteFilterTextChange}
onKeyUp={onNoteFilterKeyUp}
onBlur={() => onSearchInputBlur()}
/>
{noteFilterText ? (
<button
onClick={clearFilterText}
aria-role="button"
id="search-clear-button"
>
</button>
) : null}
<div className="ml-2">
<SearchOptions
application={application}
appState={appState}
/>
</div>
</div>
<NoAccountWarning appState={appState} />
</div>
<div id="notes-menu-bar" className="sn-component">
<div className="sk-app-bar no-edges">
<div className="left">
<div
className={`sk-app-bar-item ${
showDisplayOptionsMenu ? 'selected' : ''
}`}
onClick={() =>
toggleDisplayOptionsMenu(!showDisplayOptionsMenu)
}
>
<div className="sk-app-bar-item-column">
<div className="sk-label">Options</div>
</div>
<div className="sk-app-bar-item-column">
<div className="sk-sublabel">{optionsSubtitle}</div>
</div>
</div>
</div>
</div>
{showDisplayOptionsMenu && (
<NotesListOptionsMenu
application={application}
closeDisplayOptionsMenu={() =>
toggleDisplayOptionsMenu(false)
}
/>
)}
</div>
</div>
{completedFullSync && !renderedNotes.length ? (
<p className="empty-notes-list faded">No notes.</p>
) : null}
{!completedFullSync && !renderedNotes.length ? (
<p className="empty-notes-list faded">Loading notes...</p>
) : null}
{renderedNotes.length ? (
<NotesList
notes={renderedNotes}
selectedNotes={selectedNotes}
appState={appState}
displayOptions={displayOptions}
paginate={paginate}
/>
) : null}
</div>
{notesViewPanelRef.current && (
<PanelResizer
application={application}
collapsable={true}
defaultWidth={300}
panel={document.querySelector('notes-view') as HTMLDivElement}
prefKey={PrefKey.NotesPanelWidth}
side={PanelSide.Right}
resizeFinishCallback={panelResizeFinishCallback}
widthEventCallback={panelWidthEventCallback}
/>
)}
</div>
);
}
);
export const NotesViewDirective = toDirective<Props>(NotesView);

View File

@@ -0,0 +1,60 @@
import {
PanelResizerProps,
PanelResizerState,
} from '@/ui_models/panel_resizer';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
export const PanelResizer: FunctionComponent<PanelResizerProps> = observer(
({
alwaysVisible,
application,
defaultWidth,
hoverable,
collapsable,
minWidth,
panel,
prefKey,
resizeFinishCallback,
side,
widthEventCallback,
}) => {
const [panelResizerState] = useState(
() =>
new PanelResizerState({
alwaysVisible,
application,
defaultWidth,
hoverable,
collapsable,
minWidth,
panel,
prefKey,
resizeFinishCallback,
side,
widthEventCallback,
})
);
const panelResizerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (panelResizerRef.current) {
panelResizerState.setMinWidth(panelResizerRef.current.offsetWidth + 2);
}
}, [panelResizerState]);
return (
<div
className={`panel-resizer ${panelResizerState.side} ${
panelResizerState.hoverable ? 'hoverable' : ''
} ${panelResizerState.alwaysVisible ? 'alwaysVisible' : ''} ${
panelResizerState.pressed ? 'dragging' : ''
} ${panelResizerState.collapsed ? 'collapsed' : ''}`}
onMouseDown={panelResizerState.onMouseDown}
onDblClick={panelResizerState.onDblClick}
ref={panelResizerRef}
></div>
);
}
);

View File

@@ -0,0 +1 @@
export { usePremiumModal, PremiumModalProvider } from './usePremiumModal';

View File

@@ -0,0 +1,49 @@
import { FunctionalComponent } from 'preact';
import { useCallback, useContext, useState } from 'preact/hooks';
import { createContext } from 'react';
import { PremiumFeaturesModal } from '../PremiumFeaturesModal';
type PremiumModalContextData = {
activate: (featureName: string) => void;
};
const PremiumModalContext = createContext<PremiumModalContextData | null>(null);
const PremiumModalProvider_ = PremiumModalContext.Provider;
export const usePremiumModal = (): PremiumModalContextData => {
const value = useContext(PremiumModalContext);
if (!value) {
throw new Error('invalid PremiumModal context');
}
return value;
};
export const PremiumModalProvider: FunctionalComponent = ({ children }) => {
const [featureName, setFeatureName] = useState<null | string>(null);
const activate = setFeatureName;
const closeModal = useCallback(() => {
setFeatureName(null);
}, [setFeatureName]);
const showModal = !!featureName;
return (
<>
{showModal && (
<PremiumFeaturesModal
showModal={!!featureName}
featureName={featureName}
onClose={closeModal}
/>
)}
<PremiumModalProvider_ value={{ activate }}>
{children}
</PremiumModalProvider_>
</>
);
};

View File

@@ -0,0 +1,51 @@
import { AppState } from '@/ui_models/app_state';
import { toDirective } from './utils';
type Props = {
appState: AppState;
onViewNote: () => void;
hasProtectionSources: boolean;
};
function ProtectedNoteOverlay({
appState,
onViewNote,
hasProtectionSources,
}: Props) {
const instructionText = hasProtectionSources
? 'Authenticate to view this note.'
: 'Add a passcode or create an account to require authentication to view this note.';
return (
<div className="flex flex-col items-center justify-center text-center max-w-md">
<h1 className="text-2xl m-0 w-full">This note is protected</h1>
<p className="text-lg mt-2 w-full">{instructionText}</p>
<div className="mt-4 flex gap-3">
{!hasProtectionSources && (
<button
className="sn-button small info"
onClick={() => {
appState.accountMenu.setShow(true);
}}
>
Open account menu
</button>
)}
<button
className="sn-button small outlined normal-focus-brightness"
onClick={onViewNote}
>
{hasProtectionSources ? 'Authenticate' : 'View Note'}
</button>
</div>
</div>
);
}
export const ProtectedNoteOverlayDirective = toDirective<Props>(
ProtectedNoteOverlay,
{
onViewNote: '&',
hasProtectionSources: '=',
}
);

View File

@@ -1,8 +1,7 @@
import { WebApplication } from '@/ui_models/application';
import { FeatureIdentifier } from '@standardnotes/features';
import { FeatureStatus } from '@standardnotes/snjs';
import { FeatureStatus, FeatureIdentifier } from '@standardnotes/snjs';
import { FunctionComponent } from 'preact';
import { useState } from 'preact/hooks';
import { useCallback, useState } from 'preact/hooks';
import { JSXInternal } from 'preact/src/jsx';
import { Icon } from '../Icon';
import { PremiumFeaturesModal } from '../PremiumFeaturesModal';
@@ -10,46 +9,48 @@ import { Switch } from '../Switch';
type Props = {
application: WebApplication;
closeQuickSettingsMenu: () => void;
focusModeEnabled: boolean;
setFocusModeEnabled: (enabled: boolean) => void;
onToggle: (value: boolean) => void;
onClose: () => void;
isEnabled: boolean;
};
export const FocusModeSwitch: FunctionComponent<Props> = ({
application,
closeQuickSettingsMenu,
focusModeEnabled,
setFocusModeEnabled,
onToggle,
onClose,
isEnabled,
}) => {
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const isEntitledToFocusMode =
const isEntitled =
application.getFeatureStatus(FeatureIdentifier.FocusMode) ===
FeatureStatus.Entitled;
const toggleFocusMode = (
e: JSXInternal.TargetedMouseEvent<HTMLButtonElement>
) => {
e.preventDefault();
if (isEntitledToFocusMode) {
setFocusModeEnabled(!focusModeEnabled);
closeQuickSettingsMenu();
} else {
setShowUpgradeModal(true);
}
};
const toggle = useCallback(
(e: JSXInternal.TargetedMouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (isEntitled) {
onToggle(!isEnabled);
onClose();
} else {
setShowUpgradeModal(true);
}
},
[isEntitled, isEnabled, onToggle, setShowUpgradeModal, onClose]
);
return (
<>
<button
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between"
onClick={toggleFocusMode}
onClick={toggle}
>
<div className="flex items-center">
<Icon type="menu-close" className="color-neutral mr-2" />
Focused Writing
</div>
{isEntitledToFocusMode ? (
<Switch className="px-0" checked={focusModeEnabled} />
{isEntitled ? (
<Switch className="px-0" checked={isEnabled} />
) : (
<div title="Premium feature">
<Icon type="premium-feature" />

View File

@@ -6,10 +6,10 @@ import {
DisclosurePanel,
} from '@reach/disclosure';
import {
ContentType,
SNTheme,
ComponentArea,
ContentType,
SNComponent,
SNTheme,
} from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
@@ -174,7 +174,11 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
};
const toggleComponent = (component: SNComponent) => {
application.toggleComponent(component);
if (component.isTheme()) {
application.toggleTheme(component);
} else {
application.toggleComponent(component);
}
};
const handleBtnKeyDown: React.KeyboardEventHandler<HTMLButtonElement> = (
@@ -218,7 +222,7 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
const activeTheme = themes.find(
(theme) => theme.active && !theme.isLayerable()
);
if (activeTheme) application.toggleComponent(activeTheme);
if (activeTheme) application.toggleTheme(activeTheme);
};
return (
@@ -301,9 +305,9 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
))}
<FocusModeSwitch
application={application}
closeQuickSettingsMenu={closeQuickSettingsMenu}
focusModeEnabled={focusModeEnabled}
setFocusModeEnabled={setFocusModeEnabled}
onToggle={setFocusModeEnabled}
onClose={closeQuickSettingsMenu}
isEnabled={focusModeEnabled}
/>
<div className="h-1px my-2 bg-border"></div>
<button

View File

@@ -18,7 +18,7 @@ export const ThemesMenuButton: FunctionComponent<Props> = ({
const toggleTheme: JSXInternal.MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault();
if (theme.isLayerable() || !theme.active) {
application.toggleComponent(theme);
application.toggleTheme(theme);
}
};

View File

@@ -0,0 +1,64 @@
import {
FeaturesState,
TAG_FOLDERS_FEATURE_NAME,
} from '@/ui_models/app_state/features_state';
import { TagsState } from '@/ui_models/app_state/tags_state';
import { observer } from 'mobx-react-lite';
import { useDrop } from 'react-dnd';
import { Icon } from './Icon';
import { usePremiumModal } from './Premium';
import { DropItem, DropProps, ItemTypes } from './TagsListItem';
type Props = {
tagsState: TagsState;
featuresState: FeaturesState;
};
export const RootTagDropZone: React.FC<Props> = observer(
({ tagsState, featuresState }) => {
const premiumModal = usePremiumModal();
const isNativeFoldersEnabled = featuresState.enableNativeFoldersFeature;
const hasFolders = tagsState.hasFolders;
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
() => ({
accept: ItemTypes.TAG,
canDrop: () => {
return true;
},
drop: (item) => {
if (!hasFolders) {
premiumModal.activate(TAG_FOLDERS_FEATURE_NAME);
return;
}
tagsState.assignParent(item.uuid, undefined);
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: !!monitor.canDrop(),
}),
}),
[tagsState, hasFolders, premiumModal]
);
if (!isNativeFoldersEnabled || !hasFolders) {
return null;
}
return (
<div
ref={dropRef}
className={`root-drop ${canDrop ? 'active' : ''} ${
isOver ? 'is-over' : ''
}`}
>
<Icon className="color-neutral" type="link-off" />
<p className="content">
Move the tag here to <br />
remove it from its folder.
</p>
</div>
);
}
);

View File

@@ -1,7 +1,7 @@
import { AppState } from '@/ui_models/app_state';
import { Icon } from './Icon';
import { toDirective, useCloseOnBlur } from './utils';
import { useRef, useState } from 'preact/hooks';
import { useEffect, useRef, useState } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application';
import VisuallyHidden from '@reach/visually-hidden';
import {
@@ -11,21 +11,17 @@ import {
} from '@reach/disclosure';
import { Switch } from './Switch';
import { observer } from 'mobx-react-lite';
import { useEffect } from 'react';
type Props = {
appState: AppState;
application: WebApplication;
};
const SearchOptions = observer(({ appState }: Props) => {
export const SearchOptions = observer(({ appState }: Props) => {
const { searchOptions } = appState;
const {
includeProtectedContents,
includeArchived,
includeTrashed,
} = searchOptions;
const { includeProtectedContents, includeArchived, includeTrashed } =
searchOptions;
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({
@@ -35,7 +31,10 @@ const SearchOptions = observer(({ appState }: Props) => {
const [maxWidth, setMaxWidth] = useState<number | 'auto'>('auto');
const buttonRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(panelRef as any, setOpen);
const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(
panelRef as any,
setOpen
);
async function toggleIncludeProtectedContents() {
setLockCloseOnBlur(true);

View File

@@ -1,12 +1,12 @@
import { ComponentChildren, FunctionalComponent } from 'preact';
import { useState } from 'preact/hooks';
import { HTMLProps } from 'react';
import {
CustomCheckboxContainer,
CustomCheckboxInput,
CustomCheckboxInputProps,
} from '@reach/checkbox';
import '@reach/checkbox/styles.css';
import { ComponentChildren, FunctionalComponent } from 'preact';
import { useState } from 'preact/hooks';
import { HTMLProps } from 'react';
export type SwitchProps = HTMLProps<HTMLInputElement> & {
checked?: boolean;
@@ -23,6 +23,10 @@ export const Switch: FunctionalComponent<SwitchProps> = (
const [checkedState, setChecked] = useState(props.checked || false);
const checked = props.checked ?? checkedState;
const className = props.className ?? '';
const isDisabled = !!props.disabled;
const isActive = checked && !isDisabled;
return (
<label
className={`sn-component flex justify-between items-center cursor-pointer px-3 ${className}`}
@@ -35,7 +39,8 @@ export const Switch: FunctionalComponent<SwitchProps> = (
setChecked(event.target.checked);
props.onChange?.(event.target.checked);
}}
className={`sn-switch ${checked ? 'bg-info' : 'bg-neutral'}`}
className={`sn-switch ${isActive ? 'bg-info' : 'bg-neutral'}`}
disabled={props.disabled}
>
<CustomCheckboxInput
{...({

View File

@@ -0,0 +1,108 @@
import { TagsList } from '@/components/TagsList';
import { toDirective } from '@/components/utils';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import {
FeaturesState,
TAG_FOLDERS_FEATURE_NAME,
TAG_FOLDERS_FEATURE_TOOLTIP,
} from '@/ui_models/app_state/features_state';
import { Tooltip } from '@reach/tooltip';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useCallback } from 'preact/hooks';
import { IconButton } from '../IconButton';
import { PremiumModalProvider, usePremiumModal } from '../Premium';
type Props = {
application: WebApplication;
appState: AppState;
};
const TagAddButton: FunctionComponent<{
appState: AppState;
features: FeaturesState;
}> = observer(({ appState, features }) => {
const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
if (!isNativeFoldersEnabled) {
return null;
}
return (
<IconButton
icon="add"
title="Create a new tag"
focusable={true}
onClick={() => appState.createNewTag()}
/>
);
});
const TagTitle: FunctionComponent<{
features: FeaturesState;
}> = observer(({ features }) => {
const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
const hasFolders = features.hasFolders;
const modal = usePremiumModal();
const showPremiumAlert = useCallback(() => {
modal.activate(TAG_FOLDERS_FEATURE_NAME);
}, [modal]);
if (!isNativeFoldersEnabled) {
return (
<>
<div className="sk-h3 title">
<span className="sk-bold">Tags</span>
</div>
</>
);
}
if (hasFolders) {
return (
<>
<div className="sk-h3 title">
<span className="sk-bold">Folders</span>
</div>
</>
);
}
return (
<>
<div className="sk-h3 title">
<span className="sk-bold">Tags</span>
<Tooltip label={TAG_FOLDERS_FEATURE_TOOLTIP}>
<label
className="ml-1 sk-bold color-grey-2 cursor-pointer"
onClick={showPremiumAlert}
>
Folders
</label>
</Tooltip>
</div>
</>
);
});
export const TagsSection: FunctionComponent<Props> = observer(
({ application, appState }) => {
return (
<PremiumModalProvider>
<section>
<div className="tags-title-section section-title-bar">
<div className="section-title-bar-header">
<TagTitle features={appState.features} />
<TagAddButton appState={appState} features={appState.features} />
</div>
</div>
<TagsList application={application} appState={appState} />
</section>
</PremiumModalProvider>
);
}
);
export const TagsSectionDirective = toDirective<Props>(TagsSection);

View File

@@ -1,12 +1,18 @@
import { PremiumModalProvider } from '@/components/Premium';
import { confirmDialog } from '@/services/alertService';
import { STRING_DELETE_TAG } from '@/strings';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { isMobile } from '@/utils';
import { SNTag, TagMutator } from '@standardnotes/snjs';
import { runInAction } from 'mobx';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useCallback } from 'preact/hooks';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { TouchBackend } from 'react-dnd-touch-backend';
import { RootTagDropZone } from './RootTagDropZone';
import { TagsListItem } from './TagsListItem';
import { toDirective } from './utils';
@@ -28,8 +34,9 @@ const tagsWithOptionalTemplate = (
export const TagsList: FunctionComponent<Props> = observer(
({ application, appState }) => {
const templateTag = appState.templateTag;
const tags = appState.tags.tags;
const allTags = tagsWithOptionalTemplate(templateTag, tags);
const rootTags = appState.tags.rootTags;
const allTags = tagsWithOptionalTemplate(templateTag, rootTags);
const selectTag = useCallback(
(tag: SNTag) => {
@@ -106,29 +113,39 @@ export const TagsList: FunctionComponent<Props> = observer(
[appState]
);
const backend = isMobile({ tablet: true }) ? TouchBackend : HTML5Backend;
return (
<>
{allTags.length === 0 ? (
<div className="no-tags-placeholder">
No tags. Create one using the add button above.
</div>
) : (
<>
{allTags.map((tag) => {
return (
<TagsListItem
key={tag.uuid}
tag={tag}
selectTag={selectTag}
saveTag={saveTag}
removeTag={removeTag}
appState={appState}
/>
);
})}
</>
)}
</>
<PremiumModalProvider>
<DndProvider backend={backend}>
{allTags.length === 0 ? (
<div className="no-tags-placeholder">
No tags. Create one using the add button above.
</div>
) : (
<>
{allTags.map((tag) => {
return (
<TagsListItem
level={0}
key={tag.uuid}
tag={tag}
tagsState={appState.tags}
selectTag={selectTag}
saveTag={saveTag}
removeTag={removeTag}
appState={appState}
/>
);
})}
<RootTagDropZone
tagsState={appState.tags}
featuresState={appState.features}
/>
</>
)}
</DndProvider>
</PremiumModalProvider>
);
}
);

View File

@@ -1,26 +1,47 @@
import {
FeaturesState,
TAG_FOLDERS_FEATURE_NAME,
} from '@/ui_models/app_state/features_state';
import { TagsState } from '@/ui_models/app_state/tags_state';
import '@reach/tooltip/styles.css';
import { SNTag } from '@standardnotes/snjs';
import { computed, runInAction } from 'mobx';
import { observer } from 'mobx-react-lite';
import { FunctionComponent, JSX } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { useDrag, useDrop } from 'react-dnd';
import { Icon } from './Icon';
import { usePremiumModal } from './Premium';
export enum ItemTypes {
TAG = 'TAG',
}
export type DropItemTag = { uuid: string };
export type DropItem = DropItemTag;
export type DropProps = { isOver: boolean; canDrop: boolean };
type Props = {
tag: SNTag;
tagsState: TagsState;
selectTag: (tag: SNTag) => void;
removeTag: (tag: SNTag) => void;
saveTag: (tag: SNTag, newTitle: string) => void;
appState: TagsListState;
level: number;
};
export type TagsListState = {
readonly selectedTag: SNTag | undefined;
tags: TagsState;
editingTag: SNTag | undefined;
features: FeaturesState;
};
export const TagsListItem: FunctionComponent<Props> = observer(
({ tag, selectTag, saveTag, removeTag, appState }) => {
({ tag, selectTag, saveTag, removeTag, appState, tagsState, level }) => {
const [title, setTitle] = useState(tag.title || '');
const inputRef = useRef<HTMLInputElement>(null);
@@ -28,10 +49,36 @@ export const TagsListItem: FunctionComponent<Props> = observer(
const isEditing = appState.editingTag === tag;
const noteCounts = computed(() => appState.tags.getNotesCount(tag));
const childrenTags = computed(() => tagsState.getChildren(tag)).get();
const hasChildren = childrenTags.length > 0;
const hasFolders = tagsState.hasFolders;
const isNativeFoldersEnabled = appState.features.enableNativeFoldersFeature;
const hasAtLeastOneFolder = tagsState.hasAtLeastOneFolder;
const premiumModal = usePremiumModal();
const [showChildren, setShowChildren] = useState(hasChildren);
const [hadChildren, setHadChildren] = useState(hasChildren);
useEffect(() => {
if (!hadChildren && hasChildren) {
setShowChildren(true);
}
setHadChildren(hasChildren);
}, [hadChildren, hasChildren]);
useEffect(() => {
setTitle(tag.title || '');
}, [setTitle, tag]);
const toggleChildren = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
setShowChildren((x) => !x);
},
[setShowChildren]
);
const selectCurrentTag = useCallback(() => {
if (isEditing || isSelected) {
return;
@@ -41,7 +88,8 @@ export const TagsListItem: FunctionComponent<Props> = observer(
const onBlur = useCallback(() => {
saveTag(tag, title);
}, [tag, saveTag, title]);
setTitle(tag.title);
}, [tag, saveTag, title, setTitle]);
const onInput = useCallback(
(e: JSX.TargetedEvent<HTMLInputElement>) => {
@@ -81,56 +129,142 @@ export const TagsListItem: FunctionComponent<Props> = observer(
removeTag(tag);
}, [removeTag, tag]);
const [, dragRef] = useDrag(
() => ({
type: ItemTypes.TAG,
item: { uuid: tag.uuid },
canDrag: () => {
return isNativeFoldersEnabled;
},
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
}),
[tag, hasFolders]
);
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
() => ({
accept: ItemTypes.TAG,
canDrop: (item) => {
return tagsState.isValidTagParent(tag.uuid, item.uuid);
},
drop: (item) => {
if (!hasFolders) {
premiumModal.activate(TAG_FOLDERS_FEATURE_NAME);
return;
}
tagsState.assignParent(item.uuid, tag.uuid);
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: !!monitor.canDrop(),
}),
}),
[tag, tagsState, hasFolders, premiumModal]
);
const readyToDrop = isOver && canDrop;
return (
<div
className={`tag ${isSelected ? 'selected' : ''}`}
onClick={selectCurrentTag}
>
{!tag.errorDecrypting ? (
<div className="tag-info">
<div className="tag-icon">#</div>
<input
className={`title ${isEditing ? 'editing' : ''}`}
id={`react-tag-${tag.uuid}`}
onBlur={onBlur}
onInput={onInput}
value={title}
onKeyUp={onKeyUp}
spellCheck={false}
ref={inputRef}
/>
<div className="count">{noteCounts.get()}</div>
</div>
) : null}
{tag.conflictOf && (
<div className="danger small-text font-bold">
Conflicted Copy {tag.conflictOf}
</div>
)}
{tag.errorDecrypting && !tag.waitingForKey && (
<div className="danger small-text font-bold">Missing Keys</div>
)}
{tag.errorDecrypting && tag.waitingForKey && (
<div className="info small-text font-bold">Waiting For Keys</div>
)}
{isSelected && (
<div className="menu">
{!isEditing && (
<a className="item" onClick={onClickRename}>
Rename
</a>
<>
<div
className={`tag ${isSelected ? 'selected' : ''} ${
readyToDrop ? 'is-drag-over' : ''
}`}
onClick={selectCurrentTag}
ref={dragRef}
style={{ paddingLeft: `${level * 21 + 10}px` }}
>
{!tag.errorDecrypting ? (
<div className="tag-info" title={title} ref={dropRef}>
{hasFolders && isNativeFoldersEnabled && hasAtLeastOneFolder && (
<div
className={`tag-fold ${showChildren ? 'opened' : 'closed'}`}
onClick={hasChildren ? toggleChildren : undefined}
>
<Icon
className={`color-neutral ${!hasChildren ? 'hidden' : ''}`}
type={
showChildren ? 'menu-arrow-down-alt' : 'menu-arrow-right'
}
/>
</div>
)}
<div
className={`tag-icon ${
isNativeFoldersEnabled ? 'draggable' : ''
} mr-1`}
ref={dragRef}
>
<Icon
type="hashtag"
className={`${isSelected ? 'color-info' : 'color-neutral'}`}
/>
</div>
<input
className={`title ${isEditing ? 'editing' : ''}`}
id={`react-tag-${tag.uuid}`}
onBlur={onBlur}
onInput={onInput}
value={title}
onKeyUp={onKeyUp}
spellCheck={false}
ref={inputRef}
/>
<div className="count">{noteCounts.get()}</div>
</div>
) : null}
<div className={`meta ${hasAtLeastOneFolder ? 'with-folders' : ''}`}>
{tag.conflictOf && (
<div className="danger small-text font-bold">
Conflicted Copy {tag.conflictOf}
</div>
)}
{isEditing && (
<a className="item" onClick={onClickSave}>
Save
</a>
{tag.errorDecrypting && !tag.waitingForKey && (
<div className="danger small-text font-bold">Missing Keys</div>
)}
{tag.errorDecrypting && tag.waitingForKey && (
<div className="info small-text font-bold">Waiting For Keys</div>
)}
{isSelected && (
<div className="menu">
{!isEditing && (
<a className="item" onClick={onClickRename}>
Rename
</a>
)}
{isEditing && (
<a className="item" onClick={onClickSave}>
Save
</a>
)}
<a className="item" onClick={onClickDelete}>
Delete
</a>
</div>
)}
<a className="item" onClick={onClickDelete}>
Delete
</a>
</div>
</div>
{showChildren && (
<>
{childrenTags.map((tag) => {
return (
<TagsListItem
level={level + 1}
key={tag.uuid}
tag={tag}
tagsState={tagsState}
selectTag={selectTag}
saveTag={saveTag}
removeTag={removeTag}
appState={appState}
/>
);
})}
</>
)}
</div>
</>
);
}
);

View File

@@ -1,7 +1,6 @@
import { FunctionComponent, h, render } from 'preact';
import { unmountComponentAtNode } from 'preact/compat';
import { StateUpdater, useCallback, useState } from 'preact/hooks';
import { useEffect } from 'react';
import { StateUpdater, useCallback, useState, useEffect } from 'preact/hooks';
/**
* @returns a callback that will close a dropdown if none of its children has

View File

@@ -1,64 +0,0 @@
import { WebApplication } from '@/ui_models/application';
import { SNComponent, LiveItem } from '@standardnotes/snjs';
import { WebDirective } from './../../types';
import template from '%/directives/component-modal.pug';
export type ComponentModalScope = {
componentUuid: string
onDismiss: () => void
application: WebApplication
}
export class ComponentModalCtrl implements ComponentModalScope {
$element: JQLite
componentUuid!: string
onDismiss!: () => void
application!: WebApplication
liveComponent!: LiveItem<SNComponent>
component!: SNComponent
/* @ngInject */
constructor($element: JQLite) {
this.$element = $element;
}
$onInit() {
this.liveComponent = new LiveItem(
this.componentUuid,
this.application,
(component) => {
this.component = component;
}
);
this.application.componentGroup.activateComponent(this.component);
}
$onDestroy() {
this.application.componentGroup.deactivateComponent(this.component);
this.liveComponent.deinit();
}
dismiss() {
this.onDismiss && this.onDismiss();
const elem = this.$element;
const scope = elem.scope();
scope.$destroy();
elem.remove();
}
}
export class ComponentModal extends WebDirective {
constructor() {
super();
this.restrict = 'E';
this.template = template;
this.controller = ComponentModalCtrl;
this.controllerAs = 'ctrl';
this.bindToController = true;
this.scope = {
componentUuid: '=',
onDismiss: '&',
application: '='
};
}
}

View File

@@ -1,5 +1,4 @@
export { ActionsMenu } from './actionsMenu';
export { ComponentModal } from './componentModal';
export { EditorMenu } from './editorMenu';
export { InputModal } from './inputModal';
export { MenuRow } from './menuRow';

View File

@@ -3,14 +3,14 @@ import angular from 'angular';
import template from '%/directives/panel-resizer.pug';
import { debounce } from '@/utils';
enum PanelSide {
export enum PanelSide {
Right = 'right',
Left = 'left'
Left = 'left',
}
enum MouseEventType {
Move = 'mousemove',
Down = 'mousedown',
Up = 'mouseup'
Up = 'mouseup',
}
enum CssClass {
Hoverable = 'hoverable',
@@ -22,64 +22,63 @@ enum CssClass {
}
const WINDOW_EVENT_RESIZE = 'resize';
type ResizeFinishCallback = (
export type ResizeFinishCallback = (
lastWidth: number,
lastLeft: number,
isMaxWidth: boolean,
isCollapsed: boolean
) => void
) => void;
interface PanelResizerScope {
alwaysVisible: boolean
collapsable: boolean
control: PanelPuppet
defaultWidth: number
hoverable: boolean
index: number
minWidth: number
onResizeFinish: () => ResizeFinishCallback
onWidthEvent?: () => void
panelId: string
property: PanelSide
alwaysVisible: boolean;
collapsable: boolean;
control: PanelPuppet;
defaultWidth: number;
hoverable: boolean;
index: number;
minWidth: number;
onResizeFinish: () => ResizeFinishCallback;
onWidthEvent?: () => void;
panelId: string;
property: PanelSide;
}
class PanelResizerCtrl implements PanelResizerScope {
/** @scope */
alwaysVisible!: boolean
collapsable!: boolean
control!: PanelPuppet
defaultWidth!: number
hoverable!: boolean
index!: number
minWidth!: number
onResizeFinish!: () => ResizeFinishCallback
onWidthEvent?: () => () => void
panelId!: string
property!: PanelSide
alwaysVisible!: boolean;
collapsable!: boolean;
control!: PanelPuppet;
defaultWidth!: number;
hoverable!: boolean;
index!: number;
minWidth!: number;
onResizeFinish!: () => ResizeFinishCallback;
onWidthEvent?: () => () => void;
panelId!: string;
property!: PanelSide;
$compile: ng.ICompileService
$element: JQLite
$timeout: ng.ITimeoutService
panel!: HTMLElement
resizerColumn!: HTMLElement
currentMinWidth = 0
pressed = false
startWidth = 0
lastDownX = 0
collapsed = false
lastWidth = 0
startLeft = 0
lastLeft = 0
appFrame?: DOMRect
widthBeforeLastDblClick = 0
overlay?: JQLite
$compile: ng.ICompileService;
$element: JQLite;
$timeout: ng.ITimeoutService;
panel!: HTMLElement;
resizerColumn!: HTMLElement;
currentMinWidth = 0;
pressed = false;
startWidth = 0;
lastDownX = 0;
collapsed = false;
lastWidth = 0;
startLeft = 0;
lastLeft = 0;
appFrame?: DOMRect;
widthBeforeLastDblClick = 0;
overlay?: JQLite;
/* @ngInject */
constructor(
$compile: ng.ICompileService,
$element: JQLite,
$timeout: ng.ITimeoutService,
$timeout: ng.ITimeoutService
) {
this.$compile = $compile;
this.$element = $element;
@@ -109,7 +108,10 @@ class PanelResizerCtrl implements PanelResizerScope {
window.removeEventListener(WINDOW_EVENT_RESIZE, this.handleResize);
document.removeEventListener(MouseEventType.Move, this.onMouseMove);
document.removeEventListener(MouseEventType.Up, this.onMouseUp);
this.resizerColumn.removeEventListener(MouseEventType.Down, this.onMouseDown);
this.resizerColumn.removeEventListener(
MouseEventType.Down,
this.onMouseDown
);
(this.handleResize as any) = undefined;
(this.onMouseMove as any) = undefined;
(this.onMouseUp as any) = undefined;
@@ -140,7 +142,7 @@ class PanelResizerCtrl implements PanelResizerScope {
return;
}
this.resizerColumn = this.$element[0];
this.currentMinWidth = this.minWidth || (this.resizerColumn.offsetWidth + 2);
this.currentMinWidth = this.minWidth || this.resizerColumn.offsetWidth + 2;
this.pressed = false;
this.startWidth = this.panel.scrollWidth;
this.lastDownX = 0;
@@ -313,7 +315,8 @@ class PanelResizerCtrl implements PanelResizerScope {
width = parentRect.width;
}
const maxWidth = this.appFrame!.width - this.panel.getBoundingClientRect().x;
const maxWidth =
this.appFrame!.width - this.panel.getBoundingClientRect().x;
if (width > maxWidth) {
width = maxWidth;
}
@@ -356,7 +359,9 @@ class PanelResizerCtrl implements PanelResizerScope {
if (this.overlay) {
return;
}
this.overlay = this.$compile(`<div id='resizer-overlay'></div>`)(this as any);
this.overlay = this.$compile(`<div id='resizer-overlay'></div>`)(
this as any
);
angular.element(document.body).prepend(this.overlay);
}
@@ -395,7 +400,7 @@ export class PanelResizer extends WebDirective {
onResizeFinish: '&',
onWidthEvent: '&',
panelId: '=',
property: '='
property: '=',
};
}
}

View File

@@ -2,9 +2,8 @@ import { WebDirective } from './../../types';
import template from '%/directives/permissions-modal.pug';
class PermissionsModalCtrl {
$element: JQLite
callback!: (success: boolean) => void
$element: JQLite;
callback!: (success: boolean) => void;
/* @ngInject */
constructor($element: JQLite) {
@@ -41,7 +40,7 @@ export class PermissionsModal extends WebDirective {
show: '=',
component: '=',
permissionsString: '=',
callback: '='
callback: '=',
};
}
}

View File

@@ -1,41 +1,38 @@
import { ComponentViewer } from '@standardnotes/snjs/dist/@types';
import { PureViewCtrl } from './../../views/abstract/pure_view_ctrl';
import { WebApplication } from '@/ui_models/application';
import { WebDirective } from './../../types';
import {
ContentType,
PayloadSource,
SNComponent,
SNNote,
ComponentArea
} from '@standardnotes/snjs';
import { ContentType, PayloadSource, SNNote } from '@standardnotes/snjs';
import template from '%/directives/revision-preview-modal.pug';
import { PayloadContent } from '@standardnotes/snjs';
import { confirmDialog } from '@/services/alertService';
import { STRING_RESTORE_LOCKED_ATTEMPT } from '@/strings';
interface RevisionPreviewScope {
uuid: string
content: PayloadContent
application: WebApplication
uuid: string;
content: PayloadContent;
application: WebApplication;
}
class RevisionPreviewModalCtrl extends PureViewCtrl implements RevisionPreviewScope {
type State = {
componentViewer?: ComponentViewer;
};
$element: JQLite
$timeout: ng.ITimeoutService
uuid!: string
content!: PayloadContent
title?: string
application!: WebApplication
unregisterComponent?: any
note!: SNNote
class RevisionPreviewModalCtrl
extends PureViewCtrl<unknown, State>
implements RevisionPreviewScope
{
$element: JQLite;
$timeout: ng.ITimeoutService;
uuid!: string;
content!: PayloadContent;
title?: string;
application!: WebApplication;
note!: SNNote;
private originalNote!: SNNote;
/* @ngInject */
constructor(
$element: JQLite,
$timeout: ng.ITimeoutService
) {
constructor($element: JQLite, $timeout: ng.ITimeoutService) {
super($timeout);
this.$element = $element;
this.$timeout = $timeout;
@@ -43,53 +40,36 @@ class RevisionPreviewModalCtrl extends PureViewCtrl implements RevisionPreviewSc
$onInit() {
this.configure();
super.$onInit();
}
$onDestroy() {
if (this.unregisterComponent) {
this.unregisterComponent();
this.unregisterComponent = undefined;
if (this.state.componentViewer) {
this.application.componentManager.destroyComponentViewer(
this.state.componentViewer
);
}
super.$onDestroy();
}
get componentManager() {
return this.application.componentManager!;
return this.application.componentManager;
}
async configure() {
this.note = await this.application.createTemplateItem(
this.note = (await this.application.createTemplateItem(
ContentType.Note,
this.content
) as SNNote;
)) as SNNote;
this.originalNote = this.application.findItem(this.uuid) as SNNote;
const editorForNote = this.componentManager.editorForNote(this.originalNote);
if (editorForNote) {
/**
* Create temporary copy, as a lot of componentManager is uuid based, so might
* interfere with active editor. Be sure to copy only the content, as the top level
* editor object has non-copyable properties like .window, which cannot be transfered
*/
const editorCopy = await this.application.createTemplateItem(
ContentType.Component,
editorForNote.safeContent
) as SNComponent;
this.componentManager.setReadonlyStateForComponent(editorCopy, true, true);
this.unregisterComponent = this.componentManager.registerHandler({
identifier: editorCopy.uuid,
areas: [ComponentArea.Editor],
contextRequestHandler: (componentUuid) => {
if (componentUuid === this.state.editor?.uuid) {
return this.note;
}
},
componentForSessionKeyHandler: (key) => {
if (key === this.componentManager.sessionKeyForComponent(this.state.editor!)) {
return this.state.editor;
}
}
});
this.setState({editor: editorCopy});
const component = this.componentManager.editorForNote(this.originalNote);
if (component) {
const componentViewer =
this.application.componentManager.createComponentViewer(component);
componentViewer.setReadonly(true);
componentViewer.lockReadonly = true;
componentViewer.overrideContextItem = this.note;
this.setState({ componentViewer });
}
}
@@ -98,12 +78,19 @@ class RevisionPreviewModalCtrl extends PureViewCtrl implements RevisionPreviewSc
if (asCopy) {
await this.application.duplicateItem(this.originalNote, {
...this.content,
title: this.content.title ? this.content.title + ' (copy)' : undefined
title: this.content.title
? this.content.title + ' (copy)'
: undefined,
});
} else {
this.application.changeAndSaveItem(this.uuid, (mutator) => {
mutator.unsafe_setCustomContent(this.content);
}, true, PayloadSource.RemoteActionRetrieved);
this.application.changeAndSaveItem(
this.uuid,
(mutator) => {
mutator.unsafe_setCustomContent(this.content);
},
true,
PayloadSource.RemoteActionRetrieved
);
}
this.dismiss();
};
@@ -115,7 +102,7 @@ class RevisionPreviewModalCtrl extends PureViewCtrl implements RevisionPreviewSc
}
confirmDialog({
text: "Are you sure you want to replace the current note's contents with what you see in this preview?",
confirmButtonStyle: "danger"
confirmButtonStyle: 'danger',
}).then((confirmed) => {
if (confirmed) {
run();
@@ -146,7 +133,7 @@ export class RevisionPreviewModal extends WebDirective {
uuid: '=',
content: '=',
title: '=',
application: '='
application: '=',
};
}
}

View File

@@ -6,7 +6,6 @@ import '../stylesheets/index.css.scss';
// Vendor
import 'angular';
import '../../../vendor/assets/javascripts/angular-sanitize';
import '../../../vendor/assets/javascripts/zip/deflate';
import '../../../vendor/assets/javascripts/zip/inflate';
import '../../../vendor/assets/javascripts/zip/zip';

View File

@@ -1,10 +1,13 @@
const pathsToModuleNameMapper = require('ts-jest/utils').pathsToModuleNameMapper;
const pathsToModuleNameMapper =
require('ts-jest/utils').pathsToModuleNameMapper;
const tsConfig = require('./tsconfig.json');
const pathsFromTsconfig = tsConfig.compilerOptions.paths;
module.exports = {
restoreMocks: true,
clearMocks: true,
resetMocks: true,
moduleNameMapper: {
...pathsToModuleNameMapper(pathsFromTsconfig, {
prefix: '<rootDir>',
@@ -14,7 +17,6 @@ module.exports = {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
globals: {
window: {},
__VERSION__: '1.0.0',
__DESKTOP__: false,
__WEB__: true,

View File

@@ -0,0 +1,115 @@
import { FunctionComponent } from 'preact';
import {
Title,
Subtitle,
Text,
LinkButton,
PreferencesGroup,
PreferencesPane,
PreferencesSegment,
} from '../components';
export const CloudLink: FunctionComponent = () => (
<PreferencesPane>
<PreferencesGroup>
<PreferencesSegment>
<Title>Frequently asked questions</Title>
<div className="h-2 w-full" />
<Subtitle>Who can read my private notes?</Subtitle>
<Text>
Quite simply: no one but you. Not us, not your ISP, not a hacker, and
not a government agency. As long as you keep your password safe, and
your password is reasonably strong, then you are the only person in
the world with the ability to decrypt your notes. For more on how we
handle your privacy and security, check out our easy to read{' '}
<a target="_blank" href="https://standardnotes.com/privacy">
Privacy Manifesto.
</a>
</Text>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>Can I collaborate with others on a note?</Subtitle>
<Text>
Because of our encrypted architecture, Standard Notes does not
currently provide a real-time collaboration solution. Multiple users
can share the same account however, but editing at the same time may
result in sync conflicts, which may result in the duplication of
notes.
</Text>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>Can I use Standard Notes totally offline?</Subtitle>
<Text>
Standard Notes can be used totally offline without an account, and
without an internet connection. You can find{' '}
<a
target="_blank"
href="https://standardnotes.com/help/59/can-i-use-standard-notes-totally-offline"
>
more details here.
</a>
</Text>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>Cant find your question here?</Subtitle>
<LinkButton
className="mt-3"
label="Open FAQ"
link="https://standardnotes.com/help"
/>
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>Community forum</Title>
<Text>
If you have an issue, found a bug or want to suggest a feature, you
can browse or post to the forum. Its recommended for non-account
related issues. Please read our{' '}
<a target="_blank" href="https://standardnotes.com/longevity/">
Longevity statement
</a>{' '}
before advocating for a feature request.
</Text>
<LinkButton
className="mt-3"
label="Go to the forum"
link="https://forum.standardnotes.org/"
/>
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>Community groups</Title>
<Text>
Want to meet other passionate note-takers and privacy enthusiasts?
Want to share your feedback with us? Join the Standard Notes community
groups for discussions on security, themes, editors and more.
</Text>
<LinkButton
className="mt-3"
link="https://standardnotes.com/slack"
label="Join our Slack"
/>
<LinkButton
className="mt-3"
link="https://standardnotes.com/discord"
label="Join our Discord"
/>
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>Account related issue?</Title>
<Text>
Send an email to help@standardnotes.com and well sort it out.
</Text>
<LinkButton
className="mt-3"
link="mailto: help@standardnotes.com"
label="Email us"
/>
</PreferencesSegment>
</PreferencesGroup>
</PreferencesPane>
);

View File

@@ -1,12 +1,13 @@
import { PreferencesGroup, PreferencesSegment } from "@/preferences/components";
import { WebApplication } from "@/ui_models/application";
import { SNComponent } from "@standardnotes/snjs/dist/@types";
import { observer } from "mobx-react-lite";
import { FunctionComponent } from "preact";
import { ExtensionItem } from "./extensions-segments";
import { PreferencesGroup, PreferencesSegment } from '@/preferences/components';
import { WebApplication } from '@/ui_models/application';
import { ComponentViewer, SNComponent } from '@standardnotes/snjs/dist/@types';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { ExtensionItem } from './extensions-segments';
import { ComponentView } from '@/components/ComponentView';
import { AppState } from '@/ui_models/app_state';
import { PreferencesMenu } from '@/preferences/PreferencesMenu';
import { useEffect, useState } from 'preact/hooks';
interface IProps {
application: WebApplication;
@@ -17,7 +18,17 @@ interface IProps {
export const ExtensionPane: FunctionComponent<IProps> = observer(
({ extension, application, appState, preferencesMenu }) => {
const latestVersion = preferencesMenu.extensionsLatestVersions.getVersion(extension);
const [componentViewer] = useState<ComponentViewer>(
application.componentManager.createComponentViewer(extension)
);
const latestVersion =
preferencesMenu.extensionsLatestVersions.getVersion(extension);
useEffect(() => {
return () => {
application.componentManager.destroyComponentViewer(componentViewer);
};
}, [application, componentViewer]);
return (
<div className="preferences-extension-pane color-foreground flex-grow flex flex-row overflow-y-auto min-h-0">
@@ -28,15 +39,18 @@ export const ExtensionPane: FunctionComponent<IProps> = observer(
application={application}
extension={extension}
first={false}
uninstall={() => application.deleteItem(extension).then(() => preferencesMenu.loadExtensionsPanes())}
toggleActivate={() => application.toggleComponent(extension).then(() => preferencesMenu.loadExtensionsPanes())}
uninstall={() =>
application
.deleteItem(extension)
.then(() => preferencesMenu.loadExtensionsPanes())
}
latestVersion={latestVersion}
/>
<PreferencesSegment>
<ComponentView
application={application}
appState={appState}
componentUuid={extension.uuid}
componentViewer={componentViewer}
/>
</PreferencesSegment>
</PreferencesGroup>
@@ -44,4 +58,5 @@ export const ExtensionPane: FunctionComponent<IProps> = observer(
</div>
</div>
);
});
}
);

View File

@@ -77,11 +77,6 @@ export const Extensions: FunctionComponent<{
setExtensions(loadExtensions(application));
};
const toggleActivateExtension = (extension: SNComponent) => {
application.toggleComponent(extension);
setExtensions(loadExtensions(application));
};
const visibleExtensions = extensions.filter((extension) => {
return (
extension.package_info != undefined &&
@@ -105,7 +100,6 @@ export const Extensions: FunctionComponent<{
latestVersion={extensionsLatestVersions.getVersion(extension)}
first={i === 0}
uninstall={uninstallExtension}
toggleActivate={toggleActivateExtension}
/>
))}
</div>

View File

@@ -8,7 +8,7 @@ import { Button } from '@/components/Button';
import { SyncQueueStrategy, dateToLocalizedString } from '@standardnotes/snjs';
import { STRING_GENERIC_SYNC_ERROR } from '@/strings';
import { useState } from '@node_modules/preact/hooks';
import { observer } from '@node_modules/mobx-react-lite';
import { observer } from 'mobx-react-lite';
import { WebApplication } from '@/ui_models/application';
import { FunctionComponent } from 'preact';

View File

@@ -4,7 +4,12 @@ import { useCallback, useState } from 'preact/hooks';
import { useEffect } from 'preact/hooks';
import { ApplicationEvent } from '@standardnotes/snjs';
import { isSameDay } from '@/utils';
import { PreferencesGroup, PreferencesSegment, Title, Text } from '@/preferences/components';
import {
PreferencesGroup,
PreferencesSegment,
Title,
Text,
} from '@/preferences/components';
import { Button } from '@/components/Button';
type Props = {
@@ -16,7 +21,9 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
application.clearProtectionSession();
};
const [hasProtections, setHasProtections] = useState(() => application.hasProtectionSources());
const [hasProtections, setHasProtections] = useState(() =>
application.hasProtectionSources()
);
const getProtectionsDisabledUntil = useCallback((): string | null => {
const protectionExpiry = application.getProtectionSessionExpiryDate();
@@ -26,7 +33,7 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
if (isSameDay(protectionExpiry, now)) {
f = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric'
minute: 'numeric',
});
} else {
f = new Intl.DateTimeFormat(undefined, {
@@ -34,7 +41,7 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: 'numeric'
minute: 'numeric',
});
}
@@ -43,14 +50,23 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
return null;
}, [application]);
const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(getProtectionsDisabledUntil());
const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(
getProtectionsDisabledUntil()
);
useEffect(() => {
const removeProtectionSessionExpiryDateChangedObserver = application.addEventObserver(
const removeUnprotectedSessionBeginObserver = application.addEventObserver(
async () => {
setProtectionsDisabledUntil(getProtectionsDisabledUntil());
},
ApplicationEvent.ProtectionSessionExpiryDateChanged
ApplicationEvent.UnprotectedSessionBegan
);
const removeUnprotectedSessionEndObserver = application.addEventObserver(
async () => {
setProtectionsDisabledUntil(getProtectionsDisabledUntil());
},
ApplicationEvent.UnprotectedSessionExpired
);
const removeKeyStatusChangedObserver = application.addEventObserver(
@@ -61,7 +77,8 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
);
return () => {
removeProtectionSessionExpiryDateChangedObserver();
removeUnprotectedSessionBeginObserver();
removeUnprotectedSessionEndObserver();
removeKeyStatusChangedObserver();
};
}, [application, getProtectionsDisabledUntil]);
@@ -74,19 +91,28 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
<PreferencesGroup>
<PreferencesSegment>
<Title>Protections</Title>
{protectionsDisabledUntil
? <Text className="info">Protections are disabled until {protectionsDisabledUntil}.</Text>
: <Text className="info">Protections are enabled.</Text>
}
{protectionsDisabledUntil ? (
<Text className="info">
Unprotected access expires at {protectionsDisabledUntil}.
</Text>
) : (
<Text className="info">Protections are enabled.</Text>
)}
<Text className="mt-2">
Actions like viewing protected notes, exporting decrypted backups,
or revoking an active session, require additional authentication
like entering your account password or application passcode.
Actions like viewing or searching protected notes, exporting decrypted
backups, or revoking an active session require additional
authentication such as entering your account password or application
passcode.
</Text>
{protectionsDisabledUntil &&
<Button className="mt-3" type="primary" label="Enable Protections" onClick={enableProtections} />
}
{protectionsDisabledUntil && (
<Button
className="mt-3"
type="primary"
label="End Unprotected Access"
onClick={enableProtections}
/>
)}
</PreferencesSegment>
</PreferencesGroup >
</PreferencesGroup>
);
};

View File

@@ -5,7 +5,8 @@ import {
DisclosurePanel,
} from '@reach/disclosure';
import { FunctionComponent } from 'preact';
import { useState, useRef, useEffect, MouseEventHandler } from 'react';
import { MouseEventHandler } from 'react';
import { useState, useRef, useEffect } from 'preact/hooks';
const DisclosureIconButton: FunctionComponent<{
className?: string;
@@ -16,8 +17,9 @@ const DisclosureIconButton: FunctionComponent<{
<DisclosureButton
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={`no-border cursor-pointer bg-transparent hover:brightness-130 p-0 ${className ?? ''
}`}
className={`no-border cursor-pointer bg-transparent hover:brightness-130 p-0 ${
className ?? ''
}`}
>
<Icon type={icon} />
</DisclosureButton>

View File

@@ -34,7 +34,7 @@ export const PurchaseFlowView: FunctionComponent<PurchaseFlowViewProps> =
const { currentPane } = appState.purchaseFlow;
return (
<div className="flex items-center justify-center overflow-hidden h-full w-full absolute top-left-0 z-index-purchase-flow bg-grey-2">
<div className="flex items-center justify-center overflow-hidden h-full w-full absolute top-left-0 z-index-purchase-flow bg-grey-super-light">
<div className="relative fit-content">
<div className="relative p-12 xs:px-8 mb-4 bg-default border-1 border-solid border-main rounded xs:rounded-0">
<SNLogoFull className="mb-5" />

View File

@@ -1,40 +1,34 @@
import {
SNComponent,
PurePayload,
ComponentMutator,
AppDataField,
EncryptionIntent,
ApplicationService,
ApplicationEvent,
removeFromArray,
BackupFile,
DesktopManagerInterface,
} from '@standardnotes/snjs';
/* eslint-disable camelcase */
import { WebApplication } from '@/ui_models/application';
// An interface used by the Desktop app to interact with SN
import { isDesktopApplication } from '@/utils';
import { Bridge } from './bridge';
type UpdateObserverCallback = (component: SNComponent) => void;
type ComponentActivationCallback = (payload: PurePayload) => void;
type ComponentActivationObserver = {
id: string;
callback: ComponentActivationCallback;
};
export class DesktopManager extends ApplicationService {
/**
* An interface used by the Desktop application to interact with SN
*/
export class DesktopManager
extends ApplicationService
implements DesktopManagerInterface
{
$rootScope: ng.IRootScopeService;
$timeout: ng.ITimeoutService;
componentActivationObservers: ComponentActivationObserver[] = [];
updateObservers: {
callback: UpdateObserverCallback;
callback: (component: SNComponent) => void;
}[] = [];
isDesktop = isDesktopApplication();
dataLoaded = false;
lastSearchedText?: string;
private removeComponentObserver?: () => void;
constructor(
$rootScope: ng.IRootScopeService,
@@ -52,10 +46,7 @@ export class DesktopManager extends ApplicationService {
}
deinit() {
this.componentActivationObservers.length = 0;
this.updateObservers.length = 0;
this.removeComponentObserver?.();
this.removeComponentObserver = undefined;
super.deinit();
}
@@ -73,9 +64,9 @@ export class DesktopManager extends ApplicationService {
this.bridge.onMajorDataChange();
}
getExtServerHost() {
getExtServerHost(): string {
console.assert(!!this.bridge.extensionsServerHost, 'extServerHost is null');
return this.bridge.extensionsServerHost;
return this.bridge.extensionsServerHost!;
}
/**
@@ -83,7 +74,7 @@ export class DesktopManager extends ApplicationService {
* Keys are not passed into ItemParams, so the result is not encrypted
*/
convertComponentForTransmission(component: SNComponent) {
return this.application!.protocolService!.payloadByEncryptingPayload(
return this.application.protocolService!.payloadByEncryptingPayload(
component.payloadRepresentation(),
EncryptionIntent.FileDecrypted
);
@@ -107,7 +98,7 @@ export class DesktopManager extends ApplicationService {
});
}
registerUpdateObserver(callback: UpdateObserverCallback) {
registerUpdateObserver(callback: (component: SNComponent) => void) {
const observer = {
callback: callback,
};
@@ -143,11 +134,11 @@ export class DesktopManager extends ApplicationService {
componentData: any,
error: any
) {
const component = this.application!.findItem(componentData.uuid);
const component = this.application.findItem(componentData.uuid);
if (!component) {
return;
}
const updatedComponent = await this.application!.changeAndSaveItem(
const updatedComponent = await this.application.changeAndSaveItem(
component.uuid,
(m) => {
const mutator = m as ComponentMutator;
@@ -168,34 +159,8 @@ export class DesktopManager extends ApplicationService {
});
}
desktop_registerComponentActivationObserver(
callback: ComponentActivationCallback
) {
const observer = { id: `${Math.random}`, callback: callback };
this.componentActivationObservers.push(observer);
return observer;
}
desktop_deregisterComponentActivationObserver(
observer: ComponentActivationObserver
) {
removeFromArray(this.componentActivationObservers, observer);
}
/* Notify observers that a component has been registered/activated */
async notifyComponentActivation(component: SNComponent) {
const serializedComponent = await this.convertComponentForTransmission(
component
);
this.$timeout(() => {
for (const observer of this.componentActivationObservers) {
observer.callback(serializedComponent);
}
});
}
async desktop_requestBackupFile() {
const data = await this.application!.createBackupFile(
const data = await this.application.createBackupFile(
this.application.hasProtectionSources()
? EncryptionIntent.FileEncrypted
: EncryptionIntent.FileDecrypted

View File

@@ -4,6 +4,7 @@ export enum KeyboardKey {
Backspace = 'Backspace',
Up = 'ArrowUp',
Down = 'ArrowDown',
Enter = 'Enter',
}
export enum KeyboardModifier {
@@ -51,7 +52,9 @@ export class IOService {
(this.handleWindowBlur as unknown) = undefined;
}
private addActiveModifier = (modifier: KeyboardModifier | undefined): void => {
private addActiveModifier = (
modifier: KeyboardModifier | undefined
): void => {
if (!modifier) {
return;
}
@@ -73,14 +76,16 @@ export class IOService {
break;
}
}
}
};
private removeActiveModifier = (modifier: KeyboardModifier | undefined): void => {
private removeActiveModifier = (
modifier: KeyboardModifier | undefined
): void => {
if (!modifier) {
return;
}
this.activeModifiers.delete(modifier);
}
};
handleKeyDown = (event: KeyboardEvent): void => {
for (const modifier of this.modifiersForEvent(event)) {
@@ -91,7 +96,7 @@ export class IOService {
handleComponentKeyDown = (modifier: KeyboardModifier | undefined): void => {
this.addActiveModifier(modifier);
}
};
handleKeyUp = (event: KeyboardEvent): void => {
for (const modifier of this.modifiersForEvent(event)) {
@@ -102,7 +107,7 @@ export class IOService {
handleComponentKeyUp = (modifier: KeyboardModifier | undefined): void => {
this.removeActiveModifier(modifier);
}
};
handleWindowBlur = (): void => {
for (const modifier of this.activeModifiers) {

View File

@@ -7,12 +7,14 @@ import {
removeFromArray,
ApplicationEvent,
ContentType,
UuidString,
FeatureStatus,
} from '@standardnotes/snjs';
const CACHED_THEMES_KEY = 'cachedThemes';
export class ThemeManager extends ApplicationService {
private activeThemes: string[] = [];
private activeThemes: UuidString[] = [];
private unregisterDesktop!: () => void;
private unregisterStream!: () => void;
@@ -22,6 +24,8 @@ export class ThemeManager extends ApplicationService {
this.deactivateAllThemes();
} else if (event === ApplicationEvent.StorageReady) {
await this.activateCachedThemes();
} else if (event === ApplicationEvent.FeaturesUpdated) {
this.reloadThemeStatus();
}
}
@@ -34,11 +38,24 @@ export class ThemeManager extends ApplicationService {
this.activeThemes.length = 0;
this.unregisterDesktop();
this.unregisterStream();
(this.unregisterDesktop as any) = undefined;
(this.unregisterStream as any) = undefined;
(this.unregisterDesktop as unknown) = undefined;
(this.unregisterStream as unknown) = undefined;
super.deinit();
}
reloadThemeStatus(): void {
for (const themeUuid of this.activeThemes) {
const theme = this.application.findItem(themeUuid) as SNTheme;
if (
!theme ||
this.application.getFeatureStatus(theme.identifier) !==
FeatureStatus.Entitled
) {
this.deactivateTheme(themeUuid);
}
}
}
/** @override */
async onAppStart() {
super.onAppStart();
@@ -99,7 +116,11 @@ export class ThemeManager extends ApplicationService {
return;
}
this.activeThemes.push(theme.uuid);
const url = this.application!.componentManager!.urlForComponent(theme)!;
const url = this.application.componentManager.urlForComponent(theme);
if (!url) {
return;
}
const link = document.createElement('link');
link.href = url;
link.type = 'text/css';
@@ -125,19 +146,19 @@ export class ThemeManager extends ApplicationService {
}
private async cacheThemes() {
const themes = this.application!.getAll(this.activeThemes) as SNTheme[];
const themes = this.application.getAll(this.activeThemes) as SNTheme[];
const mapped = await Promise.all(
themes.map(async (theme) => {
const payload = theme.payloadRepresentation();
const processedPayload =
await this.application!.protocolService!.payloadByEncryptingPayload(
await this.application.protocolService.payloadByEncryptingPayload(
payload,
EncryptionIntent.LocalStorageDecrypted
);
return processedPayload;
})
);
return this.application!.setValue(
return this.application.setValue(
CACHED_THEMES_KEY,
mapped,
StorageValueModes.Nonwrapped
@@ -154,15 +175,15 @@ export class ThemeManager extends ApplicationService {
}
private async getCachedThemes() {
const cachedThemes = (await this.application!.getValue(
const cachedThemes = (await this.application.getValue(
CACHED_THEMES_KEY,
StorageValueModes.Nonwrapped
)) as SNTheme[];
if (cachedThemes) {
const themes = [];
for (const cachedTheme of cachedThemes) {
const payload = this.application!.createPayloadFromObject(cachedTheme);
const theme = this.application!.createItemFromPayload(
const payload = this.application.createPayloadFromObject(cachedTheme);
const theme = this.application.createItemFromPayload(
payload
) as SNTheme;
themes.push(theme);

View File

@@ -0,0 +1,67 @@
/* eslint-disable @typescript-eslint/ban-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
// Type definitions for hoist-non-react-statics 3.3
// Project: https://github.com/mridgway/hoist-non-react-statics#readme
// Definitions by: JounQin <https://github.com/JounQin>, James Reggio <https://github.com/jamesreggio>
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
// TypeScript Version: 2.8
// https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/hoist-non-react-statics
declare module 'hoist-non-react-statics' {
interface REACT_STATICS {
childContextTypes: true;
contextType: true;
contextTypes: true;
defaultProps: true;
displayName: true;
getDefaultProps: true;
getDerivedStateFromError: true;
getDerivedStateFromProps: true;
mixins: true;
propTypes: true;
type: true;
}
interface KNOWN_STATICS {
name: true;
length: true;
prototype: true;
caller: true;
callee: true;
arguments: true;
arity: true;
}
interface MEMO_STATICS {
$$typeof: true;
compare: true;
defaultProps: true;
displayName: true;
propTypes: true;
type: true;
}
interface FORWARD_REF_STATICS {
$$typeof: true;
render: true;
defaultProps: true;
displayName: true;
propTypes: true;
}
export type NonReactStatics<
S extends React.ComponentType<any>,
C extends {
[key: string]: true;
} = {}
> = {
[key in Exclude<
keyof S,
S extends React.MemoExoticComponent<any>
? keyof MEMO_STATICS | keyof C
: S extends React.ForwardRefExoticComponent<any>
? keyof FORWARD_REF_STATICS | keyof C
: keyof REACT_STATICS | keyof KNOWN_STATICS | keyof C
>]: S[key];
};
}

View File

@@ -22,7 +22,9 @@ import {
runInAction,
} from 'mobx';
import { ActionsMenuState } from './actions_menu_state';
import { FeaturesState } from './features_state';
import { NotesState } from './notes_state';
import { NotesViewState } from './notes_view_state';
import { NoteTagsState } from './note_tags_state';
import { NoAccountWarningState } from './no_account_warning_state';
import { PreferencesState } from './preferences_state';
@@ -56,8 +58,8 @@ export enum EventSource {
type ObserverCallback = (event: AppStateEvent, data?: any) => Promise<void>;
export class AppState {
readonly enableUnfinishedFeatures: boolean = (window as any)
?._enable_unfinished_features;
readonly enableUnfinishedFeatures: boolean =
window?._enable_unfinished_features;
$rootScope: ng.IRootScopeService;
$timeout: ng.ITimeoutService;
@@ -75,6 +77,8 @@ export class AppState {
editingTag: SNTag | undefined;
_templateTag: SNTag | undefined;
private multiEditorSupport = false;
readonly quickSettingsMenu = new QuickSettingsState();
readonly accountMenu: AccountMenuState;
readonly actionsMenu = new ActionsMenuState();
@@ -85,7 +89,9 @@ export class AppState {
readonly sync = new SyncState();
readonly searchOptions: SearchOptionsState;
readonly notes: NotesState;
readonly features: FeaturesState;
readonly tags: TagsState;
readonly notesView: NotesViewState;
isSessionsModalVisible = false;
private appEventObserverRemovers: (() => void)[] = [];
@@ -113,7 +119,12 @@ export class AppState {
this,
this.appEventObserverRemovers
);
this.tags = new TagsState(application, this.appEventObserverRemovers);
this.features = new FeaturesState(application);
this.tags = new TagsState(
application,
this.appEventObserverRemovers,
this.features
);
this.noAccountWarning = new NoAccountWarningState(
application,
this.appEventObserverRemovers
@@ -127,6 +138,11 @@ export class AppState {
this.appEventObserverRemovers
);
this.purchaseFlow = new PurchaseFlowState(application);
this.notesView = new NotesViewState(
application,
this,
this.appEventObserverRemovers
);
this.addAppEventObserver();
this.streamNotesAndTags();
this.onVisibilityChange = () => {
@@ -180,6 +196,7 @@ export class AppState {
this.unsubApp = undefined;
this.observers.length = 0;
this.appEventObserverRemovers.forEach((remover) => remover());
this.features.deinit();
this.appEventObserverRemovers.length = 0;
if (this.rootScopeCleanup1) {
this.rootScopeCleanup1();
@@ -209,27 +226,21 @@ export class AppState {
storage.set(StorageKey.ShowBetaWarning, true);
}
/**
* Creates a new editor if one doesn't exist. If one does, we'll replace the
* editor's note with an empty one.
*/
async createEditor(title?: string) {
const activeEditor = this.getActiveEditor();
if (!this.multiEditorSupport) {
this.closeActiveEditor();
}
const activeTagUuid = this.selectedTag
? this.selectedTag.isSmartTag
? undefined
: this.selectedTag.uuid
: undefined;
if (!activeEditor) {
this.application.editorGroup.createEditor(
undefined,
title,
activeTagUuid
);
} else {
await activeEditor.reset(title, activeTagUuid);
}
await this.application.editorGroup.createEditor(
undefined,
title,
activeTagUuid
);
}
getActiveEditor() {
@@ -423,6 +434,10 @@ export class AppState {
}
public async createNewTag() {
if (this.templateTag) {
return;
}
const newTag = (await this.application.createTemplateItem(
ContentType.Tag
)) as SNTag;

View File

@@ -0,0 +1,87 @@
import {
ApplicationEvent,
FeatureIdentifier,
FeatureStatus,
} from '@standardnotes/snjs';
import { computed, makeObservable, observable, runInAction } from 'mobx';
import { WebApplication } from '../application';
export const TAG_FOLDERS_FEATURE_NAME = 'Tag folders';
export const TAG_FOLDERS_FEATURE_TOOLTIP =
'A Plus or Pro plan is required to enable Tag folders.';
/**
* Holds state for premium/non premium features for the current user features,
* and eventually for in-development features (feature flags).
*/
export class FeaturesState {
readonly enableUnfinishedFeatures: boolean =
window?._enable_unfinished_features;
_hasFolders = false;
private unsub: () => void;
constructor(private application: WebApplication) {
this._hasFolders = this.hasNativeFolders();
makeObservable(this, {
_hasFolders: observable,
hasFolders: computed,
enableNativeFoldersFeature: computed,
});
this.unsub = this.application.addEventObserver(async (eventName) => {
switch (eventName) {
case ApplicationEvent.FeaturesUpdated:
case ApplicationEvent.Launched:
runInAction(() => {
this._hasFolders = this.hasNativeFolders();
});
break;
default:
break;
}
});
}
public deinit() {
this.unsub();
}
public get enableNativeFoldersFeature(): boolean {
return this.enableUnfinishedFeatures;
}
public get hasFolders(): boolean {
return this._hasFolders;
}
public set hasFolders(hasFolders: boolean) {
if (!hasFolders) {
this._hasFolders = false;
return;
}
if (!this.hasNativeFolders()) {
this.application.alertService?.alert(
`${TAG_FOLDERS_FEATURE_NAME} requires at least a Plus Subscription.`
);
this._hasFolders = false;
return;
}
this._hasFolders = hasFolders;
}
private hasNativeFolders(): boolean {
if (!this.enableNativeFoldersFeature) {
return false;
}
const status = this.application.getFeatureStatus(
FeatureIdentifier.TagNesting
);
return status === FeatureStatus.Entitled;
}
}

View File

@@ -176,16 +176,9 @@ export class NoteTagsState {
async addTagToActiveNote(tag: SNTag): Promise<void> {
const { activeNote } = this;
if (activeNote) {
const parentChainTags = this.application.getTagParentChain(tag);
const tagsToAdd = [...parentChainTags, tag];
await Promise.all(
tagsToAdd.map(async (tag) => {
await this.application.changeItem(tag.uuid, (mutator) => {
mutator.addItemAsRelationship(activeNote);
});
})
);
await this.application.addTagHierarchyToNote(activeNote, tag);
this.application.sync();
this.reloadTags();
}

View File

@@ -28,7 +28,7 @@ export class NotesState {
top: 0,
left: 0,
};
contextMenuClickLocation: { x: number, y: number } = { x: 0, y: 0 };
contextMenuClickLocation: { x: number; y: number } = { x: 0, y: 0 };
contextMenuMaxHeight: number | 'auto' = 'auto';
showProtectedWarning = false;
@@ -167,12 +167,12 @@ export class NotesState {
return;
}
if (!this.activeEditor) {
this.application.editorGroup.createEditor(noteUuid);
} else {
this.activeEditor.setNote(note);
if (this.activeEditor) {
this.application.editorGroup.closeActiveEditor();
}
await this.application.editorGroup.createEditor(noteUuid);
this.appState.noteTags.reloadTags();
await this.onActiveEditorChanged();
@@ -185,7 +185,7 @@ export class NotesState {
this.contextMenuOpen = open;
}
setContextMenuClickLocation(location: { x: number, y: number }): void {
setContextMenuClickLocation(location: { x: number; y: number }): void {
this.contextMenuClickLocation = location;
}
@@ -212,7 +212,8 @@ export class NotesState {
// Open up-bottom is default behavior
let openUpBottom = true;
const bottomSpace = clientHeight - footerHeight - this.contextMenuClickLocation.y;
const bottomSpace =
clientHeight - footerHeight - this.contextMenuClickLocation.y;
const upSpace = this.contextMenuClickLocation.y;
// If not enough space to open up-bottom
@@ -220,26 +221,18 @@ export class NotesState {
// If there's enough space, open bottom-up
if (upSpace > maxContextMenuHeight) {
openUpBottom = false;
this.setContextMenuMaxHeight(
'auto'
);
// Else, reduce max height (menu will be scrollable) and open in whichever direction there's more space
this.setContextMenuMaxHeight('auto');
// Else, reduce max height (menu will be scrollable) and open in whichever direction there's more space
} else {
if (upSpace > bottomSpace) {
this.setContextMenuMaxHeight(
upSpace - 2
);
this.setContextMenuMaxHeight(upSpace - 2);
openUpBottom = false;
} else {
this.setContextMenuMaxHeight(
bottomSpace - 2
);
this.setContextMenuMaxHeight(bottomSpace - 2);
}
}
} else {
this.setContextMenuMaxHeight(
'auto'
);
this.setContextMenuMaxHeight('auto');
}
if (openUpBottom) {
@@ -375,9 +368,7 @@ export class NotesState {
const selectedNotes = Object.values(this.selectedNotes);
if (protect) {
await this.application.protectNotes(selectedNotes);
if (!this.application.hasProtectionSources()) {
this.setShowProtectedWarning(true);
}
this.setShowProtectedWarning(true);
} else {
await this.application.unprotectNotes(selectedNotes);
this.setShowProtectedWarning(false);

View File

@@ -0,0 +1,541 @@
import {
ApplicationEvent,
CollectionSort,
ContentType,
findInArray,
NotesDisplayCriteria,
PrefKey,
SNNote,
SNTag,
UuidString,
} from '@standardnotes/snjs';
import {
action,
autorun,
computed,
makeObservable,
observable,
reaction,
} from 'mobx';
import { AppState, AppStateEvent } from '.';
import { WebApplication } from '../application';
const MIN_NOTE_CELL_HEIGHT = 51.0;
const DEFAULT_LIST_NUM_NOTES = 20;
const ELEMENT_ID_SEARCH_BAR = 'search-bar';
const ELEMENT_ID_SCROLL_CONTAINER = 'notes-scrollable';
export type DisplayOptions = {
sortBy: CollectionSort;
sortReverse: boolean;
hidePinned: boolean;
showArchived: boolean;
showTrashed: boolean;
hideProtected: boolean;
hideTags: boolean;
hideNotePreview: boolean;
hideDate: boolean;
};
export class NotesViewState {
completedFullSync = false;
noteFilterText = '';
notes: SNNote[] = [];
notesToDisplay = 0;
pageSize = 0;
panelTitle = 'All Notes';
renderedNotes: SNNote[] = [];
searchSubmitted = false;
selectedNotes: Record<UuidString, SNNote> = {};
showDisplayOptionsMenu = false;
displayOptions = {
sortBy: CollectionSort.CreatedAt,
sortReverse: false,
hidePinned: false,
showArchived: false,
showTrashed: false,
hideProtected: false,
hideTags: true,
hideDate: false,
hideNotePreview: false,
};
constructor(
private application: WebApplication,
private appState: AppState,
appObservers: (() => void)[]
) {
this.resetPagination();
appObservers.push(
application.streamItems(ContentType.Note, () => {
this.reloadNotes();
const activeNote = this.appState.notes.activeEditor?.note;
if (this.application.getAppState().notes.selectedNotesCount < 2) {
if (activeNote) {
const discarded = activeNote.deleted || activeNote.trashed;
if (
discarded &&
!this.appState?.selectedTag?.isTrashTag &&
!this.appState?.searchOptions.includeTrashed
) {
this.selectNextOrCreateNew();
} else if (!this.selectedNotes[activeNote.uuid]) {
this.selectNote(activeNote);
}
} else {
this.selectFirstNote();
}
}
}),
application.streamItems([ContentType.Tag], async (items) => {
const tags = items as SNTag[];
/** A tag could have changed its relationships, so we need to reload the filter */
this.reloadNotesDisplayOptions();
this.reloadNotes();
if (findInArray(tags, 'uuid', this.appState.selectedTag?.uuid)) {
/** Tag title could have changed */
this.reloadPanelTitle();
}
}),
application.addEventObserver(async () => {
this.reloadPreferences();
}, ApplicationEvent.PreferencesChanged),
application.addEventObserver(async () => {
this.appState.closeAllEditors();
this.selectFirstNote();
this.setCompletedFullSync(false);
}, ApplicationEvent.SignedIn),
application.addEventObserver(async () => {
this.reloadNotes();
if (
this.notes.length === 0 &&
this.appState.selectedTag?.isAllTag &&
this.noteFilterText === ''
) {
this.createPlaceholderNote();
}
this.setCompletedFullSync(true);
}, ApplicationEvent.CompletedFullSync),
autorun(() => {
if (appState.notes.selectedNotes) {
this.syncSelectedNotes();
}
}),
reaction(
() => [
appState.searchOptions.includeProtectedContents,
appState.searchOptions.includeArchived,
appState.searchOptions.includeTrashed,
],
() => {
this.reloadNotesDisplayOptions();
this.reloadNotes();
}
),
appState.addObserver(async (eventName) => {
if (eventName === AppStateEvent.TagChanged) {
this.handleTagChange();
} else if (eventName === AppStateEvent.ActiveEditorChanged) {
this.handleEditorChange();
} else if (eventName === AppStateEvent.EditorFocused) {
this.toggleDisplayOptionsMenu(false);
}
})
);
makeObservable(this, {
completedFullSync: observable,
displayOptions: observable.struct,
noteFilterText: observable,
notes: observable,
notesToDisplay: observable,
panelTitle: observable,
renderedNotes: observable,
selectedNotes: observable,
showDisplayOptionsMenu: observable,
reloadNotes: action,
reloadPanelTitle: action,
reloadPreferences: action,
resetPagination: action,
setCompletedFullSync: action,
setNoteFilterText: action,
syncSelectedNotes: action,
toggleDisplayOptionsMenu: action,
onFilterEnter: action,
handleFilterTextChanged: action,
optionsSubtitle: computed,
});
window.onresize = () => {
this.resetPagination(true);
};
}
setCompletedFullSync = (completed: boolean) => {
this.completedFullSync = completed;
};
toggleDisplayOptionsMenu = (enabled: boolean) => {
this.showDisplayOptionsMenu = enabled;
};
get searchBarElement() {
return document.getElementById(ELEMENT_ID_SEARCH_BAR);
}
get isFiltering(): boolean {
return !!this.noteFilterText && this.noteFilterText.length > 0;
}
get activeEditorNote() {
return this.appState.notes.activeEditor?.note;
}
reloadPanelTitle = () => {
let title = this.panelTitle;
if (this.isFiltering) {
const resultCount = this.notes.length;
title = `${resultCount} search results`;
} else if (this.appState.selectedTag) {
title = `${this.appState.selectedTag.title}`;
}
this.panelTitle = title;
};
reloadNotes = () => {
const tag = this.appState.selectedTag;
if (!tag) {
return;
}
const notes = this.application.getDisplayableItems(
ContentType.Note
) as SNNote[];
const renderedNotes = notes.slice(0, this.notesToDisplay);
this.notes = notes;
this.renderedNotes = renderedNotes;
this.reloadPanelTitle();
};
reloadNotesDisplayOptions = () => {
const tag = this.appState.selectedTag;
const searchText = this.noteFilterText.toLowerCase();
const isSearching = searchText.length;
let includeArchived: boolean;
let includeTrashed: boolean;
if (isSearching) {
includeArchived = this.appState.searchOptions.includeArchived;
includeTrashed = this.appState.searchOptions.includeTrashed;
} else {
includeArchived = this.displayOptions.showArchived ?? false;
includeTrashed = this.displayOptions.showTrashed ?? false;
}
const criteria = NotesDisplayCriteria.Create({
sortProperty: this.displayOptions.sortBy as CollectionSort,
sortDirection: this.displayOptions.sortReverse ? 'asc' : 'dsc',
tags: tag ? [tag] : [],
includeArchived,
includeTrashed,
includePinned: !this.displayOptions.hidePinned,
includeProtected: !this.displayOptions.hideProtected,
searchQuery: {
query: searchText,
includeProtectedNoteText:
this.appState.searchOptions.includeProtectedContents,
},
});
this.application.setNotesDisplayCriteria(criteria);
};
reloadPreferences = () => {
const freshDisplayOptions = {} as DisplayOptions;
const currentSortBy = this.displayOptions.sortBy;
let sortBy = this.application.getPreference(
PrefKey.SortNotesBy,
CollectionSort.CreatedAt
);
if (
sortBy === CollectionSort.UpdatedAt ||
(sortBy as string) === 'client_updated_at'
) {
/** Use UserUpdatedAt instead */
sortBy = CollectionSort.UpdatedAt;
}
freshDisplayOptions.sortBy = sortBy;
freshDisplayOptions.sortReverse = this.application.getPreference(
PrefKey.SortNotesReverse,
false
);
freshDisplayOptions.showArchived = this.application.getPreference(
PrefKey.NotesShowArchived,
false
);
freshDisplayOptions.showTrashed = this.application.getPreference(
PrefKey.NotesShowTrashed,
false
) as boolean;
freshDisplayOptions.hidePinned = this.application.getPreference(
PrefKey.NotesHidePinned,
false
);
freshDisplayOptions.hideProtected = this.application.getPreference(
PrefKey.NotesHideProtected,
false
);
freshDisplayOptions.hideNotePreview = this.application.getPreference(
PrefKey.NotesHideNotePreview,
false
);
freshDisplayOptions.hideDate = this.application.getPreference(
PrefKey.NotesHideDate,
false
);
freshDisplayOptions.hideTags = this.application.getPreference(
PrefKey.NotesHideTags,
true
);
const displayOptionsChanged =
freshDisplayOptions.sortBy !== this.displayOptions.sortBy ||
freshDisplayOptions.sortReverse !== this.displayOptions.sortReverse ||
freshDisplayOptions.hidePinned !== this.displayOptions.hidePinned ||
freshDisplayOptions.showArchived !== this.displayOptions.showArchived ||
freshDisplayOptions.showTrashed !== this.displayOptions.showTrashed ||
freshDisplayOptions.hideProtected !== this.displayOptions.hideProtected ||
freshDisplayOptions.hideTags !== this.displayOptions.hideTags;
this.displayOptions = freshDisplayOptions;
if (displayOptionsChanged) {
this.reloadNotesDisplayOptions();
}
this.reloadNotes();
if (freshDisplayOptions.sortBy !== currentSortBy) {
this.selectFirstNote();
}
};
createNewNote = async (focusNewNote = true) => {
this.appState.notes.unselectNotes();
let title = `Note ${this.notes.length + 1}`;
if (this.isFiltering) {
title = this.noteFilterText;
}
await this.appState.createEditor(title);
this.reloadNotes();
this.appState.noteTags.reloadTags();
const noteTitleEditorElement = document.getElementById('note-title-editor');
if (focusNewNote) {
noteTitleEditorElement?.focus();
}
};
createPlaceholderNote = () => {
const selectedTag = this.appState.selectedTag;
if (selectedTag && selectedTag.isSmartTag && !selectedTag.isAllTag) {
return;
}
return this.createNewNote(false);
};
get optionsSubtitle(): string {
let base = '';
if (this.displayOptions.sortBy === CollectionSort.CreatedAt) {
base += ' Date Added';
} else if (this.displayOptions.sortBy === CollectionSort.UpdatedAt) {
base += ' Date Modified';
} else if (this.displayOptions.sortBy === CollectionSort.Title) {
base += ' Title';
}
if (this.displayOptions.showArchived) {
base += ' | + Archived';
}
if (this.displayOptions.showTrashed) {
base += ' | + Trashed';
}
if (this.displayOptions.hidePinned) {
base += ' | Pinned';
}
if (this.displayOptions.hideProtected) {
base += ' | Protected';
}
if (this.displayOptions.sortReverse) {
base += ' | Reversed';
}
return base;
}
paginate = () => {
this.notesToDisplay += this.pageSize;
this.reloadNotes();
if (this.searchSubmitted) {
this.application.getDesktopService().searchText(this.noteFilterText);
}
};
resetPagination = (keepCurrentIfLarger = false) => {
const clientHeight = document.documentElement.clientHeight;
this.pageSize = Math.ceil(clientHeight / MIN_NOTE_CELL_HEIGHT);
if (this.pageSize === 0) {
this.pageSize = DEFAULT_LIST_NUM_NOTES;
}
if (keepCurrentIfLarger && this.notesToDisplay > this.pageSize) {
return;
}
this.notesToDisplay = this.pageSize;
};
getFirstNonProtectedNote = () => {
return this.notes.find((note) => !note.protected);
};
get notesListScrollContainer() {
return document.getElementById(ELEMENT_ID_SCROLL_CONTAINER);
}
selectNote = async (
note: SNNote,
userTriggered?: boolean,
scrollIntoView = true
): Promise<void> => {
await this.appState.notes.selectNote(note.uuid, userTriggered);
if (scrollIntoView) {
const noteElement = document.getElementById(`note-${note.uuid}`);
noteElement?.scrollIntoView({
behavior: 'smooth',
});
}
};
selectFirstNote = () => {
const note = this.getFirstNonProtectedNote();
if (note) {
this.selectNote(note, false, false);
this.resetScrollPosition();
}
};
selectNextNote = () => {
const displayableNotes = this.notes;
const currentIndex = displayableNotes.findIndex((candidate) => {
return candidate.uuid === this.activeEditorNote?.uuid;
});
if (currentIndex + 1 < displayableNotes.length) {
const nextNote = displayableNotes[currentIndex + 1];
this.selectNote(nextNote);
const nextNoteElement = document.getElementById(`note-${nextNote.uuid}`);
nextNoteElement?.focus();
}
};
selectNextOrCreateNew = () => {
const note = this.getFirstNonProtectedNote();
if (note) {
this.selectNote(note, false, false);
} else {
this.appState.closeActiveEditor();
}
};
selectPreviousNote = () => {
const displayableNotes = this.notes;
if (this.activeEditorNote) {
const currentIndex = displayableNotes.indexOf(this.activeEditorNote);
if (currentIndex - 1 >= 0) {
const previousNote = displayableNotes[currentIndex - 1];
this.selectNote(previousNote);
const previousNoteElement = document.getElementById(
`note-${previousNote.uuid}`
);
previousNoteElement?.focus();
return true;
} else {
return false;
}
}
};
setNoteFilterText = (text: string) => {
this.noteFilterText = text;
};
syncSelectedNotes = () => {
this.selectedNotes = this.appState.notes.selectedNotes;
};
handleEditorChange = async () => {
const activeNote = this.appState.getActiveEditor()?.note;
if (activeNote && activeNote.conflictOf) {
this.application.changeAndSaveItem(activeNote.uuid, (mutator) => {
mutator.conflictOf = undefined;
});
}
if (this.isFiltering) {
this.application.getDesktopService().searchText(this.noteFilterText);
}
};
resetScrollPosition = () => {
if (this.notesListScrollContainer) {
this.notesListScrollContainer.scrollTop = 0;
this.notesListScrollContainer.scrollLeft = 0;
}
};
handleTagChange = () => {
this.resetScrollPosition();
this.toggleDisplayOptionsMenu(false);
this.setNoteFilterText('');
this.application.getDesktopService().searchText();
this.resetPagination();
/* Capture db load state before beginning reloadNotes,
since this status may change during reload */
const dbLoaded = this.application.isDatabaseLoaded();
this.reloadNotesDisplayOptions();
this.reloadNotes();
if (this.notes.length > 0) {
this.selectFirstNote();
} else if (dbLoaded) {
if (
this.activeEditorNote &&
!this.notes.includes(this.activeEditorNote)
) {
this.appState.closeActiveEditor();
}
}
};
onFilterEnter = () => {
/**
* For Desktop, performing a search right away causes
* input to lose focus. We wait until user explicity hits
* enter before highlighting desktop search results.
*/
this.searchSubmitted = true;
this.application.getDesktopService().searchText(this.noteFilterText);
};
handleFilterTextChanged = () => {
if (this.searchSubmitted) {
this.searchSubmitted = false;
}
this.reloadNotesDisplayOptions();
this.reloadNotes();
};
onSearchInputBlur = () => {
this.appState.searchOptions.refreshIncludeProtectedContents();
};
clearFilterText = () => {
this.setNoteFilterText('');
this.onFilterEnter();
this.handleFilterTextChanged();
this.resetPagination();
};
}

View File

@@ -1,6 +1,6 @@
import { ApplicationEvent } from "@standardnotes/snjs";
import { makeObservable, observable, action, runInAction } from "mobx";
import { WebApplication } from "../application";
import { ApplicationEvent } from '@standardnotes/snjs';
import { makeObservable, observable, action, runInAction } from 'mobx';
import { WebApplication } from '../application';
export class SearchOptionsState {
includeProtectedContents = false;
@@ -25,7 +25,10 @@ export class SearchOptionsState {
appObservers.push(
this.application.addEventObserver(async () => {
this.refreshIncludeProtectedContents();
}, ApplicationEvent.ProtectionSessionExpiryDateChanged)
}, ApplicationEvent.UnprotectedSessionBegan),
this.application.addEventObserver(async () => {
this.refreshIncludeProtectedContents();
}, ApplicationEvent.UnprotectedSessionExpired)
);
}
@@ -38,21 +41,17 @@ export class SearchOptionsState {
};
refreshIncludeProtectedContents = (): void => {
if (
this.includeProtectedContents &&
this.application.areProtectionsEnabled()
) {
this.includeProtectedContents = false;
}
this.includeProtectedContents =
this.application.hasUnprotectedAccessSession();
};
toggleIncludeProtectedContents = async (): Promise<void> => {
if (this.includeProtectedContents) {
this.includeProtectedContents = false;
} else {
const authorized = await this.application.authorizeSearchingProtectedNotesText();
await this.application.authorizeSearchingProtectedNotesText();
runInAction(() => {
this.includeProtectedContents = authorized;
this.refreshIncludeProtectedContents();
});
}
};

View File

@@ -1,4 +1,9 @@
import { ContentType, SNSmartTag, SNTag } from '@standardnotes/snjs';
import {
ContentType,
SNSmartTag,
SNTag,
UuidString,
} from '@standardnotes/snjs';
import {
action,
computed,
@@ -8,6 +13,7 @@ import {
runInAction,
} from 'mobx';
import { WebApplication } from '../application';
import { FeaturesState } from './features_state';
export class TagsState {
tags: SNTag[] = [];
@@ -16,14 +22,20 @@ export class TagsState {
constructor(
private application: WebApplication,
appEventListeners: (() => void)[]
appEventListeners: (() => void)[],
private features: FeaturesState
) {
this.tagsCountsState = new TagsCountsState(this.application);
makeObservable(this, {
tags: observable,
smartTags: observable,
tags: observable.ref,
smartTags: observable.ref,
hasFolders: computed,
hasAtLeastOneFolder: computed,
assignParent: action,
rootTags: computed,
tagsCount: computed,
});
@@ -48,9 +60,68 @@ export class TagsState {
return this.tagsCountsState.counts[tag.uuid] || 0;
}
getChildren(tag: SNTag): SNTag[] {
if (!this.hasFolders) {
return [];
}
if (this.application.isTemplateItem(tag)) {
return [];
}
const children = this.application.getTagChildren(tag);
const childrenUuids = children.map((childTag) => childTag.uuid);
const childrenTags = this.tags.filter((tag) =>
childrenUuids.includes(tag.uuid)
);
return childrenTags;
}
isValidTagParent(parentUuid: UuidString, tagUuid: UuidString): boolean {
return this.application.isValidTagParent(parentUuid, tagUuid);
}
public async assignParent(
tagUuid: string,
parentUuid: string | undefined
): Promise<void> {
const tag = this.application.findItem(tagUuid) as SNTag;
const parent =
parentUuid && (this.application.findItem(parentUuid) as SNTag);
if (!parent) {
await this.application.unsetTagParent(tag);
} else {
await this.application.setTagParent(parent, tag);
}
await this.application.sync();
}
get rootTags(): SNTag[] {
if (!this.hasFolders) {
return this.tags;
}
return this.tags.filter((tag) => !this.application.getTagParent(tag));
}
get tagsCount(): number {
return this.tags.length;
}
public get hasFolders(): boolean {
return this.features.hasFolders;
}
public set hasFolders(hasFolders: boolean) {
this.features.hasFolders = hasFolders;
}
public get hasAtLeastOneFolder(): boolean {
return this.tags.some((tag) => !!this.application.getTagParent(tag));
}
}
/**

View File

@@ -18,12 +18,9 @@ import {
PermissionDialog,
Platform,
SNApplication,
SNComponent,
} from '@standardnotes/snjs';
import angular from 'angular';
import { ComponentModalScope } from './../directives/views/componentModal';
import { AccountSwitcherScope, PermissionsModalScope } from './../types';
import { ComponentGroup } from './component_group';
type WebServices = {
appState: AppState;
@@ -40,7 +37,6 @@ export class WebApplication extends SNApplication {
private webServices!: WebServices;
private currentAuthenticationElement?: angular.IRootElementService;
public editorGroup: EditorGroup;
public componentGroup: ComponentGroup;
/* @ngInject */
constructor(
@@ -71,8 +67,6 @@ export class WebApplication extends SNApplication {
this.scope = scope;
deviceInterface.setApplication(this);
this.editorGroup = new EditorGroup(this);
this.componentGroup = new ComponentGroup(this);
this.openModalComponent = this.openModalComponent.bind(this);
this.presentPermissionsDialog = this.presentPermissionsDialog.bind(this);
}
@@ -85,14 +79,12 @@ export class WebApplication extends SNApplication {
(service as any).application = undefined;
}
this.webServices = {} as WebServices;
(this.$compile as any) = undefined;
(this.$compile as unknown) = undefined;
this.editorGroup.deinit();
this.componentGroup.deinit();
(this.scope! as any).application = undefined;
(this.scope as any).application = undefined;
this.scope!.$destroy();
this.scope = undefined;
(this.openModalComponent as any) = undefined;
(this.presentPermissionsDialog as any) = undefined;
(this.presentPermissionsDialog as unknown) = undefined;
/** Allow our Angular directives to be destroyed and any pending digest cycles
* to complete before destroying the global application instance and all its services */
setTimeout(() => {
@@ -105,8 +97,7 @@ export class WebApplication extends SNApplication {
onStart(): void {
super.onStart();
this.componentManager!.openModalComponent = this.openModalComponent;
this.componentManager!.presentPermissionsDialog =
this.componentManager.presentPermissionsDialog =
this.presentPermissionsDialog;
}
@@ -210,24 +201,6 @@ export class WebApplication extends SNApplication {
this.applicationElement.append(el);
}
async openModalComponent(component: SNComponent): Promise<void> {
switch (component.package_info?.identifier) {
case 'org.standardnotes.cloudlink':
if (!(await this.authorizeCloudLinkAccess())) {
return;
}
break;
}
const scope = this.scope!.$new(true) as Partial<ComponentModalScope>;
scope.componentUuid = component.uuid;
scope.application = this;
const el = this.$compile!(
"<component-modal application='application' component-uuid='componentUuid' " +
"class='sk-modal'></component-modal>"
)(scope as any);
this.applicationElement.append(el);
}
presentPermissionsDialog(dialog: PermissionDialog) {
const scope = this.scope!.$new(true) as PermissionsModalScope;
scope.permissionsString = dialog.permissionsString;

View File

@@ -1,100 +0,0 @@
import { SNComponent, ComponentArea, removeFromArray, addIfUnique , UuidString } from '@standardnotes/snjs';
import { WebApplication } from './application';
/** Areas that only allow a single component to be active */
const SingleComponentAreas = [
ComponentArea.Editor,
ComponentArea.NoteTags,
ComponentArea.TagsList
];
export class ComponentGroup {
private application: WebApplication
changeObservers: any[] = []
activeComponents: UuidString[] = []
constructor(application: WebApplication) {
this.application = application;
}
get componentManager() {
return this.application.componentManager!;
}
public deinit() {
(this.application as any) = undefined;
}
async activateComponent(component: SNComponent) {
if (this.activeComponents.includes(component.uuid)) {
return;
}
if (SingleComponentAreas.includes(component.area)) {
const currentActive = this.activeComponentForArea(component.area);
if (currentActive) {
await this.deactivateComponent(currentActive, false);
}
}
addIfUnique(this.activeComponents, component.uuid);
await this.componentManager.activateComponent(component.uuid);
this.notifyObservers();
}
async deactivateComponent(component: SNComponent, notify = true) {
if (!this.activeComponents.includes(component.uuid)) {
return;
}
removeFromArray(this.activeComponents, component.uuid);
/** If this function is called as part of global application deinit (locking),
* componentManager can be destroyed. In this case, it's harmless to not take any
* action since the componentManager will be destroyed, and the component will
* essentially be deregistered. */
if(this.componentManager) {
await this.componentManager.deactivateComponent(component.uuid);
if(notify) {
this.notifyObservers();
}
}
}
async deactivateComponentForArea(area: ComponentArea) {
const component = this.activeComponentForArea(area);
if (component) {
return this.deactivateComponent(component);
}
}
activeComponentForArea(area: ComponentArea) {
return this.activeComponentsForArea(area)[0];
}
activeComponentsForArea(area: ComponentArea) {
return this.allActiveComponents().filter((c) => c.area === area);
}
allComponentsForArea(area: ComponentArea) {
return this.componentManager.componentsForArea(area);
}
private allActiveComponents() {
return this.application.getAll(this.activeComponents) as SNComponent[];
}
/**
* Notifies observer when the active editor has changed.
*/
public addChangeObserver(callback: () => void) {
this.changeObservers.push(callback);
callback();
return () => {
removeFromArray(this.changeObservers, callback);
};
}
private notifyObservers() {
for (const observer of this.changeObservers) {
observer();
}
}
}

View File

@@ -1,32 +1,38 @@
import { SNNote, ContentType, PayloadSource, UuidString, TagMutator } from '@standardnotes/snjs';
import {
SNNote,
ContentType,
PayloadSource,
UuidString,
SNTag,
} from '@standardnotes/snjs';
import { WebApplication } from './application';
export class Editor {
public note!: SNNote
private application: WebApplication
private _onNoteChange?: () => void
private _onNoteValueChange?: (note: SNNote, source?: PayloadSource) => void
private removeStreamObserver?: () => void
public isTemplateNote = false
public note!: SNNote;
private application: WebApplication;
private onNoteValueChange?: (note: SNNote, source: PayloadSource) => void;
private removeStreamObserver?: () => void;
public isTemplateNote = false;
constructor(
application: WebApplication,
noteUuid: string | undefined,
noteTitle: string | undefined,
noteTag: UuidString | undefined
private defaultTitle: string | undefined,
private defaultTag: UuidString | undefined
) {
this.application = application;
if (noteUuid) {
this.note = application.findItem(noteUuid) as SNNote;
this.streamItems();
} else {
this.reset(noteTitle, noteTag)
.then(() => this.streamItems())
.catch(console.error);
}
}
async initialize(): Promise<void> {
if (!this.note) {
await this.createTemplateNote(this.defaultTitle, this.defaultTag);
}
this.streamItems();
}
private streamItems() {
this.removeStreamObserver = this.application.streamItems(
ContentType.Note,
@@ -38,14 +44,12 @@ export class Editor {
deinit() {
this.removeStreamObserver?.();
(this.removeStreamObserver as any) = undefined;
this._onNoteChange = undefined;
(this.application as any) = undefined;
this._onNoteChange = undefined;
this._onNoteValueChange = undefined;
(this.removeStreamObserver as unknown) = undefined;
(this.application as unknown) = undefined;
this.onNoteValueChange = undefined;
}
private handleNoteStream(notes: SNNote[], source?: PayloadSource) {
private handleNoteStream(notes: SNNote[], source: PayloadSource) {
/** Update our note object reference whenever it changes */
const matchingNote = notes.find((item) => {
return item.uuid === this.note.uuid;
@@ -53,7 +57,7 @@ export class Editor {
if (matchingNote) {
this.isTemplateNote = false;
this.note = matchingNote;
this._onNoteValueChange && this._onNoteValueChange!(matchingNote, source);
this.onNoteValueChange?.(matchingNote, source);
}
}
@@ -66,58 +70,31 @@ export class Editor {
* Reverts the editor to a blank state, removing any existing note from view,
* and creating a placeholder note.
*/
async reset(
noteTitle = '',
noteTag?: UuidString,
) {
const note = await this.application.createTemplateItem(
ContentType.Note,
{
text: '',
title: noteTitle,
references: []
}
) as SNNote;
async createTemplateNote(defaultTitle?: string, noteTag?: UuidString) {
const note = (await this.application.createTemplateItem(ContentType.Note, {
text: '',
title: defaultTitle,
references: [],
})) as SNNote;
if (noteTag) {
await this.application.changeItem<TagMutator>(noteTag, (m) => {
m.addItemAsRelationship(note);
});
const tag = this.application.findItem(noteTag) as SNTag;
await this.application.addTagHierarchyToNote(note, tag);
}
if (!this.isTemplateNote || this.note.title !== note.title) {
this.setNote(note as SNNote, true);
}
}
/**
* Register to be notified when the editor's note changes.
*/
public onNoteChange(callback: () => void) {
this._onNoteChange = callback;
if (this.note) {
callback();
}
}
public clearNoteChangeListener() {
this._onNoteChange = undefined;
this.isTemplateNote = true;
this.note = note;
this.onNoteValueChange?.(this.note, this.note.payload.source);
}
/**
* Register to be notified when the editor's note's values change
* (and thus a new object reference is created)
*/
public onNoteValueChange(callback: (note: SNNote, source?: PayloadSource) => void) {
this._onNoteValueChange = callback;
}
/**
* Sets the editor contents by setting its note.
*/
public setNote(note: SNNote, isTemplate = false) {
this.note = note;
this.isTemplateNote = isTemplate;
if (this._onNoteChange) {
this._onNoteChange();
public setOnNoteValueChange(
callback: (note: SNNote, source: PayloadSource) => void
) {
this.onNoteValueChange = callback;
if (this.note) {
this.onNoteValueChange(this.note, this.note.payload.source);
}
}
}

View File

@@ -2,31 +2,31 @@ import { removeFromArray, UuidString } from '@standardnotes/snjs';
import { Editor } from './editor';
import { WebApplication } from './application';
type EditorGroupChangeCallback = () => void
type EditorGroupChangeCallback = () => void;
export class EditorGroup {
public editors: Editor[] = []
private application: WebApplication
changeObservers: EditorGroupChangeCallback[] = []
public editors: Editor[] = [];
private application: WebApplication;
changeObservers: EditorGroupChangeCallback[] = [];
constructor(application: WebApplication) {
this.application = application;
}
public deinit() {
(this.application as any) = undefined;
(this.application as unknown) = undefined;
for (const editor of this.editors) {
this.deleteEditor(editor);
}
}
createEditor(
async createEditor(
noteUuid?: string,
noteTitle?: string,
noteTag?: UuidString
) {
const editor = new Editor(this.application, noteUuid, noteTitle, noteTag);
await editor.initialize();
this.editors.push(editor);
this.notifyObservers();
}
@@ -43,13 +43,13 @@ export class EditorGroup {
closeActiveEditor() {
const activeEditor = this.activeEditor;
if(activeEditor) {
if (activeEditor) {
this.deleteEditor(activeEditor);
}
}
closeAllEditors() {
for(const editor of this.editors) {
for (const editor of this.editors) {
this.deleteEditor(editor);
}
}

View File

@@ -0,0 +1,317 @@
import {
PanelSide,
ResizeFinishCallback,
} from '@/directives/views/panelResizer';
import { debounce } from '@/utils';
import { ApplicationEvent, PrefKey } from '@standardnotes/snjs';
import { action, computed, makeObservable, observable } from 'mobx';
import { WebApplication } from './application';
export type PanelResizerProps = {
alwaysVisible?: boolean;
application: WebApplication;
collapsable: boolean;
defaultWidth?: number;
hoverable?: boolean;
minWidth?: number;
panel: HTMLDivElement;
prefKey: PrefKey;
resizeFinishCallback?: ResizeFinishCallback;
side: PanelSide;
widthEventCallback?: () => void;
};
export class PanelResizerState {
private application: WebApplication;
alwaysVisible: boolean;
collapsable: boolean;
collapsed = false;
currentMinWidth = 0;
defaultWidth: number;
hoverable: boolean;
lastDownX = 0;
lastLeft = 0;
lastWidth = 0;
panel: HTMLDivElement;
pressed = false;
prefKey: PrefKey;
resizeFinishCallback?: ResizeFinishCallback;
side: PanelSide;
startLeft = 0;
startWidth = 0;
widthBeforeLastDblClick = 0;
widthEventCallback?: () => void;
overlay?: HTMLDivElement;
constructor({
alwaysVisible,
application,
defaultWidth,
hoverable,
collapsable,
minWidth,
panel,
prefKey,
resizeFinishCallback,
side,
widthEventCallback,
}: PanelResizerProps) {
this.alwaysVisible = alwaysVisible ?? false;
this.application = application;
this.collapsable = collapsable ?? false;
this.collapsed = false;
this.currentMinWidth = minWidth ?? 0;
this.defaultWidth = defaultWidth ?? 0;
this.hoverable = hoverable ?? true;
this.lastDownX = 0;
this.lastLeft = this.startLeft;
this.lastWidth = this.startWidth;
this.panel = panel;
this.prefKey = prefKey;
this.pressed = false;
this.side = side;
this.startLeft = this.panel.offsetLeft;
this.startWidth = this.panel.scrollWidth;
this.widthBeforeLastDblClick = 0;
this.widthEventCallback = widthEventCallback;
this.resizeFinishCallback = resizeFinishCallback;
application.addEventObserver(async () => {
const changedWidth = application.getPreference(prefKey) as number;
if (changedWidth !== this.lastWidth) this.setWidth(changedWidth, true);
}, ApplicationEvent.PreferencesChanged);
makeObservable(this, {
pressed: observable,
collapsed: observable,
onMouseUp: action,
onMouseDown: action,
onDblClick: action,
handleWidthEvent: action,
handleLeftEvent: action,
setWidth: action,
setMinWidth: action,
reloadDefaultValues: action,
appFrame: computed,
});
document.addEventListener('mouseup', this.onMouseUp.bind(this));
document.addEventListener('mousemove', this.onMouseMove.bind(this));
if (this.side === PanelSide.Right) {
window.addEventListener(
'resize',
debounce(this.handleResize.bind(this), 250)
);
}
}
get appFrame() {
return document.getElementById('app')?.getBoundingClientRect() as DOMRect;
}
getParentRect() {
return (this.panel.parentNode as HTMLElement).getBoundingClientRect();
}
isAtMaxWidth = () => {
return (
Math.round(this.lastWidth + this.lastLeft) ===
Math.round(this.getParentRect().width)
);
};
isCollapsed() {
return this.lastWidth <= this.currentMinWidth;
}
reloadDefaultValues = () => {
this.startWidth = this.isAtMaxWidth()
? this.getParentRect().width
: this.panel.scrollWidth;
this.lastWidth = this.startWidth;
};
finishSettingWidth = () => {
if (!this.collapsable) {
return;
}
this.collapsed = this.isCollapsed();
};
setWidth = (width: number, finish = false) => {
if (width < this.currentMinWidth) {
width = this.currentMinWidth;
}
const parentRect = this.getParentRect();
if (width > parentRect.width) {
width = parentRect.width;
}
const maxWidth = this.appFrame.width - this.panel.getBoundingClientRect().x;
if (width > maxWidth) {
width = maxWidth;
}
if (Math.round(width + this.lastLeft) === Math.round(parentRect.width)) {
this.panel.style.width = `calc(100% - ${this.lastLeft}px)`;
} else {
this.panel.style.width = width + 'px';
}
this.lastWidth = width;
if (finish) {
this.finishSettingWidth();
if (this.resizeFinishCallback) {
this.resizeFinishCallback(
this.lastWidth,
this.lastLeft,
this.isAtMaxWidth(),
this.isCollapsed()
);
}
}
this.application.setPreference(this.prefKey, this.lastWidth);
};
setLeft = (left: number) => {
this.panel.style.left = left + 'px';
this.lastLeft = left;
};
onDblClick = () => {
const collapsed = this.isCollapsed();
if (collapsed) {
this.setWidth(this.widthBeforeLastDblClick || this.defaultWidth);
} else {
this.widthBeforeLastDblClick = this.lastWidth;
this.setWidth(this.currentMinWidth);
}
this.application.setPreference(this.prefKey, this.lastWidth);
this.finishSettingWidth();
if (this.resizeFinishCallback) {
this.resizeFinishCallback(
this.lastWidth,
this.lastLeft,
this.isAtMaxWidth(),
this.isCollapsed()
);
}
};
handleWidthEvent(event?: MouseEvent) {
if (this.widthEventCallback) {
this.widthEventCallback();
}
let x;
if (event) {
x = event.clientX;
} else {
/** Coming from resize event */
x = 0;
this.lastDownX = 0;
}
const deltaX = x - this.lastDownX;
const newWidth = this.startWidth + deltaX;
this.setWidth(newWidth, false);
}
handleLeftEvent(event: MouseEvent) {
const panelRect = this.panel.getBoundingClientRect();
const x = event.clientX || panelRect.x;
let deltaX = x - this.lastDownX;
let newLeft = this.startLeft + deltaX;
if (newLeft < 0) {
newLeft = 0;
deltaX = -this.startLeft;
}
const parentRect = this.getParentRect();
let newWidth = this.startWidth - deltaX;
if (newWidth < this.currentMinWidth) {
newWidth = this.currentMinWidth;
}
if (newWidth > parentRect.width) {
newWidth = parentRect.width;
}
if (newLeft + newWidth > parentRect.width) {
newLeft = parentRect.width - newWidth;
}
this.setLeft(newLeft);
this.setWidth(newWidth, false);
}
handleResize = () => {
this.reloadDefaultValues();
this.handleWidthEvent();
this.finishSettingWidth();
};
onMouseDown = (event: MouseEvent) => {
this.addInvisibleOverlay();
this.pressed = true;
this.lastDownX = event.clientX;
this.startWidth = this.panel.scrollWidth;
this.startLeft = this.panel.offsetLeft;
};
onMouseUp = () => {
this.removeInvisibleOverlay();
if (!this.pressed) {
return;
}
this.pressed = false;
const isMaxWidth = this.isAtMaxWidth();
if (this.resizeFinishCallback) {
this.resizeFinishCallback(
this.lastWidth,
this.lastLeft,
isMaxWidth,
this.isCollapsed()
);
}
this.finishSettingWidth();
};
onMouseMove(event: MouseEvent) {
if (!this.pressed) {
return;
}
event.preventDefault();
if (this.side === PanelSide.Left) {
this.handleLeftEvent(event);
} else {
this.handleWidthEvent(event);
}
}
setMinWidth = (minWidth?: number) => {
this.currentMinWidth = minWidth ?? this.currentMinWidth;
};
/**
* If an iframe is displayed adjacent to our panel, and the mouse exits over the iframe,
* document[onmouseup] is not triggered because the document is no longer the same over
* the iframe. We add an invisible overlay while resizing so that the mouse context
* remains in our main document.
*/
addInvisibleOverlay = () => {
if (this.overlay) {
return;
}
const overlayElement = document.createElement('div');
overlayElement.id = 'resizer-overlay';
this.overlay = overlayElement;
document.body.prepend(this.overlay);
};
removeInvisibleOverlay = () => {
if (this.overlay) {
this.overlay.remove();
this.overlay = undefined;
}
};
}

View File

@@ -1,6 +1,7 @@
import { Platform, platformFromString } from '@standardnotes/snjs';
import { IsDesktopPlatform, IsWebPlatform } from '@/version';
import { EMAIL_REGEX } from '@Views/constants';
export { isMobile } from './isMobile';
declare const process: {
env: {

View File

@@ -0,0 +1,28 @@
/**
* source: https://github.com/juliangruber/is-mobile
*
* (MIT)
* Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const mobileRE =
/(android|bb\d+|meego).+mobile|armv7l|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series[46]0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i;
const tabletRE = /android|ipad|playbook|silk/i;
export type Opts = {
tablet?: boolean;
};
export const isMobile = (opts: Opts = {}) => {
const ua = navigator.userAgent || navigator.vendor;
if (typeof ua !== 'string') {
return false;
}
return mobileRE.test(ua) || (!!opts.tablet && tabletRE.test(ua));
};

View File

@@ -3,29 +3,26 @@ import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { autorun, IReactionDisposer, IReactionPublic } from 'mobx';
export type CtrlState = Partial<Record<string, any>>
export type CtrlProps = Partial<Record<string, any>>
export type CtrlState = Partial<Record<string, any>>;
export type CtrlProps = Partial<Record<string, any>>;
export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
$timeout: ng.ITimeoutService
$timeout: ng.ITimeoutService;
/** Passed through templates */
application!: WebApplication
state: S = {} as any
private unsubApp: any
private unsubState: any
private stateTimeout?: ng.IPromise<void>
application!: WebApplication;
state: S = {} as any;
private unsubApp: any;
private unsubState: any;
private stateTimeout?: ng.IPromise<void>;
/**
* Subclasses can optionally add an ng-if=ctrl.templateReady to make sure that
* no Angular handlebars/syntax render in the UI before display data is ready.
*/
protected templateReady = false
protected templateReady = false;
private reactionDisposers: IReactionDisposer[] = [];
/* @ngInject */
constructor(
$timeout: ng.ITimeoutService,
public props: P = {} as any
) {
constructor($timeout: ng.ITimeoutService, public props: P = {} as any) {
this.$timeout = $timeout;
}
@@ -91,8 +88,7 @@ export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
/** @override */
// eslint-disable-next-line @typescript-eslint/no-empty-function
afterStateChange(): void {
}
afterStateChange(): void {}
/** @returns a promise that resolves after the UI has been updated. */
flushUI(): angular.IPromise<void> {
@@ -129,25 +125,27 @@ export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
if (this.application!.isLaunched()) {
this.onAppLaunch();
}
this.unsubApp = this.application!.addEventObserver(async (eventName) => {
this.onAppEvent(eventName);
if (eventName === ApplicationEvent.Started) {
await this.onAppStart();
} else if (eventName === ApplicationEvent.Launched) {
await this.onAppLaunch();
} else if (eventName === ApplicationEvent.CompletedIncrementalSync) {
this.onAppIncrementalSync();
} else if (eventName === ApplicationEvent.CompletedFullSync) {
this.onAppFullSync();
} else if (eventName === ApplicationEvent.KeyStatusChanged) {
this.onAppKeyChange();
} else if (eventName === ApplicationEvent.LocalDataLoaded) {
this.onLocalDataLoaded();
this.unsubApp = this.application!.addEventObserver(
async (eventName, data: any) => {
this.onAppEvent(eventName, data);
if (eventName === ApplicationEvent.Started) {
await this.onAppStart();
} else if (eventName === ApplicationEvent.Launched) {
await this.onAppLaunch();
} else if (eventName === ApplicationEvent.CompletedIncrementalSync) {
this.onAppIncrementalSync();
} else if (eventName === ApplicationEvent.CompletedFullSync) {
this.onAppFullSync();
} else if (eventName === ApplicationEvent.KeyStatusChanged) {
this.onAppKeyChange();
} else if (eventName === ApplicationEvent.LocalDataLoaded) {
this.onLocalDataLoaded();
}
}
});
);
}
onAppEvent(eventName: ApplicationEvent) {
onAppEvent(eventName: ApplicationEvent, data?: any) {
/** Optional override */
}
@@ -175,5 +173,4 @@ export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
onAppFullSync() {
/** Optional override */
}
}

View File

@@ -3,14 +3,17 @@
)
#app.app(
ng-class='self.state.appClass',
ng-if='!self.state.needsUnlock && self.state.ready'
ng-if='!self.state.needsUnlock && self.state.launched'
)
tags-view(application='self.application')
notes-view(application='self.application')
notes-view(
application='self.application'
app-state='self.appState'
)
editor-group-view.flex-grow(application='self.application')
footer-view(
ng-if='!self.state.needsUnlock && self.state.ready'
ng-if='!self.state.needsUnlock && self.state.launched'
application='self.application'
)

View File

@@ -3,25 +3,29 @@ import { WebDirective } from '@/types';
import { getPlatformString } from '@/utils';
import template from './application-view.pug';
import { AppStateEvent, PanelResizedData } from '@/ui_models/app_state';
import { ApplicationEvent, Challenge, removeFromArray } from '@standardnotes/snjs';
import {
PANEL_NAME_NOTES,
PANEL_NAME_TAGS
} from '@/views/constants';
import {
STRING_DEFAULT_FILE_ERROR
} from '@/strings';
ApplicationEvent,
Challenge,
removeFromArray,
} from '@standardnotes/snjs';
import { PANEL_NAME_NOTES, PANEL_NAME_TAGS } from '@/views/constants';
import { STRING_DEFAULT_FILE_ERROR } from '@/strings';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
import { alertDialog } from '@/services/alertService';
class ApplicationViewCtrl extends PureViewCtrl<unknown, {
ready?: boolean,
needsUnlock?: boolean,
appClass: string,
}> {
public platformString: string
private notesCollapsed = false
private tagsCollapsed = false
class ApplicationViewCtrl extends PureViewCtrl<
unknown,
{
started?: boolean;
launched?: boolean;
needsUnlock?: boolean;
appClass: string;
}
> {
public platformString: string;
private notesCollapsed = false;
private tagsCollapsed = false;
/**
* To prevent stale state reads (setState is async),
* challenges is a mutable array
@@ -76,7 +80,7 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
this.$timeout(() => {
this.challenges.push(challenge);
});
}
},
});
await this.application.launch();
}
@@ -90,14 +94,17 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
async onAppStart() {
super.onAppStart();
this.setState({
ready: true,
needsUnlock: this.application.hasPasscode()
started: true,
needsUnlock: this.application.hasPasscode(),
});
}
async onAppLaunch() {
super.onAppLaunch();
this.setState({ needsUnlock: false });
this.setState({
launched: true,
needsUnlock: false,
});
this.handleDemoSignInFromParams();
}
@@ -108,14 +115,17 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
/** @override */
async onAppEvent(eventName: ApplicationEvent) {
super.onAppEvent(eventName);
if (eventName === ApplicationEvent.LocalDatabaseReadError) {
alertDialog({
text: 'Unable to load local database. Please restart the app and try again.'
});
} else if (eventName === ApplicationEvent.LocalDatabaseWriteError) {
alertDialog({
text: 'Unable to write to local database. Please restart the app and try again.'
});
switch (eventName) {
case ApplicationEvent.LocalDatabaseReadError:
alertDialog({
text: 'Unable to load local database. Please restart the app and try again.',
});
break;
case ApplicationEvent.LocalDatabaseWriteError:
alertDialog({
text: 'Unable to write to local database. Please restart the app and try again.',
});
break;
}
}
@@ -129,9 +139,13 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
if (panel === PANEL_NAME_TAGS) {
this.tagsCollapsed = collapsed;
}
let appClass = "";
if (this.notesCollapsed) { appClass += "collapsed-notes"; }
if (this.tagsCollapsed) { appClass += " collapsed-tags"; }
let appClass = '';
if (this.notesCollapsed) {
appClass += 'collapsed-notes';
}
if (this.tagsCollapsed) {
appClass += ' collapsed-tags';
}
this.setState({ appClass });
} else if (eventName === AppStateEvent.WindowDidFocus) {
if (!(await this.application.isLocked())) {
@@ -160,7 +174,7 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
if (event.dataTransfer?.files.length) {
event.preventDefault();
void alertDialog({
text: STRING_DEFAULT_FILE_ERROR
text: STRING_DEFAULT_FILE_ERROR,
});
}
}
@@ -168,15 +182,12 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
async handleDemoSignInFromParams() {
if (
this.$location.search().demo === 'true' &&
!this.application.hasAccount()
!this.application.hasAccount()
) {
await this.application.setCustomHost(
'https://syncing-server-demo.standardnotes.com'
);
this.application.signIn(
'demo@standardnotes.org',
'password',
);
this.application.signIn('demo@standardnotes.org', 'password');
}
}
}
@@ -190,7 +201,7 @@ export class ApplicationView extends WebDirective {
this.controllerAs = 'self';
this.bindToController = true;
this.scope = {
application: '='
application: '=',
};
}
}

View File

@@ -219,6 +219,7 @@ class ChallengeModalCtrl extends PureViewCtrl<unknown, ChallengeModalState> {
$onDestroy() {
render(<></>, this.$element[0]);
super.$onDestroy();
}
private render() {
@@ -352,8 +353,8 @@ function ChallengePrompts({
{/** ProtectionSessionDuration can't just be an input field */}
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
<div key={prompt.id} className="sk-panel-row">
<div className="sk-horizontal-group">
<div className="sk-p sk-bold">Remember For</div>
<div className="sk-horizontal-group mt-3">
<div className="sk-p sk-bold">Allow protected access for</div>
{ProtectionSessionDurations.map((option) => (
<a
className={
@@ -374,10 +375,13 @@ function ChallengePrompts({
</div>
) : (
<div key={prompt.id} className="sk-panel-row">
<form className="w-full" onSubmit={(event) => {
event.preventDefault();
ctrl.submit();
}}>
<form
className="w-full"
onSubmit={(event) => {
event.preventDefault();
ctrl.submit();
}}
>
<input
className="sk-input contrast"
value={ctrl.state.values[prompt.id]!.value as string | number}

View File

@@ -1,4 +1,4 @@
export const PANEL_NAME_NOTES = 'notes';
export const PANEL_NAME_TAGS = 'tags';
// eslint-disable-next-line no-useless-escape
export const EMAIL_REGEX = /^([a-zA-Z0-9!#$%&'*+\/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+\/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)$/;
export const PANEL_NAME_TAGS = 'tags';
export const EMAIL_REGEX =
/^([a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)$/;

View File

@@ -2,6 +2,7 @@
protected-note-panel.h-full.flex.justify-center.items-center(
ng-if='self.state.showProtectedWarning'
app-state='self.appState'
has-protection-sources='self.application.hasProtectionSources()'
on-view-note='self.dismissProtectedWarning()'
)
.flex-grow.flex.flex-col(
@@ -33,10 +34,8 @@
)
.title.overflow-auto
input#note-title-editor.input(
ng-blur='self.onTitleBlur()',
ng-change='self.onTitleChange()',
ng-disabled='self.noteLocked',
ng-focus='self.onTitleFocus()',
ng-keyup='$event.keyCode == 13 && self.onTitleEnter($event)',
ng-model='self.editorValues.title',
select-on-focus='true',
@@ -75,7 +74,7 @@
callback='self.editorMenuOnSelect',
current-item='self.note',
ng-if='self.state.showEditorMenu',
selected-editor-uuid='self.state.editorComponent && self.state.editorComponent.uuid',
selected-editor-uuid='self.state.editorComponentViewer && self.state.editorComponentViewer.component.uuid',
application='self.application'
)
.sk-app-bar-item(
@@ -113,9 +112,10 @@
property="'left'"
)
component-view.component-view(
component-uuid='self.state.editorComponent.uuid',
ng-if='self.state.editorComponent && !self.state.editorUnloading',
component-viewer='self.state.editorComponentViewer',
ng-if='self.state.editorComponentViewer',
on-load='self.onEditorLoad',
request-reload='self.editorComponentViewerRequestsReload'
application='self.application'
app-state='self.appState'
)
@@ -125,7 +125,7 @@
ng-change='self.contentChanged()',
ng-click='self.clickedTextArea()',
ng-focus='self.onContentFocus()',
ng-if='!self.state.editorComponent && !self.state.textareaUnloading',
ng-if='self.state.editorStateDidLoad && !self.state.editorComponentViewer && !self.state.textareaUnloading',
ng-model='self.editorValues.text',
ng-model-options='{ debounce: self.state.editorDebounce}',
ng-readonly='self.noteLocked',
@@ -155,24 +155,23 @@
| There was an error decrypting this item. Ensure you are running the
| latest version of this app, then sign out and sign back in to try again.
#editor-pane-component-stack(ng-if='!self.note.errorDecrypting' ng-show='self.note')
#component-stack-menu-bar.sk-app-bar.no-edges(ng-if='self.state.stackComponents.length')
#component-stack-menu-bar.sk-app-bar.no-edges(ng-if='self.state.availableStackComponents.length')
.left
.sk-app-bar-item(
ng-repeat='component in self.state.stackComponents track by component.uuid'
ng-click='self.toggleStackComponentForCurrentItem(component)',
ng-repeat='component in self.state.availableStackComponents track by component.uuid'
ng-click='self.toggleStackComponent(component)',
)
.sk-app-bar-item-column
.sk-circle.small(
ng-class="{'info' : !self.stackComponentHidden(component) && component.active, 'neutral' : self.stackComponentHidden(component) || !component.active}"
ng-class="{'info' : self.stackComponentExpanded(component) && component.active, 'neutral' : !self.stackComponentExpanded(component)}"
)
.sk-app-bar-item-column
.sk-label {{component.name}}
.sn-component
component-view.component-view.component-stack-item(
ng-repeat='component in self.state.stackComponents track by component.uuid',
component-uuid='component.uuid',
ng-repeat='viewer in self.state.stackComponentViewers track by viewer.componentUuid',
component-viewer='viewer',
manual-dealloc='true',
ng-show='!self.stackComponentHidden(component)',
application='self.application'
app-state='self.appState'
)

View File

@@ -0,0 +1,196 @@
/**
* @jest-environment jsdom
*/
import { EditorViewCtrl } from '@Views/editor/editor_view';
import {
ApplicationEvent,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
} from '@standardnotes/snjs/';
describe('editor-view', () => {
let ctrl: EditorViewCtrl;
let setShowProtectedWarningSpy: jest.SpyInstance;
beforeEach(() => {
const $timeout = {} as jest.Mocked<ng.ITimeoutService>;
ctrl = new EditorViewCtrl($timeout);
setShowProtectedWarningSpy = jest.spyOn(ctrl, 'setShowProtectedOverlay');
Object.defineProperties(ctrl, {
application: {
value: {
getAppState: () => {
return {
notes: {
setShowProtectedWarning: jest.fn(),
},
};
},
hasProtectionSources: () => true,
authorizeNoteAccess: jest.fn(),
},
},
removeComponentsObserver: {
value: jest.fn(),
writable: true,
},
removeTrashKeyObserver: {
value: jest.fn(),
writable: true,
},
unregisterComponent: {
value: jest.fn(),
writable: true,
},
editor: {
value: {
clearNoteChangeListener: jest.fn(),
},
},
});
});
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
afterEach(() => {
ctrl.deinit();
});
describe('note is protected', () => {
beforeEach(() => {
Object.defineProperty(ctrl, 'note', {
value: {
protected: true,
},
});
});
it("should hide the note if at the time of the session expiration the note wasn't edited for longer than the allowed idle time", async () => {
jest
.spyOn(ctrl, 'getSecondsElapsedSinceLastEdit')
.mockImplementation(
() =>
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction +
5
);
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true);
});
it('should postpone the note hiding by correct time if the time passed after its last modification is less than the allowed idle time', async () => {
const secondsElapsedSinceLastEdit =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
3;
Object.defineProperty(ctrl.note, 'userModifiedDate', {
value: new Date(Date.now() - secondsElapsedSinceLastEdit * 1000),
configurable: true,
});
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
const secondsAfterWhichTheNoteShouldHide =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
secondsElapsedSinceLastEdit;
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000);
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled();
jest.advanceTimersByTime(1 * 1000);
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true);
});
it('should postpone the note hiding by correct time if the user continued editing it even after the protection session has expired', async () => {
const secondsElapsedSinceLastModification = 3;
Object.defineProperty(ctrl.note, 'userModifiedDate', {
value: new Date(
Date.now() - secondsElapsedSinceLastModification * 1000
),
configurable: true,
});
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
let secondsAfterWhichTheNoteShouldHide =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
secondsElapsedSinceLastModification;
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000);
// A new modification has just happened
Object.defineProperty(ctrl.note, 'userModifiedDate', {
value: new Date(),
configurable: true,
});
secondsAfterWhichTheNoteShouldHide =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction;
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000);
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled();
jest.advanceTimersByTime(1 * 1000);
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true);
});
});
describe('note is unprotected', () => {
it('should not call any hiding logic', async () => {
Object.defineProperty(ctrl, 'note', {
value: {
protected: false,
},
});
const hideProtectedNoteIfInactiveSpy = jest.spyOn(
ctrl,
'hideProtectedNoteIfInactive'
);
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
expect(hideProtectedNoteIfInactiveSpy).not.toHaveBeenCalled();
});
});
describe('dismissProtectedWarning', () => {
describe('the note has protection sources', () => {
it('should reveal note contents if the authorization has been passed', async () => {
jest
.spyOn(ctrl.application, 'authorizeNoteAccess')
.mockImplementation(async () => Promise.resolve(true));
await ctrl.dismissProtectedWarning();
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(false);
});
it('should not reveal note contents if the authorization has not been passed', async () => {
jest
.spyOn(ctrl.application, 'authorizeNoteAccess')
.mockImplementation(async () => Promise.resolve(false));
await ctrl.dismissProtectedWarning();
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled();
});
});
describe('the note does not have protection sources', () => {
it('should reveal note contents', async () => {
jest
.spyOn(ctrl.application, 'hasProtectionSources')
.mockImplementation(() => false);
await ctrl.dismissProtectedWarning();
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(false);
});
});
});
});

View File

@@ -11,11 +11,15 @@ import {
SNComponent,
SNNote,
NoteMutator,
Uuids,
ComponentArea,
PrefKey,
ComponentMutator,
PayloadSource,
ComponentViewer,
ComponentManagerEvent,
TransactionalMutation,
ItemMutator,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
} from '@standardnotes/snjs';
import { isDesktopApplication } from '@/utils';
import { KeyboardModifier, KeyboardKey } from '@/services/ioService';
@@ -51,8 +55,10 @@ type NoteStatus = {
};
type EditorState = {
stackComponents: SNComponent[];
editorComponent?: SNComponent;
availableStackComponents: SNComponent[];
stackComponentViewers: ComponentViewer[];
editorComponentViewer?: ComponentViewer;
editorStateDidLoad: boolean;
saveError?: any;
noteStatus?: NoteStatus;
marginResizersEnabled?: boolean;
@@ -63,11 +69,6 @@ type EditorState = {
showEditorMenu: boolean;
showHistoryMenu: boolean;
spellcheck: boolean;
/**
* Setting to false then true will allow the current editor component-view to be destroyed
* then re-initialized. Used when changing between component editors.
*/
editorUnloading: boolean;
/** Setting to true then false will allow the main content textarea to be destroyed
* then re-initialized. Used when reloading spellcheck status. */
textareaUnloading: boolean;
@@ -89,14 +90,13 @@ function sortAlphabetically(array: SNComponent[]): SNComponent[] {
);
}
class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
export class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
/** Passed through template */
readonly application!: WebApplication;
readonly editor!: Editor;
private leftPanelPuppet?: PanelPuppet;
private rightPanelPuppet?: PanelPuppet;
private unregisterComponent: any;
private saveTimeout?: ng.IPromise<void>;
private statusTimeout?: ng.IPromise<void>;
private lastEditorFocusEventSource?: EventSource;
@@ -104,10 +104,12 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
onEditorLoad?: () => void;
private scrollPosition = 0;
private removeTrashKeyObserver?: any;
private removeTabObserver?: any;
private removeTrashKeyObserver?: () => void;
private removeTabObserver?: () => void;
private removeComponentStreamObserver?: () => void;
private removeComponentManagerObserver?: () => void;
private removeComponentsObserver!: () => void;
private protectionTimeoutId: ReturnType<typeof setTimeout> | null = null;
/* @ngInject */
constructor($timeout: ng.ITimeoutService) {
@@ -123,28 +125,30 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
this.onPanelResizeFinish = this.onPanelResizeFinish.bind(this);
this.setScrollPosition = this.setScrollPosition.bind(this);
this.resetScrollPosition = this.resetScrollPosition.bind(this);
this.editorComponentViewerRequestsReload =
this.editorComponentViewerRequestsReload.bind(this);
this.onEditorLoad = () => {
this.application!.getDesktopService().redoSearch();
this.application.getDesktopService().redoSearch();
};
}
deinit() {
this.editor.clearNoteChangeListener();
this.removeComponentsObserver();
(this.removeComponentsObserver as any) = undefined;
this.removeTrashKeyObserver();
this.removeComponentStreamObserver?.();
(this.removeComponentStreamObserver as unknown) = undefined;
this.removeComponentManagerObserver?.();
(this.removeComponentManagerObserver as unknown) = undefined;
this.removeTrashKeyObserver?.();
this.removeTrashKeyObserver = undefined;
this.removeTabObserver && this.removeTabObserver();
this.clearNoteProtectionInactivityTimer();
this.removeTabObserver?.();
this.removeTabObserver = undefined;
this.leftPanelPuppet = undefined;
this.rightPanelPuppet = undefined;
this.onEditorLoad = undefined;
this.unregisterComponent();
this.unregisterComponent = undefined;
this.saveTimeout = undefined;
this.statusTimeout = undefined;
(this.onPanelResizeFinish as any) = undefined;
(this.editorMenuOnSelect as any) = undefined;
(this.onPanelResizeFinish as unknown) = undefined;
(this.editorMenuOnSelect as unknown) = undefined;
super.deinit();
}
@@ -159,54 +163,82 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
$onInit() {
super.$onInit();
this.registerKeyboardShortcuts();
this.editor.onNoteChange(() => {
this.handleEditorNoteChange();
});
this.editor.onNoteValueChange((note, source) => {
if (isPayloadSourceRetrieved(source!)) {
this.editorValues.title = note.title;
this.editorValues.text = note.text;
}
if (!this.editorValues.title) {
this.editorValues.title = note.title;
}
if (!this.editorValues.text) {
this.editorValues.text = note.text;
}
const isTemplateNoteInsertedToBeInteractableWithEditor = source === PayloadSource.Constructor && note.dirty;
if (isTemplateNoteInsertedToBeInteractableWithEditor) {
return;
}
if (note.lastSyncBegan || note.dirty) {
if (note.lastSyncEnd) {
if (
note.dirty ||
note.lastSyncBegan!.getTime() > note.lastSyncEnd!.getTime()
) {
this.showSavingStatus();
} else if (
note.lastSyncEnd!.getTime() > note.lastSyncBegan!.getTime()
) {
this.showAllChangesSavedStatus();
}
} else {
this.showSavingStatus();
}
}
this.editor.setOnNoteValueChange((note, source) => {
this.onNoteChanges(note, source);
});
this.autorun(() => {
this.setState({
showProtectedWarning: this.appState.notes.showProtectedWarning,
});
});
this.reloadEditorComponent();
this.reloadStackComponents();
const showProtectedWarning =
this.note.protected && !this.application.hasProtectionSources();
this.setShowProtectedOverlay(showProtectedWarning);
this.reloadPreferences();
if (this.note.dirty) {
this.showSavingStatus();
}
}
private onNoteChanges(note: SNNote, source: PayloadSource): void {
if (note.uuid !== this.note.uuid) {
throw Error('Editor received changes for non-current note');
}
if (isPayloadSourceRetrieved(source)) {
this.editorValues.title = note.title;
this.editorValues.text = note.text;
}
if (!this.editorValues.title) {
this.editorValues.title = note.title;
}
if (!this.editorValues.text) {
this.editorValues.text = note.text;
}
const isTemplateNoteInsertedToBeInteractableWithEditor =
source === PayloadSource.Constructor && note.dirty;
if (isTemplateNoteInsertedToBeInteractableWithEditor) {
return;
}
if (note.lastSyncBegan || note.dirty) {
if (note.lastSyncEnd) {
if (
note.dirty ||
note.lastSyncBegan!.getTime() > note.lastSyncEnd!.getTime()
) {
this.showSavingStatus();
} else if (
note.lastSyncEnd!.getTime() > note.lastSyncBegan!.getTime()
) {
this.showAllChangesSavedStatus();
}
} else {
this.showSavingStatus();
}
}
}
$onDestroy(): void {
if (this.state.editorComponentViewer) {
this.application.componentManager?.destroyComponentViewer(
this.state.editorComponentViewer
);
}
super.$onDestroy();
}
/** @override */
getInitialState() {
return {
stackComponents: [],
availableStackComponents: [],
stackComponentViewers: [],
editorStateDidLoad: false,
editorDebounce: EDITOR_DEBOUNCE,
isDesktop: isDesktopApplication(),
spellcheck: true,
@@ -215,7 +247,6 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
showEditorMenu: false,
showHistoryMenu: false,
noteStatus: undefined,
editorUnloading: false,
textareaUnloading: false,
showProtectedWarning: false,
} as EditorState;
@@ -224,11 +255,11 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
async onAppLaunch() {
await super.onAppLaunch();
this.streamItems();
this.registerComponentHandler();
this.registerComponentManagerEventObserver();
}
/** @override */
onAppEvent(eventName: ApplicationEvent) {
async onAppEvent(eventName: ApplicationEvent) {
switch (eventName) {
case ApplicationEvent.PreferencesChanged:
this.reloadPreferences();
@@ -261,33 +292,60 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
desc: 'Changes not saved',
});
break;
case ApplicationEvent.UnprotectedSessionBegan: {
this.setShowProtectedOverlay(false);
break;
}
case ApplicationEvent.UnprotectedSessionExpired: {
if (this.note.protected) {
this.hideProtectedNoteIfInactive();
}
break;
}
}
}
async handleEditorNoteChange() {
this.cancelPendingSetStatus();
const note = this.editor.note;
const showProtectedWarning =
note.protected && !this.application.hasProtectionSources();
this.setShowProtectedWarning(showProtectedWarning);
await this.setState({
showActionsMenu: false,
showEditorMenu: false,
showHistoryMenu: false,
noteStatus: undefined,
});
this.editorValues.title = note.title;
this.editorValues.text = note.text;
this.reloadEditor();
this.reloadPreferences();
this.reloadStackComponents();
if (note.dirty) {
this.showSavingStatus();
getSecondsElapsedSinceLastEdit(): number {
return (Date.now() - this.note.userModifiedDate.getTime()) / 1000;
}
hideProtectedNoteIfInactive(): void {
const secondsElapsedSinceLastEdit = this.getSecondsElapsedSinceLastEdit();
if (
secondsElapsedSinceLastEdit >=
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction
) {
this.setShowProtectedOverlay(true);
} else {
const secondsUntilTheNextCheck =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
secondsElapsedSinceLastEdit;
this.startNoteProtectionInactivityTimer(secondsUntilTheNextCheck);
}
}
startNoteProtectionInactivityTimer(timerDurationInSeconds: number): void {
this.clearNoteProtectionInactivityTimer();
this.protectionTimeoutId = setTimeout(() => {
this.hideProtectedNoteIfInactive();
}, timerDurationInSeconds * 1000);
}
clearNoteProtectionInactivityTimer(): void {
if (this.protectionTimeoutId) {
clearTimeout(this.protectionTimeoutId);
}
}
async dismissProtectedWarning() {
this.setShowProtectedWarning(false);
let showNoteContents = true;
if (this.application.hasProtectionSources()) {
showNoteContents = await this.application.authorizeNoteAccess(this.note);
}
if (!showNoteContents) {
return;
}
this.setShowProtectedOverlay(false);
this.focusTitle();
}
@@ -307,20 +365,45 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
}
streamItems() {
this.removeComponentsObserver = this.application.streamItems(
this.removeComponentStreamObserver = this.application.streamItems(
ContentType.Component,
async (_items, source) => {
if (isPayloadSourceInternalChange(source!)) {
if (
isPayloadSourceInternalChange(source) ||
source === PayloadSource.InitialObserverRegistrationPush
) {
return;
}
if (!this.note) return;
this.reloadStackComponents();
this.reloadEditor();
await this.reloadStackComponents();
await this.reloadEditorComponent();
}
);
}
private async reloadEditor() {
private createComponentViewer(component: SNComponent) {
const viewer = this.application.componentManager.createComponentViewer(
component,
this.note.uuid
);
return viewer;
}
public async editorComponentViewerRequestsReload(
viewer: ComponentViewer
): Promise<void> {
const component = viewer.component;
this.application.componentManager.destroyComponentViewer(viewer);
await this.setState({
editorComponentViewer: undefined,
});
await this.setState({
editorComponentViewer: this.createComponentViewer(component),
editorStateDidLoad: true,
});
}
private async reloadEditorComponent() {
const newEditor = this.application.componentManager.editorForNote(
this.note
);
@@ -328,22 +411,29 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
if (newEditor && this.editor.isTemplateNote) {
await this.editor.insertTemplatedNote();
}
const currentEditor = this.state.editorComponent;
if (currentEditor?.uuid !== newEditor?.uuid) {
const currentComponentViewer = this.state.editorComponentViewer;
if (currentComponentViewer?.componentUuid !== newEditor?.uuid) {
if (currentComponentViewer) {
this.application.componentManager.destroyComponentViewer(
currentComponentViewer
);
}
await this.setState({
/** Unload current component view so that we create a new one */
editorUnloading: true,
});
await this.setState({
/** Reload component view */
editorComponent: newEditor,
editorUnloading: false,
editorComponentViewer: undefined,
});
if (newEditor) {
await this.setState({
editorComponentViewer: this.createComponentViewer(newEditor),
editorStateDidLoad: true,
});
}
this.reloadFont();
} else {
await this.setState({
editorStateDidLoad: true,
});
}
this.application.componentManager.contextItemDidChangeInArea(
ComponentArea.Editor
);
}
setMenuState(menu: string, state: boolean) {
@@ -370,6 +460,8 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
}
async editorMenuOnSelect(component?: SNComponent) {
const transactions: TransactionalMutation[] = [];
this.setMenuState('showEditorMenu', false);
if (this.appState.getActiveEditor()?.isTemplateNote) {
await this.appState.getActiveEditor().insertTemplatedNote();
@@ -380,43 +472,56 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
}
if (!component) {
if (!this.note.prefersPlainEditor) {
await this.application.changeItem(this.note.uuid, (mutator) => {
const noteMutator = mutator as NoteMutator;
noteMutator.prefersPlainEditor = true;
transactions.push({
itemUuid: this.note.uuid,
mutate: (m: ItemMutator) => {
const noteMutator = m as NoteMutator;
noteMutator.prefersPlainEditor = true;
},
});
this.reloadEditor();
}
if (
this.state.editorComponent?.isExplicitlyEnabledForItem(this.note.uuid)
this.state.editorComponentViewer?.component.isExplicitlyEnabledForItem(
this.note.uuid
)
) {
await this.disassociateComponentWithCurrentNote(
this.state.editorComponent
transactions.push(
this.transactionForDisassociateComponentWithCurrentNote(
this.state.editorComponentViewer.component
)
);
}
this.reloadFont();
} else if (component.area === ComponentArea.Editor) {
const currentEditor = this.state.editorComponent;
if (currentEditor && component !== currentEditor) {
await this.disassociateComponentWithCurrentNote(currentEditor);
const currentEditor = this.state.editorComponentViewer?.component;
if (currentEditor && component.uuid !== currentEditor.uuid) {
transactions.push(
this.transactionForDisassociateComponentWithCurrentNote(currentEditor)
);
}
const prefersPlain = this.note.prefersPlainEditor;
if (prefersPlain) {
await this.application.changeItem(this.note.uuid, (mutator) => {
const noteMutator = mutator as NoteMutator;
noteMutator.prefersPlainEditor = false;
transactions.push({
itemUuid: this.note.uuid,
mutate: (m: ItemMutator) => {
const noteMutator = m as NoteMutator;
noteMutator.prefersPlainEditor = false;
},
});
}
await this.associateComponentWithCurrentNote(component);
} else if (component.area === ComponentArea.EditorStack) {
await this.toggleStackComponentForCurrentItem(component);
transactions.push(
this.transactionForAssociateComponentWithCurrentNote(component)
);
}
await this.application.runTransactionalMutations(transactions);
/** Dirtying can happen above */
this.application.sync();
}
hasAvailableExtensions() {
return (
this.application.actionsManager!.extensionsInContextOfItem(this.note)
this.application.actionsManager.extensionsInContextOfItem(this.note)
.length > 0
);
}
@@ -475,7 +580,9 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
const truncate = noteText.length > NOTE_PREVIEW_CHAR_LIMIT;
const substring = noteText.substring(0, NOTE_PREVIEW_CHAR_LIMIT);
const previewPlain = substring + (truncate ? STRING_ELLIPSES : '');
// eslint-disable-next-line camelcase
noteMutator.preview_plain = previewPlain;
// eslint-disable-next-line camelcase
noteMutator.preview_html = undefined;
}
},
@@ -584,12 +691,6 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
this.closeAllMenus();
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onTitleFocus() {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onTitleBlur() {}
onContentFocus() {
this.application
.getAppState()
@@ -597,17 +698,17 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
this.lastEditorFocusEventSource = undefined;
}
setShowProtectedWarning(show: boolean) {
setShowProtectedOverlay(show: boolean) {
this.appState.notes.setShowProtectedWarning(show);
}
async deleteNote(permanently: boolean) {
if (this.editor.isTemplateNote) {
this.application.alertService!.alert(STRING_DELETE_PLACEHOLDER_ATTEMPT);
this.application.alertService.alert(STRING_DELETE_PLACEHOLDER_ATTEMPT);
return;
}
if (this.note.locked) {
this.application.alertService!.alert(STRING_DELETE_LOCKED_ATTEMPT);
this.application.alertService.alert(STRING_DELETE_LOCKED_ATTEMPT);
return;
}
const title = this.note.safeTitle().length
@@ -723,25 +824,15 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
/** @components */
registerComponentHandler() {
this.unregisterComponent =
this.application.componentManager.registerHandler({
identifier: 'editor',
areas: [ComponentArea.EditorStack, ComponentArea.Editor],
contextRequestHandler: (componentUuid) => {
const currentEditor = this.state.editorComponent;
if (
componentUuid === currentEditor?.uuid ||
Uuids(this.state.stackComponents).includes(componentUuid)
) {
return this.note;
}
},
focusHandler: (component, focused) => {
if (component.isEditor() && focused) {
registerComponentManagerEventObserver() {
this.removeComponentManagerObserver =
this.application.componentManager.addEventObserver((eventName, data) => {
if (eventName === ComponentManagerEvent.ViewerDidFocus) {
const viewer = data?.componentViewer;
if (viewer?.component.isEditor) {
this.closeAllMenus();
}
},
}
});
}
@@ -751,58 +842,98 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
.componentsForArea(ComponentArea.EditorStack)
.filter((component) => component.active)
);
if (this.note) {
for (const component of stackComponents) {
if (component.active) {
this.application.componentManager.setComponentHidden(
component,
!component.isExplicitlyEnabledForItem(this.note.uuid)
);
}
const enabledComponents = stackComponents.filter((component) => {
return component.isExplicitlyEnabledForItem(this.note.uuid);
});
const needsNewViewer = enabledComponents.filter((component) => {
const hasExistingViewer = this.state.stackComponentViewers.find(
(viewer) => viewer.componentUuid === component.uuid
);
return !hasExistingViewer;
});
const needsDestroyViewer = this.state.stackComponentViewers.filter(
(viewer) => {
const viewerComponentExistsInEnabledComponents = enabledComponents.find(
(component) => {
return component.uuid === viewer.componentUuid;
}
);
return !viewerComponentExistsInEnabledComponents;
}
);
const newViewers: ComponentViewer[] = [];
for (const component of needsNewViewer) {
newViewers.push(
this.application.componentManager.createComponentViewer(
component,
this.note.uuid
)
);
}
await this.setState({ stackComponents });
this.application.componentManager.contextItemDidChangeInArea(
ComponentArea.EditorStack
for (const viewer of needsDestroyViewer) {
this.application.componentManager.destroyComponentViewer(viewer);
}
await this.setState({
availableStackComponents: stackComponents,
stackComponentViewers: newViewers,
});
}
stackComponentExpanded(component: SNComponent): boolean {
return !!this.state.stackComponentViewers.find(
(viewer) => viewer.componentUuid === component.uuid
);
}
stackComponentHidden(component: SNComponent) {
return this.application.componentManager?.isComponentHidden(component);
}
async toggleStackComponentForCurrentItem(component: SNComponent) {
const hidden =
this.application.componentManager.isComponentHidden(component);
if (hidden || !component.active) {
this.application.componentManager.setComponentHidden(component, false);
async toggleStackComponent(component: SNComponent) {
if (!component.isExplicitlyEnabledForItem(this.note.uuid)) {
await this.associateComponentWithCurrentNote(component);
this.application.componentManager.contextItemDidChangeInArea(
ComponentArea.EditorStack
);
} else {
this.application.componentManager.setComponentHidden(component, true);
await this.disassociateComponentWithCurrentNote(component);
}
this.application.sync();
}
async disassociateComponentWithCurrentNote(component: SNComponent) {
return this.application.runTransactionalMutation(
this.transactionForDisassociateComponentWithCurrentNote(component)
);
}
transactionForDisassociateComponentWithCurrentNote(component: SNComponent) {
const note = this.note;
return this.application.changeItem(component.uuid, (m) => {
const mutator = m as ComponentMutator;
mutator.removeAssociatedItemId(note.uuid);
mutator.disassociateWithItem(note.uuid);
});
const transaction: TransactionalMutation = {
itemUuid: component.uuid,
mutate: (m: ItemMutator) => {
const mutator = m as ComponentMutator;
mutator.removeAssociatedItemId(note.uuid);
mutator.disassociateWithItem(note.uuid);
},
};
return transaction;
}
async associateComponentWithCurrentNote(component: SNComponent) {
return this.application.runTransactionalMutation(
this.transactionForAssociateComponentWithCurrentNote(component)
);
}
transactionForAssociateComponentWithCurrentNote(component: SNComponent) {
const note = this.note;
return this.application.changeItem(component.uuid, (m) => {
const mutator = m as ComponentMutator;
mutator.removeDisassociatedItemId(note.uuid);
mutator.associateWithItem(note.uuid);
});
const transaction: TransactionalMutation = {
itemUuid: component.uuid,
mutate: (m: ItemMutator) => {
const mutator = m as ComponentMutator;
mutator.removeDisassociatedItemId(note.uuid);
mutator.associateWithItem(note.uuid);
},
};
return transaction;
}
registerKeyboardShortcuts() {

View File

@@ -77,8 +77,8 @@
ng-if='ctrl.state.hasAccountSwitcher'
ng-click='ctrl.openAccountSwitcher()',
)
#account-switcher-icon(ng-class='{"alone": !ctrl.state.hasPasscode}')
svg.info.ionicon
#account-switcher-icon.flex.items-center(ng-class='{"alone": !ctrl.state.hasPasscode}')
svg.info.ionicon.w-5.h-5
use(href="#layers-sharp")
.sk-app-bar-item.border(ng-if='ctrl.state.hasPasscode')
#lock-item.sk-app-bar-item(

View File

@@ -6,7 +6,6 @@ import {
ApplicationEvent,
ContentType,
SNTheme,
ComponentArea,
CollectionSort,
} from '@standardnotes/snjs';
import template from './footer-view.pug';
@@ -43,7 +42,6 @@ class FooterViewCtrl extends PureViewCtrl<
> {
private $rootScope: ng.IRootScopeService;
private showSyncResolution = false;
private unregisterComponent: any;
private rootScopeListener2: any;
public arbitraryStatusMessage?: string;
public user?: any;
@@ -73,8 +71,6 @@ class FooterViewCtrl extends PureViewCtrl<
deinit() {
for (const remove of this.observerRemovers) remove();
this.observerRemovers.length = 0;
this.unregisterComponent();
this.unregisterComponent = undefined;
this.rootScopeListener2();
this.rootScopeListener2 = undefined;
(this.closeAccountMenu as unknown) = undefined;
@@ -146,7 +142,6 @@ class FooterViewCtrl extends PureViewCtrl<
this.updateOfflineStatus();
this.findErrors();
this.streamItems();
this.registerComponentHandler();
}
reloadUser() {
@@ -273,25 +268,6 @@ class FooterViewCtrl extends PureViewCtrl<
);
}
registerComponentHandler() {
this.unregisterComponent =
this.application.componentManager.registerHandler({
identifier: 'room-bar',
areas: [ComponentArea.Modal],
focusHandler: (component, focused) => {
if (component.isEditor() && focused) {
if (
component.package_info?.identifier ===
'org.standardnotes.standard-sheets'
) {
return;
}
this.closeAccountMenu();
}
},
});
}
updateSyncStatus() {
const statusManager = this.application.getStatusManager();
const syncStatus = this.application.getSyncStatus();

View File

@@ -4,6 +4,5 @@ export { ApplicationView } from './application/application_view';
export { EditorGroupView } from './editor_group/editor_group_view';
export { EditorView } from './editor/editor_view';
export { FooterView } from './footer/footer_view';
export { NotesView } from './notes/notes_view';
export { TagsView } from './tags/tags_view';
export { ChallengeModal } from './challenge_modal/challenge_modal';

View File

@@ -1,115 +0,0 @@
#notes-column.sn-component.section.notes(aria-label='Notes')
.content
#notes-title-bar.section-title-bar
.p-4
.section-title-bar-header
.sk-h2.font-semibold.title {{self.state.panelTitle}}
.sk-button.contrast.wide(
ng-click='self.createNewNote()',
title='Create a new note in the selected tag'
aria-label="Create new note"
)
.sk-label
i.icon.ion-plus.add-button
.filter-section(role='search')
input#search-bar.filter-bar(
type="text"
ng-ref='self.searchBarInput'
ng-focus='self.onSearchInputFocus()'
ng-blur='self.onSearchInputBlur()',
ng-change='self.filterTextChanged()',
ng-keyup='$event.keyCode == 13 && self.onFilterEnter();',
ng-model='self.state.noteFilter.text',
placeholder='Search',
select-on-focus='true',
title='Searches notes in the currently selected tag'
)
#search-clear-button(
ng-click='self.clearFilterText();',
ng-show='self.state.noteFilter.text'
aria-role="button"
) ✕
search-options(
class="ml-2"
app-state='self.appState'
)
no-account-warning(
application='self.application'
app-state='self.appState'
)
#notes-menu-bar.sn-component
.sk-app-bar.no-edges
.left
.sk-app-bar-item(
ng-class="{'selected' : self.state.mutable.showMenu}",
ng-click='self.state.mutable.showMenu = !self.state.mutable.showMenu'
)
.sk-app-bar-item-column
.sk-label
| Options
.sk-app-bar-item-column
.sk-sublabel {{self.optionsSubtitle()}}
notes-list-options-menu(
ng-if='self.state.mutable.showMenu'
app-state='self.appState'
application='self.application'
set-show-menu-false='self.setShowMenuFalse'
)
p.empty-notes-list.faded(
ng-if="self.state.completedFullSync && !self.state.renderedNotes.length"
) No notes.
p.empty-notes-list.faded(
ng-if="!self.state.completedFullSync && !self.state.renderedNotes.length"
) Loading notes…
.scrollable(ng-if="self.state.renderedNotes.length")
#notes-scrollable.infinite-scroll(
can-load='true',
infinite-scroll='self.paginate()',
threshold='200'
)
.note(
ng-attr-id='note-{{note.uuid}}'
ng-repeat='note in self.state.renderedNotes track by note.uuid'
ng-class="{'selected' : self.isNoteSelected(note.uuid) }"
ng-click='self.selectNote(note, true)'
)
.note-flags.flex.flex-wrap(ng-show='self.noteFlags[note.uuid].length > 0')
.flag(ng-class='flag.class', ng-repeat='flag in self.noteFlags[note.uuid]')
.label {{flag.text}}
.name(ng-show='note.title')
| {{note.title}}
.note-preview(
ng-if=`
!self.state.hideNotePreview &&
!note.hidePreview &&
!note.protected`
)
.html-preview(
ng-bind-html='note.preview_html',
ng-show='note.preview_html'
)
.plain-preview(
ng-show='!note.preview_html && note.preview_plain'
) {{note.preview_plain}}
.default-preview(
ng-show='!note.preview_html && !note.preview_plain'
) {{note.text}}
.bottom-info.faded(ng-show='!self.state.hideDate || note.protected')
span(ng-if="note.protected")
| Protected{{self.state.hideDate ? '' : ' • '}}
span(ng-show="!self.state.hideDate && self.state.sortBy == 'userModifiedDate'")
| Modified {{note.updatedAtString || 'Now'}}
span(ng-show="!self.state.hideDate && self.state.sortBy != 'userModifiedDate'")
| {{note.createdAtString || 'Now'}}
.tags-string(ng-if='!self.state.hideTags && self.state.renderedNotesTags[$index]')
.faded {{self.state.renderedNotesTags[$index]}}
panel-resizer(
collapsable="true"
control="self.panelPuppet"
default-width="300"
hoverable="true"
on-resize-finish="self.onPanelResize"
on-width-event="self.onPanelWidthEvent"
panel-id="'notes-column'"
)

View File

@@ -1,955 +0,0 @@
import { PanelPuppet, WebDirective } from './../../types';
import template from './notes-view.pug';
import {
ApplicationEvent,
ContentType,
removeFromArray,
SNNote,
SNTag,
PrefKey,
findInArray,
CollectionSort,
UuidString,
NotesDisplayCriteria
} from '@standardnotes/snjs';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
import { AppStateEvent } from '@/ui_models/app_state';
import { KeyboardKey, KeyboardModifier } from '@/services/ioService';
import {
PANEL_NAME_NOTES
} from '@/views/constants';
type NotesCtrlState = {
panelTitle: string
notes: SNNote[]
renderedNotes: SNNote[]
renderedNotesTags: string[],
selectedNotes: Record<UuidString, SNNote>,
sortBy?: string
sortReverse?: boolean
showArchived?: boolean
hidePinned?: boolean
hideNotePreview?: boolean
hideDate?: boolean
hideTags: boolean
noteFilter: {
text: string;
}
searchOptions: {
includeProtectedContents: boolean;
includeArchived: boolean;
includeTrashed: boolean;
}
mutable: { showMenu: boolean }
completedFullSync: boolean
[PrefKey.TagsPanelWidth]?: number
[PrefKey.NotesPanelWidth]?: number
[PrefKey.EditorWidth]?: number
[PrefKey.EditorLeft]?: number
[PrefKey.EditorMonospaceEnabled]?: boolean
[PrefKey.EditorSpellcheck]?: boolean
[PrefKey.EditorResizersEnabled]?: boolean
[PrefKey.NotesShowTrashed]?: boolean
[PrefKey.NotesHideProtected]?: boolean
}
type NoteFlag = {
text: string
class: 'info' | 'neutral' | 'warning' | 'success' | 'danger'
}
/**
* This is the height of a note cell with nothing but the title,
* which *is* a display option
*/
const MIN_NOTE_CELL_HEIGHT = 51.0;
const DEFAULT_LIST_NUM_NOTES = 20;
const ELEMENT_ID_SEARCH_BAR = 'search-bar';
const ELEMENT_ID_SCROLL_CONTAINER = 'notes-scrollable';
class NotesViewCtrl extends PureViewCtrl<unknown, NotesCtrlState> {
private panelPuppet?: PanelPuppet
private reloadNotesPromise?: any
private notesToDisplay = 0
private pageSize = 0
private searchSubmitted = false
private newNoteKeyObserver: any
private nextNoteKeyObserver: any
private previousNoteKeyObserver: any
private searchKeyObserver: any
private noteFlags: Partial<Record<UuidString, NoteFlag[]>> = {}
private removeObservers: Array<() => void> = [];
private rightClickListeners: Map<UuidString, (e: MouseEvent) => void> = new Map();
/* @ngInject */
constructor($timeout: ng.ITimeoutService,) {
super($timeout);
this.resetPagination();
}
$onInit() {
super.$onInit();
this.panelPuppet = {
onReady: () => this.reloadPanelWidth()
};
this.onWindowResize = this.onWindowResize.bind(this);
this.onPanelResize = this.onPanelResize.bind(this);
this.onPanelWidthEvent = this.onPanelWidthEvent.bind(this);
this.setShowMenuFalse = this.setShowMenuFalse.bind(this);
window.addEventListener('resize', this.onWindowResize, true);
this.registerKeyboardShortcuts();
this.autorun(async () => {
const {
includeProtectedContents,
includeArchived,
includeTrashed,
} = this.appState.searchOptions;
await this.setState({
searchOptions: {
includeProtectedContents,
includeArchived,
includeTrashed,
}
});
if (this.state.noteFilter.text) {
this.reloadNotesDisplayOptions();
this.reloadNotes();
}
});
this.autorun(() => {
this.setState({
selectedNotes: this.appState.notes.selectedNotes,
});
});
}
onWindowResize() {
this.resetPagination(true);
}
deinit() {
for (const remove of this.removeObservers) remove();
this.removeObservers.length = 0;
this.removeRightClickListeners();
this.panelPuppet!.onReady = undefined;
this.panelPuppet = undefined;
window.removeEventListener('resize', this.onWindowResize, true);
(this.onWindowResize as any) = undefined;
(this.onPanelResize as any) = undefined;
(this.onPanelWidthEvent as any) = undefined;
this.newNoteKeyObserver();
this.nextNoteKeyObserver();
this.previousNoteKeyObserver();
this.searchKeyObserver();
this.newNoteKeyObserver = undefined;
this.nextNoteKeyObserver = undefined;
this.previousNoteKeyObserver = undefined;
this.searchKeyObserver = undefined;
super.deinit();
}
async setNotesState(state: Partial<NotesCtrlState>) {
return this.setState(state);
}
getInitialState(): NotesCtrlState {
return {
notes: [],
renderedNotes: [],
renderedNotesTags: [],
selectedNotes: {},
mutable: { showMenu: false },
noteFilter: {
text: '',
},
searchOptions: {
includeArchived: false,
includeProtectedContents: false,
includeTrashed: false,
},
panelTitle: '',
completedFullSync: false,
hideTags: true
};
}
async onAppLaunch() {
super.onAppLaunch();
this.streamNotesAndTags();
this.reloadPreferences();
}
/** @override */
onAppStateEvent(eventName: AppStateEvent, data?: any) {
if (eventName === AppStateEvent.TagChanged) {
this.handleTagChange(this.selectedTag!);
} else if (eventName === AppStateEvent.ActiveEditorChanged) {
this.handleEditorChange();
} else if (eventName === AppStateEvent.EditorFocused) {
this.setShowMenuFalse();
}
}
private get activeEditorNote() {
return this.appState.notes.activeEditor?.note;
}
/** @template */
public isNoteSelected(uuid: UuidString) {
return !!this.state.selectedNotes[uuid];
}
public get editorNotes() {
return this.appState.getEditors().map((editor) => editor.note);
}
/** @override */
async onAppEvent(eventName: ApplicationEvent) {
switch (eventName) {
case ApplicationEvent.PreferencesChanged:
this.reloadPreferences();
break;
case ApplicationEvent.SignedIn:
this.appState.closeAllEditors();
this.selectFirstNote();
this.setState({
completedFullSync: false,
});
break;
case ApplicationEvent.CompletedFullSync:
this.getMostValidNotes().then((notes) => {
if (notes.length === 0 && this.selectedTag?.isAllTag && this.state.noteFilter.text === '') {
this.createPlaceholderNote();
}
});
this.setState({
completedFullSync: true,
});
break;
}
}
/**
* Access the current state notes without awaiting any potential reloads
* that may be in progress. This is the sync alternative to `async getMostValidNotes`
*/
private getPossiblyStaleNotes() {
return this.state.notes;
}
/**
* Access the current state notes after waiting for any pending reloads.
* This returns the most up to date notes, but is the asyncronous counterpart
* to `getPossiblyStaleNotes`
*/
private async getMostValidNotes() {
await this.reloadNotesPromise;
return this.getPossiblyStaleNotes();
}
/**
* Triggered programatically to create a new placeholder note
* when conditions allow for it. This is as opposed to creating a new note
* as part of user interaction (pressing the + button).
*/
private async createPlaceholderNote() {
const selectedTag = this.selectedTag!;
if (selectedTag.isSmartTag && !selectedTag.isAllTag) {
return;
}
return this.createNewNote(false);
}
streamNotesAndTags() {
this.removeObservers.push(this.application.streamItems(
[ContentType.Note],
async (items) => {
const notes = items as SNNote[];
/** Note has changed values, reset its flags */
for (const note of notes) {
if (note.deleted) {
continue;
}
this.loadFlagsForNote(note);
}
/** If a note changes, it will be queried against the existing filter;
* we dont need to reload display options */
await this.reloadNotes();
const activeNote = this.activeEditorNote;
if (this.application.getAppState().notes.selectedNotesCount < 2) {
if (activeNote) {
const discarded = activeNote.deleted || activeNote.trashed;
if (
discarded &&
!this.appState?.selectedTag?.isTrashTag &&
!this.appState?.searchOptions.includeTrashed
) {
this.selectNextOrCreateNew();
} else if (!this.state.selectedNotes[activeNote.uuid]) {
this.selectNote(activeNote);
}
} else {
this.selectFirstNote();
}
}
}
));
this.removeObservers.push(this.application.streamItems(
[ContentType.Tag],
async (items) => {
const tags = items as SNTag[];
/** A tag could have changed its relationships, so we need to reload the filter */
this.reloadNotesDisplayOptions();
await this.reloadNotes();
if (findInArray(tags, 'uuid', this.appState.selectedTag?.uuid)) {
/** Tag title could have changed */
this.reloadPanelTitle();
}
}
));
}
private async openNotesContextMenu(e: MouseEvent, note: SNNote) {
e.preventDefault();
if (!this.state.selectedNotes[note.uuid]) {
await this.selectNote(note, true);
}
if (this.state.selectedNotes[note.uuid]) {
this.appState.notes.setContextMenuClickLocation({
x: e.clientX,
y: e.clientY,
});
this.appState.notes.reloadContextMenuLayout();
this.appState.notes.setContextMenuOpen(true);
}
}
private removeRightClickListeners() {
for (const [noteUuid, listener] of this.rightClickListeners.entries()) {
document
.getElementById(`note-${noteUuid}`)
?.removeEventListener('contextmenu', listener);
}
this.rightClickListeners.clear();
}
private addRightClickListeners() {
for (const [noteUuid, listener] of this.rightClickListeners.entries()) {
if (!this.state.renderedNotes.find(note => note.uuid === noteUuid)) {
document
.getElementById(`note-${noteUuid}`)
?.removeEventListener('contextmenu', listener);
this.rightClickListeners.delete(noteUuid);
}
}
for (const note of this.state.renderedNotes) {
if (!this.rightClickListeners.has(note.uuid)) {
const listener = async (e: MouseEvent): Promise<void> => {
return await this.openNotesContextMenu(e, note);
};
document
.getElementById(`note-${note.uuid}`)
?.addEventListener('contextmenu', listener);
this.rightClickListeners.set(note.uuid, listener);
}
}
}
async selectNote(note: SNNote, userTriggered?: boolean): Promise<void> {
await this.appState.notes.selectNote(note.uuid, userTriggered);
}
async createNewNote(focusNewNote = true) {
this.appState.notes.unselectNotes();
let title = `Note ${this.state.notes.length + 1}`;
if (this.isFiltering()) {
title = this.state.noteFilter.text;
}
await this.appState.createEditor(title);
await this.flushUI();
await this.reloadNotes();
await this.appState.noteTags.reloadTags();
const noteTitleEditorElement = document.getElementById('note-title-editor');
if (focusNewNote) {
noteTitleEditorElement?.focus();
}
}
async handleTagChange(tag: SNTag) {
this.resetScrollPosition();
this.setShowMenuFalse();
await this.setNoteFilterText('');
this.application.getDesktopService().searchText();
this.resetPagination();
/* Capture db load state before beginning reloadNotes,
since this status may change during reload */
const dbLoaded = this.application.isDatabaseLoaded();
this.reloadNotesDisplayOptions();
await this.reloadNotes();
if (this.state.notes.length > 0) {
this.selectFirstNote();
} else if (dbLoaded) {
if (
this.activeEditorNote &&
!this.state.notes.includes(this.activeEditorNote!)
) {
this.appState.closeActiveEditor();
}
}
}
resetScrollPosition() {
const scrollable = document.getElementById(ELEMENT_ID_SCROLL_CONTAINER);
if (scrollable) {
scrollable.scrollTop = 0;
scrollable.scrollLeft = 0;
}
}
async removeNoteFromList(note: SNNote) {
const notes = this.state.notes;
removeFromArray(notes, note);
await this.setNotesState({
notes: notes,
renderedNotes: notes.slice(0, this.notesToDisplay)
});
}
private async reloadNotes() {
this.reloadNotesPromise = this.performReloadNotes();
return this.reloadNotesPromise;
}
/**
* Note that reloading display options destroys the current index and rebuilds it,
* so call sparingly. The runtime complexity of destroying and building
* an index is roughly O(n^2).
*/
private reloadNotesDisplayOptions() {
const tag = this.appState.selectedTag;
const searchText = this.state.noteFilter.text.toLowerCase();
const isSearching = searchText.length;
let includeArchived: boolean;
let includeTrashed: boolean;
if (isSearching) {
includeArchived = this.state.searchOptions.includeArchived;
includeTrashed = this.state.searchOptions.includeTrashed;
} else {
includeArchived = this.state.showArchived ?? false;
includeTrashed = this.state.showTrashed ?? false;
}
const criteria = NotesDisplayCriteria.Create({
sortProperty: this.state.sortBy as CollectionSort,
sortDirection: this.state.sortReverse ? 'asc' : 'dsc',
tags: tag ? [tag] : [],
includeArchived,
includeTrashed,
includePinned: !this.state.hidePinned,
includeProtected: !this.state.hideProtected,
searchQuery: {
query: searchText,
includeProtectedNoteText: this.state.searchOptions.includeProtectedContents
}
});
this.application.setNotesDisplayCriteria(criteria);
}
private get selectedTag() {
return this.application.getAppState().getSelectedTag();
}
private async performReloadNotes() {
const tag = this.appState.selectedTag!;
if (!tag) {
return;
}
const notes = this.application.getDisplayableItems(
ContentType.Note
) as SNNote[];
const renderedNotes = notes.slice(0, this.notesToDisplay);
const renderedNotesTags = this.notesTagsList(renderedNotes);
await this.setNotesState({
notes,
renderedNotesTags,
renderedNotes,
});
this.reloadPanelTitle();
this.addRightClickListeners();
}
private notesTagsList(notes: SNNote[]): string[] {
if (this.state.hideTags) {
return [];
} else {
const selectedTag = this.appState.selectedTag;
if (!selectedTag) {
return [];
} else if (selectedTag?.isSmartTag) {
return notes.map((note) =>
this.appState
.getNoteTags(note)
.map((tag) => '#' + tag.title)
.join(' ')
);
} else {
/**
* Displaying a normal tag, hide the note's tag when there's only one
*/
return notes.map((note) => {
const tags = this.appState.getNoteTags(note);
if (tags.length === 1) return '';
return tags.map((tag) => '#' + tag.title).join(' ');
});
}
}
}
setShowMenuFalse() {
this.setNotesState({
mutable: {
...this.state.mutable,
showMenu: false
}
});
}
async handleEditorChange() {
const activeNote = this.appState.getActiveEditor()?.note;
if (activeNote && activeNote.conflictOf) {
this.application.changeAndSaveItem(activeNote.uuid, (mutator) => {
mutator.conflictOf = undefined;
});
}
if (this.isFiltering()) {
this.application.getDesktopService().searchText(this.state.noteFilter.text);
}
}
async reloadPreferences() {
const viewOptions = {} as NotesCtrlState;
const prevSortValue = this.state.sortBy;
let sortBy = this.application.getPreference(
PrefKey.SortNotesBy,
CollectionSort.CreatedAt
);
if (
sortBy === CollectionSort.UpdatedAt ||
(sortBy as string) === "client_updated_at"
) {
/** Use UserUpdatedAt instead */
sortBy = CollectionSort.UpdatedAt;
}
viewOptions.sortBy = sortBy;
viewOptions.sortReverse = this.application.getPreference(
PrefKey.SortNotesReverse,
false
);
viewOptions.showArchived = this.application.getPreference(
PrefKey.NotesShowArchived,
false
);
viewOptions.showTrashed = this.application.getPreference(
PrefKey.NotesShowTrashed,
false
) as boolean;
viewOptions.hidePinned = this.application.getPreference(
PrefKey.NotesHidePinned,
false
);
viewOptions.hideProtected = this.application.getPreference(
PrefKey.NotesHideProtected,
false
);
viewOptions.hideNotePreview = this.application.getPreference(
PrefKey.NotesHideNotePreview,
false
);
viewOptions.hideDate = this.application.getPreference(
PrefKey.NotesHideDate,
false
);
viewOptions.hideTags = this.application.getPreference(
PrefKey.NotesHideTags,
true,
);
const state = this.state;
const displayOptionsChanged = (
viewOptions.sortBy !== state.sortBy ||
viewOptions.sortReverse !== state.sortReverse ||
viewOptions.hidePinned !== state.hidePinned ||
viewOptions.showArchived !== state.showArchived ||
viewOptions.showTrashed !== state.showTrashed ||
viewOptions.hideProtected !== state.hideProtected ||
viewOptions.hideTags !== state.hideTags
);
await this.setNotesState({
...viewOptions
});
this.reloadPanelWidth();
if (displayOptionsChanged) {
this.reloadNotesDisplayOptions();
}
await this.reloadNotes();
if (prevSortValue && prevSortValue !== sortBy) {
this.selectFirstNote();
}
}
reloadPanelWidth() {
const width = this.application.getPreference(
PrefKey.NotesPanelWidth
);
if (width && this.panelPuppet!.ready) {
this.panelPuppet!.setWidth!(width);
if (this.panelPuppet!.isCollapsed!()) {
this.application.getAppState().panelDidResize(
PANEL_NAME_NOTES,
this.panelPuppet!.isCollapsed!()
);
}
}
}
onPanelResize(
newWidth: number,
newLeft: number,
__: boolean,
isCollapsed: boolean
) {
this.appState.noteTags.reloadTagsContainerMaxWidth();
this.application.setPreference(
PrefKey.NotesPanelWidth,
newWidth
);
this.application.getAppState().panelDidResize(
PANEL_NAME_NOTES,
isCollapsed
);
}
onPanelWidthEvent(): void {
this.appState.noteTags.reloadTagsContainerMaxWidth();
}
paginate() {
this.notesToDisplay += this.pageSize;
this.reloadNotes();
if (this.searchSubmitted) {
this.application.getDesktopService().searchText(this.state.noteFilter.text);
}
}
resetPagination(keepCurrentIfLarger = false) {
const clientHeight = document.documentElement.clientHeight;
this.pageSize = Math.ceil(clientHeight / MIN_NOTE_CELL_HEIGHT);
if (this.pageSize === 0) {
this.pageSize = DEFAULT_LIST_NUM_NOTES;
}
if (keepCurrentIfLarger && this.notesToDisplay > this.pageSize) {
return;
}
this.notesToDisplay = this.pageSize;
}
reloadPanelTitle() {
let title;
if (this.isFiltering()) {
const resultCount = this.state.notes.length;
title = `${resultCount} search results`;
} else if (this.appState.selectedTag) {
title = `${this.appState.selectedTag.title}`;
}
this.setNotesState({
panelTitle: title
});
}
optionsSubtitle() {
let base = "";
if (this.state.sortBy === CollectionSort.CreatedAt) {
base += " Date Added";
} else if (this.state.sortBy === CollectionSort.UpdatedAt) {
base += " Date Modified";
} else if (this.state.sortBy === CollectionSort.Title) {
base += " Title";
}
if (this.state.showArchived) {
base += " | + Archived";
}
if (this.state.showTrashed) {
base += " | + Trashed";
}
if (this.state.hidePinned) {
base += " | Pinned";
}
if (this.state.hideProtected) {
base += " | Protected";
}
if (this.state.sortReverse) {
base += " | Reversed";
}
return base;
}
loadFlagsForNote(note: SNNote) {
const flags = [] as NoteFlag[];
if (note.pinned) {
flags.push({
text: "Pinned",
class: 'info'
});
}
if (note.archived) {
flags.push({
text: "Archived",
class: 'warning'
});
}
if (note.locked) {
flags.push({
text: "Editing Disabled",
class: 'neutral'
});
}
if (note.trashed) {
flags.push({
text: "Deleted",
class: 'danger'
});
}
if (note.conflictOf) {
flags.push({
text: "Conflicted Copy",
class: 'danger'
});
}
if (note.errorDecrypting) {
if (note.waitingForKey) {
flags.push({
text: "Waiting For Keys",
class: 'info'
});
} else {
flags.push({
text: "Missing Keys",
class: 'danger'
});
}
}
if (note.deleted) {
flags.push({
text: "Deletion Pending Sync",
class: 'danger'
});
}
this.noteFlags[note.uuid] = flags;
}
getFirstNonProtectedNote() {
return this.state.notes.find(note => !note.protected);
}
selectFirstNote() {
const note = this.getFirstNonProtectedNote();
if (note) {
this.selectNote(note);
}
}
selectNextNote() {
const displayableNotes = this.state.notes;
const currentIndex = displayableNotes.findIndex((candidate) => {
return candidate.uuid === this.activeEditorNote?.uuid;
});
if (currentIndex + 1 < displayableNotes.length) {
const nextNote = displayableNotes[currentIndex + 1];
this.selectNote(nextNote);
const nextNoteElement = document.getElementById(`note-${nextNote.uuid}`);
nextNoteElement?.focus();
}
}
selectNextOrCreateNew() {
const note = this.getFirstNonProtectedNote();
if (note) {
this.selectNote(note);
} else {
this.appState.closeActiveEditor();
}
}
selectPreviousNote() {
const displayableNotes = this.state.notes;
const currentIndex = displayableNotes.indexOf(this.activeEditorNote!);
if (currentIndex - 1 >= 0) {
const previousNote = displayableNotes[currentIndex - 1];
this.selectNote(previousNote);
const previousNoteElement = document.getElementById(`note-${previousNote.uuid}`);
previousNoteElement?.focus();
return true;
} else {
return false;
}
}
isFiltering() {
return this.state.noteFilter.text &&
this.state.noteFilter.text.length > 0;
}
async setNoteFilterText(text: string) {
await this.setNotesState({
noteFilter: {
...this.state.noteFilter,
text: text
}
});
}
async clearFilterText() {
await this.setNoteFilterText('');
this.onFilterEnter();
this.filterTextChanged();
this.resetPagination();
}
async filterTextChanged() {
if (this.searchSubmitted) {
this.searchSubmitted = false;
}
this.reloadNotesDisplayOptions();
await this.reloadNotes();
}
async onSearchInputBlur() {
this.appState.searchOptions.refreshIncludeProtectedContents();
}
onFilterEnter() {
/**
* For Desktop, performing a search right away causes
* input to lose focus. We wait until user explicity hits
* enter before highlighting desktop search results.
*/
this.searchSubmitted = true;
this.application.getDesktopService().searchText(this.state.noteFilter.text);
}
selectedMenuItem() {
this.setShowMenuFalse();
}
togglePrefKey(key: PrefKey) {
this.application.setPreference(
key,
!this.state[key]
);
}
selectedSortByCreated() {
this.setSortBy(CollectionSort.CreatedAt);
}
selectedSortByUpdated() {
this.setSortBy(CollectionSort.UpdatedAt);
}
selectedSortByTitle() {
this.setSortBy(CollectionSort.Title);
}
toggleReverseSort() {
this.selectedMenuItem();
this.application.setPreference(
PrefKey.SortNotesReverse,
!this.state.sortReverse
);
}
setSortBy(type: CollectionSort) {
this.application.setPreference(
PrefKey.SortNotesBy,
type
);
}
getSearchBar() {
return document.getElementById(ELEMENT_ID_SEARCH_BAR)!;
}
registerKeyboardShortcuts() {
/**
* In the browser we're not allowed to override cmd/ctrl + n, so we have to
* use Control modifier as well. These rules don't apply to desktop, but
* probably better to be consistent.
*/
this.newNoteKeyObserver = this.application.io.addKeyObserver({
key: 'n',
modifiers: [
KeyboardModifier.Meta,
KeyboardModifier.Ctrl
],
onKeyDown: (event) => {
event.preventDefault();
this.createNewNote();
}
});
this.nextNoteKeyObserver = this.application.io.addKeyObserver({
key: KeyboardKey.Down,
elements: [
document.body,
this.getSearchBar()
],
onKeyDown: () => {
const searchBar = this.getSearchBar();
if (searchBar === document.activeElement) {
searchBar.blur();
}
this.selectNextNote();
}
});
this.previousNoteKeyObserver = this.application.io.addKeyObserver({
key: KeyboardKey.Up,
element: document.body,
onKeyDown: () => {
this.selectPreviousNote();
}
});
this.searchKeyObserver = this.application.io.addKeyObserver({
key: "f",
modifiers: [
KeyboardModifier.Meta,
KeyboardModifier.Shift
],
onKeyDown: () => {
const searchBar = this.getSearchBar();
if (searchBar) { searchBar.focus(); }
}
});
}
}
export class NotesView extends WebDirective {
constructor() {
super();
this.template = template;
this.replace = true;
this.controller = NotesViewCtrl;
this.controllerAs = 'self';
this.bindToController = true;
this.scope = {
application: '='
};
}
}

View File

@@ -1,11 +1,11 @@
#tags-column.sn-component.section.tags(aria-label='Tags')
.component-view-container(ng-if='self.component')
.component-view-container(ng-if='self.state.componentViewer')
component-view.component-view(
component-uuid='self.component.uuid',
component-viewer='self.state.componentViewer',
application='self.application'
app-state='self.appState'
)
#tags-content.content(ng-if='!(self.component)')
#tags-content.content(ng-if='!(self.state.componentViewer)')
.tags-title-section.section-title-bar
.section-title-bar-header
.sk-h3.title
@@ -29,11 +29,7 @@
.danger.small-text.font-bold(ng-show='tag.conflictOf') Conflicted Copy
.danger.small-text.font-bold(ng-show='tag.errorDecrypting && !tag.waitingForKey') Missing Keys
.info.small-text.font-bold(ng-show='tag.errorDecrypting && tag.waitingForKey') Waiting For Keys
.tags-title-section.section-title-bar
.section-title-bar-header
.sk-h3.title
span.sk-bold Tags
tags(
tags-section(
application='self.application',
app-state='self.appState'
)

View File

@@ -6,7 +6,11 @@ import {
ApplicationEvent,
ComponentAction,
ComponentArea,
ComponentViewer,
ContentType,
isPayloadSourceInternalChange,
MessageData,
PayloadSource,
PrefKey,
SNComponent,
SNSmartTag,
@@ -22,14 +26,14 @@ type TagState = {
smartTags: SNSmartTag[];
noteCounts: NoteCounts;
selectedTag?: SNTag;
componentViewer?: ComponentViewer;
};
class TagsViewCtrl extends PureViewCtrl<unknown, TagState> {
/** Passed through template */
readonly application!: WebApplication;
private readonly panelPuppet: PanelPuppet;
private unregisterComponent?: any;
component?: SNComponent;
private unregisterComponent?: () => void;
/** The original name of the edtingTag before it began editing */
formData: { tagTitle?: string } = {};
titles: Partial<Record<UuidString, string>> = {};
@@ -46,9 +50,9 @@ class TagsViewCtrl extends PureViewCtrl<unknown, TagState> {
deinit() {
this.removeTagsObserver?.();
(this.removeTagsObserver as any) = undefined;
(this.removeFoldersObserver as any) = undefined;
this.unregisterComponent();
(this.removeTagsObserver as unknown) = undefined;
(this.removeFoldersObserver as unknown) = undefined;
this.unregisterComponent?.();
this.unregisterComponent = undefined;
super.deinit();
}
@@ -64,15 +68,10 @@ class TagsViewCtrl extends PureViewCtrl<unknown, TagState> {
return this.state;
}
async onAppStart() {
super.onAppStart();
this.registerComponentHandler();
}
async onAppLaunch() {
super.onAppLaunch();
this.loadPreferences();
this.beginStreamingItems();
this.streamForFoldersComponent();
const smartTags = this.application.getSmartTags();
this.setState({ smartTags });
@@ -85,13 +84,78 @@ class TagsViewCtrl extends PureViewCtrl<unknown, TagState> {
this.reloadNoteCounts();
}
beginStreamingItems() {
async setFoldersComponent(component?: SNComponent) {
if (this.state.componentViewer) {
this.application.componentManager.destroyComponentViewer(
this.state.componentViewer
);
await this.setState({ componentViewer: undefined });
}
if (component) {
await this.setState({
componentViewer:
this.application.componentManager.createComponentViewer(
component,
undefined,
this.handleFoldersComponentMessage.bind(this)
),
});
}
}
handleFoldersComponentMessage(
action: ComponentAction,
data: MessageData
): void {
if (action === ComponentAction.SelectItem) {
const item = data.item;
if (!item) {
return;
}
if (item.content_type === ContentType.Tag) {
const matchingTag = this.application.findItem(item.uuid);
if (matchingTag) {
this.selectTag(matchingTag as SNTag);
}
} else if (item.content_type === ContentType.SmartTag) {
const matchingTag = this.getState().smartTags.find(
(t) => t.uuid === item.uuid
);
if (matchingTag) {
this.selectTag(matchingTag);
}
}
} else if (action === ComponentAction.ClearSelection) {
this.selectTag(this.getState().smartTags[0]);
}
}
streamForFoldersComponent() {
this.removeFoldersObserver = this.application.streamItems(
[ContentType.Component],
async () => {
this.component = this.application.componentManager
.componentsForArea(ComponentArea.TagsList).find((component) => component.active);
});
async (items, source) => {
if (
isPayloadSourceInternalChange(source) ||
source === PayloadSource.InitialObserverRegistrationPush
) {
return;
}
const components = items as SNComponent[];
const hasFoldersChange = !!components.find(
(component) => component.area === ComponentArea.TagsList
);
if (hasFoldersChange) {
this.setFoldersComponent(
this.application.componentManager
.componentsForArea(ComponentArea.TagsList)
.find((component) => component.active)
);
}
}
);
this.removeTagsObserver = this.application.streamItems(
[ContentType.Tag, ContentType.SmartTag],
@@ -200,41 +264,6 @@ class TagsViewCtrl extends PureViewCtrl<unknown, TagState> {
this.application.getAppState().panelDidResize(PANEL_NAME_TAGS, isCollapsed);
};
registerComponentHandler() {
this.unregisterComponent =
this.application.componentManager.registerHandler({
identifier: 'tags',
areas: [ComponentArea.TagsList],
actionHandler: (_, action, data) => {
if (action === ComponentAction.SelectItem) {
const item = data.item;
if (!item) {
return;
}
if (item.content_type === ContentType.Tag) {
const matchingTag = this.application.findItem(item.uuid);
if (matchingTag) {
this.selectTag(matchingTag as SNTag);
}
} else if (item.content_type === ContentType.SmartTag) {
const matchingTag = this.getState().smartTags.find(
(t) => t.uuid === item.uuid
);
if (matchingTag) {
this.selectTag(matchingTag);
}
}
} else if (action === ComponentAction.ClearSelection) {
this.selectTag(this.getState().smartTags[0]);
}
},
});
}
async selectTag(tag: SNTag) {
if (tag.conflictOf) {
this.application.changeAndSaveItem(tag.uuid, (mutator) => {

View File

@@ -36,7 +36,7 @@
}
.section.tags,
.section.notes {
notes-view {
will-change: opacity;
animation: fade-out 1.25s forwards;
transition: width 1.25s;
@@ -50,7 +50,7 @@
width: 0px !important;
}
.section.notes:hover {
notes-view:hover {
flex: initial;
width: 0px !important;
}
@@ -58,7 +58,7 @@
.disable-focus-mode {
.section.tags,
.section.notes {
notes-view {
transition: width 1.25s;
will-change: opacity;
animation: fade-in 1.25s forwards;

View File

@@ -136,7 +136,7 @@ $footer-height: 2rem;
overflow: hidden;
position: relative;
panel-resizer {
panel-resizer, .panel-resizer {
top: 0;
right: 0;
z-index: $z-index-panel-resizer;

View File

@@ -1,11 +1,16 @@
notes-view {
width: 350px;
}
#notes-column,
.notes {
width: 100%;
border-left: 1px solid var(--sn-stylekit-border-color);
border-right: 1px solid var(--sn-stylekit-border-color);
font-size: var(--sn-stylekit-font-size-h2);
width: 350px;
flex-grow: 0;
user-select: none;
@@ -71,6 +76,8 @@
}
#search-clear-button {
padding: 0;
border: none;
border-radius: 50%;
width: 17px;
height: 17px;

View File

@@ -1,11 +1,10 @@
/* Components and utilities that are good candidates for extraction to StyleKit. */
:root {
--sn-stylekit-grey-2: #f8f9fc;
}
.bg-grey-2 {
background-color: var(--sn-stylekit-grey-2);
.bg-grey-super-light {
background-color: var(--sn-stylekit-grey-super-light);
}
.h-90vh {
@@ -788,6 +787,15 @@
}
}
.sn-button {
&.normal-focus-brightness {
&:hover,
&:focus {
filter: brightness(100%);
}
}
}
@media screen and (max-width: $screen-md-min) {
.sn-component {
.md\:hidden {

View File

@@ -51,7 +51,35 @@
margin-top: -5px;
}
.root-drop {
width: '100%';
padding: 12px;
opacity: 0;
transition: opacity 0.3s ease-in;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
.sn-icon {
margin-right: 0.5rem;
}
&.active {
opacity: 1;
&.is-over {
background-color: var(--sn-stylekit-white);
color: var(--sn-stylekit-secondary-contrast-foreground-color);
}
}
}
.tag {
font-size: 14px;
line-height: 18px;
min-height: 30px;
padding: 5px 12px;
cursor: pointer;
@@ -65,17 +93,42 @@
align-items: center;
justify-content: space-between;
.sn-icon {
display: block;
margin: 0 auto;
&.hidden {
visibility: hidden;
}
}
> .tag-fold {
width: 22px;
display: flex;
align-items: center;
height: 100%;
}
> .tag-icon {
width: 10px;
opacity: 0.2;
font-size: var(--sn-stylekit-font-size-h2);
font-weight: bold;
margin-right: 6px;
display: flex;
align-items: center;
height: 100%;
&.draggable {
cursor: move;
}
&.propose-folders {
cursor: help;
}
}
> .title {
@extend .focus\:outline-none;
@extend .focus\:shadow-none;
font-size: 14px;
line-height: 18px;
width: 80%;
background-color: transparent;
font-weight: 600;
@@ -84,6 +137,7 @@
cursor: pointer;
text-overflow: ellipsis;
width: 75%;
flex-grow: 1;
// Required for Safari to avoid highlighting when dragging panel resizers
// Make sure to undo if it's selected (for editing)
@@ -118,21 +172,29 @@
}
}
> .menu {
font-size: 11px;
.meta {
padding-left: 3px;
> .item {
margin-right: 2px;
&.with-folders {
padding-left: 25px;
}
opacity: 0.5;
font-weight: bold;
clear: both;
margin-top: 2px;
margin-bottom: 2px;
> .menu {
font-size: 11px;
&:hover {
opacity: 1;
> .item {
margin-right: 2px;
}
opacity: 0.5;
font-weight: bold;
clear: both;
margin-top: 2px;
margin-bottom: 2px;
&:hover {
opacity: 1;
}
}
}
@@ -145,7 +207,8 @@
}
&:hover:not(.selected),
&.selected {
&.selected,
&.is-drag-over {
background-color: var(--sn-stylekit-secondary-contrast-background-color);
color: var(--sn-stylekit-secondary-contrast-foreground-color);

View File

@@ -1,18 +0,0 @@
.sk-modal-background(ng-click="ctrl.dismiss()")
.sk-modal-content(
ng-attr-id="component-content-outer-{{ctrl.component.uuid}}"
)
.sn-component
.sk-panel(
ng-attr-id="component-content-inner-{{ctrl.component.uuid}}"
)
.sk-panel-header
.sk-panel-header-title
| {{ctrl.component.name}}
a.sk-a.info.close-button(ng-click="ctrl.dismiss()") Close
component-view.component-view(
ng-if='ctrl.component.active'
component-uuid="ctrl.component.uuid",
application='ctrl.application'
app-state='self.appState'
)

View File

@@ -3,7 +3,7 @@
.sn-component
.sk-panel
.sk-panel-header
.sk-panel-header-title Activate Extension
.sk-panel-header-title Activate Component
a.sk-a.info.close-button(ng-click='ctrl.deny()') Cancel
.sk-panel-content
.sk-panel-section
@@ -14,8 +14,8 @@
| {{ctrl.permissionsString}}
.sk-panel-row
p.sk-p
| Extensions use an offline messaging system to communicate. Learn more at
|
| Components use an offline messaging system to communicate. Learn more at
|
a.sk-a.info(
href='https://standardnotes.com/permissions',
rel='noopener',

View File

@@ -9,7 +9,7 @@
.sk-panel-header-title Preview
.sk-subtitle.neutral.mt-1(
ng-if="ctrl.title"
) {{ctrl.title}}
) {{ctrl.title}}
.sk-horizontal-group
a.sk-a.info.close-button(
ng-click="ctrl.restore(false)"
@@ -20,18 +20,18 @@
a.sk-a.info.close-button(
ng-click="ctrl.dismiss(); $event.stopPropagation()"
) Close
.sk-panel-content.selectable(ng-if="!ctrl.state.editor")
.sk-panel-content.selectable(ng-if="!ctrl.state.componentViewer")
.sk-h2 {{ctrl.content.title}}
p.normal.sk-p(
style="white-space: pre-wrap; font-size: 16px;"
) {{ctrl.content.text}}
.sk-panel-content.sk-h2(
ng-if="ctrl.state.editor"
ng-if="ctrl.state.componentViewer"
style="height: auto; flex-grow: 0"
) {{ctrl.content.title}}
component-view.component-view(
ng-if="ctrl.state.editor",
template-component="ctrl.state.editor",
ng-if="ctrl.state.componentViewer",
component-viewer="ctrl.state.componentViewer",
application='ctrl.application'
app-state='self.appState'
)

View File

@@ -18,6 +18,7 @@
"lint": "eslint --fix app/assets/javascripts",
"tsc": "tsc --project app/assets/javascripts/tsconfig.json",
"test": "jest --config app/assets/javascripts/jest.config.js",
"test:coverage": "yarn test --coverage",
"prepare": "husky install"
},
"devDependencies": {
@@ -49,10 +50,10 @@
"eslint-plugin-react-hooks": "^4.2.1-beta-149b420f6-20211119",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.4.0",
"husky": "^7.0.4",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.3.1",
"jest-transform-pug": "^0.1.0",
"husky": "^7.0.4",
"lint-staged": ">=10",
"lodash": "^4.17.21",
"mini-css-extract-plugin": "^2.4.3",
@@ -65,7 +66,7 @@
"pug-loader": "^2.4.0",
"sass-loader": "^12.2.0",
"serve-static": "^1.14.1",
"sn-stylekit": "5.2.17",
"sn-stylekit": "5.2.20",
"svg-jest": "^1.0.1",
"ts-jest": "^27.0.7",
"ts-loader": "^9.2.6",
@@ -84,12 +85,16 @@
"@reach/dialog": "^0.16.2",
"@reach/listbox": "^0.16.2",
"@standardnotes/features": "1.10.2",
"@reach/tooltip": "^0.16.2",
"@standardnotes/sncrypto-web": "1.5.3",
"@standardnotes/snjs": "2.20.5",
"@standardnotes/snjs": "2.29.0",
"mobx": "^6.3.5",
"mobx-react-lite": "^3.2.2",
"preact": "^10.5.15",
"qrcode.react": "^1.0.1"
"qrcode.react": "^1.0.1",
"react-dnd": "^14.0.4",
"react-dnd-html5-backend": "^14.0.2",
"react-dnd-touch-backend": "^14.1.1"
},
"lint-staged": {
"*.{js,ts,jsx,tsx}": "eslint --cache --fix",

Some files were not shown because too many files have changed in this diff Show More