diff --git a/CHANGELOG.md b/CHANGELOG.md index 425ae293f0..1b9bb97fb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ ### Features * Adds hcaptcha verification for protected QSS actions [#2908](https://github.com/TryQuiet/quiet/issues/2908) +* Added warning popup when a server was unexpectedly added to chain [#2907](https://github.com/TryQuiet/quiet/issues/2907) * Messages can now be relayed using QSS [#2805](https://github.com/TryQuiet/quiet/issues/2805) * Messages can be retrieved from QSS stores [#2806](https://github.com/TryQuiet/quiet/issues/2806) * Profile photos are now uploaded via IPFS [#3048](https://github.com/TryQuiet/quiet/issues/3048) diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts index 1c840eac5b..ad3685e346 100644 --- a/packages/backend/src/nest/auth/sigchain.service.ts +++ b/packages/backend/src/nest/auth/sigchain.service.ts @@ -13,7 +13,7 @@ import { import { KeyMetadata } from '@localfirst/crdx' import { LocalDbService } from '../local-db/local-db.service' import { createLogger } from '../common/logger' -import { SocketEvents, StorableKey } from '@quiet/types' +import { SocketEvents, StorableKey, type User } from '@quiet/types' import { type RoleService } from './services/roles/role.service' import { type DeviceService } from './services/members/device.service' import { type InviteService } from './services/invites/invite.service' @@ -144,8 +144,17 @@ export class SigChainService extends EventEmitter { } private handleChainUpdate = async (teamId: string) => { - this.saveChain(teamId) - this.logger.info('Chain updated, emitted updated event') + const chain = this.getChain(teamId, false) + if (chain?.team != null) { + const users = chain.team.members().map(user => ({ + userId: user.userId, + roles: user.roles, + isRegistered: true, + isDuplicated: false, + })) as User[] + this.serverIoProvider.io.emit(SocketEvents.USERS_UPDATED, { users }) + } + void this._updateKeysOnChainUpdate(teamId).catch(err => { this.logger.error('Failed to update iOS keychain on chain update', err) }) @@ -155,6 +164,20 @@ export class SigChainService extends EventEmitter { }) this.emit(SigchainEvents.UPDATED, teamId) this.logger.info('Chain updated, emitted updated event') + + if (chain?.team != null) { + const community = await this.localDbService.getCurrentCommunity() + if (community) { + const teamServerHosts = chain.team.servers().map(s => s.host) + const communityHostsSet = new Set(community.serverHosts?.map(sh => sh.hostUrl) || []) + const teamHostsSet = new Set(teamServerHosts) + const setsAreEqual = + communityHostsSet.size === teamHostsSet.size && [...communityHostsSet].every(h => teamHostsSet.has(h)) + if (!setsAreEqual && teamServerHosts.length > 0) { + this.serverIoProvider.io.emit(SocketEvents.SERVER_ADDED, { id: community.id, serverHosts: teamServerHosts }) + } + } + } } /** 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 da2427923a..73cf7fc4e0 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 @@ -4,7 +4,7 @@ import fs from 'fs' import path from 'path' import { Test, TestingModule } from '@nestjs/testing' import { getReduxStoreFactory, prepareStore, type Store } from '@quiet/state-manager' -import { CommunityOwnership, SocketActions, type Community, type Identity } from '@quiet/types' +import { CommunityOwnership, SocketActions, SocketEvents, type Community, type Identity } from '@quiet/types' import { type FactoryGirl } from 'factory-girl' import { TestModule } from '../common/test.module' import { removeFilesFromDir } from '../common/utils' @@ -630,4 +630,71 @@ describe('ConnectionsManagerService', () => { expect(leaveCommunitySpy).toHaveBeenCalledTimes(1) expect(callback).toHaveBeenCalledWith(false) }) + + it('adds a debug server to the sigchain while marking it as known locally', async () => { + const originalIsE2E = process.env.IS_E2E + process.env.IS_E2E = 'true' + const serverHost = 'unknown-server.example.com' + + try { + await localDbService.setCommunity({ ...community, serverHosts: [] }) + await localDbService.setCurrentCommunityId(community.id) + await sigChainService.loadChain(community.teamId, true) + connectionsManagerService['attachSocketServiceListeners']() + const emitSpy = jest.spyOn(connectionsManagerService.serverIoProvider.io, 'emit') + + connectionsManagerService['socketService'].emit(SocketActions.DEBUG_ADD_SERVER, { + serverHosts: [serverHost], + }) + + await waitForExpect(async () => { + expect(sigChainService.activeChain.team?.hasServer(serverHost)).toBe(true) + expect(await localDbService.getCommunity(community.id)).toMatchObject({ + serverHosts: [{ hostUrl: serverHost, accepted: true }], + }) + expect(emitSpy).toHaveBeenCalledWith(SocketEvents.COMMUNITY_UPDATED, { + id: community.id, + updates: { serverHosts: [{ hostUrl: serverHost, accepted: true }] }, + }) + }) + } finally { + if (originalIsE2E == null) { + delete process.env.IS_E2E + } else { + process.env.IS_E2E = originalIsE2E + } + } + }) + + it('persists ToS and starts QSS when an unexpected server is accepted', async () => { + const qssEndpoint = 'wss://qss.example.com' + await localDbService.setCommunity({ + ...community, + qssEnabled: false, + tosAccepted: false, + qssEndpoint, + serverHosts: [{ hostUrl: 'qss.example.com', accepted: false }], + }) + await localDbService.setCurrentCommunityId(community.id) + await connectionsManagerService.init() + const connectSpy = jest.spyOn(qssService, 'connect').mockResolvedValue(QSSOperationResult.SUCCESS) + + connectionsManagerService['socketService'].emit(SocketActions.UPDATE_COMMUNITY, { + id: community.id, + updates: { + qssEnabled: true, + tosAccepted: true, + serverHosts: [{ hostUrl: 'qss.example.com', accepted: true }], + }, + }) + + await waitForExpect(async () => { + expect(await localDbService.getCommunity(community.id)).toMatchObject({ + qssEnabled: true, + tosAccepted: true, + serverHosts: [{ hostUrl: 'qss.example.com', accepted: true }], + }) + expect(connectSpy).toHaveBeenCalledWith(qssEndpoint) + }) + }) }) 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 e88f9b21b9..f82f7733de 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts @@ -6,6 +6,7 @@ import { EventEmitter } from 'events' import getPort from 'get-port' import { Agent } from 'https' import { CryptoEngine, setEngine } from 'pkijs' +import * as url from 'node:url' import { createPeerId, generateLibp2pPSK } from '../common/utils' import { createLibp2pAddress, isPSKcodeValid } from '@quiet/common' @@ -49,6 +50,7 @@ import { DeleteChannelPayload, SetUserProfilePayload, SetUserProfileResponse, + ServerHost, AddMembersChannelPayload, AddMembersChannelResponse, PublicChannel, @@ -56,6 +58,7 @@ import { UserProfilesUpdatedPayload, UpdateCommunityPayload, ChannelOperationStatus, + DebugAddServerPayload, } from '@quiet/types' import { CONFIG_OPTIONS, QSS_ALLOWED, QSS_ENDPOINT, SERVER_IO_PROVIDER, SOCKS_PROXY_AGENT } from '../const' import { Libp2pService, Libp2pState } from '../libp2p/libp2p.service' @@ -81,6 +84,7 @@ import { SigchainEvents } from '../auth/types' import { QPSService } from '../qps/qps.service' import { CaptchaService } from '../captcha/captcha.service' import { SigChain } from '../auth/sigchain' +import { createKeyset, redactKeys, type Server } from '@localfirst/auth' /** * A monolith service that handles lots of events received from the state-manager. @@ -637,7 +641,7 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI psk: generateLibp2pPSK().psk, ownership: CommunityOwnership.Owner, teamId: sigchain.teamId!, - qssEnabled: this.qssAllowed && payload.useServer, + qssEnabled: this.qssAllowed && payload.useServer && payload.tosAccepted, qssEndpoint: this.qssEndpoint, tosAccepted: payload.tosAccepted, } @@ -736,6 +740,19 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI ownership: CommunityOwnership.User, qssEnabled: inviteData.version === InvitationDataVersion.v5 ? inviteData.qssEnabled : undefined, qssEndpoint: inviteData.version === InvitationDataVersion.v5 ? inviteData.qssEndpoint : undefined, + tosAccepted: payload.tosAccepted, + } + + if (community.qssEnabled && payload.tosAccepted && community.qssEndpoint) { + if (this.qssEndpoint) { + let host = url.parse(this.qssEndpoint).hostname + if (host === '127.0.0.1') { + host = 'localhost' + } + if (host) { + community.serverHosts = [{ hostUrl: host, accepted: true } as ServerHost] + } + } } await this.localDbService.setCommunity(community) @@ -1066,6 +1083,32 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI } }) + this.socketService.on(SocketActions.UPDATE_COMMUNITY, async (payload: UpdateCommunityPayload) => { + this.logger.info(`socketService - ${SocketActions.UPDATE_COMMUNITY}`) + const community = await this.localDbService.getCommunity(payload.id) + if (!community) { + this.logger.error(`No community found with id ${payload.id}`) + return + } + const updatedCommunity = { ...community, ...payload.updates } + await this.localDbService.setCommunity(updatedCommunity) + + const qssBecameUsable = + updatedCommunity.qssEnabled && updatedCommunity.tosAccepted && (!community.qssEnabled || !community.tosAccepted) + if (qssBecameUsable) { + await this.qssService.connect(updatedCommunity.qssEndpoint) + } + }) + + this.socketService.on(SocketActions.DEBUG_ADD_SERVER, async (payload: DebugAddServerPayload) => { + this.logger.info(`socketService - ${SocketActions.DEBUG_ADD_SERVER}`) + try { + await this.debugAddServer(payload) + } catch (e) { + this.logger.error('Error while adding a debug server', e) + } + }) + // Local First Auth this.socketService.on( @@ -1205,6 +1248,53 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI }) } + private async debugAddServer(payload: DebugAddServerPayload): Promise { + if (process.env.NODE_ENV !== 'development' && process.env.IS_E2E !== 'true') { + this.logger.warn('Ignoring debug server request outside development and E2E') + return + } + + const community = await this.localDbService.getCurrentCommunity() + if (!community) { + this.logger.warn('No active community found for debug server request') + return + } + + const sigChain = this.sigChainService.getActiveChain(false) + if (!sigChain?.team || sigChain.teamId !== community.teamId) { + this.logger.warn(`No active sigchain found for community ${community.id}`) + return + } + + const serverHosts = [...new Set(payload.serverHosts.map(host => host.trim()).filter(Boolean))] + if (serverHosts.length === 0) { + return + } + + const knownHosts = new Map(community.serverHosts?.map(server => [server.hostUrl, server]) ?? []) + for (const hostUrl of serverHosts) { + knownHosts.set(hostUrl, { hostUrl, accepted: true }) + } + const updatedServerHosts = [...knownHosts.values()] + + await this.localDbService.setCommunity({ ...community, serverHosts: updatedServerHosts }) + this.serverIoProvider.io.emit(SocketEvents.COMMUNITY_UPDATED, { + id: community.id, + updates: { serverHosts: updatedServerHosts }, + }) + + for (const host of serverHosts) { + if (sigChain.team.hasServer(host)) { + continue + } + const server: Server = { + host, + keys: redactKeys(createKeyset({ type: 'SERVER', name: host })), + } + sigChain.server.addServer(server) + } + } + /** * Handle events from the sigchain service and update data in the state manager */ diff --git a/packages/backend/src/nest/qss/qss-sync-manager.service.ts b/packages/backend/src/nest/qss/qss-sync-manager.service.ts index 2ed952fd1d..5c70512ff7 100644 --- a/packages/backend/src/nest/qss/qss-sync-manager.service.ts +++ b/packages/backend/src/nest/qss/qss-sync-manager.service.ts @@ -986,6 +986,7 @@ export class QSSSyncManager implements OnModuleDestroy, OnModuleInit { communityInitialized: false, qssEnabled: false, qssSetup: false, + tosAccepted: false, community, } if (community == null) { @@ -994,8 +995,9 @@ export class QSSSyncManager implements OnModuleDestroy, OnModuleInit { return { ...status, - qssEnabled: (community as any).qssEnabled ?? false, - qssSetup: (community as any).qssSetup ?? false, + qssEnabled: community.qssEnabled ?? false, + qssSetup: community.qssSetup ?? false, + tosAccepted: community.tosAccepted ?? false, communityInitialized: true, } } diff --git a/packages/backend/src/nest/qss/qss.service.spec.ts b/packages/backend/src/nest/qss/qss.service.spec.ts index b1bd0229f0..27d8244199 100644 --- a/packages/backend/src/nest/qss/qss.service.spec.ts +++ b/packages/backend/src/nest/qss/qss.service.spec.ts @@ -203,14 +203,16 @@ describe('QSSService', () => { }) interface InitCommunitySettings { - qssEnabled: boolean - qssSetup: boolean + qssEnabled?: boolean + qssSetup?: boolean + tosAccepted?: boolean } - const initCommunity = async ( - settings: InitCommunitySettings = { qssEnabled: true, qssSetup: false } - ): Promise => { + const initCommunity = async (settings: InitCommunitySettings = {}): Promise => { await localDbService.setCommunity({ ...community, + qssEnabled: true, + qssSetup: false, + tosAccepted: true, ...settings, }) await localDbService.setCurrentCommunityId(community.id) @@ -312,6 +314,14 @@ describe('QSSService', () => { expect(qssService.canConnect).toBeFalsy() }) + it(`doesn't connect to QSS when enabled but TOS isn't accepted`, async () => { + await initCommunity({ tosAccepted: false, qssEnabled: true, qssSetup: false }) + mockedAllowed = jest.spyOn(qssService, 'qssAllowed', 'get').mockReturnValue(true) + await qssService.connect('ws://localhost:3000') + expect(qssService.connected).toBeFalsy() + expect(qssService.canConnect).toBeTruthy() + }) + it('reconnects when the requested QSS endpoint changes', async () => { await initCommunity() mockedAllowed = jest.spyOn(qssService, 'qssAllowed', 'get').mockReturnValue(true) @@ -815,6 +825,7 @@ describe('QSSService', () => { ...community, teamId: 'team-id', qssEnabled: true, + tosAccepted: true, }) await localDbService.setCurrentCommunityId(community.id) await localDbService.setIdentity(userIdentity) @@ -844,6 +855,7 @@ describe('QSSService', () => { ...community, teamId: 'team-id', qssEnabled: true, + tosAccepted: true, }) await localDbService.setCurrentCommunityId(community.id) await localDbService.setIdentity(userIdentity) @@ -873,6 +885,7 @@ describe('QSSService', () => { ...community, teamId: 'team-id', qssEnabled: true, + tosAccepted: true, }) await localDbService.setCurrentCommunityId(community.id) await localDbService.setIdentity(userIdentity) @@ -903,6 +916,7 @@ describe('QSSService', () => { ...community, teamId: 'team-id', qssEnabled: true, + tosAccepted: true, }) await localDbService.setCurrentCommunityId(community.id) await localDbService.setIdentity(userIdentity) @@ -1034,7 +1048,7 @@ describe('QSSService', () => { describe('sendLogEntrySyncMessage', () => { it(`sends a successful log sync to QSS`, async () => { - await initCommunity({ qssEnabled: true, qssSetup: true }) + await initCommunity({ qssEnabled: true, qssSetup: true, tosAccepted: true }) const initStatusOrig = await qssService.getQssInitStatus() expect(initStatusOrig.qssSetup).toBeTruthy() const syncSeq = 41 @@ -1282,7 +1296,7 @@ describe('QSSService', () => { }) it(`fails to send log sync to QSS and writes pending message to local DB`, async () => { - await initCommunity({ qssEnabled: true, qssSetup: true }) + await initCommunity({ qssEnabled: true, qssSetup: true, tosAccepted: true }) const initStatusOrig = await qssService.getQssInitStatus() expect(initStatusOrig.qssSetup).toBeTruthy() diff --git a/packages/backend/src/nest/qss/qss.service.ts b/packages/backend/src/nest/qss/qss.service.ts index 8b9a80b29d..78f7b1279a 100644 --- a/packages/backend/src/nest/qss/qss.service.ts +++ b/packages/backend/src/nest/qss/qss.service.ts @@ -274,6 +274,7 @@ export class QSSService extends EventEmitter implements OnModuleDestroy { communityInitialized: false, qssEnabled: false, qssSetup: false, + tosAccepted: false, community, } if (community == null) { @@ -282,8 +283,9 @@ export class QSSService extends EventEmitter implements OnModuleDestroy { return { ...status, - qssEnabled: (community as any).qssEnabled ?? false, - qssSetup: (community as any).qssSetup ?? false, + qssEnabled: community.qssEnabled ?? false, + qssSetup: community.qssSetup ?? false, + tosAccepted: community.tosAccepted ?? false, communityInitialized: true, } } @@ -417,6 +419,12 @@ export class QSSService extends EventEmitter implements OnModuleDestroy { return QSSOperationResult.DISABLED } + const initStatus = await this.getQssInitStatus() + if (!initStatus.tosAccepted) { + this.logger.warn(`Can't connect to QSS until TOS is accepted`) + return QSSOperationResult.ERROR + } + if (!enabledOverride) { const initStatus = await this.getQssInitStatus() if (!initStatus.communityInitialized) { @@ -499,6 +507,7 @@ export class QSSService extends EventEmitter implements OnModuleDestroy { * Add a community to QSS and start syncing our chain with QSS * * @param sigChain Sigchain for this community + * @param community Community metadata for this community * @returns True if successfully created */ public async createCommunity(sigChain: SigChain): Promise { @@ -520,15 +529,26 @@ export class QSSService extends EventEmitter implements OnModuleDestroy { * @returns True if successfully created */ public async _createCommunityImpl(sigChain: SigChain): Promise { + const initStatus = await this.getQssInitStatus() if (!this.canConnect) { this.logger.trace(`Can't create community on QSS because QSS is not initialized`) return false } + if (!initStatus.tosAccepted) { + this.logger.warn(`Can't create community on QSS until TOS is accepted`) + return false + } + if (sigChain.team == null) { throw new Error(`Team on this sigchain is nullish!`) } + if (initStatus.community == null) { + this.logger.warn(`Can't create community on QSS until the community is initialized locally`) + return false + } + if (!this.connected) { this.logger.warn(`Can't create community on QSS because the client hasn't connected`) return false @@ -554,6 +574,12 @@ export class QSSService extends EventEmitter implements OnModuleDestroy { host = 'localhost' } + await this.localDbService.setCommunity({ + ...initStatus.community, + qssEnabled: true, + serverHosts: [{ hostUrl: host, accepted: true }], + }) + // if we don't already have this server in our chain we need to generate keys and add it if (!sigChain.team.hasServer(host)) { // Generating the QSS LFA keyset for this community diff --git a/packages/backend/src/nest/qss/qss.types.ts b/packages/backend/src/nest/qss/qss.types.ts index cfeacb42cf..f65321bff0 100644 --- a/packages/backend/src/nest/qss/qss.types.ts +++ b/packages/backend/src/nest/qss/qss.types.ts @@ -61,6 +61,7 @@ export interface QSSInitStatus { communityInitialized: boolean qssEnabled: boolean qssSetup: boolean + tosAccepted: boolean community?: Community } diff --git a/packages/backend/src/nest/socket/socket.service.spec.ts b/packages/backend/src/nest/socket/socket.service.spec.ts index bfe005a423..73ec70dbd0 100644 --- a/packages/backend/src/nest/socket/socket.service.spec.ts +++ b/packages/backend/src/nest/socket/socket.service.spec.ts @@ -6,7 +6,7 @@ import { SocketModule } from './socket.module' import { SocketService } from './socket.service' import { io, Socket } from 'socket.io-client' import waitForExpect from 'wait-for-expect' -import { SocketActions } from '@quiet/types' +import { type DebugAddServerPayload, SocketActions } from '@quiet/types' import { suspendableSocketEvents } from './suspendable.events' import { TEST_DATA_PORT } from '../const' @@ -36,6 +36,10 @@ describe('SocketService', () => { // await module.close() }) + afterEach(() => { + jest.restoreAllMocks() + }) + it('sets no default cors', async () => { expect(socketService.serverIoProvider.io.engine.opts.cors).toStrictEqual({}) // No cors should be set by default }) @@ -63,4 +67,17 @@ describe('SocketService', () => { expect(suspendableSocketEvents).not.toContain(event) }) }) + + it('forwards debug server requests to backend listeners', async () => { + const spy = jest.spyOn(socketService, 'emit') + const payload: DebugAddServerPayload = { + serverHosts: ['unknown-server.example.com'], + } + + client.emit(SocketActions.DEBUG_ADD_SERVER, payload) + + await waitForExpect(() => { + expect(spy).toHaveBeenCalledWith(SocketActions.DEBUG_ADD_SERVER, payload) + }) + }) }) diff --git a/packages/backend/src/nest/socket/socket.service.ts b/packages/backend/src/nest/socket/socket.service.ts index fd6ddccf37..c4eb7a2cce 100644 --- a/packages/backend/src/nest/socket/socket.service.ts +++ b/packages/backend/src/nest/socket/socket.service.ts @@ -22,11 +22,14 @@ import { ResponseCreateCommunityPayload, SetUserProfileResponse, SetUserProfilePayload, + ServerAddedPayload, + UpdateCommunityPayload, type HCaptchaFormResponse, InviteResultWithSalt, AddMembersChannelPayload, AddMembersChannelResponse, UserProfilesUpdatedPayload, + DebugAddServerPayload, } from '@quiet/types' import EventEmitter from 'events' import { CONFIG_OPTIONS, SERVER_IO_PROVIDER } from '../const' @@ -200,6 +203,16 @@ export class SocketService extends EventEmitter implements OnModuleInit { this.emit(SocketActions.LEAVE_COMMUNITY, callback) }) + socket.on(SocketActions.UPDATE_COMMUNITY, async (payload: UpdateCommunityPayload) => { + this.logger.info(`socketService - ${SocketActions.UPDATE_COMMUNITY}`) + this.emit(SocketActions.UPDATE_COMMUNITY, payload) + }) + + socket.on(SocketActions.DEBUG_ADD_SERVER, async (payload: DebugAddServerPayload) => { + this.logger.info(`socketService - ${SocketActions.DEBUG_ADD_SERVER}`) + this.emit(SocketActions.DEBUG_ADD_SERVER, payload) + }) + // ====== Users ====== socket.on( @@ -229,6 +242,11 @@ export class SocketService extends EventEmitter implements OnModuleInit { this.emit(SocketEvents.CREATED_LONG_LIVED_LFA_INVITE, invite) }) + socket.on(SocketEvents.SERVER_ADDED, async (payload: ServerAddedPayload) => { + this.logger.info(`socket.on - ${SocketEvents.SERVER_ADDED}`, payload) + this.emit(SocketEvents.SERVER_ADDED, payload) + }) + // ====== Misc ====== socket.on(SocketActions.LOAD_MIGRATION_DATA, async (data: Record) => { diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index 38fca024e2..5bac233b7f 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -129,7 +129,6 @@ "ts-loader": "^9.5.4", "tsconfig-paths-webpack-plugin": "^4.2.0", "typed-redux-saga": "^1.3.1", - "typeface-roboto": "0.0.54", "typescript": "^4.9.5", "unicode-emoji-json": "^0.8.0", "webpack": "^5.105.3", @@ -44943,12 +44942,6 @@ "is-typedarray": "^1.0.0" } }, - "node_modules/typeface-roboto": { - "version": "0.0.54", - "resolved": "https://registry.npmjs.org/typeface-roboto/-/typeface-roboto-0.0.54.tgz", - "integrity": "sha512-sOFA1FXgP0gOgBYlS6irwq6hHYA370KE3dPlgYEJHL3PJd5X8gQE0RmL79ONif6fL5JZuGDj+rtOrFeOqz5IZQ==", - "dev": true - }, "node_modules/typescript": { "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", @@ -81321,12 +81314,6 @@ "is-typedarray": "^1.0.0" } }, - "typeface-roboto": { - "version": "0.0.54", - "resolved": "https://registry.npmjs.org/typeface-roboto/-/typeface-roboto-0.0.54.tgz", - "integrity": "sha512-sOFA1FXgP0gOgBYlS6irwq6hHYA370KE3dPlgYEJHL3PJd5X8gQE0RmL79ONif6fL5JZuGDj+rtOrFeOqz5IZQ==", - "dev": true - }, "typescript": { "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 69fe6e6f6b..4884ce0fac 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -260,7 +260,6 @@ "ts-loader": "^9.5.4", "tsconfig-paths-webpack-plugin": "^4.2.0", "typed-redux-saga": "^1.3.1", - "typeface-roboto": "0.0.54", "typescript": "^4.9.5", "unicode-emoji-json": "^0.8.0", "webpack": "^5.105.3", diff --git a/packages/desktop/src/renderer/Root.tsx b/packages/desktop/src/renderer/Root.tsx index 477f531f75..2e8d386487 100644 --- a/packages/desktop/src/renderer/Root.tsx +++ b/packages/desktop/src/renderer/Root.tsx @@ -1,4 +1,3 @@ -import 'typeface-roboto' import React from 'react' import CssBaseline from '@mui/material/CssBaseline' import { ThemeProvider, StyledEngineProvider } from '@mui/material/styles' @@ -35,6 +34,7 @@ import UsernameTakenModalContainer from './components/widgets/usernameTakenModal import PossibleImpersonationAttackModalContainer from './components/widgets/possibleImpersonationAttackModal/PossibleImpersonationAttackModal.container' import BreakingChangesWarning from './containers/widgets/breakingChangesWarning/BreakingChangesWarning' import TermsOfService from './components/TermsOfService/TermsOfService' +import ServerAddedModal from './components/ServerAdded/ServerAddedModal' // Trigger lerna export const persistor = persistStore(store) @@ -69,6 +69,7 @@ export default () => { + } /> diff --git a/packages/desktop/src/renderer/components/ServerAdded/ServerAddedComponent.stories.tsx b/packages/desktop/src/renderer/components/ServerAdded/ServerAddedComponent.stories.tsx new file mode 100644 index 0000000000..0929641e2f --- /dev/null +++ b/packages/desktop/src/renderer/components/ServerAdded/ServerAddedComponent.stories.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { ComponentStory, ComponentMeta } from '@storybook/react' + +import { withTheme } from '../../storybook/decorators' +import { ServerAddedComponent, ServerAddedComponentProps } from './ServerAddedComponent' + +const Template: ComponentStory = args => { + return +} + +export const QuietServer = Template.bind({}) +QuietServer.args = { + open: true, + onChoose: useServer => { + // eslint-disable-next-line no-console + console.info('ServerAdded closed with selection:', useServer) + }, + serverHosts: [process.env.QSS_ENDPOINT || 'https://quiet.example.com'], +} as ServerAddedComponentProps + +export const OtherServer = Template.bind({}) +OtherServer.args = { + open: true, + onChoose: useServer => { + // eslint-disable-next-line no-console + console.info('ServerAdded closed with selection:', useServer) + }, + serverHosts: ['https://other-server.example.com'], +} as ServerAddedComponentProps + +export const MultipleServers = Template.bind({}) +MultipleServers.args = { + open: true, + onChoose: useServer => { + // eslint-disable-next-line no-console + console.info('ServerAdded closed with selection:', useServer) + }, + serverHosts: ['https://server1.example.com', 'https://server2.example.com'], +} as ServerAddedComponentProps + +const component: ComponentMeta = { + title: 'Components/ServerAdded', + decorators: [withTheme], + component: ServerAddedComponent, +} + +export default component diff --git a/packages/desktop/src/renderer/components/ServerAdded/ServerAddedComponent.tsx b/packages/desktop/src/renderer/components/ServerAdded/ServerAddedComponent.tsx new file mode 100644 index 0000000000..da2de9741a --- /dev/null +++ b/packages/desktop/src/renderer/components/ServerAdded/ServerAddedComponent.tsx @@ -0,0 +1,182 @@ +import React from 'react' +import { styled } from '@mui/material/styles' + +import Typography from '@mui/material/Typography' +import Grid from '@mui/material/Grid' + +import Modal from '../ui/Modal/Modal' +import Button from '@mui/material/Button' +import { createLogger } from '../../logger' + +import ServerBoxIcon from '../ui/assets/icons/ServerBoxIcon' + +const logger = createLogger('ServerAdded:component') + +const PREFIX = 'ServerAddedComponent-' +const classes = { + contentWrap: `${PREFIX}contentWrap`, + actionsWrap: `${PREFIX}actions`, + textWrap: `${PREFIX}text`, + dividerWrap: `${PREFIX}dividerWrap`, + info: `${PREFIX}info`, + icon: `${PREFIX}icon`, + iconContainer: `${PREFIX}iconContainer`, + pill: `${PREFIX}pill`, + mutedAction: `${PREFIX}mutedAction`, + useServerButton: `${PREFIX}useServerButton`, + notNowButton: `${PREFIX}notNowButton`, +} + +const StyledGrid = styled(Grid)(({ theme }) => ({ + backgroundColor: theme.palette.background.default, + textAlign: 'center', + justifyContent: 'center', + + [`&.${classes.contentWrap}`]: { + width: '100%', + flex: 1, + display: 'flex', + gap: theme.spacing(3), + padding: theme.spacing(0, 4), + alignItems: 'center', + justifyContent: 'center', + }, + + [`& .${classes.actionsWrap}`]: { + gap: theme.spacing(2), + }, + + [`& .${classes.textWrap}`]: { + padding: theme.spacing(0, 3), + gap: theme.spacing(2), + }, + + [`& .${classes.dividerWrap}`]: { + width: '100%', + }, + + [`& .${classes.useServerButton}`]: { + height: '50px', + padding: theme.spacing(1.5, 2.5), + width: 'auto', + ...theme.typography.body1, + }, + + [`& .${classes.notNowButton}`]: { + minWidth: '62px', + padding: 0, + ...theme.typography.body1, + color: theme.palette.text.secondary, + }, + + [`& .${classes.iconContainer}`]: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '64px', + height: '64px', + }, + [`& .${classes.icon}`]: { + width: 48, + height: 51, + }, + + [`& .${classes.pill}`]: { + ...theme.typography.subtitle2, + + '& .MuiChip-root': { + height: 24, + borderRadius: 4, + backgroundColor: theme.palette.colors.lightPurple, + color: theme.palette.primary.main, + border: `1px solid ${theme.palette.colors.border03}`, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + + '& .MuiChip-label': { + fontWeight: 500, + fontSize: '14px', + lineHeight: '20px', + padding: theme.spacing(0.5, 1), + }, + }, + + [`& .${classes.info}`]: { + maxWidth: 520, + color: theme.palette.text.secondary, + ...theme.typography.body2, + }, + [`& .${classes.mutedAction}`]: { + '& .MuiFormControlLabel-label': { + ...theme.typography.body2, + }, + }, +})) + +export interface ServerAddedComponentProps { + open: boolean + onChoose: (useServer: boolean) => void + serverHosts: string[] +} + +export const ServerAddedComponent: React.FC = ({ open, onChoose, serverHosts }) => { + const isQuietServer = serverHosts.length === 1 && serverHosts[0] === process.env.QSS_ENDPOINT + + return ( + onChoose(false)} + isCloseDisabled={true} + withoutHeader + testIdPrefix='ServerAdded' + > + + + + + + + + {isQuietServer ? 'This community is hosted on Quiet’s server' : 'This community is hosted on a server'} + + + + + This community's admins have added a server ( + {isQuietServer ? serverHosts[0] : serverHosts.length > 1 ? serverHosts.join(', ') : serverHosts[0]}) + {isQuietServer ? ' for more speed and reliability' : ''}. Quiet will connect to the server without Tor, so + this comes at the cost of Tor's anonymity protection. Would you like to use the server or leave the + community? + + + + + + + + + + + + + + ) +} diff --git a/packages/desktop/src/renderer/components/ServerAdded/ServerAddedModal.tsx b/packages/desktop/src/renderer/components/ServerAdded/ServerAddedModal.tsx new file mode 100644 index 0000000000..cc87028a70 --- /dev/null +++ b/packages/desktop/src/renderer/components/ServerAdded/ServerAddedModal.tsx @@ -0,0 +1,54 @@ +import React, { useCallback, useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import { createLogger } from '../../logger' +import { useModal } from '../../containers/hooks' +import { ModalName } from '../../sagas/modals/modals.types' +import { clearCommunity } from '../..' +import { communities } from '@quiet/state-manager' +import { ServerAddedComponent } from './ServerAddedComponent' + +const logger = createLogger('ServerAddedModal') + +export const ServerAddedModal = () => { + const dispatch = useDispatch() + + const modal = useModal(ModalName.serverAddedModal) + + const unacceptedServers = useSelector(communities.selectors.unacceptedServers) + const currentCommunity = useSelector(communities.selectors.currentCommunity) + const tosRequested = useSelector(communities.selectors.tosRequested) + + useEffect(() => { + if (modal.open && (unacceptedServers.length === 0 || tosRequested)) { + logger.info('Closing ServerAddedModal because no server choice is currently needed') + modal.handleClose() + return + } + if (unacceptedServers.length > 0 && !modal.open && !tosRequested) { + logger.warn('Opening ServerAddedModal because unacceptedServers unexpectedly found in current community') + modal.handleOpen() + return + } + }, [modal, tosRequested, unacceptedServers]) + + const handleChoose = useCallback( + async (useServer: boolean) => { + if (!currentCommunity) { + logger.warn('No current community found when handling server choice') + return + } + if (useServer) { + dispatch(communities.actions.acceptServer()) + } else { + await clearCommunity() + } + modal.handleClose() + }, + [currentCommunity, dispatch] + ) + + return +} + +export default ServerAddedModal diff --git a/packages/desktop/src/renderer/components/TermsOfService/TermsOfService.tsx b/packages/desktop/src/renderer/components/TermsOfService/TermsOfService.tsx index 91a9001d28..f9f2e0b5ef 100644 --- a/packages/desktop/src/renderer/components/TermsOfService/TermsOfService.tsx +++ b/packages/desktop/src/renderer/components/TermsOfService/TermsOfService.tsx @@ -6,6 +6,7 @@ import { ModalName } from '../../sagas/modals/modals.types' import { useModal } from '../../containers/hooks' import { createLogger } from '../../logger' import { shell } from 'electron' +import { clearCommunity } from '../..' const logger = createLogger('TermsOfService') @@ -27,16 +28,6 @@ const TermsOfService = () => { }, [tosRequested]) const handleChoice = async (accepted: boolean) => { - if (accepted) { - if (!currentCommunity) { - loadingPanelModal.handleOpen() - } - } else { - logger.info('User declined ToS, aborting join process') - joinCommunityModal.handleOpen() - loadingPanelModal.handleClose() - } - dispatch( communities.actions.setTermsOfServiceAccepted({ communityId: currentCommunity?.id, @@ -44,6 +35,21 @@ const TermsOfService = () => { }) ) + if (accepted) { + if (!currentCommunity) { + loadingPanelModal.handleOpen() + } + } else { + if (!currentCommunity) { + logger.info('User declined ToS, aborting join process') + joinCommunityModal.handleOpen() + loadingPanelModal.handleClose() + } else { + logger.info('User declined ToS, clearing community data') + await clearCommunity() + } + } + termsOfServiceModal.handleClose() } diff --git a/packages/desktop/src/renderer/components/debugInfo/debugInfoComponent.tsx b/packages/desktop/src/renderer/components/debugInfo/debugInfoComponent.tsx index cf52edc997..20a96e2d87 100644 --- a/packages/desktop/src/renderer/components/debugInfo/debugInfoComponent.tsx +++ b/packages/desktop/src/renderer/components/debugInfo/debugInfoComponent.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { useState } from 'react' import { network, users, @@ -117,6 +118,8 @@ const StyledGrid = styled(Grid)(({ theme }) => ({ export const DebugInfoComponent: React.FC = () => { const dispatch = useDispatch() + const [serverHostsInput, setServerHostsInput] = useState('') + // --- Network --- const connectedPeers = useSelector(network.selectors.connectedPeers) const initializedCommunities = useSelector(network.selectors.initializedCommunities) @@ -202,6 +205,17 @@ export const DebugInfoComponent: React.FC = () => { files: { downloadStatuses }, } + const handleAddServer = () => { + const hosts = serverHostsInput + .split(',') + .map(h => h.trim()) + .filter(Boolean) + if (hosts.length > 0) { + dispatch(communities.actions.debugAddServer({ serverHosts: hosts })) + setServerHostsInput('') + } + } + return ( @@ -476,6 +490,41 @@ export const DebugInfoComponent: React.FC = () => { + {/* --- Add Server Section --- */} +
+ Add Server to Community + +
+ setServerHostsInput(e.target.value)} + style={{ flex: 1, padding: 6, borderRadius: 4, border: '1px solid #ccc', fontSize: 14 }} + /> + +
+ + Enter a comma separated list of server host URLs to add to the current community. + +
+
diff --git a/packages/desktop/src/renderer/components/ui/PopupMenu/PopupMenu.tsx b/packages/desktop/src/renderer/components/ui/PopupMenu/PopupMenu.tsx index 67fffd57e0..378d9f9f53 100644 --- a/packages/desktop/src/renderer/components/ui/PopupMenu/PopupMenu.tsx +++ b/packages/desktop/src/renderer/components/ui/PopupMenu/PopupMenu.tsx @@ -92,12 +92,7 @@ export const PopupMenu: React.FC = ({ > {({ TransitionProps, placement }) => { const splitPlacement: keyof typeof classes = placement.split('-')[0] as - | 'wrapper' - | 'paper' - | 'bottom' - | 'top' - | 'arrow' - | 'popper' + 'wrapper' | 'paper' | 'bottom' | 'top' | 'arrow' | 'popper' return ( { store.dispatch(socketActions.startConnection({ dataPort: parseInt(dataPort), socketIOSecret })) }) +let container: HTMLElement | null = null +let root: ReactDomRoot | null = null +export function renderApp() { + if (!container) { + container = document.getElementById('root') + if (!container) throw new Error('No root html element!') + } + if (root) { + root.unmount() + } + root = createRoot(container) + root.render() +} ipcRenderer.on('hcaptcha:token', (_event, token: string) => { store.dispatch(captcha.actions.captchaFormResponse({ token })) }) @@ -56,10 +69,11 @@ ipcRenderer.on('hcaptcha:error', (_event, message: string) => { store.dispatch(captcha.actions.captchaFormResponse({ error: message })) }) -const container = document.getElementById('root') -if (!container) throw new Error('No root html element!') -let root = createRoot(container) -root.render() +logger.info('NODE_ENV', process.env.NODE_ENV) +// Only call renderApp if not running in a test environment +if (process.env.NODE_ENV !== 'test') { + renderApp() +} export const clearCommunity = async () => { await clearCommunityWithDependencies({ @@ -67,11 +81,7 @@ export const clearCommunity = async () => { dispatch: store.dispatch, resetAppAction: communities.actions.resetApp('payload'), requestBackendLeave: () => ipcRenderer.invoke('clear-community'), - remountRoot: () => { - root.unmount() - root = createRoot(container) - root.render() - }, + remountRoot: renderApp, }) } diff --git a/packages/desktop/src/renderer/sagas/modals/modals.slice.ts b/packages/desktop/src/renderer/sagas/modals/modals.slice.ts index a3f6293529..1d9b376f71 100644 --- a/packages/desktop/src/renderer/sagas/modals/modals.slice.ts +++ b/packages/desktop/src/renderer/sagas/modals/modals.slice.ts @@ -39,6 +39,7 @@ export class ModalsInitialState { [ModalName.possibleImpersonationAttackModal] = { open: false, args: {} }; [ModalName.breakingChangesWarning] = { open: false, args: {} }; [ModalName.termsOfServiceModal] = { open: false, args: {} }; + [ModalName.serverAddedModal] = { open: false, args: {} }; [ModalName.addMembersChannel] = { open: false, args: {} } } diff --git a/packages/desktop/src/renderer/sagas/modals/modals.types.ts b/packages/desktop/src/renderer/sagas/modals/modals.types.ts index 383bc3bbdb..cd4f43c476 100644 --- a/packages/desktop/src/renderer/sagas/modals/modals.types.ts +++ b/packages/desktop/src/renderer/sagas/modals/modals.types.ts @@ -26,5 +26,6 @@ export enum ModalName { usernameTakenModal = 'usernameTakenModal', possibleImpersonationAttackModal = 'possibleImpersonationAttackModal', termsOfServiceModal = 'termsOfServiceModal', + serverAddedModal = 'serverAddedModal', addMembersChannel = 'addMembersChannel', } diff --git a/packages/desktop/src/rtl-tests/community.join.test.tsx b/packages/desktop/src/rtl-tests/community.join.test.tsx index 414a325b9f..be0a47de8f 100644 --- a/packages/desktop/src/rtl-tests/community.join.test.tsx +++ b/packages/desktop/src/rtl-tests/community.join.test.tsx @@ -156,6 +156,10 @@ describe('User', () => { unobserve: jest.fn(), disconnect: jest.fn(), })) + jest.mock('../renderer', () => ({ + ...jest.requireActual('../renderer'), + clearCommunity: jest.fn(), + })) }) it('joins community and registers username', async () => { diff --git a/packages/e2e-tests/src/selectors.ts b/packages/e2e-tests/src/selectors.ts index 029abe4bef..5db6f250b1 100644 --- a/packages/e2e-tests/src/selectors.ts +++ b/packages/e2e-tests/src/selectors.ts @@ -1315,6 +1315,38 @@ export class ServerOfferModal { } } +export class ServerAddedModal { + private readonly driver: ThenableWebDriver + + constructor(driver: ThenableWebDriver) { + this.driver = driver + } + + async isReady(timeoutMs: number = 120_000): Promise { + const title = await this.driver.wait( + until.elementLocated(By.xpath("//*[@data-testid='ServerAdded-Title']")), + timeoutMs, + `Server added warning couldn't be found within timeout`, + 500 + ) + await this.driver.wait( + until.elementIsVisible(title), + 10_000, + `Server added warning wasn't visible within timeout`, + 500 + ) + return true + } + + async getTitle(): Promise { + return await this.driver.findElement(By.xpath("//*[@data-testid='ServerAdded-Title']")).getText() + } + + async getMessage(): Promise { + return await this.driver.findElement(By.xpath("//*[@data-testid='ServerAdded-Message']")).getText() + } +} + export class TermsOfServiceModal { private readonly driver: ThenableWebDriver @@ -2539,6 +2571,28 @@ export class Settings { await this.switchTab(SettingsModalTabName.DEBUG) } + async addServerToCommunity(serverHost: string): Promise { + const input = await this.driver.wait( + until.elementLocated(By.xpath("//input[@data-testid='debug-add-server-input']")), + 10_000, + `Debug server input couldn't be found within timeout`, + 500 + ) + await this.driver.wait(until.elementIsVisible(input), 5_000) + await input.clear() + await input.sendKeys(serverHost) + + const button = await this.driver.wait( + until.elementLocated(By.xpath("//button[@data-testid='debug-add-server-button']")), + 10_000, + `Debug add server 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 this.driver.executeScript('arguments[0].click()', button) + } + /** * Clicks the “Leave community” button, retrying until it becomes clickable or the timeout elapses. * diff --git a/packages/e2e-tests/src/tests/unknownServer.test.ts b/packages/e2e-tests/src/tests/unknownServer.test.ts new file mode 100644 index 0000000000..3ccb997606 --- /dev/null +++ b/packages/e2e-tests/src/tests/unknownServer.test.ts @@ -0,0 +1,92 @@ +import { jest } from '@jest/globals' + +import { + App, + Channel, + CreateCommunityModal, + JoinCommunityModal, + JoiningLoadingPanel, + RegisterUsernameModal, + ServerAddedModal, + Sidebar, +} from '../selectors' +import { SettingsModalTabName } from '../enums' + +jest.setTimeout(600_000) + +describe('Unknown Server Warning', () => { + const communityName = 'unknown-server-test' + const ownerUsername = 'server-test-owner' + const memberUsername = 'server-test-member' + const unknownServerHost = 'unknown-server.example.com' + const readyMessage = 'Ready to receive server updates' + + const ownerApp = new App({ username: ownerUsername }) + const memberApp = new App({ username: memberUsername }) + + afterAll(async () => { + for (const app of [ownerApp, memberApp]) { + await app.close() + await app.cleanup() + } + }) + + it('warns another user when an admin adds an unknown server from the debug sidebar', async () => { + await ownerApp.openWithRetries() + + const ownerJoinModal = new JoinCommunityModal(ownerApp.driver) + expect(await ownerJoinModal.isReady()).toBeTruthy() + await ownerJoinModal.switchToCreateCommunity() + + const createModal = new CreateCommunityModal(ownerApp.driver) + expect(await createModal.isReady()).toBeTruthy() + await createModal.typeCommunityName(communityName) + await createModal.submit() + + const ownerRegisterModal = new RegisterUsernameModal(ownerApp.driver) + expect(await ownerRegisterModal.isReady()).toBeTruthy() + await ownerRegisterModal.typeUsername(ownerUsername) + await ownerRegisterModal.submit() + await new JoiningLoadingPanel(ownerApp.driver).waitForJoinToComplete() + + const ownerChannel = new Channel(ownerApp.driver, 'general') + expect(await ownerChannel.isReady()).toBeTruthy() + + const inviteSettings = await new Sidebar(ownerApp.driver).openSettings() + expect(await inviteSettings.isReady()).toBeTruthy() + await inviteSettings.switchTab(SettingsModalTabName.INVITE) + const invitationLink = await (await inviteSettings.invitationLink()).getText() + await inviteSettings.closeTabThenModal() + + await memberApp.openWithRetries() + const memberJoinModal = new JoinCommunityModal(memberApp.driver) + expect(await memberJoinModal.isReady()).toBeTruthy() + await memberJoinModal.typeCommunityInviteLink(invitationLink) + await memberJoinModal.submit() + + const memberRegisterModal = new RegisterUsernameModal(memberApp.driver) + expect(await memberRegisterModal.isReady()).toBeTruthy() + await memberRegisterModal.typeUsername(memberUsername) + await memberRegisterModal.submit() + await new JoiningLoadingPanel(memberApp.driver).waitForJoinToComplete() + + const memberChannel = new Channel(memberApp.driver, 'general') + expect(await memberChannel.isReady()).toBeTruthy() + await memberChannel.sendMessage(readyMessage, memberUsername) + await ownerChannel.getMessageIdsByText(readyMessage, memberUsername, 120_000) + + const debugSettings = await new Sidebar(ownerApp.driver).openSettings() + expect(await debugSettings.isReady()).toBeTruthy() + await debugSettings.openDebugTab() + await debugSettings.addServerToCommunity(unknownServerHost) + await debugSettings.closeTabThenModal() + + const serverAddedModal = new ServerAddedModal(memberApp.driver) + expect(await serverAddedModal.isReady()).toBeTruthy() + expect(await serverAddedModal.getTitle()).toEqual('This community is hosted on a server') + expect(await serverAddedModal.getMessage()).toContain(unknownServerHost) + + // Keep the validated warning visible long enough for a manual visual check before cleanup closes the app. + await memberApp.driver.sleep(5_000) + }) +}) diff --git a/packages/mobile/src/App.tsx b/packages/mobile/src/App.tsx index 099db62b9b..ad61f8b749 100644 --- a/packages/mobile/src/App.tsx +++ b/packages/mobile/src/App.tsx @@ -58,6 +58,7 @@ import UsernameTakenScreen from './screens/UsernameTaken/UsernameTaken.screen' import { ChannelMembershipScreen } from './screens/ChannelMembership/ChannelMembership.screen' import { UpdateChannelMembershipScreen } from './screens/ChannelMembership/UpdateChannelMembership/UpdateChannelMembership.screen' import { CaptchaModal } from './components/Captcha/CaptchaModal.component' +import { ServerAddedDrawer } from './components/ModalBottomDrawer/drawers/ServerAdded.drawer' const logger = createLogger('app') @@ -135,6 +136,7 @@ function App(): JSX.Element { + diff --git a/packages/mobile/src/components/ModalBottomDrawer/drawers/ServerAdded.drawer.stories.tsx b/packages/mobile/src/components/ModalBottomDrawer/drawers/ServerAdded.drawer.stories.tsx new file mode 100644 index 0000000000..087ea9c995 --- /dev/null +++ b/packages/mobile/src/components/ModalBottomDrawer/drawers/ServerAdded.drawer.stories.tsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react' +import { storiesOf } from '@storybook/react-native' +import { View, Button } from 'react-native' +import { ServerAddedDrawer } from './ServerAdded.drawer' + +const ServerAddedDrawerStory = () => { + const [visible, setVisible] = useState(true) + const [lastResult, setLastResult] = useState(null) + const [serverHost, setServerHost] = useState('example.com') + + const handleClose = (useServer: boolean) => { + setVisible(false) + setLastResult(useServer) + } + + return ( + +