Compare commits

...
Author SHA1 Message Date
StandardNotes CI c9078f374b chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].6
 - @standardnotes/[email protected].3
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected].1
2022-12-19 07:53:04 +00:00
Karol Sójko bb4f1ff099 fix(snjs): refreshing sessions (#2106)
* fix(snjs): refreshing sessions

* fix(snjs): bring back all tests

* fix(snjs): passing session tokens values

* fix(api): remove redundant specs

* fix(snjs): add projecting sessions to storage values

* fix(snjs): deps tree

* fix(snjs): bring back subscription tests

* fix(snjs): remove only tag for migration tests

* fix(snjs): session specs
2022-12-19 08:28:10 +01:00
StandardNotes CI abdaec89b7 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].0
2022-12-16 20:09:12 +00:00
Aman Harwara f559442a67 feat: Added "Whats New" indicator to Preferences button (#2107) 2022-12-17 00:35:25 +05:30
StandardNotes CI 51aed0a518 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].4
2022-12-15 16:01:35 +00:00
Mo 35bb671e31 build: only unpack keytar for macOS to avoid Windows runtime issue (skip e2e) 2022-12-15 09:47:17 -06:00
StandardNotes CI d76b190aba chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
2022-12-15 09:54:12 +00:00
Aman Harwara d6fd85e451 fix(desktop): Fixed issue where the "Record video" modal would error after stopping recording 2022-12-15 14:55:31 +05:30
StandardNotes CI c90087462f chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
2022-12-14 16:57:03 +00:00
Mo 58838a44cc ci: trigger desktop build 2022-12-14 10:24:20 -06:00
StandardNotes CI 0223800fdf chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
2022-12-14 15:55:51 +00:00
Mo 318b1fdb0f build: remove entitlements no longer required by newer versions of Electron 2022-12-14 08:54:56 -06:00
StandardNotes CI fb517a91b0 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected].0
2022-12-13 17:15:10 +00:00
Mo c7b158eb64 ci(temp): skip subscription tests 2022-12-13 10:50:44 -06:00
Aman Harwara d4b63e4ea6 feat: Ability to record videos and capture photos directly in app by selecting + in Files view (#2095) 2022-12-13 21:41:41 +05:30
Karol Sójko cd8596b14e Revert "fix(snjs): add sleep intervals for subscription sharing tests"
This reverts commit 4e576d7526.
2022-12-13 06:39:23 +01:00
Karol Sójko 6c11766051 chore: upgrade trigger-workflow-and-wait procedure 2022-12-12 13:25:14 +01:00
Karol Sójko 7f41564328 fix(snjs): remove @newrelic/native-metrics by upgrading @standardnotes/settings 2022-12-12 13:23:20 +01:00
Karol Sójko 4e576d7526 fix(snjs): add sleep intervals for subscription sharing tests 2022-12-12 13:22:53 +01:00
StandardNotes CI 317c6555d2 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
2022-12-08 19:53:54 +00:00
Aman Harwara 220f62665e feat: Added "Open" button to file upload success notification (#2091) 2022-12-09 00:58:32 +05:30
StandardNotes CI 1dc9086257 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].4
 - @standardnotes/[email protected].4
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].3
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].5
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected].6
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].0
2022-12-08 17:37:43 +00:00
Mo d149b500ee feat: Add current Writing Streak to Daily Notebooks (#2093) 2022-12-08 11:11:13 -06:00
StandardNotes CI ad70bea368 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].5
 - @standardnotes/[email protected].1
2022-12-08 16:09:41 +00:00
Aman Harwara 3bcd969f83 chore: remove unnecessary conditions 2022-12-08 21:12:45 +05:30
Aman Harwara d4af612fea fix: Fixed issue where passcode lock setting was not visible on mobile 2022-12-08 21:00:54 +05:30
Aman Harwara b068b814f2 refactor: allow opting out of toast pause on window blur behavior 2022-12-08 20:49:13 +05:30
StandardNotes CI 51de012236 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
2022-12-08 03:35:46 +00:00
Mo 2694c2f1a9 refactor: class-based photo recorder for Moments 2022-12-07 21:09:23 -06:00
Mo d3609853c6 fix: alignment of Loading text in What's New screen 2022-12-07 20:37:53 -06:00
Mo a7ac715499 feat: Moments will now create a link with the current open note, so you can look back on what you were working on during a moment 2022-12-07 20:34:17 -06:00
StandardNotes CI 5ac93a6bd8 chore(release): publish
- @standardnotes/[email protected].0
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].4
 - @standardnotes/[email protected].0
