diff --git a/.github/workflows/e2e-linux.yml b/.github/workflows/e2e-linux.yml index 63fb29e123..a7a37f2740 100644 --- a/.github/workflows/e2e-linux.yml +++ b/.github/workflows/e2e-linux.yml @@ -13,7 +13,7 @@ jobs: timeout-minutes: 180 env: - DISPLAY: ":99.0" + DISPLAY: ":99" TEST_MODE: true IS_CI: true SKIP_BACK_COMPAT_TEST_BRANCHES: '["update-orbitdb", "chore/upgrade-orbitdb-2_4_3", "fix/2679-2680-2682-3_0-fixes"]' @@ -64,11 +64,13 @@ jobs: run: ENVFILE=.env.e2e npm run distUbuntu && VERSION=$(jq -r ".version" package.json) && cd dist && FILE_NAME=$(ls | grep $VERSION) && echo "FILE_NAME=$(ls | grep $VERSION)" >> $GITHUB_ENV && cp $FILE_NAME ../../e2e-tests/Quiet && export FILE_NAME=$FILE_NAME - name: Chmod + id: finish-build working-directory: ./packages/e2e-tests/Quiet run: chmod +x $FILE_NAME - name: Run one client test uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 + if: (success() || failure()) && steps.finish-build.outcome == 'success' with: timeout_minutes: 15 max_attempts: 3 @@ -76,6 +78,7 @@ jobs: - name: Run user profile test uses: nick-fields/retry@v2 + if: (success() || failure()) && steps.finish-build.outcome == 'success' with: timeout_minutes: 25 max_attempts: 3 @@ -83,13 +86,23 @@ jobs: - name: Run multiple clients test uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 + if: (success() || failure()) && steps.finish-build.outcome == 'success' with: timeout_minutes: 25 max_attempts: 3 command: cd packages/e2e-tests && npm run test multipleClients.test.ts + + # - name: Run multiple clients test (DMs) + # uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 + # if: (success() || failure()) && steps.finish-build.outcome == 'success' + # with: + # timeout_minutes: 25 + # max_attempts: 3 + # command: cd packages/e2e-tests && npm run test multipleClients.dms.test.ts - name: Run multiple clients test (private channels) uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 + if: (success() || failure()) && steps.finish-build.outcome == 'success' with: timeout_minutes: 25 max_attempts: 3 @@ -97,6 +110,7 @@ jobs: - name: Run invitation link test - Includes 2 separate application clients uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 + if: (success() || failure()) && steps.finish-build.outcome == 'success' with: timeout_minutes: 25 max_attempts: 3 diff --git a/.github/workflows/e2e-mac.yml b/.github/workflows/e2e-mac.yml index b33d2dcb0d..47cdc82473 100644 --- a/.github/workflows/e2e-mac.yml +++ b/.github/workflows/e2e-mac.yml @@ -63,10 +63,12 @@ jobs: run: hdiutil mount $FILE_NAME - name: Add App file to applications + id: finish-build run: cd ~ && cp -R "/Volumes/Quiet $VERSION${{matrix.binary_suffix}}/Quiet.app" /Applications - name: Run one client test uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 + if: (success() || failure()) && steps.finish-build.outcome == 'success' with: timeout_minutes: 15 max_attempts: 3 @@ -74,6 +76,7 @@ jobs: - name: Run user profile test uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 + if: (success() || failure()) && steps.finish-build.outcome == 'success' with: timeout_minutes: 25 max_attempts: 3 @@ -81,6 +84,7 @@ jobs: - name: Run multiple clients test uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 + if: (success() || failure()) && steps.finish-build.outcome == 'success' with: timeout_minutes: 25 max_attempts: 3 @@ -88,13 +92,23 @@ jobs: - name: Run multiple clients test (private channels) uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 + if: (success() || failure()) && steps.finish-build.outcome == 'success' with: timeout_minutes: 25 max_attempts: 3 command: cd packages/e2e-tests && npm run test multipleClients.privateChannels.test.ts + # - name: Run multiple clients test (DMs) + # uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 + # if: (success() || failure()) && steps.finish-build.outcome == 'success' + # with: + # timeout_minutes: 25 + # max_attempts: 3 + # command: cd packages/e2e-tests && npm run test multipleClients.dms.test.ts + - name: Run invitation link test - Includes 2 separate application clients uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 + if: (success() || failure()) && steps.finish-build.outcome == 'success' with: timeout_minutes: 25 max_attempts: 3 diff --git a/.github/workflows/e2e-qss-linux.yml b/.github/workflows/e2e-qss-linux.yml index a7ef2cf1ff..428760f07e 100644 --- a/.github/workflows/e2e-qss-linux.yml +++ b/.github/workflows/e2e-qss-linux.yml @@ -70,29 +70,41 @@ jobs: run: ENVFILE=.env.e2e.qss npm run distUbuntu && VERSION=$(jq -r ".version" package.json) && cd dist && FILE_NAME=$(ls | grep $VERSION) && echo "FILE_NAME=$(ls | grep $VERSION)" >> $GITHUB_ENV && cp $FILE_NAME ../../e2e-tests/Quiet && export FILE_NAME=$FILE_NAME - name: Chmod + id: finish-build working-directory: ./packages/e2e-tests/Quiet run: chmod +x $FILE_NAME - - name: Run multiple clients with QSS test + - name: Run one client with QSS test uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 + if: (success() || failure()) && steps.finish-build.outcome == 'success' with: timeout_minutes: 25 max_attempts: 3 - command: cd packages/e2e-tests && npm run test multipleClients.qss.test.ts + command: cd packages/e2e-tests && npm run test oneClient.qss.test.ts - - name: Run multiple clients with QSS test (private channels) + - name: Run multiple clients with QSS test uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 + if: (success() || failure()) && steps.finish-build.outcome == 'success' with: timeout_minutes: 25 max_attempts: 3 - command: cd packages/e2e-tests && npm run test multipleClients.privateChannels.qss.test.ts + command: cd packages/e2e-tests && npm run test multipleClients.qss.test.ts - - name: Run one client with QSS test + - name: Run multiple clients with QSS test (private channels) uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 + if: (success() || failure()) && steps.finish-build.outcome == 'success' with: timeout_minutes: 25 max_attempts: 3 - command: cd packages/e2e-tests && npm run test oneClient.qss.test.ts + command: cd packages/e2e-tests && npm run test multipleClients.privateChannels.qss.test.ts + + # - name: Run multiple clients with QSS test (DMs) + # uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 + # if: (success() || failure()) && steps.finish-build.outcome == 'success' + # with: + # timeout_minutes: 25 + # max_attempts: 3 + # command: cd packages/e2e-tests && npm run test multipleClients.dms.qss.test.ts - name: Stop QSS docker if: always() diff --git a/.gitmodules b/.gitmodules index 0f244fa210..decae1df09 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,7 @@ [submodule "3rd-party/auth"] path = 3rd-party/auth url = https://github.com/TryQuiet/auth.git - branch = main + branch = feat/private-dms [submodule "3rd-party/js-libp2p-noise"] path = 3rd-party/js-libp2p-noise url = https://github.com/TryQuiet/js-libp2p-noise.git @@ -13,4 +13,4 @@ [submodule "3rd-party/qss"] path = 3rd-party/qss url = https://github.com/TryQuiet/quiet-storage-service.git - branch = main + branch = feat/2571-private-dms diff --git a/3rd-party/auth b/3rd-party/auth index 018421c01a..22dc11339a 160000 --- a/3rd-party/auth +++ b/3rd-party/auth @@ -1 +1 @@ -Subproject commit 018421c01a6840bcf0329ae8144730edda84e5c5 +Subproject commit 22dc11339a407f94d34bdb24a82e8338430a7152 diff --git a/3rd-party/qss b/3rd-party/qss index 63b00eb043..ae44b86195 160000 --- a/3rd-party/qss +++ b/3rd-party/qss @@ -1 +1 @@ -Subproject commit 63b00eb04308eed7264fed07f3fcd961ee211720 +Subproject commit ae44b86195afe250d1b5c25d04992050c2298bb2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dc886f392..2678bef56d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +* Adds one-on-one and group DMs to desktop [#2571](https://github.com/TryQuiet/quiet/issues/2571) * Allow all users to create private channels [#3277](https://github.com/TryQuiet/quiet/issues/3277) ### Fixes @@ -60,6 +61,7 @@ * Registers APNS token with push notifications service [#3080](https://github.com/TryQuiet/quiet/issues/3080) * Adds push notification service [#3086](https://github.com/TryQuiet/quiet/issues/3086) +* Adds private channels [#3155](https://github.com/TryQuiet/quiet/issues/3155) ### Fixes diff --git a/package-lock.json b/package-lock.json index 1cde8dbc93..b0d961af54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3607,7 +3607,9 @@ "dev": true }, "node_modules/hasown": { - "version": "2.0.2", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -3981,12 +3983,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "dev": true, + "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -4358,6 +4361,7 @@ "resolved": "https://registry.npmjs.org/lerna/-/lerna-6.6.2.tgz", "integrity": "sha512-W4qrGhcdutkRdHEaDf9eqp7u4JvI+1TwFy5woX6OI8WPe4PYBdxuILAsvhp614fUG41rKSGDKlOh+AWzdSidTg==", "dev": true, + "license": "MIT", "dependencies": { "@lerna/child-process": "6.6.2", "@lerna/create": "6.6.2", @@ -8321,6 +8325,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8345,7 +8350,8 @@ "node_modules/unicode-emoji-json": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/unicode-emoji-json/-/unicode-emoji-json-0.8.0.tgz", - "integrity": "sha512-3wDXXvp6YGoKGhS2O2H7+V+bYduOBydN1lnI0uVfr1cIdY02uFFiEH1i3kE5CCE4l6UqbLKVmEFW9USxTAMD1g==" + "integrity": "sha512-3wDXXvp6YGoKGhS2O2H7+V+bYduOBydN1lnI0uVfr1cIdY02uFFiEH1i3kE5CCE4l6UqbLKVmEFW9USxTAMD1g==", + "license": "MIT" }, "node_modules/unique-filename": { "version": "3.0.0", diff --git a/packages/backend/package-lock.json b/packages/backend/package-lock.json index 916c2ef237..0c1976fee5 100644 --- a/packages/backend/package-lock.json +++ b/packages/backend/package-lock.json @@ -17,7 +17,7 @@ "@helia/unixfs": "4.0.1", "@ipld/dag-cbor": "^9.2.1", "@ipld/dag-pb": "^4.1.2", - "@libp2p/crypto": "^5.0.8", + "@libp2p/crypto": "5.0.8", "@libp2p/echo": "^1.1.1", "@libp2p/identify": "^3.0.14", "@libp2p/interface": "^2.3.0", @@ -116,7 +116,7 @@ "@types/jest": "28.1.8", "@types/luxon": "^3.4.2", "@types/mock-fs": "^4.13.1", - "@types/node": "18.11.9", + "@types/node": "20.19.43", "@types/node-fetch": "^2.5.11", "@types/supertest": "^2.0.11", "@types/tmp": "^0.2.3", @@ -19266,14 +19266,6 @@ "version": "2.0.1", "license": "MIT" }, - "node_modules/@libp2p/interface-transport/node_modules/multiformats": { - "version": "11.0.2", - "license": "Apache-2.0 OR MIT", - "engines": { - "node": ">=16.0.0", - "npm": ">=7.0.0" - } - }, "node_modules/@libp2p/kad-dht": { "version": "14.1.6", "license": "Apache-2.0 OR MIT", @@ -25075,8 +25067,13 @@ } }, "node_modules/@types/node": { - "version": "18.11.9", - "license": "MIT" + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } }, "node_modules/@types/node-fetch": { "version": "2.6.12", @@ -25143,6 +25140,12 @@ "node": ">= 0.6" } }, + "node_modules/@types/node/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/@types/supertest": { "version": "2.0.16", "dev": true, diff --git a/packages/backend/package.json b/packages/backend/package.json index d95da929ca..2b4000e161 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -73,7 +73,7 @@ "@types/jest": "28.1.8", "@types/luxon": "^3.4.2", "@types/mock-fs": "^4.13.1", - "@types/node": "18.11.9", + "@types/node": "20.19.43", "@types/node-fetch": "^2.5.11", "@types/supertest": "^2.0.11", "@types/tmp": "^0.2.3", @@ -107,7 +107,7 @@ "@helia/unixfs": "4.0.1", "@ipld/dag-cbor": "^9.2.1", "@ipld/dag-pb": "^4.1.2", - "@libp2p/crypto": "^5.0.8", + "@libp2p/crypto": "5.0.8", "@libp2p/echo": "^1.1.1", "@libp2p/identify": "^3.0.14", "@libp2p/interface": "^2.3.0", @@ -201,8 +201,11 @@ }, "overrides": { "level": "$level", + "multiformats": "13.3.1", + "@chainsafe/libp2p-noise": { + "@libp2p/crypto": "5.0.8" + }, "@orbitdb/core": { - "multiformats": "13.3.1", "@ipld/dag-cbor": "9.2.1" }, "express": { diff --git a/packages/backend/src/nest/auth/services/crypto/crypto.service.ts b/packages/backend/src/nest/auth/services/crypto/crypto.service.ts index 533cee2340..2ccdb6daed 100644 --- a/packages/backend/src/nest/auth/services/crypto/crypto.service.ts +++ b/packages/backend/src/nest/auth/services/crypto/crypto.service.ts @@ -178,8 +178,10 @@ class CryptoService extends ChainServiceBase { contents, } const isValid = this.validateSignature(fullSig) - if (!isValid && failOnInvalid) { - throw new Error(`Couldn't verify signature on message`) + if (!isValid) { + const validationError = new Error(`Couldn't verify signature on message`) + logger.error(`Error while validating message signature`, validationError) + if (failOnInvalid) throw validationError } return { diff --git a/packages/backend/src/nest/auth/services/roles/permissions.ts b/packages/backend/src/nest/auth/services/roles/permissions.ts index 2ee83d74b5..9bd2d81578 100644 --- a/packages/backend/src/nest/auth/services/roles/permissions.ts +++ b/packages/backend/src/nest/auth/services/roles/permissions.ts @@ -1,3 +1,21 @@ -export enum Permissions { - MODIFIABLE_MEMBERSHIP = 'modifiable-membership', +import { PermissionsMap, Permission } from '@localfirst/auth' + +export const defaultDmPermissions = (memberIds: string[]): PermissionsMap => { + return { + [Permission.MODIFIABLE_MEMBERSHIP]: { + memberIds, + }, + } +} + +export const defaultChannelPermissions = (): PermissionsMap => { + return { + [Permission.MODIFIABLE_MEMBERSHIP]: true, + } +} + +export const defaultGenericPermissions = (): PermissionsMap => { + return { + [Permission.MODIFIABLE_MEMBERSHIP]: true, + } } diff --git a/packages/backend/src/nest/auth/services/roles/role.service.ts b/packages/backend/src/nest/auth/services/roles/role.service.ts index a811036d78..de4cab7583 100644 --- a/packages/backend/src/nest/auth/services/roles/role.service.ts +++ b/packages/backend/src/nest/auth/services/roles/role.service.ts @@ -4,7 +4,6 @@ import { SigChain } from '../../sigchain' import { ChainServiceBase } from '../chainServiceBase' -import { Permissions } from './permissions' import { QuietRole, RoleName, SELF_ASSIGN_ROLES } from './roles' import { AddRoleInput, Member, PermissionsMap, Role } from '@localfirst/auth' import { createLogger } from '../../../common/logger' @@ -17,12 +16,8 @@ class RoleService extends ChainServiceBase { } // TODO: figure out permissions - public create(roleName: RoleName | string, permissions: PermissionsMap = {}, staticMembership: boolean = false) { + public create(roleName: RoleName | string, permissions: PermissionsMap) { logger.info(`Adding new role with name ${roleName}`) - if (!staticMembership) { - permissions[Permissions.MODIFIABLE_MEMBERSHIP] = true - } - const input: AddRoleInput = { roleName, permissions, @@ -32,20 +27,15 @@ class RoleService extends ChainServiceBase { } // TODO: figure out permissions - public createWithMembers( - roleName: RoleName | string, - memberIdsForRole: string[], - permissions: PermissionsMap = {}, - staticMembership: boolean = false - ) { - this.create(roleName, permissions, staticMembership) + public createWithMembers(roleName: RoleName | string, memberIdsForRole: string[], permissions: PermissionsMap) { + this.create(roleName, permissions) for (const memberId of memberIdsForRole) { this.addMember(memberId, roleName) } } public addMember(memberId: string, roleName: RoleName | string) { - logger.info(`Adding member with ID ${memberId} to role ${roleName}`) + logger.info(`Adding member to role ${roleName}`) this.sigChain.team!.addMemberRole(memberId, roleName) } @@ -59,7 +49,7 @@ class RoleService extends ChainServiceBase { } public revokeMembership(memberId: string, roleName: RoleName | string) { - logger.info(`Revoking role ${roleName} for member with ID ${memberId}`) + logger.info(`Revoking membership for role ${roleName}`) this.sigChain.team!.removeMemberRole(memberId, roleName) } diff --git a/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts b/packages/backend/src/nest/auth/services/roles/wrappers/channel.service.spec.ts similarity index 95% rename from packages/backend/src/nest/auth/services/roles/channel.service.spec.ts rename to packages/backend/src/nest/auth/services/roles/wrappers/channel.service.spec.ts index 6647244b9d..a90576fecb 100644 --- a/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts +++ b/packages/backend/src/nest/auth/services/roles/wrappers/channel.service.spec.ts @@ -1,10 +1,10 @@ -import { SigChain } from '../../sigchain' -import { createLogger } from '../../../common/logger' -import { RoleName } from './roles' +import { SigChain } from '../../../sigchain' +import { createLogger } from '../../../../common/logger' +import { RoleName } from '../roles' import { hash, randomBytes } from '@localfirst/crypto' import * as uint8arrays from 'uint8arrays' import { generateProof, InviteResult, MemberContext, redactKeys, Team } from '@localfirst/auth' -import { InviteLockboxMetadata } from '../crypto/types' +import { InviteLockboxMetadata } from '../../crypto/types' const logger = createLogger('auth:services:channels.spec') diff --git a/packages/backend/src/nest/auth/services/roles/channel.service.ts b/packages/backend/src/nest/auth/services/roles/wrappers/channel.service.ts similarity index 83% rename from packages/backend/src/nest/auth/services/roles/channel.service.ts rename to packages/backend/src/nest/auth/services/roles/wrappers/channel.service.ts index 16e1e90f02..85a1577b70 100644 --- a/packages/backend/src/nest/auth/services/roles/channel.service.ts +++ b/packages/backend/src/nest/auth/services/roles/wrappers/channel.service.ts @@ -2,12 +2,12 @@ * Handles role-related chain operations */ -import { SigChain } from '../../sigchain' -import { ChainServiceBase } from '../chainServiceBase' +import { SigChain } from '../../../sigchain' +import { ChainServiceBase } from '../../chainServiceBase' import { Member } from '@localfirst/auth' -import { createLogger } from '../../../common/logger' +import { createLogger } from '../../../../common/logger' import { hash } from '@localfirst/crypto' -import { NotAdminError, RoleName } from './roles' +import { defaultChannelPermissions } from '../permissions' const logger = createLogger('auth:channelService') @@ -19,7 +19,7 @@ class ChannelService extends ChainServiceBase { public create(channelId: string): string { const roleName = this.generateChannelRoleName(channelId) logger.info(`Adding new channel role with name ${roleName}`) - this.sigChain.roles.create(roleName) + this.sigChain.roles.create(roleName, defaultChannelPermissions()) return roleName } @@ -32,7 +32,7 @@ class ChannelService extends ChainServiceBase { } public addMember(memberId: string, channelId: string) { - logger.info(`Adding member with ID ${memberId} to channel ${channelId}`) + logger.info(`Adding member to channel ${channelId}`) const roleName = this.generateChannelRoleName(channelId) this.sigChain.roles!.addMember(memberId, roleName) } @@ -53,7 +53,7 @@ class ChannelService extends ChainServiceBase { } public revokeMembership(memberId: string, channelId: string) { - logger.info(`Revoking membership of channel ${channelId} for member with ID ${memberId}`) + logger.info(`Revoking membership of channel ${channelId}`) const roleName = this.generateChannelRoleName(channelId) this.sigChain.roles.revokeMembership(memberId, roleName) } diff --git a/packages/backend/src/nest/auth/services/roles/channels.service.spec.ts b/packages/backend/src/nest/auth/services/roles/wrappers/dm.service.spec.ts similarity index 75% rename from packages/backend/src/nest/auth/services/roles/channels.service.spec.ts rename to packages/backend/src/nest/auth/services/roles/wrappers/dm.service.spec.ts index 6647244b9d..f5a0bf5b45 100644 --- a/packages/backend/src/nest/auth/services/roles/channels.service.spec.ts +++ b/packages/backend/src/nest/auth/services/roles/wrappers/dm.service.spec.ts @@ -1,14 +1,14 @@ -import { SigChain } from '../../sigchain' -import { createLogger } from '../../../common/logger' -import { RoleName } from './roles' +import { SigChain } from '../../../sigchain' +import { createLogger } from '../../../../common/logger' +import { RoleName } from '../roles' import { hash, randomBytes } from '@localfirst/crypto' import * as uint8arrays from 'uint8arrays' import { generateProof, InviteResult, MemberContext, redactKeys, Team } from '@localfirst/auth' -import { InviteLockboxMetadata } from '../crypto/types' +import { InviteLockboxMetadata } from '../../crypto/types' -const logger = createLogger('auth:services:channels.spec') +const logger = createLogger('auth:services:dm.spec') -describe('channels', () => { +describe('DMs', () => { let adminSigChain: SigChain let secondSigChain: SigChain const adminUsername = 'admin' @@ -18,7 +18,8 @@ describe('channels', () => { let seed: string let salt: string let generatedKeys: InviteLockboxMetadata - const channelId = 'foobar' + let selfDmRoleName: string + let dmRoleName: string it('should initialize a new sigchain and be admin', () => { adminSigChain = SigChain.create(teamName, adminUsername) @@ -26,14 +27,12 @@ describe('channels', () => { expect(adminSigChain.context).toBeDefined() expect(adminSigChain.team!.teamName).toBe(teamName) expect(adminSigChain.user.userName).toBe(adminUsername) - expect(adminSigChain.roles.amIAdmin()).toBe(true) + expect(adminSigChain.roles.amIMemberOfRole(RoleName.ADMIN)).toBe(true) expect(adminSigChain.roles.amIMemberOfRole(RoleName.MEMBER)).toBe(true) }) - it('should create channel and admin should be added as member', () => { - const channel = adminSigChain.channels.create(channelId) - expect(channel).toBeDefined() - expect(channel).toBe(adminSigChain.channels.generateChannelRoleName(channelId)) - expect(adminSigChain.channels.amIMemberOfChannel(channelId)) + it('should create DM with self', () => { + selfDmRoleName = adminSigChain.dms.createWithMembers([adminSigChain.user.userId]) + expect(adminSigChain.dms.memberHasDmRole(adminSigChain.user.userId, selfDmRoleName)).toBe(true) }) it('should create an invite', () => { invite = adminSigChain.invites.createUserInvite() @@ -93,13 +92,14 @@ describe('channels', () => { secondSigChain.roles.addSelf(RoleName.MEMBER, seed, salt) expect(secondSigChain.roles.amIMemberOfRole(RoleName.MEMBER)).toBe(true) }) - it('should fail to self-assign channel role on second user', () => { + it(`should fail to self-assign admin's self-dm role on second user`, () => { const failedSelfAssign = () => - secondSigChain.roles.addSelf(secondSigChain.channels.generateChannelRoleName(channelId), seed, salt) + secondSigChain.roles.addSelf(secondSigChain.channels.generateChannelRoleName(selfDmRoleName), seed, salt) expect(failedSelfAssign).toThrow() }) - it('should add second user to channel', () => { - adminSigChain.channels.addMember(secondSigChain.context.user.userId, channelId) - expect(adminSigChain.channels.memberInChannel(secondSigChain.context.user.userId, channelId)).toBe(true) + it('should create DM with second user', () => { + dmRoleName = adminSigChain.dms.createWithMembers([adminSigChain.user.userId, secondSigChain.user.userId]) + expect(adminSigChain.dms.memberHasDmRole(secondSigChain.user.userId, dmRoleName)).toBe(true) + expect(adminSigChain.dms.memberHasDmRole(adminSigChain.user.userId, dmRoleName)).toBe(true) }) }) diff --git a/packages/backend/src/nest/auth/services/roles/wrappers/dm.service.ts b/packages/backend/src/nest/auth/services/roles/wrappers/dm.service.ts new file mode 100644 index 0000000000..aa366fca58 --- /dev/null +++ b/packages/backend/src/nest/auth/services/roles/wrappers/dm.service.ts @@ -0,0 +1,43 @@ +/** + * Handles role-related for private DMs chain operations + */ + +import { hash } from '@localfirst/crypto' + +import { SigChain } from '../../../sigchain' +import { ChainServiceBase } from '../../chainServiceBase' +import { createLogger } from '../../../../common/logger' +import { defaultDmPermissions } from '../permissions' + +const logger = createLogger('auth:dmService') + +class DMService extends ChainServiceBase { + constructor(sigChain: SigChain) { + super(sigChain) + } + + public createWithMembers(memberIds: string[]): string { + const membersWithMe = [...memberIds, this.sigChain.context.user.userId] + const roleName = this.generateDmRoleName(membersWithMe) + logger.info(`Adding new group DM role`) + this.sigChain.roles.createWithMembers(roleName, memberIds, defaultDmPermissions(memberIds)) + return roleName + } + + public memberHasDmRole(memberId: string, roleName: string): boolean { + logger.trace(`Checking for membership in DM role`) + return this.sigChain.roles.memberHasRole(memberId, roleName) + } + + public delete(memberIds: string[]) { + logger.info(`Removing role for DM`) + const roleName = this.generateDmRoleName(memberIds) + this.sigChain.roles.delete(roleName) + } + + public generateDmRoleName(memberIds: string[]): string { + return hash(this.sigChain.team!.id, `private_dm_${memberIds.sort()}`) + } +} + +export { DMService } diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts index 325d547712..8fb4cff07c 100644 --- a/packages/backend/src/nest/auth/sigchain.service.ts +++ b/packages/backend/src/nest/auth/sigchain.service.ts @@ -2,7 +2,6 @@ import { Inject, Injectable } from '@nestjs/common' import { SigChain } from './sigchain' import { Connection, - Hash, InviteeMemberContext, Keyring, LocalUserContext, @@ -274,10 +273,6 @@ export class SigChainService extends EventEmitter { } } - private _handleEventSigchainUpdated = (chain: SigChain): (() => void) => { - return () => this.handleChainUpdate(chain.team!.id, chain.team!.teamName) - } - private attachSocketListeners(chain: SigChain): void { this.logger.info('Attaching socket listeners') const listener = (): void => { diff --git a/packages/backend/src/nest/auth/sigchain.ts b/packages/backend/src/nest/auth/sigchain.ts index 2c8dce99c8..77a7e91b74 100644 --- a/packages/backend/src/nest/auth/sigchain.ts +++ b/packages/backend/src/nest/auth/sigchain.ts @@ -13,8 +13,10 @@ import { RoleName, SELF_ASSIGN_ROLES } from './services/roles/roles' import { createLogger } from '../common/logger' import EventEmitter from 'events' import { LockboxService } from './services/crypto/lockbox.service' -import { ChannelService } from './services/roles/channel.service' +import { ChannelService } from './services/roles/wrappers/channel.service' import { LFAEvents, SigchainEvents } from './types' +import { defaultGenericPermissions } from './services/roles/permissions' +import { DMService } from './services/roles/wrappers/dm.service' const logger = createLogger('auth:sigchain') const lfaLogger = createLogger('localfirst') @@ -25,6 +27,7 @@ class SigChain extends EventEmitter { private _devices: DeviceService | null = null private _roles: RoleService | null = null private _channels: ChannelService | null = null + private _dms: DMService | null = null private _invites: InviteService | null = null private _crypto: CryptoService | null = null private _server: ServerService | null = null @@ -106,7 +109,7 @@ class SigChain extends EventEmitter { const sigChain = new SigChain(adminContext) // Initialize member role (your own user is added by default to the role) - sigChain.roles.create(RoleName.MEMBER) + sigChain.roles.create(RoleName.MEMBER, defaultGenericPermissions()) return sigChain } @@ -168,6 +171,7 @@ class SigChain extends EventEmitter { this._devices = new DeviceService(this) this._roles = new RoleService(this) this._channels = new ChannelService(this) + this._dms = new DMService(this) this._invites = new InviteService(this) this._crypto = new CryptoService(this) this._server = new ServerService(this) @@ -193,6 +197,10 @@ class SigChain extends EventEmitter { return this._channels! } + get dms(): DMService { + return this._dms! + } + get devices(): DeviceService { return this._devices! } diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts index da9e4866a7..e5d251b57f 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts @@ -79,6 +79,10 @@ describe('ConnectionsManagerService', () => { logger.debug('MOCK: handling chain update') }) + handleChainUpdateSpy = jest.spyOn(sigChainService as any, 'handleChainUpdate').mockImplementation(() => { + logger.debug('MOCK: handling chain update') + }) + // initialize sigchain on local db await sigChainService.createChain(community.name!, 'john', false) await sigChainService.saveChain(community.name!) diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts index 614134c2bd..1816980e8d 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts @@ -792,6 +792,11 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI this.communityState = ServiceState.LAUNCHING this.logger.info(`Community state is now ${this.communityState}`) + // Clear the data about which peers are connected from the state-manager so it accurately reflects real connection + // statuses + this.logger.debug('Clearing connected peers from frontend') + this.serverIoProvider.io.emit(SocketEvents.PEER_CLEAR, { communityId: community.id }) + if (community.name) { try { this.logger.info('Loading sigchain for community', community.name) @@ -953,6 +958,12 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI this.serverIoProvider.io.emit(SocketEvents.CONNECTION_PROCESS_INFO, ConnectionProcessInfo.CONNECTING_TO_COMMUNITY) } + /** + * Add team ID to community in LocalDB and state-manager storage + * + * @param community Community that is being modified + * @param chainOrTeamId Sigchain associated with the community or its team ID + */ private async _updateTeamIdOnStoredCommunity(community: Community, chain: SigChain): Promise private async _updateTeamIdOnStoredCommunity(community: Community, teamId: string): Promise private async _updateTeamIdOnStoredCommunity(community: Community, chainOrTeamId: SigChain | string): Promise { diff --git a/packages/backend/src/nest/storage/channels/channel.store.ts b/packages/backend/src/nest/storage/channels/channel.store.ts index 8e5b91a592..4a09b6bc86 100644 --- a/packages/backend/src/nest/storage/channels/channel.store.ts +++ b/packages/backend/src/nest/storage/channels/channel.store.ts @@ -100,6 +100,7 @@ export class ChannelStore extends EventStoreBase { this.logger.info('Adding message to database') try { - const encryptedMessage = await this.messagesService.onSend(message) + let encryptedMessage: EncryptedMessage + if (this.channelData.public) { + encryptedMessage = await (this.messagesService as PublicChannelMessagesService).onSend(message) + } else { + const roleName = this.channelData.roleName + if (roleName == null) { + throw new Error('Channel is private but lacks role name in metadata') + } + encryptedMessage = await (this.messagesService as PrivateChannelMessagesService).onSend(message, roleName) + } return await this.getStore().add(encryptedMessage) } catch (e) { throw new CompoundError(`Could not append message (entry not allowed to write to the log)`, e) diff --git a/packages/backend/src/nest/storage/channels/channels.service.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.spec.ts index d531eeb798..18872c6101 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.spec.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.spec.ts @@ -147,6 +147,9 @@ describe('ChannelsService', () => { await channelsService.subscribeToChannel(channel1) await channelsService.subscribeToChannel(channel2) + expect(await channelsService.getChannel(channel1.id)).toBeDefined() + expect(await channelsService.getChannel(channel2.id)).toBeDefined() + // send a message to channel1 const message1 = await factory.build('ChannelMessage', { channelId: channel1.id, diff --git a/packages/backend/src/nest/storage/channels/channels.service.ts b/packages/backend/src/nest/storage/channels/channels.service.ts index 2783c879b4..81471ee837 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.ts @@ -20,6 +20,7 @@ import { AddMembersChannelPayload, AddMembersChannelResponse, AddMembersChannelStatus, + ChannelType, DownloadStatus, RemoveDownloadStatus, CHANNEL_METADATA_STORE_NAME, @@ -139,6 +140,7 @@ export class ChannelsService extends EventEmitter { this.logger.timeEnd('Initializing channel databases') this.logger.info('Initialized databases') + this.initialized = true } /** @@ -207,10 +209,10 @@ export class ChannelsService extends EventEmitter { type: EncryptionScopeType.ROLE, name: RoleName.MEMBER, } - if (!(payload.public ?? true)) { + if (!payload.public) { scope = { type: EncryptionScopeType.ROLE, - name: chain.channels.generateChannelRoleName(payload.id), + name: payload.roleName, } } const encryptedPayload = chain.crypto.encryptAndSign(payload, scope) @@ -463,12 +465,22 @@ export class ChannelsService extends EventEmitter { owner: this.sigchainService.getActiveChain().user.userId, timestamp: DateTime.utc().valueOf(), public: payload.public ?? true, + type: payload.type, + memberIds: payload.memberIds, teamId: payload.teamId, } let roleName: string | undefined = undefined - if (!(channelData.public ?? true)) { + // Create a private channel role if the type === CHANNEL and is private + if (!channelData.public && (channelData.type == null || channelData.type === ChannelType.CHANNEL)) { roleName = this.sigchainService.getActiveChain().channels.create(channelData.id) channelData.roleName = roleName + // Create a DM role and add the specified members if the type === DM + } else if (channelData.type === ChannelType.DM) { + if (channelData.memberIds == null) { + throw new Error('Attempted to create a DM with no members') + } + roleName = this.sigchainService.getActiveChain().dms.createWithMembers(channelData.memberIds) + channelData.roleName = roleName } const store = await this.createChannel(channelData) if (!store) { diff --git a/packages/backend/src/nest/storage/channels/messages/base-messages.service.ts b/packages/backend/src/nest/storage/channels/messages/base-messages.service.ts index fbe510e5cb..88eadd3b2d 100644 --- a/packages/backend/src/nest/storage/channels/messages/base-messages.service.ts +++ b/packages/backend/src/nest/storage/channels/messages/base-messages.service.ts @@ -23,7 +23,7 @@ export class BaseMessagesService extends EventEmitter { * @param message Message to send * @returns Processed message */ - public async onSend(message: ChannelMessage): Promise { + public async onSend(message: ChannelMessage, roleName?: string): Promise { throw new NotImplementedException('onSend is not implemented') } diff --git a/packages/backend/src/nest/storage/channels/messages/orbitdb/BaseMessageAccessController.ts b/packages/backend/src/nest/storage/channels/messages/orbitdb/BaseMessageAccessController.ts index 439e30c499..0e85e5706f 100644 --- a/packages/backend/src/nest/storage/channels/messages/orbitdb/BaseMessageAccessController.ts +++ b/packages/backend/src/nest/storage/channels/messages/orbitdb/BaseMessageAccessController.ts @@ -56,6 +56,7 @@ const getAccessControllerManifestHash = (address: string): string => { export interface AccessControllerConfig { write: string[] sigchainService: SigChainService + roleName?: string } export class BaseMessagesAccessController { diff --git a/packages/backend/src/nest/storage/channels/messages/orbitdb/PrivateMessagesAccessController.ts b/packages/backend/src/nest/storage/channels/messages/orbitdb/PrivateMessagesAccessController.ts index 6815b45d9a..f08bb55e52 100644 --- a/packages/backend/src/nest/storage/channels/messages/orbitdb/PrivateMessagesAccessController.ts +++ b/packages/backend/src/nest/storage/channels/messages/orbitdb/PrivateMessagesAccessController.ts @@ -61,18 +61,21 @@ export class PrivateMessagesAccessController extends BaseMessagesAccessControlle return false } - const sigchain = config.sigchainService.getChain({ teamId: config.teamId }, false) - if (sigchain == null) { - this.logger.warn(`User is not a member of this team or team hasn't been initialized, sigchain was nullish`) + const sigchain = config.sigchainService.getChain({ teamId: entry.payload.value!.teamId }) + if (config.roleName == null) { + this.logger.error( + 'Channel is private but no rolename was provided', + entry.payload.value!.teamId, + entry.payload.value!.channelId + ) return false } - - if (!sigchain.channels.memberInChannel(id, config.channelId)) { + if (!sigchain.roles.memberHasRole(id, config.roleName)) { this.logger.warn( `User is not a member of the channel, skipping log append`, id, - config.teamId, - config.channelId + entry.payload.value!.teamId, + entry.payload.value!.channelId ) return false } diff --git a/packages/backend/src/nest/storage/channels/messages/private-channel-messages.service.spec.ts b/packages/backend/src/nest/storage/channels/messages/private-channel-messages.service.spec.ts index 286495535b..f8c1ae1b4a 100644 --- a/packages/backend/src/nest/storage/channels/messages/private-channel-messages.service.spec.ts +++ b/packages/backend/src/nest/storage/channels/messages/private-channel-messages.service.spec.ts @@ -14,6 +14,8 @@ import { StorageModule } from '../../storage.module' import { EncryptedMessage } from './messages.types' import { isEncryptedMessage } from '../../../validation/validators' import { PrivateChannelMessagesService } from './private-channel-messages.service' +import { role, Role } from '@localfirst/auth' +import { defaultChannelPermissions } from '../../../auth/services/roles/permissions' const logger = createLogger('privateChannelMessagesService:test') @@ -21,6 +23,7 @@ describe('PrivateChannelMessagesService', () => { let module: TestingModule let messagesService: PrivateChannelMessagesService let sigChainService: SigChainService + const roleName = 'foobar' let factory: FactoryGirl let message: ChannelMessage @@ -46,6 +49,7 @@ describe('PrivateChannelMessagesService', () => { handleChainUpdateSpy = jest.spyOn(sigChainService as any, 'handleChainUpdate').mockImplementation(() => { logger.debug('MOCK: handling chain update') }) + sigChainService.roles.create(roleName, defaultChannelPermissions()) }) afterEach(async () => { @@ -55,7 +59,7 @@ describe('PrivateChannelMessagesService', () => { describe('onSend', () => { it('encrypts message correctly', async () => { - const encryptedMessage = await messagesService.onSend(message) + const encryptedMessage = await messagesService.onSend(message, roleName) expect(isEncryptedMessage(encryptedMessage)).toBeTruthy() expect(encryptedMessage).toEqual( expect.objectContaining({ @@ -68,7 +72,7 @@ describe('PrivateChannelMessagesService', () => { scope: { generation: 0, type: EncryptionScopeType.ROLE, - name: sigChainService.activeChain.channels.generateChannelRoleName(message.channelId), + name: roleName, }, }), encSignature: expect.objectContaining({ @@ -87,7 +91,7 @@ describe('PrivateChannelMessagesService', () => { describe('onConsume', () => { it('decrypts an encrypted message correctly', async () => { - const encryptedMessage = await messagesService.onSend(message) + const encryptedMessage = await messagesService.onSend(message, roleName) expect(await messagesService.onConsume(encryptedMessage)).toEqual({ ...message, verified: true, @@ -96,14 +100,14 @@ describe('PrivateChannelMessagesService', () => { }) it('returns undefined when the signature is invalid', async () => { - const encryptedMessage = await messagesService.onSend(message) + const encryptedMessage = await messagesService.onSend(message, roleName) const invalidEncryptedMessage: EncryptedMessage = { ...encryptedMessage, encSignature: { ...encryptedMessage.encSignature, author: { generation: 1, - name: 'foobar', + name: 'notarole', type: '', }, }, diff --git a/packages/backend/src/nest/storage/channels/messages/private-channel-messages.service.ts b/packages/backend/src/nest/storage/channels/messages/private-channel-messages.service.ts index 91e82683b5..9117c28ff0 100644 --- a/packages/backend/src/nest/storage/channels/messages/private-channel-messages.service.ts +++ b/packages/backend/src/nest/storage/channels/messages/private-channel-messages.service.ts @@ -24,9 +24,8 @@ export class PrivateChannelMessagesService extends BaseMessagesService { * @param message Message to send * @returns Processed message */ - public async onSend(message: ChannelMessage): Promise { - this.logger.debug('Sending private channel message') - return this._encryptPrivateChannelMessage(message) + public async onSend(message: ChannelMessage, roleName: string): Promise { + return this._encryptPrivateChannelMessage(message, roleName) } /** @@ -36,7 +35,6 @@ export class PrivateChannelMessagesService extends BaseMessagesService { * @returns Processed message if decryptable, undefined if undecryptable and false if intentionally skip decryption */ public async onConsume(message: EncryptedMessage): Promise { - this.logger.debug('Received private channel message') const chain = this.sigChainService.getChain({ teamId: message.teamId }, false) if (chain == null) { this.logger.warn( @@ -44,7 +42,11 @@ export class PrivateChannelMessagesService extends BaseMessagesService { ) return false } - if (!chain.channels.amIMemberOfChannel(message.channelId)) { + if ( + message.contents != null && + message.contents.scope.name != null && + !chain.roles.amIMemberOfRole(message.contents.scope.name) + ) { this.logger.warn(`Not a member of channel ${message.channelId} on team ${message.teamId}`) return false } @@ -61,7 +63,7 @@ export class PrivateChannelMessagesService extends BaseMessagesService { } } - private _encryptPrivateChannelMessage(rawMessage: ChannelMessage): EncryptedMessage { + private _encryptPrivateChannelMessage(rawMessage: ChannelMessage, roleName: string): EncryptedMessage { try { const chain = this.sigChainService.getActiveChain() const encryptable: EncryptableMessageComponents = { @@ -72,7 +74,6 @@ export class PrivateChannelMessagesService extends BaseMessagesService { message: rawMessage.message, media: rawMessage.media, } - const roleName = chain.channels.generateChannelRoleName(rawMessage.channelId) const encryptedMessage = chain.crypto.encryptAndSign(encryptable, { type: EncryptionScopeType.ROLE, name: roleName, diff --git a/packages/backend/src/nest/storage/channels/messages/public-channel-messages.service.ts b/packages/backend/src/nest/storage/channels/messages/public-channel-messages.service.ts index 28965f4a36..6af285333f 100644 --- a/packages/backend/src/nest/storage/channels/messages/public-channel-messages.service.ts +++ b/packages/backend/src/nest/storage/channels/messages/public-channel-messages.service.ts @@ -95,7 +95,7 @@ export class PublicChannelMessagesService extends BaseMessagesService { const decryptedMessage = chain.crypto.decryptAndVerify( encryptedMessage.contents, encryptedMessage.encSignature, - false + true ) return { ...decryptedMessage.contents, diff --git a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts index 6d24f3a5b6..aff79c3c11 100644 --- a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts +++ b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts @@ -19,7 +19,6 @@ import { Entry, DatabaseType, LogType, - IdentitiesType, } from '@orbitdb/core' import { HeliaLibp2p } from 'helia' import { OrbitDbStorage } from '../../types' diff --git a/packages/backend/src/nest/storage/storage.service.spec.ts b/packages/backend/src/nest/storage/storage.service.spec.ts index 99fdbdfbfa..a99cb4962a 100644 --- a/packages/backend/src/nest/storage/storage.service.spec.ts +++ b/packages/backend/src/nest/storage/storage.service.spec.ts @@ -2,7 +2,7 @@ import { jest } from '@jest/globals' import { Test, TestingModule } from '@nestjs/testing' import { prepareStore, getReduxStoreFactory, publicChannels, Store } from '@quiet/state-manager' -import { Community, Identity, NetworkStats, PublicChannel, UserProfile } from '@quiet/types' +import { ChannelType, Community, Identity, NetworkStats, PublicChannel, UserProfile } from '@quiet/types' import path from 'path' import { TestModule } from '../common/test.module' @@ -68,6 +68,7 @@ describe('StorageService', () => { timestamp: channel.timestamp, id: channel.id, public: true, + type: ChannelType.CHANNEL, teamId: community.teamId!, } diff --git a/packages/backend/src/nest/storage/storage.service.ts b/packages/backend/src/nest/storage/storage.service.ts index 71890ad7cc..95ca701310 100644 --- a/packages/backend/src/nest/storage/storage.service.ts +++ b/packages/backend/src/nest/storage/storage.service.ts @@ -1,6 +1,5 @@ import { Inject, Injectable } from '@nestjs/common' import { EventEmitter } from 'events' -import { type PeerId } from '@libp2p/interface' import { ConnectionProcessInfo, SocketEvents, diff --git a/packages/backend/src/nest/storage/userProfile/userProfile.store.ts b/packages/backend/src/nest/storage/userProfile/userProfile.store.ts index dfebf7bada..b8f384aa26 100644 --- a/packages/backend/src/nest/storage/userProfile/userProfile.store.ts +++ b/packages/backend/src/nest/storage/userProfile/userProfile.store.ts @@ -187,7 +187,7 @@ export class UserProfileStore extends EncryptedKeyValueIndexedValidatedStoreBase this.nicknameMaps.set(userProfile.userId, userProfile.nickname) return encEntry } catch (err) { - logger.error('Failed to add user profile', userProfile.userId, err) + logger.error('Failed to add user profile - deferring', userProfile.userId, err) this.deferredProfiles.push(userProfile) throw err } diff --git a/packages/backend/src/nest/validation/validators.spec.ts b/packages/backend/src/nest/validation/validators.spec.ts index 5ed0c5f86a..1cc09bdb2b 100644 --- a/packages/backend/src/nest/validation/validators.spec.ts +++ b/packages/backend/src/nest/validation/validators.spec.ts @@ -1,4 +1,4 @@ -import { ChannelMessage, PublicChannel } from '@quiet/types' +import { ChannelMessage, ChannelType, PublicChannel } from '@quiet/types' import { isMessage, isDirectMessage, isChannel, isEncryptedMessage } from './validators' import { EncryptedMessage } from '../storage/channels/messages/messages.types' @@ -185,6 +185,7 @@ describe('Validators - Channels', () => { timestamp: 12341234, id: 'sadfdasfsadfsdfsnfsdjfdsfsdfjsdf', public: true, + type: ChannelType.CHANNEL, teamId: 'foobar', } expect(isChannel(channel)).toBeTruthy() diff --git a/packages/backend/src/nest/validation/validators.ts b/packages/backend/src/nest/validation/validators.ts index ee33d801c5..04f536e2e9 100644 --- a/packages/backend/src/nest/validation/validators.ts +++ b/packages/backend/src/nest/validation/validators.ts @@ -1,6 +1,6 @@ import _ from 'validator' import joi from 'joi' -import { ChannelMessage, PublicChannel } from '@quiet/types' +import { ChannelMessage, ChannelType, PublicChannel } from '@quiet/types' import { ServerStoredCommunityMetadata } from '../storageServiceClient/storageServiceClient.types' import { isPSKcodeValid } from '@quiet/common' import { createLogger } from '../common/logger' @@ -49,7 +49,7 @@ const EncryptionSignatureSchema = joi.object({ const messageSchema = joi.object({ id: joi.string().required(), - type: joi.number().required().positive().integer(), + type: joi.number().required().integer(), message: joi.string().required().allow(''), createdAt: joi.number().required(), channelId: joi.string().required(), @@ -99,6 +99,13 @@ const channelSchema = joi.object({ public: joi.boolean().optional(), roleName: joi.string().optional(), disabled: joi.boolean().optional(), + type: joi + .string() + .optional() + .custom((value, _helpers) => { + return value === ChannelType.CHANNEL || value === ChannelType.DM + }), + memberIds: joi.array().items(joi.string()).optional(), teamId: joi.string().optional(), }) @@ -141,6 +148,7 @@ export const isEncryptedMessage = (msg: EncryptedMessage): boolean => { export const isChannel = (channel: PublicChannel): boolean => { const value = channelSchema.validate(channel) + logger.error('Channel validation error', value.error) return !value.error } diff --git a/packages/common/package-lock.json b/packages/common/package-lock.json index 9e67f0a373..9b6aaeac5a 100644 --- a/packages/common/package-lock.json +++ b/packages/common/package-lock.json @@ -20,7 +20,7 @@ }, "devDependencies": { "@types/jest": "^26.0.23", - "@types/node": "^17.0.21", + "@types/node": "20.19.43", "@types/validator": "^13.11.5", "jest": "^26.6.3", "lint-staged": "^15.2.2", @@ -1203,10 +1203,21 @@ } }, "node_modules/@types/node": { - "version": "17.0.45", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", - "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", - "dev": true + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" }, "node_modules/@types/normalize-package-data": { "version": "2.4.1", @@ -7902,10 +7913,21 @@ } }, "@types/node": { - "version": "17.0.45", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", - "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", - "dev": true + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "requires": { + "undici-types": "~6.21.0" + }, + "dependencies": { + "undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + } + } }, "@types/normalize-package-data": { "version": "2.4.1", diff --git a/packages/common/package.json b/packages/common/package.json index 60d58810b1..c126f5910c 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -23,7 +23,7 @@ "@quiet/eslint-config": "^2.0.2-alpha.0", "@quiet/node-common": "^4.0.3", "@types/jest": "^26.0.23", - "@types/node": "^17.0.21", + "@types/node": "20.19.43", "@types/validator": "^13.11.5", "jest": "^26.6.3", "lint-staged": "^15.2.2", diff --git a/packages/common/src/channelAddress.ts b/packages/common/src/channelAddress.ts index cc67c5a893..24411edd3b 100644 --- a/packages/common/src/channelAddress.ts +++ b/packages/common/src/channelAddress.ts @@ -1,7 +1,15 @@ +import { UserProfile } from '@quiet/types' import crypto from 'crypto' export const generateChannelId = (channelName: string) => `${channelName}_${crypto.randomBytes(16).toString('hex')}` +export const generateDmChannelId = (memberIds: string[]) => + crypto + .createHash('sha256') + .update([...new Set(memberIds)].sort().toString()) + .digest() + .toString('base64url') + export const getChannelNameFromChannelId = (channelId: string) => { const index = channelId.indexOf('_') if (index === -1) { @@ -10,3 +18,20 @@ export const getChannelNameFromChannelId = (channelId: string) => { return channelId.substring(0, index) } } + +export const generateDmChannelName = ( + memberIds: string[] | undefined, + userProfiles: Record, + me: UserProfile | undefined +): string => { + if (memberIds == null) return 'Empty DM Channel Name' + if (memberIds.length === 1) { + return me?.nickname ?? 'Me' + } + + return memberIds + .filter(id => id !== me?.userId) + .map(id => userProfiles[id]?.nickname) + .sort() + .join(', ') +} diff --git a/packages/common/src/messages.test.ts b/packages/common/src/messages.test.ts index 2c25ee43b0..6456236e33 100644 --- a/packages/common/src/messages.test.ts +++ b/packages/common/src/messages.test.ts @@ -1,4 +1,4 @@ -import { PublicChannelStorage } from '@quiet/types' +import { ChannelType, PublicChannelStorage } from '@quiet/types' import { generateChannelId } from './channelAddress' import { createdChannelMessage, userJoinedMessage, verifyUserInfoMessage } from './messages' @@ -14,6 +14,8 @@ describe('messages helper', () => { id: generateChannelId('general'), messages: { ids: [], entities: {} }, public: true, + type: ChannelType.CHANNEL, + displayedName: 'general', teamId: 'foobar', } @@ -25,6 +27,8 @@ describe('messages helper', () => { id: generateChannelId('sport'), messages: { ids: [], entities: {} }, public: true, + type: ChannelType.CHANNEL, + displayedName: 'sport', teamId: 'foobar', } diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index 7164c6d90c..99691d7cfb 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -16,6 +16,7 @@ "electron-localshortcut": "^3.2.1", "electron-store": "^8.2.0", "electron-updater": "^5.0.1", + "fuse.js": "^7.3.0", "get-port": "^5.1.1", "keymirror": "0.1.1", "libsodium-wrappers-sumo": "0.7.15", @@ -56,7 +57,7 @@ "@types/libsodium-wrappers-sumo": "^0.7.8", "@types/lodash": "^4.14.169", "@types/luxon": "^3.4.2", - "@types/node": "^20", + "@types/node": "20.19.43", "@types/ps-node": "^0.1.3", "@types/ramda": "^0.27.31", "@types/react": "^18.0.25", @@ -19085,9 +19086,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.19.35", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", - "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -29363,6 +29364,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.4.1.tgz", + "integrity": "sha512-AY7lKAXK71hi3WgUvDy6oZL67UEHOOtvCAwVdOXHyJd6ZzftBy7QqxuXt4HxmmAhYjmp/YCuOELZtIvAdlZ+fw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/krisk" + } + }, "node_modules/gauge": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", @@ -61401,9 +61415,9 @@ "dev": true }, "@types/node": { - "version": "20.19.35", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", - "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "requires": { "undici-types": "~6.21.0" }, @@ -69427,6 +69441,11 @@ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true }, + "fuse.js": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.4.1.tgz", + "integrity": "sha512-AY7lKAXK71hi3WgUvDy6oZL67UEHOOtvCAwVdOXHyJd6ZzftBy7QqxuXt4HxmmAhYjmp/YCuOELZtIvAdlZ+fw==" + }, "gauge": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index d3649b7e15..f24f40477a 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -139,6 +139,7 @@ "electron-localshortcut": "^3.2.1", "electron-store": "^8.2.0", "electron-updater": "^5.0.1", + "fuse.js": "^7.3.0", "get-port": "^5.1.1", "keymirror": "0.1.1", "libsodium-wrappers-sumo": "0.7.15", @@ -182,7 +183,7 @@ "@types/libsodium-wrappers-sumo": "^0.7.8", "@types/lodash": "^4.14.169", "@types/luxon": "^3.4.2", - "@types/node": "^20", + "@types/node": "20.19.43", "@types/ps-node": "^0.1.3", "@types/ramda": "^0.27.31", "@types/react": "^18.0.25", diff --git a/packages/desktop/src/renderer/components/Channel/Channel.stories.tsx b/packages/desktop/src/renderer/components/Channel/Channel.stories.tsx index acf326a055..88302b2749 100644 --- a/packages/desktop/src/renderer/components/Channel/Channel.stories.tsx +++ b/packages/desktop/src/renderer/components/Channel/Channel.stories.tsx @@ -9,7 +9,7 @@ import { DndProvider } from 'react-dnd' import { HTML5Backend } from 'react-dnd-html5-backend' import ChannelComponent, { ChannelComponentProps } from './ChannelComponent' import { UploadFilesPreviewsProps } from './File/FileAttachmentPreview' -import { DownloadState, DisplayableMessage } from '@quiet/types' +import { DownloadState, DisplayableMessage, ChannelType } from '@quiet/types' import { HandleOpenModalType } from '../widgets/userLabel/UserLabel.types' import { DEFAULT_AUTODOWNLOAD_SIZE_LIMIT } from '@quiet/state-manager' @@ -831,6 +831,8 @@ export const SendingMessagesWithScroll: ComponentStory user={validUser} channelId='general' channelName='general' + channelType={ChannelType.CHANNEL} + members={[]} isPublic={true} newestMessage={ args.newestMessage || { diff --git a/packages/desktop/src/renderer/components/Channel/Channel.tsx b/packages/desktop/src/renderer/components/Channel/Channel.tsx index d07a6b0b82..351a93388c 100644 --- a/packages/desktop/src/renderer/components/Channel/Channel.tsx +++ b/packages/desktop/src/renderer/components/Channel/Channel.tsx @@ -1,10 +1,19 @@ -import React, { useCallback, useEffect } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { shell, ipcRenderer, webUtils } from 'electron' import { useDispatch, useSelector } from 'react-redux' import { users, messages, publicChannels, communities, files, network, settings } from '@quiet/state-manager' -import { FileMetadata, CancelDownload, FileContent, FilePreviewData } from '@quiet/types' +import { + FileMetadata, + CancelDownload, + FileContent, + FilePreviewData, + ChannelType, + UserProfile, + CreateChannelPayload, + EMPTY_CHANNEL_ID, +} from '@quiet/types' import ChannelComponent, { ChannelComponentProps } from './ChannelComponent' @@ -12,20 +21,30 @@ import { useModal } from '../../containers/hooks' import { ModalName } from '../../sagas/modals/modals.types' import { UploadFilesPreviewsProps } from './File/FileAttachmentPreview' -import { getFilesData } from '@quiet/common' +import { generateDmChannelId, getFilesData, isDefined } from '@quiet/common' import { FileActionsProps } from './File/FileComponent/FileComponent' import { useContextMenu } from '../../../hooks/useContextMenu' import { MenuName } from '../../../const/MenuNames.enum' +import { createLogger } from '../../logger' +import _ from 'lodash' +import NewDirectMessageComponent, { NewDirectMessageComponentProps } from './NewDirectMessage.component' + +const logger = createLogger('Channel') const Channel = () => { const dispatch = useDispatch() - const user = useSelector(users.selectors.myUserProfile) + const myUserProfile = useSelector(users.selectors.myUserProfile) + const userProfiles = useSelector(users.selectors.userProfiles) const currentChannelId = useSelector(publicChannels.selectors.currentChannelId) const currentChannelName = useSelector(publicChannels.selectors.currentChannelName) const currentChannel = useSelector(publicChannels.selectors.currentChannel) + const generalChannel = useSelector(publicChannels.selectors.generalChannel) + const prevChannelId = useSelector(publicChannels.selectors.prevChannelId) + const channels = useSelector(publicChannels.selectors.publicChannels) + const isNewMessageOpen = useSelector(publicChannels.selectors.isNewMessageOpen) const currentChannelMessagesCount = useSelector(publicChannels.selectors.currentChannelMessagesCount) @@ -55,15 +74,36 @@ const Channel = () => { const { handleOpen: unregisteredUsernameModalHandleOpen } = useModal(ModalName.unregisteredUsernameModal) const [attachingFiles, setAttachingFiles] = React.useState({}) + const [channelName, setChannelName] = useState() + const [members, setMembers] = useState([]) + const [me, setMe] = useState(myUserProfile) const filesRef = React.useRef({}) const contextMenu = useContextMenu(MenuName.Channel) + useEffect(() => { + logger.debug('Current channel updated') + if (currentChannel == null) return + setChannelName(currentChannelName) + }, [currentChannel, currentChannelName, currentChannelId]) + + useEffect(() => { + if (currentChannel == null || currentChannel.memberIds == null) { + setMembers(Object.values(userProfiles)) + return + } + setMembers(currentChannel.memberIds.map(memberId => userProfiles[memberId]).filter(isDefined) ?? []) + }, [userProfiles, currentChannel, currentChannelId]) + const onInputChange = useCallback((_value: string) => { // TODO https://github.com/TryQuiet/ZbayLite/issues/442 }, []) + useEffect(() => { + setMe(myUserProfile) + }, [myUserProfile]) + const onInputEnter = useCallback( (message: string) => { // Send message out of input value @@ -80,7 +120,7 @@ const Channel = () => { [dispatch] ) - React.useEffect(() => { + useEffect(() => { filesRef.current = attachingFiles }, [attachingFiles]) @@ -190,12 +230,104 @@ const Channel = () => { dispatch(messages.actions.resetCurrentPublicChannelCache()) }, [currentChannelId]) - if (!currentChannelId) return null + const closeNewMessageWindow = () => { + dispatch(publicChannels.actions.setNewMessageOpen({ isOpen: false })) + dispatch(publicChannels.actions.setCurrentChannel({ channelId: prevChannelId })) + } + + const generateDmChannelIdFromMemberIds = ( + memberIds: string[], + me: UserProfile + ): { uniqueMemberIds: string[]; channelId: string } => { + const uniqueMemberIds = _.uniq([...memberIds, me.userId]).sort() + return { + channelId: generateDmChannelId(uniqueMemberIds), + uniqueMemberIds, + } + } + + const handleNewMessageInputChange = (members: UserProfile[]) => { + logger.debug('New message - Handling member ID change') + const memberIds = members.map(member => member.userId) + if (me == null || members.length === 0) { + dispatch(publicChannels.actions.setCurrentChannel({ channelId: EMPTY_CHANNEL_ID })) + return + } + const { channelId: dmChannelId } = generateDmChannelIdFromMemberIds(memberIds, me) + if (channels.find(channel => channel.id === dmChannelId)) { + logger.debug('New message - Found existing DM channel') + dispatch(publicChannels.actions.setCurrentChannel({ channelId: dmChannelId })) + } else { + dispatch(publicChannels.actions.setCurrentChannel({ channelId: EMPTY_CHANNEL_ID })) + } + } + + const setOrCreateDmChannel = useCallback( + (memberIds: string[]) => { + if (me == null || memberIds.length === 0) { + logger.debug('Setting channel ID to empty - missing own user profile or member IDs was empty') + dispatch(publicChannels.actions.setCurrentChannel({ channelId: EMPTY_CHANNEL_ID })) + return + } + + const { channelId, uniqueMemberIds } = generateDmChannelIdFromMemberIds(memberIds, me) + const dmChannel = channels.find(channel => channel.id === channelId) + if (dmChannel != null) { + logger.debug('Found existing DM channel') + dispatch(publicChannels.actions.setNewMessageOpen({ isOpen: false })) + dispatch( + publicChannels.actions.setCurrentChannel({ + channelId, + }) + ) + } else { + logger.debug('Creating new DM channel') + if (community == null || community.teamId == null) { + logger.error(`Community or team ID was undefined, can't create DM channel`) + return + } + const payload: CreateChannelPayload = { + id: channelId, + name: channelId, + type: ChannelType.DM, + description: 'foo', + public: false, + memberIds: uniqueMemberIds, + teamId: community.teamId, + } + logger.debug('Running create channel action') + dispatch(publicChannels.actions.createChannel(payload)) + logger.debug('Closing new message view and updating current channel', channelId) + dispatch(publicChannels.actions.setNewMessageOpen({ isOpen: false })) + dispatch( + publicChannels.actions.setCurrentChannel({ + channelId, + }) + ) + } + }, + [dispatch, me, channels] + ) + + if (!currentChannelId) { + logger.warn('Current channel ID is nullish') + return null + } + if (!channelName) { + logger.warn('Channel name is nullish') + return null + } + if (!isNewMessageOpen && currentChannelId === EMPTY_CHANNEL_ID) { + logger.warn('New message view is closed but current channel ID is considered empty') + return null + } const channelComponentProps: ChannelComponentProps = { - user: user, + user: me, channelId: currentChannelId, - channelName: currentChannelName, + channelType: currentChannel?.type ?? ChannelType.CHANNEL, + channelName, + members, isPublic: currentChannel?.public ?? true, messages: { count: currentChannelMessagesCount, @@ -220,6 +352,35 @@ const Channel = () => { duplicatedUsernameModalHandleOpen, } + const newDirectMessageComponentProps: NewDirectMessageComponentProps = { + user: me, + userProfiles, + channelId: currentChannelId, + channelName, + messages: { + count: currentChannelMessagesCount, + groups: currentChannelDisplayableMessages, + }, + newestMessage: newestCurrentChannelMessage, + pendingMessages: pendingMessages, + downloadStatuses: downloadStatusesMapping, + maxAutodownloadSizeBytes, + lazyLoading: lazyLoading, + onInputChange: onInputChange, + onInputEnter: onInputEnter, + openUrl: openUrl, + handleFileDrop: handleFileDrop, + openFilesDialog: openFilesDialog, + handleClipboardFiles: handleClipboardFiles, + uploadedFileModal: uploadedFileModal, + pendingGeneralChannelRecreation: pendingGeneralChannelRecreation, + unregisteredUsernameModalHandleOpen, + duplicatedUsernameModalHandleOpen, + handleInputChange: handleNewMessageInputChange, + handleClose: closeNewMessageWindow, + setOrCreateDmChannel, + } + const uploadFilesPreviewProps: UploadFilesPreviewsProps = { filesData: attachingFiles, removeFile: removeFilePreview, @@ -233,13 +394,22 @@ const Channel = () => { return ( <> - {currentChannelId && ( - + ) : ( + currentChannelId && ( + + ) )} ) diff --git a/packages/desktop/src/renderer/components/Channel/ChannelComponent.tsx b/packages/desktop/src/renderer/components/Channel/ChannelComponent.tsx index 37d6f598ce..39c2ef3027 100644 --- a/packages/desktop/src/renderer/components/Channel/ChannelComponent.tsx +++ b/packages/desktop/src/renderer/components/Channel/ChannelComponent.tsx @@ -12,7 +12,14 @@ import ChannelInputComponent from '../widgets/channels/ChannelInput' import { INPUT_STATE } from '../widgets/channels/ChannelInput/InputState.enum' -import { ChannelMessage, DownloadStatus, MessagesDailyGroups, MessageSendingStatus, UserProfile } from '@quiet/types' +import { + ChannelMessage, + ChannelType, + DownloadStatus, + MessagesDailyGroups, + MessageSendingStatus, + UserProfile, +} from '@quiet/types' import { useResizeDetector } from 'react-resize-detector' import { Dictionary } from '@reduxjs/toolkit' @@ -37,6 +44,8 @@ export interface ChannelComponentProps { user: UserProfile | undefined channelId: string channelName: string + channelType: ChannelType + members: UserProfile[] isPublic: boolean messages: { count: number @@ -74,6 +83,8 @@ export const ChannelComponent: React.FC @@ -252,7 +267,7 @@ export const ChannelComponent: React.FC { onInputChange(value) }} diff --git a/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx b/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx index 4b98406088..cd0480294a 100644 --- a/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx +++ b/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import CreateChannelComponent from './CreateChannelComponent' -import { communities, errors, identity, publicChannels, users } from '@quiet/state-manager' -import { CreateChannelPayload, ErrorCodes, ErrorMessages, SocketActions } from '@quiet/types' +import { communities, errors, identity, publicChannels } from '@quiet/state-manager' +import { ChannelType, CreateChannelPayload, ErrorCodes, ErrorMessages, SocketActions } from '@quiet/types' import { useModal } from '../../../containers/hooks' import { ModalName } from '../../../sagas/modals/modals.types' import { generateChannelId } from '@quiet/common' @@ -47,7 +47,7 @@ export const CreateChannel = () => { } const createChannel = (name: string, isPublic: boolean) => { - logger.warn(`Creating ${isPublic ? 'public' : 'private'} channel...`, name) + logger.debug(`Creating ${isPublic ? 'public' : 'private'} channel...`) // Clear errors clearErrors() if (!user) { @@ -62,6 +62,7 @@ export const CreateChannel = () => { ) return } + logger.debug('Creating channel - checking for duplicate name') // Validate channel name if (channels.some(channel => channel.name === name)) { dispatch( @@ -74,6 +75,7 @@ export const CreateChannel = () => { ) return } + logger.debug('Creating channel - checking for community metadata') if (community == null || community.teamId == null) { logger.error('Community or team ID was nullish') dispatch( @@ -86,15 +88,18 @@ export const CreateChannel = () => { ) return } - const payload = { + logger.debug('Creating channel - executing saga') + const payload: CreateChannelPayload = { id: generateChannelId(name), name: name, description: `Welcome to #${name}`, public: isPublic, + type: ChannelType.CHANNEL, teamId: community.teamId, } as CreateChannelPayload dispatch(publicChannels.actions.createChannel(payload)) setNewChannel(payload) + logger.debug('Creating channel - done') } return ( <> diff --git a/packages/desktop/src/renderer/components/Channel/NewDirectMessage.component.tsx b/packages/desktop/src/renderer/components/Channel/NewDirectMessage.component.tsx new file mode 100644 index 0000000000..b4b62db062 --- /dev/null +++ b/packages/desktop/src/renderer/components/Channel/NewDirectMessage.component.tsx @@ -0,0 +1,335 @@ +import React, { useState, useEffect, useLayoutEffect, useRef } from 'react' + +import { styled } from '@mui/material/styles' +import { Grid } from '@mui/material' + +import Page from '../ui/Page/Page' +import PageHeader from '../ui/Page/PageHeader' + +import ChannelMessagesComponent from '../widgets/channels/ChannelMessages' +import ChannelInputComponent from '../widgets/channels/ChannelInput' + +import { INPUT_STATE } from '../widgets/channels/ChannelInput/InputState.enum' + +import { + ChannelMessage, + DownloadStatus, + EMPTY_CHANNEL_ID, + MessagesDailyGroups, + MessageSendingStatus, + UserProfile, +} from '@quiet/types' + +import { useResizeDetector } from 'react-resize-detector' +import { Dictionary } from '@reduxjs/toolkit' + +import UploadFilesPreviewsComponent, { UploadFilesPreviewsProps } from './File/FileAttachmentPreview' + +import { DropZoneComponent } from './DropZone/DropZoneComponent' + +import { NewMessagesInfoComponent } from './NewMessagesInfo/NewMessagesInfoComponent' + +import { FileActionsProps } from './File/FileComponent/FileComponent' +import { UseModalType } from '../../containers/hooks' +import { HandleOpenModalType } from '../widgets/userLabel/UserLabel.types' +import NewMessageGroupHeader from '../widgets/channels/NewMessageGroupHeader' +import UserSearchAutocomplete from '../widgets/userSearch/UserSearchAutoComplete' +import { createLogger } from '../../logger' + +const ChannelMessagesWrapperStyled = styled(Grid)(({ theme }) => ({ + position: 'relative', + height: 0, + backgroundColor: theme.palette.background.default, +})) + +export interface NewDirectMessageComponentProps { + user: UserProfile | undefined + userProfiles: Record + channelId: string + channelName: string + handleClose: () => void + handleInputChange: (selectedUsers: UserProfile[]) => void + setOrCreateDmChannel: (memberIds: string[]) => void + messages: { + count: number + groups: MessagesDailyGroups + } + newestMessage: ChannelMessage + pendingMessages: Dictionary + downloadStatuses?: Dictionary + maxAutodownloadSizeBytes: number + lazyLoading: (load: boolean) => void + onInputChange: (value: string) => void + onInputEnter: (message: string) => void + openUrl: (url: string) => void + openFilesDialog: () => void + handleFileDrop: (arg: any) => void + handleClipboardFiles: (arg: ArrayBuffer, ext: string, name: string) => void + uploadedFileModal?: UseModalType<{ + src: string + }> + pendingGeneralChannelRecreation: boolean + unregisteredUsernameModalHandleOpen: HandleOpenModalType + duplicatedUsernameModalHandleOpen: HandleOpenModalType +} + +const enum ScrollPosition { + TOP = 0, + MIDDLE = -1, + BOTTOM = 1, +} + +const logger = createLogger('NewDirectMessageComponent') + +const EMPTY_CHANNEL_PLACEHOLDER_TEXT = '' +const ERROR_EMPTY_INPUT_NEW_DM = 'Enter a message to send when creating a new DM' +const ERROR_CANT_DETERMINE_MEMBERSHIP = `Can't determine membership of this DM because your user profile was undefined` +export const SEARCH_PLACEHOLDER_TEXT = 'Search for members or chats' + +export const NewDirectMessageComponent: React.FC< + NewDirectMessageComponentProps & UploadFilesPreviewsProps & FileActionsProps +> = ({ + user, + userProfiles, + handleInputChange, + handleClose, + channelId, + channelName, + setOrCreateDmChannel, + messages, + newestMessage, + pendingMessages, + downloadStatuses = {}, + maxAutodownloadSizeBytes, + lazyLoading, + onInputChange, + onInputEnter, + openUrl, + removeFile, + handleFileDrop, + filesData, + openFilesDialog, + handleClipboardFiles, + uploadedFileModal, + openContainingFolder, + downloadFile, + cancelDownload, + pendingGeneralChannelRecreation, + unregisteredUsernameModalHandleOpen, + duplicatedUsernameModalHandleOpen, +}) => { + const [lastSeenMessage, setLastSeenMessage] = useState() + const [newMessagesInfo, setNewMessagesInfo] = useState(false) + const [infoClass, setInfoClass] = useState('') + const [scrollPosition, setScrollPosition] = useState(ScrollPosition.BOTTOM) + const [mathMessagesRendered, onMathMessageRendered] = useState(0) + const [selectedMembers, setSelectedMembers] = useState([]) + const [inputPlaceholderText, setInputPlaceholderText] = useState(EMPTY_CHANNEL_PLACEHOLDER_TEXT) + const [inputErrorMessage, setInputErrorMessage] = useState(undefined) + const [potentialChannelName, setPotentialChannelName] = useState(undefined) + + const memoizedScrollHeight = useRef() + + const updateMathMessagesRendered = () => { + // To rerender Channel on each call + onMathMessageRendered(mathMessagesRendered + 1) + } + + useEffect(() => { + if (scrollPosition === ScrollPosition.BOTTOM) { + scrollBottom() + } + }, [mathMessagesRendered]) + + const onResize = React.useCallback(() => { + scrollBottom() + }, []) + + const { ref: scrollbarRef } = useResizeDetector({ onResize }) + const scrollBottom = () => { + if (!scrollbarRef?.current?.scrollTo) return + setNewMessagesInfo(false) + memoizedScrollHeight.current = 0 + scrollbarRef.current.scrollTo({ + behavior: 'auto', + top: Math.abs(scrollbarRef.current.clientHeight - scrollbarRef.current.scrollHeight), + }) + } + + const onEnterKeyPress = (message: string) => { + logger.debug('Setting or creating dm channel and sending message on enter') + if (user == null) { + logger.error('Me profile was nullish') + setInputErrorMessage(ERROR_CANT_DETERMINE_MEMBERSHIP) + return + } + if (channelId === EMPTY_CHANNEL_ID && message === '') { + logger.error('This is a new DM but the message to be sent was empty') + setInputErrorMessage(ERROR_EMPTY_INPUT_NEW_DM) + return + } + logger.debug('Setting or creating a new DM channel') + setOrCreateDmChannel(selectedMembers.map(member => member.userId)) + // Send message and files + logger.debug('Sending message to DM channel and scrolling to bottom of messages') + onInputEnter(message) + // Go back to the bottom if scroll is at the top or in the middle + setScrollPosition(ScrollPosition.BOTTOM) + } + + /* Get scroll position and save it to the state as 0 (top), 1 (bottom) or -1 (middle) */ + const onScroll = React.useCallback(() => { + if (!scrollbarRef.current) return + const top = scrollbarRef.current?.scrollTop === 0 + const bottom = + Math.floor(scrollbarRef.current?.scrollHeight - scrollbarRef.current?.scrollTop) <= + Math.floor(scrollbarRef.current?.clientHeight) + + let position = ScrollPosition.MIDDLE + if (top) position = ScrollPosition.TOP + if (bottom) position = ScrollPosition.BOTTOM + + // Clear new messages info when scrolled back to bottom + if (bottom) { + setNewMessagesInfo(false) + } + setScrollPosition(position) + }, []) + + /* Keep scroll position in certain cases */ + useLayoutEffect(() => { + // Keep scroll at the bottom when new message arrives + if (scrollbarRef.current && scrollPosition === ScrollPosition.BOTTOM) { + scrollBottom() + } + // Keep scroll position when new chunk of messages is being loaded + if (scrollbarRef.current && scrollPosition === ScrollPosition.TOP && memoizedScrollHeight.current !== undefined) { + scrollbarRef.current.scrollTop = scrollbarRef.current.scrollHeight - memoizedScrollHeight.current + } + }, [messages]) + + /* Lazy loading messages - top (load) */ + useEffect(() => { + if (!scrollbarRef.current) return + if (scrollbarRef.current.scrollHeight < scrollbarRef.current.clientHeight) return + if (scrollbarRef.current && scrollPosition === ScrollPosition.TOP) { + /* Cache scroll height before loading new messages (to keep the scroll position after re-rendering) */ + memoizedScrollHeight.current = scrollbarRef.current.scrollHeight + lazyLoading(true) + } + }, [scrollPosition]) + + /* Lazy loading messages - bottom (trim) */ + useEffect(() => { + if (!scrollbarRef.current) return + if (scrollbarRef.current.scrollHeight < scrollbarRef.current.clientHeight) return + if (scrollbarRef.current && scrollPosition === ScrollPosition.BOTTOM) { + lazyLoading(false) + } + }, [scrollPosition, messages.count]) + + useEffect(() => { + if (!scrollbarRef.current) return + if ( + Math.floor(scrollbarRef.current?.scrollHeight - scrollbarRef.current?.scrollTop) - 1 >= + Math.floor(scrollbarRef.current?.clientHeight) && + lastSeenMessage !== newestMessage.id + ) { + setNewMessagesInfo(true) + } + }, [scrollPosition, lastSeenMessage, messages]) + + useEffect(() => { + if (scrollPosition === ScrollPosition.BOTTOM && newestMessage) { + setLastSeenMessage(newestMessage?.id) + } + }, [scrollPosition, messages]) + + useEffect(() => { + scrollBottom() + }, [channelId]) + + const handleUserSearchInputChange = (members: UserProfile[]) => { + setSelectedMembers(members) + handleInputChange(members) + } + + useEffect(() => { + if (selectedMembers.length === 0) { + setInputPlaceholderText(EMPTY_CHANNEL_PLACEHOLDER_TEXT) + return + } + if (channelId !== EMPTY_CHANNEL_ID) { + setInputPlaceholderText(`${channelName}${user ? ` as @${user?.nickname}` : ''}`) + return + } + const _potentialChannelName = selectedMembers.map(member => member.nickname).join(', ') + setPotentialChannelName(_potentialChannelName) + setInputPlaceholderText(`${_potentialChannelName}${user ? ` as @${user?.nickname}` : ''}`) + }, [selectedMembers, channelId, channelName, user, potentialChannelName]) + + return ( + + + + + + + + + + + + + + { + setInputErrorMessage(undefined) + onInputChange(value) + }} + onKeyPress={message => { + onEnterKeyPress(message) + }} + openFilesDialog={openFilesDialog} + infoClass={infoClass} + setInfoClass={setInfoClass} + inputState={INPUT_STATE.AVAILABLE} + inputStateErrorMessage={inputErrorMessage} + handleClipboardFiles={handleClipboardFiles} + handleOpenFiles={handleFileDrop} + > + removeFile(id)} /> + + + + + ) +} + +export default NewDirectMessageComponent diff --git a/packages/desktop/src/renderer/components/ContextMenu/menus/ChannelContextMenu.container.tsx b/packages/desktop/src/renderer/components/ContextMenu/menus/ChannelContextMenu.container.tsx index 042dc9ce9e..f2c106c72e 100644 --- a/packages/desktop/src/renderer/components/ContextMenu/menus/ChannelContextMenu.container.tsx +++ b/packages/desktop/src/renderer/components/ContextMenu/menus/ChannelContextMenu.container.tsx @@ -1,7 +1,7 @@ import React, { FC } from 'react' import { useSelector } from 'react-redux' -import { communities, publicChannels } from '@quiet/state-manager' +import { communities, publicChannels, users } from '@quiet/state-manager' import { useContextMenu } from '../../../../hooks/useContextMenu' import { MenuName } from '../../../../const/MenuNames.enum' @@ -13,16 +13,21 @@ import { useModal } from '../../../containers/hooks' import { ModalName } from '../../../sagas/modals/modals.types' import { exportChats } from '../../../../utils/functions/exportMessages' import ChannelTypeIcon from '../../widgets/channels/ChannelTypeIcon' +import { ChannelType, PublicChannelStorage, UserProfile } from '@quiet/types' +import DMProfilePhoto from '../../widgets/channels/DMProfilePhoto' +import { isDefined } from '@quiet/common' export const ChannelContextMenu: FC = () => { const [showDebug, setShowDebug] = React.useState(false) const isOwner = useSelector(communities.selectors.isOwner) const channel = useSelector(publicChannels.selectors.currentChannel) const channelMessages = useSelector(publicChannels.selectors.currentChannelMessagesMergedBySender) + const userProfiles = useSelector(users.selectors.userProfiles) + const me = useSelector(users.selectors.myUserProfile) let title = '' if (channel) { - title = `${channel.name}` + title = `${channel.displayedName}` } const channelContextMenu = useContextMenu(MenuName.Channel) @@ -32,8 +37,7 @@ export const ChannelContextMenu: FC = () => { const items: ContextMenuItemProps[] = [] - // TODO: update this to actually use the LFA admin role - if (!(channel?.public ?? true) && isOwner) { + if (channel && !(channel?.public ?? true) && channel.type !== ChannelType.DM && isOwner) { items.push({ title: 'Add members', action: () => { @@ -65,21 +69,42 @@ export const ChannelContextMenu: FC = () => { }) } - return showDebug ? ( - setShowDebug(false)}> - - - ) : ( - + }> = ({ channel, me, userProfiles }) => { + if (channel == null) { + return <> + } + if (channel.type == null || channel.type === ChannelType.CHANNEL) { + return ( - } + ) + } + + const members = channel.memberIds?.map(memberId => userProfiles[memberId]).filter(isDefined) ?? [] + return + } + + return showDebug ? ( + } + {...channelContextMenu} + handleBack={() => setShowDebug(false)} + > + + + ) : ( + } {...channelContextMenu} > diff --git a/packages/desktop/src/renderer/components/ProfilePhoto/ProfilePhoto.tsx b/packages/desktop/src/renderer/components/ProfilePhoto/ProfilePhoto.tsx index 5d37df1219..fcd5b96f64 100644 --- a/packages/desktop/src/renderer/components/ProfilePhoto/ProfilePhoto.tsx +++ b/packages/desktop/src/renderer/components/ProfilePhoto/ProfilePhoto.tsx @@ -13,6 +13,7 @@ interface ProfilePhotoProps { size?: number style?: React.CSSProperties alt?: string + borderRadius?: string | number } const hasProfilePhoto = ( @@ -34,6 +35,7 @@ export const ProfilePhoto: React.FC = ({ className, size = 96, style, + borderRadius = 4, alt, }) => { const theme = useTheme() @@ -42,8 +44,8 @@ export const ProfilePhoto: React.FC = ({ const defaultStyle = { width: `${size}px`, height: `${size}px`, - borderRadius: '4px', - marginBottom: '16px', + size, + borderRadius, ...style, } @@ -52,9 +54,13 @@ export const ProfilePhoto: React.FC = ({ return ( <> {userProfile?.photo ? ( - {altText} +
+ {altText} +
) : profilePhotoPath ? ( - {altText} +
+ {altText} +
) : ( { + let store: Store + let socket: MockedSocket + let factory: FactoryGirl + beforeAll(async () => { + socket = new MockedSocket() + const preparedStore = await prepareStore( + {}, + socket // Fork State manager's sagas + ) + store = preparedStore.store + factory = await getReduxStoreFactory(store) + }) + + it('renderComponent - default icon all undefineds', () => { + // this is just to avoid non-deterministic values in the test, if userData is undefined a random uuid will be used to generate the jdenticon + const fakeUserData = { user: { nickname: 'foo' } } as any + const result = renderComponent() + expect(result.baseElement).toMatchInlineSnapshot(` + +
+ + + Jdenticon + + + +
+ + `) + }) + + it('renderComponent - default icon with valid user profile and no connected badge', async () => { + const userProfile = await factory.create('UserProfile', { photo: undefined }) + const userData: DmChannelUserData = { + user: userProfile, + connected: false, + } + const result = renderComponent() + expect(result.baseElement).toMatchInlineSnapshot(` + +
+ + + Jdenticon + + + +
+ + `) + }) + + it('renderComponent - default icon with valid user profile and connected badge', async () => { + const userProfile = await factory.create('UserProfile', { photo: undefined }) + const userData: DmChannelUserData = { + user: userProfile, + connected: true, + } + const result = renderComponent() + expect(result.baseElement).toMatchInlineSnapshot(` + +
+ + + Jdenticon + + + +
+ + `) + }) + + it('renderComponent - profile photo icon with connected badge', async () => { + const userProfile = await factory.create('UserProfile') + const userData: DmChannelUserData = { + user: userProfile, + connected: true, + } + const result = renderComponent() + expect(result.baseElement).toMatchInlineSnapshot(` + +
+ + +
+ user_3 +
+
+ + +
+ + `) + }) + + it('renderComponent - profile photo icon with no connected badge', async () => { + const userProfile = await factory.create('UserProfile') + const userData: DmChannelUserData = { + user: userProfile, + connected: false, + } + const result = renderComponent() + expect(result.baseElement).toMatchInlineSnapshot(` + +
+ + +
+ user_4 +
+
+ + +
+ + `) + }) + + it('renderComponent - default icon with valid user profile, self dm channel (1 user), and connected badge', async () => { + const userProfile = await factory.create('UserProfile', { photo: undefined }) + const userData: DmChannelUserData = { + user: userProfile, + connected: true, + } + const channel: PublicChannelStorage = { + ...(await factory.create('PublicChannel', { + memberIds: [userProfile.userId], + id: 'channel1', + })), + messages: {} as any, + displayedName: userProfile.nickname, + } + const result = renderComponent() + expect(result.baseElement).toMatchInlineSnapshot(` + +
+ + + Jdenticon + + + +
+ + `) + }) + + it('renderComponent - default icon with valid user profile, self dm channel (1 user), and connected badge', async () => { + const userProfile = await factory.create('UserProfile', { photo: undefined }) + const userData: DmChannelUserData = { + user: userProfile, + connected: true, + } + const channel: PublicChannelStorage = { + ...(await factory.create('PublicChannel', { + memberIds: [userProfile.userId], + id: 'channel1', + })), + messages: {} as any, + displayedName: userProfile.nickname, + } + const result = renderComponent() + expect(result.baseElement).toMatchInlineSnapshot(` + +
+ + + Jdenticon + + + +
+ + `) + }) + + it('renderComponent - default icon with valid user profile, dm channel (1 other user), and connected badge', async () => { + const userProfile = await factory.create('UserProfile', { photo: undefined }) + const userData: DmChannelUserData = { + user: userProfile, + connected: true, + } + const channel: PublicChannelStorage = { + ...(await factory.create('PublicChannel', { + memberIds: [userProfile.userId, 'foo'], + id: 'channel1', + })), + messages: {} as any, + displayedName: userProfile.nickname, + } + const result = renderComponent() + expect(result.baseElement).toMatchInlineSnapshot(` + +
+ + + Jdenticon + + + +
+ + `) + }) + + it('renderComponent - default icon with valid user profile, dm group channel (3 other users), and member count badge', async () => { + const userProfile = await factory.create('UserProfile', { photo: undefined }) + const userData: DmChannelUserData = { + user: userProfile, + connected: true, + } + const channel: PublicChannelStorage = { + ...(await factory.create('PublicChannel', { + memberIds: [userProfile.userId, 'foo', 'bar', 'baz'], + id: 'channel1', + })), + messages: {} as any, + displayedName: userProfile.nickname, + } + const result = renderComponent() + expect(result.baseElement).toMatchInlineSnapshot(` + +
+ + + Jdenticon + + + 3 + + +
+ + `) + }) + + it('renderComponent - default icon with valid user profile, dm group channel (10 other users), and max member count badge', async () => { + const userProfile = await factory.create('UserProfile', { photo: undefined }) + const userData: DmChannelUserData = { + user: userProfile, + connected: true, + } + const channel: PublicChannelStorage = { + ...(await factory.create('PublicChannel', { + memberIds: [userProfile.userId, 'foo', 'bar', 'baz', 'bin', 'far', 'faz', 'boo', '123', '456', '789'], + id: 'channel1', + })), + messages: {} as any, + displayedName: userProfile.nickname, + } + const result = renderComponent() + expect(result.baseElement).toMatchInlineSnapshot(` + +
+ + + Jdenticon + + + 9+ + + +
+ + `) + }) + + it('renderComponent - profile photo icon with valid user profile, self dm channel (1 user), and connected badge', async () => { + const userProfile = await factory.create('UserProfile', { photo: undefined }) + const userData: DmChannelUserData = { + user: userProfile, + connected: true, + } + const channel: PublicChannelStorage = { + ...(await factory.create('PublicChannel', { + memberIds: [userProfile.userId], + id: 'channel1', + })), + messages: {} as any, + displayedName: userProfile.nickname, + } + const result = renderComponent() + expect(result.baseElement).toMatchInlineSnapshot(` + +
+ + + Jdenticon + + + +
+ + `) + }) + + it('renderComponent - profile photo icon with valid user profile, dm channel (1 other user), and connected badge', async () => { + const userProfile = await factory.create('UserProfile') + const userData: DmChannelUserData = { + user: userProfile, + connected: true, + } + const channel: PublicChannelStorage = { + ...(await factory.create('PublicChannel', { + memberIds: [userProfile.userId, 'foo'], + id: 'channel1', + })), + messages: {} as any, + displayedName: userProfile.nickname, + } + const result = renderComponent() + expect(result.baseElement).toMatchInlineSnapshot(` + +
+ + +
+ user_11 +
+
+ + +
+ + `) + }) + + it('renderComponent - profile photo icon with valid user profile, dm group channel (3 other users), and member count badge', async () => { + const userProfile = await factory.create('UserProfile') + const userData: DmChannelUserData = { + user: userProfile, + connected: true, + } + const channel: PublicChannelStorage = { + ...(await factory.create('PublicChannel', { + memberIds: [userProfile.userId, 'foo', 'bar', 'baz'], + id: 'channel1', + })), + messages: {} as any, + displayedName: userProfile.nickname, + } + const result = renderComponent() + expect(result.baseElement).toMatchInlineSnapshot(` + +
+ + +
+ user_12 +
+
+ + 3 + +
+
+ + `) + }) + + it('renderComponent - profile photo icon with valid user profile, dm group channel (10 other users), and max member count badge', async () => { + const userProfile = await factory.create('UserProfile') + const userData: DmChannelUserData = { + user: userProfile, + connected: true, + } + const channel: PublicChannelStorage = { + ...(await factory.create('PublicChannel', { + memberIds: [userProfile.userId, 'foo', 'bar', 'baz', 'bin', 'far', 'faz', 'boo', '123', '456', '789'], + id: 'channel1', + })), + messages: {} as any, + displayedName: userProfile.nickname, + } + const result = renderComponent() + expect(result.baseElement).toMatchInlineSnapshot(` + +
+ + +
+ user_13 +
+
+ + 9+ + +
+
+ + `) + }) +}) diff --git a/packages/desktop/src/renderer/components/ProfilePhoto/ProfilePhotoWithBadge.tsx b/packages/desktop/src/renderer/components/ProfilePhoto/ProfilePhotoWithBadge.tsx new file mode 100644 index 0000000000..bef4706946 --- /dev/null +++ b/packages/desktop/src/renderer/components/ProfilePhoto/ProfilePhotoWithBadge.tsx @@ -0,0 +1,140 @@ +import React from 'react' +import { Badge, styled, useTheme } from '@mui/material' +import ProfilePhoto from './ProfilePhoto' +import { ProfilePhotoSize, ProfilePhotoWithBadgeProps } from './ProfilePhoto.types' +import { randomUUID } from 'crypto' +import classNames from 'classnames' + +const PREFIX = 'ProfilePhotoWithBadge' + +const classes = { + avatar: `${PREFIX}avatar`, + avatarSmall: `${PREFIX}avatarSmall`, + avatarMedium: `${PREFIX}avatarMedium`, + avatarLarge: `${PREFIX}avatarLarge`, +} + +const StyledBadge = styled(Badge)(({ theme }) => ({ + '.MuiBadge-dot': { + backgroundColor: theme.palette.colors.statusGreen, + color: theme.palette.colors.statusGreen, + height: theme.componentSizes.statusIndicator.size, + width: theme.componentSizes.statusIndicator.size, + minWidth: theme.componentSizes.statusIndicator.size, + minHeight: theme.componentSizes.statusIndicator.size, + borderRadius: '50%', + border: `${theme.componentSizes.statusIndicator.borderWidth}px solid ${ + theme.palette.colors?.sidebarBackground || theme.palette.background.default + }`, + boxSizing: 'border-box', + right: theme.componentSizes.statusIndicator.position.right, + bottom: theme.componentSizes.statusIndicator.position.bottom, + padding: 0, + fontSize: 9, + }, + + '.MuiBadge-standard': { + backgroundColor: theme.palette.colors.gray50, + color: theme.palette.colors.trueBlack, + height: 'auto', + width: '100%', + minWidth: theme.componentSizes.dmMemberCountIndicator.minSize, + minHeight: theme.componentSizes.dmMemberCountIndicator.minSize, + maxWidth: theme.componentSizes.dmMemberCountIndicator.maxSize, + maxHeight: theme.componentSizes.dmMemberCountIndicator.maxSize, + borderRadius: '25%', + border: `${theme.componentSizes.dmMemberCountIndicator.borderWidth}px solid ${ + theme.palette.colors?.sidebarBackground || theme.palette.background.default + }`, + boxSizing: 'border-box', + right: theme.componentSizes.dmMemberCountIndicator.position.right, + bottom: theme.componentSizes.dmMemberCountIndicator.position.bottom, + padding: 2, + fontSize: theme.componentSizes.dmMemberCountIndicator.fontSize, + lineHeight: theme.componentSizes.dmMemberCountIndicator.lineHeight, + }, + [`& .${classes.avatar}`]: { + marginRight: 0, + background: theme.palette.background.paper, + }, + [`& .${classes.avatarSmall}`]: { + width: theme.componentSizes.avatar.small, + height: theme.componentSizes.avatar.small, + }, + [`& .${classes.avatarMedium}`]: { + width: theme.componentSizes.avatar.medium, + height: theme.componentSizes.avatar.medium, + }, + [`& .${classes.avatarLarge}`]: { + width: theme.componentSizes.avatar.large, + height: theme.componentSizes.avatar.large, + }, +})) + +const MAX_BADGE_MEMBER_COUNT = 9 + +export const ProfilePhotoWithBadge: React.FC = ({ + channel, + userData, + size = ProfilePhotoSize.SMALL, + borderRadius = 4, +}) => { + const theme = useTheme() + let variant: 'dot' | 'standard' = 'dot' + let badgeContent: number | undefined = undefined + let invisible = !(userData?.connected ?? false) + let groupDm = false + let overlap: 'circular' | 'rectangular' = 'circular' + if (channel && channel.memberIds != null && channel.memberIds.length > 2) { + variant = 'standard' + badgeContent = channel.memberIds.length - 1 + invisible = false + groupDm = true + overlap = 'rectangular' + } + const id = (channel ? channel.id : userData?.user.nickname) ?? randomUUID() + let componentSize: number + switch (size) { + case ProfilePhotoSize.SMALL: + componentSize = theme.componentSizes.avatar.small + break + case ProfilePhotoSize.MEDIUM: + componentSize = theme.componentSizes.avatar.medium + break + case ProfilePhotoSize.LARGE: + componentSize = theme.componentSizes.avatar.large + break + default: + throw Error(`Invalid profile photo size: ${size}`) + } + + return ( + + + + + + ) +} + +export default ProfilePhotoWithBadge diff --git a/packages/desktop/src/renderer/components/SearchModal/SearchModal.stories.tsx b/packages/desktop/src/renderer/components/SearchModal/SearchModal.stories.tsx index a4f6a96e99..8ac168ecdd 100644 --- a/packages/desktop/src/renderer/components/SearchModal/SearchModal.stories.tsx +++ b/packages/desktop/src/renderer/components/SearchModal/SearchModal.stories.tsx @@ -2,6 +2,7 @@ import React from 'react' import { ComponentStory, ComponentMeta } from '@storybook/react' import { withTheme } from '../../storybook/decorators' import SearchModalComponent, { SearchModalComponentProps } from './SearchModelComponent' +import { ChannelType } from '@quiet/types' const Template: ComponentStory = args => { return @@ -20,6 +21,8 @@ const args: SearchModalComponentProps = { owner: '', timestamp: 123123, public: true, + type: ChannelType.CHANNEL, + displayedName: 'fun', teamId: 'foobar', }, { @@ -30,6 +33,8 @@ const args: SearchModalComponentProps = { owner: '', timestamp: 123123, public: true, + type: ChannelType.CHANNEL, + displayedName: 'mobile', teamId: 'foobar', }, { @@ -40,6 +45,8 @@ const args: SearchModalComponentProps = { owner: '', timestamp: 123123, public: true, + type: ChannelType.CHANNEL, + displayedName: 'new-york-plans', teamId: 'foobar', }, ], @@ -52,6 +59,8 @@ const args: SearchModalComponentProps = { owner: '', timestamp: 123123, public: true, + type: ChannelType.CHANNEL, + displayedName: 'fun', teamId: 'foobar', }, { @@ -62,6 +71,8 @@ const args: SearchModalComponentProps = { owner: '', timestamp: 123123, public: true, + type: ChannelType.CHANNEL, + displayedName: 'mobile', teamId: 'foobar', }, { @@ -72,6 +83,8 @@ const args: SearchModalComponentProps = { owner: '', timestamp: 123123, public: true, + type: ChannelType.CHANNEL, + displayedName: 'new-york-plans', teamId: 'foobar', }, { @@ -82,6 +95,8 @@ const args: SearchModalComponentProps = { owner: '', timestamp: 123123, public: true, + type: ChannelType.CHANNEL, + displayedName: 'general', teamId: 'foobar', }, ], diff --git a/packages/desktop/src/renderer/components/Settings/Settings.tsx b/packages/desktop/src/renderer/components/Settings/Settings.tsx index 50bb5a3ea3..086f263eee 100644 --- a/packages/desktop/src/renderer/components/Settings/Settings.tsx +++ b/packages/desktop/src/renderer/components/Settings/Settings.tsx @@ -12,6 +12,7 @@ import { LeaveCommunity } from './Tabs/LeaveCommunity/LeaveCommunity' import SettingsComponent from './SettingsComponent' import { DebugInfoComponent } from '../debugInfo/debugInfoComponent' +import { CommunityMembership } from './Tabs/CommunityMembership/CommunityMembership' const Settings = () => { const modal = useModal(ModalName.accountSettingsModal) @@ -23,6 +24,7 @@ const Settings = () => { invite: Invite, qrcode: QRCode, leaveCommunity: LeaveCommunity, + communityMembership: CommunityMembership, } if (process.env.NODE_ENV === 'development' || process.env.IS_E2E === 'true') { diff --git a/packages/desktop/src/renderer/components/Settings/SettingsComponent.tsx b/packages/desktop/src/renderer/components/Settings/SettingsComponent.tsx index b23486e960..1d7ad5eafc 100644 --- a/packages/desktop/src/renderer/components/Settings/SettingsComponent.tsx +++ b/packages/desktop/src/renderer/components/Settings/SettingsComponent.tsx @@ -47,7 +47,7 @@ export const SettingsComponent: React.FC = ({ return ( <> - +
@@ -69,6 +69,16 @@ export const SettingsComponent: React.FC = ({ + handleChange('communityMembership')} + > + Community membership + + + + + handleChange('notifications')}> Notifications @@ -128,7 +138,7 @@ export const SettingsComponent: React.FC = ({ - {TabComponent && } + {TabComponent && } diff --git a/packages/desktop/src/renderer/components/Settings/Tabs/CommunityMembership/CommunityMemberListItem.tsx b/packages/desktop/src/renderer/components/Settings/Tabs/CommunityMembership/CommunityMemberListItem.tsx new file mode 100644 index 0000000000..825b485ac2 --- /dev/null +++ b/packages/desktop/src/renderer/components/Settings/Tabs/CommunityMembership/CommunityMemberListItem.tsx @@ -0,0 +1,176 @@ +import React, { useRef } from 'react' +import { styled, useTheme } from '@mui/material/styles' +import classNames from 'classnames' +import { Typography, ListItemButton, Grid } from '@mui/material' +import Badge from '@mui/material/Badge' +import ListItemText from '@mui/material/ListItemText' +import { UserProfile } from '@quiet/types' +import ProfilePhotoWithBadge from '../../../ProfilePhoto/ProfilePhotoWithBadge' +import { DmChannelUserData } from '../../../Sidebar/DirectMessagesPanel/DirectMessagesPanel' +import { ProfilePhotoSize } from '../../../ProfilePhoto/ProfilePhoto.types' + +const PREFIX = 'CommunityMemberListItem' + +const classes = { + root: `${PREFIX}root`, + avatar: `${PREFIX}avatar`, + primary: `${PREFIX}primary`, + nickname: `${PREFIX}nickname`, + itemText: `${PREFIX}itemText`, + selected: `${PREFIX}selected`, + disabled: `${PREFIX}disabled`, + newMessages: `${PREFIX}newMessages`, + me: `${PREFIX}me`, +} + +const StyledBadge = styled(Badge)(({ theme }) => ({ + '.MuiBadge-dot': { + backgroundColor: theme.palette.colors.statusGreen, + color: theme.palette.colors.statusGreen, + height: theme.componentSizes.statusIndicator.size, + width: theme.componentSizes.statusIndicator.size, + minWidth: theme.componentSizes.statusIndicator.size, + minHeight: theme.componentSizes.statusIndicator.size, + borderRadius: '50%', + border: `${theme.componentSizes.statusIndicator.borderWidth}px solid ${ + theme.palette.colors?.sidebarBackground || theme.palette.background.default + }`, + boxSizing: 'border-box', + right: theme.componentSizes.statusIndicator.position.right, + bottom: theme.componentSizes.statusIndicator.position.bottom, + padding: 0, + fontSize: 9, + }, + + '.MuiBadge-standard': { + backgroundColor: theme.palette.colors.gray50, + color: theme.palette.colors.trueBlack, + height: 'auto', + width: '100%', + minWidth: theme.componentSizes.dmMemberCountIndicator.minSize, + minHeight: theme.componentSizes.dmMemberCountIndicator.minSize, + maxWidth: theme.componentSizes.dmMemberCountIndicator.maxSize, + maxHeight: theme.componentSizes.dmMemberCountIndicator.maxSize, + borderRadius: '25%', + border: `${theme.componentSizes.dmMemberCountIndicator.borderWidth}px solid ${ + theme.palette.colors?.sidebarBackground || theme.palette.background.default + }`, + boxSizing: 'border-box', + right: theme.componentSizes.dmMemberCountIndicator.position.right, + bottom: theme.componentSizes.dmMemberCountIndicator.position.bottom, + padding: 2, + fontSize: theme.componentSizes.dmMemberCountIndicator.fontSize, + lineHeight: theme.componentSizes.dmMemberCountIndicator.lineHeight, + }, +})) + +const StyledListItemButton = styled(ListItemButton)(({ theme }) => ({ + [`&.${classes.root}`]: { + width: 220, + padding: `3px 16px 3px 16px`, + gap: 8, + opacity: 1, + display: 'flex', + flexDirection: 'row', + backgroundColor: 'inherit', + alignItems: 'center', + alignContent: 'center', + }, + [`&:hover`]: { + backgroundColor: theme.palette.colors?.sidebarHover || theme.palette.action.hover, + }, + [`& .${classes.nickname}`]: { + fontWeight: 400, + paddingLeft: 0, + paddingRight: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + maxWidth: 150, + whiteSpace: 'nowrap', + }, + [`& .${classes.itemText}`]: { + margin: 0, + }, + [`&.${classes.selected}`]: { + backgroundColor: theme.palette.colors.sidebarSelected, + }, + [`&.${classes.disabled}`]: { + opacity: '0.3', + pointerEvents: 'none', + cursor: 'not-allowed', + }, + [`& .${classes.newMessages}`]: { + opacity: 1, + fontWeight: 600, + }, + [`& .${classes.me}`]: { + color: theme.palette.colors.gray50, + }, + + [`&.${classes.root}:hover`]: { + backgroundColor: theme.palette.colors.sidebarHover, + }, +})) + +export interface CommunityMemberListItemProps { + me: UserProfile | undefined + userData: DmChannelUserData | undefined + selected: boolean + openUserProfile: (user: UserProfile) => void +} + +export const CommunityMemberListItem: React.FC = ({ + me, + userData, + selected, + openUserProfile, +}) => { + const theme = useTheme() + const ref = useRef(null) + + return ( + { + event.persist() + if (!userData) return + openUserProfile(userData.user) + }} + ref={ref} + > + + + + {userData?.user.nickname ?? 'undefined'} + + {me != null && userData?.user.userId === me.userId && ( + + me + + )} + + } + className={classNames(classes.itemText, classes.primary)} + /> + + ) +} + +export default CommunityMemberListItem diff --git a/packages/desktop/src/renderer/components/Settings/Tabs/CommunityMembership/CommunityMembership.component.test.tsx b/packages/desktop/src/renderer/components/Settings/Tabs/CommunityMembership/CommunityMembership.component.test.tsx new file mode 100644 index 0000000000..7a827c7fd5 --- /dev/null +++ b/packages/desktop/src/renderer/components/Settings/Tabs/CommunityMembership/CommunityMembership.component.test.tsx @@ -0,0 +1,659 @@ +import { getReduxStoreFactory, Store } from '@quiet/state-manager' +import { UserProfile } from '@quiet/types' +import '@testing-library/jest-dom' +import { FactoryGirl } from 'factory-girl' +import { prepareStore } from '../../../../testUtils' +import React from 'react' +import MockedSocket from 'socket.io-mock' +import { renderComponent } from '../../../../testUtils/renderComponent' +import { CommunityMembershipComponent } from './CommunityMembership.component' + +describe('CommunityMembership', () => { + let store: Store + let socket: MockedSocket + let factory: FactoryGirl + beforeAll(async () => { + socket = new MockedSocket() + const preparedStore = await prepareStore( + {}, + socket // Fork State manager's sagas + ) + store = preparedStore.store + factory = await getReduxStoreFactory(store) + }) + + it('renderComponent - empty user list', () => { + const result = renderComponent( + + ) + expect(result.baseElement).toMatchInlineSnapshot(` + +
+
+
+
+

+ Community membership +

+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
    +
+
+
+
+ + `) + }) + + it('renderComponent - user list with one user, me tag, and connected badge', async () => { + const me: UserProfile = await factory.create('UserProfile') + const result = renderComponent( + + ) + expect(result.baseElement).toMatchInlineSnapshot(` + +
+
+
+
+

+ Community membership +

+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
    +
    +
    +
    + + +
    + user_1 +
    +
    + + +
    + +
    +

    + user_1 +

    +

    + me +

    +
    +
    +
    + +
    +
    +
    +
  • +
  • +
    +
+
+
+
+
+ + `) + }) + + it('renderComponent - user list with one user, no me tag, and no connected badge', async () => { + const me: UserProfile = await factory.create('UserProfile') + const result = renderComponent( + + ) + expect(result.baseElement).toMatchInlineSnapshot(` + +
+
+
+
+

+ Community membership +

+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
    +
    +
    +
    + + +
    + user_2 +
    +
    + + +
    + +
    +

    + user_2 +

    +
    +
    +
    + +
    +
    +
    +
  • +
  • +
    +
+
+
+
+
+ + `) + }) + + it('renderComponent - user list with multiple users', async () => { + const me: UserProfile = await factory.create('UserProfile') + const user1: UserProfile = await factory.create('UserProfile') + const user2: UserProfile = await factory.create('UserProfile') + const result = renderComponent( + + ) + expect(result.baseElement).toMatchInlineSnapshot(` + +
+
+
+
+

+ Community membership +

+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
    +
    +
    +
    + + +
    + user_3 +
    +
    + + +
    + +
    +

    + user_3 +

    +

    + me +

    +
    +
    +
    + +
    +
    +
    +
  • +
  • +
    +
    +
    +
    + + +
    + user_4 +
    +
    + + +
    + +
    +

    + user_4 +

    +
    +
    +
    + +
    +
    +
    +
  • +
  • +
    +
    +
    +
    + + +
    + user_5 +
    +
    + + +
    + +
    +

    + user_5 +

    +
    +
    +
    + +
    +
    +
    +
  • +
  • +
    +
+
+
+
+
+ + `) + }) +}) diff --git a/packages/desktop/src/renderer/components/Settings/Tabs/CommunityMembership/CommunityMembership.component.tsx b/packages/desktop/src/renderer/components/Settings/Tabs/CommunityMembership/CommunityMembership.component.tsx new file mode 100644 index 0000000000..e1a2ef883a --- /dev/null +++ b/packages/desktop/src/renderer/components/Settings/Tabs/CommunityMembership/CommunityMembership.component.tsx @@ -0,0 +1,215 @@ +import React, { FC, useEffect, useState } from 'react' +import { styled } from '@mui/material/styles' +import Grid from '@mui/material/Grid' +import Typography from '@mui/material/Typography' +import { List } from '@mui/material' +import { UserProfile } from '@quiet/types' +import UserSearchFuzzy from '../../../widgets/userSearch/UserSearchFuzzySearch' +import { DmChannelUserData } from '../../../Sidebar/DirectMessagesPanel/DirectMessagesPanel' +import CommunityMemberListItem from './CommunityMemberListItem' +import { SelectableListOption } from '../../../widgets/userSearch/UserSearch.types' +import { createLogger } from '../../../../logger' + +const PREFIX = 'CommunityMembership' + +const classes = { + title: `${PREFIX}title`, + titleDiv: `${PREFIX}titleDiv`, + link: `${PREFIX}link`, + button: `${PREFIX}button`, + bold: `${PREFIX}bold`, + componentContainer: `${PREFIX}componentContainer`, + memberContainer: `${PREFIX}memberContainer`, + eyeIcon: `${PREFIX}eyeIcon`, + wrapper: `${PREFIX}wrapper`, + divider: `${PREFIX}divider`, +} + +const StyledGrid = styled(Grid)(({ theme }) => ({ + [`& .${classes.title}`]: {}, + [`& .${classes.wrapper}`]: { + maxWidth: '100%', + }, + [`& .${classes.titleDiv}`]: { + marginBottom: 24, + }, + [`& .${classes.link}`]: { + marginTop: '16px', + fontSize: '13px', + letterSpacing: '-0.4px', + overflowWrap: 'break-word', + inlineSize: 'calc(100% - 40px);', + }, + [`& .${classes.button}`]: { + marginTop: 24, + textTransform: 'none', + width: '100%', + height: 60, + color: theme.palette.colors.white, + backgroundColor: theme.palette.colors.quietBlue, + '&:hover': { + opacity: 0.7, + backgroundColor: theme.palette.colors.quietBlue, + }, + }, + [`& .${classes.bold}`]: { + fontWeight: 'bold', + }, + + [`& .${classes.componentContainer}`]: { + display: 'flex', + flexDirection: 'column', + flexWrap: 'nowrap', + justifyContent: 'flex-start', + alignItems: 'baseline', + alignContent: 'stretch', + maxWidth: '375px', + position: 'relative', + gap: 16, + }, + + [`& .${classes.memberContainer}`]: { + display: 'flex', + flexDirection: 'row', + flexWrap: 'nowrap', + justifyContent: 'flex-start', + alignItems: 'baseline', + alignContent: 'stretch', + maxWidth: '375px', + position: 'relative', + flex: 1, + }, + + [`& .${classes.eyeIcon}`]: { + margin: '5px', + top: '8px', + position: 'absolute', + right: '0', + }, + + [`& .${classes.divider}`]: { + marginTop: 8, + marginBottom: 8, + borderBottom: `1px solid ${theme.palette.colors.border01}`, + }, +})) + +export interface CommunityMembershipComponentProps { + userProfiles: Record + me: UserProfile | undefined + connectedPeers: string[] + openUserProfilePanel: (userProfile: UserProfile | undefined) => void + open: boolean +} + +const LOGGER = createLogger('CommunityMembershipComponent') + +const getUserDataForUser = ( + userProfile: UserProfile, + me: UserProfile | undefined, + connectedPeers: string[] +): DmChannelUserData | undefined => { + if (me != null && userProfile.userId === me.userId) { + return { + connected: true, + user: userProfile, + } + } + + const connected = + userProfile.userData != null && + userProfile.userData.peerId != null && + connectedPeers.includes(userProfile.userData.peerId) + return { + connected, + user: userProfile, + } +} + +const SEARCH_PLACEHOLDER_TEXT = 'Search for users in your community' + +export const CommunityMembershipComponent: FC = ({ + userProfiles, + me, + connectedPeers, + openUserProfilePanel, + open, +}) => { + LOGGER.debug('Creating community membership component') + const [visibleUsers, setVisibleUsers] = useState([]) + const [options, setOptions] = useState([]) + + const _initializeOptions = () => { + LOGGER.debug('Initializing membership options', Object.values(userProfiles).length) + const initialOptions: SelectableListOption[] = [] + let index = 0 + for (const user of Object.values(userProfiles)) { + const mutable = true + const selected = false + const hide = false + initialOptions.push({ label: user.nickname, id: user.userId, selected, index, mutable, hide }) + index++ + } + LOGGER.debug('Initialized membership options', initialOptions.length) + setOptions(initialOptions) + } + + useEffect(() => { + if (open) { + _initializeOptions() + setVisibleUsers(Object.values(userProfiles)) + } else { + setOptions([]) + setVisibleUsers([]) + } + }, [open]) + + const handleUserSearchInputChange = (visibleOptions: SelectableListOption[]) => { + setVisibleUsers(visibleOptions.map(option => userProfiles[option.id])) + } + + return ( + + + + + Community membership + + + + + + + + + + {visibleUsers.map(user => { + const userData = getUserDataForUser(user, me, connectedPeers) + return ( + + + + + +
  • + + + ) + })} + + + + + ) +} diff --git a/packages/desktop/src/renderer/components/Settings/Tabs/CommunityMembership/CommunityMembership.tsx b/packages/desktop/src/renderer/components/Settings/Tabs/CommunityMembership/CommunityMembership.tsx new file mode 100644 index 0000000000..e8a0291709 --- /dev/null +++ b/packages/desktop/src/renderer/components/Settings/Tabs/CommunityMembership/CommunityMembership.tsx @@ -0,0 +1,49 @@ +import React, { FC, useEffect, useState } from 'react' +import { useSelector } from 'react-redux' + +import { network, users } from '@quiet/state-manager' + +import { CommunityMembershipComponent } from './CommunityMembership.component' +import { createLogger } from '../../../../logger' +import { UserProfile } from '@quiet/types' +import { useContextMenu } from '../../../../../hooks/useContextMenu' +import { MenuName } from '../../../../../const/MenuNames.enum' + +const LOGGER = createLogger('CommunityMembership') + +export const CommunityMembership: FC<{ handleClose: () => void; currentTab: string }> = ({ currentTab }) => { + LOGGER.debug('Opening community membership settings tab') + + const userProfiles = useSelector(users.selectors.userProfiles) + const me = useSelector(users.selectors.myUserProfile) + const connectedPeers = useSelector(network.selectors.connectedPeers) + const userProfileContextMenu = useContextMenu(MenuName.UserProfile) + + const [tabOpen, setTabOpen] = useState(false) + + const openUserProfilePanel = (userProfile: UserProfile | undefined) => { + if (userProfile) { + userProfileContextMenu.handleOpen({ userProfile }) + } else { + userProfileContextMenu.handleOpen() + } + } + + useEffect(() => { + if (currentTab == 'communityMembership') { + setTabOpen(true) + } else { + setTabOpen(false) + } + }, [currentTab]) + + return ( + + ) +} diff --git a/packages/desktop/src/renderer/components/Settings/Tabs/LeaveCommunity/LeaveCommunity.tsx b/packages/desktop/src/renderer/components/Settings/Tabs/LeaveCommunity/LeaveCommunity.tsx index c293d56c9a..8defd31c01 100644 --- a/packages/desktop/src/renderer/components/Settings/Tabs/LeaveCommunity/LeaveCommunity.tsx +++ b/packages/desktop/src/renderer/components/Settings/Tabs/LeaveCommunity/LeaveCommunity.tsx @@ -4,7 +4,7 @@ import { useSelector } from 'react-redux' import { communities } from '@quiet/state-manager' -import { LeaveCommunityComponent, LeaveCommunityProps } from './LeaveCommunityComponent' +import { LeaveCommunityComponent } from './LeaveCommunityComponent' import { clearCommunity } from '../../../..' diff --git a/packages/desktop/src/renderer/components/Sidebar/ChannelsPanel/ChannelsPanel.test.tsx b/packages/desktop/src/renderer/components/Sidebar/ChannelsPanel/ChannelsPanel.test.tsx index f5eea3aff9..deec1b2d55 100644 --- a/packages/desktop/src/renderer/components/Sidebar/ChannelsPanel/ChannelsPanel.test.tsx +++ b/packages/desktop/src/renderer/components/Sidebar/ChannelsPanel/ChannelsPanel.test.tsx @@ -122,9 +122,13 @@ describe('Channels panel', () => { ) @@ -158,8 +162,7 @@ describe('Channels panel', () => {
  • @@ -291,7 +294,7 @@ describe('Channels panel', () => {

    - allergies + croatia

    @@ -345,7 +348,7 @@ describe('Channels panel', () => {
    @@ -364,7 +367,7 @@ describe('Channels panel', () => {

    - antiques + allergies

    @@ -418,7 +421,7 @@ describe('Channels panel', () => {
    @@ -437,7 +440,7 @@ describe('Channels panel', () => {

    - croatia + sailing

    @@ -561,7 +564,7 @@ describe('Channels panel', () => {
    @@ -580,7 +583,7 @@ describe('Channels panel', () => {

    - sailing + antiques

    @@ -647,126 +650,49 @@ describe('Channels panel', () => {

    - Users + Direct messages

    - -
      -
      - - - user_2 - - - -
      -

      - user_2 -

      -
      - -
      -
      - - - user_4 - - - -
      -

      - user_4 -

      -
      - -
      - - + -
      -

      - user_6 -

      -
      -
      -
    + +
      , @@ -795,8 +721,7 @@ describe('Channels panel', () => { -
      -

      - user_6 -

      -
      - -
    + +
      , "debug": [Function], diff --git a/packages/desktop/src/renderer/components/Sidebar/ChannelsPanel/ChannelsPanel.tsx b/packages/desktop/src/renderer/components/Sidebar/ChannelsPanel/ChannelsPanel.tsx index a1f2134859..82df1eb253 100644 --- a/packages/desktop/src/renderer/components/Sidebar/ChannelsPanel/ChannelsPanel.tsx +++ b/packages/desktop/src/renderer/components/Sidebar/ChannelsPanel/ChannelsPanel.tsx @@ -2,7 +2,7 @@ import React from 'react' import Grid from '@mui/material/Grid' import List from '@mui/material/List' import { useModal } from '../../../containers/hooks' -import { PublicChannel, UserProfile } from '@quiet/types' +import { ChannelType, PublicChannel, UserProfile } from '@quiet/types' import SidebarHeader from '../../ui/Sidebar/SidebarHeader' import ChannelsListItem from './ChannelsListItem' @@ -32,26 +32,28 @@ const ChannelsPanel: React.FC = ({ - {channels.map((channel, _index) => { - const unread = unreadChannels.some(id => id === channel.id) - const selected = currentChannelId === channel.id - return ( - - ) - })} + {channels + .filter(channel => channel.type == null || channel.type === ChannelType.CHANNEL) + .map((channel, _index) => { + const unread = unreadChannels.some(id => id === channel.id) + const selected = currentChannelId === channel.id + return ( + + ) + })} {/* diff --git a/packages/desktop/src/renderer/components/Sidebar/DirectMessagesPanel/DirectMessageListItem.tsx b/packages/desktop/src/renderer/components/Sidebar/DirectMessagesPanel/DirectMessageListItem.tsx new file mode 100644 index 0000000000..c913cee134 --- /dev/null +++ b/packages/desktop/src/renderer/components/Sidebar/DirectMessagesPanel/DirectMessageListItem.tsx @@ -0,0 +1,141 @@ +import React, { useRef } from 'react' +import { styled, useTheme } from '@mui/material/styles' +import classNames from 'classnames' +import { Typography, ListItemButton, Grid } from '@mui/material' +import Badge from '@mui/material/Badge' +import ListItemText from '@mui/material/ListItemText' +import { PublicChannelStorage, UserProfile } from '@quiet/types' +import ProfilePhoto from '../../ProfilePhoto/ProfilePhoto' +import { DmChannelUserData } from './DirectMessagesPanel' +import ProfilePhotoWithBadge from '../../ProfilePhoto/ProfilePhotoWithBadge' + +const PREFIX = 'UserProfileListItem' + +const classes = { + root: `${PREFIX}root`, + primary: `${PREFIX}primary`, + nickname: `${PREFIX}nickname`, + itemText: `${PREFIX}itemText`, + selected: `${PREFIX}selected`, + disabled: `${PREFIX}disabled`, + newMessages: `${PREFIX}newMessages`, + me: `${PREFIX}me`, +} + +const StyledListItemButton = styled(ListItemButton)(({ theme }) => ({ + [`&.${classes.root}`]: { + width: 220, + padding: `3px 16px 3px 16px`, + gap: theme.componentSizes.userListItem.gap, + opacity: 1, + display: 'flex', + backgroundColor: 'inherit', + alignItems: 'center', + }, + [`&:hover`]: { + backgroundColor: theme.palette.colors?.sidebarHover || theme.palette.action.hover, + }, + [`& .${classes.nickname}`]: { + fontWeight: 400, + paddingLeft: 0, + paddingRight: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + maxWidth: 150, + whiteSpace: 'nowrap', + }, + [`& .${classes.itemText}`]: { + margin: 0, + }, + [`&.${classes.selected}`]: { + backgroundColor: theme.palette.colors.sidebarSelected, + }, + [`&.${classes.disabled}`]: { + opacity: '0.3', + pointerEvents: 'none', + cursor: 'not-allowed', + }, + [`& .${classes.newMessages}`]: { + opacity: 1, + fontWeight: 600, + }, + [`& .${classes.me}`]: { + color: theme.palette.colors.gray50, + }, + + [`&.${classes.root}:hover`]: { + backgroundColor: theme.palette.colors.sidebarHover, + }, +})) + +export interface DirectMessageListItemProps { + channel: PublicChannelStorage + me: UserProfile | undefined + userProfiles: Record + userData: DmChannelUserData | undefined + selected: boolean + unread: boolean + setCurrentChannel: (channelId: string) => void +} + +export const DirectMessageListItem: React.FC = ({ + channel, + me, + userProfiles, + userData, + selected, + unread, + setCurrentChannel, +}) => { + const theme = useTheme() + const ref = useRef(null) + + return ( + { + setCurrentChannel(channel.id) + }} + ref={ref} + > + + + + {channel.displayedName} + + {userData != null && me != null && userData.user.userId === me.userId && ( + + me + + )} + + } + classes={{ + primary: classes.primary, + }} + className={classes.itemText} + /> + + ) +} + +export default DirectMessageListItem diff --git a/packages/desktop/src/renderer/components/Sidebar/DirectMessagesPanel/DirectMessagesPanel.tsx b/packages/desktop/src/renderer/components/Sidebar/DirectMessagesPanel/DirectMessagesPanel.tsx index c71fedaacb..ba5c16cd2c 100644 --- a/packages/desktop/src/renderer/components/Sidebar/DirectMessagesPanel/DirectMessagesPanel.tsx +++ b/packages/desktop/src/renderer/components/Sidebar/DirectMessagesPanel/DirectMessagesPanel.tsx @@ -2,57 +2,104 @@ import React from 'react' import Grid from '@mui/material/Grid' import List from '@mui/material/List' import SidebarHeader from '../../ui/Sidebar/SidebarHeader' -import UserProfileListItem from './UserProfileListItem' -import { UserProfile } from '@quiet/types' -import { useContextMenu } from '../../../../hooks/useContextMenu' +import DirectMessageListItem from './DirectMessageListItem' +import { PublicChannelStorage, UserProfile } from '@quiet/types' +import _ from 'lodash' export interface DirectMessagesPanelProps { myUserProfile?: UserProfile userProfiles: Record - userProfileContextMenu: ReturnType + dmChannels: PublicChannelStorage[] + unreadDms: string[] + currentChannelId: string connectedPeers: string[] isTorInitialized: boolean + setCurrentChannel: (channelId: string) => void + openNewMessageWindow: () => void +} + +export interface DmChannelUserData { + connected: boolean | undefined + user: UserProfile +} + +const getUserDataForDmChannel = ( + dmChannel: PublicChannelStorage, + me: UserProfile | undefined, + userProfiles: Record, + connectedPeers: string[] +): DmChannelUserData | undefined => { + if (dmChannel.memberIds == null || me == null) { + return undefined + } + + if (dmChannel.memberIds.length === 1) { + return { + connected: true, + user: me, + } + } + + const notMeId = _.find(dmChannel.memberIds, memberId => memberId != me.userId) + if (notMeId == null) { + return undefined + } + const userThatIsntMe = userProfiles[notMeId] + + if (dmChannel.memberIds.length > 2) { + return { + connected: undefined, + user: userThatIsntMe, + } + } + + const connected = + userThatIsntMe.userData != null && + userThatIsntMe.userData.peerId != null && + connectedPeers.includes(userThatIsntMe.userData.peerId) + return { + connected, + user: userThatIsntMe, + } } const DirectMessagesPanel: React.FC = ({ myUserProfile, userProfiles, - userProfileContextMenu, // TODO: replace with direct message hook once implemented + dmChannels = [], + unreadDms, + currentChannelId, connectedPeers, isTorInitialized, + setCurrentChannel, + openNewMessageWindow, }) => { return ( - - - {myUserProfile && ( - - )} - {Object.values(userProfiles) - .filter(user => !myUserProfile || user.userId !== myUserProfile.userId) - .sort((a, b) => { - const aConnected = - a.userData != null && a.userData.peerId != null && connectedPeers.includes(a.userData.peerId) - const bConnected = - b.userData != null && b.userData.peerId != null && connectedPeers.includes(b.userData.peerId) - if (aConnected === bConnected) { - return a.nickname.localeCompare(b.nickname, undefined, { sensitivity: 'base' }) - } - return aConnected ? -1 : 1 - }) - .map(user => ( - + + {dmChannels.map(channel => { + const userData = getUserDataForDmChannel(channel, myUserProfile, userProfiles, connectedPeers) + const unread = unreadDms.some(unreadDmId => unreadDmId === channel.id) + const selected = currentChannelId === channel.id + return ( + - ))} + ) + })} ) diff --git a/packages/desktop/src/renderer/components/Sidebar/DirectMessagesPanel/UserProfileListItem.tsx b/packages/desktop/src/renderer/components/Sidebar/DirectMessagesPanel/UserProfileListItem.tsx deleted file mode 100644 index 9e549b7c57..0000000000 --- a/packages/desktop/src/renderer/components/Sidebar/DirectMessagesPanel/UserProfileListItem.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React from 'react' -import { styled, useTheme } from '@mui/material/styles' -import classNames from 'classnames' -import { Typography, ListItemButton, Avatar } from '@mui/material' -import Badge from '@mui/material/Badge' -import ListItemText from '@mui/material/ListItemText' -import { UserProfile } from '@quiet/types' -import { useContextMenu } from '../../../../hooks/useContextMenu' -import { MenuName } from '../../../../const/MenuNames.enum' -import { users } from '@quiet/state-manager' -import { useSelector } from 'react-redux' -import ProfilePhoto from '../../ProfilePhoto/ProfilePhoto' - -const PREFIX = 'UserProfileListItem' - -const classes = { - root: `${PREFIX}root`, - avatar: `${PREFIX}avatar`, - primary: `${PREFIX}primary`, - nickname: `${PREFIX}nickname`, - itemText: `${PREFIX}itemText`, -} - -const StyledBadge = styled(Badge)(({ theme }) => ({ - '& .MuiBadge-badge': { - backgroundColor: theme.palette.colors.statusGreen, - color: theme.palette.colors.statusGreen, - width: theme.componentSizes.statusIndicator.size, - height: theme.componentSizes.statusIndicator.size, - minWidth: theme.componentSizes.statusIndicator.size, - minHeight: theme.componentSizes.statusIndicator.size, - borderRadius: '50%', - border: `${theme.componentSizes.statusIndicator.borderWidth}px solid ${ - theme.palette.colors?.sidebarBackground || theme.palette.background.default - }`, - boxSizing: 'border-box', - right: theme.componentSizes.statusIndicator.position.right, - bottom: theme.componentSizes.statusIndicator.position.bottom, - padding: 0, - }, -})) - -const StyledListItemButton = styled(ListItemButton)(({ theme }) => ({ - [`&.${classes.root}`]: { - width: 220, - padding: `3px 16px 3px 16px`, - gap: theme.componentSizes.userListItem.gap, - opacity: 1, - display: 'flex', - backgroundColor: 'inherit', - alignItems: 'center', - }, - [`&:hover`]: { - backgroundColor: theme.palette.colors?.sidebarHover || theme.palette.action.hover, - }, - [`& .${classes.avatar}`]: { - width: theme.componentSizes.avatar.small, - height: theme.componentSizes.avatar.small, - marginRight: 0, - fontSize: 14, - borderRadius: 4, - background: theme.palette.background.paper, - }, - [`& .${classes.nickname}`]: { - opacity: 0.7, - fontWeight: 300, - paddingLeft: 0, - paddingRight: 0, - overflow: 'hidden', - textOverflow: 'ellipsis', - maxWidth: 150, - whiteSpace: 'nowrap', - }, - [`& .${classes.itemText}`]: { - margin: 0, - }, -})) - -export interface UserProfileListItemProps { - userProfile: UserProfile - userProfileContextMenu: ReturnType - connected?: boolean -} - -export const UserProfileListItem: React.FC = ({ - userProfile, - userProfileContextMenu, - connected = false, -}) => { - const theme = useTheme() - - const handleOpenMenu = (event: React.MouseEvent) => { - event.stopPropagation() - userProfileContextMenu.handleOpen({ userProfile }) - } - - return ( - - - - - - - - {userProfile.nickname} - - } - classes={{ - primary: classes.primary, - }} - className={classes.itemText} - /> - - ) -} - -export default UserProfileListItem diff --git a/packages/desktop/src/renderer/components/Sidebar/Sidebar.stories.tsx b/packages/desktop/src/renderer/components/Sidebar/Sidebar.stories.tsx index a876e6ac82..38459e61f0 100644 --- a/packages/desktop/src/renderer/components/Sidebar/Sidebar.stories.tsx +++ b/packages/desktop/src/renderer/components/Sidebar/Sidebar.stories.tsx @@ -12,7 +12,7 @@ import { ChannelsPanelProps } from './ChannelsPanel/ChannelsPanel' import { TorStatusProps } from './TorStatus' import { UserProfilePanelProps } from './UserProfilePanel/UserProfilePanel' import { DirectMessagesPanelProps } from './DirectMessagesPanel/DirectMessagesPanel' -import { CommunityOwnership } from '@quiet/types' +import { ChannelType, CommunityOwnership } from '@quiet/types' const Template: ComponentStory = args => { const [currentChannel, setCurrentChannel] = useState('general') @@ -61,6 +61,7 @@ const args: IdentityPanelProps & owner: 'aliceUserId', timestamp: Date.now(), public: true, + type: ChannelType.CHANNEL, teamId: 'foobar', }, { @@ -70,6 +71,7 @@ const args: IdentityPanelProps & owner: 'bobUserId', timestamp: Date.now(), public: true, + type: ChannelType.CHANNEL, teamId: 'foobar', }, { @@ -79,6 +81,7 @@ const args: IdentityPanelProps & owner: 'charlieUserId', timestamp: Date.now(), public: true, + type: ChannelType.CHANNEL, teamId: 'foobar', }, ], diff --git a/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx b/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx index 2013ce6ce0..eec18f00fd 100644 --- a/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx +++ b/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useModal } from '../../containers/hooks' import { useContextMenu } from '../../../hooks/useContextMenu' @@ -10,6 +10,10 @@ import { IdentityPanelProps } from './IdentityPanel/IdentityPanel' import { UserProfilePanelProps } from './UserProfilePanel/UserProfilePanel' import { MenuName } from '../../../const/MenuNames.enum' import { DirectMessagesPanelProps } from './DirectMessagesPanel/DirectMessagesPanel' +import { createLogger } from '../../logger' +import _ from 'lodash' + +const logger = createLogger('Sidebar') const Sidebar = () => { const dispatch = useDispatch() @@ -22,6 +26,8 @@ const Sidebar = () => { const userProfileSelector = useSelector(users.selectors.userProfiles) const connectedPeers = useSelector(network.selectors.connectedPeers) const unreadChannels = useSelector(publicChannels.selectors.unreadChannels) + const dmChannels = useSelector(publicChannels.selectors.sortedDmChannels) + const unreadDms = useSelector(publicChannels.selectors.unreadDms) const currentCommunity = useSelector(communities.selectors.currentCommunity) const currentChannelId = useSelector(publicChannels.selectors.currentChannelId) const currentIdentity = useSelector(identity.selectors.currentIdentity) @@ -29,12 +35,11 @@ const Sidebar = () => { const canCreateChannel = useSelector(publicChannels.selectors.canCreateChannel) const userId = userProfile?.userId || '' - // Workaround for Redux bug, issue: https://github.com/TryQuiet/quiet/issues/1332 - useSelector(publicChannels.selectors.sortedChannels) const publicChannelsSelector = useSelector(publicChannels.selectors.publicChannels) const isTorInitialized = useSelector(connection.selectors.isTorInitialized) const setCurrentChannel = (id: string) => { + dispatch(publicChannels.actions.setNewMessageOpen({ isOpen: false })) dispatch( publicChannels.actions.setCurrentChannel({ channelId: id, @@ -42,6 +47,10 @@ const Sidebar = () => { ) } + const openNewMessageWindow = () => { + dispatch(publicChannels.actions.setNewMessageOpen({ isOpen: true, prevChannelId: currentChannelId })) + } + if (!currentCommunity || !currentChannelId) { return null } @@ -54,8 +63,8 @@ const Sidebar = () => { const channelsPanelProps: ChannelsPanelProps = { channels: publicChannelsSelector, userProfiles: userProfileSelector, - connectedPeers: connectedPeers, - unreadChannels: unreadChannels, + connectedPeers, + unreadChannels, setCurrentChannel: setCurrentChannel, currentChannelId: currentChannelId, createChannelModal: createChannelModal, @@ -73,9 +82,13 @@ const Sidebar = () => { const directMessagesPanelProps: DirectMessagesPanelProps = { myUserProfile: userProfile, userProfiles: userProfileSelector, - userProfileContextMenu: userProfileContextMenu, + dmChannels, + unreadDms, + currentChannelId, connectedPeers: connectedPeers, isTorInitialized: isTorInitialized, + setCurrentChannel, + openNewMessageWindow, } return ( diff --git a/packages/desktop/src/renderer/components/Sidebar/UserProfilePanel/UserProfilePanel.tsx b/packages/desktop/src/renderer/components/Sidebar/UserProfilePanel/UserProfilePanel.tsx index cedfe21613..d98047b25c 100644 --- a/packages/desktop/src/renderer/components/Sidebar/UserProfilePanel/UserProfilePanel.tsx +++ b/packages/desktop/src/renderer/components/Sidebar/UserProfilePanel/UserProfilePanel.tsx @@ -18,6 +18,7 @@ const classes = { circleWrapper: `${PREFIX}circleWrapper`, circle: `${PREFIX}circle`, nickname: `${PREFIX}nickname`, + buttonContainer: `${PREFIX}buttonContainer`, } const UserProfilePanelButtonStyled = styled('div')(({ theme }) => ({ @@ -54,6 +55,14 @@ const UserProfilePanelButtonStyled = styled('div')(({ theme }) => ({ maxWidth: 215, whiteSpace: 'nowrap', }, + + [`& .${classes.buttonContainer}`]: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + alignContent: 'center', + gap: 8, + }, })) export interface UserProfilePanelProps { @@ -65,7 +74,7 @@ export interface UserProfilePanelProps { export const UserProfilePanel: React.FC = ({ currentIdentity, - userId: userID, + userId, userProfile, userProfileContextMenu, }) => { @@ -87,19 +96,17 @@ export const UserProfilePanel: React.FC = ({ classes={{ root: classes.button }} data-testid={'user-profile-menu-button'} > - - - {username} - + + + + {username} + + ) diff --git a/packages/desktop/src/renderer/components/ui/Sidebar/SidebarHeader.test.tsx b/packages/desktop/src/renderer/components/ui/Sidebar/SidebarHeader.test.tsx index 786ff92870..e2f72cc7a8 100644 --- a/packages/desktop/src/renderer/components/ui/Sidebar/SidebarHeader.test.tsx +++ b/packages/desktop/src/renderer/components/ui/Sidebar/SidebarHeader.test.tsx @@ -6,7 +6,7 @@ import { SidebarHeader } from './SidebarHeader' describe('SidebarHeader', () => { it('renders component', () => { const result = renderComponent( - + ) expect(result.baseElement).toMatchInlineSnapshot(` @@ -29,8 +29,7 @@ describe('SidebarHeader', () => { + +
        @@ -289,7 +323,7 @@ describe('Main', () => { class="MuiGrid-root MuiGrid-item css-13i4rnv-MuiGrid-root" >
        { role="button" tabindex="0" > - Jdenticon -

        +

        + Jdenticon +

        +

        diff --git a/packages/desktop/src/renderer/expanded-theme.ts b/packages/desktop/src/renderer/expanded-theme.ts index 5c3c042782..e6026b6dac 100644 --- a/packages/desktop/src/renderer/expanded-theme.ts +++ b/packages/desktop/src/renderer/expanded-theme.ts @@ -40,6 +40,17 @@ declare module '@mui/material/styles' { bottom: number } } + dmMemberCountIndicator: { + minSize: number + maxSize: number + borderWidth: number + fontSize: number + lineHeight: number + position: { + right: number + bottom: number + } + } userListItem: { gap: number } @@ -60,6 +71,17 @@ declare module '@mui/material/styles' { bottom?: number } } + dmMemberCountIndicator?: { + minSize?: number + maxSize?: number + borderWidth?: number + fontSize?: number + lineHeight?: number + position?: { + right?: number + bottom?: number + } + } userListItem?: { gap?: number } diff --git a/packages/desktop/src/renderer/store/reducers.ts b/packages/desktop/src/renderer/store/reducers.ts index 6cd71c2b67..29e1033160 100644 --- a/packages/desktop/src/renderer/store/reducers.ts +++ b/packages/desktop/src/renderer/store/reducers.ts @@ -1,7 +1,6 @@ import { AnyAction, combineReducers } from '@reduxjs/toolkit' import ElectronStore from 'electron-store' import createElectronStorage from 'redux-persist-electron-storage' -import path from 'path' import { createMigrate, persistReducer } from 'redux-persist' import stateManagerReducers, { diff --git a/packages/desktop/src/renderer/theme.ts b/packages/desktop/src/renderer/theme.ts index fe228cc090..1fb9077724 100644 --- a/packages/desktop/src/renderer/theme.ts +++ b/packages/desktop/src/renderer/theme.ts @@ -389,6 +389,17 @@ const darkTheme = createTheme({ bottom: 2, }, }, + dmMemberCountIndicator: { + minSize: 14, // Total size including border + maxSize: 16, + borderWidth: 2, + fontSize: 9, + lineHeight: 12, + position: { + right: 4, + bottom: 4, + }, + }, userListItem: { gap: 8, }, diff --git a/packages/desktop/src/rtl-tests/app.restart.test.tsx b/packages/desktop/src/rtl-tests/app.restart.test.tsx index ed6e3afe69..a6e4e801ce 100644 --- a/packages/desktop/src/rtl-tests/app.restart.test.tsx +++ b/packages/desktop/src/rtl-tests/app.restart.test.tsx @@ -16,7 +16,7 @@ import { ioMock } from '../shared/setupTests' import { communities, getReduxStoreFactory, network, publicChannels } from '@quiet/state-manager' import { act } from 'react-dom/test-utils' import { identityActions } from 'packages/state-manager/src/sagas/identity/identity.slice' -import { LoadingPanelType } from '@quiet/types' +import { ChannelType, LoadingPanelType } from '@quiet/types' import { socketActions } from '../renderer/sagas/socket/socket.slice' jest.setTimeout(20_000) @@ -78,6 +78,7 @@ describe('Restart app works correctly', () => { publicChannels.actions.sendInitialChannelMessage({ channelId: generalId, channelName: 'general', + type: ChannelType.CHANNEL, }) ) }) diff --git a/packages/desktop/src/rtl-tests/channel.add.test.tsx b/packages/desktop/src/rtl-tests/channel.add.test.tsx index 6bb3b75e4e..067eec0a50 100644 --- a/packages/desktop/src/rtl-tests/channel.add.test.tsx +++ b/packages/desktop/src/rtl-tests/channel.add.test.tsx @@ -14,7 +14,15 @@ import Channel from '../renderer/components/Channel/Channel' import Sidebar from '../renderer/components/Sidebar/Sidebar' import { getReduxStoreFactory, getSocketFactory, publicChannels } from '@quiet/state-manager' -import { Community, CreateChannelPayload, Identity, SendMessagePayload, SocketActions, UserProfile } from '@quiet/types' +import { + ChannelType, + Community, + CreateChannelPayload, + Identity, + SendMessagePayload, + SocketActions, + UserProfile, +} from '@quiet/types' import { ModalsInitialState } from '../renderer/sagas/modals/modals.slice' import { ModalName } from '../renderer/sagas/modals/modals.types' @@ -115,6 +123,7 @@ describe('Add new channel', () => { owner: userProfile.nickname, timestamp: 0, public: payload.public, + type: payload.type, }, }) return socketFactory.build(`${SocketActions.CREATE_CHANNEL}_response`, { @@ -226,7 +235,9 @@ describe('Add new channel', () => { owner: userProfile.nickname, timestamp: 0, public: payload.public ?? true, + type: ChannelType.CHANNEL, }, + displayedName: payload.name, }) return socketFactory.build(`${SocketActions.CREATE_CHANNEL}_response`, { channel: { @@ -236,7 +247,127 @@ describe('Add new channel', () => { owner: userProfile.nickname, timestamp: 0, public: payload.public ?? true, + type: ChannelType.CHANNEL, }, + displayedName: payload.name, + }) + } + if (action === SocketActions.SEND_MESSAGE) { + const data = input[1] as SendMessagePayload + const { message } = data + factory.create('TestMessage', { + message: { + ...message, + }, + }) + } + } + + jest.spyOn(socket, 'emit').mockImplementation(mockImpl) + // @ts-ignore + socket.emitWithAck = mockImpl + + window.HTMLElement.prototype.scrollTo = jest.fn() + + renderComponent( + <> + + + + , + store + ) + const user = userEvent.setup() + const input = screen.getByPlaceholderText('Enter a channel name') + await user.type(input, channelName.input) + + const privateToggle = screen.getByTestId('createChannel-private-form-control-toggle') + expect(privateToggle).toBeVisible() + expect(privateToggle.className.includes('checked')).toBeFalsy() + + await userEvent.click(privateToggle) + expect(privateToggle.className.includes('checked')).toBeTruthy() + + // FIXME: await user.click(screen.getByText('Create Channel') causes this and few other tests to fail (hangs on taking createChannel action) + await act( + async () => + await waitFor(() => { + user.click(screen.getByText('Create Channel')).catch(e => { + logger.error(e) + }) + }) + ) + + function* testCreateChannelSaga(): Generator { + const createChannelAction = yield* take(publicChannels.actions.createChannel) + const addChannelAction = yield* take(publicChannels.actions.addChannel) + } + + await act(async () => { + await runSaga(testCreateChannelSaga).toPromise() + }) + + const createChannelModal = screen.queryByTestId('createChannelModal') + expect(createChannelModal).toBeNull() + + // Check if newly created channel is present and selected + expect(screen.getByTestId('channelTitle')).toHaveTextContent(channelName.output) + expect(screen.getByTestId('channelTitle-icon-private')).toBeVisible() + // Check if sidebar item displays as selected + const link = screen.getByTestId(`${channelName.output}-link`) + expect(link).toHaveClass('ChannelsListItemselected') + const linkIcon = screen.getByTestId(`${channelName.output}-channel-link-icon-public`) + expect(linkIcon).toBeVisible() + }) + + it.only('Adds new private channel and opens it. Sends initial message', async () => { + const { store, runSaga } = await prepareStore( + { + [StoreKeys.Modals]: { + ...new ModalsInitialState(), + [ModalName.createChannel]: { open: true }, + }, + }, + socket // Fork state manager's sagas + ) + + const factory = await getReduxStoreFactory(store) + const community: Community = await factory.create('Community') + const userProfile: UserProfile = await factory.create('UserProfile', { + nickname: 'alice', + }) + const alice: Identity = await factory.create('Identity', { + userId: userProfile.userId, + communityId: community.id, + }) + const channelName = { input: 'my-Super Channel ', output: 'my-super-channel-' } + + const mockImpl = async (...input: [string, ...any]) => { + const action = input[0] + if (action === SocketActions.CREATE_CHANNEL) { + const payload = input[1] as CreateChannelPayload + factory.create('PublicChannel', { + channel: { + id: payload.id, + name: payload.name, + description: payload.description ?? '', + owner: userProfile.nickname, + timestamp: 0, + public: payload.public ?? true, + type: payload.type, + }, + displayedName: payload.name, + }) + return socketFactory.build(`${SocketActions.CREATE_CHANNEL}_response`, { + channel: { + id: payload.id, + name: payload.name, + description: payload.description ?? '', + owner: userProfile.nickname, + timestamp: 0, + public: payload.public ?? true, + }, + displayedName: payload.name, }) } if (action === SocketActions.SEND_MESSAGE) { @@ -468,6 +599,7 @@ describe('Add new channel', () => { owner: 'alice', timestamp: 0, public: true, + type: payload.type, teamId: payload.teamId, }, }) @@ -592,6 +724,7 @@ describe('Add new channel', () => { owner: 'alice', timestamp: 0, public: payload.public, + type: payload.type, teamId: community.teamId, }, }) diff --git a/packages/desktop/src/rtl-tests/channel.main.test.tsx b/packages/desktop/src/rtl-tests/channel.main.test.tsx index 99dfcacd00..ea4ea7fe28 100644 --- a/packages/desktop/src/rtl-tests/channel.main.test.tsx +++ b/packages/desktop/src/rtl-tests/channel.main.test.tsx @@ -848,10 +848,10 @@ describe('PublicChannel', () => { expect(actions).toMatchInlineSnapshot(` Array [ - "Messages/lazyLoading", "Messages/resetCurrentPublicChannelCache", "Messages/retryVerification", "Messages/verifyMessages", + "Messages/lazyLoading", "Messages/resetCurrentPublicChannelCache", "Messages/retryVerification", "Messages/verifyMessages", @@ -1009,12 +1009,12 @@ describe('PublicChannel', () => { "Communities/setCurrentCommunity", "Files/checkForMissingFiles", "Network/addInitializedCommunity", - "Messages/lazyLoading", "Messages/resetCurrentPublicChannelCache", "Messages/retryVerification", "Messages/verifyMessages", "PublicChannels/cacheMessages", "Messages/setDisplayedMessagesNumber", + "Messages/lazyLoading", "Messages/resetCurrentPublicChannelCache", "Messages/retryVerification", "Messages/verifyMessages", @@ -1109,10 +1109,10 @@ describe('PublicChannel', () => { expect(actions).toMatchInlineSnapshot(` Array [ - "Messages/lazyLoading", "Messages/resetCurrentPublicChannelCache", "Messages/retryVerification", "Messages/verifyMessages", + "Messages/lazyLoading", "Messages/resetCurrentPublicChannelCache", "Messages/retryVerification", "Messages/verifyMessages", @@ -1229,10 +1229,10 @@ describe('PublicChannel', () => { expect(actions).toMatchInlineSnapshot(` Array [ - "Messages/lazyLoading", "Messages/resetCurrentPublicChannelCache", "Messages/retryVerification", "Messages/verifyMessages", + "Messages/lazyLoading", "Messages/resetCurrentPublicChannelCache", "Messages/retryVerification", "Messages/verifyMessages", @@ -1344,10 +1344,10 @@ describe('PublicChannel', () => { expect(actions).toMatchInlineSnapshot(` Array [ - "Messages/lazyLoading", "Messages/resetCurrentPublicChannelCache", "Messages/retryVerification", "Messages/verifyMessages", + "Messages/lazyLoading", "Messages/resetCurrentPublicChannelCache", "Messages/retryVerification", "Messages/verifyMessages", @@ -1493,10 +1493,10 @@ describe('PublicChannel', () => { expect(actions).toMatchInlineSnapshot(` Array [ - "Messages/lazyLoading", "Messages/resetCurrentPublicChannelCache", "Messages/retryVerification", "Messages/verifyMessages", + "Messages/lazyLoading", "Messages/resetCurrentPublicChannelCache", "Messages/retryVerification", "Messages/verifyMessages", diff --git a/packages/desktop/src/rtl-tests/channel.newDirectMessage.test.tsx b/packages/desktop/src/rtl-tests/channel.newDirectMessage.test.tsx new file mode 100644 index 0000000000..5f359db2a4 --- /dev/null +++ b/packages/desktop/src/rtl-tests/channel.newDirectMessage.test.tsx @@ -0,0 +1,394 @@ +import React from 'react' +import '@testing-library/jest-dom/extend-expect' +import { act } from 'react-dom/test-utils' +import { screen } from '@testing-library/dom' +import userEvent from '@testing-library/user-event' +import MockedSocket from 'socket.io-mock' +import { ioMock } from '../shared/setupTests' +import { ChannelType, PublicChannel, UserProfile } from '@quiet/types' +import { renderComponent } from '../renderer/testUtils/renderComponent' +import { prepareStore } from '../renderer/testUtils/prepareStore' +import Channel from '../renderer/components/Channel/Channel' +import { publicChannels, getReduxStoreFactory, messages } from '@quiet/state-manager' + +import { FETCHING_CHANNEL_MESSAGES } from '../renderer/components/widgets/channels/ChannelMessages' +import { cleanup } from '@testing-library/react' +import { SEARCH_PLACEHOLDER_TEXT } from '../renderer/components/Channel/NewDirectMessage.component' +import { generateDmChannelId } from '@quiet/common' + +jest.setTimeout(20_000) + +describe('New Direct Message', () => { + let socket: MockedSocket + let notification: any + + beforeEach(() => { + notification = jest.fn().mockImplementation(() => { + return jest.fn() + }) + window.Notification = notification + jest.mock('electron', () => { + return { + ipcRenderer: { on: () => {}, send: jest.fn(), sendSync: jest.fn() }, + remote: { + BrowserWindow: { + getAllWindows: () => { + return [ + { + show: jest.fn(), + isFocused: jest.fn(), + }, + ] + }, + }, + }, + } + }) + + jest.mock('../shared/sounds', () => ({ + ...jest.requireActual('../shared/sounds'), + soundTypeToAudio: { + pow: { + play: jest.fn(), + }, + }, + })) + + socket = new MockedSocket() + ioMock.mockImplementation(() => socket) + window.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + })) + }) + afterEach(() => { + cleanup() + }) + + it("causes no error if there's no data yet", async () => { + const { store } = await prepareStore( + {}, + socket // Fork state manager's sagas + ) + + window.HTMLElement.prototype.scrollTo = jest.fn() + + renderComponent( + <> + + , + store + ) + }) + + it(`doesn't display before opened`, async () => { + const { store } = await prepareStore( + {}, + socket // Fork state manager's sagas + ) + + const factory = await getReduxStoreFactory(store) + + const nickname = 'alice' + const alice = await factory.create('Identity', { userId: 'alice123' }) + const alicesUserProfile: UserProfile = await factory.create('UserProfile', { + userId: alice.userId, + nickname: nickname, + }) + const alicesUser = await factory.create('User', { userId: alice.userId }) + const publicChannel = await factory.create('PublicChannel') + window.HTMLElement.prototype.scrollTo = jest.fn() + + renderComponent( + <> + + , + store + ) + + const channelName = screen.getByText('general') + expect(channelName).toBeVisible() + + const messageInput = screen.getByPlaceholderText(`Message #general as @${nickname}`) + expect(messageInput).toBeVisible() + + expect(() => screen.getByText('New message')).toThrow() + expect(() => screen.getByPlaceholderText(SEARCH_PLACEHOLDER_TEXT)).toThrow() + }) + + it('displays properly on open', async () => { + const { store } = await prepareStore( + {}, + socket // Fork state manager's sagas + ) + + const factory = await getReduxStoreFactory(store) + + const nickname = 'alice' + const alice = await factory.create('Identity', { userId: 'alice123' }) + const alicesUserProfile: UserProfile = await factory.create('UserProfile', { + userId: alice.userId, + nickname: nickname, + }) + const alicesUser = await factory.create('User', { userId: alice.userId }) + const publicChannel = await factory.create('PublicChannel') + window.HTMLElement.prototype.scrollTo = jest.fn() + + renderComponent( + <> + + , + store + ) + + await act(async () => { + store.dispatch( + publicChannels.actions.setNewMessageOpen({ + isOpen: true, + prevChannelId: publicChannel.id, + }) + ) + }) + + const headerTitle = screen.getByText('New message') + expect(headerTitle).toBeVisible() + + const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER_TEXT) + expect(searchInput).toBeVisible() + + const messageInput = screen.getByTestId('messageInput') + expect(messageInput).toBeVisible() + + expect(() => screen.getByText(FETCHING_CHANNEL_MESSAGES)).toThrow() + }) + + it('switches to inputtable when user selected and no channel exists', async () => { + const { store } = await prepareStore( + {}, + socket // Fork state manager's sagas + ) + + const factory = await getReduxStoreFactory(store) + + const nickname = 'alice' + const alice = await factory.create('Identity', { userId: 'alice123' }) + const alicesUserProfile: UserProfile = await factory.create('UserProfile', { + userId: alice.userId, + nickname: nickname, + }) + const suesUserProfile: UserProfile = await factory.create('UserProfile', { + nickname: 'sue', + }) + const alicesUser = await factory.create('User', { userId: alice.userId }) + const publicChannel = await factory.create('PublicChannel') + + window.HTMLElement.prototype.scrollTo = jest.fn() + + renderComponent( + <> + + , + store + ) + + await act(async () => { + store.dispatch( + publicChannels.actions.setNewMessageOpen({ + isOpen: true, + prevChannelId: publicChannel.id, + }) + ) + }) + + const headerTitle = screen.getByText('New message') + expect(headerTitle).toBeVisible() + + const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER_TEXT) + expect(searchInput).toBeVisible() + + let messageInput = screen.getByTestId('messageInput') + expect(messageInput).toBeVisible() + + expect(() => screen.getByText(FETCHING_CHANNEL_MESSAGES)).toThrow() + + await userEvent.type(searchInput, suesUserProfile.nickname) + await userEvent.type(searchInput, '{enter}') + + const aliceTag = screen.getByText(suesUserProfile.nickname) + expect(aliceTag).toBeVisible() + + messageInput = screen.getByPlaceholderText(`Message ${suesUserProfile.nickname} as @${alicesUserProfile.nickname}`) + expect(messageInput).toBeVisible() + }) + + it('shows messages when dm channel already exists', async () => { + const { store } = await prepareStore( + {}, + socket // Fork state manager's sagas + ) + + const factory = await getReduxStoreFactory(store) + + const nickname = 'alice' + const alice = await factory.create('Identity', { userId: 'alice123' }) + const alicesUserProfile: UserProfile = await factory.create('UserProfile', { + userId: alice.userId, + nickname: nickname, + }) + const suesUserProfile: UserProfile = await factory.create('UserProfile', { + nickname: 'sue', + }) + const alicesUser = await factory.create('User', { userId: alice.userId }) + const publicChannel = await factory.create('PublicChannel') + + const dmChannel = await factory.create('PublicChannel', { + channel: { + type: ChannelType.DM, + memberIds: [alicesUserProfile.userId, suesUserProfile.userId], + id: generateDmChannelId([alicesUserProfile.userId, suesUserProfile.userId]), + name: generateDmChannelId([alicesUserProfile.userId, suesUserProfile.userId]), + }, + displayedName: 'sue', + }) + window.HTMLElement.prototype.scrollTo = jest.fn() + + const messageText = 'hello!' + + renderComponent( + <> + + , + store + ) + + await act(async () => { + store.dispatch( + publicChannels.actions.setNewMessageOpen({ + isOpen: true, + prevChannelId: publicChannel.id, + }) + ) + }) + + const headerTitle = screen.getByText('New message') + expect(headerTitle).toBeVisible() + + const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER_TEXT) + expect(searchInput).toBeVisible() + + let messageInput = screen.getByTestId('messageInput') + expect(messageInput).toBeVisible() + + expect(() => screen.getByText(FETCHING_CHANNEL_MESSAGES)).toThrow() + + await act(async () => { + await userEvent.type(searchInput, suesUserProfile.nickname) + await userEvent.type(searchInput, '{enter}') + }) + + const aliceTag = screen.getByText(suesUserProfile.nickname) + expect(aliceTag).toBeVisible() + + messageInput = screen.getByPlaceholderText(`Message ${suesUserProfile.nickname} as @${alicesUserProfile.nickname}`) + expect(messageInput).toBeVisible() + + await act(async () => { + store.dispatch(messages.actions.sendMessage({ message: messageText })) + }) + + // Get sent message for further assertions + const sentMessage = publicChannels.selectors.currentChannelMessages(store.getState())[0] + + // Confirm message has been stored immediately + const displayableMessages = publicChannels.selectors.currentChannelMessagesMergedBySender(store.getState()) + expect(Object.values(displayableMessages).length).toBe(1) + + expect(await screen.findByText(messageText)).toBeVisible() + }) + + it('switches to dm channel when message sent', async () => { + const { store } = await prepareStore( + {}, + socket // Fork state manager's sagas + ) + + const factory = await getReduxStoreFactory(store) + + const nickname = 'alice' + const alice = await factory.create('Identity', { userId: 'alice123' }) + const alicesUserProfile: UserProfile = await factory.create('UserProfile', { + userId: alice.userId, + nickname: nickname, + }) + const suesUserProfile: UserProfile = await factory.create('UserProfile', { + nickname: 'sue', + }) + const alicesUser = await factory.create('User', { userId: alice.userId }) + const publicChannel = await factory.create('PublicChannel') + const dmChannel = await factory.create('PublicChannel', { + channel: { + type: ChannelType.DM, + memberIds: [alicesUserProfile.userId, suesUserProfile.userId], + id: generateDmChannelId([alicesUserProfile.userId, suesUserProfile.userId]), + name: generateDmChannelId([alicesUserProfile.userId, suesUserProfile.userId]), + }, + displayedName: 'sue', + }) + + window.HTMLElement.prototype.scrollTo = jest.fn() + + renderComponent( + <> + + , + store + ) + + await act(async () => { + store.dispatch( + publicChannels.actions.setNewMessageOpen({ + isOpen: true, + prevChannelId: publicChannel.id, + }) + ) + }) + + const headerTitle = screen.getByText('New message') + expect(headerTitle).toBeVisible() + + const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER_TEXT) + expect(searchInput).toBeVisible() + + let messageInput = screen.getByTestId('messageInput') + expect(messageInput).toBeVisible() + + expect(() => screen.getByText(FETCHING_CHANNEL_MESSAGES)).toThrow() + + await userEvent.type(searchInput, suesUserProfile.nickname) + await userEvent.type(searchInput, '{enter}') + + const aliceTag = screen.getByText(suesUserProfile.nickname) + expect(aliceTag).toBeVisible() + + messageInput = screen.getByPlaceholderText(`Message ${suesUserProfile.nickname} as @${alicesUserProfile.nickname}`) + expect(messageInput).toBeVisible() + + const messageText = 'hello!' + await act(async () => { + await userEvent.type(messageInput, messageText) + await userEvent.type(messageInput, '{enter}') + }) + + const channelName = screen.getByText(suesUserProfile.nickname) + expect(channelName).toBeVisible() + + messageInput = screen.getByPlaceholderText(`Message ${suesUserProfile.nickname} as @${alicesUserProfile.nickname}`) + expect(messageInput).toBeVisible() + + expect(() => screen.getByText('New message')).toThrow() + expect(() => screen.getByPlaceholderText(SEARCH_PLACEHOLDER_TEXT)).toThrow() + + expect(await screen.findByText(messageText)).toBeVisible() + }) +}) diff --git a/packages/desktop/src/rtl-tests/channel.switch.test.tsx b/packages/desktop/src/rtl-tests/channel.switch.test.tsx index 8ddeba6cdc..d5174119b4 100644 --- a/packages/desktop/src/rtl-tests/channel.switch.test.tsx +++ b/packages/desktop/src/rtl-tests/channel.switch.test.tsx @@ -21,7 +21,7 @@ import { messages, generateMessageFactoryContentWithId, } from '@quiet/state-manager' -import { Identity, MessageType, ChannelMessage, SocketEvents } from '@quiet/types' +import { Identity, MessageType, ChannelMessage, SocketEvents, ChannelType } from '@quiet/types' import { DateTime } from 'luxon' @@ -94,7 +94,9 @@ describe('Switch channels', () => { owner: alice.userId, id: name, public: isPublic, + type: ChannelType.CHANNEL, }, + displayedName: name, }) } }) diff --git a/packages/desktop/src/rtl-tests/community.create.test.tsx b/packages/desktop/src/rtl-tests/community.create.test.tsx index dfd4fb3f71..6b7bba304c 100644 --- a/packages/desktop/src/rtl-tests/community.create.test.tsx +++ b/packages/desktop/src/rtl-tests/community.create.test.tsx @@ -154,10 +154,6 @@ describe('User', () => { "Connection/createInvite", "PublicChannels/clearUnreadChannel", "Modals/closeModal", - "Messages/lazyLoading", - "Messages/resetCurrentPublicChannelCache", - "Messages/retryVerification", - "Messages/verifyMessages", "Messages/resetCurrentPublicChannelCache", "Messages/retryVerification", "Messages/verifyMessages", diff --git a/packages/desktop/src/rtl-tests/community.join.test.tsx b/packages/desktop/src/rtl-tests/community.join.test.tsx index 3724778fe6..4988f71625 100644 --- a/packages/desktop/src/rtl-tests/community.join.test.tsx +++ b/packages/desktop/src/rtl-tests/community.join.test.tsx @@ -31,6 +31,7 @@ import { ErrorMessages, ResponseJoinCommunityPayload, CommunityOwnership, + ChannelType, } from '@quiet/types' import { composeInvitationShareUrl, getValidInvitationUrlTestData, validInvitationDatav3 } from '@quiet/common' @@ -60,6 +61,7 @@ const makeMockEmitImpl = (socket: MockedSocket, opts?: { qss?: boolean }) => { timestamp: 0, id: 'general', public: true, + type: ChannelType.CHANNEL, teamId: 'foobar', }, ], @@ -238,6 +240,7 @@ describe('User', () => { "Identity/registerUsername", "Identity/setUsername", "PublicChannels/channelsReplicated", + "PublicChannels/syncChannelDisplayNames", "PublicChannels/setChannelSubscribed", "PublicChannels/addChannel", "Messages/addPublicChannelsMessagesBase", @@ -250,16 +253,16 @@ describe('User', () => { "Users/setUserProfile", "Communities/launchCommunity", "Communities/clearInvitationCodes", - "Messages/lazyLoading", - "Messages/resetCurrentPublicChannelCache", - "Messages/retryVerification", - "Messages/verifyMessages", "Messages/resetCurrentPublicChannelCache", "Messages/retryVerification", "Messages/verifyMessages", "Communities/setCurrentCommunity", "Files/checkForMissingFiles", "Network/addInitializedCommunity", + "Messages/lazyLoading", + "Messages/resetCurrentPublicChannelCache", + "Messages/retryVerification", + "Messages/verifyMessages", ] `) }) diff --git a/packages/desktop/src/rtl-tests/searchModal.test.tsx b/packages/desktop/src/rtl-tests/searchModal.test.tsx index 82730c18c2..839db5960b 100644 --- a/packages/desktop/src/rtl-tests/searchModal.test.tsx +++ b/packages/desktop/src/rtl-tests/searchModal.test.tsx @@ -16,7 +16,15 @@ import { getReduxStoreFactory, identity, publicChannels, communities, Store } fr import SearchModal from '../renderer/components/SearchModal/SearchModal' import { modalsActions } from '../renderer/sagas/modals/modals.slice' import { ModalName } from '../renderer/sagas/modals/modals.types' -import { type Community, SocketActions, SocketEvents, Identity, MessageType, ChannelMessage } from '@quiet/types' +import { + type Community, + SocketActions, + SocketEvents, + Identity, + MessageType, + ChannelMessage, + ChannelType, +} from '@quiet/types' jest.setTimeout(20_000) @@ -85,6 +93,8 @@ describe('Switch channels', () => { timestamp: channelMock.timestamp, owner: alice.userId, id: channelMock.name, + public: true, + type: ChannelType.CHANNEL, }, }) } diff --git a/packages/e2e-tests/src/enums.ts b/packages/e2e-tests/src/enums.ts index e0c08925f1..eea09fddca 100644 --- a/packages/e2e-tests/src/enums.ts +++ b/packages/e2e-tests/src/enums.ts @@ -24,6 +24,7 @@ export enum SettingsModalTabName { QR_CODE = 'qr-code', LEAVE_COMMUNITY = 'leave-community', DEBUG = 'debug', + COMMUNITY_MEMBERSHIP = 'community-membership', } export enum PhotoExt { diff --git a/packages/e2e-tests/src/selectors.ts b/packages/e2e-tests/src/selectors.ts index 0199c5191c..175594878b 100644 --- a/packages/e2e-tests/src/selectors.ts +++ b/packages/e2e-tests/src/selectors.ts @@ -1,11 +1,23 @@ -import { By, Key, type ThenableWebDriver, type WebElement, until, WebElementPromise } from 'selenium-webdriver' -import { BuildSetup, logAndReturnError, promiseWithRetries, sleep, type BuildSetupInit } from './utils' +import { execSync } from 'child_process' +import { DateTime } from 'luxon' import path from 'path' +import { By, Key, type ThenableWebDriver, type WebElement, until } from 'selenium-webdriver' + +import { BuildSetup, logAndReturnError, promiseWithRetries, sleep, type BuildSetupInit } from './utils' import { FileDownloadStatus, PhotoExt, SettingsModalTabName, FileAttachmentType, X_DATA_TESTID } from './enums' -import { MessageIds, RetryConfig, UserListItem, UserListStatus } from './types' +import { + CreatedDM, + DEFAULT_ADD_NEW_CHANNEL_OPTIONS, + MessageIds, + NewMessageDM, + RetryConfig, + TestAddNewChannelOptions, + TestChannelType, + TestNewChannelResult, + UserListItem, + UserListStatus, +} from './types' import { createLogger } from './logger' -import { DateTime } from 'luxon' -import { execSync } from 'child_process' const logger = createLogger('selectors') @@ -47,7 +59,7 @@ export class App { this.thenableWebDriver = this.buildSetup.getDriver() await this.driver.getSession() const startingPanel = new StartingLoadingPanel(this.driver) - const startingPanelLoaded = startingPanel.waitForLoadingToComplete() + const startingPanelLoaded = startingPanel.waitForLoadingToComplete(15_000, 45_000) await startingPanelLoaded this.watchForLaunchModals() } @@ -532,7 +544,7 @@ export class JoiningLoadingPanel { } } -export class UsersList { +export class DirectMessageList { private readonly driver: ThenableWebDriver constructor(driver: ThenableWebDriver) { this.driver = driver @@ -579,6 +591,7 @@ export class UsersList { return { element: undefined, status, + textMatches: true, } } @@ -618,6 +631,7 @@ export class UsersList { return { element: userItem, status, + textMatches: true, } } } @@ -1406,7 +1420,11 @@ export class Channel { return true } - async isOpen(isPublic: boolean = true, expectChannelTypeIcon: boolean = true, timeout = 15_000): Promise { + async isOpen( + channelType: TestChannelType = TestChannelType.PUBLIC_CHANNEL, + expectHeaderIcon: boolean = true, + timeout = 15_000 + ): Promise { const titleElement = await this.driver.wait( until.elementIsVisible(await this.title), timeout, @@ -1414,18 +1432,19 @@ export class Channel { 500 ) - let titleText = this.name - if (expectChannelTypeIcon) { - await this.driver.wait( - until.elementIsVisible(await (isPublic ? this.hash : this.lock)), - timeout, - `Channel title type icon element for ${this.name} couldn't be seen within timeout`, - 500 - ) - } else { - titleText = `# ${this.name}` + if (expectHeaderIcon) { + if (channelType === TestChannelType.DM) { + // TODO: Add logic for validating DM profile photo in header + } else { + await this.driver.wait( + until.elementIsVisible(await (channelType === TestChannelType.PUBLIC_CHANNEL ? this.hash : this.lock)), + timeout, + `Channel title type icon element for ${this.name} couldn't be seen within timeout`, + 500 + ) + } } - return (await titleElement.getText()) === titleText + return (await titleElement.getText()) === this.name } async isMessageInputReady(): Promise { @@ -2095,6 +2114,248 @@ export class Channel { } } +export class NewMessage { + private readonly driver: ThenableWebDriver + constructor(driver: ThenableWebDriver) { + this.driver = driver + } + + get newMessageButton() { + return this.driver.wait( + until.elementLocated(By.xpath(`//*[@data-testid="sidebar-button-createNewMessage"]`)), + 60_000, + `New message button couldn't be found within timeout`, + 500 + ) + } + + get title() { + return this.driver.wait( + until.elementLocated(By.xpath(`//*[@data-testid='new-message-header-title']`)), + 10_000, + `New message title element couldn't be found within timeout`, + 500 + ) + } + + get searchInput() { + return this.driver.wait( + until.elementLocated(By.xpath(`//input[@aria-autocomplete="list"]`)), + 15_000, + `User search input for new message view couldn't be found within timeout`, + 500 + ) + } + + get messagesList() { + return this.driver.wait( + until.elementLocated(By.xpath('//ul[@id="messages-scroll"]')), + 10_000, + `New message message list element couldn't be found within timeout`, + 500 + ) + } + + get messageInput() { + return this.driver.wait( + until.elementLocated(By.xpath('//*[@data-testid="messageInput"]')), + 15_000, + `Message input for new message view couldn't be found within timeout`, + 500 + ) + } + + get closeButton() { + return this.driver.wait( + until.elementLocated(By.xpath('//*[@data-testid="new-message-close-button"]')), + 15_000, + `Message input for new message view couldn't be found within timeout`, + 500 + ) + } + + get uploadFileInput() { + return this.driver.wait( + until.elementLocated(By.xpath('//*[@data-testid="uploadFileInput"]')), + 15_000, + `File attachment button for new message view couldn't be found within timeout`, + 500 + ) + } + + async isNewMessageButtonReady(timeoutMs = 15_000): Promise { + await this.driver.wait( + until.elementIsVisible(this.newMessageButton), + timeoutMs, + `New message button wasn't ready within timeout`, + 500 + ) + return true + } + + async isOpen(timeout = 15_000): Promise { + await this.driver.wait( + until.elementIsVisible(await this.title), + timeout, + `New message header title element couldn't be seen within timeout`, + 500 + ) + + return true + } + + async isMessageInputReady(): Promise { + await this.driver.wait( + until.elementIsVisible(this.messageInput), + 15_000, + `Message input element for new message view couldn't be seen within timeout`, + 500 + ) + await this.driver.wait( + until.elementIsEnabled(this.messageInput), + 15_000, + `Message input element for new message view wasn't enabled within timeout`, + 500 + ) + return true + } + + async isUserSearchInputReady(): Promise { + await this.driver.wait( + until.elementIsVisible(this.searchInput), + 15_000, + `User search input element for new message view couldn't be seen within timeout`, + 500 + ) + await this.driver.wait( + until.elementIsEnabled(this.searchInput), + 15_000, + `User search input element for new message view wasn't enabled within timeout`, + 500 + ) + return true + } + + async open(): Promise { + await this.isNewMessageButtonReady() + const button = await this.newMessageButton + await button.click() + await this.isOpen() + } + + async getUserSearchChip(username: string): Promise { + const chipElement = await this.driver.wait( + until.elementLocated(By.xpath(`//span[text()="${username}" and contains(@class, 'MuiChip-label')]`)), + 5_000, + `User search chip for ${username} couldn't be found within timeout`, + 500 + ) + + await this.driver.wait( + until.elementIsVisible(chipElement), + 5_000, + `User search chip for ${username} wasn't visible within timeout`, + 500 + ) + + return chipElement + } + + async changeDmUsers(usernames: string[]): Promise { + const successfulUsers: string[] = [] + const failedUsers: string[] = [] + const searchInput = await this.searchInput + await this.isUserSearchInputReady() + for (const username of usernames) { + logger.warn(`Searching for ${username} in new message search input`) + try { + await searchInput.sendKeys(username) + await searchInput.sendKeys(Key.ENTER) + await this.getUserSearchChip(username) + successfulUsers.push(username) + } catch (e) { + logger.error(`Failed to find user chip for ${username}`, e) + failedUsers.push(username) + } + } + + if (successfulUsers.length === 0) { + logger.error('No successful attempts at adding users to DM') + } + return { + successfulUsers, + failedUsers, + } + } + + async createNewDm(usernames: string[], firstMessage: string): Promise { + logger.info('Creating new DM', usernames, firstMessage) + const { successfulUsers, failedUsers } = await this.changeDmUsers(usernames) + let messageInput: WebElement + try { + logger.info('Checking for message input') + messageInput = await this.messageInput + await this.isMessageInputReady() + } catch (e) { + logger.error(`Failed to find message input on new message view`, e) + return { + successfulUsers, + failedUsers, + success: false, + error: e, + } + } + + logger.info('Waiting...') + await sleep(5_000) + + logger.info('Entering and sending message') + await messageInput.sendKeys(firstMessage) + await messageInput.sendKeys(Key.ENTER) + + let titleElement: WebElement | undefined = undefined + try { + logger.info('Waiting for new message view to disappear') + titleElement = await this.title + if (titleElement) { + await this.driver.wait( + until.elementIsNotVisible(titleElement), + 15_000, + `New message title element was still visible after sending message`, + 500 + ) + } + } catch (e) { + if (!(e as Error).message.includes(`New message title element couldn't be found within timeout`)) { + logger.error(`Failed to create new DM`, e) + return { + successfulUsers, + failedUsers, + success: false, + error: e, + } + } + } + + return { + successfulUsers, + failedUsers, + success: true, + } + } + + async close(): Promise { + const button = await this.closeButton + await this.driver.wait( + until.elementIsVisible(button), + 5_000, + `Couldn't see the new message close button within the timeout`, + 500 + ) + await button.click() + } +} + export class Sidebar { private readonly driver: ThenableWebDriver constructor(driver: ThenableWebDriver) { @@ -2144,7 +2405,7 @@ export class Sidebar { /** * Get channel link elements in the sidebar */ - async getChannelList(): Promise { + private async getChannelList(): Promise { // We use a more generic XPath and then filter out user links to handle backwards compatibility const channels = await this.driver.wait( this.driver.findElements(By.xpath('//*[contains(@data-testid, "-link-text")]')), @@ -2169,36 +2430,50 @@ export class Sidebar { } /** - * Get user profile link elements in the sidebar + * Get names of all channels in the sidebar */ - async getUserProfileList(): Promise { - const userProfileList = await this.driver.wait( - this.driver.findElements(By.xpath('//*[contains(@data-testid, "user-link-text")]')), - 15_000, - `Sidebar user profile list couldn't be found within timeout`, + async getChannelsNames(): Promise { + const elements = await this.getChannelList() + return Promise.all( + elements.map(async element => { + return await element.getText() + }) + ) + } + + async waitForChannelsNum(num: number, timeoutMs: number = 15_000): Promise { + logger.info(`Waiting for ${num} channels`) + return this.driver.wait( + async () => { + const channels = await this.getChannelList() + return channels.length === num + }, + timeoutMs, + `Sidebar channel list length couldn't be determined within timeout`, 500 ) - return userProfileList } /** - * Get names of all users in the sidebar + * Get DM link elements in the sidebar */ - async getUserNames(): Promise { - const elements = await this.getUserProfileList() - return Promise.all( - elements.map(async element => { - const fullName = await element.getText() - return fullName.split(' ')[1] - }) + async getDmList(): Promise { + // We use a more generic XPath and then filter out user links to handle backwards compatibility + const dmChannels = await this.driver.wait( + this.driver.findElements(By.xpath('//*[contains(@data-testid, "-dm-link-text")]')), + 15_000, + `Sidebar DM list couldn't be found within timeout`, + 500 ) + logger.warn('dm channels', JSON.stringify(await Promise.all(dmChannels.map(foo => foo.getText())), null, 2)) + return dmChannels } /** - * Get names of all channels in the sidebar + * Get names of all DM channels in the sidebar */ - async getChannelsNames(): Promise { - const elements = await this.getChannelList() + async getDmChannelsNames(): Promise { + const elements = await this.getDmList() return Promise.all( elements.map(async element => { return await element.getText() @@ -2206,23 +2481,22 @@ export class Sidebar { ) } - async waitForChannelsNum(num: number, timeoutMs: number = 15_000): Promise { - logger.info(`Waiting for ${num} channels`) + async waitForDmChannelsNum(num: number, timeoutMs: number = 15_000): Promise { + logger.info(`Waiting for ${num} DM channels`) return this.driver.wait( async () => { - const channels = await this.getChannelList() + const channels = await this.getDmList() return channels.length === num }, timeoutMs, - `Sidebar channel list length couldn't be determined within timeout`, + `Sidebar DM channel list length couldn't be determined within timeout`, 500 ) } - async waitForChannels(channelsNames: Array): Promise { - await this.waitForChannelsNum(channelsNames.length) - const names = await this.getChannelsNames() - expect(names).toEqual(expect.arrayContaining(channelsNames)) + async waitForDmChannels(dmNames: Array): Promise { + const names = await this.getDmChannelsNames() + expect(names).toEqual(expect.arrayContaining(dmNames)) } async openSettings(): Promise { @@ -2236,7 +2510,8 @@ export class Sidebar { await this.driver.wait(until.elementIsVisible(button), 10_000) await this.driver.wait(until.elementIsEnabled(button), 10_000) await button.click() - return new Settings(this.driver) + const settings = new Settings(this.driver) + return settings } async switchChannel(name: string, isPublic: boolean = true, expectChannelTypeIcon: boolean = true): Promise { @@ -2248,62 +2523,148 @@ export class Sidebar { ) await channelLink.click() const channel = new Channel(this.driver, name) - await channel.isOpen(isPublic, expectChannelTypeIcon) + await channel.isOpen( + isPublic ? TestChannelType.PUBLIC_CHANNEL : TestChannelType.PRIVATE_CHANNEL, + expectChannelTypeIcon + ) return channel } - async addNewChannel(name: string, isPublic: boolean = true, expectToggle: boolean = true): Promise { - const button = await this.driver.wait( - until.elementLocated(By.xpath('//button[@data-testid="addChannelButton"]')), - 5_000, - `Add channel button couldn't be found within timeout`, - 500 - ) - await this.driver.wait(until.elementIsVisible(button), 5_000) - await this.driver.wait(until.elementIsEnabled(button), 5_000) - await button.click() - const channelNameInput = await this.driver.wait( - until.elementLocated(By.xpath('//input[@name="channelName"]')), - 5_000, - `Add channel name input field couldn't be found within timeout`, + async switchDm(name: string): Promise { + const dmLink = await this.driver.wait( + until.elementLocated(By.xpath(`//*[contains(@data-testid, "-dm-link-text") and text()="${name}"]`)), + 20_000, + `Channel link button for ${name} couldn't be found within timeout`, 500 ) - await this.driver.wait(until.elementIsVisible(channelNameInput), 5_000) - await this.driver.wait(until.elementIsEnabled(channelNameInput), 5_000) - await channelNameInput.sendKeys(name) + await dmLink.click() + const channel = new Channel(this.driver, name) + await channel.isOpen(TestChannelType.DM) + return channel + } + + async addNewChannel( + name: string, + options: TestAddNewChannelOptions = DEFAULT_ADD_NEW_CHANNEL_OPTIONS + ): Promise { + try { + logger.debug('Opening create channel modal') + const button = await this.driver.wait( + until.elementLocated(By.xpath(`//button[@data-testid="${options.buttonId}"]`)), + 5_000, + `Add channel button couldn't be found within timeout`, + 500 + ) + await this.driver.wait(until.elementIsVisible(button), 5_000) + await this.driver.wait(until.elementIsEnabled(button), 5_000) + await button.click() + } catch (e) { + logger.error('Error while opening create channel modal', e) + return { + errors: [e], + } + } + + try { + logger.debug('Entering channel name', name) + const channelNameInput = await this.driver.wait( + until.elementLocated(By.xpath('//input[@name="channelName"]')), + 5_000, + `Add channel name input field couldn't be found within timeout`, + 500 + ) + await this.driver.wait(until.elementIsVisible(channelNameInput), 5_000) + await this.driver.wait(until.elementIsEnabled(channelNameInput), 5_000) + await channelNameInput.sendKeys(name) + } catch (e) { + logger.error(`Error while entering channel name: ${name}`, e) + return { + errors: [e], + } + } - if (!isPublic && !expectToggle) { - logger.warn(`Can't create a private channel without the privacy toggle - overriding expectToggle`) + const errors: Error[] = [] + let expectToggle = options.expectToggle + if (!options.isPublic && !options.expectToggle) { + errors.push(new Error(`Can't create a private channel without the privacy toggle - overriding expectToggle`)) expectToggle = true } - if (expectToggle) { + try { + logger.debug('Checking for private toggle', expectToggle, options.isPublic) const channelPrivateToggle = await this.driver.wait( until.elementLocated(By.xpath('//span[@data-testid="createChannel-private-form-control-toggle"]')), 5_000, `Channel private toggle couldn't be found within timeout`, 500 ) - await this.driver.wait(until.elementIsVisible(channelPrivateToggle), 5_000) + await this.driver.wait( + until.elementIsVisible(channelPrivateToggle), + 5_000, + `Channel private toggle wasn't visible within timeout`, + 500 + ) + if (!expectToggle) { + errors.push(new Error(`Channel privacy toggle was present but expected it to be missing`)) + } if ((await channelPrivateToggle.getAttribute('class')).includes('checked')) { - throw new Error('Channel privacy toggle was enabled before clicking') + if (options.isPublic) { + await channelPrivateToggle.click() + if ((await channelPrivateToggle.getAttribute('class')).includes('checked')) { + errors.push(new Error(`Channel privacy toggle was enabled before clicking and couldn't be disabled`)) + return { + errors, + } + } + errors.push(new Error(`Channel privacy toggle was enabled before clicking but was disabled`)) + } else { + errors.push(new Error('Channel privacy toggle was enabled before clicking')) + } } - if (!isPublic) { + if (!options.isPublic) { + logger.debug('Enabled private toggle') await channelPrivateToggle.click() if (!(await channelPrivateToggle.getAttribute('class')).includes('checked')) { - throw new Error('Channel privacy toggle was disabled after clicking') + errors.push(new Error('Channel privacy toggle was disabled after clicking')) + return { + errors, + } + } + } + } catch (e) { + if ( + expectToggle || + (!(e as Error).message.includes(`Channel private toggle couldn't be found within timeout`) && + !(e as Error).message.includes(`Channel private toggle wasn't visible within timeout`)) + ) { + logger.error('Error while validating and optionally clicking the private channel toggle', e) + errors.push(e) + return { + errors, } } } - const channelNameButton = await this.driver.wait( - until.elementLocated(By.xpath('//button[@data-testid="channelNameSubmit"]')), - 5_000, - `Add channel submit button couldn't be found within timeout`, - 500 - ) - await this.driver.wait(until.elementIsVisible(channelNameButton), 5_000) - await channelNameButton.click() - return new Channel(this.driver, name) + + let channel: Channel | undefined = undefined + try { + logger.debug('Submitting channel creation') + const channelNameButton = await this.driver.wait( + until.elementLocated(By.xpath('//button[@data-testid="channelNameSubmit"]')), + 5_000, + `Add channel submit button couldn't be found within timeout`, + 500 + ) + await this.driver.wait(until.elementIsVisible(channelNameButton), 5_000) + await channelNameButton.click() + channel = new Channel(this.driver, name) + } catch (e) { + logger.error('Error while submiting create channel request', e) + errors.push(e) + } + return { + channel, + errors: errors.length > 0 ? errors : undefined, + } } /** @@ -2318,31 +2679,6 @@ export class Sidebar { ) } - /** - * Wait for a specific number of user profiles in the sidebar - */ - async waitForUserProfilesNum(num: number) { - logger.info(`Waiting for ${num} user profiles`) - return this.driver.wait( - async () => { - const users = await this.getUserProfileList() - return users.length === num - }, - 15_000, - `Sidebar user profile list length couldn't be determined within timeout`, - 500 - ) - } - - /** - * Wait for a specific set of user profile names in the sidebar - */ - async waitForUserProfiles(userNames: Array) { - await this.waitForUserProfilesNum(userNames.length) - const names = await this.getUserNames() - expect(names).toEqual(expect.arrayContaining(userNames)) - } - /** * Check if a user's connected badge is visible */ @@ -2486,9 +2822,18 @@ export class Settings { ) } + get tabCloseElement() { + return this.driver.wait( + until.elementLocated(By.xpath('//div[@data-testid="close-tab-button-box"]//button')), + 10_000, + `Settings tab close button couldn't be found within timeout`, + 500 + ) + } + async isReady(): Promise { await this.driver.wait( - until.elementIsVisible(this.element), + until.elementIsVisible(await this.element), 10_000, `Settings modal wasn't ready within timeout`, 500 @@ -2574,14 +2919,24 @@ export class Settings { } async switchTab(name: SettingsModalTabName) { + logger.info(`Switching to settings tab ${name}`) + logger.info(`switchTab - before locate`) const tab = await this.driver.wait( until.elementLocated(By.xpath(`//div[@data-testid='${name}-settings-tab']`)), 15_000, `Settings tab button for ${name} couldn't be found within timeout`, 500 ) - await this.driver.wait(until.elementIsVisible(tab), 5_000) + logger.info(`switchTab - before visibility check`) + await this.driver.wait( + until.elementIsVisible(tab), + 5_000, + `Settings tab button for ${name} wasn't visible within timeout`, + 500 + ) + logger.info(`switchTab - before click`) await tab.click() + logger.info(`switchTab - before tab modal readiness`) await this.waitForTabToBeReady(name) } @@ -2640,8 +2995,171 @@ export class Settings { await element.click() } + async openCommunityMembership(expectedUserCount?: number) { + try { + await this.switchTab(SettingsModalTabName.COMMUNITY_MEMBERSHIP) + const title = await this.driver.wait( + until.elementLocated(By.xpath("//*[@data-testid='community-membership-title']")), + 5_000, + `Community membership tab header title couldn't be found within timeout`, + 500 + ) + await this.driver.wait( + until.elementIsVisible(title), + 5_000, + `Community membership tab header title wasn't visible within timeout`, + 500 + ) + + const search = await this.driver.wait( + until.elementLocated(By.xpath("//*[@data-testid='community-membership-search']")), + 5_000, + `Community membership tab search bar couldn't be found within timeout`, + 500 + ) + await this.driver.wait( + until.elementIsVisible(search), + 5_000, + `Community membership tab search bar wasn't visible within timeout`, + 500 + ) + + const list = await this.driver.wait( + until.elementLocated(By.xpath("//*[@data-testid='community-membership-list']")), + 5_000, + `Community membership tab user list couldn't be found within timeout`, + 500 + ) + await this.driver.wait( + until.elementIsVisible(list), + 5_000, + `Community membership tab user list wasn't visible within timeout`, + 500 + ) + + if (expectedUserCount == null) return + + const userElements = await this.getUsersInCommunityMembership() + if (userElements.length !== expectedUserCount) { + throw new Error( + `Expected ${expectedUserCount} users in community membership user list but found ${userElements.length}` + ) + } + return + } catch (e) { + logger.error('Error while opening and verifying community membership settings tab', e) + throw e + } + } + + async getUserInCommunityMembership( + username: string, + expectedState: UserListStatus, + includeMeTag = false + ): Promise { + logger.debug('Getting community membership user list item', username) + let status: UserListStatus = UserListStatus.NOT_FOUND + let testText = new RegExp(`${username}`) + let baseBadgeTimeout = 60_000 + if (includeMeTag) { + testText = new RegExp(`${username}\\s+me`) + baseBadgeTimeout = 5_000 + } + + let userItem: WebElement | undefined = undefined + try { + userItem = await this.driver.wait( + until.elementLocated(By.xpath(`//*[@data-testid="${username}-membership-list-item"]`)), + 10_000, + `User ${username} couldn't be found in membership list within timeout`, + 500 + ) + + await this.driver.wait( + until.elementIsVisible(userItem), + 5_000, + `User ${username} wasn't visible in membership list within timeout`, + 500 + ) + } catch (e) { + if (expectedState !== UserListStatus.NOT_FOUND) { + logger.error(`Error while finding user ${username} in membership list`, e) + } + return { + element: undefined, + status, + textMatches: false, + } + } + + if (userItem == null) { + if (expectedState !== UserListStatus.NOT_FOUND) { + logger.error(`Failed to find user ${username} in membership list`) + } + return { + element: undefined, + status, + textMatches: false, + } + } + + const textMatches = (await userItem.getText()).match(testText) != null + + const statusBadge = await this.driver.wait( + until.elementLocated(By.xpath(`//span[@data-testid="${username}-profile-photo-status-badge"]`)), + baseBadgeTimeout, + `Users item status badge for ${username} couldn't be located within timeout`, + 500 + ) + + if (expectedState === UserListStatus.ONLINE) { + try { + await this.driver.wait( + until.elementIsVisible(statusBadge), + baseBadgeTimeout * 2, + `Users item status badge for ${username} was not visibile within timeout`, + 500 + ) + status = UserListStatus.ONLINE + } catch (e) { + status = UserListStatus.OFFLINE + } + } else { + try { + await this.driver.wait( + until.elementIsNotVisible(statusBadge), + baseBadgeTimeout * 2, + `Users item status badge for ${username} was not invisible within timeout`, + 500 + ) + status = UserListStatus.OFFLINE + } catch (e) { + status = UserListStatus.ONLINE + } + } + + return { + element: userItem, + status, + textMatches, + } + } + + async getUsersInCommunityMembership(): Promise { + return await this.driver.wait( + until.elementsLocated(By.xpath('//*[contains(@data-testid, "-membership-list-item")]')), + 5_000, + `Users within community membership list couldn't be found within timeout`, + 500 + ) + } + async closeTabThenModal() { - await this.closeTab() + try { + await this.closeTab() + } catch (e) { + logger.error('Error while closing settings tab', e) + } await sleep(1_000) await this.close() } @@ -2665,12 +3183,7 @@ export class Settings { async closeTab() { logger.debug('Closing settings tab') - const closeTabButton = await this.driver.wait( - until.elementLocated(By.xpath('//div[@data-testid="close-tab-button-box"]//button')), - 10_000, - `Settings tab close button couldn't be found within timeout`, - 500 - ) + const closeTabButton = await this.tabCloseElement await this.driver.wait( until.elementIsVisible(closeTabButton), 5_000, @@ -2701,16 +3214,41 @@ export class Settings { case SettingsModalTabName.DEBUG: locator = "//div[contains(@class, 'DebugInfotitleContainer')]" break + case SettingsModalTabName.COMMUNITY_MEMBERSHIP: + locator = "//*[contains(@class, 'CommunityMembershipcomponentContainer')]" + break default: throw new Error(`Can't wait for unknown tab ${tabName}`) } + // try { + // logger.info(`waitForTabToBeReady - before sanity check`) + // const settingsElement = await this.element + // logger.info(`waitForTabToBeReady - before sanity visibility check`) + // await this.driver.wait( + // until.elementIsVisible(settingsElement), + // 5_000, + // `Settings element was not visible after timeout`, + // 500 + // ) + // throw new Error(`Settings was still visible after switching to tab ${tabName}`) + // } catch (e) { + // if ( + // !(e as Error).message.includes(`Settings modal couldn't be found within timeout`) && + // !(e as Error).message.includes(`Settings element was not visible after timeout`) + // ) { + // throw e + // } + // } + + logger.info(`waitForTabToBeReady - before tab element`) const result = await this.driver.wait( until.elementLocated(By.xpath(locator!)), - 15_000, + 30_000, `Settings tab ${tabName} wasn't ready within timeout`, 500 ) + logger.info(`waitForTabToBeReady - before tab element visibility`) await this.driver.wait( until.elementIsVisible(result), 10_000, diff --git a/packages/e2e-tests/src/tests/backwardsCompatibility.test.ts b/packages/e2e-tests/src/tests/backwardsCompatibility.test.ts index d9464a970c..eff17923d9 100644 --- a/packages/e2e-tests/src/tests/backwardsCompatibility.test.ts +++ b/packages/e2e-tests/src/tests/backwardsCompatibility.test.ts @@ -11,7 +11,7 @@ import { Settings, Sidebar, } from '../selectors' -import { MessageIds } from '../types' +import { DEFAULT_ADD_NEW_CHANNEL_OPTIONS, MessageIds, TestAddNewChannelButtonId } from '../types' import { BACKWARD_COMPATIBILITY_BASE_VERSION, BuildSetup, copyInstallerFile, downloadInstaller, sleep } from '../utils' import { createLogger } from '../logger' @@ -148,10 +148,15 @@ describe('Backwards Compatibility', () => { describe('Second channel', () => { itif(process.platform == 'linux')('Owner creates second channel', async () => { sidebar = new Sidebar(ownerAppOldVersion.driver) - await sidebar.addNewChannel(newChannelName, true, false) + const { channel, errors } = await sidebar.addNewChannel(newChannelName, { + isPublic: true, + expectToggle: false, + buttonId: TestAddNewChannelButtonId.PRE_DMS, + }) + expect(channel).toBeDefined() + expect(errors).toBeUndefined() + await sidebar.waitForChannelsNum(2) await sidebar.switchChannel(newChannelName, true, false) - const channels = await sidebar.getChannelList() - expect(channels.length).toEqual(2) }) itif(process.platform == 'linux')('Owner sends a message in second channel', async () => { @@ -242,8 +247,7 @@ describe('Backwards Compatibility', () => { itif(process.platform == 'linux')('Verify number of channels', async () => { sidebar = new Sidebar(ownerAppNewVersion.driver) - const channels = await sidebar.getChannelList() - expect(channels.length).toEqual(2) + await sidebar.waitForChannelsNum(2) }) itif(process.platform == 'linux')('Switch to second channel', async () => { diff --git a/packages/e2e-tests/src/tests/invitationLink.test.ts b/packages/e2e-tests/src/tests/invitationLink.test.ts index ec0debc4aa..342a5d5150 100644 --- a/packages/e2e-tests/src/tests/invitationLink.test.ts +++ b/packages/e2e-tests/src/tests/invitationLink.test.ts @@ -9,7 +9,7 @@ import { WarningModal, JoiningLoadingPanel, } from '../selectors' -import { composeInvitationDeepUrl, parseInvitationLink, userJoinedMessage } from '@quiet/common' +import { composeInvitationDeepUrl, parseInvitationLink } from '@quiet/common' import { execSync } from 'child_process' import { type SupportedPlatformDesktop } from '@quiet/types' import { createLogger } from '../logger' diff --git a/packages/e2e-tests/src/tests/multipleClients.dms.qss.test.ts b/packages/e2e-tests/src/tests/multipleClients.dms.qss.test.ts new file mode 100644 index 0000000000..5d0b9b4bc8 --- /dev/null +++ b/packages/e2e-tests/src/tests/multipleClients.dms.qss.test.ts @@ -0,0 +1,806 @@ +import { jest } from '@jest/globals' +import * as path from 'path' + +import { + App, + Channel, + ChannelContextMenu, + CreateCommunityModal, + DebugModeModal, + JoinCommunityModal, + JoiningLoadingPanel, + NewMessage, + RegisterUsernameModal, + ServerOfferModal, + Settings, + Sidebar, + TermsOfServiceModal, +} from '../selectors' +import { createArbitraryFile, promiseWithRetries, sleep } from '../utils' +import { MessageIds, TestChannelType, UserListStatus, UserTestData, UserTestData2, UserTestDataMap } from '../types' +import { createLogger } from '../logger' +import { FileAttachmentType, SettingsModalTabName } from '../enums' +import { + BIG_FILE_SIZE, + TEST_BIG_FILE_NAME, + TEST_FILE_NAME, + TEST_IMAGE_FILE_NAME, + UPLOAD_FILE_DIR, +} from '../attachFile.const' + +const logger = createLogger('multipleClients:dms') + +jest.setTimeout(1200000) // 20 minutes +describe('Multiple Clients (DMs)', () => { + let generalChannelOwner: Channel + let generalChannelUser1: Channel + let generalChannelUser2: Channel + + let selfDmChannelOwner: Channel + let oneOnOneChannelOwner: Channel + let oneOnOneChannelUser1: Channel + let groupDmChannelOwner: Channel + let groupDmChannelUser1: Channel + let groupDmChannelUser2: Channel + let channelContextMenuOwner: ChannelContextMenu + + let invitationLink: string + + let sidebarOwner: Sidebar + let sidebarUser1: Sidebar + let sidebarUser2: Sidebar + + let settingsOwner: Settings + let settingsUser1: Settings + let settingsUser2: Settings + + let newMessageOwner: NewMessage + let newMessageUser1: NewMessage + let newMessageUser2: NewMessage + + const communityName = 'testcommunity' + const displayedCommunityName = 'Testcommunity' + const generalChannelName = 'general' + + type Usernames = 'owner' | 'user1' | 'user2' + type ChannelNames = 'selfDm' | 'oneOnOne' | 'groupDm' | 'general' + let users: UserTestDataMap> + let channelNameMap: Record> + + beforeAll(async () => { + users = { + owner: { + username: 'owner', + app: new App({ username: 'owner' }), + messages: { + general: ['Hi'], + selfDm: ['Only I can see this', `I'm still the only one who can see this`], + oneOnOne: [`I'm talking to just you`], + groupDm: [`Multiple people can see this`, `I sent this from the new message form!`], + }, + }, + user1: { + username: 'user-1', + app: new App({ username: 'user-1' }), + messages: { + general: ['Hello'], + selfDm: [], + oneOnOne: [`This is between us`], + groupDm: [`Hey everyone`], + }, + }, + user2: { + username: 'user-2', + app: new App({ username: 'user-2' }), + messages: { + general: [`I'm here too`], + selfDm: [], + oneOnOne: [], + groupDm: [`We're all here!`], + }, + }, + } + + channelNameMap = { + owner: { + general: generalChannelName, + selfDm: users.owner.username, + oneOnOne: users.user1.username, + groupDm: `${users.user1.username}, ${users.user2.username}`, + }, + user1: { + general: generalChannelName, + selfDm: '', + oneOnOne: users.owner.username, + groupDm: `${users.owner.username}, ${users.user2.username}`, + }, + user2: { + general: generalChannelName, + selfDm: '', + oneOnOne: '', + groupDm: `${users.owner.username}, ${users.user1.username}`, + }, + } + }) + + afterAll(async () => { + for (const user of Object.values(users)) { + try { + if (!user.app.isOpened) continue + await user.app.close() + // await user.app.cleanup() + } catch (e) { + logger.error('Failed to close and cleanup app data', e) + } + } + }) + + beforeEach(async () => { + logger.info(`░░░ ${expect.getState().currentTestName}`) + }) + + describe('Stages:', () => { + describe('Owner Creates Community With QSS', () => { + it('Owner opens the app', async () => { + await users.owner.app.open(true) + }) + + it('Owner sees "join community" modal and switches to "create community" modal', async () => { + const joinModal = new JoinCommunityModal(users.owner.app.driver) + expect(await joinModal.isReady()).toBeTruthy() + await joinModal.switchToCreateCommunity() + }) + + it('Owner submits valid community name', async () => { + const createModal = new CreateCommunityModal(users.owner.app.driver) + expect(await createModal.isReady()).toBeTruthy() + await createModal.typeCommunityName(communityName) + await createModal.submit() + }) + + it('Owner sees "server offer" modal', async () => { + const serverOfferModal = new ServerOfferModal(users.owner.app.driver) + expect(await serverOfferModal.isReady()).toBeTruthy() + }) + + it('Owner accepts server offer', async () => { + const serverOfferModal = new ServerOfferModal(users.owner.app.driver) + await serverOfferModal.chooseUseServer() + }) + + it('Owner sees "register username" modal and submits valid username', async () => { + const registerModal = new RegisterUsernameModal(users.owner.app.driver) + expect(await registerModal.isReady()).toBeTruthy() + await registerModal.typeUsername(users.owner.username) + await registerModal.submit() + }) + + it('Owner agrees to Terms of Service', async () => { + const tosModal = new TermsOfServiceModal(users.owner.app.driver) + expect(await tosModal.isReady()).toBeTruthy() + await tosModal.chooseAgreeAndJoin() + }) + + it('Owner waits to join', async () => { + const joinPanel = new JoiningLoadingPanel(users.owner.app.driver) + await joinPanel.waitForJoinToComplete() + }) + + it('Owner registers successfully and sees general channel', async () => { + generalChannelOwner = new Channel(users.owner.app.driver, generalChannelName) + expect(await generalChannelOwner.isReady()).toBeTruthy() + expect(await generalChannelOwner.isOpen()).toBeTruthy() + sidebarOwner = new Sidebar(users.owner.app.driver) + await sidebarOwner.getChannelIcon(generalChannelName, true) + + const generalChannelText = await generalChannelOwner.element.getText() + expect(generalChannelText).toEqual('general') + }) + + it.skip('Owner opens community membership tab', async () => { + settingsOwner = await new Sidebar(users.owner.app.driver).openSettings() + expect(await settingsOwner.isReady()).toBeTruthy() + await settingsOwner.openCommunityMembership(1) + }) + + it.skip('Owner sees self in user list', async () => { + const status = await settingsOwner.getUserInCommunityMembership( + users.owner.username, + UserListStatus.ONLINE, + true + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('Owner closes community membership tab', async () => { + await settingsOwner.closeTabThenModal() + }) + }) + + describe('Owner Sends a Message in General', () => { + it('Owner sends a message in general channel', async () => { + expect(await generalChannelOwner.isMessageInputReady()).toBeTruthy() + await generalChannelOwner.sendMessage(users.owner.messages.general[0], users.owner.username) + }) + + it("Owner's message is visible in general channel", async () => { + await generalChannelOwner.getUserMessages(users.owner.username) + await generalChannelOwner.getMessageIdsByText(users.owner.messages.general[0], users.owner.username) + }) + }) + + describe('Owner creates DM with self', () => { + it('Owner opens new message view', async () => { + newMessageOwner = new NewMessage(users.owner.app.driver) + await newMessageOwner.open() + }) + + it('Owner creates new DM with self', async () => { + const dmCreationStatus = await newMessageOwner.createNewDm( + [users.owner.username], + users.owner.messages.selfDm[0] + ) + expect(dmCreationStatus.error).toBeUndefined() + expect(dmCreationStatus.failedUsers).toHaveLength(0) + expect(dmCreationStatus.successfulUsers).toHaveLength(1) + expect(dmCreationStatus.success).toBeTruthy() + }) + + it('Owner sees newly created DM channel', async () => { + selfDmChannelOwner = new Channel(users.owner.app.driver, channelNameMap.owner.selfDm) + await selfDmChannelOwner.isOpen(TestChannelType.DM) + await selfDmChannelOwner.isMessageInputReady() + }) + + it('Owner sees their message in the newly created DM channel', async () => { + await selfDmChannelOwner.getUserMessages(users.owner.username) + await selfDmChannelOwner.getMessageIdsByText(users.owner.messages.selfDm[0], users.owner.username) + }) + }) + + describe('Owner invites first user', () => { + it('Owner opens the settings tab and gets an invitation link', async () => { + const settingsModal = await new Sidebar(users.owner.app.driver).openSettings() + expect(await settingsModal.isReady()).toBeTruthy() + await settingsModal.switchTab(SettingsModalTabName.INVITE) + const invitationLinkElement = await settingsModal.invitationLink() + invitationLink = await invitationLinkElement.getText() + expect(invitationLink).not.toBeUndefined() + logger.info('Received invitation link:', invitationLink) + logger.warn('closing invite tab') + await settingsModal.closeTabThenModal() + }) + }) + + describe('First User Joins Community', () => { + it('First user opens the app', async () => { + logger.info('Second client') + await users.user1.app.open(true) + }) + + it('First user submits invitation code received from owner', async () => { + logger.info('new user - 3') + const joinCommunityModal = new JoinCommunityModal(users.user1.app.driver) + expect(await joinCommunityModal.isReady()).toBeTruthy() + logger.info({ invitationLink }) + await joinCommunityModal.typeCommunityInviteLink(invitationLink) + await joinCommunityModal.submit() + }) + + it('First user submits valid username', async () => { + const app = users.user1.app + logger.info('new user - 5') + const registerModal = new RegisterUsernameModal(app.driver) + expect(await registerModal.isReady()).toBeTruthy() + await registerModal.clearInput() + await registerModal.typeUsername(users.user1.username) + await registerModal.submit() + logger.time(`[${app.name}] '${users.user1.username}' joining community time`) + }) + + it('First user agrees to Terms of Service', async () => { + const tosModal = new TermsOfServiceModal(users.user1.app.driver) + expect(await tosModal.isReady()).toBeTruthy() + await tosModal.chooseAgreeAndJoin() + }) + + it('First user waits to join', async () => { + const joinPanel = new JoiningLoadingPanel(users.user1.app.driver) + await joinPanel.waitForJoinToComplete() + }) + + it('First user joins successfully sees general channel and sends a message', async () => { + logger.info('new user - 7') + const app = users.user1.app + const loadNewUser = async () => { + generalChannelUser1 = new Channel(app.driver, generalChannelName) + expect(await generalChannelUser1.isReady()).toBeTruthy() + expect(await generalChannelUser1.isOpen()).toBeTruthy() + expect(await generalChannelUser1.isMessageInputReady()).toBeTruthy() + logger.timeEnd(`[${app.name}] '${users.user1.username}' joining community time`) + } + + const retryConfig = app.retryConfig + const failureReason = `Failed to load app for new user ${users.user1.username} within ${retryConfig.timeoutMs}ms` + const onTimeout = async () => { + await app.close() + await app.open() + } + await promiseWithRetries(loadNewUser(), failureReason, retryConfig, onTimeout) + await generalChannelUser1.sendMessage(users.user1.messages.general[0], users.user1.username) + }) + + it("First user sees owner's message in channel", async () => { + await generalChannelUser1.getUserMessages(users.owner.username) + await generalChannelUser1.getMessageIdsByText(users.owner.messages.general[0], users.owner.username) + }) + + it("First user's message is visible in a channel", async () => { + await generalChannelUser1.getUserMessages(users.user1.username) + await generalChannelUser1.getMessageIdsByText(users.user1.messages.general[0], users.user1.username) + }) + + it("First user's message is visible in a channel to the owner", async () => { + generalChannelOwner = await sidebarOwner.switchChannel(generalChannelName) + await generalChannelOwner.getUserMessages(users.user1.username) + await generalChannelOwner.getMessageIdsByText(users.user1.messages.general[0], users.user1.username) + }) + }) + + describe('First user creates DM with owner', () => { + it('First user opens new message view', async () => { + newMessageUser1 = new NewMessage(users.user1.app.driver) + await newMessageUser1.open() + }) + + it('First user creates new DM with owner', async () => { + const dmCreationStatus = await newMessageUser1.createNewDm( + [users.owner.username], + users.user1.messages.oneOnOne[0] + ) + expect(dmCreationStatus.error).toBeUndefined() + expect(dmCreationStatus.failedUsers).toHaveLength(0) + expect(dmCreationStatus.successfulUsers).toHaveLength(1) + expect(dmCreationStatus.success).toBeTruthy() + }) + + it('First user sees newly created DM channel', async () => { + oneOnOneChannelUser1 = new Channel(users.user1.app.driver, channelNameMap.user1.oneOnOne) + await oneOnOneChannelUser1.isOpen(TestChannelType.DM) + await oneOnOneChannelUser1.isMessageInputReady() + }) + + it('First user sees their message in the newly created DM channel', async () => { + await oneOnOneChannelUser1.getUserMessages(users.user1.username) + await oneOnOneChannelUser1.getMessageIdsByText(users.user1.messages.oneOnOne[0], users.user1.username) + }) + + it('Owner sees new DM in sidebar', async () => { + sidebarOwner = new Sidebar(users.owner.app.driver) + await sidebarOwner.waitForDmChannelsNum(2, 45_000) + await sidebarOwner.waitForDmChannels([channelNameMap.owner.oneOnOne]) + }) + + it('Owner opens new DM channel', async () => { + oneOnOneChannelOwner = await sidebarOwner.switchDm(channelNameMap.owner.oneOnOne) + await oneOnOneChannelOwner.isOpen(TestChannelType.DM) + await oneOnOneChannelOwner.isMessageInputReady() + }) + + it(`Owner sees first user's message in the newly created DM channel`, async () => { + await oneOnOneChannelOwner.getUserMessages(users.user1.username) + await oneOnOneChannelOwner.getMessageIdsByText(users.user1.messages.oneOnOne[0], users.user1.username) + }) + + it('Owner sends message in new DM channel', async () => { + await oneOnOneChannelOwner.sendMessage(users.owner.messages.oneOnOne[0], users.owner.username) + }) + + it('Owner sees their message in the newly created DM channel', async () => { + await oneOnOneChannelOwner.getUserMessages(users.owner.username) + await oneOnOneChannelOwner.getMessageIdsByText(users.owner.messages.oneOnOne[0], users.owner.username) + }) + + it(`First user sees owner's message in the newly created DM channel`, async () => { + await oneOnOneChannelUser1.getUserMessages(users.owner.username) + await oneOnOneChannelUser1.getMessageIdsByText(users.owner.messages.oneOnOne[0], users.owner.username) + }) + }) + + describe('Owner invites second user while everyone offline', () => { + it('Owner opens the settings tab and gets an updated invitation link', async () => { + const settingsModal = await new Sidebar(users.owner.app.driver).openSettings() + expect(await settingsModal.isReady()).toBeTruthy() + await settingsModal.switchTab(SettingsModalTabName.INVITE) + const invitationLinkElement = await settingsModal.invitationLink() + invitationLink = await invitationLinkElement.getText() + expect(invitationLink).not.toBeUndefined() + logger.info('Received invitation link:', invitationLink) + logger.warn('closing invite tab') + await settingsModal.closeTabThenModal() + }) + + it('First user goes offline', async () => { + await users.user1.app.close({ forceSaveState: true }) + }) + + it('Owner goes offline', async () => { + await users.owner.app.close({ forceSaveState: true }) + }) + }) + + describe('Second User Joins Community With Other Users Offline', () => { + it('Second user opens the app', async () => { + logger.info('Third client') + await users.user2.app.open(true) + }) + + it('Second user submits invitation code received from owner', async () => { + const joinCommunityModal = new JoinCommunityModal(users.user2.app.driver) + expect(await joinCommunityModal.isReady()).toBeTruthy() + await joinCommunityModal.typeCommunityInviteLink(invitationLink) + await joinCommunityModal.submit() + }) + + it('Second user submits valid username', async () => { + const app = users.user2.app + logger.info('new user - 5') + const registerModal = new RegisterUsernameModal(app.driver) + expect(await registerModal.isReady()).toBeTruthy() + await registerModal.clearInput() + await registerModal.typeUsername(users.user2.username) + await registerModal.submit() + logger.time(`[${app.name}] '${users.user2.username}' joining community time`) + }) + + it('Second user agrees to Terms of Service', async () => { + const tosModal = new TermsOfServiceModal(users.user2.app.driver) + expect(await tosModal.isReady()).toBeTruthy() + await tosModal.chooseAgreeAndJoin() + }) + + it('Second user waits to join', async () => { + const joinPanel = new JoiningLoadingPanel(users.user2.app.driver) + await joinPanel.waitForJoinToComplete() + }) + + it('Second user joins successfully sees general channel and sends a message', async () => { + const app = users.user2.app + const loadNewUser = async () => { + generalChannelUser2 = new Channel(app.driver, generalChannelName) + expect(await generalChannelUser2.isReady()).toBeTruthy() + expect(await generalChannelUser2.isOpen()).toBeTruthy() + expect(await generalChannelUser2.isMessageInputReady()).toBeTruthy() + logger.timeEnd(`[${app.name}] '${users.user2.username}' joining community time`) + } + + const retryConfig = app.retryConfig + const failureReason = `Failed to load app for new user ${users.user2.username} within ${retryConfig.timeoutMs}ms` + const onTimeout = async () => { + await app.close() + await app.open() + } + await promiseWithRetries(loadNewUser(), failureReason, retryConfig, onTimeout) + await generalChannelUser2.sendMessage(users.user2.messages.general[0], users.user2.username) + }) + + it("Second user's message is visible in a channel", async () => { + await generalChannelUser2.getUserMessages(users.user2.username) + await generalChannelUser2.getMessageIdsByText(users.user2.messages.general[0], users.user2.username) + }) + + it("Second user sees owner's message in channel", async () => { + await generalChannelUser2.getUserMessages(users.owner.username) + await generalChannelUser2.getMessageIdsByText(users.owner.messages.general[0], users.owner.username) + }) + + it("Second user sees first user's message in channel", async () => { + await generalChannelUser2.getUserMessages(users.user1.username) + await generalChannelUser2.getMessageIdsByText(users.user1.messages.general[0], users.user1.username) + }) + }) + + describe('Second user creates group DM', () => { + it('Second user opens new message view', async () => { + newMessageUser2 = new NewMessage(users.user2.app.driver) + await newMessageUser2.open() + }) + + it('Second user creates new group DM', async () => { + const dmCreationStatus = await newMessageUser2.createNewDm( + [users.owner.username, users.user1.username], + users.user2.messages.groupDm[0] + ) + expect(dmCreationStatus.error).toBeUndefined() + expect(dmCreationStatus.failedUsers).toHaveLength(0) + expect(dmCreationStatus.successfulUsers).toHaveLength(2) + expect(dmCreationStatus.success).toBeTruthy() + }) + + it('Second user sees group DM channel in sidebar', async () => { + sidebarUser2 = new Sidebar(users.user2.app.driver) + expect(await sidebarUser2.waitForDmChannelsNum(1)).toBeTruthy() + await sidebarUser2.waitForDmChannels([channelNameMap.user2.groupDm]) + }) + + it('Second user sees newly created group DM channel', async () => { + groupDmChannelUser2 = new Channel(users.user2.app.driver, channelNameMap.user2.groupDm) + await groupDmChannelUser2.isOpen(TestChannelType.DM) + await groupDmChannelUser2.isMessageInputReady() + }) + + it('Second user sees their message in the newly created DM channel', async () => { + await groupDmChannelUser2.getUserMessages(users.user2.username) + await groupDmChannelUser2.getMessageIdsByText(users.user2.messages.groupDm[0], users.user2.username) + }) + }) + + describe('Second User Goes Offline and Other Users Come Back', () => { + it('Second user goes offline', async () => { + await sleep(15_000) + await users.user2.app.close({ forceSaveState: true }) + }) + + it('Owner goes back online', async () => { + await users.owner.app.open(true) + const debugModal = new DebugModeModal(users.owner.app.driver) + await debugModal.close() + }) + + it('Owner sees general channel', async () => { + const app = users.owner.app + const loadOwner = async () => { + generalChannelOwner = new Channel(app.driver, generalChannelName) + expect(await generalChannelOwner.isReady()).toBeTruthy() + sidebarOwner = new Sidebar(app.driver) + await sidebarOwner.switchChannel(generalChannelName) + expect(await generalChannelOwner.isOpen()).toBeTruthy() + expect(await generalChannelOwner.isMessageInputReady()).toBeTruthy() + } + + const retryConfig = app.retryConfig + const failureReason = `Failed to load app for ${users.owner.username} within ${retryConfig.timeoutMs}ms` + const onTimeout = async () => { + await app.close() + await app.open() + } + await promiseWithRetries(loadOwner(), failureReason, retryConfig, onTimeout) + }) + + it("Second user's message is visible in general channel for owner", async () => { + generalChannelOwner = new Channel(users.owner.app.driver, generalChannelName) + await generalChannelOwner.getUserMessages(users.user2.username) + await generalChannelOwner.getMessageIdsByText(users.user2.messages.general[0], users.user2.username) + }) + + it('First user goes back online', async () => { + await users.user1.app.open(true) + const debugModal = new DebugModeModal(users.user1.app.driver) + await debugModal.close() + }) + + it('First user sees general channel', async () => { + const app = users.user1.app + const loadUser = async () => { + generalChannelUser1 = new Channel(app.driver, generalChannelName) + expect(await generalChannelUser1.isReady()).toBeTruthy() + expect(await generalChannelUser1.isOpen()).toBeTruthy() + expect(await generalChannelUser1.isMessageInputReady()).toBeTruthy() + } + + const retryConfig = app.retryConfig + const failureReason = `Failed to load app for ${users.user1.username} within ${retryConfig.timeoutMs}ms` + const onTimeout = async () => { + await app.close() + await app.open() + } + await promiseWithRetries(loadUser(), failureReason, retryConfig, onTimeout) + }) + + it("Second user's message is visible in general channel for first user", async () => { + generalChannelUser1 = new Channel(users.user1.app.driver, generalChannelName) + await generalChannelUser1.getUserMessages(users.user2.username) + await generalChannelUser1.getMessageIdsByText(users.user2.messages.general[0], users.user2.username) + }) + }) + + describe('Owner And First User See Group DM', () => { + it('Owner sees group DM in sidebar', async () => { + sidebarOwner = new Sidebar(users.owner.app.driver) + await sidebarOwner.waitForDmChannelsNum(3, 45_000) + await sidebarOwner.waitForDmChannels([channelNameMap.owner.groupDm]) + }) + + it('Owner opens group DM channel', async () => { + groupDmChannelOwner = await sidebarOwner.switchDm(channelNameMap.owner.groupDm) + await groupDmChannelOwner.isOpen(TestChannelType.DM) + await groupDmChannelOwner.isMessageInputReady() + }) + + it(`Owner sees second user's message in the group DM channel`, async () => { + await groupDmChannelOwner.getUserMessages(users.user2.username) + await groupDmChannelOwner.getMessageIdsByText(users.user2.messages.groupDm[0], users.user2.username) + }) + + it('Owner sends message in group DM channel', async () => { + await groupDmChannelOwner.sendMessage(users.owner.messages.groupDm[0], users.owner.username) + }) + + it('Owner sees their message in the group DM channel', async () => { + await groupDmChannelOwner.getUserMessages(users.owner.username) + await groupDmChannelOwner.getMessageIdsByText(users.owner.messages.groupDm[0], users.owner.username) + }) + + it('First user sees group DM in sidebar', async () => { + sidebarUser1 = new Sidebar(users.user1.app.driver) + await sidebarUser1.waitForDmChannelsNum(2, 45_000) + await sidebarUser1.waitForDmChannels([channelNameMap.user1.groupDm]) + }) + + it('First user opens group DM channel', async () => { + groupDmChannelUser1 = await sidebarUser1.switchDm(channelNameMap.user1.groupDm) + await groupDmChannelUser1.isOpen(TestChannelType.DM) + await groupDmChannelUser1.isMessageInputReady() + }) + + it(`First user sees second user's message in the group DM channel`, async () => { + await groupDmChannelUser1.getUserMessages(users.user2.username) + await groupDmChannelUser1.getMessageIdsByText(users.user2.messages.groupDm[0], users.user2.username) + }) + + it(`First user sees owner's message in the group DM channel`, async () => { + await groupDmChannelUser1.getUserMessages(users.owner.username) + await groupDmChannelUser1.getMessageIdsByText(users.owner.messages.groupDm[0], users.owner.username) + }) + + it('First user sends message in group DM channel', async () => { + await groupDmChannelUser1.sendMessage(users.user1.messages.groupDm[0], users.user1.username) + }) + + it('First user sees their message in the group DM channel', async () => { + await groupDmChannelUser1.getUserMessages(users.user1.username) + await groupDmChannelUser1.getMessageIdsByText(users.user1.messages.groupDm[0], users.user1.username) + }) + + it(`Owner sees first user's message in the group DM channel`, async () => { + await groupDmChannelOwner.getUserMessages(users.user1.username) + await groupDmChannelOwner.getMessageIdsByText(users.user1.messages.groupDm[0], users.user1.username) + }) + }) + + describe('Second User Comes Back Online', () => { + it('Second user goes back online', async () => { + await users.user2.app.openWithRetries(undefined, true) + const debugModal = new DebugModeModal(users.user2.app.driver) + await debugModal.close() + }) + + it('Second user sees general channel', async () => { + const app = users.user2.app + const loadUser = async () => { + generalChannelUser2 = new Channel(app.driver, generalChannelName) + expect(await generalChannelUser2.isReady()).toBeTruthy() + expect(await generalChannelUser2.isOpen()).toBeTruthy() + expect(await generalChannelUser2.isMessageInputReady()).toBeTruthy() + } + + const retryConfig = app.retryConfig + const failureReason = `Failed to load app for ${users.user2.username} within ${retryConfig.timeoutMs}ms` + const onTimeout = async () => { + await app.close() + await app.open() + } + await promiseWithRetries(loadUser(), failureReason, retryConfig, onTimeout) + }) + + describe('Second User Sees New Messages In Group DM', () => { + it('Second user sees group DM in sidebar', async () => { + sidebarUser2 = new Sidebar(users.user2.app.driver) + await sidebarUser2.waitForDmChannelsNum(1, 45_000) + await sidebarUser2.waitForDmChannels([channelNameMap.user2.groupDm]) + }) + + it('Second user opens group DM channel', async () => { + groupDmChannelUser2 = await sidebarUser2.switchDm(channelNameMap.user2.groupDm) + await groupDmChannelUser2.isOpen(TestChannelType.DM) + await groupDmChannelUser2.isMessageInputReady() + }) + + it(`Second user sees owner's message in the group DM channel`, async () => { + await groupDmChannelUser2.getUserMessages(users.owner.username) + await groupDmChannelUser2.getMessageIdsByText(users.owner.messages.groupDm[0], users.owner.username) + }) + + it(`Second user sees first user's message in the group DM channel`, async () => { + await groupDmChannelUser2.getUserMessages(users.user1.username) + await groupDmChannelUser2.getMessageIdsByText(users.user1.messages.groupDm[0], users.user1.username) + }) + }) + }) + + describe('Open existing DM in New Message view', () => { + it(`Owner opens general`, async () => { + generalChannelOwner = await sidebarOwner.switchChannel(generalChannelName) + expect(await generalChannelOwner.isOpen()).toBeTruthy() + expect(await generalChannelOwner.isReady()).toBeTruthy() + expect(await generalChannelOwner.isMessageInputReady()).toBeTruthy() + }) + + it('Owner opens new message view', async () => { + newMessageOwner = new NewMessage(users.owner.app.driver) + await newMessageOwner.open() + }) + + it('Owner switches new message view to group DM', async () => { + const dmCreationStatus = await newMessageOwner.changeDmUsers([users.user1.username, users.user2.username]) + expect(dmCreationStatus.failedUsers).toHaveLength(0) + expect(dmCreationStatus.successfulUsers).toHaveLength(2) + }) + + it(`Owner sees first user's message from group DM in new message view`, async () => { + await groupDmChannelOwner.getUserMessages(users.user1.username) + await groupDmChannelOwner.getMessageIdsByText(users.user1.messages.groupDm[0], users.user1.username) + }) + }) + + describe('Close new message view and reopen previous channel', () => { + it(`Owner closes the new message view`, async () => { + await newMessageOwner.close() + }) + + it(`Owner sees general (the previous channel)`, async () => { + expect(await generalChannelOwner.isOpen()).toBeTruthy() + expect(await generalChannelOwner.isReady()).toBeTruthy() + expect(await generalChannelOwner.isMessageInputReady()).toBeTruthy() + }) + + it("First user's message is visible in a channel to the owner", async () => { + await generalChannelOwner.getUserMessages(users.user1.username) + await generalChannelOwner.getMessageIdsByText(users.user1.messages.general[0], users.user1.username) + }) + }) + + describe('Open existing DM in New Message view and send message', () => { + it('Owner opens new message view', async () => { + newMessageOwner = new NewMessage(users.owner.app.driver) + await newMessageOwner.open() + }) + + it('Owner switches new message view to group DM', async () => { + const dmCreationStatus = await newMessageOwner.changeDmUsers([users.user1.username, users.user2.username]) + expect(dmCreationStatus.failedUsers).toHaveLength(0) + expect(dmCreationStatus.successfulUsers).toHaveLength(2) + }) + + it(`Owner sees first user's message from group DM in new message view`, async () => { + await groupDmChannelOwner.getUserMessages(users.user1.username) + await groupDmChannelOwner.getMessageIdsByText(users.user1.messages.groupDm[0], users.user1.username) + }) + + it('Owner sends message in group DM channel via new message', async () => { + await groupDmChannelOwner.sendMessage(users.owner.messages.groupDm[1], users.owner.username) + }) + + it('Owner is now on the channel screen for the group DM', async () => { + expect(await groupDmChannelOwner.isMessageInputReady()).toBeTruthy() + expect(await groupDmChannelOwner.isOpen(TestChannelType.DM, false, 30_000)).toBeTruthy() + }) + + it('Owner sees their message in the group DM channel', async () => { + await groupDmChannelOwner.getUserMessages(users.owner.username) + await groupDmChannelOwner.getMessageIdsByText(users.owner.messages.groupDm[1], users.owner.username) + }) + + it(`Second user sees owner's second message in the group DM channel`, async () => { + await groupDmChannelUser2.getUserMessages(users.owner.username) + await groupDmChannelUser2.getMessageIdsByText(users.owner.messages.groupDm[1], users.owner.username) + }) + + it(`First user sees owner's second message in the group DM channel`, async () => { + await groupDmChannelUser1.getUserMessages(users.owner.username) + await groupDmChannelUser1.getMessageIdsByText(users.owner.messages.groupDm[1], users.owner.username) + }) + }) + }) +}) diff --git a/packages/e2e-tests/src/tests/multipleClients.dms.test.ts b/packages/e2e-tests/src/tests/multipleClients.dms.test.ts new file mode 100644 index 0000000000..4d48df4f3f --- /dev/null +++ b/packages/e2e-tests/src/tests/multipleClients.dms.test.ts @@ -0,0 +1,623 @@ +import { jest } from '@jest/globals' +import * as path from 'path' + +import { + App, + Channel, + ChannelContextMenu, + CreateCommunityModal, + DebugModeModal, + JoinCommunityModal, + JoiningLoadingPanel, + NewMessage, + RegisterUsernameModal, + Settings, + Sidebar, +} from '../selectors' +import { createArbitraryFile, promiseWithRetries, sleep } from '../utils' +import { MessageIds, TestChannelType, UserListStatus, UserTestData, UserTestData2, UserTestDataMap } from '../types' +import { createLogger } from '../logger' +import { FileAttachmentType, SettingsModalTabName } from '../enums' +import { + BIG_FILE_SIZE, + TEST_BIG_FILE_NAME, + TEST_FILE_NAME, + TEST_IMAGE_FILE_NAME, + UPLOAD_FILE_DIR, +} from '../attachFile.const' + +const logger = createLogger('multipleClients:dms') + +jest.setTimeout(1200000) // 20 minutes +describe('Multiple Clients (DMs)', () => { + let generalChannelOwner: Channel + let generalChannelUser1: Channel + let generalChannelUser2: Channel + + let selfDmChannelOwner: Channel + let oneOnOneChannelOwner: Channel + let oneOnOneChannelUser1: Channel + let groupDmChannelOwner: Channel + let groupDmChannelUser1: Channel + let groupDmChannelUser2: Channel + let channelContextMenuOwner: ChannelContextMenu + + let invitationLink: string + + let sidebarOwner: Sidebar + let sidebarUser1: Sidebar + let sidebarUser2: Sidebar + + let settingsOwner: Settings + let settingsUser1: Settings + let settingsUser2: Settings + + let newMessageOwner: NewMessage + let newMessageUser1: NewMessage + let newMessageUser2: NewMessage + + const communityName = 'testcommunity' + const displayedCommunityName = 'Testcommunity' + const generalChannelName = 'general' + + type Usernames = 'owner' | 'user1' | 'user2' + type ChannelNames = 'selfDm' | 'oneOnOne' | 'groupDm' | 'general' + let users: UserTestDataMap> + let channelNameMap: Record> + + beforeAll(async () => { + users = { + owner: { + username: 'owner', + app: new App(), + messages: { + general: ['Hi'], + selfDm: ['Only I can see this', `I'm still the only one who can see this`], + oneOnOne: [`I'm talking to just you`], + groupDm: [`Multiple people can see this`, `I sent this from the new message form!`], + }, + }, + user1: { + username: 'user-1', + app: new App(), + messages: { + general: ['Hello'], + selfDm: [], + oneOnOne: [`This is between us`], + groupDm: [`Hey everyone`], + }, + }, + user2: { + username: 'user-2', + app: new App(), + messages: { + general: [`I'm here too`], + selfDm: [], + oneOnOne: [], + groupDm: [`We're all here!`], + }, + }, + } + + channelNameMap = { + owner: { + general: generalChannelName, + selfDm: users.owner.username, + oneOnOne: users.user1.username, + groupDm: `${users.user1.username}, ${users.user2.username}`, + }, + user1: { + general: generalChannelName, + selfDm: '', + oneOnOne: users.owner.username, + groupDm: `${users.owner.username}, ${users.user2.username}`, + }, + user2: { + general: generalChannelName, + selfDm: '', + oneOnOne: '', + groupDm: `${users.owner.username}, ${users.user1.username}`, + }, + } + }) + + afterAll(async () => { + for (const user of Object.values(users)) { + try { + await user.app.close() + await user.app.cleanup() + } catch (e) { + logger.error('Failed to close and cleanup app data', e) + } + } + }) + + beforeEach(async () => { + logger.info(`░░░ ${expect.getState().currentTestName}`) + }) + + describe('Stages:', () => { + describe('Owner Creates Community', () => { + it('Owner opens the app', async () => { + await users.owner.app.openWithRetries() + }) + + it('Owner sees "join community" modal and switches to "create community" modal', async () => { + const joinModal = new JoinCommunityModal(users.owner.app.driver) + expect(await joinModal.isReady()).toBeTruthy() + await joinModal.switchToCreateCommunity() + }) + + it('Owner submits valid community name', async () => { + const createModal = new CreateCommunityModal(users.owner.app.driver) + expect(await createModal.isReady()).toBeTruthy() + await createModal.typeCommunityName(communityName) + await createModal.submit() + }) + + it('Owner sees "register username" modal and submits valid username', async () => { + const registerModal = new RegisterUsernameModal(users.owner.app.driver) + expect(await registerModal.isReady()).toBeTruthy() + await registerModal.typeUsername(users.owner.username) + await registerModal.submit() + }) + + it('Owner waits to join', async () => { + const joinPanel = new JoiningLoadingPanel(users.owner.app.driver) + await joinPanel.waitForJoinToComplete() + }) + + it('Owner registers successfully and sees general channel', async () => { + generalChannelOwner = new Channel(users.owner.app.driver, generalChannelName) + expect(await generalChannelOwner.isReady()).toBeTruthy() + expect(await generalChannelOwner.isOpen()).toBeTruthy() + sidebarOwner = new Sidebar(users.owner.app.driver) + await sidebarOwner.getChannelIcon(generalChannelName, true) + + const generalChannelText = await generalChannelOwner.element.getText() + expect(generalChannelText).toEqual('general') + }) + + it.skip('Owner opens community membership tab', async () => { + settingsOwner = await new Sidebar(users.owner.app.driver).openSettings() + expect(await settingsOwner.isReady()).toBeTruthy() + await settingsOwner.openCommunityMembership(1) + }) + + it.skip('Owner sees self in user list', async () => { + const status = await settingsOwner.getUserInCommunityMembership( + users.owner.username, + UserListStatus.ONLINE, + true + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('Owner closes community membership tab', async () => { + await settingsOwner.closeTabThenModal() + }) + }) + + describe('Owner Sends a Message in General', () => { + it('Owner sends a message in general channel', async () => { + expect(await generalChannelOwner.isMessageInputReady()).toBeTruthy() + await generalChannelOwner.sendMessage(users.owner.messages.general[0], users.owner.username) + }) + + it("Owner's message is visible in general channel", async () => { + await generalChannelOwner.getUserMessages(users.owner.username) + await generalChannelOwner.getMessageIdsByText(users.owner.messages.general[0], users.owner.username) + }) + }) + + describe('Owner creates DM with self', () => { + it('Owner opens new message view', async () => { + newMessageOwner = new NewMessage(users.owner.app.driver) + await newMessageOwner.open() + }) + + it('Owner creates new DM with self', async () => { + const dmCreationStatus = await newMessageOwner.createNewDm( + [users.owner.username], + users.owner.messages.selfDm[0] + ) + logger.info('DM creation status', dmCreationStatus) + expect(dmCreationStatus.error).toBeUndefined() + expect(dmCreationStatus.failedUsers).toHaveLength(0) + expect(dmCreationStatus.successfulUsers).toHaveLength(1) + expect(dmCreationStatus.success).toBeTruthy() + }) + + it('Owner sees newly created DM channel', async () => { + selfDmChannelOwner = new Channel(users.owner.app.driver, channelNameMap.owner.selfDm) + await selfDmChannelOwner.isOpen(TestChannelType.DM, false, 60_000) + await selfDmChannelOwner.isMessageInputReady() + }) + + it('Owner sees their message in the newly created DM channel', async () => { + await selfDmChannelOwner.getUserMessages(users.owner.username) + await selfDmChannelOwner.getMessageIdsByText(users.owner.messages.selfDm[0], users.owner.username) + }) + }) + + describe('Owner invites first user', () => { + it('Owner opens the settings tab and gets an invitation link', async () => { + const settingsModal = await new Sidebar(users.owner.app.driver).openSettings() + expect(await settingsModal.isReady()).toBeTruthy() + await settingsModal.switchTab(SettingsModalTabName.INVITE) + const invitationLinkElement = await settingsModal.invitationLink() + invitationLink = await invitationLinkElement.getText() + expect(invitationLink).not.toBeUndefined() + logger.info('Received invitation link:', invitationLink) + logger.warn('closing invite tab') + await settingsModal.closeTabThenModal() + }) + }) + + describe('First User Joins Community', () => { + it('First user opens the app', async () => { + logger.info('Second client') + await users.user1.app.openWithRetries() + }) + + it('First user submits invitation code received from owner', async () => { + logger.info('new user - 3') + const joinCommunityModal = new JoinCommunityModal(users.user1.app.driver) + expect(await joinCommunityModal.isReady()).toBeTruthy() + logger.info({ invitationLink }) + await joinCommunityModal.typeCommunityInviteLink(invitationLink) + await joinCommunityModal.submit() + }) + + it('First user submits valid username', async () => { + const app = users.user1.app + logger.info('new user - 5') + const registerModal = new RegisterUsernameModal(app.driver) + expect(await registerModal.isReady()).toBeTruthy() + await registerModal.clearInput() + await registerModal.typeUsername(users.user1.username) + await registerModal.submit() + logger.time(`[${app.name}] '${users.user1.username}' joining community time`) + }) + + it('First user waits to join', async () => { + const joinPanel = new JoiningLoadingPanel(users.user1.app.driver) + await joinPanel.waitForJoinToComplete() + }) + + it('First user joins successfully sees general channel and sends a message', async () => { + logger.info('new user - 7') + const app = users.user1.app + const loadNewUser = async () => { + generalChannelUser1 = new Channel(app.driver, generalChannelName) + expect(await generalChannelUser1.isReady()).toBeTruthy() + expect(await generalChannelUser1.isOpen()).toBeTruthy() + expect(await generalChannelUser1.isMessageInputReady()).toBeTruthy() + logger.timeEnd(`[${app.name}] '${users.user1.username}' joining community time`) + } + + const retryConfig = app.retryConfig + const failureReason = `Failed to load app for new user ${users.user1.username} within ${retryConfig.timeoutMs}ms` + const onTimeout = async () => { + await app.close() + await app.open() + } + await promiseWithRetries(loadNewUser(), failureReason, retryConfig, onTimeout) + await sleep(2_000) + await generalChannelUser1.sendMessage(users.user1.messages.general[0], users.user1.username) + }) + + it("First user sees owner's message in channel", async () => { + await generalChannelUser1.getUserMessages(users.owner.username) + await generalChannelUser1.getMessageIdsByText(users.owner.messages.general[0], users.owner.username) + }) + + it("First user's message is visible in a channel", async () => { + await generalChannelUser1.getUserMessages(users.user1.username) + await generalChannelUser1.getMessageIdsByText(users.user1.messages.general[0], users.user1.username) + }) + + it("First user's message is visible in a channel to the owner", async () => { + generalChannelOwner = await sidebarOwner.switchChannel(generalChannelName) + await generalChannelOwner.getUserMessages(users.user1.username) + await generalChannelOwner.getMessageIdsByText(users.user1.messages.general[0], users.user1.username) + }) + }) + + describe('First user creates DM with owner', () => { + it('First user opens new message view', async () => { + newMessageUser1 = new NewMessage(users.user1.app.driver) + await newMessageUser1.open() + }) + + it('First user creates new DM with owner', async () => { + const dmCreationStatus = await newMessageUser1.createNewDm( + [users.owner.username], + users.user1.messages.oneOnOne[0] + ) + expect(dmCreationStatus.error).toBeUndefined() + expect(dmCreationStatus.failedUsers).toHaveLength(0) + expect(dmCreationStatus.successfulUsers).toHaveLength(1) + expect(dmCreationStatus.success).toBeTruthy() + }) + + it('First user sees newly created DM channel', async () => { + oneOnOneChannelUser1 = new Channel(users.user1.app.driver, channelNameMap.user1.oneOnOne) + await oneOnOneChannelUser1.isOpen(TestChannelType.DM) + await oneOnOneChannelUser1.isMessageInputReady() + }) + + it('First user sees their message in the newly created DM channel', async () => { + await oneOnOneChannelUser1.getUserMessages(users.user1.username) + await oneOnOneChannelUser1.getMessageIdsByText(users.user1.messages.oneOnOne[0], users.user1.username) + }) + + it('Owner sees new DM in sidebar', async () => { + sidebarOwner = new Sidebar(users.owner.app.driver) + await sidebarOwner.waitForDmChannelsNum(2, 45_000) + await sidebarOwner.waitForDmChannels([channelNameMap.owner.oneOnOne]) + }) + + it('Owner opens new DM channel', async () => { + oneOnOneChannelOwner = await sidebarOwner.switchDm(channelNameMap.owner.oneOnOne) + await oneOnOneChannelOwner.isOpen(TestChannelType.DM) + await oneOnOneChannelOwner.isMessageInputReady() + }) + + it(`Owner sees first user's message in the newly created DM channel`, async () => { + await oneOnOneChannelOwner.getUserMessages(users.user1.username) + await oneOnOneChannelOwner.getMessageIdsByText(users.user1.messages.oneOnOne[0], users.user1.username) + }) + + it('Owner sends message in new DM channel', async () => { + await oneOnOneChannelOwner.sendMessage(users.owner.messages.oneOnOne[0], users.owner.username) + }) + + it('Owner sees their message in the newly created DM channel', async () => { + await oneOnOneChannelOwner.getUserMessages(users.owner.username) + await oneOnOneChannelOwner.getMessageIdsByText(users.owner.messages.oneOnOne[0], users.owner.username) + }) + + it(`First user sees owner's message in the newly created DM channel`, async () => { + await oneOnOneChannelUser1.getUserMessages(users.owner.username) + await oneOnOneChannelUser1.getMessageIdsByText(users.owner.messages.oneOnOne[0], users.owner.username) + }) + }) + + describe('Owner invites second user', () => { + it('Owner opens the settings tab and gets an updated invitation link', async () => { + const settingsModal = await new Sidebar(users.owner.app.driver).openSettings() + expect(await settingsModal.isReady()).toBeTruthy() + await settingsModal.switchTab(SettingsModalTabName.INVITE) + const invitationLinkElement = await settingsModal.invitationLink() + invitationLink = await invitationLinkElement.getText() + expect(invitationLink).not.toBeUndefined() + logger.info('Received invitation link:', invitationLink) + logger.warn('closing invite tab') + await settingsModal.closeTabThenModal() + }) + }) + + describe('Second User Joins Community', () => { + it('Second user opens the app', async () => { + logger.info('Third client') + await users.user2.app.openWithRetries() + }) + + it('Second user submits invitation code received from owner', async () => { + const joinCommunityModal = new JoinCommunityModal(users.user2.app.driver) + expect(await joinCommunityModal.isReady()).toBeTruthy() + await joinCommunityModal.typeCommunityInviteLink(invitationLink) + await joinCommunityModal.submit() + }) + + it('Second user submits valid username', async () => { + const app = users.user2.app + logger.info('new user - 5') + const registerModal = new RegisterUsernameModal(app.driver) + expect(await registerModal.isReady()).toBeTruthy() + await registerModal.clearInput() + await registerModal.typeUsername(users.user2.username) + await registerModal.submit() + logger.time(`[${app.name}] '${users.user2.username}' joining community time`) + }) + + it('Second user waits to join', async () => { + const joinPanel = new JoiningLoadingPanel(users.user2.app.driver) + await joinPanel.waitForJoinToComplete() + }) + + it('Second user joins successfully sees general channel and sends a message', async () => { + const app = users.user2.app + const loadNewUser = async () => { + generalChannelUser2 = new Channel(app.driver, generalChannelName) + expect(await generalChannelUser2.isReady()).toBeTruthy() + expect(await generalChannelUser2.isOpen()).toBeTruthy() + expect(await generalChannelUser2.isMessageInputReady()).toBeTruthy() + logger.timeEnd(`[${app.name}] '${users.user2.username}' joining community time`) + } + + const retryConfig = app.retryConfig + const failureReason = `Failed to load app for new user ${users.user2.username} within ${retryConfig.timeoutMs}ms` + const onTimeout = async () => { + await app.close() + await app.open() + } + await promiseWithRetries(loadNewUser(), failureReason, retryConfig, onTimeout) + await generalChannelUser2.sendMessage(users.user2.messages.general[0], users.user2.username) + }) + + it("Second user's message is visible in a channel", async () => { + await generalChannelUser2.getUserMessages(users.user2.username) + await generalChannelUser2.getMessageIdsByText(users.user2.messages.general[0], users.user2.username) + }) + + it("Second user sees owner's message in channel", async () => { + await generalChannelUser2.getUserMessages(users.owner.username) + await generalChannelUser2.getMessageIdsByText(users.owner.messages.general[0], users.owner.username) + }) + + it("Second user sees first user's message in channel", async () => { + await generalChannelUser2.getUserMessages(users.user1.username) + await generalChannelUser2.getMessageIdsByText(users.user1.messages.general[0], users.user1.username) + }) + + it("Second user's message is visible in a channel to the owner", async () => { + generalChannelOwner = await sidebarOwner.switchChannel(generalChannelName) + await generalChannelOwner.getUserMessages(users.user2.username) + await generalChannelOwner.getMessageIdsByText(users.user2.messages.general[0], users.user2.username) + }) + + it("Second user's message is visible in a channel to the first user", async () => { + sidebarUser1 = new Sidebar(users.user1.app.driver) + generalChannelUser1 = await sidebarUser1.switchChannel(generalChannelName) + await generalChannelUser1.getUserMessages(users.user2.username) + await generalChannelUser1.getMessageIdsByText(users.user2.messages.general[0], users.user2.username) + }) + }) + + describe('Second user creates group DM', () => { + it('Second user opens new message view', async () => { + newMessageUser2 = new NewMessage(users.user2.app.driver) + await newMessageUser2.open() + }) + + it('Second user creates new group DM', async () => { + const dmCreationStatus = await newMessageUser2.createNewDm( + [users.owner.username, users.user1.username], + users.user2.messages.groupDm[0] + ) + expect(dmCreationStatus.error).toBeUndefined() + expect(dmCreationStatus.failedUsers).toHaveLength(0) + expect(dmCreationStatus.successfulUsers).toHaveLength(2) + expect(dmCreationStatus.success).toBeTruthy() + }) + + it('Second user sees newly created group DM channel', async () => { + groupDmChannelUser2 = new Channel(users.user2.app.driver, channelNameMap.user2.groupDm) + await groupDmChannelUser2.isOpen(TestChannelType.DM) + await groupDmChannelUser2.isMessageInputReady() + }) + + it('Owner sees group DM in sidebar', async () => { + sidebarOwner = new Sidebar(users.owner.app.driver) + await sidebarOwner.waitForDmChannelsNum(3, 45_000) + await sidebarOwner.waitForDmChannels([channelNameMap.owner.groupDm]) + }) + + it('Owner opens group DM channel', async () => { + groupDmChannelOwner = await sidebarOwner.switchDm(channelNameMap.owner.groupDm) + await groupDmChannelOwner.isOpen(TestChannelType.DM) + await groupDmChannelOwner.isMessageInputReady() + }) + + it(`Owner sees second user's message in the group DM channel`, async () => { + await groupDmChannelOwner.getUserMessages(users.user2.username) + await groupDmChannelOwner.getMessageIdsByText(users.user2.messages.groupDm[0], users.user2.username) + }) + + it('Owner sends message in group DM channel', async () => { + await groupDmChannelOwner.sendMessage(users.owner.messages.groupDm[0], users.owner.username) + }) + + it('Owner sees their message in the group DM channel', async () => { + await groupDmChannelOwner.getUserMessages(users.owner.username) + await groupDmChannelOwner.getMessageIdsByText(users.owner.messages.groupDm[0], users.owner.username) + }) + + it(`Second user sees owner's message in the group DM channel`, async () => { + await groupDmChannelUser2.getUserMessages(users.owner.username) + await groupDmChannelUser2.getMessageIdsByText(users.owner.messages.groupDm[0], users.owner.username) + }) + + it('First user sees group DM in sidebar', async () => { + sidebarUser1 = new Sidebar(users.user1.app.driver) + await sidebarUser1.waitForDmChannelsNum(2, 45_000) + await sidebarUser1.waitForDmChannels([channelNameMap.user1.groupDm]) + }) + + it('First user opens group DM channel', async () => { + groupDmChannelUser1 = await sidebarUser1.switchDm(channelNameMap.user1.groupDm) + await groupDmChannelUser1.isOpen(TestChannelType.DM) + await groupDmChannelUser1.isMessageInputReady() + }) + + it(`First user sees second user's message in the group DM channel`, async () => { + await groupDmChannelUser1.getUserMessages(users.user2.username) + await groupDmChannelUser1.getMessageIdsByText(users.user2.messages.groupDm[0], users.user2.username) + }) + + it(`First user sees owner's message in the group DM channel`, async () => { + await groupDmChannelUser1.getUserMessages(users.owner.username) + await groupDmChannelUser1.getMessageIdsByText(users.owner.messages.groupDm[0], users.owner.username) + }) + + it('First user sends message in group DM channel', async () => { + await groupDmChannelUser1.sendMessage(users.user1.messages.groupDm[0], users.user1.username) + }) + + it('First user sees their message in the group DM channel', async () => { + await groupDmChannelUser1.getUserMessages(users.user1.username) + await groupDmChannelUser1.getMessageIdsByText(users.user1.messages.groupDm[0], users.user1.username) + }) + + it(`Second user sees first user's message in the group DM channel`, async () => { + await groupDmChannelUser2.getUserMessages(users.user1.username) + await groupDmChannelUser2.getMessageIdsByText(users.user1.messages.groupDm[0], users.user1.username) + }) + + it(`Owner sees first user's message in the group DM channel`, async () => { + await groupDmChannelOwner.getUserMessages(users.user1.username) + await groupDmChannelOwner.getMessageIdsByText(users.user1.messages.groupDm[0], users.user1.username) + }) + + it('Second user sees their message in the newly created DM channel', async () => { + await groupDmChannelUser2.getUserMessages(users.user2.username) + await groupDmChannelUser2.getMessageIdsByText(users.user2.messages.groupDm[0], users.user2.username) + }) + }) + + describe('Open existing DM in New Message view', () => { + it(`Owner opens general`, async () => { + generalChannelOwner = await sidebarOwner.switchChannel(generalChannelName) + expect(await generalChannelOwner.isOpen()).toBeTruthy() + expect(await generalChannelOwner.isReady()).toBeTruthy() + expect(await generalChannelOwner.isMessageInputReady()).toBeTruthy() + }) + + it('Owner opens new message view', async () => { + newMessageOwner = new NewMessage(users.owner.app.driver) + await newMessageOwner.open() + }) + + it('Owner switches new message view to group DM', async () => { + const dmCreationStatus = await newMessageOwner.changeDmUsers([users.user1.username, users.user2.username]) + expect(dmCreationStatus.failedUsers).toHaveLength(0) + expect(dmCreationStatus.successfulUsers).toHaveLength(2) + }) + + it(`Owner sees first user's message from group DM in new message view`, async () => { + await groupDmChannelOwner.getUserMessages(users.user1.username) + await groupDmChannelOwner.getMessageIdsByText(users.user1.messages.groupDm[0], users.user1.username) + }) + }) + + describe('Close new message view and reopen previous channel', () => { + it(`Owner closes the new message view`, async () => { + await newMessageOwner.close() + }) + + it(`Owner sees general (the previous channel)`, async () => { + expect(await generalChannelOwner.isOpen()).toBeTruthy() + expect(await generalChannelOwner.isReady()).toBeTruthy() + expect(await generalChannelOwner.isMessageInputReady()).toBeTruthy() + }) + + it("First user's message is visible in a channel to the owner", async () => { + await generalChannelOwner.getUserMessages(users.user1.username) + await generalChannelOwner.getMessageIdsByText(users.user1.messages.general[0], users.user1.username) + }) + }) + }) +}) diff --git a/packages/e2e-tests/src/tests/multipleClients.privateChannels.qss.test.ts b/packages/e2e-tests/src/tests/multipleClients.privateChannels.qss.test.ts index f76cd5a88f..95e7a4969f 100644 --- a/packages/e2e-tests/src/tests/multipleClients.privateChannels.qss.test.ts +++ b/packages/e2e-tests/src/tests/multipleClients.privateChannels.qss.test.ts @@ -10,12 +10,12 @@ import { JoiningLoadingPanel, RegisterUsernameModal, ServerOfferModal, + Settings, Sidebar, TermsOfServiceModal, - UsersList, } from '../selectors' import { promiseWithRetries, sleep, tailQssLogs } from '../utils' -import { UserListStatus, UserTestData2, UserTestDataMap } from '../types' +import { DEFAULT_ADD_NEW_CHANNEL_PRIVATE_OPTIONS, UserListStatus, UserTestData2, UserTestDataMap } from '../types' import { createLogger } from '../logger' import { SettingsModalTabName } from '../enums' import { ChildProcess } from 'child_process' @@ -41,6 +41,10 @@ describe('Multiple Clients (QSS - Private Channels)', () => { let sidebarUser1: Sidebar let sidebarUser2: Sidebar + let settingsOwner: Settings + let settingsUser1: Settings + let settingsUser2: Settings + let qssLogTailProcess: ChildProcess const communityName = 'testcommunity' @@ -160,6 +164,26 @@ describe('Multiple Clients (QSS - Private Channels)', () => { expect(generalChannelText).toEqual('general') await sleep(10_000) }) + + it.skip('Owner opens community membership tab', async () => { + settingsOwner = await new Sidebar(users.owner.app.driver).openSettings() + expect(await settingsOwner.isReady()).toBeTruthy() + await settingsOwner.openCommunityMembership(1) + }) + + it.skip('Owner sees self in user list', async () => { + const ownStatus = await settingsOwner.getUserInCommunityMembership( + users.owner.username, + UserListStatus.ONLINE, + true + ) + expect(ownStatus.status).toBe(UserListStatus.ONLINE) + expect(ownStatus.textMatches).toBe(true) + }) + + it.skip('Owner closes community membership tab', async () => { + await settingsOwner.closeTabThenModal() + }) }) describe('Owner Sends a Message in General', () => { @@ -177,10 +201,16 @@ describe('Multiple Clients (QSS - Private Channels)', () => { describe('Creating Private Channel Before User Joins', () => { describe('Owner Creates a Private Channel', () => { it('Owner creates a private channel', async () => { - await sidebarOwner.addNewChannel(privateChannelName, false) - await sidebarOwner.switchChannel(privateChannelName, false) + const { channel, errors } = await sidebarOwner.addNewChannel( + privateChannelName, + DEFAULT_ADD_NEW_CHANNEL_PRIVATE_OPTIONS + ) + expect(channel).toBeDefined() + expect(errors).toBeUndefined() + await sidebarOwner.waitForChannelsNum(2) const channels = await sidebarOwner.getChannelsNames() expect(channels).toContain(privateChannelName) + await sidebarOwner.switchChannel(privateChannelName, false) await sidebarOwner.getChannelIcon(privateChannelName, false) }) @@ -248,11 +278,6 @@ describe('Multiple Clients (QSS - Private Channels)', () => { await joinPanel.waitForJoinToComplete() }) - it('First user sees user list', async () => { - const userList = new UsersList(users.user1.app.driver) - expect(await userList.isReady()).toBeTruthy() - }) - it('First user sees general channel', async () => { const app = users.user1.app const loadNewUser = async () => { @@ -272,16 +297,54 @@ describe('Multiple Clients (QSS - Private Channels)', () => { await promiseWithRetries(loadNewUser(), failureReason, retryConfig, onTimeout) }) - it('User sees owner in user list', async () => { - const userList = new UsersList(users.user1.app.driver) - expect(await userList.isReady()).toBeTruthy() - expect(await userList.getUser(users.owner.username, UserListStatus.ONLINE)) + it.skip('First user opens community membership tab', async () => { + settingsUser1 = await new Sidebar(users.user1.app.driver).openSettings() + expect(await settingsUser1.isReady()).toBeTruthy() + await settingsUser1.openCommunityMembership(2) + }) + + it.skip('First user sees self in user list', async () => { + const ownStatus = await settingsUser1.getUserInCommunityMembership( + users.user1.username, + UserListStatus.ONLINE, + true + ) + expect(ownStatus.status).toBe(UserListStatus.ONLINE) + expect(ownStatus.textMatches).toBe(true) + }) + + it.skip('First user sees owner in user list', async () => { + const status = await settingsUser1.getUserInCommunityMembership( + users.owner.username, + UserListStatus.ONLINE, + false + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('First user closes community membership tab', async () => { + await settingsUser1.closeTabThenModal() + }) + + it.skip('Owner opens community membership tab', async () => { + settingsOwner = await new Sidebar(users.owner.app.driver).openSettings() + expect(await settingsOwner.isReady()).toBeTruthy() + await settingsOwner.openCommunityMembership(2) + }) + + it.skip('Owner sees first user in user list', async () => { + const ownStatus = await settingsOwner.getUserInCommunityMembership( + users.user1.username, + UserListStatus.ONLINE, + false + ) + expect(ownStatus.status).toBe(UserListStatus.ONLINE) + expect(ownStatus.textMatches).toBe(true) }) - it('Owner sees user in user list', async () => { - const userList = new UsersList(users.owner.app.driver) - expect(await userList.isReady()).toBeTruthy() - expect(await userList.getUser(users.user1.username, UserListStatus.ONLINE)) + it.skip('Owner closes community membership tab', async () => { + await settingsOwner.closeTabThenModal() }) it("Owner's message is visible in general channel to user", async () => { @@ -381,7 +444,6 @@ describe('Multiple Clients (QSS - Private Channels)', () => { sidebarUser1 = new Sidebar(users.user1.app.driver) await sidebarUser1.switchChannel(privateChannelName, false) privateChannelUser1 = new Channel(users.user1.app.driver, privateChannelName) - expect(await privateChannelUser1.isOpen(false)) expect(await privateChannelUser1.isMessageInputReady()).toBeTruthy() }) @@ -419,11 +481,16 @@ describe('Multiple Clients (QSS - Private Channels)', () => { describe('Owner Creates Another Private Channel', () => { it('Owner creates a second private channel', async () => { sidebarOwner = new Sidebar(users.owner.app.driver) - await sidebarOwner.addNewChannel(privateChannel2Name, false) - await sidebarOwner.switchChannel(privateChannel2Name, false) + const { channel, errors } = await sidebarOwner.addNewChannel( + privateChannel2Name, + DEFAULT_ADD_NEW_CHANNEL_PRIVATE_OPTIONS + ) + expect(channel).toBeDefined() + expect(errors).toBeUndefined() await sidebarOwner.waitForChannelsNum(3) const channels = await sidebarOwner.getChannelsNames() expect(channels).toContain(privateChannel2Name) + await sidebarOwner.switchChannel(privateChannel2Name, false) await sidebarOwner.getChannelIcon(privateChannel2Name, false) }) @@ -559,11 +626,6 @@ describe('Multiple Clients (QSS - Private Channels)', () => { await joinPanel.waitForJoinToComplete() }) - it('Second user sees user list', async () => { - const userList = new UsersList(users.user2.app.driver) - expect(await userList.isReady()).toBeTruthy() - }) - it('Second user sees general channel', async () => { const app = users.user2.app const loadNewUser = async () => { @@ -595,6 +657,46 @@ describe('Multiple Clients (QSS - Private Channels)', () => { expect(channels.length).toBe(1) expect(channels).toContain(generalChannelName) }) + + it.skip('Second user opens community membership', async () => { + settingsUser2 = await new Sidebar(users.user2.app.driver).openSettings() + expect(await settingsUser2.isReady()).toBeTruthy() + await settingsUser2.openCommunityMembership(3) + }) + + it.skip('Second user sees self in user list', async () => { + const ownStatus = await settingsUser2.getUserInCommunityMembership( + users.user2.username, + UserListStatus.ONLINE, + true + ) + expect(ownStatus.status).toBe(UserListStatus.ONLINE) + expect(ownStatus.textMatches).toBe(true) + }) + + it.skip('Second user sees user in user list', async () => { + const status = await settingsUser2.getUserInCommunityMembership( + users.user1.username, + UserListStatus.OFFLINE, + false + ) + expect(status.status).toBe(UserListStatus.OFFLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('Second user sees owner in user list', async () => { + const status = await settingsUser2.getUserInCommunityMembership( + users.owner.username, + UserListStatus.OFFLINE, + false + ) + expect(status.status).toBe(UserListStatus.OFFLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('Second user closes community membership tab', async () => { + await settingsUser2.closeTabThenModal() + }) }) }) }) diff --git a/packages/e2e-tests/src/tests/multipleClients.privateChannels.test.ts b/packages/e2e-tests/src/tests/multipleClients.privateChannels.test.ts index be3b4a028e..f82c04711f 100644 --- a/packages/e2e-tests/src/tests/multipleClients.privateChannels.test.ts +++ b/packages/e2e-tests/src/tests/multipleClients.privateChannels.test.ts @@ -10,11 +10,11 @@ import { JoinCommunityModal, JoiningLoadingPanel, RegisterUsernameModal, + Settings, Sidebar, - UsersList, } from '../selectors' import { createArbitraryFile, promiseWithRetries } from '../utils' -import { MessageIds, UserListStatus, UserTestData } from '../types' +import { DEFAULT_ADD_NEW_CHANNEL_PRIVATE_OPTIONS, MessageIds, UserListStatus, UserTestData } from '../types' import { createLogger } from '../logger' import { FileAttachmentType, SettingsModalTabName } from '../enums' import { @@ -47,6 +47,10 @@ describe('Multiple Clients (Private Channels)', () => { let sidebarUser1: Sidebar let sidebarUser2: Sidebar + let settingsOwner: Settings + let settingsUser1: Settings + let settingsUser2: Settings + let users: Record const communityName = 'testcommunity' @@ -155,7 +159,13 @@ describe('Multiple Clients (Private Channels)', () => { describe('Creating Private Channel Before User Joins', () => { describe('Owner Creates a Private Channel', () => { it('Owner creates a private channel', async () => { - await sidebarOwner.addNewChannel(privateChannelName, false) + const { channel, errors } = await sidebarOwner.addNewChannel( + privateChannelName, + DEFAULT_ADD_NEW_CHANNEL_PRIVATE_OPTIONS + ) + expect(channel).toBeDefined() + expect(errors).toBeUndefined() + await sidebarOwner.waitForChannelsNum(2) await sidebarOwner.switchChannel(privateChannelName, false) }) @@ -246,16 +256,54 @@ describe('Multiple Clients (Private Channels)', () => { await promiseWithRetries(loadNewUser(), failureReason, retryConfig, onTimeout) }) - it('User sees owner in user list', async () => { - const userList = new UsersList(users.user1.app.driver) - expect(await userList.isReady()).toBeTruthy() - expect(await userList.getUser(users.owner.username, UserListStatus.ONLINE)) + it.skip('First user opens community membership tab', async () => { + settingsUser1 = await new Sidebar(users.user1.app.driver).openSettings() + expect(await settingsUser1.isReady()).toBeTruthy() + await settingsUser1.openCommunityMembership(2) + }) + + it.skip('First user sees self in user list', async () => { + const status = await settingsUser1.getUserInCommunityMembership( + users.user1.username, + UserListStatus.ONLINE, + true + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('First user sees owner in user list', async () => { + const status = await settingsUser1.getUserInCommunityMembership( + users.owner.username, + UserListStatus.ONLINE, + false + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('First user closes community membership tab', async () => { + await settingsUser1.closeTabThenModal() }) - it('Owner sees user in user list', async () => { - const userList = new UsersList(users.owner.app.driver) - expect(await userList.isReady()).toBeTruthy() - expect(await userList.getUser(users.user1.username, UserListStatus.ONLINE)) + it.skip('Owner opens community membership tab', async () => { + settingsOwner = await new Sidebar(users.owner.app.driver).openSettings() + expect(await settingsOwner.isReady()).toBeTruthy() + await settingsOwner.openCommunityMembership(2) + }) + + it.skip('Owner sees user in user list', async () => { + const status = await settingsOwner.getUserInCommunityMembership( + users.user1.username, + UserListStatus.ONLINE, + false + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('Owner closes community membership tab', async () => { + await settingsOwner.closeTabThenModal() }) it("Owner's message is visible in general channel", async () => { @@ -357,10 +405,16 @@ describe('Multiple Clients (Private Channels)', () => { describe('Owner Creates Another Private Channel', () => { it('Owner creates a second private channel', async () => { sidebarOwner = new Sidebar(users.owner.app.driver) - await sidebarOwner.addNewChannel(privateChannel2Name, false) + const { channel, errors } = await sidebarOwner.addNewChannel( + privateChannel2Name, + DEFAULT_ADD_NEW_CHANNEL_PRIVATE_OPTIONS + ) + expect(channel).toBeDefined() + expect(errors).toBeUndefined() + await sidebarOwner.waitForChannelsNum(3) + const channels = await sidebarOwner.getChannelsNames() + expect(channels).toContain(privateChannel2Name) await sidebarOwner.switchChannel(privateChannel2Name, false) - const channels = await sidebarOwner.getChannelList() - expect(channels.length).toEqual(3) }) it(`Second private channel is in owner's sidebar`, async () => { @@ -387,8 +441,7 @@ describe('Multiple Clients (Private Channels)', () => { describe(`Owner Adds User To Second Private Channel`, () => { it(`First user's sidebar is missing private channel`, async () => { sidebarUser1 = new Sidebar(users.user1.app.driver) - const channels = await sidebarUser1.getChannelList() - expect(channels.length).toBe(2) + await sidebarUser1.waitForChannelsNum(2) }) it('Owner adds first user to second private channel', async () => { @@ -506,28 +559,84 @@ describe('Multiple Clients (Private Channels)', () => { await promiseWithRetries(loadNewUser(), failureReason, retryConfig, onTimeout) }) - it('User sees second user in user list', async () => { - const userList = new UsersList(users.user1.app.driver) - expect(await userList.isReady()).toBeTruthy() - expect(await userList.getUser(users.user2.username, UserListStatus.ONLINE)) + it.skip('First user opens community membership tab', async () => { + settingsUser1 = await new Sidebar(users.user1.app.driver).openSettings() + expect(await settingsUser1.isReady()).toBeTruthy() + await settingsUser1.openCommunityMembership(3) }) - it('Owner sees second user in user list', async () => { - const userList = new UsersList(users.owner.app.driver) - expect(await userList.isReady()).toBeTruthy() - expect(await userList.getUser(users.user2.username, UserListStatus.ONLINE)) + it.skip('First user sees second user in user list', async () => { + const status = await settingsUser1.getUserInCommunityMembership( + users.user2.username, + UserListStatus.ONLINE, + false + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('First user closes community membership tab', async () => { + await settingsUser1.closeTabThenModal() + }) + + it.skip('Owner opens community membership tab', async () => { + settingsOwner = await new Sidebar(users.owner.app.driver).openSettings() + expect(await settingsOwner.isReady()).toBeTruthy() + await settingsOwner.openCommunityMembership(3) + }) + + it.skip('Owner user sees second user in user list', async () => { + const status = await settingsOwner.getUserInCommunityMembership( + users.user2.username, + UserListStatus.ONLINE, + false + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('Owner closes community membership tab', async () => { + await settingsOwner.closeTabThenModal() }) - it('Second user sees first user in user list', async () => { - const userList = new UsersList(users.user2.app.driver) - expect(await userList.isReady()).toBeTruthy() - expect(await userList.getUser(users.user1.username, UserListStatus.ONLINE)) + it.skip('Second user opens community membership tab', async () => { + settingsUser2 = await new Sidebar(users.user2.app.driver).openSettings() + expect(await settingsUser2.isReady()).toBeTruthy() + await settingsUser2.openCommunityMembership(3) + }) + + it.skip('Second user sees self in user list', async () => { + const status = await settingsUser2.getUserInCommunityMembership( + users.user2.username, + UserListStatus.ONLINE, + true + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('Second user sees first user in user list', async () => { + const status = await settingsUser2.getUserInCommunityMembership( + users.user1.username, + UserListStatus.ONLINE, + false + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('Second user sees owner in user list', async () => { + const status = await settingsUser2.getUserInCommunityMembership( + users.owner.username, + UserListStatus.ONLINE, + false + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) }) - it('Second user sees owner in user list', async () => { - const userList = new UsersList(users.user2.app.driver) - expect(await userList.isReady()).toBeTruthy() - expect(await userList.getUser(users.owner.username, UserListStatus.ONLINE)) + it.skip('Second user closes community membership tab', async () => { + await settingsUser2.closeTabThenModal() }) it('Second user can see messages from before they joined', async () => { diff --git a/packages/e2e-tests/src/tests/multipleClients.qss.test.ts b/packages/e2e-tests/src/tests/multipleClients.qss.test.ts index a5cf1a9bd5..cb7cd31e52 100644 --- a/packages/e2e-tests/src/tests/multipleClients.qss.test.ts +++ b/packages/e2e-tests/src/tests/multipleClients.qss.test.ts @@ -12,7 +12,7 @@ import { Sidebar, ServerOfferModal, TermsOfServiceModal, - UsersList, + Settings, } from '../selectors' import { promiseWithRetries, tailQssLogs } from '../utils' import { UserListStatus, UserTestData } from '../types' @@ -52,6 +52,10 @@ describe('Multiple Clients (QSS)', () => { let generalChannelUser2: Channel let qssLogTailProcess: ChildProcess + let settingsOwner: Settings + let settingsUser1: Settings + let settingsUser2: Settings + let invitationLink: string let users: MultipleClientsUsers @@ -249,24 +253,64 @@ describe('Multiple Clients (QSS)', () => { await generalChannelUser1.sendMessage(users.user1.messages.initialChannelMessage, users.user1.username) }) - it('First user sees user list', async () => { - const userList = new UsersList(users.user1.app.driver) - expect(await userList.isReady()).toBeTruthy() + it.skip('First user opens community membership tab', async () => { + settingsUser1 = await new Sidebar(users.user1.app.driver).openSettings() + expect(await settingsUser1.isReady()).toBeTruthy() + await settingsUser1.openCommunityMembership(2) + }) + + it.skip('First user sees self in user list', async () => { + const status = await settingsUser1.getUserInCommunityMembership( + users.user1.username, + UserListStatus.ONLINE, + true + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('First user sees owner in user list', async () => { + const status = await settingsUser1.getUserInCommunityMembership( + users.owner.username, + UserListStatus.ONLINE, + false + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('First user closes community membership tab', async () => { + await settingsUser1.closeTabThenModal() + }) + + it.skip('Owner opens community membership tab', async () => { + settingsOwner = await new Sidebar(users.owner.app.driver).openSettings() + expect(await settingsOwner.isReady()).toBeTruthy() + await settingsOwner.openCommunityMembership(2) }) - it('First user sees owner in user list as online', async () => { - const userList = new UsersList(users.user1.app.driver) - await userList.getUser(users.owner.username, UserListStatus.ONLINE) + it.skip('Owner sees self in user list', async () => { + const status = await settingsOwner.getUserInCommunityMembership( + users.owner.username, + UserListStatus.ONLINE, + true + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) }) - it('Owner sees user list', async () => { - const userList = new UsersList(users.owner.app.driver) - expect(await userList.isReady()).toBeTruthy() + it.skip('Owner sees first user in user list', async () => { + const status = await settingsOwner.getUserInCommunityMembership( + users.user1.username, + UserListStatus.ONLINE, + false + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) }) - it('Owner sees first user in user list as online', async () => { - const userList = new UsersList(users.owner.app.driver) - await userList.getUser(users.user1.username, UserListStatus.ONLINE) + it.skip('Owner closes community membership tab', async () => { + await settingsOwner.closeTabThenModal() }) it("First user's message is visible in a channel", async () => { @@ -371,10 +415,24 @@ describe('Multiple Clients (QSS)', () => { await generalChannelUser1.getMessageIdsByText(users.user1.messages.ownerOfflineMessage, users.user1.username) }) - it('First user sees owner in user list as offline', async () => { - const userList = new UsersList(users.user1.app.driver) - const userListOwner = await userList.getUser(users.owner.username, UserListStatus.OFFLINE) - expect(userListOwner.status).toBe(UserListStatus.OFFLINE) + it.skip('First user opens community membership tab', async () => { + settingsUser1 = await new Sidebar(users.user1.app.driver).openSettings() + expect(await settingsUser1.isReady()).toBeTruthy() + await settingsUser1.openCommunityMembership(2) + }) + + it.skip('First user sees owner as offline in user list', async () => { + const status = await settingsUser1.getUserInCommunityMembership( + users.owner.username, + UserListStatus.OFFLINE, + false + ) + expect(status.status).toBe(UserListStatus.OFFLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('First user closes community membership tab', async () => { + await settingsUser1.closeTabThenModal() }) }) @@ -421,9 +479,44 @@ describe('Multiple Clients (QSS)', () => { await joinPanel.waitForJoinToComplete() }) - it('Second user sees user list', async () => { - const userList = new UsersList(users.user2.app.driver) - expect(await userList.isReady()).toBeTruthy() + it.skip('Second user opens community membership tab', async () => { + settingsUser2 = await new Sidebar(users.user2.app.driver).openSettings() + expect(await settingsUser2.isReady()).toBeTruthy() + await settingsUser2.openCommunityMembership(3) + }) + + it.skip('Second user sees self in user list', async () => { + const status = await settingsUser2.getUserInCommunityMembership( + users.user2.username, + UserListStatus.ONLINE, + true + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('Second user sees owner as offline in user list', async () => { + const status = await settingsUser2.getUserInCommunityMembership( + users.owner.username, + UserListStatus.OFFLINE, + false + ) + expect(status.status).toBe(UserListStatus.OFFLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('Second user sees first user as offline in user list', async () => { + const status = await settingsUser2.getUserInCommunityMembership( + users.user1.username, + UserListStatus.OFFLINE, + false + ) + expect(status.status).toBe(UserListStatus.OFFLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('Second user closes community membership tab', async () => { + await settingsUser2.closeTabThenModal() }) it('Second user sees general channel', async () => { @@ -481,9 +574,24 @@ describe('Multiple Clients (QSS)', () => { await promiseWithRetries(loadOwner(), failureReason, retryConfig, onTimeout) }) - it('Second user sees owner in user list as online', async () => { - const userList = new UsersList(users.user2.app.driver) - await userList.getUser(users.owner.username, UserListStatus.ONLINE) + it.skip('Second user opens community membership tab', async () => { + settingsUser2 = await new Sidebar(users.user2.app.driver).openSettings() + expect(await settingsUser2.isReady()).toBeTruthy() + await settingsUser2.openCommunityMembership(3) + }) + + it.skip('Second user sees owner as online in user list', async () => { + const status = await settingsUser2.getUserInCommunityMembership( + users.owner.username, + UserListStatus.ONLINE, + false + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('Second user closes community membership tab', async () => { + await settingsUser2.closeTabThenModal() }) it("Second user's first message is visible in a channel for owner", async () => { @@ -514,14 +622,34 @@ describe('Multiple Clients (QSS)', () => { await debugModal.close() }) - it('First user sees second user in user list as online', async () => { - const userList = new UsersList(users.user1.app.driver) - await userList.getUser(users.user2.username, UserListStatus.ONLINE) + it.skip('First user opens community membership tab', async () => { + settingsUser1 = await new Sidebar(users.user1.app.driver).openSettings() + expect(await settingsUser1.isReady()).toBeTruthy() + await settingsUser1.openCommunityMembership(3) + }) + + it.skip('First user sees owner as online in user list', async () => { + const status = await settingsUser1.getUserInCommunityMembership( + users.owner.username, + UserListStatus.ONLINE, + false + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) + }) + + it.skip('First user sees second user as online in user list', async () => { + const status = await settingsUser1.getUserInCommunityMembership( + users.user2.username, + UserListStatus.ONLINE, + false + ) + expect(status.status).toBe(UserListStatus.ONLINE) + expect(status.textMatches).toBe(true) }) - it('First user sees owner in user list as online', async () => { - const userList = new UsersList(users.user1.app.driver) - await userList.getUser(users.owner.username, UserListStatus.ONLINE) + it.skip('First user closes community membership tab', async () => { + await settingsUser1.closeTabThenModal() }) it(`First user can see the second user's messages`, async () => { diff --git a/packages/e2e-tests/src/tests/multipleClients.test.ts b/packages/e2e-tests/src/tests/multipleClients.test.ts index 2fd2bc4a2b..9e1e7a7778 100644 --- a/packages/e2e-tests/src/tests/multipleClients.test.ts +++ b/packages/e2e-tests/src/tests/multipleClients.test.ts @@ -13,7 +13,13 @@ import { StartingLoadingPanel, } from '../selectors' import { promiseWithRetries, createArbitraryFile } from '../utils' -import { MessageIds, UserTestData } from '../types' +import { + DEFAULT_ADD_NEW_CHANNEL_NONADMIN_OPTIONS, + DEFAULT_ADD_NEW_CHANNEL_OPTIONS, + MessageIds, + TestChannelType, + UserTestData, +} from '../types' import { createLogger } from '../logger' import * as path from 'path' import { SettingsModalTabName, FileAttachmentType } from '../enums' @@ -239,7 +245,7 @@ describe('Multiple Clients', () => { await users.owner.app.close() }) - it(`First user sends a message`, async () => { + it(`First user sends a message after owner goes offline`, async () => { await generalChannelUser1.sendMessage(users.user1.messages[1], users.user1.username) }) @@ -333,24 +339,33 @@ describe('Multiple Clients', () => { describe('Owner Creates New Channel', () => { it('Owner creates second channel', async () => { sidebarOwner = new Sidebar(users.owner.app.driver) - await sidebarOwner.addNewChannel(newChannelName) + const { channel, errors } = await sidebarOwner.addNewChannel(newChannelName) + expect(channel).toBeDefined() + expect(errors).toBeUndefined() + await sidebarOwner.waitForChannelsNum(2) await sidebarOwner.switchChannel(newChannelName) - const channels = await sidebarOwner.getChannelList() - expect(channels.length).toEqual(2) }) it('First user sees second channel', async () => { sidebarUser1 = new Sidebar(users.user1.app.driver) - await sidebarUser1.switchChannel(newChannelName) - const channels = await sidebarUser1.getChannelList() - expect(channels.length).toEqual(2) + await sidebarUser1.waitForChannelsNum(2, 60_000) + }) + + it('First user switches to second channel', async () => { + secondChannelUser1 = await sidebarUser1.switchChannel(newChannelName) + expect(await secondChannelUser1.isReady()).toBeTruthy() + expect(await secondChannelUser1.isMessageInputReady()).toBeTruthy() }) it('Second user sees second channel', async () => { sidebarUser3 = new Sidebar(users.user3.app.driver) - await sidebarUser3.switchChannel(newChannelName) - const channels = await sidebarUser3.getChannelList() - expect(channels.length).toEqual(2) + await sidebarUser3.waitForChannelsNum(2, 60_000) + }) + + it('Second user switches to second channel', async () => { + secondChannelUser3 = await sidebarUser3.switchChannel(newChannelName) + expect(await secondChannelUser3.isReady()).toBeTruthy() + expect(await secondChannelUser3.isMessageInputReady()).toBeTruthy() }) it('Owner sends message in second channel', async () => { @@ -384,14 +399,13 @@ describe('Multiple Clients', () => { expect(iconVisible).toBe(true) await channelContextMenuOwner.openDeletionChannelModal() await channelContextMenuOwner.deleteChannel() - const channels = await sidebarOwner.getChannelList() expect(await generalChannelOwner.isOpen()).toBeTruthy() - expect(channels.length).toEqual(1) }) it('Owner sees that the channel is missing in the sidebar', async () => { - const channels = await sidebarOwner.getChannelList() - expect(channels.length).toEqual(1) + await sidebarOwner.waitForChannelsNum(1) + const channelNames = await sidebarOwner.getChannelsNames() + expect(channelNames).not.toContain(newChannelName) }) it('Owner sees info about channel deletion in general channel', async () => { @@ -405,40 +419,48 @@ describe('Multiple Clients', () => { }) it('Second user sees info about channel deletion in general channel', async () => { - expect(await generalChannelUser3.isOpen(true, true, 30_000)).toBeTruthy() + expect(await generalChannelUser3.isOpen(TestChannelType.PUBLIC_CHANNEL, true, 30_000)).toBeTruthy() await generalChannelUser3.getMessageIdsByText(deleteChannelMessage(newChannelName), users.owner.username) }) it('User sees that the channel is missing in the sidebar', async () => { - const channels = await sidebarUser1.getChannelList() - expect(channels.length).toEqual(1) + await sidebarUser1.waitForChannelsNum(1) + const channelNames = await sidebarUser1.getChannelsNames() + expect(channelNames).not.toContain(newChannelName) }) it('Second user sees that the channel is missing in the sidebar', async () => { - const channels = await sidebarUser3.getChannelList() - expect(channels.length).toEqual(1) + await sidebarUser3.waitForChannelsNum(1) + const channelNames = await sidebarUser3.getChannelsNames() + expect(channelNames).not.toContain(newChannelName) }) it('User can create channel with the same name and is fresh channel', async () => { - await sidebarUser1.addNewChannel(newChannelName, true, true) + const { channel, errors } = await sidebarUser1.addNewChannel( + newChannelName, + DEFAULT_ADD_NEW_CHANNEL_NONADMIN_OPTIONS + ) + expect(channel).toBeDefined() + expect(errors).toBeUndefined() + await sidebarUser1.waitForChannelsNum(2) await sidebarUser1.switchChannel(newChannelName) const messages = await secondChannelUser1.getUserMessages(users.user1.username) expect(messages.length).toEqual(1) expect(await secondChannelUser1.isReady()).toBeTruthy() - const channels = await sidebarUser1.getChannelList() - expect(channels.length).toEqual(2) }) it('Owner sees the recreated second channel', async () => { expect(await secondChannelOwner.isReady(30_000)).toBeTruthy() - const channels = await sidebarOwner.getChannelList() - expect(channels.length).toEqual(2) + await sidebarOwner.waitForChannelsNum(2) + const channelNames = await sidebarOwner.getChannelsNames() + expect(channelNames).toContain(newChannelName) }) it('Second user sees the recreated second channel', async () => { expect(await secondChannelUser3.isReady(30_000)).toBeTruthy() - const channels = await sidebarUser3.getChannelList() - expect(channels.length).toEqual(2) + await sidebarUser3.waitForChannelsNum(2) + const channelNames = await sidebarUser3.getChannelsNames() + expect(channelNames).toContain(newChannelName) }) // End of tests for Windows @@ -473,26 +495,14 @@ describe('Multiple Clients', () => { expect(await generalChannelOwner.isMessageInputReady()).toBeTruthy() const retryConfig = users.owner.app.retryConfig const failureReason = `Expected 2 channels to be present in the sidebar within ${retryConfig.timeoutMs}ms` - const channels = await promiseWithRetries( - (async () => { - const channelList = await sidebarOwner.getChannelList() - if (channelList.length !== 2) { - throw new Error(`Expected 2 channels, but found ${channelList.length}`) - } - return channelList - })(), - failureReason, - retryConfig - ) - expect(channels.length).toEqual(2) + await sidebarOwner.waitForChannelsNum(2) }) it('Second user sees recreated general channel', async () => { expect(await generalChannelUser3.isReady()).toBeTruthy() expect(await generalChannelUser3.isOpen()).toBeTruthy() expect(await generalChannelUser3.isMessageInputReady()).toBeTruthy() - const channels = await sidebarOwner.getChannelList() - expect(channels.length).toEqual(2) + await sidebarUser3.waitForChannelsNum(2) }) } }) diff --git a/packages/e2e-tests/src/tests/oneClient.test.ts b/packages/e2e-tests/src/tests/oneClient.test.ts index 3833bde02c..584e891dd9 100644 --- a/packages/e2e-tests/src/tests/oneClient.test.ts +++ b/packages/e2e-tests/src/tests/oneClient.test.ts @@ -6,6 +6,7 @@ import { JoinCommunityModal, JoiningLoadingPanel, RegisterUsernameModal, + Settings, Sidebar, } from '../selectors' import getPort from 'get-port' @@ -14,6 +15,7 @@ import path from 'path' import { createLogger } from '../logger' import { SettingsModalTabName, FileAttachmentType } from '../enums' import { TEST_FILE_NAME, TEST_IMAGE_FILE_NAME, UPLOAD_FILE_DIR } from '../attachFile.const' +import { UserListStatus } from '../types' const logger = createLogger('oneClient') @@ -55,6 +57,15 @@ describe('One Client', () => { resourcesPath = processData.resourcesPath }) + it('Owner closes update modal if opened', async () => { + try { + await app.closeUpdateModalIfPresent() + logger.info('Closed update modal') + } catch (e) { + // do nothing + } + }) + it('User sees "join community" page and switches to "create community" view by clicking on the link', async () => { const debugModal = new DebugModeModal(app.driver) await debugModal.close() @@ -99,6 +110,8 @@ describe('One Client', () => { it('User sees general channel', async () => { generalChannel = new Channel(app.driver, generalChannelName) expect(await generalChannel.isReady()).toBeTruthy() + expect(await generalChannel.isMessageInputReady()).toBeTruthy() + expect(await generalChannel.isOpen()).toBeTruthy() const generalChannelText = await generalChannel.element.getText() expect(generalChannelText).toEqual(generalChannelName) @@ -106,16 +119,9 @@ describe('One Client', () => { it('User sees just the general channel in the sidebar', async () => { const sidebar = new Sidebar(app.driver) - const channelList = await sidebar.getChannelList() - expect(channelList.length).toBe(1) - expect(await channelList[0].getText()).toBe(generalChannelName) - }) - - it('Users sees just themselves in the user list', async () => { - const sidebar = new Sidebar(app.driver) - const userList = await sidebar.getUserProfileList() - expect(userList.length).toBe(1) - expect(await userList[0].getText()).toBe(ownerUserName) + await sidebar.waitForChannelsNum(1) + const channelNames = await sidebar.getChannelsNames() + expect(channelNames).toContain(generalChannelName) }) it('User sends a message', async () => { @@ -192,6 +198,28 @@ describe('One Client', () => { }) }) + describe.skip('Community membership tab', () => { + let settingsModal: Settings + it('User opens community settings', async () => { + settingsModal = await new Sidebar(app.driver).openSettings() + expect(await settingsModal.isReady()).toBeTruthy() + }) + + it.skip('User opens community membership tab', async () => { + await settingsModal.openCommunityMembership(1) + }) + + it('Users sees just themselves in the user list', async () => { + const ownStatus = await settingsModal.getUserInCommunityMembership(ownerUserName, UserListStatus.ONLINE, true) + expect(ownStatus.status).toBe(UserListStatus.ONLINE) + expect(ownStatus.textMatches).toBe(true) + }) + + it('Users closes community membership tab', async () => { + await settingsModal.closeTabThenModal() + }) + }) + describe('security: socketIOSecret exposure', () => { it('does NOT leak socketIOSecret in renderer process URL (query string)', async () => { // Get the main window's URL via webdriver diff --git a/packages/e2e-tests/src/types.ts b/packages/e2e-tests/src/types.ts index aa1f9ee605..4a30f2fc83 100644 --- a/packages/e2e-tests/src/types.ts +++ b/packages/e2e-tests/src/types.ts @@ -1,5 +1,5 @@ import { WebElement } from 'selenium-webdriver' -import { App } from './selectors' +import { App, Channel } from './selectors' export interface UserTestData { username: string @@ -40,4 +40,55 @@ export enum UserListStatus { export interface UserListItem { element: WebElement | undefined status: UserListStatus + textMatches: boolean +} + +export interface NewMessageDM { + successfulUsers: string[] + failedUsers: string[] +} + +export interface CreatedDM extends NewMessageDM { + success: boolean + error?: Error +} + +export enum TestChannelType { + PUBLIC_CHANNEL = 'publicChannel', + PRIVATE_CHANNEL = 'privateChannel', + DM = 'dm', +} + +export enum TestAddNewChannelButtonId { + PRE_DMS = 'addChannelButton', + DMS = 'sidebar-button-createChannel', +} + +export interface TestAddNewChannelOptions { + isPublic: boolean + expectToggle: boolean + buttonId: TestAddNewChannelButtonId +} + +export interface TestNewChannelResult { + channel?: Channel + errors?: Error[] +} + +export const DEFAULT_ADD_NEW_CHANNEL_OPTIONS: TestAddNewChannelOptions = { + isPublic: true, + expectToggle: true, + buttonId: TestAddNewChannelButtonId.DMS, +} + +export const DEFAULT_ADD_NEW_CHANNEL_NONADMIN_OPTIONS: TestAddNewChannelOptions = { + isPublic: true, + expectToggle: false, + buttonId: TestAddNewChannelButtonId.DMS, +} + +export const DEFAULT_ADD_NEW_CHANNEL_PRIVATE_OPTIONS: TestAddNewChannelOptions = { + isPublic: false, + expectToggle: true, + buttonId: TestAddNewChannelButtonId.DMS, } diff --git a/packages/e2e-tests/src/utils.ts b/packages/e2e-tests/src/utils.ts index 5dcb0145ea..218b30dd73 100644 --- a/packages/e2e-tests/src/utils.ts +++ b/packages/e2e-tests/src/utils.ts @@ -1,4 +1,5 @@ import { Browser, Builder, type ThenableWebDriver } from 'selenium-webdriver' +import * as logging from 'selenium-webdriver/lib/logging' import { spawn, exec, execSync, type ChildProcessWithoutNullStreams, ChildProcess } from 'child_process' import { type SupportedPlatformDesktop } from '@quiet/types' import getPort from 'get-port' @@ -38,6 +39,7 @@ export class BuildSetup { private defaultDataDir: boolean private fileName?: string private chromeDriverPath?: string + private _seleniumLogger: logging.Logger constructor({ port, @@ -61,6 +63,8 @@ export class BuildSetup { } this.dataDirPath = getAppDataPath({ dataDir: this.dataDir }) logger.info('Running app from directory', this.dataDirPath) + this._seleniumLogger = logging.getLogger() + this._configureSeleniumLogging() } async initPorts() { @@ -146,13 +150,32 @@ export class BuildSetup { } } + private _generateDebugSetting(): string { + if (process.env.TRACE_APP_LOGS == 'true') { + return '*:trace' + } + + if (process.env.VERBOSE == 'true') { + return '*' + } + + return 'backend*,quiet*,state-manager*,desktop*,utils*,identity*,common*,main,libp2p:*' + } + + private _configureSeleniumLogging(): void { + if (process.env.VERBOSE == 'true') { + this._seleniumLogger.setLevel(logging.Level.FINEST) + } else { + this._seleniumLogger.setLevel(logging.Level.WARNING) + } + + logging.installConsoleHandler() + } + public async createChromeDriver(qssEnabled = false) { await this.initPorts() let env: any = { - DEBUG: - process.env.TRACE_APP_LOGS === 'true' - ? '*:trace' - : 'backend*,quiet*,state-manager*,desktop*,utils*,identity*,common*,main,libp2p:*', + DEBUG: this._generateDebugSetting(), DATA_DIR: this.dataDir, STATIC_LOG_ID: this.id, } diff --git a/packages/identity/package-lock.json b/packages/identity/package-lock.json index 90c630ad1c..663f1445d2 100644 --- a/packages/identity/package-lock.json +++ b/packages/identity/package-lock.json @@ -14,7 +14,7 @@ "devDependencies": { "@babel/runtime": "^7.12.5", "@types/jest": "^26.0.23", - "@types/node": "^14.17.6", + "@types/node": "20.19.43", "babel-jest": "^29.3.1", "jest": "^26.6.3", "lint-staged": "^15.2.2", @@ -797,10 +797,21 @@ } }, "node_modules/@types/node": { - "version": "14.17.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.6.tgz", - "integrity": "sha512-iBxsxU7eswQDGhlr3AiamBxOssaYxbM+NKXVil8jg9yFXvrfEFbDumLD/2dMTB+zYyg7w+Xjt8yuxfdbUHAtcQ==", - "dev": true + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" }, "node_modules/@types/normalize-package-data": { "version": "2.4.0", @@ -7805,10 +7816,21 @@ } }, "@types/node": { - "version": "14.17.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.6.tgz", - "integrity": "sha512-iBxsxU7eswQDGhlr3AiamBxOssaYxbM+NKXVil8jg9yFXvrfEFbDumLD/2dMTB+zYyg7w+Xjt8yuxfdbUHAtcQ==", - "dev": true + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "requires": { + "undici-types": "~6.21.0" + }, + "dependencies": { + "undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + } + } }, "@types/normalize-package-data": { "version": "2.4.0", diff --git a/packages/identity/package.json b/packages/identity/package.json index cc6d3978e5..fb568aad77 100644 --- a/packages/identity/package.json +++ b/packages/identity/package.json @@ -60,7 +60,7 @@ "@babel/runtime": "^7.12.5", "@quiet/node-common": "^4.0.3", "@types/jest": "^26.0.23", - "@types/node": "^14.17.6", + "@types/node": "20.19.43", "babel-jest": "^29.3.1", "jest": "^26.6.3", "lint-staged": "^15.2.2", diff --git a/packages/logger/package-lock.json b/packages/logger/package-lock.json index a57b518ea7..2c1442458d 100644 --- a/packages/logger/package-lock.json +++ b/packages/logger/package-lock.json @@ -18,7 +18,7 @@ "devDependencies": { "@types/debug": "^4.1.7", "@types/luxon": "^3.4.2", - "@types/node": "^17.0.21", + "@types/node": "20.19.43", "lint-staged": "^15.2.2", "typescript": "^4.9.5" } @@ -61,10 +61,21 @@ "dev": true }, "node_modules/@types/node": { - "version": "17.0.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.21.tgz", - "integrity": "sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==", - "dev": true + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" }, "node_modules/ansi-colors": { "version": "4.1.3", @@ -922,10 +933,21 @@ "dev": true }, "@types/node": { - "version": "17.0.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.21.tgz", - "integrity": "sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==", - "dev": true + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "requires": { + "undici-types": "~6.21.0" + }, + "dependencies": { + "undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + } + } }, "ansi-colors": { "version": "4.1.3", diff --git a/packages/logger/package.json b/packages/logger/package.json index 344fca7815..d4de1a7db2 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -22,7 +22,7 @@ "@quiet/eslint-config": "^2.0.2-alpha.0", "@types/debug": "^4.1.7", "@types/luxon": "^3.4.2", - "@types/node": "^17.0.21", + "@types/node": "20.19.43", "lint-staged": "^15.2.2", "typescript": "^4.9.5" }, diff --git a/packages/mobile/src/components/ChannelMembership/UpdateChannelMembership/UpdateChannelMembershipList.component.tsx b/packages/mobile/src/components/ChannelMembership/UpdateChannelMembership/UpdateChannelMembershipList.component.tsx index 52990e14e5..589c59aa1e 100644 --- a/packages/mobile/src/components/ChannelMembership/UpdateChannelMembership/UpdateChannelMembershipList.component.tsx +++ b/packages/mobile/src/components/ChannelMembership/UpdateChannelMembership/UpdateChannelMembershipList.component.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { FlatList, ListRenderItemInfo, View } from 'react-native' import { ProfilePhoto } from '../../ProfilePhoto/ProfilePhoto.component' diff --git a/packages/mobile/src/components/Chat/Chat.stories.tsx b/packages/mobile/src/components/Chat/Chat.stories.tsx index ad0471e5bb..04030330d6 100644 --- a/packages/mobile/src/components/Chat/Chat.stories.tsx +++ b/packages/mobile/src/components/Chat/Chat.stories.tsx @@ -6,6 +6,7 @@ import { Chat } from './Chat.component' import { DocumentPickerResponse } from 'react-native-document-picker' import { Asset } from 'react-native-image-picker' import { createLogger } from '../../utils/logger' +import { ChannelType } from '@quiet/types' const logger = createLogger('chat:stories') @@ -31,6 +32,7 @@ storiesOf('Chat', module) timestamp: 0, id: '', public: true, + type: ChannelType.CHANNEL, teamId: 'foobar', }} messages={{ @@ -278,6 +280,7 @@ storiesOf('Chat', module) timestamp: 0, id: '', public: true, + type: ChannelType.CHANNEL, teamId: 'foobar', }} updateFileAttachments={function (_filesData: DocumentPickerResponse[]): void { @@ -323,6 +326,7 @@ storiesOf('Chat', module) timestamp: 0, id: 'sticky-date-test', public: true, + type: ChannelType.CHANNEL, teamId: 'foobar', }} messages={{ diff --git a/packages/mobile/src/components/Chat/__tests__/Chat.test.tsx b/packages/mobile/src/components/Chat/__tests__/Chat.test.tsx index 4ec070fff7..3950c12c3c 100644 --- a/packages/mobile/src/components/Chat/__tests__/Chat.test.tsx +++ b/packages/mobile/src/components/Chat/__tests__/Chat.test.tsx @@ -4,6 +4,7 @@ import { Chat } from '../Chat.component' import { Keyboard } from 'react-native' import { ChatProps } from '../Chat.types' import { FileActionsProps } from '../../FileAttachment/FileAttachment.types' +import { ChannelType } from '@quiet/types' jest.useFakeTimers() @@ -35,6 +36,7 @@ describe('Chat component', () => { timestamp: 0, id: '', public: true, + type: ChannelType.CHANNEL, teamId: 'foobar', }, pendingMessages: {}, diff --git a/packages/mobile/src/components/Chat/__tests__/ChatContents.test.tsx b/packages/mobile/src/components/Chat/__tests__/ChatContents.test.tsx index ea01561428..b041603151 100644 --- a/packages/mobile/src/components/Chat/__tests__/ChatContents.test.tsx +++ b/packages/mobile/src/components/Chat/__tests__/ChatContents.test.tsx @@ -4,7 +4,7 @@ import { Chat } from '../Chat.component' import { Keyboard } from 'react-native' import { ChatProps, ListItem } from '../Chat.types' import { FileActionsProps } from '../../FileAttachment/FileAttachment.types' -import { DisplayableMessage } from '@quiet/types' +import { ChannelType, DisplayableMessage } from '@quiet/types' // Mock dependencies jest.mock('react-native-safe-area-context', () => ({ @@ -176,6 +176,7 @@ describe('Chat component list data', () => { timestamp: 0, id: '', public: true, + type: ChannelType.CHANNEL, teamId: 'foobar', }, pendingMessages: {}, diff --git a/packages/mobile/src/screens/CreateChannel/CreateChannel.screen.tsx b/packages/mobile/src/screens/CreateChannel/CreateChannel.screen.tsx index 8af78b108b..12d5e855dc 100644 --- a/packages/mobile/src/screens/CreateChannel/CreateChannel.screen.tsx +++ b/packages/mobile/src/screens/CreateChannel/CreateChannel.screen.tsx @@ -2,7 +2,7 @@ import React, { FC, useState, useCallback, useEffect } from 'react' import { CreateChannel } from '../../components/CreateChannel/CreateChannel.component' import { useDispatch, useSelector } from 'react-redux' import { communities, identity, publicChannels, errors } from '@quiet/state-manager' -import { ErrorCodes, ErrorMessages, SocketActions, ChannelStructure } from '@quiet/types' +import { ErrorCodes, ErrorMessages, SocketActions, ChannelStructure, ChannelType } from '@quiet/types' import { navigationSelectors } from '../../store/navigation/navigation.selectors' import { ScreenNames } from '../../const/ScreenNames.enum' import { navigationActions } from '../../store/navigation/navigation.slice' @@ -100,6 +100,7 @@ export const CreateChannelScreen: FC = () => { description: `Welcome to #${name}`, id: id, public: isPublic, + type: ChannelType.CHANNEL, teamId: community.teamId, }) ) diff --git a/packages/node-common/package-lock.json b/packages/node-common/package-lock.json index 609265eb6a..aa3d52c9f3 100644 --- a/packages/node-common/package-lock.json +++ b/packages/node-common/package-lock.json @@ -13,7 +13,6 @@ "@formatjs/intl-datetimeformat": "^6.12.3", "@formatjs/intl-getcanonicallocales": "^2.3.0", "@formatjs/intl-locale": "^3.4.5", - "@types/node": "^20", "base64url": "^3.0.1", "cross-env": "^7.0.3", "debug": "^4.3.1", @@ -24,6 +23,7 @@ }, "devDependencies": { "@types/jest": "^26.0.23", + "@types/node": "20.19.43", "@types/validator": "^13.11.5", "@types/winston": "^2.4.4", "jest": "^26.6.3", @@ -1157,9 +1157,10 @@ } }, "node_modules/@types/node": { - "version": "20.19.35", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", - "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1169,6 +1170,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/normalize-package-data": { diff --git a/packages/node-common/package.json b/packages/node-common/package.json index d2bafa19e8..e6e8b67017 100644 --- a/packages/node-common/package.json +++ b/packages/node-common/package.json @@ -26,7 +26,7 @@ "devDependencies": { "@quiet/eslint-config": "^2.0.2-alpha.0", "@types/jest": "^26.0.23", - "@types/node": "^20", + "@types/node": "20.19.43", "@types/validator": "^13.11.5", "@types/winston": "^2.4.4", "jest": "^26.6.3", @@ -41,7 +41,6 @@ "@formatjs/intl-locale": "^3.4.5", "@quiet/logger": "^2.0.2-alpha.0", "@quiet/types": "^2.0.2-alpha.1", - "@types/node": "^20", "base64url": "^3.0.1", "cross-env": "^7.0.3", "debug": "^4.3.1", diff --git a/packages/state-manager/src/sagas/network/network.slice.ts b/packages/state-manager/src/sagas/network/network.slice.ts index e14619ec22..3177cc86ec 100644 --- a/packages/state-manager/src/sagas/network/network.slice.ts +++ b/packages/state-manager/src/sagas/network/network.slice.ts @@ -1,7 +1,7 @@ import { createSlice, type EntityState, type PayloadAction } from '@reduxjs/toolkit' import { StoreKeys } from '../store.keys' import { connectedPeersAdapter } from './network.adapter' -import { type CommunityId, type ConnectedPeers, LoadingPanelType } from '@quiet/types' +import { type CommunityId, type ConnectedPeers, LoadingPanelType, ClearConnectedPeersPayload } from '@quiet/types' export class NetworkState { public initializedCommunities: Record = {} @@ -28,6 +28,9 @@ export const networkSlice = createSlice({ removeConnectedPeer: (state, action: PayloadAction) => { connectedPeersAdapter.removeOne(state.connectedPeers, action.payload[0]) }, + clearConnectedPeers: (state, action: PayloadAction) => { + connectedPeersAdapter.removeAll(state.connectedPeers) + }, setLoadingPanelType: (state, action) => { state.loadingPanelType = action.payload }, diff --git a/packages/state-manager/src/sagas/publicChannels/channelDeletionResponse/channelDeletionResponse.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/channelDeletionResponse/channelDeletionResponse.saga.test.ts index c8f9cce12f..fa36812399 100644 --- a/packages/state-manager/src/sagas/publicChannels/channelDeletionResponse/channelDeletionResponse.saga.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/channelDeletionResponse/channelDeletionResponse.saga.test.ts @@ -11,7 +11,7 @@ import { DateTime } from 'luxon' import { messagesActions } from '../../messages/messages.slice' import { channelDeletionResponseSaga } from './channelDeletionResponse.saga' import { generateChannelId } from '@quiet/common' -import { CommunityOwnership, type Community, type Identity, type PublicChannel } from '@quiet/types' +import { ChannelType, CommunityOwnership, type Community, type Identity, type PublicChannel } from '@quiet/types' import { publicChannelsSelectors } from '../publicChannels.selectors' import { select } from 'redux-saga-test-plan/matchers' @@ -202,6 +202,7 @@ describe('channelDeletionResponseSaga', () => { timestamp: 0, id: newGeneralId, public: true, + type: ChannelType.CHANNEL, teamId: community.teamId!, } diff --git a/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.test.ts index 14730db186..e58854895f 100644 --- a/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.test.ts @@ -4,16 +4,13 @@ import { prepareStore, testReducers } from '../../../utils/tests/prepareStore' import { messages, publicChannels } from '../../..' import { type FactoryGirl } from 'factory-girl' import { combineReducers } from 'redux' -import { reducers } from '../../reducers' import { expectSaga } from 'redux-saga-test-plan' import { publicChannelsActions } from './../publicChannels.slice' -import { type identityActions } from '../../identity/identity.slice' -import { type communitiesActions } from '../../communities/communities.slice' import { channelsReplicatedSaga } from './channelsReplicated.saga' import { DateTime } from 'luxon' import { publicChannelsSelectors } from '../publicChannels.selectors' import { messagesActions } from '../../messages/messages.slice' -import { ChannelOperationStatus, type Community, type Identity, type PublicChannel } from '@quiet/types' +import { ChannelOperationStatus, ChannelType, type Community, type Identity, type PublicChannel } from '@quiet/types' import { generateChannelId } from '@quiet/common' import { createLogger } from '../../../utils/logger' import { getBaseTypesFactory, getReduxStoreFactory } from '../../../utils/tests/factories' @@ -61,6 +58,7 @@ describe('channelsReplicatedSaga', () => { timestamp: DateTime.utc().valueOf(), owner: 'owner', id: generateChannelId('sailing'), + type: ChannelType.CHANNEL, }, }) ).payload.channel @@ -74,6 +72,7 @@ describe('channelsReplicatedSaga', () => { timestamp: DateTime.utc().valueOf(), owner: 'owner', id: generateChannelId('photo'), + type: ChannelType.CHANNEL, }, }) ).payload.channel @@ -93,6 +92,7 @@ describe('channelsReplicatedSaga', () => { .putResolve( publicChannelsActions.addChannel({ channel: sailingChannel, + displayedName: sailingChannel.name, status: ChannelOperationStatus.SUCCESS, }) ) @@ -112,12 +112,14 @@ describe('channelsReplicatedSaga', () => { .not.putResolve( publicChannelsActions.addChannel({ channel: generalChannel, + displayedName: generalChannel.name, status: ChannelOperationStatus.SUCCESS, }) ) .putResolve( publicChannelsActions.addChannel({ channel: sailingChannel, + displayedName: sailingChannel.name, status: ChannelOperationStatus.SUCCESS, }) ) @@ -137,6 +139,7 @@ describe('channelsReplicatedSaga', () => { .putResolve( publicChannelsActions.addChannel({ channel: sailingChannel, + displayedName: sailingChannel.name, status: ChannelOperationStatus.SUCCESS, }) ) @@ -161,6 +164,7 @@ describe('channelsReplicatedSaga', () => { .putResolve( publicChannelsActions.addChannel({ channel: sailingChannel, + displayedName: sailingChannel.name, status: ChannelOperationStatus.SUCCESS, }) ) @@ -172,6 +176,7 @@ describe('channelsReplicatedSaga', () => { .not.putResolve( publicChannelsActions.addChannel({ channel: generalChannel, + displayedName: generalChannel.name, status: ChannelOperationStatus.SUCCESS, }) ) @@ -249,6 +254,7 @@ describe('channelsReplicatedSaga', () => { .putResolve( publicChannelsActions.addChannel({ channel: sailingChannel, + displayedName: sailingChannel.name, status: ChannelOperationStatus.SUCCESS, }) ) diff --git a/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.ts b/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.ts index b64f4b276d..5eb8072a83 100644 --- a/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.ts +++ b/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.ts @@ -4,36 +4,43 @@ import { publicChannelsSelectors } from '../publicChannels.selectors' import { publicChannelsActions } from '../publicChannels.slice' import { messagesSelectors } from '../../messages/messages.selectors' import { messagesActions } from '../../messages/messages.slice' -import { communitiesSelectors, isOwner } from '../../communities/communities.selectors' +import { communitiesSelectors } from '../../communities/communities.selectors' import { createLogger } from '../../../utils/logger' -import { ChannelOperationStatus, CommunityOwnership } from '@quiet/types' +import { userProfileSelectors } from '../../users/userProfile/userProfile.selectors' +import { generateDmChannelName } from '@quiet/common' +import { ChannelType, ChannelOperationStatus } from '@quiet/types' const logger = createLogger('channelsReplicatedSaga') export function* channelsReplicatedSaga( action: PayloadAction['payload']> ): Generator { - logger.info(`Syncing channels: ${JSON.stringify(action.payload, null, 2)}`) + logger.info(`Syncing channels`) const { channels } = action.payload const _locallyStoredChannels = yield* select(publicChannelsSelectors.publicChannels) const locallyStoredChannels = _locallyStoredChannels.map(channel => channel.id) const databaseStoredChannels = channels const databaseStoredChannelsIds = databaseStoredChannels.map(channel => channel.id) - - logger.info({ locallyStoredChannels, databaseStoredChannelsIds }) + const userProfiles = yield* select(userProfileSelectors.userProfiles) + const me = yield* select(userProfileSelectors.myUserProfile) // Upserting channels to local storage for (const channel of databaseStoredChannels) { + const displayedName = + channel.type === ChannelType.CHANNEL || channel.type == null + ? channel.name + : generateDmChannelName(channel.memberIds, userProfiles, me) if (!locallyStoredChannels.includes(channel.id)) { - logger.info(`Adding #${channel.name} to store`) + logger.info(`Adding channel to store`, channel.name) yield* putResolve( publicChannelsActions.addChannel({ channel, + displayedName, status: ChannelOperationStatus.SUCCESS, }) ) - logger.info(`Adding #${channel.name} messages to store`) + logger.info(`Adding messages to store`, channel.name) yield* putResolve( messagesActions.addPublicChannelsMessagesBase({ channelId: channel.id, @@ -46,7 +53,7 @@ export function* channelsReplicatedSaga( if (databaseStoredChannelsIds.length > 0) { for (const channelId of locallyStoredChannels) { if (!databaseStoredChannelsIds.includes(channelId)) { - logger.info(`Removing #${channelId} from store`) + logger.info(`Removing channel from store`) yield* putResolve(publicChannelsActions.deleteChannel({ channelId })) yield* take(publicChannelsActions.completeChannelDeletion) } diff --git a/packages/state-manager/src/sagas/publicChannels/createChannel/createChannel.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/createChannel/createChannel.saga.test.ts index 65d22ce022..77824a7bf5 100644 --- a/packages/state-manager/src/sagas/publicChannels/createChannel/createChannel.saga.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/createChannel/createChannel.saga.test.ts @@ -8,7 +8,7 @@ import { type FactoryGirl } from 'factory-girl' import { setupCrypto } from '@quiet/identity' import { prepareStore, testReducers } from '../../../utils/tests/prepareStore' import { getReduxStoreFactory, getSocketFactory } from '../../../utils/tests/factories' -import { CreateChannelPayload, CreateChannelResponse, SocketActions } from '@quiet/types' +import { ChannelType, CreateChannelPayload, CreateChannelResponse, SocketActions } from '@quiet/types' import { messagesActions } from '../../messages/messages.slice' import { MockedSocket } from '../../../utils/tests/mockedSocket' @@ -51,6 +51,7 @@ describe('createChannelSaga', () => { publicChannelsActions.sendInitialChannelMessage({ channelName: createChannelPayload.name, channelId: createChannelPayload.id, + type: ChannelType.CHANNEL, }) ) .run() @@ -80,6 +81,37 @@ describe('createChannelSaga', () => { publicChannelsActions.sendInitialChannelMessage({ channelName: createChannelPayload.name, channelId: createChannelPayload.id, + type: ChannelType.CHANNEL, + }) + ) + .run() + }) + + it('creates new private channel', async () => { + const community = await factory.create('Community') + + const createChannelPayload = await socketPayloadFactory.build(SocketActions.CREATE_CHANNEL, { + public: false, + }) + const createChannelResponse: CreateChannelResponse = await socket.buildResponse(SocketActions.CREATE_CHANNEL, { + ...createChannelPayload, + }) + socket.registerExpectedResponse(SocketActions.CREATE_CHANNEL, createChannelResponse) + await expectSaga( + createChannelSaga, + socket as unknown as Socket, + publicChannelsActions.createChannel(createChannelPayload) + ) + .withReducer(combineReducers(testReducers)) + .withState(store.getState()) + .apply(socket, socket.emitWithAck, [SocketActions.CREATE_CHANNEL, createChannelPayload]) + .put(messagesActions.addPublicChannelsMessagesBase({ channelId: createChannelPayload.id })) + .put(publicChannelsActions.addChannel(createChannelResponse)) + .put( + publicChannelsActions.sendInitialChannelMessage({ + channelName: createChannelPayload.name, + channelId: createChannelPayload.id, + type: ChannelType.CHANNEL, }) ) .run() diff --git a/packages/state-manager/src/sagas/publicChannels/createChannel/createChannel.saga.ts b/packages/state-manager/src/sagas/publicChannels/createChannel/createChannel.saga.ts index 0d0a65e0ba..4c08dfd209 100644 --- a/packages/state-manager/src/sagas/publicChannels/createChannel/createChannel.saga.ts +++ b/packages/state-manager/src/sagas/publicChannels/createChannel/createChannel.saga.ts @@ -1,11 +1,13 @@ import { publicChannelsActions } from '../publicChannels.slice' import { messagesActions } from '../../messages/messages.slice' import { type PayloadAction } from '@reduxjs/toolkit' -import { apply, put } from 'typed-redux-saga' +import { apply, put, select } from 'typed-redux-saga' import { type Socket, applyEmitParams } from '../../../types' -import { ChannelOperationStatus, SocketActions, type CreateChannelResponse } from '@quiet/types' +import { ChannelOperationStatus, ChannelType, SocketActions, type CreateChannelResponse } from '@quiet/types' import { createLogger } from '../../../utils/logger' +import { userProfileSelectors } from '../../users/userProfile/userProfile.selectors' +import { generateDmChannelName } from '@quiet/common' const logger = createLogger('createChannelSaga') @@ -14,6 +16,8 @@ export function* createChannelSaga( action: PayloadAction['payload']> ): Generator { logger.info(`Creating ${action.payload.public === false ? 'private' : 'public'} channel ${action.payload.name}`) + const userProfiles = yield* select(userProfileSelectors.userProfiles) + const me = yield* select(userProfileSelectors.myUserProfile) const response: CreateChannelResponse = yield* apply( socket, @@ -44,11 +48,21 @@ export function* createChannelSaga( channelId: response.channel.id, }) ) - yield* put(publicChannelsActions.addChannel(response)) + const displayedName = + response.channel.type == null || response.channel.type === ChannelType.CHANNEL + ? response.channel.name + : generateDmChannelName(response.channel.memberIds, userProfiles, me) + yield* put( + publicChannelsActions.addChannel({ + ...response, + displayedName, + }) + ) yield* put( publicChannelsActions.sendInitialChannelMessage({ channelName: response.channel.name, channelId: response.channel.id, + type: action.payload.type, }) ) } diff --git a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.test.ts index a399daf783..7c00301dff 100644 --- a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.test.ts @@ -9,7 +9,7 @@ import { publicChannelsActions } from './../publicChannels.slice' import { createGeneralChannelSaga } from './createGeneralChannel.saga' import { generateChannelId } from '@quiet/common' import { type communitiesActions } from '../../communities/communities.slice' -import { type Community, type Identity } from '@quiet/types' +import { ChannelType, type Community, type Identity } from '@quiet/types' import { createLogger } from '../../../utils/logger' import { getReduxStoreFactory } from '../../../utils/tests/factories' @@ -42,6 +42,7 @@ describe('createGeneralChannelSaga', () => { name: 'general', description: 'Welcome to #general', id: generalId, + type: ChannelType.CHANNEL, teamId: community.teamId!, public: true, } diff --git a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.ts b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.ts index 7661d8ecc7..241e005c31 100644 --- a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.ts +++ b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.ts @@ -2,7 +2,7 @@ import { put, call, select } from 'typed-redux-saga' import { publicChannelsActions } from '../publicChannels.slice' import { generateChannelId } from '@quiet/common' import { createLogger } from '../../../utils/logger' -import { CreateChannelPayload } from '@quiet/types' +import { ChannelType, CreateChannelPayload } from '@quiet/types' import { communities } from '../../..' const logger = createLogger('createGeneralChannelSaga') @@ -21,6 +21,7 @@ export function* createGeneralChannelSaga(): Generator { id: id, name: 'general', description: 'Welcome to #general', + type: ChannelType.CHANNEL, teamId: community.teamId, public: true, } as CreateChannelPayload) diff --git a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.test.ts index 72b135350c..8bd97c84c9 100644 --- a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.test.ts @@ -12,7 +12,7 @@ import { DateTime } from 'luxon' import { publicChannelsSelectors } from '../publicChannels.selectors' import { combineReducers } from '@reduxjs/toolkit' import { generalChannelDeletionMessage, generateChannelId } from '@quiet/common' -import { type Community, type PublicChannel, type Identity, UserProfile } from '@quiet/types' +import { type Community, type PublicChannel, type Identity, UserProfile, ChannelType } from '@quiet/types' import { userProfiles, userProfileSelectors } from '../../users/userProfile/userProfile.selectors' describe('sendInitialChannelMessageSaga', () => { @@ -67,6 +67,7 @@ describe('sendInitialChannelMessageSaga', () => { publicChannelsActions.sendInitialChannelMessage({ channelName: channel.name, channelId: channel.id, + type: ChannelType.CHANNEL, }) ) .withReducer(reducer) @@ -89,6 +90,7 @@ describe('sendInitialChannelMessageSaga', () => { publicChannelsActions.sendInitialChannelMessage({ channelName: generalChannel.name, channelId: generalChannel.id, + type: ChannelType.CHANNEL, }) ) .withReducer(reducer) diff --git a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.ts b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.ts index 7dea6e7a17..42e4ec8ddb 100644 --- a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.ts +++ b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.ts @@ -3,14 +3,14 @@ import { put, select, call } from 'typed-redux-saga' import { messagesActions } from '../../messages/messages.slice' import { publicChannelsSelectors } from '../publicChannels.selectors' import { publicChannelsActions } from '../publicChannels.slice' -import { MessageType, type WriteMessagePayload } from '@quiet/types' +import { ChannelType, MessageType, type WriteMessagePayload } from '@quiet/types' import { generalChannelDeletionMessage, createdChannelMessage } from '@quiet/common' import { userProfileSelectors } from '../../users/userProfile/userProfile.selectors' export function* sendInitialChannelMessageSaga( action: PayloadAction['payload']> ): Generator { - const { channelName, channelId } = action.payload + const { channelName, channelId, type } = action.payload const generalChannel = yield* select(publicChannelsSelectors.generalChannel) if (!generalChannel) return const isGeneral = channelId === generalChannel.id @@ -24,8 +24,9 @@ export function* sendInitialChannelMessageSaga( ? yield* call(generalChannelDeletionMessage, user?.nickname || '') : yield* call(createdChannelMessage, channelName) + // if the message is being sent to a regular channel send a visible message, for DMs send an empty message const payload: WriteMessagePayload = { - type: MessageType.Info, + type: type === ChannelType.CHANNEL ? MessageType.Info : MessageType.Empty, message, channelId, } diff --git a/packages/state-manager/src/sagas/publicChannels/publicChannels.master.saga.ts b/packages/state-manager/src/sagas/publicChannels/publicChannels.master.saga.ts index ea343e4660..3f185e72d1 100644 --- a/packages/state-manager/src/sagas/publicChannels/publicChannels.master.saga.ts +++ b/packages/state-manager/src/sagas/publicChannels/publicChannels.master.saga.ts @@ -11,6 +11,7 @@ import { channelDeletionResponseSaga } from './channelDeletionResponse/channelDe import { sendIntroductionMessageSaga } from './sendIntroductionMessage/sendIntroductionMessage.saga' import { createLogger } from '../../utils/logger' import { addMembersChannelSaga } from './addMembersChannel/addMembersChannel.saga' +import { syncChannelDisplayNamesSaga } from './syncChannelDisplayNames/syncChannelDisplayNames.saga' const logger = createLogger('publicChannelsMasterSaga') @@ -27,6 +28,7 @@ export function* publicChannelsMasterSaga(socket: Socket): Generator { takeEvery(publicChannelsActions.setCurrentChannel.type, clearUnreadChannelsSaga), takeEvery(publicChannelsActions.sendIntroductionMessage.type, sendIntroductionMessageSaga), takeEvery(publicChannelsActions.addMembersChannel.type, addMembersChannelSaga, socket), + takeEvery(publicChannelsActions.syncChannelDisplayNames.type, syncChannelDisplayNamesSaga), ]) } finally { logger.info('publicChannelsMasterSaga stopping') diff --git a/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.test.ts b/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.test.ts index 818c3cd8bc..f65579c2e2 100644 --- a/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.test.ts @@ -1,19 +1,21 @@ import { setupCrypto } from '@quiet/identity' import { type Store } from '../store.types' import { type FactoryGirl } from 'factory-girl' -import { prepareStore, testReducers } from '../../utils/tests/prepareStore' +import { prepareStore } from '../../utils/tests/prepareStore' import { publicChannels as getPublicChannels, currentChannelMessagesMergedBySender, sortedCurrentChannelMessages, publicChannelsSelectors, + dmChannels, + sortedDmChannels, } from './publicChannels.selectors' import { publicChannelsActions } from './publicChannels.slice' import { formatMessageDisplayDate } from '../../utils/functions/dates/formatMessageDisplayDate' import { displayableMessage } from '../../utils/functions/dates/formatDisplayableMessage' import { DateTime } from 'luxon' -import { generateChannelId } from '@quiet/common' +import { generateChannelId, generateDmChannelId, generateDmChannelName } from '@quiet/common' import { type ChannelMessage, type Community, @@ -22,9 +24,11 @@ import { MessageType, type PublicChannel, UserProfile, + ChannelType, } from '@quiet/types' -import { getBaseTypesFactory, getReduxStoreFactory } from '../../utils/tests/factories' +import { getReduxStoreFactory } from '../../utils/tests/factories' import { communitiesSelectors } from '../communities/communities.selectors' +import { createLogger } from '../../utils/logger' describe('publicChannelsSelectors', () => { let store: Store @@ -34,13 +38,20 @@ describe('publicChannelsSelectors', () => { let owner: Identity let alice: UserProfile let john: UserProfile + let userA: UserProfile let generalChannel: PublicChannel - let channelIdes: string[] = [] + let channelIds: string[] = [] const msgs: Record = {} const msgsOwners: Record = {} + const CHANNEL_NAMES: string[] = ['croatia', 'allergies', 'sailing', 'pets', 'antiques'] + const DM_CHANNEL_NAMES: string[] = [] + const DM_CHANNEL_IDS: string[] = [] + + const logger = createLogger('publicChannelsSelectors:test') + beforeAll(async () => { setupCrypto() @@ -66,17 +77,21 @@ describe('publicChannelsSelectors', () => { expect(generalChannel).not.toBeUndefined() - channelIdes = [...channelIdes, generalChannel.id] + channelIds = [...channelIds, generalChannel.id] john = await factory.create('UserProfile', { userId: 'userId_john', nickname: 'john', }) + userA = await factory.create('UserProfile', { + userId: 'userId_a', + nickname: 'a', + }) + store.dispatch(publicChannelsActions.setCurrentChannel({ channelId: generalChannel.id })) // Setup channels - const channelNames = ['croatia', 'allergies', 'sailing', 'pets', 'antiques'] - for (const name of channelNames) { + for (const name of CHANNEL_NAMES) { const channel = await factory.create('PublicChannel', { channel: { name, @@ -84,11 +99,62 @@ describe('publicChannelsSelectors', () => { timestamp: DateTime.utc().valueOf(), owner: alice.userId, id: generateChannelId(name), + type: ChannelType.CHANNEL, }, + displayedName: name, }) - channelIdes = [...channelIdes, channel.channel.id] + channelIds.push(channel.channel.id) } + const dmGroups = [ + [alice.userId, john.userId], + [alice.userId, userA.userId], + [alice.userId], + [alice.userId, john.userId, userA.userId], + ] + + const userProfiles = store.getState().Users.userProfiles + for (const dmGroup of dmGroups) { + const dmChannelId = generateDmChannelId(dmGroup) + const channel = await factory.create('PublicChannel', { + channel: { + name: dmChannelId, + description: '', + timestamp: DateTime.utc().valueOf(), + owner: alice.userId, + id: dmChannelId, + type: ChannelType.DM, + memberIds: dmGroup, + }, + displayedName: generateDmChannelName(dmGroup, userProfiles, alice), + }) + CHANNEL_NAMES.push(channel.displayedName) + channelIds.push(channel.channel.id) + DM_CHANNEL_NAMES.push(channel.displayedName) + DM_CHANNEL_IDS.push(channel.channel.id) + } + + CHANNEL_NAMES.push('general') + CHANNEL_NAMES.sort((nameA: string, nameB: string) => { + if (nameA === 'general') { + return -1 + } + if (nameB === 'general') { + return 1 + } + return nameA.localeCompare(nameB) + }) + + DM_CHANNEL_NAMES.sort((nameA: string, nameB: string) => { + if (nameA === alice.nickname) { + return -1 + } + if (nameB === alice.nickname) { + return 1 + } + return nameA.localeCompare(nameB) + }) + const messageData = [ { id: '1', @@ -288,9 +354,8 @@ describe('publicChannelsSelectors', () => { }) it('get channel list in a consistent order', async () => { - const channels = getPublicChannels(store.getState()).map(channel => channel.name) - - expect(channels).toStrictEqual(['general', 'allergies', 'antiques', 'croatia', 'pets', 'sailing']) + const channels = getPublicChannels(store.getState()).map(channel => channel.displayedName) + expect(channels).toStrictEqual(CHANNEL_NAMES) }) it('unreadChannels return empty object if PublicChannels is in the wrong state (no channelStatus)', async () => { @@ -301,6 +366,7 @@ describe('publicChannelsSelectors', () => { const oldState = store.getState() const channelId = oldState.PublicChannels.channels.ids[0] + const dmChannelId = DM_CHANNEL_IDS[0] const newState = { ...oldState, PublicChannels: { @@ -312,6 +378,10 @@ describe('publicChannelsSelectors', () => { ...oldState.PublicChannels.channels.entities[channelId], channelsStatus: undefined, }, + [dmChannelId]: { + ...oldState.PublicChannels.channels.entities[dmChannelId], + channelsStatus: undefined, + }, }, }, }, @@ -321,17 +391,53 @@ describe('publicChannelsSelectors', () => { expect(unreadChannels).toEqual([]) }) - it('unreadChannels selector returns only unread channels', async () => { - const channelId = channelIdes.find(channelId => channelId.includes('allergies')) + it('unreadChannels selector returns only unread channels (not unread DMs)', async () => { + const channelId = channelIds.find(channelId => channelId.includes('allergies')) if (!channelId) throw new Error('no channel id') store.dispatch( publicChannelsActions.markUnreadChannel({ channelId, }) ) + store.dispatch( + publicChannelsActions.markUnreadChannel({ + channelId: DM_CHANNEL_IDS[0], + }) + ) const unreadChannels = publicChannelsSelectors.unreadChannels(store.getState()) expect(unreadChannels).toEqual([channelId]) }) + + it('unreadDms selector returns only unread DMs (not unread channels)', async () => { + const channelId = channelIds.find(channelId => channelId.includes('allergies')) + if (!channelId) throw new Error('no channel id') + store.dispatch( + publicChannelsActions.markUnreadChannel({ + channelId, + }) + ) + store.dispatch( + publicChannelsActions.markUnreadChannel({ + channelId: DM_CHANNEL_IDS[0], + }) + ) + const unreadDms = publicChannelsSelectors.unreadDms(store.getState()) + expect(unreadDms).toEqual([DM_CHANNEL_IDS[0]]) + }) + + it('dmChannels returns only channels with type === ChannelType.DM', async () => { + const channels = dmChannels(store.getState()) + const names = channels.map(channel => channel.displayedName) + expect(channels).toHaveLength(DM_CHANNEL_NAMES.length) + expect(names).toStrictEqual([...DM_CHANNEL_NAMES].sort()) + }) + + it('sortedDmChannels returns only channels with type === ChannelType.DM sorted with self first', async () => { + const channels = sortedDmChannels(store.getState()) + const names = channels.map(channel => channel.displayedName) + expect(channels).toHaveLength(DM_CHANNEL_NAMES.length) + expect(names).toStrictEqual(DM_CHANNEL_NAMES) + }) }) export {} diff --git a/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts b/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts index a4c4d8c246..d822323eed 100644 --- a/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts +++ b/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts @@ -21,6 +21,7 @@ import { type PublicChannelStatus, INITIAL_CURRENT_CHANNEL_ID, type UserProfile, + ChannelType, } from '@quiet/types' import { createLogger } from '../../utils/logger' import { currentCommunity } from '../communities/communities.selectors' @@ -70,6 +71,7 @@ export const selectGeneralChannel = createSelector(selectChannels, currentCommun timestamp: draft.timestamp, id: draft.id, public: draft.public, + type: ChannelType.CHANNEL, teamId: currentCommunity.teamId, } return channel @@ -84,7 +86,7 @@ export const publicChannels = createSelector(selectChannels, selectChannelsSelec if (b.name === 'general') { return 0 } - return a.name.localeCompare(b.name) + return a.displayedName.localeCompare(b.displayedName) }) return sorted @@ -160,7 +162,7 @@ export const currentChannel = createSelector(currentChannelId, selectChannels, ( export const currentChannelName = createSelector(currentChannel, channel => { if (!channel) return '' - return channel.name + return channel.displayedName }) export const currentChannelMessages = createSelector(currentChannel, channel => { @@ -278,15 +280,41 @@ export const channelsStatusSorted = createSelector(selectState, state => { .reverse() }) -export const unreadChannels = createSelector(channelsStatus, status => { - return Object.values(status) - .filter(isDefined) - .reduce((result: string[], channel: PublicChannelStatus) => { - if (channel.unread) { - result.push(channel.id) +export const getUnreadChannelsByType = (channelType: ChannelType) => + createSelector(channelsStatus, status => { + const unreadIds: string[] = [] + Object.values(status).forEach(thisStatus => { + if (isDefined(thisStatus) && thisStatus.type === channelType && thisStatus.unread) { + unreadIds.push(thisStatus.id) } - return result - }, []) + }) + return unreadIds + }) + +export const unreadChannels = createSelector(getUnreadChannelsByType(ChannelType.CHANNEL), unreadChannels => { + return unreadChannels +}) + +export const unreadDms = createSelector(getUnreadChannelsByType(ChannelType.DM), unreadDms => { + return unreadDms +}) + +export const dmChannels = createSelector(publicChannels, channels => { + return channels.filter(channel => channel.type === ChannelType.DM) +}) + +export const sortedDmChannels = createSelector(dmChannels, dmChannels => { + const sorted = dmChannels.sort((a, b) => { + if (a.memberIds != null && a.memberIds.length === 1) { + return -1 + } + if (b.memberIds != null && b.memberIds.length === 1) { + return 1 + } + return a.displayedName.localeCompare(b.displayedName) + }) + + return sorted }) export const areMessagesLoaded = createSelector(currentChannelMessagesMergedBySender, currentChannelMessages => { @@ -299,6 +327,14 @@ export const areChannelsLoaded = createSelector(publicChannels, channels => { return channelCount > 0 }) +export const isNewMessageOpen = createSelector(selectState, state => { + return state.newMessageOpen +}) + +export const prevChannelId = createSelector(selectState, state => { + return state.prevChannelId +}) + // TODO: update when we have assignable roles and tie channel operations to specific roles export const canCreateChannel = createSelector(selectState, () => { return true @@ -332,6 +368,11 @@ export const publicChannelsSelectors = { getChannelById, areMessagesLoaded, areChannelsLoaded, + isNewMessageOpen, + dmChannels, + sortedDmChannels, + unreadDms, + prevChannelId, canCreateChannel, canCreatePrivateChannel, } diff --git a/packages/state-manager/src/sagas/publicChannels/publicChannels.slice.ts b/packages/state-manager/src/sagas/publicChannels/publicChannels.slice.ts index cbcc6ad162..0f0d417c69 100644 --- a/packages/state-manager/src/sagas/publicChannels/publicChannels.slice.ts +++ b/packages/state-manager/src/sagas/publicChannels/publicChannels.slice.ts @@ -28,9 +28,11 @@ import { type SetCurrentChannelPayload, type UpdateNewestMessagePayload, type AddMembersChannelPayload, + EMPTY_CHANNEL_ID, ChannelOperationStatus, } from '@quiet/types' import { createLogger } from '../../utils/logger' +import { prevChannelId } from './publicChannels.selectors' const logger = createLogger('publicChannelsSlice') @@ -43,6 +45,10 @@ export class PublicChannelsState { public channelsStatus: EntityState = publicChannelsStatusAdapter.getInitialState() + public newMessageOpen = false + + public prevChannelId: string = INITIAL_CURRENT_CHANNEL_ID + public channelsSubscriptions: EntityState = publicChannelsSubscriptionsAdapter.getInitialState() } @@ -89,7 +95,7 @@ export const publicChannelsSlice = createSlice({ sendInitialChannelMessage: (state, _action: PayloadAction) => state, addChannel: (state, action: PayloadAction) => { logger.info('addChannel', action.payload) - const { channel, status } = action.payload + const { channel, displayedName, status } = action.payload if (status === ChannelOperationStatus.FAILED) { logger.error('addChannel got a failed status!') return @@ -100,6 +106,7 @@ export const publicChannelsSlice = createSlice({ } publicChannelsAdapter.addOne(state.channels, { ...channel, + displayedName: displayedName ?? channel.name, messages: channelMessagesAdapter.getInitialState(), }) publicChannelsStatusAdapter.addOne(state.channelsStatus, { @@ -107,6 +114,7 @@ export const publicChannelsSlice = createSlice({ unread: false, newestMessage: null, public: channel.public, + type: channel.type, }) }, setChannelSubscribed: (state, action: PayloadAction) => { @@ -118,6 +126,7 @@ export const publicChannelsSlice = createSlice({ }) }, channelsReplicated: (state, _action: PayloadAction) => state, + syncChannelDisplayNames: state => state, setCurrentChannel: (state, action: PayloadAction) => { const { channelId } = action.payload state.currentChannelId = channelId @@ -169,6 +178,26 @@ export const publicChannelsSlice = createSlice({ if (!channel) return channelMessagesAdapter.addOne(channel.messages, message) }, + setNewMessageOpen: (state, action: PayloadAction<{ isOpen: boolean; prevChannelId?: string }>) => { + const { isOpen, prevChannelId } = action.payload + if (prevChannelId != null) { + state.prevChannelId = prevChannelId + } + + if (isOpen) { + state.currentChannelId = EMPTY_CHANNEL_ID + } + state.newMessageOpen = isOpen + }, + setDisplayedName: (state, action: PayloadAction<{ channelId: string; displayedName: string }>) => { + const { channelId, displayedName } = action.payload + publicChannelsAdapter.updateOne(state.channels, { + id: channelId, + changes: { + displayedName, + }, + }) + }, }, }) diff --git a/packages/state-manager/src/sagas/publicChannels/syncChannelDisplayNames/syncChannelDisplayNames.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/syncChannelDisplayNames/syncChannelDisplayNames.saga.test.ts new file mode 100644 index 0000000000..ce99004e14 --- /dev/null +++ b/packages/state-manager/src/sagas/publicChannels/syncChannelDisplayNames/syncChannelDisplayNames.saga.test.ts @@ -0,0 +1,192 @@ +import { setupCrypto } from '@quiet/identity' +import { type Store } from '../../store.types' +import { prepareStore, testReducers } from '../../../utils/tests/prepareStore' +import { messages, publicChannels } from '../../..' +import { type FactoryGirl } from 'factory-girl' +import { combineReducers } from 'redux' +import { reducers } from '../../reducers' +import { expectSaga } from 'redux-saga-test-plan' +import { publicChannelsActions } from '../publicChannels.slice' +import { identityActions } from '../../identity/identity.slice' +import { type communitiesActions } from '../../communities/communities.slice' +import { DateTime } from 'luxon' +import { publicChannelsSelectors } from '../publicChannels.selectors' +import { messagesActions } from '../../messages/messages.slice' +import { + ChannelsReplicatedPayload, + ChannelType, + UserProfile, + type Community, + type Identity, + type PublicChannel, +} from '@quiet/types' +import { generateChannelId, generateDmChannelId } from '@quiet/common' +import { createLogger } from '../../../utils/logger' +import { getBaseTypesFactory, getReduxStoreFactory } from '../../../utils/tests/factories' +import { syncChannelDisplayNamesSaga } from './syncChannelDisplayNames.saga' + +const logger = createLogger('syncChannelDisplayNames:test') + +describe('syncChannelDisplayNamesSaga', () => { + let store: Store + let factory: FactoryGirl + let baseTypes: FactoryGirl + + let community: Community + let alice: UserProfile + let john: UserProfile + let sue: UserProfile + + let generalChannel: PublicChannel + + let sailingChannel: PublicChannel + let photoChannel: PublicChannel + + let dmChannel: PublicChannel + let selfDmChannel: PublicChannel + let dmGroupChannel: PublicChannel + + beforeAll(async () => { + setupCrypto() + + store = prepareStore().store + factory = await getReduxStoreFactory(store) + baseTypes = await getBaseTypesFactory() + + community = await factory.create('Community') + + alice = await factory.create('UserProfile', { + nickname: 'alice', + }) + + john = await factory.create('UserProfile', { + nickname: 'john', + }) + + sue = await factory.create('UserProfile', { + nickname: 'sue', + }) + + const aliceIdentity = await factory.create('Identity', { + userId: alice.userId, + communityId: community.id, + }) + + const generalChannelState = publicChannelsSelectors.generalChannel(store.getState()) + if (generalChannelState) generalChannel = generalChannelState + expect(generalChannel).not.toBeUndefined() + + store.dispatch(publicChannelsActions.setCurrentChannel({ channelId: generalChannel.id })) + sailingChannel = ( + await factory.create('PublicChannel', { + communityId: community.id, + channel: { + name: 'sailing', + description: 'Welcome to #sailing', + timestamp: DateTime.utc().valueOf(), + owner: 'owner', + id: generateChannelId('sailing'), + type: ChannelType.CHANNEL, + }, + displayedName: 'sailing', + }) + ).channel + + photoChannel = ( + await factory.create('PublicChannel', { + communityId: community.id, + channel: { + name: 'photo', + description: 'Welcome to #photo', + timestamp: DateTime.utc().valueOf(), + owner: 'owner', + id: generateChannelId('photo'), + type: ChannelType.CHANNEL, + }, + }) + ).channel + + dmChannel = ( + await factory.create('PublicChannel', { + communityId: community.id, + channel: { + name: 'dm-channel', + description: '', + timestamp: DateTime.utc().valueOf(), + owner: alice.userId, + id: generateDmChannelId([alice.userId, john.userId]), + type: ChannelType.DM, + memberIds: [alice.userId, john.userId], + }, + }) + ).channel + + selfDmChannel = ( + await factory.create('PublicChannel', { + communityId: community.id, + channel: { + name: 'self-dm-channel', + description: '', + timestamp: DateTime.utc().valueOf(), + owner: 'owner', + id: generateDmChannelId([alice.userId]), + type: ChannelType.DM, + memberIds: [alice.userId], + }, + }) + ).channel + + dmGroupChannel = ( + await factory.create('PublicChannel', { + communityId: community.id, + channel: { + name: 'group-dm-channel', + description: '', + timestamp: DateTime.utc().valueOf(), + owner: alice.userId, + id: generateDmChannelId([alice.userId, john.userId, sue.userId]), + type: ChannelType.DM, + memberIds: [alice.userId, john.userId, sue.userId], + }, + }) + ).channel + }) + + it('syncs only updated displayed names', async () => { + const reducer = combineReducers(testReducers) + await expectSaga(syncChannelDisplayNamesSaga) + .withReducer(reducer) + .withState(store.getState()) + .not.putResolve( + publicChannelsActions.setDisplayedName({ + channelId: sailingChannel.id, + displayedName: sailingChannel.name, + }) + ) + .putResolve( + publicChannelsActions.setDisplayedName({ + channelId: photoChannel.id, + displayedName: photoChannel.name, + }) + ) + .putResolve( + publicChannelsActions.setDisplayedName({ + channelId: selfDmChannel.id, + displayedName: alice.nickname, + }) + ) + .putResolve( + publicChannelsActions.setDisplayedName({ + channelId: dmChannel.id, + displayedName: john.nickname, + }) + ) + .putResolve( + publicChannelsActions.setDisplayedName({ + channelId: dmGroupChannel.id, + displayedName: `${john.nickname}, ${sue.nickname}`, + }) + ) + .run() + }) +}) diff --git a/packages/state-manager/src/sagas/publicChannels/syncChannelDisplayNames/syncChannelDisplayNames.saga.ts b/packages/state-manager/src/sagas/publicChannels/syncChannelDisplayNames/syncChannelDisplayNames.saga.ts new file mode 100644 index 0000000000..ab265539ab --- /dev/null +++ b/packages/state-manager/src/sagas/publicChannels/syncChannelDisplayNames/syncChannelDisplayNames.saga.ts @@ -0,0 +1,31 @@ +import { select, putResolve } from 'typed-redux-saga' +import { publicChannelsSelectors } from '../publicChannels.selectors' +import { publicChannelsActions } from '../publicChannels.slice' +import { createLogger } from '../../../utils/logger' +import { userProfileSelectors } from '../../users/userProfile/userProfile.selectors' +import { ChannelType } from '@quiet/types' +import { generateDmChannelName } from '@quiet/common' + +const logger = createLogger('syncChannelDisplayNamesSaga') + +export function* syncChannelDisplayNamesSaga(): Generator { + logger.info(`Syncing channel display names`) + + const locallyStoredChannels = yield* select(publicChannelsSelectors.publicChannels) + const userProfiles = yield* select(userProfileSelectors.userProfiles) + const me = yield* select(userProfileSelectors.myUserProfile) + + // Upserting channels to local storage + for (const channel of locallyStoredChannels) { + const displayedName = + channel.type == null || channel.type === ChannelType.CHANNEL + ? channel.name + : generateDmChannelName(channel.memberIds, userProfiles, me) + if (channel && channel.displayedName !== displayedName) { + logger.warn('Setting display name') + yield* putResolve(publicChannelsActions.setDisplayedName({ channelId: channel.id, displayedName })) + } + } + + logger.info('Channel display name sync saga finished') +} diff --git a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts index 7781592de2..726e6c47e4 100644 --- a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts +++ b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts @@ -41,6 +41,7 @@ import { HCaptchaRequest, HCaptchaChallengeRequest, InviteResultWithSalt, + ClearConnectedPeersPayload, UserProfilesUpdatedPayload, UpdateCommunityPayload, } from '@quiet/types' @@ -71,6 +72,7 @@ export function subscribe(socket: Socket) { | ReturnType | ReturnType | ReturnType + | ReturnType | ReturnType | ReturnType | ReturnType @@ -81,6 +83,7 @@ export function subscribe(socket: Socket) { | ReturnType | ReturnType | ReturnType + | ReturnType | ReturnType | ReturnType | ReturnType @@ -132,6 +135,10 @@ export function subscribe(socket: Socket) { emit(connectionActions.onConnectionProcessInfo(payload)) }) // Misc + socket.on(SocketEvents.PEER_CLEAR, (payload: ClearConnectedPeersPayload) => { + logger.info(`${SocketEvents.PEER_CLEAR}`, payload) + emit(networkActions.clearConnectedPeers(payload)) + }) socket.on(SocketEvents.PEER_CONNECTED, (payload: NetworkDataPayload) => { logger.info(`${SocketEvents.PEER_CONNECTED}`, payload) emit(networkActions.addConnectedPeers([payload.peer])) @@ -167,6 +174,7 @@ export function subscribe(socket: Socket) { socket.on(SocketEvents.CHANNELS_STORED, (payload: ChannelsReplicatedPayload) => { logger.info(`${SocketEvents.CHANNELS_STORED}`, payload) emit(publicChannelsActions.channelsReplicated(payload)) + emit(publicChannelsActions.syncChannelDisplayNames()) }) socket.on(SocketEvents.CHANNEL_SUBSCRIBED, (payload: ChannelSubscribedPayload) => { logger.info(`${SocketEvents.CHANNEL_SUBSCRIBED}`, payload) @@ -204,6 +212,7 @@ export function subscribe(socket: Socket) { payload.users.map(user => user.userId) ) emit(usersActions.setUsers(payload.users)) + emit(publicChannelsActions.syncChannelDisplayNames()) emit(messagesActions.retryVerification({ currentChannel: true })) }) @@ -216,6 +225,7 @@ export function subscribe(socket: Socket) { logger.info(`${SocketEvents.USER_PROFILES_STORED}`, payload.profiles.length) emit(usersActions.updateUserProfiles(payload.profiles)) emit(messagesActions.retryVerification({ currentChannel: true })) + emit(publicChannelsActions.syncChannelDisplayNames()) }) socket.on(SocketEvents.HCAPTCHA_CHALLENGE_REQUEST, (payload: HCaptchaChallengeRequest) => { diff --git a/packages/state-manager/src/sagas/users/userProfile/userProfile.selectors.ts b/packages/state-manager/src/sagas/users/userProfile/userProfile.selectors.ts index 5be088a6f5..07cbf5cb0c 100644 --- a/packages/state-manager/src/sagas/users/userProfile/userProfile.selectors.ts +++ b/packages/state-manager/src/sagas/users/userProfile/userProfile.selectors.ts @@ -5,8 +5,11 @@ import { StoreKeys } from '../../store.keys' import { Identity, UserProfile } from '@quiet/types' import { identitySelectors } from '../../identity/identity.selectors' import { usersSelectors } from '../users.selectors' +import { createLogger } from '../../../utils/logger' const usersSlice: CreatedSelectors[StoreKeys.Users] = (state: StoreState) => state[StoreKeys.Users] +const logger = createLogger('UserProfilesSelectors') + // Nullish coalescing operator for backwards compatibility with 2.0.1 export const userProfiles = createSelector(usersSlice, users => { const profiles: { [userId: string]: UserProfile } = {} diff --git a/packages/state-manager/src/utils/tests/factories.ts b/packages/state-manager/src/utils/tests/factories.ts index 3169d2c192..dffcd2e8fb 100644 --- a/packages/state-manager/src/utils/tests/factories.ts +++ b/packages/state-manager/src/utils/tests/factories.ts @@ -49,6 +49,7 @@ import { AddMembersChannelPayload, AddMembersChannelResponse, AddMembersChannelStatus, + ChannelType, FileMessage, FileEncryptionMetadata, UserProfilesUpdatedPayload, @@ -117,6 +118,7 @@ export const getBaseTypesFactory = async () => { public: true, owner: factory.assoc('User', 'userId'), timestamp: DateTime.utc().toSeconds(), + type: ChannelType.CHANNEL, teamId: factory.assoc('Community', 'teamId'), }) @@ -201,6 +203,12 @@ export const getReduxStoreFactory = async (store: Store) => { const factory = new factoryGirl.FactoryGirl() const baseTypes = await getBaseTypesFactory() + const _generateDmChannelName = (memberIds: string[], myMemberId: string): string => { + if (memberIds.length === 0) return 'NONAME' + if (memberIds.length === 1) return memberIds[0] + return memberIds.filter(memberId => memberId != myMemberId).join(', ') + } + factory.setAdapter(new CustomReduxAdapter(store)) factory.define['payload']>( @@ -230,8 +238,10 @@ export const getReduxStoreFactory = async (store: Store) => { owner: 'alice', id: generateChannelId('general'), public: true, + type: ChannelType.CHANNEL, teamId: payload.teamId, }, + displayedName: 'general', }) return payload }, @@ -304,19 +314,38 @@ export const getReduxStoreFactory = async (store: Store) => { { channel: factory.sequence('PublicChannel.channel', (n: number) => { const name = `public-channel-${n}` - return { + const payload: PublicChannel = { name, description: 'Description', timestamp: DateTime.utc().toSeconds(), owner: 'alice', // simpler than nested assoc; tests only need non‑undefined id: generateChannelId(name), public: true, + type: ChannelType.CHANNEL, + memberIds: undefined, teamId: factory.assoc('Community', 'teamId'), } + return payload + }), + displayedName: factory.sequence('PublicChannel.displayedName', (n: number) => { + return `public-channel-${n}` }), status: ChannelOperationStatus.SUCCESS, }, { + afterBuild: async (model: ReturnType) => { + return { + ...model, + payload: { + ...model.payload, + displayedName: + model.payload.displayedName ?? + (model.payload.channel!.type === ChannelType.CHANNEL + ? model.payload.channel!.name + : _generateDmChannelName(model.payload.channel!.memberIds ?? [], model.payload.channel!.owner)), + }, + } + }, afterCreate: async (payload: ReturnType['payload']) => { await factory.create('PublicChannelsMessagesBase', { channelId: payload.channel!.id, @@ -324,6 +353,7 @@ export const getReduxStoreFactory = async (store: Store) => { await factory.create('PublicChannelSubscription', { channelId: payload.channel!.id, }) + return payload }, } @@ -566,6 +596,7 @@ export const getSocketFactory = async () => { id: 'new-channel-id', name: 'Test Channel', description: 'A channel used for tests', + type: ChannelType.CHANNEL, teamId: 'foobar', }) @@ -577,8 +608,11 @@ export const getSocketFactory = async () => { owner: 'test-owner', timestamp: Date.now(), public: true, + type: ChannelType.CHANNEL, + memberIds: undefined, teamId: 'foobar', }, + displayedName: 'Test Channel', }) factory.define(SocketActions.ADD_MEMBERS_TO_CHANNEL, Object, { diff --git a/packages/types/package-lock.json b/packages/types/package-lock.json index 71922ab084..a526d09236 100644 --- a/packages/types/package-lock.json +++ b/packages/types/package-lock.json @@ -19,7 +19,7 @@ "devDependencies": { "@reduxjs/toolkit": "^1.9.1", "@types/jest": "^26.0.23", - "@types/node": "^17.0.21", + "@types/node": "20.19.43", "jest": "^26.6.3", "lint-staged": "^15.2.2", "ts-jest": "^26.5.2", @@ -1111,10 +1111,21 @@ } }, "node_modules/@types/node": { - "version": "17.0.45", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", - "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", - "dev": true + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" }, "node_modules/@types/normalize-package-data": { "version": "2.4.1", @@ -7871,10 +7882,21 @@ } }, "@types/node": { - "version": "17.0.45", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", - "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", - "dev": true + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "requires": { + "undici-types": "~6.21.0" + }, + "dependencies": { + "undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + } + } }, "@types/normalize-package-data": { "version": "2.4.1", diff --git a/packages/types/package.json b/packages/types/package.json index 6b95b218a5..64b0b6af3a 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -23,7 +23,7 @@ "@quiet/eslint-config": "^2.0.2-alpha.0", "@reduxjs/toolkit": "^1.9.1", "@types/jest": "^26.0.23", - "@types/node": "^17.0.21", + "@types/node": "20.19.43", "jest": "^26.6.3", "lint-staged": "^15.2.2", "ts-jest": "^26.5.2", diff --git a/packages/types/src/channel.ts b/packages/types/src/channel.ts index 19b32a6158..b01df58af2 100644 --- a/packages/types/src/channel.ts +++ b/packages/types/src/channel.ts @@ -5,6 +5,12 @@ import { Base58, KeyMetadata } from '@localfirst/crdx' export const PROFILE_PHOTO_CHANNEL_ID = '__profile-photo__' export const INITIAL_CURRENT_CHANNEL_ID = 'initialcurrentChannelId' +export const EMPTY_CHANNEL_ID = '-1' + +export enum ChannelType { + CHANNEL = 'channel', + DM = 'dm', +} export const CHANNEL_METADATA_STORE_NAME = 'public-channels' @@ -16,12 +22,15 @@ export interface PublicChannel { timestamp: number disabled?: boolean public?: boolean - teamId?: string roleName?: string + type?: ChannelType + teamId?: string + memberIds?: string[] } export interface PublicChannelStorage extends PublicChannel { messages: EntityState + displayedName: string } export interface PublicChannelStatus { @@ -29,6 +38,7 @@ export interface PublicChannelStatus { unread: boolean newestMessage: ChannelMessage | null public?: boolean + type?: ChannelType } export interface PublicChannelStatusWithName extends PublicChannelStatus { @@ -95,6 +105,8 @@ export interface CreateChannelPayload { public: boolean teamId: string description?: string + type: ChannelType + memberIds?: string[] } export enum ChannelOperationStatus { @@ -104,6 +116,7 @@ export enum ChannelOperationStatus { export interface CreateChannelResponse { status: ChannelOperationStatus + displayedName?: string channel?: PublicChannel } @@ -136,6 +149,7 @@ export interface PendingMessage { export interface SendInitialChannelMessagePayload { channelName: string channelId: string + type: ChannelType } export interface MessagesLoadedPayload { diff --git a/packages/types/src/connection.ts b/packages/types/src/connection.ts index c6c1bc6406..aa44b190a5 100644 --- a/packages/types/src/connection.ts +++ b/packages/types/src/connection.ts @@ -12,6 +12,10 @@ export interface NetworkDataPayload { lastSeen: number } +export interface ClearConnectedPeersPayload { + communityId: string +} + export interface NetworkStats { peerId: string address?: string // multiaddr diff --git a/packages/types/src/socket.ts b/packages/types/src/socket.ts index dd60b08e6a..26d7bf5c2c 100644 --- a/packages/types/src/socket.ts +++ b/packages/types/src/socket.ts @@ -44,6 +44,7 @@ import { } from './community' import { ErrorPayload } from './errors' import { HCaptchaChallengeRequest, HCaptchaFormResponse, HCaptchaRequest } from './captcha' +import { ClearConnectedPeersPayload } from './connection' import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent, NseQssUrlUpdatedEvent, NseSyncSeqUpdatedEvent } from './keys' // ----------------------------------------------------------------------------- @@ -153,6 +154,7 @@ export enum SocketEvents { CREATED_LONG_LIVED_LFA_INVITE = 'createdLongLivedLfaInvite', // ====== Network ====== + PEER_CLEAR = 'peerClear', PEER_CONNECTED = 'peerConnected', PEER_DISCONNECTED = 'peerDisconnected', TOR_INITIALIZED = 'torInitialized', @@ -274,6 +276,7 @@ export interface SocketEventsMap { [SocketEvents.CREATED_LONG_LIVED_LFA_INVITE]: EmitEvent // ====== Network ====== + [SocketEvents.PEER_CLEAR]: EmitEvent [SocketEvents.PEER_CONNECTED]: EmitEvent [SocketEvents.PEER_DISCONNECTED]: EmitEvent [SocketEvents.TOR_INITIALIZED]: EmitEvent