2022-12-07 17:40:15 +00:00
Mo be4cc4e605 feat: add ability to convert selection in Super to bulleted or numbered list 2022-12-07 11:14:03 -06:00
110 changed files with 1920 additions and 1638 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ jobs:
- name: Run E2E test suite
if: "${{ contains(github.event.head_commit.message, 'skip e2e') == false }}"
uses: convictional/trigger-workflow-and-wait@v1.6.1
uses: convictional/trigger-workflow-and-wait@master
with:
owner: standardnotes
repo: e2e
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
docker tag standardnotes/snjs:${{ github.sha }} standardnotes/snjs:test
docker push standardnotes/snjs:test
- name: Run E2E test suite
uses: convictional/trigger-workflow-and-wait@v1.6.1
uses: convictional/trigger-workflow-and-wait@master
with:
owner: standardnotes
repo: e2e
+10
View File
@@ -3,6 +3,16 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.20.20](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-19)
### Bug Fixes
* **snjs:** refreshing sessions ([#2106](https://github.com/standardnotes/app/issues/2106)) ([bb4f1ff](https://github.com/standardnotes/app/commit/bb4f1ff0990eb5a6f64e76a699fb95540cffdbb4))
## [1.20.19](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/api
## [1.20.18](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-05)
**Note:** Version bump only for package @standardnotes/api
-8
View File
@@ -6,12 +6,4 @@ module.exports = {
transform: {
'^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }],
},
coverageThreshold: {
global: {
branches: 22,
functions: 69,
lines: 67,
statements: 67
}
}
};
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/api",
"version": "1.20.18",
"version": "1.20.20",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -23,7 +23,7 @@
"build": "tsc -p tsconfig.json",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"test": "jest spec --coverage"
"test": "jest spec --coverage --passWithNoTests"
},
"devDependencies": {
"@types/jest": "^29.2.3",
@@ -37,6 +37,7 @@
},
"dependencies": {
"@standardnotes/common": "^1.45.0",
"@standardnotes/domain-core": "^1.11.0",
"@standardnotes/encryption": "workspace:*",
"@standardnotes/models": "workspace:*",
"@standardnotes/responses": "workspace:*",
@@ -1,211 +0,0 @@
import { type Invitation } from '@standardnotes/models'
import { SubscriptionInviteAcceptResponse } from '../../Response/Subscription/SubscriptionInviteAcceptResponse'
import { SubscriptionInviteCancelResponse } from '../../Response/Subscription/SubscriptionInviteCancelResponse'
import { SubscriptionInviteListResponse } from '../../Response/Subscription/SubscriptionInviteListResponse'
import { SubscriptionInviteResponse } from '../../Response/Subscription/SubscriptionInviteResponse'
import { SubscriptionServerInterface } from '../../Server/Subscription/SubscriptionServerInterface'
import { SubscriptionApiOperations } from './SubscriptionApiOperations'
import { SubscriptionApiService } from './SubscriptionApiService'
describe('SubscriptionApiService', () => {
let subscriptionServer: SubscriptionServerInterface
const createService = () => new SubscriptionApiService(subscriptionServer)
beforeEach(() => {
subscriptionServer = {} as jest.Mocked<SubscriptionServerInterface>
subscriptionServer.invite = jest.fn().mockReturnValue({
data: { success: true, sharedSubscriptionInvitationUuid: '1-2-3' },
} as jest.Mocked<SubscriptionInviteResponse>)
subscriptionServer.cancelInvite = jest.fn().mockReturnValue({
data: { success: true },
} as jest.Mocked<SubscriptionInviteCancelResponse>)
subscriptionServer.listInvites = jest.fn().mockReturnValue({
data: { invitations: [{} as jest.Mocked<Invitation>] },
} as jest.Mocked<SubscriptionInviteListResponse>)
subscriptionServer.acceptInvite = jest.fn().mockReturnValue({
data: { success: true },
} as jest.Mocked<SubscriptionInviteAcceptResponse>)
})
it('should invite a user', async () => {
const response = await createService().invite('[email protected]')
expect(response).toEqual({
data: {
success: true,
sharedSubscriptionInvitationUuid: '1-2-3',
},
})
expect(subscriptionServer.invite).toHaveBeenCalledWith({
api: '20200115',
identifier: '[email protected]',
})
})
it('should not invite a user if it is already inviting', async () => {
const service = createService()
Object.defineProperty(service, 'operationsInProgress', {
get: () => new Map([[SubscriptionApiOperations.Inviting, true]]),
})
let error = null
try {
await service.invite('[email protected]')
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should not invite a user if the server fails', async () => {
subscriptionServer.invite = jest.fn().mockImplementation(() => {
throw new Error('Oops')
})
let error = null
try {
await createService().invite('[email protected]')
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should cancel an invite', async () => {
const response = await createService().cancelInvite('1-2-3')
expect(response).toEqual({
data: {
success: true,
},
})
expect(subscriptionServer.cancelInvite).toHaveBeenCalledWith({
api: '20200115',
inviteUuid: '1-2-3',
})
})
it('should not cancel an invite if it is already canceling', async () => {
const service = createService()
Object.defineProperty(service, 'operationsInProgress', {
get: () => new Map([[SubscriptionApiOperations.CancelingInvite, true]]),
})
let error = null
try {
await service.cancelInvite('1-2-3')
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should not cancel an invite if the server fails', async () => {
subscriptionServer.cancelInvite = jest.fn().mockImplementation(() => {
throw new Error('Oops')
})
let error = null
try {
await createService().cancelInvite('1-2-3')
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should list invites', async () => {
const response = await createService().listInvites()
expect(response).toEqual({
data: {
invitations: [{} as jest.Mocked<Invitation>],
},
})
expect(subscriptionServer.listInvites).toHaveBeenCalledWith({
api: '20200115',
})
})
it('should not list invitations if it is already listing', async () => {
const service = createService()
Object.defineProperty(service, 'operationsInProgress', {
get: () => new Map([[SubscriptionApiOperations.ListingInvites, true]]),
})
let error = null
try {
await service.listInvites()
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should not list invites if the server fails', async () => {
subscriptionServer.listInvites = jest.fn().mockImplementation(() => {
throw new Error('Oops')
})
let error = null
try {
await createService().listInvites()
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should accept an invite', async () => {
const response = await createService().acceptInvite('1-2-3')
expect(response).toEqual({
data: {
success: true,
},
})
expect(subscriptionServer.acceptInvite).toHaveBeenCalledWith({
inviteUuid: '1-2-3',
})
})
it('should not accept an invite if it is already accepting', async () => {
const service = createService()
Object.defineProperty(service, 'operationsInProgress', {
get: () => new Map([[SubscriptionApiOperations.AcceptingInvite, true]]),
})
let error = null
try {
await service.acceptInvite('1-2-3')
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should not accept an invite if the server fails', async () => {
subscriptionServer.acceptInvite = jest.fn().mockImplementation(() => {
throw new Error('Oops')
})
let error = null
try {
await createService().acceptInvite('1-2-3')
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
})
@@ -1,197 +0,0 @@
import { ProtocolVersion, UserRequestType } from '@standardnotes/common'
import { type RootKeyParamsInterface } from '@standardnotes/models'
import { UserDeletionResponse } from '../../Response/User/UserDeletionResponse'
import { UserRegistrationResponse } from '../../Response/User/UserRegistrationResponse'
import { UserRequestResponse } from '../../Response/UserRequest/UserRequestResponse'
import { UserServerInterface } from '../../Server'
import { UserRequestServerInterface } from '../../Server/UserRequest/UserRequestServerInterface'
import { UserApiOperations } from './UserApiOperations'
import { UserApiService } from './UserApiService'
describe('UserApiService', () => {
let userServer: UserServerInterface
let userRequestServer: UserRequestServerInterface
let keyParams: RootKeyParamsInterface
const createService = () => new UserApiService(userServer, userRequestServer)
beforeEach(() => {
userServer = {} as jest.Mocked<UserServerInterface>
userServer.register = jest.fn().mockReturnValue({
data: { user: { email: '[email protected]', uuid: '1-2-3' } },
} as jest.Mocked<UserRegistrationResponse>)
userServer.deleteAccount = jest.fn().mockReturnValue({
data: { message: 'Success' },
} as jest.Mocked<UserDeletionResponse>)
userRequestServer = {} as jest.Mocked<UserRequestServerInterface>
userRequestServer.submitUserRequest = jest.fn().mockReturnValue({
data: { success: true },
} as jest.Mocked<UserRequestResponse>)
keyParams = {} as jest.Mocked<RootKeyParamsInterface>
keyParams.getPortableValue = jest.fn().mockReturnValue({
identifier: '[email protected]',
version: ProtocolVersion.V004,
})
})
it('should register a user', async () => {
const response = await createService().register({
email: '[email protected]',
serverPassword: 'testpasswd',
keyParams,
ephemeral: false,
})
expect(response).toEqual({
data: {
user: {
email: '[email protected]',
uuid: '1-2-3',
},
},
})
expect(userServer.register).toHaveBeenCalledWith({
api: '20200115',
email: '[email protected]',
ephemeral: false,
identifier: '[email protected]',
password: 'testpasswd',
version: '004',
})
})
it('should not register a user if it is already registering', async () => {
const service = createService()
Object.defineProperty(service, 'operationsInProgress', {
get: () => new Map([[UserApiOperations.Registering, true]]),
})
let error = null
try {
await service.register({ email: '[email protected]', serverPassword: 'testpasswd', keyParams, ephemeral: false })
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should not register a user if the server fails', async () => {
userServer.register = jest.fn().mockImplementation(() => {
throw new Error('Oops')
})
let error = null
try {
await createService().register({
email: '[email protected]',
serverPassword: 'testpasswd',
keyParams,
ephemeral: false,
})
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should submit a user request', async () => {
const response = await createService().submitUserRequest({
userUuid: '1-2-3',
requestType: UserRequestType.ExitDiscount,
})
expect(response).toEqual({
data: {
success: true,
},
})
expect(userRequestServer.submitUserRequest).toHaveBeenCalledWith({
userUuid: '1-2-3',
requestType: UserRequestType.ExitDiscount,
})
})
it('should not submit a user request if it is already submitting', async () => {
const service = createService()
Object.defineProperty(service, 'operationsInProgress', {
get: () => new Map([[UserApiOperations.SubmittingRequest, true]]),
})
let error = null
try {
await service.submitUserRequest({ userUuid: '1-2-3', requestType: UserRequestType.ExitDiscount })
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should not submit a user request if the server fails', async () => {
userRequestServer.submitUserRequest = jest.fn().mockImplementation(() => {
throw new Error('Oops')
})
let error = null
try {
await createService().submitUserRequest({
userUuid: '1-2-3',
requestType: UserRequestType.ExitDiscount,
})
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should delete a user', async () => {
const response = await createService().deleteAccount('1-2-3')
expect(response).toEqual({
data: {
message: 'Success',
},
})
expect(userServer.deleteAccount).toHaveBeenCalledWith({
userUuid: '1-2-3',
})
})
it('should not delete a user if it is already deleting', async () => {
const service = createService()
Object.defineProperty(service, 'operationsInProgress', {
get: () => new Map([[UserApiOperations.DeletingAccount, true]]),
})
let error = null
try {
await service.deleteAccount('1-2-3')
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should not delete a user if the server fails', async () => {
userServer.deleteAccount = jest.fn().mockImplementation(() => {
throw new Error('Oops')
})
let error = null
try {
await createService().deleteAccount('1-2-3')
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
})
@@ -1,61 +0,0 @@
import { WebSocketConnectionTokenResponse } from '../../Response'
import { WebSocketServerInterface } from '../../Server/WebSocket/WebSocketServerInterface'
import { WebSocketApiOperations } from './WebSocketApiOperations'
import { WebSocketApiService } from './WebSocketApiService'
describe('WebSocketApiService', () => {
let webSocketServer: WebSocketServerInterface
const createService = () => new WebSocketApiService(webSocketServer)
beforeEach(() => {
webSocketServer = {} as jest.Mocked<WebSocketServerInterface>
webSocketServer.createConnectionToken = jest.fn().mockReturnValue({
data: { token: 'foobar' },
} as jest.Mocked<WebSocketConnectionTokenResponse>)
})
it('should create a websocket connection token', async () => {
const response = await createService().createConnectionToken()
expect(response).toEqual({
data: {
token: 'foobar',
},
})
expect(webSocketServer.createConnectionToken).toHaveBeenCalledWith({})
})
it('should not create a token if it is already creating', async () => {
const service = createService()
Object.defineProperty(service, 'operationsInProgress', {
get: () => new Map([[WebSocketApiOperations.CreatingConnectionToken, true]]),
})
let error = null
try {
await service.createConnectionToken()
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should not create a token if the server fails', async () => {
webSocketServer.createConnectionToken = jest.fn().mockImplementation(() => {
throw new Error('Oops')
})
let error = null
try {
await createService().createConnectionToken()
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
})
@@ -1,368 +0,0 @@
import { WorkspaceAccessLevel, WorkspaceType } from '@standardnotes/common'
import { HttpStatusCode } from '../../Http'
import { WorkspaceCreationResponse } from '../../Response/Workspace/WorkspaceCreationResponse'
import { WorkspaceInvitationAcceptingResponse } from '../../Response/Workspace/WorkspaceInvitationAcceptingResponse'
import { WorkspaceInvitationResponse } from '../../Response/Workspace/WorkspaceInvitationResponse'
import { WorkspaceListResponse } from '../../Response/Workspace/WorkspaceListResponse'
import { WorkspaceUserListResponse } from '../../Response/Workspace/WorkspaceUserListResponse'
import { WorkspaceServerInterface } from '../../Server/Workspace/WorkspaceServerInterface'
import { WorkspaceKeyshareInitiatingResponse } from '../../Response/Workspace/WorkspaceKeyshareInitiatingResponse'
import { WorkspaceApiOperations } from './WorkspaceApiOperations'
import { WorkspaceApiService } from './WorkspaceApiService'
describe('WorkspaceApiService', () => {
let workspaceServer: WorkspaceServerInterface
const createService = () => new WorkspaceApiService(workspaceServer)
beforeEach(() => {
workspaceServer = {} as jest.Mocked<WorkspaceServerInterface>
workspaceServer.createWorkspace = jest.fn().mockReturnValue({
data: { uuid: '1-2-3' },
} as jest.Mocked<WorkspaceCreationResponse>)
workspaceServer.inviteToWorkspace = jest.fn().mockReturnValue({
data: { uuid: 'i-1-2-3' },
} as jest.Mocked<WorkspaceInvitationResponse>)
workspaceServer.acceptInvite = jest.fn().mockReturnValue({
data: { success: true },
} as jest.Mocked<WorkspaceInvitationAcceptingResponse>)
workspaceServer.listWorkspaces = jest.fn().mockReturnValue({
status: HttpStatusCode.Success,
data: { ownedWorkspaces: [], joinedWorkspaces: [] },
} as jest.Mocked<WorkspaceListResponse>)
workspaceServer.listWorkspaceUsers = jest.fn().mockReturnValue({
status: HttpStatusCode.Success,
data: { users: [] },
} as jest.Mocked<WorkspaceUserListResponse>)
workspaceServer.initiateKeyshare = jest.fn().mockReturnValue({
status: HttpStatusCode.Success,
data: { success: true },
} as jest.Mocked<WorkspaceKeyshareInitiatingResponse>)
})
it('should create a workspace', async () => {
const response = await createService().createWorkspace({
workspaceType: WorkspaceType.Private,
encryptedPrivateKey: 'foo',
encryptedWorkspaceKey: 'bar',
publicKey: 'buzz',
})
expect(response).toEqual({
data: {
uuid: '1-2-3',
},
})
expect(workspaceServer.createWorkspace).toHaveBeenCalledWith({
encryptedPrivateKey: 'foo',
encryptedWorkspaceKey: 'bar',
publicKey: 'buzz',
workspaceType: 'private',
})
})
it('should not create a workspace if it is already creating', async () => {
const service = createService()
Object.defineProperty(service, 'operationsInProgress', {
get: () => new Map([[WorkspaceApiOperations.Creating, true]]),
})
let error = null
try {
await service.createWorkspace({
workspaceType: WorkspaceType.Private,
encryptedPrivateKey: 'foo',
encryptedWorkspaceKey: 'bar',
publicKey: 'buzz',
})
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should not create a workspace if the server fails', async () => {
workspaceServer.createWorkspace = jest.fn().mockImplementation(() => {
throw new Error('Oops')
})
let error = null
try {
await createService().createWorkspace({
workspaceType: WorkspaceType.Private,
encryptedPrivateKey: 'foo',
encryptedWorkspaceKey: 'bar',
publicKey: 'buzz',
})
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should invite to a workspace', async () => {
const response = await createService().inviteToWorkspace({
workspaceUuid: 'w-1-2-3',
inviteeEmail: '[email protected]',
accessLevel: WorkspaceAccessLevel.WriteAndRead,
})
expect(response).toEqual({
data: {
uuid: 'i-1-2-3',
},
})
expect(workspaceServer.inviteToWorkspace).toHaveBeenCalledWith({
workspaceUuid: 'w-1-2-3',
inviteeEmail: '[email protected]',
accessLevel: 'write-and-read',
})
})
it('should not invite to a workspace if it is already inviting', async () => {
const service = createService()
Object.defineProperty(service, 'operationsInProgress', {
get: () => new Map([[WorkspaceApiOperations.Inviting, true]]),
})
let error = null
try {
await service.inviteToWorkspace({
workspaceUuid: 'w-1-2-3',
inviteeEmail: '[email protected]',
accessLevel: WorkspaceAccessLevel.WriteAndRead,
})
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should not invite to a workspace if the server fails', async () => {
workspaceServer.inviteToWorkspace = jest.fn().mockImplementation(() => {
throw new Error('Oops')
})
let error = null
try {
await createService().inviteToWorkspace({
workspaceUuid: 'w-1-2-3',
inviteeEmail: '[email protected]',
accessLevel: WorkspaceAccessLevel.WriteAndRead,
})
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should accept invite to a workspace', async () => {
const response = await createService().acceptInvite({
userUuid: 'u-1-2-3',
inviteUuid: 'i-1-2-3',
publicKey: 'foo',
encryptedPrivateKey: 'bar',
})
expect(response).toEqual({
data: {
success: true,
},
})
expect(workspaceServer.acceptInvite).toHaveBeenCalledWith({
userUuid: 'u-1-2-3',
inviteUuid: 'i-1-2-3',
publicKey: 'foo',
encryptedPrivateKey: 'bar',
})
})
it('should not accept invite to a workspace if it is already accepting', async () => {
const service = createService()
Object.defineProperty(service, 'operationsInProgress', {
get: () => new Map([[WorkspaceApiOperations.Accepting, true]]),
})
let error = null
try {
await service.acceptInvite({
userUuid: 'u-1-2-3',
inviteUuid: 'i-1-2-3',
publicKey: 'foo',
encryptedPrivateKey: 'bar',
})
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should not accept invite to a workspace if the server fails', async () => {
workspaceServer.acceptInvite = jest.fn().mockImplementation(() => {
throw new Error('Oops')
})
let error = null
try {
await createService().acceptInvite({
userUuid: 'u-1-2-3',
inviteUuid: 'i-1-2-3',
publicKey: 'foo',
encryptedPrivateKey: 'bar',
})
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should list workspaces', async () => {
const response = await createService().listWorkspaces()
expect(response).toEqual({
status: 200,
data: {
ownedWorkspaces: [],
joinedWorkspaces: [],
},
})
expect(workspaceServer.listWorkspaces).toHaveBeenCalled()
})
it('should not list workspaces if it is already listing them', async () => {
const service = createService()
Object.defineProperty(service, 'operationsInProgress', {
get: () => new Map([[WorkspaceApiOperations.ListingWorkspaces, true]]),
})
let error = null
try {
await service.listWorkspaces()
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should not list workspaces if the server fails', async () => {
workspaceServer.listWorkspaces = jest.fn().mockImplementation(() => {
throw new Error('Oops')
})
let error = null
try {
await createService().listWorkspaces()
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should list workspace users', async () => {
const response = await createService().listWorkspaceUsers({ workspaceUuid: 'w-1-2-3' })
expect(response).toEqual({
status: 200,
data: {
users: [],
},
})
expect(workspaceServer.listWorkspaceUsers).toHaveBeenCalledWith({ workspaceUuid: 'w-1-2-3' })
})
it('should not list workspace users if it is already listing them', async () => {
const service = createService()
Object.defineProperty(service, 'operationsInProgress', {
get: () => new Map([[WorkspaceApiOperations.ListingWorkspaceUsers, true]]),
})
let error = null
try {
await service.listWorkspaceUsers({ workspaceUuid: 'w-1-2-3' })
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should not list workspace users if the server fails', async () => {
workspaceServer.listWorkspaceUsers = jest.fn().mockImplementation(() => {
throw new Error('Oops')
})
let error = null
try {
await createService().listWorkspaceUsers({ workspaceUuid: 'w-1-2-3' })
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should initiate keyshare in workspace for user', async () => {
const response = await createService().initiateKeyshare({
workspaceUuid: 'w-1-2-3',
userUuid: 'u-1-2-3',
encryptedWorkspaceKey: 'foobar',
})
expect(response).toEqual({
status: 200,
data: {
success: true,
},
})
expect(workspaceServer.initiateKeyshare).toHaveBeenCalledWith({
workspaceUuid: 'w-1-2-3',
userUuid: 'u-1-2-3',
encryptedWorkspaceKey: 'foobar',
})
})
it('should not initiate keyshare in workspace if it is already initiating', async () => {
const service = createService()
Object.defineProperty(service, 'operationsInProgress', {
get: () => new Map([[WorkspaceApiOperations.InitiatingKeyshare, true]]),
})
let error = null
try {
await service.initiateKeyshare({ workspaceUuid: 'w-1-2-3', userUuid: 'u-1-2-3', encryptedWorkspaceKey: 'foobar' })
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should not initiate keyshare in workspace if the server fails', async () => {
workspaceServer.initiateKeyshare = jest.fn().mockImplementation(() => {
throw new Error('Oops')
})
let error = null
try {
await createService().initiateKeyshare({
workspaceUuid: 'w-1-2-3',
userUuid: 'u-1-2-3',
encryptedWorkspaceKey: 'foobar',
})
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
})
@@ -1,42 +0,0 @@
import { Environment } from '@standardnotes/models'
import { HttpResponseMeta } from './HttpResponseMeta'
import { HttpService } from './HttpService'
describe('HttpService', () => {
const environment = Environment.Web
const appVersion = '1.2.3'
const snjsVersion = '2.3.4'
const host = 'http://bar'
let updateMetaCallback: (meta: HttpResponseMeta) => void
const createService = () => {
const service = new HttpService(environment, appVersion, snjsVersion, updateMetaCallback)
service.setHost(host)
return service
}
beforeEach(() => {
updateMetaCallback = jest.fn()
})
it('should set host', () => {
const service = createService()
expect(service['host']).toEqual('http://bar')
service.setHost('http://foo')
expect(service['host']).toEqual('http://foo')
})
it('should set and use the authorization token', () => {
const service = createService()
expect(service['authorizationToken']).toBeUndefined()
service.setAuthorizationToken('foo-bar')
expect(service['authorizationToken']).toEqual('foo-bar')
})
})
+76 -10
View File
@@ -1,5 +1,7 @@
import { isString, joinPaths, sleep } from '@standardnotes/utils'
import { Environment } from '@standardnotes/models'
import { Session, SessionToken } from '@standardnotes/domain-core'
import { HttpRequestParams } from './HttpRequestParams'
import { HttpVerb } from './HttpVerb'
import { HttpRequest } from './HttpRequest'
@@ -10,21 +12,26 @@ import { XMLHttpRequestState } from './XMLHttpRequestState'
import { ErrorMessage } from '../Error/ErrorMessage'
import { HttpResponseMeta } from './HttpResponseMeta'
import { HttpErrorResponseBody } from './HttpErrorResponseBody'
import { Paths } from '../Server/Auth/Paths'
import { SessionRefreshResponse } from '../Response/Auth/SessionRefreshResponse'
export class HttpService implements HttpServiceInterface {
private authorizationToken?: string
private session: Session | null
private __latencySimulatorMs?: number
private host!: string
private declare host: string
constructor(
private environment: Environment,
private appVersion: string,
private snjsVersion: string,
private updateMetaCallback: (meta: HttpResponseMeta) => void,
) {}
private refreshSessionCallback: (session: Session) => void,
) {
this.session = null
}
setAuthorizationToken(authorizationToken: string): void {
this.authorizationToken = authorizationToken
setSession(session: Session): void {
this.session = session
}
setHost(host: string): void {
@@ -36,7 +43,7 @@ export class HttpService implements HttpServiceInterface {
url: joinPaths(this.host, path),
params,
verb: HttpVerb.Get,
authentication: authentication ?? this.authorizationToken,
authentication: authentication ?? this.session?.accessToken.value,
})
}
@@ -45,7 +52,7 @@ export class HttpService implements HttpServiceInterface {
url: joinPaths(this.host, path),
params,
verb: HttpVerb.Post,
authentication: authentication ?? this.authorizationToken,
authentication: authentication ?? this.session?.accessToken.value,
})
}
@@ -54,7 +61,7 @@ export class HttpService implements HttpServiceInterface {
url: joinPaths(this.host, path),
params,
verb: HttpVerb.Put,
authentication: authentication ?? this.authorizationToken,
authentication: authentication ?? this.session?.accessToken.value,
})
}
@@ -63,7 +70,7 @@ export class HttpService implements HttpServiceInterface {
url: joinPaths(this.host, path),
params,
verb: HttpVerb.Patch,
authentication: authentication ?? this.authorizationToken,
authentication: authentication ?? this.session?.accessToken.value,
})
}
@@ -72,7 +79,7 @@ export class HttpService implements HttpServiceInterface {
url: joinPaths(this.host, path),
params,
verb: HttpVerb.Delete,
authentication: authentication ?? this.authorizationToken,
authentication: authentication ?? this.session?.accessToken.value,
})
}
@@ -89,9 +96,68 @@ export class HttpService implements HttpServiceInterface {
this.updateMetaCallback(response.meta)
}
if (response.status === HttpStatusCode.ExpiredAccessToken) {
const isSessionRefreshed = await this.refreshSession()
if (!isSessionRefreshed) {
return response
}
httpRequest.authentication = this.session?.accessToken.value
return this.runHttp(httpRequest)
}
return response
}
private async refreshSession(): Promise<boolean> {
if (this.session === null) {
return false
}
const response = (await this.post(joinPaths(this.host, Paths.v1.refreshSession), {
access_token: this.session.accessToken.value,
refresh_token: this.session.refreshToken.value,
})) as SessionRefreshResponse
if (response.data.error) {
return false
}
if (response.meta) {
this.updateMetaCallback(response.meta)
}
const accessTokenOrError = SessionToken.create(
response.data.session.access_token,
response.data.session.access_expiration,
)
if (accessTokenOrError.isFailed()) {
return false
}
const accessToken = accessTokenOrError.getValue()
const refreshTokenOrError = SessionToken.create(
response.data.session.refresh_token,
response.data.session.refresh_expiration,
)
if (refreshTokenOrError.isFailed()) {
return false
}
const refreshToken = refreshTokenOrError.getValue()
const sessionOrError = Session.create(accessToken, refreshToken, response.data.session.readonly_access)
if (sessionOrError.isFailed()) {
return false
}
this.setSession(sessionOrError.getValue())
this.refreshSessionCallback(this.session)
return true
}
private createRequestBody(httpRequest: HttpRequest): string | Uint8Array | undefined {
if (
httpRequest.params !== undefined &&
@@ -1,9 +1,11 @@
import { Session } from '@standardnotes/domain-core'
import { HttpRequestParams } from './HttpRequestParams'
import { HttpResponse } from './HttpResponse'
export interface HttpServiceInterface {
setHost(host: string): void
setAuthorizationToken(authorizationToken: string): void
setSession(session: Session): void
get(path: string, params?: HttpRequestParams, authentication?: string): Promise<HttpResponse>
post(path: string, params?: HttpRequestParams, authentication?: string): Promise<HttpResponse>
put(path: string, params?: HttpRequestParams, authentication?: string): Promise<HttpResponse>
@@ -0,0 +1,9 @@
import { Either } from '@standardnotes/common'
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
import { HttpResponse } from '../../Http/HttpResponse'
import { SessionRefreshResponseBody } from './SessionRefreshResponseBody'
export interface SessionRefreshResponse extends HttpResponse {
data: Either<SessionRefreshResponseBody, HttpErrorResponseBody>
}
@@ -0,0 +1,9 @@
export type SessionRefreshResponseBody = {
session: {
access_token: string
refresh_token: string
access_expiration: number
refresh_expiration: number
readonly_access: boolean
}
}
@@ -0,0 +1,9 @@
const SessionPaths = {
refreshSession: '/v1/sessions/refresh',
}
export const Paths = {
v1: {
...SessionPaths,
},
}
@@ -1,106 +0,0 @@
import { type Invitation } from '@standardnotes/models'
import { ApiVersion } from '../../Api'
import { HttpServiceInterface } from '../../Http'
import { SubscriptionInviteResponse } from '../../Response'
import { SubscriptionInviteAcceptResponse } from '../../Response/Subscription/SubscriptionInviteAcceptResponse'
import { SubscriptionInviteCancelResponse } from '../../Response/Subscription/SubscriptionInviteCancelResponse'
import { SubscriptionInviteDeclineResponse } from '../../Response/Subscription/SubscriptionInviteDeclineResponse'
import { SubscriptionInviteListResponse } from '../../Response/Subscription/SubscriptionInviteListResponse'
import { SubscriptionServer } from './SubscriptionServer'
describe('SubscriptionServer', () => {
let httpService: HttpServiceInterface
const createServer = () => new SubscriptionServer(httpService)
beforeEach(() => {
httpService = {} as jest.Mocked<HttpServiceInterface>
})
it('should invite a user to a shared subscription', async () => {
httpService.post = jest.fn().mockReturnValue({
data: { success: true, sharedSubscriptionInvitationUuid: '1-2-3' },
} as jest.Mocked<SubscriptionInviteResponse>)
const response = await createServer().invite({
api: ApiVersion.v0,
identifier: '[email protected]',
})
expect(response).toEqual({
data: {
success: true,
sharedSubscriptionInvitationUuid: '1-2-3',
},
})
})
it('should accept an invite to a shared subscription', async () => {
httpService.post = jest.fn().mockReturnValue({
data: { success: true },
} as jest.Mocked<SubscriptionInviteAcceptResponse>)
const response = await createServer().acceptInvite({
api: ApiVersion.v0,
inviteUuid: '1-2-3',
})
expect(response).toEqual({
data: {
success: true,
},
})
})
it('should decline an invite to a shared subscription', async () => {
httpService.get = jest.fn().mockReturnValue({
data: { success: true },
} as jest.Mocked<SubscriptionInviteDeclineResponse>)
const response = await createServer().declineInvite({
api: ApiVersion.v0,
inviteUuid: '1-2-3',
})
expect(response).toEqual({
data: {
success: true,
},
})
})
it('should cancel an invite to a shared subscription', async () => {
httpService.delete = jest.fn().mockReturnValue({
data: { success: true },
} as jest.Mocked<SubscriptionInviteCancelResponse>)
const response = await createServer().cancelInvite({
api: ApiVersion.v0,
inviteUuid: '1-2-3',
})
expect(response).toEqual({
data: {
success: true,
},
})
})
it('should list invitations to a shared subscription', async () => {
httpService.get = jest.fn().mockReturnValue({
data: { invitations: [{} as jest.Mocked<Invitation>] },
} as jest.Mocked<SubscriptionInviteListResponse>)
const response = await createServer().listInvites({
api: ApiVersion.v0,
})
expect(response).toEqual({
data: {
invitations: [{} as jest.Mocked<Invitation>],
},
})
})
})
@@ -1,54 +0,0 @@
import { ProtocolVersion } from '@standardnotes/common'
import { ApiVersion } from '../../Api'
import { HttpServiceInterface } from '../../Http'
import { UserDeletionResponse, UserRegistrationResponse } from '../../Response'
import { UserServer } from './UserServer'
describe('UserServer', () => {
let httpService: HttpServiceInterface
const createServer = () => new UserServer(httpService)
beforeEach(() => {
httpService = {} as jest.Mocked<HttpServiceInterface>
httpService.post = jest.fn().mockReturnValue({
data: { user: { email: '[email protected]', uuid: '1-2-3' } },
} as jest.Mocked<UserRegistrationResponse>)
httpService.delete = jest.fn().mockReturnValue({
data: { message: 'Success' },
} as jest.Mocked<UserDeletionResponse>)
})
it('should register a user', async () => {
const response = await createServer().register({
password: 'test',
api: ApiVersion.v0,
email: '[email protected]',
ephemeral: false,
version: ProtocolVersion.V004,
pw_nonce: 'test',
identifier: '[email protected]',
})
expect(response).toEqual({
data: {
user: {
email: '[email protected]',
uuid: '1-2-3',
},
},
})
})
it('should delete a user', async () => {
const response = await createServer().deleteAccount({
userUuid: '1-2-3',
})
expect(response).toEqual({
data: {
message: 'Success',
},
})
})
})
@@ -1,32 +0,0 @@
import { UserRequestType } from '@standardnotes/common'
import { HttpServiceInterface } from '../../Http'
import { UserRequestResponse } from '../../Response/UserRequest/UserRequestResponse'
import { UserRequestServer } from './UserRequestServer'
describe('UserRequestServer', () => {
let httpService: HttpServiceInterface
const createServer = () => new UserRequestServer(httpService)
beforeEach(() => {
httpService = {} as jest.Mocked<HttpServiceInterface>
httpService.post = jest.fn().mockReturnValue({
data: { success: true },
} as jest.Mocked<UserRequestResponse>)
})
it('should submit a user request', async () => {
const response = await createServer().submitUserRequest({
userUuid: '1-2-3',
requestType: UserRequestType.ExitDiscount,
})
expect(response).toEqual({
data: {
success: true,
},
})
})
})
@@ -1,27 +0,0 @@
import { HttpServiceInterface } from '../../Http'
import { WebSocketConnectionTokenResponse } from '../../Response'
import { WebSocketServer } from './WebSocketServer'
describe('WebSocketServer', () => {
let httpService: HttpServiceInterface
const createServer = () => new WebSocketServer(httpService)
beforeEach(() => {
httpService = {} as jest.Mocked<HttpServiceInterface>
httpService.post = jest.fn().mockReturnValue({
data: { token: 'foobar' },
} as jest.Mocked<WebSocketConnectionTokenResponse>)
})
it('should create a websocket connection token', async () => {
const response = await createServer().createConnectionToken({})
expect(response).toEqual({
data: {
token: 'foobar',
},
})
})
})
@@ -1,124 +0,0 @@
import { WorkspaceAccessLevel, WorkspaceType } from '@standardnotes/common'
import { HttpServiceInterface, HttpStatusCode } from '../../Http'
import { WorkspaceCreationResponse } from '../../Response/Workspace/WorkspaceCreationResponse'
import { WorkspaceInvitationAcceptingResponse } from '../../Response/Workspace/WorkspaceInvitationAcceptingResponse'
import { WorkspaceInvitationResponse } from '../../Response/Workspace/WorkspaceInvitationResponse'
import { WorkspaceKeyshareInitiatingResponse } from '../../Response/Workspace/WorkspaceKeyshareInitiatingResponse'
import { WorkspaceListResponse } from '../../Response/Workspace/WorkspaceListResponse'
import { WorkspaceUserListResponse } from '../../Response/Workspace/WorkspaceUserListResponse'
import { WorkspaceServer } from './WorkspaceServer'
describe('WorkspaceServer', () => {
let httpService: HttpServiceInterface
const createServer = () => new WorkspaceServer(httpService)
beforeEach(() => {
httpService = {} as jest.Mocked<HttpServiceInterface>
httpService.post = jest.fn().mockReturnValue({
data: { uuid: '1-2-3' },
} as jest.Mocked<WorkspaceCreationResponse>)
})
it('should create a workspace', async () => {
const response = await createServer().createWorkspace({
workspaceType: WorkspaceType.Private,
encryptedPrivateKey: 'foo',
encryptedWorkspaceKey: 'bar',
publicKey: 'buzz',
})
expect(response).toEqual({
data: {
uuid: '1-2-3',
},
})
})
it('should inivte to a workspace', async () => {
httpService.post = jest.fn().mockReturnValue({
data: { uuid: 'i-1-2-3' },
} as jest.Mocked<WorkspaceInvitationResponse>)
const response = await createServer().inviteToWorkspace({
inviteeEmail: '[email protected]',
workspaceUuid: 'w-1-2-3',
accessLevel: WorkspaceAccessLevel.WriteAndRead,
})
expect(response).toEqual({
data: {
uuid: 'i-1-2-3',
},
})
})
it('should accept invitation to a workspace', async () => {
httpService.post = jest.fn().mockReturnValue({
data: { success: true },
} as jest.Mocked<WorkspaceInvitationAcceptingResponse>)
const response = await createServer().acceptInvite({
encryptedPrivateKey: 'foo',
inviteUuid: 'i-1-2-3',
publicKey: 'bar',
userUuid: 'u-1-2-3',
})
expect(response).toEqual({
data: {
success: true,
},
})
})
it('should list workspaces', async () => {
httpService.get = jest.fn().mockReturnValue({
status: HttpStatusCode.Success,
data: { ownedWorkspaces: [], joinedWorkspaces: [] },
} as jest.Mocked<WorkspaceListResponse>)
const response = await createServer().listWorkspaces({})
expect(response).toEqual({
status: 200,
data: { ownedWorkspaces: [], joinedWorkspaces: [] },
})
})
it('should list workspace users', async () => {
httpService.get = jest.fn().mockReturnValue({
status: HttpStatusCode.Success,
data: { users: [] },
} as jest.Mocked<WorkspaceUserListResponse>)
const response = await createServer().listWorkspaceUsers({ workspaceUuid: 'w-1-2-3' })
expect(response).toEqual({
status: 200,
data: { users: [] },
})
})
it('should initiate keyshare for user in a workspace', async () => {
httpService.post = jest.fn().mockReturnValue({
status: HttpStatusCode.Success,
data: { success: true },
} as jest.Mocked<WorkspaceKeyshareInitiatingResponse>)
const response = await createServer().initiateKeyshare({
workspaceUuid: 'w-1-2-3',
userUuid: 'u-1-2-3',
encryptedWorkspaceKey: 'foobar',
})
expect(response).toEqual({
status: 200,
data: {
success: true,
},
})
})
})
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.10.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-07)
### Features
* add ability to convert selection in Super to bulleted or numbered list ([be4cc4e](https://github.com/standardnotes/app/commit/be4cc4e605100e0a86de214e548e9ed10e2568aa))
## [1.9.7](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-07)
### Bug Fixes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/blocks-editor",
"version": "1.9.7",
"version": "1.10.0",
"private": true,
"main": "./src/index.ts",
"scripts": {
@@ -11,7 +11,11 @@ import './index.css';
import {$isCodeHighlightNode} from '@lexical/code';
import {$isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {mergeRegister} from '@lexical/utils';
import {
mergeRegister,
$findMatchingParent,
$getNearestNodeOfType,
} from '@lexical/utils';
import {
$getSelection,
$isRangeSelection,
@@ -20,7 +24,17 @@ import {
FORMAT_TEXT_COMMAND,
LexicalEditor,
SELECTION_CHANGE_COMMAND,
$isRootOrShadowRoot,
COMMAND_PRIORITY_CRITICAL,
} from 'lexical';
import {$isHeadingNode} from '@lexical/rich-text';
import {
INSERT_UNORDERED_LIST_COMMAND,
REMOVE_LIST_COMMAND,
$isListNode,
ListNode,
INSERT_ORDERED_LIST_COMMAND,
} from '@lexical/list';
import {useCallback, useEffect, useRef, useState} from 'react';
import {createPortal} from 'react-dom';
@@ -36,9 +50,26 @@ import {
LinkIcon,
SuperscriptIcon,
SubscriptIcon,
ListBulleted,
ListNumbered,
} from '@standardnotes/icons';
import {IconComponent} from '../../Theme/IconComponent';
const blockTypeToBlockName = {
bullet: 'Bulleted List',
check: 'Check List',
code: 'Code Block',
h1: 'Heading 1',
h2: 'Heading 2',
h3: 'Heading 3',
h4: 'Heading 4',
h5: 'Heading 5',
h6: 'Heading 6',
number: 'Numbered List',
paragraph: 'Normal',
quote: 'Quote',
};
const IconSize = 15;
function TextFormatFloatingToolbar({
@@ -52,6 +83,8 @@ function TextFormatFloatingToolbar({
isStrikethrough,
isSubscript,
isSuperscript,
isBulletedList,
isNumberedList,
}: {
editor: LexicalEditor;
anchorElem: HTMLElement;
@@ -63,6 +96,8 @@ function TextFormatFloatingToolbar({
isSubscript: boolean;
isSuperscript: boolean;
isUnderline: boolean;
isBulletedList: boolean;
isNumberedList: boolean;
}): JSX.Element {
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null);
@@ -74,6 +109,22 @@ function TextFormatFloatingToolbar({
}
}, [editor, isLink]);
const formatBulletList = useCallback(() => {
if (!isBulletedList) {
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
} else {
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
}
}, [isBulletedList]);
const formatNumberedList = useCallback(() => {
if (!isNumberedList) {
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
} else {
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
}
}, [isNumberedList]);
const updateTextFormatFloatingToolbar = useCallback(() => {
const selection = $getSelection();
@@ -226,6 +277,22 @@ function TextFormatFloatingToolbar({
<LinkIcon />
</IconComponent>
</button>
<button
onClick={formatBulletList}
className={'popup-item spaced ' + (isBulletedList ? 'active' : '')}
aria-label="Insert bulleted list">
<IconComponent size={IconSize}>
<ListBulleted />
</IconComponent>
</button>
<button
onClick={formatNumberedList}
className={'popup-item spaced ' + (isNumberedList ? 'active' : '')}
aria-label="Insert numbered list">
<IconComponent size={IconSize}>
<ListNumbered />
</IconComponent>
</button>
</>
)}
</div>
@@ -236,6 +303,7 @@ function useFloatingTextFormatToolbar(
editor: LexicalEditor,
anchorElem: HTMLElement,
): JSX.Element | null {
const [activeEditor, setActiveEditor] = useState(editor);
const [isText, setIsText] = useState(false);
const [isLink, setIsLink] = useState(false);
const [isBold, setIsBold] = useState(false);
@@ -245,6 +313,8 @@ function useFloatingTextFormatToolbar(
const [isSubscript, setIsSubscript] = useState(false);
const [isSuperscript, setIsSuperscript] = useState(false);
const [isCode, setIsCode] = useState(false);
const [blockType, setBlockType] =
useState<keyof typeof blockTypeToBlockName>('paragraph');
const updatePopup = useCallback(() => {
editor.getEditorState().read(() => {
@@ -270,6 +340,42 @@ function useFloatingTextFormatToolbar(
return;
}
const anchorNode = selection.anchor.getNode();
let element =
anchorNode.getKey() === 'root'
? anchorNode
: $findMatchingParent(anchorNode, (e) => {
const parent = e.getParent();
return parent !== null && $isRootOrShadowRoot(parent);
});
if (element === null) {
element = anchorNode.getTopLevelElementOrThrow();
}
const elementKey = element.getKey();
const elementDOM = activeEditor.getElementByKey(elementKey);
if (elementDOM !== null) {
if ($isListNode(element)) {
const parentList = $getNearestNodeOfType<ListNode>(
anchorNode,
ListNode,
);
const type = parentList
? parentList.getListType()
: element.getListType();
setBlockType(type);
} else {
const type = $isHeadingNode(element)
? element.getTag()
: element.getType();
if (type in blockTypeToBlockName) {
setBlockType(type as keyof typeof blockTypeToBlockName);
}
}
}
const node = getSelectedNode(selection);
// Update text format
@@ -298,14 +404,19 @@ function useFloatingTextFormatToolbar(
setIsText(false);
}
});
}, [editor]);
}, [editor, activeEditor]);
useEffect(() => {
document.addEventListener('selectionchange', updatePopup);
return () => {
document.removeEventListener('selectionchange', updatePopup);
};
}, [updatePopup]);
return editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, newEditor) => {
setActiveEditor(newEditor);
updatePopup();
return false;
},
COMMAND_PRIORITY_CRITICAL,
);
}, [editor, updatePopup]);
useEffect(() => {
return mergeRegister(
@@ -336,6 +447,8 @@ function useFloatingTextFormatToolbar(
isSuperscript={isSuperscript}
isUnderline={isUnderline}
isCode={isCode}
isBulletedList={blockType === 'bullet'}
isNumberedList={blockType === 'number'}
/>,
anchorElem,
);
+50
View File
@@ -3,6 +3,56 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.104.27](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-19)
**Note:** Version bump only for package @standardnotes/desktop
## [3.104.26](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-16)
**Note:** Version bump only for package @standardnotes/desktop
## [3.104.25](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-15)
**Note:** Version bump only for package @standardnotes/desktop
## [3.104.24](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-15)
### Bug Fixes
* **desktop:** Fixed issue where the "Record video" modal would error after stopping recording ([d6fd85e](https://github.com/standardnotes/app/commit/d6fd85e451962e539bada9e207aa0ec65a92f511))
## [3.104.23](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-14)
**Note:** Version bump only for package @standardnotes/desktop
## [3.104.22](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-14)
**Note:** Version bump only for package @standardnotes/desktop
## [3.104.21](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-13)
**Note:** Version bump only for package @standardnotes/desktop
## [3.104.20](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/desktop
## [3.104.19](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/desktop
## [3.104.18](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/desktop
## [3.104.17](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/desktop
## [3.104.16](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-07)
**Note:** Version bump only for package @standardnotes/desktop
## [3.104.15](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-07)
**Note:** Version bump only for package @standardnotes/desktop
+1 -1
View File
@@ -12,7 +12,7 @@
default-src 'self' blob:;
script-src 'self' 'unsafe-eval';
worker-src blob:;
connect-src * data:;
connect-src * data: blob:;
style-src 'unsafe-inline' 'self' http://localhost:* http://127.0.0.1:45653;
frame-src * blob:;
"
@@ -4,12 +4,6 @@
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
+4 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@standardnotes/desktop",
"main": "./app/dist/index.js",
"version": "3.104.15",
"version": "3.104.27",
"license": "AGPL-3.0-or-later",
"author": "Standard Notes.",
"private": true,
@@ -107,6 +107,9 @@
"extendInfo": {
"NSCameraUsageDescription": "Standard Notes requires access to your camera to enable the Moments feature."
},
"asarUnpack": [
"node_modules/keytar"
],
"target": [
"dmg",
"zip"
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.19.27](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/encryption
## [1.19.26](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-05)
**Note:** Version bump only for package @standardnotes/encryption
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/encryption",
"version": "1.19.26",
"version": "1.19.27",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.27.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/filepicker
## [1.27.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-05)
**Note:** Version bump only for package @standardnotes/filepicker
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/filepicker",
"version": "1.27.3",
"version": "1.27.4",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.14.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/files
## [1.14.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-05)
**Note:** Version bump only for package @standardnotes/files
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/files",
"version": "1.14.3",
"version": "1.14.4",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.7.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-07)
### Features
* add ability to convert selection in Super to bulleted or numbered list ([be4cc4e](https://github.com/standardnotes/app/commit/be4cc4e605100e0a86de214e548e9ed10e2568aa))
## [1.6.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-28)
### Bug Fixes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/icons",
"version": "1.6.1",
"version": "1.7.0",
"private": true,
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -1,3 +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 viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M5.83325 3.16667H17.4999V4.83333H5.83325V3.16667ZM5.83325 10.8333V9.16667H17.4999V10.8333H5.83325ZM3.33325 2.75C3.66477 2.75 3.98272 2.8817 4.21714 3.11612C4.45156 3.35054 4.58325 3.66848 4.58325 4C4.58325 4.33152 4.45156 4.64946 4.21714 4.88388C3.98272 5.1183 3.66477 5.25 3.33325 5.25C3.00173 5.25 2.68379 5.1183 2.44937 4.88388C2.21495 4.64946 2.08325 4.33152 2.08325 4C2.08325 3.66848 2.21495 3.35054 2.44937 3.11612C2.68379 2.8817 3.00173 2.75 3.33325 2.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 16.8333V15.1667H17.4999V16.8333H5.83325ZM3.33325 14.75C3.66477 14.75 3.98272 14.8817 4.21714 15.1161C4.45156 15.3505 4.58325 15.6685 4.58325 16C4.58325 16.3315 4.45156 16.6495 4.21714 16.8839C3.98272 17.1183 3.66477 17.25 3.33325 17.25C3.00173 17.25 2.68379 17.1183 2.44937 16.8839C2.21495 16.6495 2.08325 16.3315 2.08325 16C2.08325 15.6685 2.21495 15.3505 2.44937 15.1161C2.68379 14.8817 3.00173 14.75 3.33325 14.75Z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,6 @@
<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M5.83325 3.16675H17.4999V4.83341H5.83325V3.16675ZM5.83325 10.8334V9.16675H17.4999V10.8334H5.83325ZM5.83325 16.8334V15.1667H17.4999V16.8334H5.83325Z" />
<path d="M4.02415 2.36364V6H3.03693V3.27273H3.01562L2.22017 3.74858V2.91051L3.11506 2.36364H4.02415Z" />
<path d="M2.24148 12V11.2898L3.59801 10.1676C3.68797 10.093 3.76491 10.0232 3.82884 9.9581C3.89394 9.89181 3.94366 9.82375 3.97798 9.75391C4.01349 9.68407 4.03125 9.60653 4.03125 9.52131C4.03125 9.42779 4.01113 9.34789 3.97088 9.28161C3.93182 9.21532 3.87737 9.16442 3.80753 9.12891C3.73769 9.09221 3.6572 9.07386 3.56605 9.07386C3.47491 9.07386 3.39441 9.09221 3.32457 9.12891C3.25592 9.1656 3.20265 9.21946 3.16477 9.29048C3.12689 9.36151 3.10795 9.44792 3.10795 9.54972H2.17045C2.17045 9.29403 2.22786 9.07386 2.34268 8.8892C2.4575 8.70455 2.61967 8.5625 2.82919 8.46307C3.03871 8.36364 3.28433 8.31392 3.56605 8.31392C3.85724 8.31392 4.10938 8.36068 4.32244 8.45419C4.5367 8.54652 4.70182 8.67673 4.81783 8.84482C4.93501 9.0129 4.99361 9.20999 4.99361 9.43608C4.99361 9.57576 4.96461 9.71484 4.90661 9.85334C4.8486 9.99065 4.74444 10.1428 4.59411 10.3097C4.44377 10.4766 4.23011 10.6754 3.95312 10.9062L3.61222 11.1903V11.2116H5.03267V12H2.24148Z" />
<path d="M3.66193 18.0497C3.37547 18.0497 3.12098 18.0006 2.89844 17.9023C2.67708 17.8029 2.50308 17.6662 2.37642 17.4922C2.24976 17.3182 2.18584 17.1181 2.18466 16.892H3.17898C3.18016 16.9619 3.20147 17.0246 3.2429 17.0803C3.28551 17.1347 3.34351 17.1773 3.4169 17.2081C3.49029 17.2389 3.57434 17.2543 3.66903 17.2543C3.76018 17.2543 3.84067 17.2383 3.91051 17.2063C3.98035 17.1732 4.0348 17.1276 4.07386 17.0696C4.11293 17.0116 4.13187 16.9453 4.13068 16.8707C4.13187 16.7973 4.10938 16.7322 4.06321 16.6754C4.01823 16.6186 3.9549 16.5742 3.87322 16.5423C3.79155 16.5103 3.69744 16.4943 3.59091 16.4943H3.22159V15.8125H3.59091C3.68916 15.8125 3.77557 15.7965 3.85014 15.7646C3.9259 15.7326 3.98449 15.6882 4.02592 15.6314C4.06854 15.5746 4.08925 15.5095 4.08807 15.4361C4.08925 15.3651 4.07209 15.3023 4.03658 15.2479C4.00107 15.1934 3.95135 15.1508 3.88743 15.12C3.82469 15.0893 3.75189 15.0739 3.66903 15.0739C3.57907 15.0739 3.49858 15.0898 3.42756 15.1218C3.35772 15.1538 3.30268 15.1982 3.26243 15.255C3.22218 15.3118 3.20147 15.3769 3.20028 15.4503H2.25568C2.25687 15.2277 2.31783 15.0312 2.43857 14.8608C2.56049 14.6903 2.72739 14.5566 2.93928 14.4595C3.15116 14.3625 3.39441 14.3139 3.66903 14.3139C3.93774 14.3139 4.17507 14.3595 4.38104 14.4506C4.58819 14.5418 4.74976 14.6673 4.86577 14.8271C4.98295 14.9857 5.04096 15.1674 5.03977 15.3722C5.04214 15.5758 4.9723 15.7427 4.83026 15.8729C4.68939 16.0031 4.51065 16.08 4.29403 16.1037V16.1321C4.58759 16.1641 4.80836 16.254 4.95632 16.402C5.10429 16.5488 5.17708 16.7334 5.17472 16.956C5.1759 17.169 5.11198 17.3578 4.98295 17.5224C4.85511 17.6869 4.67696 17.8159 4.44851 17.9094C4.22124 18.003 3.95904 18.0497 3.66193 18.0497Z" />
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

+1 -1
View File
@@ -1,3 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M11.9999 8.6665C11.5579 8.6665 11.134 8.49091 10.8214 8.17835C10.5088 7.86579 10.3333 7.44187 10.3333 6.99984C10.3333 6.55781 10.5088 6.13389 10.8214 5.82133C11.134 5.50877 11.5579 5.33317 11.9999 5.33317C12.4419 5.33317 12.8659 5.50877 13.1784 5.82133C13.491 6.13389 13.6666 6.55781 13.6666 6.99984C13.6666 7.44187 13.491 7.86579 13.1784 8.17835C12.8659 8.49091 12.4419 8.6665 11.9999 8.6665ZM11.9999 13.6665C11.5579 13.6665 11.134 13.4909 10.8214 13.1783C10.5088 12.8658 10.3333 12.4419 10.3333 11.9998C10.3333 11.5578 10.5088 11.1339 10.8214 10.8213C11.134 10.5088 11.5579 10.3332 11.9999 10.3332C12.4419 10.3332 12.8659 10.5088 13.1784 10.8213C13.491 11.1339 13.6666 11.5578 13.6666 11.9998C13.6666 12.4419 13.491 12.8658 13.1784 13.1783C12.8659 13.4909 12.4419 13.6665 11.9999 13.6665ZM11.9999 18.6665C11.5579 18.6665 11.134 18.4909 10.8214 18.1783C10.5088 17.8658 10.3333 17.4419 10.3333 16.9998C10.3333 16.5578 10.5088 16.1339 10.8214 15.8213C11.134 15.5088 11.5579 15.3332 11.9999 15.3332C12.4419 15.3332 12.8659 15.5088 13.1784 15.8213C13.491 16.1339 13.6666 16.5578 13.6666 16.9998C13.6666 17.4419 13.491 17.8658 13.1784 18.1783C12.8659 18.4909 12.4419 18.6665 11.9999 18.6665Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+2
View File
@@ -112,6 +112,7 @@ import LineWidthIcon from './ic-line-width.svg'
import LinkIcon from './ic-link.svg'
import LinkOffIcon from './ic-link-off.svg'
import ListBulleted from './ic-list-bulleted.svg'
import ListNumbered from './ic-list-numbered.svg'
import ListedFilledIcon from './ic-listed-filled.svg'
import ListedIcon from './ic-listed.svg'
import LockFilledIcon from './ic-lock-filled.svg'
@@ -318,6 +319,7 @@ export {
ListBulleted,
ListedFilledIcon,
ListedIcon,
ListNumbered,
LockFilledIcon,
LockIcon,
MarkdownIcon,
+40
View File
@@ -3,6 +3,46 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.49.24](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-19)
**Note:** Version bump only for package @standardnotes/mobile
## [3.49.23](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-16)
**Note:** Version bump only for package @standardnotes/mobile
## [3.49.22](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-15)
**Note:** Version bump only for package @standardnotes/mobile
## [3.49.21](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-14)
**Note:** Version bump only for package @standardnotes/mobile
## [3.49.20](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-13)
**Note:** Version bump only for package @standardnotes/mobile
## [3.49.19](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/mobile
## [3.49.18](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/mobile
## [3.49.17](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/mobile
## [3.49.16](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/mobile
## [3.49.15](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-07)
**Note:** Version bump only for package @standardnotes/mobile
## [3.49.14](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-07)
**Note:** Version bump only for package @standardnotes/mobile
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/mobile",
"version": "3.49.14",
"version": "3.49.24",
"author": "Standard Notes.",
"private": true,
"license": "AGPL-3.0-or-later",
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.39.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/models
## [1.39.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-05)
**Note:** Version bump only for package @standardnotes/models
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/models",
"version": "1.39.2",
"version": "1.39.3",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+48
View File
@@ -3,6 +3,54 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.4.50](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-19)
**Note:** Version bump only for package @standardnotes/releases
## [1.4.49](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-16)
**Note:** Version bump only for package @standardnotes/releases
## [1.4.48](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-15)
**Note:** Version bump only for package @standardnotes/releases
## [1.4.47](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-15)
**Note:** Version bump only for package @standardnotes/releases
## [1.4.46](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-14)
**Note:** Version bump only for package @standardnotes/releases
## [1.4.45](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-14)
**Note:** Version bump only for package @standardnotes/releases
## [1.4.44](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-13)
**Note:** Version bump only for package @standardnotes/releases
## [1.4.43](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/releases
## [1.4.42](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/releases
## [1.4.41](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/releases
## [1.4.40](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/releases
## [1.4.39](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-07)
**Note:** Version bump only for package @standardnotes/releases
## [1.4.38](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-07)
**Note:** Version bump only for package @standardnotes/releases
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/releases",
"version": "1.4.38",
"version": "1.4.50",
"license": "AGPL-3.0-or-later",
"main": "dist/releases.json",
"types": "dist/index.d.ts",
+8
View File
@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.50.6](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-19)
**Note:** Version bump only for package @standardnotes/services
## [1.50.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/services
## [1.50.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-05)
**Note:** Version bump only for package @standardnotes/services
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/services",
"version": "1.50.4",
"version": "1.50.6",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+16
View File
@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [2.156.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-19)
### Bug Fixes
* **snjs:** refreshing sessions ([#2106](https://github.com/standardnotes/app/issues/2106)) ([bb4f1ff](https://github.com/standardnotes/app/commit/bb4f1ff0990eb5a6f64e76a699fb95540cffdbb4))
## [2.156.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-13)
### Bug Fixes
* **snjs:** remove @newrelic/native-metrics by upgrading @standardnotes/settings ([7f41564](https://github.com/standardnotes/app/commit/7f415643283f418886a641deb7539f1e256e6a3d))
## [2.156.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/snjs
# [2.156.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-05)
### Features
@@ -82,6 +82,9 @@ import { SNLog } from '../Log'
import { ChallengeResponse, ListedClientInterface } from '../Services'
import { ApplicationConstructorOptions, FullyResolvedApplicationOptions } from './Options/ApplicationOptions'
import { ApplicationOptionsDefaults } from './Options/Defaults'
import { LegacySession, MapperInterface, Session } from '@standardnotes/domain-core'
import { SessionStorageMapper } from '@Lib/Services/Mapping/SessionStorageMapper'
import { LegacySessionStorageMapper } from '@Lib/Services/Mapping/LegacySessionStorageMapper'
/** How often to automatically sync, in milliseconds */
const DEFAULT_AUTO_SYNC_INTERVAL = 30_000
@@ -154,6 +157,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
private integrityService!: ExternalServices.IntegrityService
private statusService!: ExternalServices.StatusService
private filesBackupService?: FilesBackupService
private declare sessionStorageMapper: MapperInterface<Session, Record<string, unknown>>
private declare legacySessionStorageMapper: MapperInterface<LegacySession, Record<string, unknown>>
private internalEventBus!: ExternalServices.InternalEventBusInterface
@@ -1078,6 +1083,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
}
private constructServices() {
this.createMappers()
this.createPayloadManager()
this.createItemManager()
this.createDiskStorageManager()
@@ -1169,6 +1175,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
;(this.mutatorService as unknown) = undefined
;(this.filesBackupService as unknown) = undefined
;(this.statusService as unknown) = undefined
;(this.sessionStorageMapper as unknown) = undefined
;(this.legacySessionStorageMapper as unknown) = undefined
this.services = []
}
@@ -1289,6 +1297,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
environment: this.environment,
identifier: this.identifier,
internalEventBus: this.internalEventBus,
legacySessionStorageMapper: this.legacySessionStorageMapper,
})
this.services.push(this.migrationService)
}
@@ -1335,6 +1344,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.options.defaultHost,
this.inMemoryStore,
this.options.crypto,
this.sessionStorageMapper,
this.legacySessionStorageMapper,
this.internalEventBus,
)
this.services.push(this.apiService)
@@ -1419,9 +1430,15 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.options.appVersion,
SnjsVersion,
this.apiService.processMetaObject.bind(this.apiService),
this.apiService.setSession.bind(this.apiService),
)
}
private createMappers() {
this.sessionStorageMapper = new SessionStorageMapper()
this.legacySessionStorageMapper = new LegacySessionStorageMapper()
}
private createPayloadManager() {
this.payloadManager = new InternalServices.PayloadManager(this.internalEventBus)
this.services.push(this.payloadManager)
@@ -1497,6 +1514,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.challengeService,
this.webSocketsService,
this.httpService,
this.sessionStorageMapper,
this.legacySessionStorageMapper,
this.internalEventBus,
)
this.serviceObservers.push(
@@ -5,6 +5,7 @@ import { SNSessionManager } from '../Services/Session/SessionManager'
import { ApplicationIdentifier } from '@standardnotes/common'
import { ItemManager } from '@Lib/Services/Items/ItemManager'
import { ChallengeService, SNSingletonManager, SNFeaturesService, DiskStorageService } from '@Lib/Services'
import { LegacySession, MapperInterface } from '@standardnotes/domain-core'
export type MigrationServices = {
protocolService: EncryptionService
@@ -17,5 +18,6 @@ export type MigrationServices = {
featuresService: SNFeaturesService
environment: Environment
identifier: ApplicationIdentifier
legacySessionStorageMapper: MapperInterface<LegacySession, Record<string, unknown>>
internalEventBus: InternalEventBusInterface
}
@@ -1,5 +1,4 @@
import { AnyKeyParamsContent, ContentType, ProtocolVersion } from '@standardnotes/common'
import { JwtSession } from '../../Services/Session/Sessions/JwtSession'
import { Migration } from '@Lib/Migrations/Migration'
import { MigrationServices } from '../MigrationServices'
import { PreviousSnjsVersion2_0_0 } from '../../Version'
@@ -16,6 +15,7 @@ import {
PayloadTimestampDefaults,
} from '@standardnotes/models'
import { isMobileDevice } from '@standardnotes/services'
import { LegacySession } from '@standardnotes/domain-core'
interface LegacyStorageContent extends Models.ItemContent {
storage: unknown
@@ -673,8 +673,13 @@ export class Migration2_0_0 extends Migration {
}
}
const session = new JwtSession(currentToken)
this.services.storageService.setValue(Services.StorageKey.Session, session)
const sessionOrError = LegacySession.create(currentToken)
if (!sessionOrError.isFailed()) {
this.services.storageService.setValue(
Services.StorageKey.Session,
this.services.legacySessionStorageMapper.toProjection(sessionOrError.getValue()),
)
}
/** Server has to be migrated separately on mobile */
if (isEnvironmentMobile(this.services.environment)) {
+104 -39
View File
@@ -34,23 +34,22 @@ import {
API_MESSAGE_TOKEN_REFRESH_IN_PROGRESS,
} from '@standardnotes/services'
import { FilesApiInterface } from '@standardnotes/files'
import { ServerSyncPushContextualPayload, SNFeatureRepo, FileContent } from '@standardnotes/models'
import * as Responses from '@standardnotes/responses'
import { LegacySession, MapperInterface, Session, SessionToken } from '@standardnotes/domain-core'
import { HttpResponseMeta } from '@standardnotes/api'
import { SNRootKeyParams } from '@standardnotes/encryption'
import { ApiEndpointParam, ClientDisplayableError, CreateValetTokenPayload } from '@standardnotes/responses'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { HttpParams, HttpRequest, HttpVerb, SNHttpService } from './HttpService'
import { isUrlFirstParty, TRUSTED_FEATURE_HOSTS } from '@Lib/Hosts'
import { Paths } from './Paths'
import { Session } from '../Session/Sessions/Session'
import { TokenSession } from '../Session/Sessions/TokenSession'
import { DiskStorageService } from '../Storage/DiskStorageService'
import { HttpResponseMeta } from '@standardnotes/api'
import { UuidString } from '../../Types/UuidString'
import merge from 'lodash/merge'
import { SettingsServerInterface } from '../Settings/SettingsServerInterface'
import { Strings } from '@Lib/Strings'
import { SNRootKeyParams } from '@standardnotes/encryption'
import { ApiEndpointParam, ClientDisplayableError, CreateValetTokenPayload } from '@standardnotes/responses'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
/** Legacy api version field to be specified in params when calling v0 APIs. */
const V0_API_VERSION = '20200115'
@@ -66,7 +65,7 @@ export class SNApiService
ItemsServerInterface,
SettingsServerInterface
{
private session?: Session
private session: Session | LegacySession | null
public user?: Responses.User
private registering = false
private authenticating = false
@@ -81,16 +80,20 @@ export class SNApiService
private host: string,
private inMemoryStore: KeyValueStoreInterface<string>,
private crypto: PureCryptoInterface,
private sessionStorageMapper: MapperInterface<Session, Record<string, unknown>>,
private legacySessionStorageMapper: MapperInterface<LegacySession, Record<string, unknown>>,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
this.session = null
}
override deinit(): void {
;(this.httpService as unknown) = undefined
;(this.storageService as unknown) = undefined
this.invalidSessionObserver = undefined
this.session = undefined
this.session = null
super.deinit()
}
@@ -145,14 +148,21 @@ export class SNApiService
return this.filesHost
}
public setSession(session: Session, persist = true): void {
public setSession(session: Session | LegacySession, persist = true): void {
this.session = session
if (persist) {
this.storageService.setValue(StorageKey.Session, session)
let sessionProjection: Record<string, unknown>
if (session instanceof Session) {
sessionProjection = this.sessionStorageMapper.toProjection(session)
} else {
sessionProjection = this.legacySessionStorageMapper.toProjection(session)
}
this.storageService.setValue(StorageKey.Session, sessionProjection)
}
}
public getSession(): Session | undefined {
public getSession(): Session | LegacySession | null {
return this.session
}
@@ -252,7 +262,7 @@ export class SNApiService
fallbackErrorMessage: API_MESSAGE_GENERIC_INVALID_LOGIN,
params,
/** A session is optional here, if valid, endpoint bypasses 2FA and returns additional params */
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
})
}
@@ -289,7 +299,7 @@ export class SNApiService
signOut(): Promise<Responses.SignOutResponse> {
const url = joinPaths(this.host, Paths.v1.signOut)
return this.httpService.postAbsolute(url, undefined, this.session?.authorizationValue).catch((errorResponse) => {
return this.httpService.postAbsolute(url, undefined, this.getSessionAccessToken()).catch((errorResponse) => {
return errorResponse
}) as Promise<Responses.SignOutResponse>
}
@@ -317,7 +327,7 @@ export class SNApiService
...parameters.newKeyParams.getPortableValue(),
})
const response = await this.httpService
.putAbsolute(url, params, this.session?.authorizationValue)
.putAbsolute(url, params, this.getSessionAccessToken())
.catch(async (errorResponse) => {
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
return this.refreshSessionThenRetryRequest({
@@ -353,7 +363,7 @@ export class SNApiService
[ApiEndpointParam.SyncDlLimit]: limit,
})
const response = await this.httpService
.postAbsolute(url, params, this.session?.authorizationValue)
.postAbsolute(url, params, this.getSessionAccessToken())
.catch<Responses.HttpResponse>(async (errorResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
@@ -378,7 +388,7 @@ export class SNApiService
return this.httpService
.runHttp({
...httpRequest,
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
})
.catch((errorResponse) => {
return errorResponse
@@ -393,16 +403,54 @@ export class SNApiService
}
this.refreshingSession = true
const url = joinPaths(this.host, Paths.v1.refreshSession)
const session = this.session as TokenSession
const session = this.session as Session
const params = this.params({
access_token: session.accessToken,
refresh_token: session.refreshToken,
access_token: session.accessToken.value,
refresh_token: session.refreshToken.value,
})
const result = await this.httpService
.postAbsolute(url, params)
.then(async (response) => {
const session = TokenSession.FromApiResponse(response as Responses.SessionRenewalResponse)
await this.setSession(session)
const sessionRenewalResponse = response as Responses.SessionRenewalResponse
if (
sessionRenewalResponse.error ||
sessionRenewalResponse.data?.error ||
!sessionRenewalResponse.data.session
) {
return null
}
const accessTokenOrError = SessionToken.create(
sessionRenewalResponse.data.session.access_token,
sessionRenewalResponse.data.session.access_expiration,
)
if (accessTokenOrError.isFailed()) {
return null
}
const accessToken = accessTokenOrError.getValue()
const refreshTokenOrError = SessionToken.create(
sessionRenewalResponse.data.session.refresh_token,
sessionRenewalResponse.data.session.refresh_expiration,
)
if (refreshTokenOrError.isFailed()) {
return null
}
const refreshToken = refreshTokenOrError.getValue()
const sessionOrError = Session.create(
accessToken,
refreshToken,
sessionRenewalResponse.data.session.readonly_access,
)
if (sessionOrError.isFailed()) {
return null
}
const session = sessionOrError.getValue()
this.session = session
this.setSession(session)
this.processResponse(response)
return response
})
@@ -411,6 +459,11 @@ export class SNApiService
return this.errorResponseWithFallbackMessage(errorResponse, API_MESSAGE_GENERIC_TOKEN_REFRESH_FAIL)
})
this.refreshingSession = false
if (result === null) {
return this.createErrorResponse(API_MESSAGE_INVALID_SESSION)
}
return result
}
@@ -421,7 +474,7 @@ export class SNApiService
}
const url = joinPaths(this.host, Paths.v1.sessions)
const response = await this.httpService
.getAbsolute(url, {}, this.session?.authorizationValue)
.getAbsolute(url, {}, this.getSessionAccessToken())
.catch(async (errorResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
@@ -444,7 +497,7 @@ export class SNApiService
}
const url = joinPaths(this.host, <string>Paths.v1.session(sessionId))
const response: Responses.RevisionListResponse | Responses.HttpResponse = await this.httpService
.deleteAbsolute(url, { uuid: sessionId }, this.session?.authorizationValue)
.deleteAbsolute(url, { uuid: sessionId }, this.getSessionAccessToken())
.catch((error: Responses.HttpResponse) => {
const errorResponse = error as Responses.HttpResponse
this.preprocessAuthenticatedErrorResponse(errorResponse)
@@ -467,7 +520,7 @@ export class SNApiService
}
const url = joinPaths(this.host, Paths.v1.itemRevisions(itemId))
const response: Responses.RevisionListResponse | Responses.HttpResponse = await this.httpService
.getAbsolute(url, undefined, this.session?.authorizationValue)
.getAbsolute(url, undefined, this.getSessionAccessToken())
.catch((errorResponse: Responses.HttpResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
@@ -492,7 +545,7 @@ export class SNApiService
}
const url = joinPaths(this.host, Paths.v1.itemRevision(itemId, entry.uuid))
const response: Responses.SingleRevisionResponse | Responses.HttpResponse = await this.httpService
.getAbsolute(url, undefined, this.session?.authorizationValue)
.getAbsolute(url, undefined, this.getSessionAccessToken())
.catch((errorResponse: Responses.HttpResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
@@ -510,7 +563,7 @@ export class SNApiService
async getUserFeatures(userUuid: UuidString): Promise<Responses.HttpResponse | Responses.UserFeaturesResponse> {
const url = joinPaths(this.host, Paths.v1.userFeatures(userUuid))
const response = await this.httpService
.getAbsolute(url, undefined, this.session?.authorizationValue)
.getAbsolute(url, undefined, this.getSessionAccessToken())
.catch((errorResponse: Responses.HttpResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
@@ -550,7 +603,7 @@ export class SNApiService
verb: HttpVerb.Get,
url: joinPaths(this.host, Paths.v1.settings(userUuid)),
fallbackErrorMessage: API_MESSAGE_FAILED_GET_SETTINGS,
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
})
}
@@ -568,7 +621,7 @@ export class SNApiService
return this.tokenRefreshableRequest<Responses.UpdateSettingResponse>({
verb: HttpVerb.Put,
url: joinPaths(this.host, Paths.v1.settings(userUuid)),
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_UPDATE_SETTINGS,
params,
})
@@ -578,7 +631,7 @@ export class SNApiService
return await this.tokenRefreshableRequest<Responses.GetSettingResponse>({
verb: HttpVerb.Get,
url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName.toLowerCase() as SettingName)),
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_GET_SETTINGS,
})
}
@@ -593,7 +646,7 @@ export class SNApiService
this.host,
Paths.v1.subscriptionSetting(userUuid, settingName.toLowerCase() as SubscriptionSettingName),
),
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_GET_SETTINGS,
})
}
@@ -602,7 +655,7 @@ export class SNApiService
return this.tokenRefreshableRequest<Responses.DeleteSettingResponse>({
verb: HttpVerb.Delete,
url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName)),
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_UPDATE_SETTINGS,
})
}
@@ -616,7 +669,7 @@ export class SNApiService
verb: HttpVerb.Delete,
url,
fallbackErrorMessage: API_MESSAGE_FAILED_DELETE_REVISION,
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
})
return response
}
@@ -635,7 +688,7 @@ export class SNApiService
const response = await this.tokenRefreshableRequest({
verb: HttpVerb.Get,
url,
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_SUBSCRIPTION_INFO,
})
return response
@@ -658,7 +711,7 @@ export class SNApiService
const response: Responses.HttpResponse | Responses.PostSubscriptionTokensResponse = await this.request({
verb: HttpVerb.Post,
url,
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_ACCESS_PURCHASE,
})
return (response as Responses.PostSubscriptionTokensResponse).data?.token
@@ -706,7 +759,7 @@ export class SNApiService
verb: HttpVerb.Post,
url: joinPaths(this.host, Paths.v1.listedRegistration(this.user.uuid)),
fallbackErrorMessage: API_MESSAGE_FAILED_LISTED_REGISTRATION,
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
})
}
@@ -725,7 +778,7 @@ export class SNApiService
const response = await this.tokenRefreshableRequest<Responses.CreateValetTokenResponse>({
verb: HttpVerb.Post,
url: url,
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_CREATE_FILE_TOKEN,
params,
})
@@ -860,7 +913,7 @@ export class SNApiService
integrityPayloads,
},
fallbackErrorMessage: API_MESSAGE_GENERIC_INTEGRITY_CHECK_FAIL,
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
})
}
@@ -869,7 +922,7 @@ export class SNApiService
verb: HttpVerb.Get,
url: joinPaths(this.host, Paths.v1.getSingleItem(itemUuid)),
fallbackErrorMessage: API_MESSAGE_GENERIC_SINGLE_ITEM_SYNC_FAIL,
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
})
}
@@ -890,6 +943,18 @@ export class SNApiService
}
}
private getSessionAccessToken(): string | undefined {
if (!this.session) {
return undefined
}
if (this.session instanceof Session) {
return this.session.accessToken.value
}
return this.session.accessToken
}
override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve({
api: {
-1
View File
@@ -1,6 +1,5 @@
export * from './ApiService'
export * from './HttpService'
export * from './Paths'
export * from '../Session/Sessions/Session'
export * from '../Session/SessionManager'
export * from './WebsocketsService'
@@ -290,7 +290,7 @@ export class ChallengeService extends AbstractService implements ChallengeServic
if (operation.isFinished()) {
this.deleteChallengeOperation(operation)
const observers = this.challengeObservers[challenge.id]
const observers = this.challengeObservers[challenge.id] || []
observers.forEach(clearChallengeObserver)
observers.length = 0
@@ -0,0 +1,20 @@
import { LegacySession, MapperInterface } from '@standardnotes/domain-core'
export class LegacySessionStorageMapper implements MapperInterface<LegacySession, Record<string, unknown>> {
toDomain(projection: Record<string, unknown>): LegacySession {
const { jwt } = projection
const legacySessionOrError = LegacySession.create(jwt as string)
if (legacySessionOrError.isFailed()) {
throw new Error(legacySessionOrError.getError())
}
return legacySessionOrError.getValue()
}
toProjection(domain: LegacySession): Record<string, unknown> {
return {
jwt: domain.accessToken,
}
}
}
@@ -0,0 +1,40 @@
import { MapperInterface, Session, SessionToken } from '@standardnotes/domain-core'
export class SessionStorageMapper implements MapperInterface<Session, Record<string, unknown>> {
toDomain(projection: Record<string, unknown>): Session {
const accessTokenOrError = SessionToken.create(
projection.accessToken as string,
projection.accessExpiration as number,
)
if (accessTokenOrError.isFailed()) {
throw new Error(accessTokenOrError.getError())
}
const accessToken = accessTokenOrError.getValue()
const refreshTokenOrError = SessionToken.create(
projection.refreshToken as string,
projection.refreshExpiration as number,
)
if (refreshTokenOrError.isFailed()) {
throw new Error(refreshTokenOrError.getError())
}
const refreshToken = refreshTokenOrError.getValue()
const session = Session.create(accessToken, refreshToken, projection.readonlyAccess as boolean)
if (session.isFailed()) {
throw new Error(session.getError())
}
return session.getValue()
}
toProjection(domain: Session): Record<string, unknown> {
return {
accessToken: domain.accessToken.value,
refreshToken: domain.refreshToken.value,
accessExpiration: domain.accessToken.expiresAt,
refreshExpiration: domain.refreshToken.expiresAt,
readonlyAccess: domain.isReadOnly(),
}
}
}
@@ -27,21 +27,19 @@ import { Base64String } from '@standardnotes/sncrypto-common'
import { ClientDisplayableError } from '@standardnotes/responses'
import { CopyPayloadWithContentOverride } from '@standardnotes/models'
import { isNullOrUndefined } from '@standardnotes/utils'
import { JwtSession } from './Sessions/JwtSession'
import { LegacySession, MapperInterface, Session, SessionToken } from '@standardnotes/domain-core'
import { KeyParamsFromApiResponse, SNRootKeyParams, SNRootKey, CreateNewRootKey } from '@standardnotes/encryption'
import * as Responses from '@standardnotes/responses'
import { Subscription } from '@standardnotes/security'
import * as Common from '@standardnotes/common'
import { RemoteSession, RawStorageValue } from './Sessions/Types'
import { Session } from './Sessions/Session'
import { SessionFromRawStorageValue } from './Sessions/Generator'
import { ShareToken } from './ShareToken'
import { SNApiService } from '../Api/ApiService'
import { DiskStorageService } from '../Storage/DiskStorageService'
import { SNWebSocketsService } from '../Api/WebsocketsService'
import { Strings } from '@Lib/Strings'
import { Subscription } from '@standardnotes/security'
import { TokenSession } from './Sessions/TokenSession'
import { UuidString } from '@Lib/Types/UuidString'
import * as Common from '@standardnotes/common'
import * as Responses from '@standardnotes/responses'
import { ChallengeService } from '../Challenge'
import {
ApiCallError,
@@ -82,6 +80,8 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
private challengeService: ChallengeService,
private webSocketsService: SNWebSocketsService,
private httpService: HttpServiceInterface,
private sessionStorageMapper: MapperInterface<Session, Record<string, unknown>>,
private legacySessionStorageMapper: MapperInterface<LegacySession, Record<string, unknown>>,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
@@ -122,13 +122,23 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
const rawSession = this.diskStorageService.getValue<RawStorageValue>(StorageKey.Session)
if (rawSession) {
const session = SessionFromRawStorageValue(rawSession)
this.setSession(session, false)
try {
const session =
'jwt' in rawSession
? this.legacySessionStorageMapper.toDomain(rawSession)
: this.sessionStorageMapper.toDomain(rawSession)
this.setSession(session, false)
} catch (error) {
console.error(`Could not deserialize session from storage: ${(error as Error).message}`)
}
}
}
private setSession(session: Session, persist = true): void {
this.httpService.setAuthorizationToken(session.authorizationValue)
private setSession(session: Session | LegacySession, persist = true): void {
if (session instanceof Session) {
this.httpService.setSession(session)
}
this.apiService.setSession(session, persist)
@@ -158,7 +168,7 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
public async signOut() {
this.setUser(undefined)
const session = this.apiService.getSession()
if (session && session.canExpire()) {
if (session && session instanceof Session) {
await this.apiService.signOut()
this.webSocketsService.closeWebSocketConnection()
}
@@ -560,17 +570,17 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
if (!session) {
return new ClientDisplayableError('Cannot generate share token without active session')
}
if (!(session instanceof TokenSession)) {
if (!(session instanceof Session)) {
return new ClientDisplayableError('Cannot generate share token with non-token session')
}
const keyParams = (await this.protocolService.getRootKeyParams()) as SNRootKeyParams
const payload: ShareToken = {
accessToken: session.accessToken,
refreshToken: session.refreshToken,
accessExpiration: session.accessExpiration,
refreshExpiration: session.refreshExpiration,
accessToken: session.accessToken.value,
refreshToken: session.refreshToken.value,
accessExpiration: session.accessToken.expiresAt,
refreshExpiration: session.refreshToken.expiresAt,
readonlyAccess: true,
masterKey: this.protocolService.getRootKey()?.masterKey as string,
keyParams: keyParams.content,
@@ -597,7 +607,7 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
const user = sharePayload.user
const session = new TokenSession(
const session = this.createSession(
sharePayload.accessToken,
sharePayload.accessExpiration,
sharePayload.refreshToken,
@@ -605,13 +615,15 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
sharePayload.readonlyAccess,
)
await this.populateSession(rootKey, user, session, sharePayload.host)
if (session !== null) {
await this.populateSession(rootKey, user, session, sharePayload.host)
}
}
private async populateSession(
rootKey: SNRootKey,
user: Responses.User,
session: Session,
session: Session | LegacySession,
host: string,
wrappingKey?: SNRootKey,
) {
@@ -629,14 +641,17 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
}
private async handleAuthResponse(body: UserRegistrationResponseBody, rootKey: SNRootKey, wrappingKey?: SNRootKey) {
const session = new TokenSession(
const session = this.createSession(
body.session.access_token,
body.session.access_expiration,
body.session.refresh_token,
body.session.refresh_expiration,
body.session.readonly_access,
)
await this.populateSession(rootKey, body.user, session, this.apiService.getHost(), wrappingKey)
if (session !== null) {
await this.populateSession(rootKey, body.user, session, this.apiService.getHost(), wrappingKey)
}
}
/**
@@ -652,14 +667,51 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
const isLegacyJwtResponse = data.token != undefined
if (isLegacyJwtResponse) {
const session = new JwtSession(data.token as string)
await this.populateSession(rootKey, user, session, this.apiService.getHost(), wrappingKey)
const sessionOrError = LegacySession.create(data.token as string)
if (!sessionOrError.isFailed()) {
await this.populateSession(rootKey, user, sessionOrError.getValue(), this.apiService.getHost(), wrappingKey)
}
} else if (data.session) {
const session = TokenSession.FromApiResponse(response)
await this.populateSession(rootKey, user, session, this.apiService.getHost(), wrappingKey)
const session = this.createSession(
data.session.access_token,
data.session.access_expiration,
data.session.refresh_token,
data.session.refresh_expiration,
data.session.readonly_access,
)
if (session !== null) {
await this.populateSession(rootKey, user, session, this.apiService.getHost(), wrappingKey)
}
}
}
private createSession(
accessTokenValue: string,
accessExpiration: number,
refreshTokenValue: string,
refreshExpiration: number,
readonlyAccess: boolean,
): Session | null {
const accessTokenOrError = SessionToken.create(accessTokenValue, accessExpiration)
if (accessTokenOrError.isFailed()) {
return null
}
const accessToken = accessTokenOrError.getValue()
const refreshTokenOrError = SessionToken.create(refreshTokenValue, refreshExpiration)
if (refreshTokenOrError.isFailed()) {
return null
}
const refreshToken = refreshTokenOrError.getValue()
const sessionOrError = Session.create(accessToken, refreshToken, readonlyAccess)
if (sessionOrError.isFailed()) {
return null
}
return sessionOrError.getValue()
}
override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve({
session: {
@@ -1,18 +0,0 @@
import { JwtSession } from './JwtSession'
import { TokenSession } from './TokenSession'
import { RawSessionPayload, RawStorageValue } from './Types'
export function SessionFromRawStorageValue(raw: RawStorageValue): JwtSession | TokenSession {
if ('jwt' in raw) {
return new JwtSession(raw.jwt as string)
} else {
const rawSession = raw as RawSessionPayload
return new TokenSession(
rawSession.accessToken,
rawSession.accessExpiration,
rawSession.refreshToken,
rawSession.refreshExpiration,
rawSession.readonlyAccess,
)
}
}
@@ -1,20 +0,0 @@
import { Session } from './Session'
/** Legacy, for protocol versions <= 003 */
export class JwtSession extends Session {
public jwt: string
constructor(jwt: string) {
super()
this.jwt = jwt
}
public get authorizationValue(): string {
return this.jwt
}
public canExpire(): false {
return false
}
}
@@ -1,6 +0,0 @@
export abstract class Session {
public abstract canExpire(): boolean
/** Return the token that should be included in the header of authorized network requests */
public abstract get authorizationValue(): string
}
@@ -1,46 +0,0 @@
import { SessionBody, SessionRenewalResponse } from '@standardnotes/responses'
import { Session } from './Session'
/** For protocol versions >= 004 */
export class TokenSession extends Session {
static FromApiResponse(response: SessionRenewalResponse) {
const body = response.data.session as SessionBody
const accessToken: string = body.access_token
const refreshToken: string = body.refresh_token
const accessExpiration: number = body.access_expiration
const refreshExpiration: number = body.refresh_expiration
const readonlyAccess: boolean = body.readonly_access
return new TokenSession(accessToken, accessExpiration, refreshToken, refreshExpiration, readonlyAccess)
}
constructor(
public accessToken: string,
public accessExpiration: number,
public refreshToken: string,
public refreshExpiration: number,
private readonlyAccess: boolean,
) {
super()
}
isReadOnly() {
return this.readonlyAccess
}
private getExpireAt() {
return this.accessExpiration || 0
}
public get authorizationValue() {
return this.accessToken
}
public canExpire() {
return true
}
public isExpired() {
return this.getExpireAt() < Date.now()
}
}
@@ -1,5 +1 @@
export * from './Generator'
export * from './JwtSession'
export * from './Session'
export * from './TokenSession'
export * from './Types'
+63 -23
View File
@@ -31,7 +31,7 @@ describe('server session', function () {
async function sleepUntilSessionExpires(application, basedOnAccessToken = true) {
const currentSession = application.apiService.session
const timestamp = basedOnAccessToken ? currentSession.accessExpiration : currentSession.refreshExpiration
const timestamp = basedOnAccessToken ? currentSession.accessToken.expiresAt : currentSession.refreshToken.expiresAt
const timeRemaining = (timestamp - Date.now()) / 1000 // in ms
/*
If the token has not expired yet, we will return the remaining time.
@@ -98,12 +98,12 @@ describe('server session', function () {
// After the above sync request is completed, we obtain the session information.
const sessionAfterSync = this.application.apiService.getSession()
expect(sessionBeforeSync).to.not.equal(sessionAfterSync)
expect(sessionBeforeSync.accessToken).to.not.equal(sessionAfterSync.accessToken)
expect(sessionBeforeSync.refreshToken).to.not.equal(sessionAfterSync.refreshToken)
expect(sessionBeforeSync.accessExpiration).to.be.lessThan(sessionAfterSync.accessExpiration)
expect(sessionBeforeSync.equals(sessionAfterSync)).to.not.equal(true)
expect(sessionBeforeSync.accessToken.value).to.not.equal(sessionAfterSync.accessToken.value)
expect(sessionBeforeSync.refreshToken.value).to.not.equal(sessionAfterSync.refreshToken.value)
expect(sessionBeforeSync.accessToken.expiresAt).to.be.lessThan(sessionAfterSync.accessToken.expiresAt)
// New token should expire in the future.
expect(sessionAfterSync.accessExpiration).to.be.greaterThan(Date.now())
expect(sessionAfterSync.accessToken.expiresAt).to.be.greaterThan(Date.now())
})
it('should succeed when a sync request is perfomed after signing into an ephemeral session', async function () {
@@ -142,14 +142,22 @@ describe('server session', function () {
const sessionFromStorage = await getSessionFromStorage(this.application)
const sessionFromApiService = this.application.apiService.getSession()
expect(sessionFromStorage).to.equal(sessionFromApiService)
expect(sessionFromStorage.accessToken).to.equal(sessionFromApiService.accessToken.value)
expect(sessionFromStorage.refreshToken).to.equal(sessionFromApiService.refreshToken.value)
expect(sessionFromStorage.accessExpiration).to.equal(sessionFromApiService.accessToken.expiresAt)
expect(sessionFromStorage.refreshExpiration).to.equal(sessionFromApiService.refreshToken.expiresAt)
expect(sessionFromStorage.readonlyAccess).to.equal(sessionFromApiService.isReadOnly())
await this.application.apiService.refreshSession()
const updatedSessionFromStorage = await getSessionFromStorage(this.application)
const updatedSessionFromApiService = this.application.apiService.getSession()
expect(updatedSessionFromStorage).to.equal(updatedSessionFromApiService)
expect(updatedSessionFromStorage.accessToken).to.equal(updatedSessionFromApiService.accessToken.value)
expect(updatedSessionFromStorage.refreshToken).to.equal(updatedSessionFromApiService.refreshToken.value)
expect(updatedSessionFromStorage.accessExpiration).to.equal(updatedSessionFromApiService.accessToken.expiresAt)
expect(updatedSessionFromStorage.refreshExpiration).to.equal(updatedSessionFromApiService.refreshToken.expiresAt)
expect(updatedSessionFromStorage.readonlyAccess).to.equal(updatedSessionFromApiService.isReadOnly())
})
it('should be performed successfully and terminate session with a valid access token', async function () {
@@ -221,8 +229,16 @@ describe('server session', function () {
let { application, password } = await Factory.createAndInitSimpleAppContext({
registerUser: true,
})
const fakeSession = application.apiService.getSession()
fakeSession.accessToken = 'this-is-a-fake-token-1234'
application.diskStorageService.setValue(StorageKey.Session, {
accessToken: 'this-is-a-fake-token-1234',
refreshToken: 'this-is-a-fake-token-1234',
accessExpiration: 999999999999999,
refreshExpiration: 99999999999999,
readonlyAccess: false,
})
application.sessions.initializeFromDisk()
Factory.ignoreChallenges(application)
const newEmail = UuidGenerator.GenerateUuid()
@@ -311,8 +327,15 @@ describe('server session', function () {
password: this.password,
})
const fakeSession = this.application.apiService.getSession()
fakeSession.accessToken = 'this-is-a-fake-token-1234'
this.application.diskStorageService.setValue(StorageKey.Session, {
accessToken: 'this-is-a-fake-token-1234',
refreshToken: 'this-is-a-fake-token-1234',
accessExpiration: 999999999999999,
refreshExpiration: 99999999999999,
readonlyAccess: false,
})
this.application.sessions.initializeFromDisk()
Factory.ignoreChallenges(this.application)
const changePasswordResponse = await this.application.changePassword(this.password, this.newPassword)
expect(changePasswordResponse.error.message).to.equal('Invalid login credentials.')
@@ -354,7 +377,7 @@ describe('server session', function () {
expect(currentSession).to.be.ok
expect(currentSession.accessToken).to.be.ok
expect(currentSession.refreshToken).to.be.ok
expect(currentSession.accessExpiration).to.be.greaterThan(Date.now())
expect(currentSession.accessToken.expiresAt).to.be.greaterThan(Date.now())
})
it('should fail when renewing a session with an expired refresh token', async function () {
@@ -392,10 +415,16 @@ describe('server session', function () {
password: this.password,
})
const fakeSession = this.application.apiService.getSession()
fakeSession.refreshToken = 'this-is-a-fake-token-1234'
const originalSession = this.application.apiService.getSession()
await this.application.apiService.setSession(fakeSession, true)
this.application.diskStorageService.setValue(StorageKey.Session, {
accessToken: originalSession.accessToken.value,
refreshToken: 'this-is-a-fake-token-1234',
accessExpiration: originalSession.accessToken.expiresAt,
refreshExpiration: originalSession.refreshToken.expiresAt,
readonlyAccess: false,
})
this.application.sessions.initializeFromDisk()
const refreshSessionResponse = await this.application.apiService.refreshSession()
@@ -530,8 +559,14 @@ describe('server session', function () {
const oldRootKey = await appA.protocolService.getRootKey()
/** Set the session as nonsense */
appA.apiService.session.accessToken = 'foo'
appA.apiService.session.refreshToken = 'bar'
appA.diskStorageService.setValue(StorageKey.Session, {
accessToken: 'foo',
refreshToken: 'bar',
accessExpiration: 999999999999999,
refreshExpiration: 999999999999999,
readonlyAccess: false,
})
appA.sessions.initializeFromDisk()
/** Perform an authenticated network request */
await appA.sync.sync()
@@ -540,8 +575,8 @@ describe('server session', function () {
await Factory.sleep(5.0)
expect(didPromptForSignIn).to.equal(true)
expect(appA.apiService.session.accessToken).to.not.equal('foo')
expect(appA.apiService.session.refreshToken).to.not.equal('bar')
expect(appA.apiService.session.accessToken.value).to.not.equal('foo')
expect(appA.apiService.session.refreshToken.value).to.not.equal('bar')
/** Expect that the session recovery replaces the global root key */
const newRootKey = await appA.protocolService.getRootKey()
@@ -646,9 +681,14 @@ describe('server session', function () {
password: this.password,
})
const invalidSession = this.application.apiService.getSession()
invalidSession.accessToken = undefined
invalidSession.refreshToken = undefined
this.application.diskStorageService.setValue(StorageKey.Session, {
accessToken: undefined,
refreshToken: undefined,
accessExpiration: 999999999999999,
refreshExpiration: 999999999999999,
readonlyAccess: false,
})
this.application.sessions.initializeFromDisk()
const storageKey = this.application.diskStorageService.getPersistenceKey()
expect(localStorage.getItem(storageKey)).to.be.ok
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/snjs",
"version": "2.156.0",
"version": "2.156.3",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -44,7 +44,7 @@
"@standardnotes/responses": "workspace:*",
"@standardnotes/security": "^1.7.0",
"@standardnotes/services": "workspace:*",
"@standardnotes/settings": "^1.18.3",
"@standardnotes/settings": "^1.18.4",
"@standardnotes/sncrypto-common": "workspace:*",
"@standardnotes/sncrypto-web": "workspace:*",
"@standardnotes/utils": "workspace:*",
@@ -83,5 +83,8 @@
"webpack": "*",
"webpack-cli": "*",
"webpack-merge": "^5.8.0"
},
"dependencies": {
"@standardnotes/domain-core": "^1.11.0"
}
}
+8
View File
@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.3.14](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/toast
## [1.3.13](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-07)
**Note:** Version bump only for package @standardnotes/toast
## [1.3.12](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-28)
**Note:** Version bump only for package @standardnotes/toast
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/toast",
"version": "1.3.12",
"version": "1.3.14",
"private": true,
"main": "./src/index.ts",
"scripts": {
+8 -4
View File
@@ -75,8 +75,10 @@ export const ToastTimer: FunctionComponent<Props> = ({ toast, index }) => {
toastElement.addEventListener('mouseenter', handleMouseEnter)
toastElement.addEventListener('mouseleave', handleMouseLeave)
}
window.addEventListener('focus', handlePageFocus)
window.addEventListener('blur', handlePageBlur)
if (toast.pauseOnWindowBlur) {
window.addEventListener('focus', handlePageFocus)
window.addEventListener('blur', handlePageBlur)
}
return () => {
clearTimer()
@@ -84,8 +86,10 @@ export const ToastTimer: FunctionComponent<Props> = ({ toast, index }) => {
toastElement.removeEventListener('mouseenter', handleMouseEnter)
toastElement.removeEventListener('mouseleave', handleMouseLeave)
}
window.removeEventListener('focus', handlePageFocus)
window.removeEventListener('blur', handlePageBlur)
if (toast.pauseOnWindowBlur) {
window.removeEventListener('focus', handlePageFocus)
window.removeEventListener('blur', handlePageBlur)
}
}
}, [
clearTimer,
+2 -1
View File
@@ -61,10 +61,11 @@ export const addToast = action(toastStore, 'addToast', (store: WritableAtom<Toas
If you want to update an existing toast, use the \`updateToast()\` function instead.`)
}
const toast = {
const toast: Toast = {
...options,
id,
dismissed: false,
pauseOnWindowBlur: options.pauseOnWindowBlur ?? true,
}
store.set([...existingToasts, toast])
+1
View File
@@ -12,6 +12,7 @@ type CommonToastProperties = {
progress?: number
autoClose?: boolean
duration?: number
pauseOnWindowBlur?: boolean
}
export type Toast = CommonToastProperties & {
+22
View File
@@ -3,6 +3,28 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.20.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-19)
**Note:** Version bump only for package @standardnotes/ui-services
# [1.20.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-16)
### Features
* Added "Whats New" indicator to Preferences button ([#2107](https://github.com/standardnotes/app/issues/2107)) ([f559442](https://github.com/standardnotes/app/commit/f559442a6726444f338a43e51d7a872f19bce701))
## [1.19.6](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/ui-services
## [1.19.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
**Note:** Version bump only for package @standardnotes/ui-services
## [1.19.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-07)
**Note:** Version bump only for package @standardnotes/ui-services
## [1.19.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-07)
### Bug Fixes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/ui-services",
"version": "1.19.3",
"version": "1.20.1",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -1,7 +1,7 @@
import { Environment } from '@standardnotes/models'
import { StorageServiceInterface, StorageKey } from '@standardnotes/services'
import { Changelog, ChangelogVersion } from './Changelog'
import { ChangelogServiceInterface } from './ChangelogServiceInterface'
import { ChangelogLastReadVersionListener, ChangelogServiceInterface } from './ChangelogServiceInterface'
import { LegacyWebToDesktopVersionMapping } from './LegacyDesktopMapping'
import { LegacyWebToMobileVersionMapping } from './LegacyMobileMapping'
@@ -10,9 +10,18 @@ const DesktopDownloadsUrlBase = 'https://github.com/standardnotes/app/releases/t
export class ChangelogService implements ChangelogServiceInterface {
private changeLog?: Changelog
private lastReadChangeListeners: ChangelogLastReadVersionListener[] = []
constructor(private environment: Environment, private diskService: StorageServiceInterface) {}
public addLastReadChangeListener(listener: ChangelogLastReadVersionListener) {
this.lastReadChangeListeners.push(listener)
return () => {
this.lastReadChangeListeners = this.lastReadChangeListeners.filter((l) => l !== listener)
}
}
private async performDownloadChangelog(): Promise<Changelog> {
const response = await fetch(WebChangelogUrl)
const changelog = await response.text()
@@ -47,7 +56,11 @@ export class ChangelogService implements ChangelogServiceInterface {
return
}
this.diskService.setValue(StorageKey.LastReadChangelogVersion, this.changeLog.versions[0].version)
const version = this.changeLog.versions[0].version
this.diskService.setValue(StorageKey.LastReadChangelogVersion, version)
if (version) {
this.lastReadChangeListeners.forEach((listener) => listener(version))
}
}
public getLastReadVersion(): string | undefined {
@@ -1,6 +1,9 @@
import { Changelog, ChangelogVersion } from './Changelog'
export type ChangelogLastReadVersionListener = (version: string) => void
export interface ChangelogServiceInterface {
addLastReadChangeListener(listener: ChangelogLastReadVersionListener): () => void
getChangelog(): Promise<Changelog>
getVersions(): Promise<ChangelogVersion[]>
getDesktopDownloadsUrl(version: string): string
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.15.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
### Features
* Add current Writing Streak to Daily Notebooks ([#2093](https://github.com/standardnotes/app/issues/2093)) ([d149b50](https://github.com/standardnotes/app/commit/d149b500ee3f13f63a1f831e6e0ba29a53e3e416))
## [1.14.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-02)
### Bug Fixes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/utils",
"version": "1.14.1",
"version": "1.15.0",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+4
View File
@@ -687,3 +687,7 @@ export function useBoolean(value: boolean | undefined, defaultValue: boolean): b
export function spaceSeparatedStrings(...strings: string[]): string {
return strings.join(' ')
}
export function pluralize(count: number, singular: string, plural: string): string {
return count === 1 ? singular : plural
}
+60 -2
View File
@@ -3,12 +3,70 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.127.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-19)
**Note:** Version bump only for package @standardnotes/web
# [3.127.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-16)
### Features
* Added "Whats New" indicator to Preferences button ([#2107](https://github.com/standardnotes/app/issues/2107)) ([f559442](https://github.com/standardnotes/app/commit/f559442a6726444f338a43e51d7a872f19bce701))
## [3.126.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-15)
**Note:** Version bump only for package @standardnotes/web
## [3.126.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-14)
**Note:** Version bump only for package @standardnotes/web
# [3.126.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-13)
### Features
* Ability to record videos and capture photos directly in app by selecting + in Files view ([#2095](https://github.com/standardnotes/app/issues/2095)) ([d4b63e4](https://github.com/standardnotes/app/commit/d4b63e4ea6e82ba17ebb02da3ca1cfc9ced92b57))
# [3.125.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
### Features
* Added "Open" button to file upload success notification ([#2091](https://github.com/standardnotes/app/issues/2091)) ([220f626](https://github.com/standardnotes/app/commit/220f62665e6daa18020b410b893207eccdfd0b67))
# [3.124.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
### Features
* Add current Writing Streak to Daily Notebooks ([#2093](https://github.com/standardnotes/app/issues/2093)) ([d149b50](https://github.com/standardnotes/app/commit/d149b500ee3f13f63a1f831e6e0ba29a53e3e416))
## [3.123.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
### Bug Fixes
* Fixed issue where passcode lock setting was not visible on mobile ([d4af612](https://github.com/standardnotes/app/commit/d4af612fea6960a5233404682aeb1819754b9b8b))
# [3.123.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)
### Bug Fixes
* alignment of Loading text in What's New screen ([d360985](https://github.com/standardnotes/app/commit/d3609853c6c6121a5633d72a685723498099edba))
### Features
* Moments will now create a link with the current open note, so you can look back on what you were working on during a moment ([a7ac715](https://github.com/standardnotes/app/commit/a7ac715499fdf3f15a736f68af4bf5a78454742f))
# [3.122.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-07)
### Features
* add ability to convert selection in Super to bulleted or numbered list ([be4cc4e](https://github.com/standardnotes/app/commit/be4cc4e605100e0a86de214e548e9ed10e2568aa))
## [3.121.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-07)
### Bug Fixes
* Fixed issue about note UUID not being selectable in menu ([5a0c005](https://github.com/standardnotes/app/commit/5a0c005e8c0373e2f47fa613fc5bfec0e84cdb37))
* Fixed workspace menu item rename issue ([45cdab6](https://github.com/standardnotes/app/commit/45cdab6f6c0416e0a01a66e4b4983ff0a52c1afc))
* Fixed issue where note UUID was not selectable in context menu ([5a0c005](https://github.com/standardnotes/app/commit/5a0c005e8c0373e2f47fa613fc5bfec0e84cdb37))
* Fixed issue where workspaces could not be renamed ([45cdab6](https://github.com/standardnotes/app/commit/45cdab6f6c0416e0a01a66e4b4983ff0a52c1afc))
# [3.121.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-07)
+140 -5
View File
@@ -1,18 +1,153 @@
{
"versions": [
{
"version": "3.127.1",
"title": "[3.127.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-19)",
"date": null,
"body": "**Note:** Version bump only for package @standardnotes/web",
"parsed": {
"_": [
"Note: Version bump only for package @standardnotes/web"
]
}
},
{
"version": "3.127.0",
"title": "[3.127.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-16)",
"date": null,
"body": "### Features\n\n* Added \"Whats New\" indicator to Preferences button ([#2107](https://github.com/standardnotes/app/issues/2107)) ([f559442](https://github.com/standardnotes/app/commit/f559442a6726444f338a43e51d7a872f19bce701))",
"parsed": {
"_": [
"Added \"Whats New\" indicator to Preferences button (#2107) (f559442)"
],
"Features": [
"Added \"Whats New\" indicator to Preferences button (#2107) (f559442)"
]
}
},
{
"version": "3.126.4",
"title": "[3.126.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-15)",
"date": null,
"body": "**Note:** Version bump only for package @standardnotes/web",
"parsed": {
"_": [
"Note: Version bump only for package @standardnotes/web"
]
}
},
{
"version": "3.126.2",
"title": "[3.126.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-14)",
"date": null,
"body": "**Note:** Version bump only for package @standardnotes/web",
"parsed": {
"_": [
"Note: Version bump only for package @standardnotes/web"
]
}
},
{
"version": "3.126.0",
"title": "[3.126.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-13)",
"date": null,
"body": "### Features\n\n* Ability to record videos and capture photos directly in app by selecting + in Files view ([#2095](https://github.com/standardnotes/app/issues/2095)) ([d4b63e4](https://github.com/standardnotes/app/commit/d4b63e4ea6e82ba17ebb02da3ca1cfc9ced92b57))",
"parsed": {
"_": [
"Ability to record videos and capture photos directly in app by selecting + in Files view (#2095) (d4b63e4)"
],
"Features": [
"Ability to record videos and capture photos directly in app by selecting + in Files view (#2095) (d4b63e4)"
]
}
},
{
"version": "3.125.0",
"title": "[3.125.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)",
"date": null,
"body": "### Features\n\n* Added \"Open\" button to file upload success notification ([#2091](https://github.com/standardnotes/app/issues/2091)) ([220f626](https://github.com/standardnotes/app/commit/220f62665e6daa18020b410b893207eccdfd0b67))",
"parsed": {
"_": [
"Added \"Open\" button to file upload success notification (#2091) (220f626)"
],
"Features": [
"Added \"Open\" button to file upload success notification (#2091) (220f626)"
]
}
},
{
"version": "3.124.0",
"title": "[3.124.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)",
"date": null,
"body": "### Features\n\n* Add current Writing Streak to Daily Notebooks ([#2093](https://github.com/standardnotes/app/issues/2093)) ([d149b50](https://github.com/standardnotes/app/commit/d149b500ee3f13f63a1f831e6e0ba29a53e3e416))",
"parsed": {
"_": [
"Add current Writing Streak to Daily Notebooks (#2093) (d149b50)"
],
"Features": [
"Add current Writing Streak to Daily Notebooks (#2093) (d149b50)"
]
}
},
{
"version": "3.123.1",
"title": "[3.123.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)",
"date": null,
"body": "### Bug Fixes\n\n* Fixed issue where passcode lock setting was not visible on mobile ([d4af612](https://github.com/standardnotes/app/commit/d4af612fea6960a5233404682aeb1819754b9b8b))",
"parsed": {
"_": [
"Fixed issue where passcode lock setting was not visible on mobile (d4af612)"
],
"Bug Fixes": [
"Fixed issue where passcode lock setting was not visible on mobile (d4af612)"
]
}
},
{
"version": "3.123.0",
"title": "[3.123.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-08)",
"date": null,
"body": "### Bug Fixes\n\n* alignment of Loading text in What's New screen ([d360985](https://github.com/standardnotes/app/commit/d3609853c6c6121a5633d72a685723498099edba))\n\n### Features\n\n* Moments will now create a link with the current open note, so you can look back on what you were working on during a moment ([a7ac715](https://github.com/standardnotes/app/commit/a7ac715499fdf3f15a736f68af4bf5a78454742f))",
"parsed": {
"_": [
"alignment of Loading text in What's New screen (d360985)",
"Moments will now create a link with the current open note, so you can look back on what you were working on during a moment (a7ac715)"
],
"Bug Fixes": [
"alignment of Loading text in What's New screen (d360985)"
],
"Features": [
"Moments will now create a link with the current open note, so you can look back on what you were working on during a moment (a7ac715)"
]
}
},
{
"version": "3.122.0",
"title": "[3.122.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-07)",
"date": null,
"body": "### Features\n\n* add ability to convert selection in Super to bulleted or numbered list ([be4cc4e](https://github.com/standardnotes/app/commit/be4cc4e605100e0a86de214e548e9ed10e2568aa))",
"parsed": {
"_": [
"add ability to convert selection in Super to bulleted or numbered list (be4cc4e)"
],
"Features": [
"add ability to convert selection in Super to bulleted or numbered list (be4cc4e)"
]
}
},
{
"version": "3.121.1",
"title": "[3.121.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-07)",
"date": null,
"body": "### Bug Fixes\n\n* Fixed issue about note UUID not being selectable in menu ([5a0c005](https://github.com/standardnotes/app/commit/5a0c005e8c0373e2f47fa613fc5bfec0e84cdb37))\n* Fixed workspace menu item rename issue ([45cdab6](https://github.com/standardnotes/app/commit/45cdab6f6c0416e0a01a66e4b4983ff0a52c1afc))",
"body": "### Bug Fixes\n\n* Fixed issue where note UUID was not selectable in context menu ([5a0c005](https://github.com/standardnotes/app/commit/5a0c005e8c0373e2f47fa613fc5bfec0e84cdb37))\n* Fixed issue where workspaces could not be renamed ([45cdab6](https://github.com/standardnotes/app/commit/45cdab6f6c0416e0a01a66e4b4983ff0a52c1afc))",
"parsed": {
"_": [
"Fixed issue about note UUID not being selectable in menu (5a0c005)",
"Fixed workspace menu item rename issue (45cdab6)"
"Fixed issue where note UUID was not selectable in context menu (5a0c005)",
"Fixed issue where workspaces could not be renamed (45cdab6)"
],
"Bug Fixes": [
"Fixed issue about note UUID not being selectable in menu (5a0c005)",
"Fixed workspace menu item rename issue (45cdab6)"
"Fixed issue where note UUID was not selectable in context menu (5a0c005)",
"Fixed issue where workspaces could not be renamed (45cdab6)"
]
}
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/web",
"version": "3.121.1",
"version": "3.127.1",
"license": "AGPL-3.0-or-later",
"main": "dist/app.js",
"author": "Standard Notes",
@@ -2,7 +2,7 @@ import { FunctionComponent } from 'react'
const DotOrgNotice: FunctionComponent = () => {
return (
<div className="align-center z-modal flex h-30 w-full w-full items-center bg-danger text-center text-info-contrast">
<div className="z-modal flex h-30 w-full w-full items-center bg-danger text-center text-info-contrast">
<div className="w-full text-center text-xl font-bold">
app.standardnotes.org is no longer maintained. Please switch to{' '}
<a className="underline" href="https://app.standardnotes.com">
@@ -40,6 +40,6 @@ export type PopoverFileItemAction =
type: PopoverFileItemActionType.PreviewFile
payload: {
file: FileItem
otherFiles: FileItem[]
otherFiles?: FileItem[]
}
}
@@ -0,0 +1,181 @@
import { FilesController } from '@/Controllers/FilesController'
import { PhotoRecorder } from '@/Controllers/Moments/PhotoRecorder'
import { formatDateAndTimeForNote } from '@/Utils/DateUtils'
import { classNames } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Button from '../Button/Button'
import Dropdown from '../Dropdown/Dropdown'
import Icon from '../Icon/Icon'
import DecoratedInput from '../Input/DecoratedInput'
import ModalDialog from '../Shared/ModalDialog'
import ModalDialogButtons from '../Shared/ModalDialogButtons'
import ModalDialogDescription from '../Shared/ModalDialogDescription'
import ModalDialogLabel from '../Shared/ModalDialogLabel'
type Props = {
filesController: FilesController
close: () => void
}
const PhotoCaptureModal = ({ filesController, close }: Props) => {
const [fileName, setFileName] = useState(formatDateAndTimeForNote(new Date()))
const [recorder, setRecorder] = useState<PhotoRecorder | undefined>(() => new PhotoRecorder())
const [isRecorderReady, setIsRecorderReady] = useState(false)
const [capturedPhoto, setCapturedPhoto] = useState<File>()
const fileNameInputRef = useRef<HTMLInputElement>(null)
const previewRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!recorder) {
return
}
setIsRecorderReady(false)
const init = async () => {
await recorder.initialize()
if (previewRef.current) {
recorder.video.style.position = ''
recorder.video.style.display = ''
recorder.video.style.height = '100%'
previewRef.current.append(recorder.video)
}
setIsRecorderReady(true)
}
void init()
return () => {
if (recorder.video) {
recorder.finish()
}
}
}, [recorder])
const takePhoto = useCallback(async () => {
if (!recorder) {
return
}
const file = await recorder.takePhoto(fileName)
setCapturedPhoto(file)
setRecorder(undefined)
}, [fileName, recorder])
const devicesAsDropdownItems = useMemo(() => {
return recorder?.devices
? recorder.devices.map((device) => ({
label: device.label || `Camera (${device.deviceId.slice(0, 10)})`,
value: device.deviceId,
}))
: []
}, [recorder?.devices])
const savePhoto = useCallback(() => {
if (!fileName) {
fileNameInputRef.current?.focus()
return
}
if (!capturedPhoto) {
return
}
void filesController.uploadNewFile(capturedPhoto)
close()
}, [capturedPhoto, close, fileName, filesController])
return (
<ModalDialog>
<ModalDialogLabel closeDialog={close}>Take a photo</ModalDialogLabel>
<ModalDialogDescription>
<div className="mb-4 flex flex-col">
<label className="text-sm font-medium text-neutral">
File name:
<DecoratedInput
className={{
container: 'mt-1',
}}
value={fileName}
onChange={(fileName) => setFileName(fileName)}
ref={fileNameInputRef}
/>
</label>
</div>
<div className="mt-2">
<div className="text-sm font-medium text-neutral">Preview:</div>
{!isRecorderReady && (
<div className="mt-1 w-full">
<div className="flex h-64 w-full items-center justify-center gap-2 rounded-md bg-contrast text-base">
<Icon type="camera" className="text-neutral-300" />
Initializing...
</div>
</div>
)}
<div className={classNames('mt-1 w-full', capturedPhoto && 'hidden')} ref={previewRef}></div>
{capturedPhoto && (
<div className="mt-1 w-full">
<img src={URL.createObjectURL(capturedPhoto)} alt="Captured photo" />
</div>
)}
</div>
{recorder && devicesAsDropdownItems.length > 1 && !capturedPhoto && (
<div className="mt-4">
<label className="text-sm font-medium text-neutral">
Device:
<Dropdown
id={'photo-capture-device-dropdown'}
label={'Photo Capture Device'}
items={devicesAsDropdownItems}
value={recorder.selectedDevice.deviceId}
onChange={(value: string) => {
void recorder.setDevice(value)
}}
className={{
wrapper: 'mt-1',
popover: 'z-modal',
}}
/>
</label>
</div>
)}
</ModalDialogDescription>
<ModalDialogButtons>
{!capturedPhoto && (
<Button
primary
colorStyle="danger"
className="flex items-center gap-2"
onClick={() => {
void takePhoto()
}}
>
<Icon type="camera" />
Take photo
</Button>
)}
{capturedPhoto && (
<div className="flex items-center gap-2">
<Button
className="flex items-center gap-2"
onClick={() => {
setCapturedPhoto(undefined)
setRecorder(new PhotoRecorder())
}}
>
Retry
</Button>
<Button primary className="flex items-center gap-2" onClick={savePhoto}>
<Icon type="upload" />
Upload
</Button>
</div>
)}
</ModalDialogButtons>
</ModalDialog>
)
}
export default observer(PhotoCaptureModal)
@@ -0,0 +1,166 @@
import { FilesController } from '@/Controllers/FilesController'
import { VideoRecorder } from '@/Controllers/Moments/VideoRecorder'
import { formatDateAndTimeForNote } from '@/Utils/DateUtils'
import { classNames } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Button from '../Button/Button'
import Icon from '../Icon/Icon'
import DecoratedInput from '../Input/DecoratedInput'
import ModalDialog from '../Shared/ModalDialog'
import ModalDialogButtons from '../Shared/ModalDialogButtons'
import ModalDialogDescription from '../Shared/ModalDialogDescription'
import ModalDialogLabel from '../Shared/ModalDialogLabel'
type Props = {
filesController: FilesController
close: () => void
}
const VideoCaptureModal = ({ filesController, close }: Props) => {
const [fileName, setFileName] = useState(formatDateAndTimeForNote(new Date()))
const [recorder, setRecorder] = useState(() => new VideoRecorder(fileName))
const [isRecorderReady, setIsRecorderReady] = useState(false)
const [isRecording, setIsRecording] = useState(false)
const [capturedVideo, setCapturedVideo] = useState<File>()
const fileNameInputRef = useRef<HTMLInputElement>(null)
const previewRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const init = async () => {
await recorder.initialize()
if (previewRef.current) {
recorder.video.style.position = ''
recorder.video.style.display = ''
recorder.video.style.height = '100%'
previewRef.current.append(recorder.video)
}
setIsRecorderReady(true)
}
void init()
return () => {
if (recorder.video) {
void recorder.stop()
}
}
}, [recorder])
const startRecording = useCallback(async () => {
await recorder.startRecording()
setIsRecording(true)
}, [recorder])
const saveVideo = useCallback(() => {
if (!fileName) {
fileNameInputRef.current?.focus()
return
}
if (!capturedVideo) {
return
}
const namedFile = new File([capturedVideo], fileName, {
type: capturedVideo.type,
})
void filesController.uploadNewFile(namedFile)
close()
}, [capturedVideo, close, fileName, filesController])
const capturedVideoObjectURL = useMemo(() => {
if (!capturedVideo) {
return
}
return URL.createObjectURL(capturedVideo)
}, [capturedVideo])
return (
<ModalDialog>
<ModalDialogLabel closeDialog={close}>Record a video</ModalDialogLabel>
<ModalDialogDescription>
<div className="mb-4 flex flex-col">
<label className="text-sm font-medium text-neutral">
File name:
<DecoratedInput
className={{
container: 'mt-1',
}}
value={fileName}
onChange={(fileName) => setFileName(fileName)}
ref={fileNameInputRef}
/>
</label>
</div>
<div className="mt-2">
<div className="text-sm font-medium text-neutral">Preview:</div>
{!isRecorderReady && (
<div className="mt-1 w-full">
<div className="flex h-64 w-full items-center justify-center gap-2 rounded-md bg-contrast text-base">
<Icon type="camera" className="text-neutral-300" />
Initializing...
</div>
</div>
)}
<div className={classNames('mt-1 w-full', capturedVideo && 'hidden')} ref={previewRef}></div>
{capturedVideo && (
<div className="mt-1 w-full">
<video src={capturedVideoObjectURL} controls />
</div>
)}
</div>
</ModalDialogDescription>
<ModalDialogButtons>
{!capturedVideo && !isRecording && (
<Button
primary
className="flex items-center gap-2"
onClick={() => {
void startRecording()
}}
>
<Icon type="camera" />
Start recording
</Button>
)}
{!capturedVideo && isRecording && (
<Button
primary
colorStyle="danger"
className="flex items-center gap-2"
onClick={async () => {
const capturedVideo = await recorder.stop()
setIsRecording(false)
setCapturedVideo(capturedVideo)
}}
>
<Icon type="camera" />
Stop recording
</Button>
)}
{capturedVideo && (
<div className="flex items-center gap-2">
<Button
className="flex items-center gap-2"
onClick={() => {
setCapturedVideo(undefined)
setRecorder(new VideoRecorder(fileName))
setIsRecorderReady(false)
}}
>
Retry
</Button>
<Button primary className="flex items-center gap-2" onClick={saveVideo}>
<Icon type="upload" />
Upload
</Button>
</div>
)}
</ModalDialogButtons>
</ModalDialog>
)
}
export default observer(VideoCaptureModal)
@@ -18,6 +18,7 @@ type Props = {
selectedDay?: Date
selectedDayType?: 'item' | 'template'
className?: string
children?: React.ReactNode
}
export type InfiniteCalendarInterface = {
@@ -27,7 +28,7 @@ export type InfiniteCalendarInterface = {
const PageSize = 2
const InfiniteCalendar = forwardRef<InfiniteCalendarInterface, Props>(
({ activities, onDateSelect, selectedDay, className }: Props, ref) => {
({ activities, onDateSelect, selectedDay, className, children }: Props, ref) => {
const [expanded, setExpanded] = useState(isMobileScreen() ? false : true)
const [restoreScrollAfterExpand, setRestoreScrollAfterExpand] = useState(false)
const scrollerRef = useRef<InfiniteScrollerInterface | null>(null)
@@ -216,6 +217,7 @@ const InfiniteCalendar = forwardRef<InfiniteCalendarInterface, Props>(
})}
</InfinteScroller>
)}
{expanded && children}
</div>
)
},
@@ -302,6 +302,7 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
isFilesSmartView={isFilesSmartView}
optionsSubtitle={optionsSubtitle}
selectedTag={selectedTag}
filesController={filesController}
/>
)}
<SearchBar itemListController={itemListController} searchOptionsController={searchOptionsController} />
@@ -7,9 +7,9 @@ import { AppPaneId } from '../../Panes/AppPaneMetadata'
import { createDailyItemsWithToday, createItemsByDateMapping, insertBlanks } from './CreateDailySections'
import { DailyItemsDay } from './DailyItemsDaySection'
import { DailyItemCell } from './DailyItemCell'
import { SNTag } from '@standardnotes/snjs'
import { SNTag, pluralize } from '@standardnotes/snjs'
import { CalendarActivity } from '../Calendar/CalendarActivity'
import { dateToDailyDayIdentifier } from './Utils'
import { dateToDailyDayIdentifier, getDailyWritingStreak } from './Utils'
import InfiniteCalendar, { InfiniteCalendarInterface } from '../Calendar/InfiniteCalendar'
import { InfiniteScrollerInterface, InfinteScroller } from '../InfiniteScroller/InfiniteScroller'
import { LoggingDomain, log } from '@/Logging'
@@ -50,6 +50,11 @@ const DailyContentList: FunctionComponent<Props> = ({
return createItemsByDateMapping(items)
}, [items])
const currentStreak = useMemo(
() => getDailyWritingStreak(todayItem, itemsByDateMapping),
[todayItem, itemsByDateMapping],
)
useEffect(() => {
setTodayItem(dailyItems.find((item) => item.isToday) as DailyItemsDay)
}, [dailyItems])
@@ -182,7 +187,16 @@ const DailyContentList: FunctionComponent<Props> = ({
selectedDayType={!selectedDay ? undefined : hasItemsOnSelectedDay ? 'item' : 'template'}
ref={calendarRef}
className={'flex-column flex'}
/>
>
{currentStreak > 0 && (
<div className="flex w-full items-center justify-center border-t border-solid border-border bg-secondary-background p-2">
<span className="opacity-50">Current Streak</span>
<span className="ml-1.5 font-bold">
{currentStreak} {pluralize(currentStreak, 'Day', 'Days')}
</span>
</div>
)}
</InfiniteCalendar>
<InfinteScroller
paginateFront={paginateTop}
@@ -1,3 +1,37 @@
import { addDaysToDate } from '@standardnotes/utils'
import { ListableContentItem } from '../Types/ListableContentItem'
import { DailyItemsDay } from './DailyItemsDaySection'
export function dateToDailyDayIdentifier(date: Date): string {
return date.toLocaleDateString()
}
export function getDailyWritingStreak(
todayItem: DailyItemsDay | undefined,
itemsByDateMapping: Record<string, ListableContentItem[]>,
) {
if (!todayItem) {
return 0
}
const startDay = todayItem.date
let checkingDayOffsetFromToday = -1
let keepLooping = true
let streak = 0
while (keepLooping) {
const checkingDay = addDaysToDate(startDay, checkingDayOffsetFromToday)
const items = itemsByDateMapping[dateToDailyDayIdentifier(checkingDay)]
if (!items || items?.length === 0) {
keepLooping = false
break
}
streak++
checkingDayOffsetFromToday--
}
const hasEntryForToday = itemsByDateMapping[dateToDailyDayIdentifier(todayItem.date)]?.length > 0
return streak + (hasEntryForToday ? 1 : 0)
}
@@ -0,0 +1,127 @@
import PhotoCaptureModal from '@/Components/CameraCaptureModal/PhotoCaptureModal'
import VideoCaptureModal from '@/Components/CameraCaptureModal/VideoCaptureModal'
import Icon from '@/Components/Icon/Icon'
import Menu from '@/Components/Menu/Menu'
import MenuItem from '@/Components/Menu/MenuItem'
import Popover from '@/Components/Popover/Popover'
import { FilesController } from '@/Controllers/FilesController'
import { PhotoRecorder } from '@/Controllers/Moments/PhotoRecorder'
import { classNames } from '@standardnotes/snjs'
import { useEffect, useRef, useState } from 'react'
type Props = {
isDailyEntry: boolean
isInFilesSmartView: boolean
addButtonLabel: string
addNewItem: () => void
filesController: FilesController
}
const AddItemMenuButton = ({
filesController,
isDailyEntry,
addButtonLabel,
isInFilesSmartView,
addNewItem,
}: Props) => {
const addItemButtonRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [deviceHasCamera, setDeviceHasCamera] = useState(false)
const [captureType, setCaptureType] = useState<'photo' | 'video'>()
useEffect(() => {
const setCameraSupport = async () => {
setDeviceHasCamera(await PhotoRecorder.isSupported())
}
void setCameraSupport()
}, [])
const canShowMenu = isInFilesSmartView && deviceHasCamera
return (
<>
<button
className={classNames(
'hidden md:flex',
'h-8 w-8 hover:brightness-125',
'z-editor-title-bar ml-3 cursor-pointer items-center',
`justify-center rounded-full border border-solid border-transparent ${
isDailyEntry ? 'bg-danger text-danger-contrast' : 'bg-info text-info-contrast'
}`,
)}
title={addButtonLabel}
aria-label={addButtonLabel}
onClick={() => {
if (canShowMenu) {
setIsMenuOpen((isOpen) => !isOpen)
} else {
addNewItem()
}
}}
ref={addItemButtonRef}
>
<Icon type="add" size="custom" className="h-5 w-5" />
</button>
<Popover
open={canShowMenu && isMenuOpen}
anchorElement={addItemButtonRef.current}
togglePopover={() => {
setIsMenuOpen((isOpen) => !isOpen)
}}
side="bottom"
align="center"
className="py-2"
>
<Menu a11yLabel={'test'} isOpen={isMenuOpen}>
<MenuItem
onClick={() => {
addNewItem()
setIsMenuOpen(false)
}}
>
<Icon type="add" className="mr-2" />
{addButtonLabel}
</MenuItem>
<MenuItem
onClick={async () => {
setCaptureType('photo')
setIsMenuOpen(false)
}}
>
<Icon type="camera" className="mr-2" />
Take photo
</MenuItem>
<MenuItem
onClick={async () => {
setCaptureType('video')
setIsMenuOpen(false)
}}
>
<Icon type="camera" className="mr-2" />
Record video
</MenuItem>
</Menu>
</Popover>
{captureType === 'photo' && (
<PhotoCaptureModal
filesController={filesController}
close={() => {
setCaptureType(undefined)
}}
/>
)}
{captureType === 'video' && (
<VideoCaptureModal
filesController={filesController}
close={() => {
setCaptureType(undefined)
}}
/>
)}
</>
)
}
export default AddItemMenuButton
@@ -9,6 +9,8 @@ import { isTag, VectorIconNameOrEmoji } from '@standardnotes/snjs'
import RoundIconButton from '@/Components/Button/RoundIconButton'
import { AnyTag } from '@/Controllers/Navigation/AnyTagType'
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import AddItemMenuButton from './AddItemMenuButton'
import { FilesController } from '@/Controllers/FilesController'
type Props = {
application: WebApplication
@@ -19,6 +21,7 @@ type Props = {
isFilesSmartView: boolean
optionsSubtitle?: string
selectedTag: AnyTag
filesController: FilesController
}
const ContentListHeader = ({
@@ -30,6 +33,7 @@ const ContentListHeader = ({
isFilesSmartView,
optionsSubtitle,
selectedTag,
filesController,
}: Props) => {
const displayOptionsContainerRef = useRef<HTMLDivElement>(null)
const displayOptionsButtonRef = useRef<HTMLButtonElement>(null)
@@ -84,23 +88,15 @@ const ContentListHeader = ({
const AddButton = useMemo(() => {
return (
<button
className={classNames(
'hidden md:flex',
'h-8 w-8 hover:brightness-125',
'z-editor-title-bar ml-3 cursor-pointer items-center',
`justify-center rounded-full border border-solid border-transparent ${
isDailyEntry ? 'bg-danger text-danger-contrast' : 'bg-info text-info-contrast'
}`,
)}
title={addButtonLabel}
aria-label={addButtonLabel}
onClick={addNewItem}
>
<Icon type="add" size="custom" className="h-5 w-5" />
</button>
<AddItemMenuButton
isInFilesSmartView={isFilesSmartView}
isDailyEntry={isDailyEntry}
addButtonLabel={addButtonLabel}
addNewItem={addNewItem}
filesController={filesController}
/>
)
}, [addButtonLabel, addNewItem, isDailyEntry])
}, [addButtonLabel, addNewItem, filesController, isDailyEntry, isFilesSmartView])
const FolderName = useMemo(() => {
return (
@@ -6,6 +6,7 @@ import Icon from '@/Components/Icon/Icon'
import { DropdownItem } from './DropdownItem'
import StyledListboxButton from './StyledListboxButton'
import StyledListboxOption from './StyledListboxOption'
import { classNames } from '@standardnotes/snjs'
type DropdownProps = {
id: string
@@ -14,7 +15,11 @@ type DropdownProps = {
value: string
onChange: (value: string, item: DropdownItem) => void
disabled?: boolean
className?: string
className?: {
wrapper?: string
button?: string
popover?: string
}
fullWidth?: boolean
portal?: boolean
}
@@ -63,12 +68,16 @@ const Dropdown: FunctionComponent<DropdownProps> = ({
onChange(value, selectedItem)
}
const wrapperClassName = className?.wrapper ?? ''
const buttonClassName = className?.button ?? ''
const popoverClassName = className?.popover ?? ''
return (
<div className={className}>
<div className={wrapperClassName}>
<VisuallyHidden id={labelId}>{label}</VisuallyHidden>
<ListboxInput value={value} onChange={handleChange} aria-labelledby={labelId} disabled={disabled}>
<StyledListboxButton
className={`w-full ${!fullWidth ? 'md:w-fit' : ''}`}
className={classNames('w-full', !fullWidth && 'md:w-fit', buttonClassName)}
children={({ value, label, isExpanded }) => {
const current = items.find((item) => item.value === value)
const icon = current ? current?.icon : null
@@ -82,7 +91,7 @@ const Dropdown: FunctionComponent<DropdownProps> = ({
})
}}
/>
<ListboxPopover portal={portal} className="sn-dropdown sn-dropdown-popover">
<ListboxPopover portal={portal} className={classNames('sn-dropdown sn-dropdown-popover', popoverClassName)}>
<div className="sn-component">
<ListboxList>
{items.map((item) => (
@@ -339,8 +339,11 @@ class Footer extends AbstractComponent<Props, State> {
this.viewControllerManager.quickSettingsMenuController.closeQuickSettingsMenu()
}
openPreferences = () => {
openPreferences = (openWhatsNew: boolean) => {
this.clickOutsideQuickSettingsMenu()
if (openWhatsNew) {
this.viewControllerManager.preferencesController.setCurrentPane('whats-new')
}
this.viewControllerManager.preferencesController.openPreferences()
}
@@ -1,27 +1,47 @@
import { compareSemVersions } from '@standardnotes/snjs'
import { keyboardStringForShortcut, OPEN_PREFERENCES_COMMAND } from '@standardnotes/ui-services'
import { useMemo } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useApplication } from '../ApplicationProvider'
import { useCommandService } from '../CommandProvider'
import Icon from '../Icon/Icon'
import StyledTooltip from '../StyledTooltip/StyledTooltip'
type Props = {
openPreferences: () => void
openPreferences: (openWhatsNew: boolean) => void
}
const PreferencesButton = ({ openPreferences }: Props) => {
const commandService = useCommandService()
const application = useApplication()
const commandService = useCommandService()
const shortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(OPEN_PREFERENCES_COMMAND)),
[commandService],
)
const [changelogLastReadVersion, setChangelogLastReadVersion] = useState(() =>
application.changelogService.getLastReadVersion(),
)
const isChangelogUnread = useMemo(
() => (changelogLastReadVersion ? compareSemVersions(application.version, changelogLastReadVersion) > 0 : false),
[application.version, changelogLastReadVersion],
)
useEffect(
() => application.changelogService.addLastReadChangeListener(setChangelogLastReadVersion),
[application.changelogService],
)
const onClick = useCallback(() => {
openPreferences(isChangelogUnread)
}, [isChangelogUnread, openPreferences])
return (
<StyledTooltip label={`Open preferences (${shortcut})`}>
<button onClick={openPreferences} className="flex h-full w-8 cursor-pointer items-center justify-center">
<button onClick={onClick} className="relative flex h-full w-8 cursor-pointer items-center justify-center">
<div className="h-5">
<Icon type="tune" className="rounded hover:text-info" />
</div>
{isChangelogUnread && <div className="absolute top-0.5 right-0.5 h-2 w-2 rounded-full bg-info" />}
</button>
</StyledTooltip>
)
@@ -62,6 +62,7 @@ export const IconNameToSvgMapping = {
archive: icons.ArchiveIcon,
asterisk: icons.AsteriskIcon,
authenticator: icons.AuthenticatorIcon,
camera: icons.CameraIcon,
check: icons.CheckIcon,
close: icons.CloseIcon,
code: icons.CodeIcon,
@@ -106,6 +107,7 @@ export const IconNameToSvgMapping = {
tune: icons.TuneIcon,
unarchive: icons.UnarchiveIcon,
unpin: icons.UnpinIcon,
upload: icons.UploadIcon,
user: icons.UserIcon,
view: icons.ViewIcon,
warning: icons.WarningIcon,
@@ -168,7 +168,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
>
<BlocksEditor
onChange={handleChange}
ignoreFirstChange={false}
ignoreFirstChange={controller.isTemplateNote}
className={classNames(
'relative h-full resize-none px-4 py-4 focus:shadow-none focus:outline-none',
lineHeight && `leading-${lineHeight.toLowerCase()}`,
@@ -8,7 +8,6 @@ import { WebApplication } from '@/Application/Application'
import { useBeforeUnload } from '@/Hooks/useBeforeUnload'
import ChangeEmailForm from './ChangeEmailForm'
import ChangeEmailSuccess from './ChangeEmailSuccess'
import { isEmailValid } from '@/Utils'
enum SubmitButtonTitles {
Default = 'Continue',
@@ -57,18 +56,6 @@ const ChangeEmail: FunctionComponent<Props> = ({ onCloseDialog, application }) =
return success
}
const validateNewEmail = async () => {
if (!isEmailValid(newEmail)) {
applicationAlertService
.alert('The email you entered has an invalid format. Please review your input and try again.')
.catch(console.error)
return false
}
return true
}
const resetProgressState = () => {
setSubmitButtonTitle(SubmitButtonTitles.Default)
setIsContinuing(false)
@@ -110,7 +97,7 @@ const ChangeEmail: FunctionComponent<Props> = ({ onCloseDialog, application }) =
setIsContinuing(true)
setSubmitButtonTitle(SubmitButtonTitles.GeneratingKeys)
const valid = (await validateCurrentPassword()) && (await validateNewEmail())
const valid = await validateCurrentPassword()
if (!valid) {
resetProgressState()
@@ -186,10 +186,6 @@ const PasscodeLock = ({ application, viewControllerManager }: Props) => {
const autolockService = application.getAutolockService()
if (!autolockService) {
return null
}
return (
<>
<PreferencesGroup>
@@ -246,7 +242,7 @@ const PasscodeLock = ({ application, viewControllerManager }: Props) => {
</PreferencesSegment>
</PreferencesGroup>
{hasPasscode && !isNativeMobileWeb && (
{hasPasscode && autolockService && (
<>
<div className="min-h-3" />
<PreferencesGroup>

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