diff --git a/app/actions/remote/entry/common.ts b/app/actions/remote/entry/common.ts index dfcc510ec..0f81057bb 100644 --- a/app/actions/remote/entry/common.ts +++ b/app/actions/remote/entry/common.ts @@ -28,7 +28,7 @@ import {getHasCRTChanged} from '@queries/servers/preference'; import {getCurrentChannelId, getCurrentTeamId, getIsDataRetentionEnabled, getPushVerificationStatus, getLastFullSync, setCurrentTeamAndChannelId, getConfigValue} from '@queries/servers/system'; import {getTeamChannelHistory} from '@queries/servers/team'; import NavigationStore from '@store/navigation_store'; -import {isDMorGM, sortChannelsByDisplayName} from '@utils/channel'; +import {isDefaultChannel, isDMorGM, sortChannelsByDisplayName} from '@utils/channel'; import {getFullErrorMessage} from '@utils/errors'; import {isMinimumServerVersion, isTablet} from '@utils/helpers'; import {logDebug, logError} from '@utils/log'; @@ -227,7 +227,7 @@ export async function entryInitialChannelId(database: Database, requestedChannel } // Check if we are member of the default channel. - const defaultChannel = channels?.find((c) => c.name === General.DEFAULT_CHANNEL && c.team_id === initialTeamId); + const defaultChannel = channels?.find((c) => isDefaultChannel(c) && c.team_id === initialTeamId); const iAmMemberOfTheTeamDefaultChannel = Boolean(defaultChannel && membershipIds.has(defaultChannel.id)); if (iAmMemberOfTheTeamDefaultChannel) { return defaultChannel!.id; diff --git a/app/components/channel_actions/leave_channel_label/index.ts b/app/components/channel_actions/leave_channel_label/index.ts index bda43db50..0c4268f47 100644 --- a/app/components/channel_actions/leave_channel_label/index.ts +++ b/app/components/channel_actions/leave_channel_label/index.ts @@ -5,9 +5,9 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {combineLatestWith, switchMap} from 'rxjs/operators'; -import {General} from '@constants'; import {observeChannel} from '@queries/servers/channel'; import {observeCurrentUser} from '@queries/servers/user'; +import {isDefaultChannel} from '@utils/channel'; import LeaveChannelLabel from './leave_channel_label'; @@ -23,8 +23,8 @@ const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps const canLeave = channel.pipe( combineLatestWith(currentUser), switchMap(([ch, u]) => { - const isDefaultChannel = ch?.name === General.DEFAULT_CHANNEL; - return of$(!isDefaultChannel || (isDefaultChannel && u?.isGuest)); + const isDC = isDefaultChannel(ch); + return of$(!isDC || (isDC && u?.isGuest)); }), ); diff --git a/app/components/post_draft/index.ts b/app/components/post_draft/index.ts index e6d58669a..3dea22685 100644 --- a/app/components/post_draft/index.ts +++ b/app/components/post_draft/index.ts @@ -7,12 +7,12 @@ import {combineLatest, of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; import {General, Permissions} from '@constants'; -import {observeChannel} from '@queries/servers/channel'; +import {observeChannel, observeIsReadOnlyChannel} from '@queries/servers/channel'; import {queryDraft, observeFirstDraft} from '@queries/servers/drafts'; import {observePermissionForChannel} from '@queries/servers/role'; -import {observeConfigBooleanValue, observeCurrentChannelId} from '@queries/servers/system'; +import {observeCurrentChannelId} from '@queries/servers/system'; import {observeCurrentUser, observeUser} from '@queries/servers/user'; -import {isSystemAdmin, getUserIdFromChannelName} from '@utils/user'; +import {getUserIdFromChannelName} from '@utils/user'; import PostDraft from './post_draft'; @@ -49,10 +49,7 @@ const enhanced = withObservables(['channelId', 'rootId', 'channelIsArchived'], ( const canPost = combineLatest([channel, currentUser]).pipe(switchMap(([c, u]) => (c && u ? observePermissionForChannel(database, c, u, Permissions.CREATE_POST, true) : of$(true)))); const channelIsArchived = channel.pipe(switchMap((c) => (ownProps.channelIsArchived ? of$(true) : of$(c?.deleteAt !== 0)))); - const experimentalTownSquareIsReadOnly = observeConfigBooleanValue(database, 'ExperimentalTownSquareIsReadOnly'); - const channelIsReadOnly = combineLatest([currentUser, channel, experimentalTownSquareIsReadOnly]).pipe( - switchMap(([u, c, readOnly]) => of$(c?.name === General.DEFAULT_CHANNEL && !isSystemAdmin(u?.roles || '') && readOnly)), - ); + const channelIsReadOnly = observeIsReadOnlyChannel(database, ownProps.channelId); const deactivatedChannel = combineLatest([currentUser, channel]).pipe( switchMap(([u, c]) => { diff --git a/app/components/post_list/post/body/reactions/index.ts b/app/components/post_list/post/body/reactions/index.ts index 98794f956..a9eebaef5 100644 --- a/app/components/post_list/post/body/reactions/index.ts +++ b/app/components/post_list/post/body/reactions/index.ts @@ -3,15 +3,14 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {combineLatest, of as of$} from 'rxjs'; -import {map, switchMap} from 'rxjs/operators'; +import {switchMap} from 'rxjs/operators'; -import {General, Permissions} from '@constants'; -import {observeChannel} from '@queries/servers/channel'; +import {Permissions} from '@constants'; +import {observeChannel, observeIsReadOnlyChannel} from '@queries/servers/channel'; import {observeReactionsForPost} from '@queries/servers/reaction'; import {observePermissionForPost} from '@queries/servers/role'; -import {observeConfigBooleanValue, observeCurrentUserId} from '@queries/servers/system'; +import {observeCurrentUserId} from '@queries/servers/system'; import {observeUser} from '@queries/servers/user'; -import {isSystemAdmin} from '@utils/user'; import Reactions from './reactions'; @@ -28,9 +27,13 @@ const withReactions = withObservables(['post'], ({database, post}: WithReactions switchMap((id) => observeUser(database, id)), ); const channel = observeChannel(database, post.channelId); - const experimentalTownSquareIsReadOnly = observeConfigBooleanValue(database, 'ExperimentalTownSquareIsReadOnly'); - const disabled = combineLatest([currentUser, channel, experimentalTownSquareIsReadOnly]).pipe( - map(([u, c, readOnly]) => ((c && c.deleteAt > 0) || (c?.name === General.DEFAULT_CHANNEL && !isSystemAdmin(u?.roles || '') && readOnly))), + const disabled = combineLatest([channel]).pipe( + switchMap(([c]) => { + if (c && c.deleteAt > 0) { + return of$(true); + } + return observeIsReadOnlyChannel(database, post.channelId); + }), ); const canAddReaction = currentUser.pipe(switchMap((u) => observePermissionForPost(database, post, u, Permissions.ADD_REACTION, true))); diff --git a/app/queries/servers/channel.nomock.test.ts b/app/queries/servers/channel.nomock.test.ts new file mode 100644 index 000000000..96e5bee66 --- /dev/null +++ b/app/queries/servers/channel.nomock.test.ts @@ -0,0 +1,374 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database} from '@nozbe/watermelondb'; + +import {General} from '@constants'; +import DatabaseManager from '@database/manager'; +import ServerDataOperator from '@database/operator/server_data_operator'; +import TestHelper from '@test/test_helper'; + +import { + observeIsReadOnlyChannel, + observeMyChannelUnreads, + prepareAllMyChannels, +} from './channel'; + +describe('observeMyChannelUnreads', () => { + const teamId = 'team_id'; + const userId = 'user_id'; + const serverUrl = 'baseHandler.test.com'; + let database: Database; + let operator: ServerDataOperator; + + jest.restoreAllMocks(); + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + }); + + afterEach(async () => { + await DatabaseManager.deleteServerDatabase(serverUrl); + }); + + it('should return true when there are unread channels that are not muted', async () => { + const subscriptionNext = jest.fn(); + const notify_props = {mark_unread: 'all' as const}; + const result = observeMyChannelUnreads(database, teamId); + result.subscribe({next: subscriptionNext}); + + // Subscription always returns the first value + expect(subscriptionNext).toHaveBeenCalledWith(false); + subscriptionNext.mockClear(); + + let models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 20})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + + // No change + expect(subscriptionNext).not.toHaveBeenCalled(); + + models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + + expect(subscriptionNext).toHaveBeenCalledWith(true); + subscriptionNext.mockClear(); + + models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 30})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + }); + + it('should return false when all unread channels are muted', async () => { + const subscriptionNext = jest.fn(); + const notify_props = {mark_unread: 'mention' as const}; + const result = observeMyChannelUnreads(database, teamId); + result.subscribe({next: subscriptionNext}); + + // Subscription always returns the first value + expect(subscriptionNext).toHaveBeenCalledWith(false); + subscriptionNext.mockClear(); + + let models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 20})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + + // No change + expect(subscriptionNext).not.toHaveBeenCalled(); + + models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + + expect(subscriptionNext).not.toHaveBeenCalled(); + + models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 30})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + + expect(subscriptionNext).not.toHaveBeenCalled(); + }); + + it('missing notify props are considered unmuted', async () => { + const subscriptionNext = jest.fn(); + const notify_props = {}; + const result = observeMyChannelUnreads(database, teamId); + result.subscribe({next: subscriptionNext}); + + // Subscription always returns the first value + expect(subscriptionNext).toHaveBeenCalledWith(false); + subscriptionNext.mockClear(); + + let models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 20})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + + // No change + expect(subscriptionNext).not.toHaveBeenCalled(); + + models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + + expect(subscriptionNext).toHaveBeenCalledWith(true); + subscriptionNext.mockClear(); + + models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 30})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + }); + + it('should not retrigger the subscription when there are changes in other teams', async () => { + const subscriptionNext = jest.fn(); + const otherTeamId = 'other_team_id'; + const notify_props = {mark_unread: 'all' as const}; + const result = observeMyChannelUnreads(database, teamId); + result.subscribe({next: subscriptionNext}); + + // Subscription always returns the first value + expect(subscriptionNext).toHaveBeenCalledWith(false); + subscriptionNext.mockClear(); + + let models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: otherTeamId, total_msg_count: 20})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + + // No change + expect(subscriptionNext).not.toHaveBeenCalled(); + + models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: otherTeamId, total_msg_count: 30})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + + expect(subscriptionNext).not.toHaveBeenCalled(); + + models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: otherTeamId, total_msg_count: 30})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 30})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + + expect(subscriptionNext).not.toHaveBeenCalled(); + }); +}); + +describe('observeIsReadOnlyChannel', () => { + const userId = 'user_id'; + const serverUrl = 'baseHandler.test.com'; + let database: Database; + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + + await operator.handleSystem({ + systems: [{id: 'currentUserId', value: userId}], + prepareRecordsOnly: false, + }); + }); + + afterEach(async () => { + await DatabaseManager.deleteServerDatabase(serverUrl); + }); + + it('should return true for town square when user is not admin and setting is enabled', async () => { + const subscriptionNext = jest.fn(); + const channelId = 'channel_id'; + + // Set up initial data + const models = (await Promise.all([ + operator.handleChannel({ + channels: [TestHelper.fakeChannel({id: channelId, name: General.DEFAULT_CHANNEL})], + prepareRecordsOnly: true, + }), + operator.handleUsers({ + users: [TestHelper.fakeUser({id: userId, roles: ''})], + prepareRecordsOnly: true, + }), + ])).flat(); + await operator.batchRecords(models, 'test'); + + // Set up config + await operator.handleConfigs({ + configs: [{id: 'ExperimentalTownSquareIsReadOnly', value: 'true'}], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + const result = observeIsReadOnlyChannel(database, channelId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(true); + }); + + it('should return false for non-town-square channels', async () => { + const subscriptionNext = jest.fn(); + const channelId = 'channel_id'; + + // Set up initial data + const models = (await Promise.all([ + operator.handleChannel({ + channels: [TestHelper.fakeChannel({id: channelId, name: 'other-channel'})], + prepareRecordsOnly: true, + }), + operator.handleUsers({ + users: [TestHelper.fakeUser({id: userId, roles: ''})], + prepareRecordsOnly: true, + }), + ])).flat(); + await operator.batchRecords(models, 'test'); + + // Set up config + await operator.handleConfigs({ + configs: [{id: 'ExperimentalTownSquareIsReadOnly', value: 'true'}], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + const result = observeIsReadOnlyChannel(database, channelId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + }); + + it('should return false when experimental setting is disabled', async () => { + const subscriptionNext = jest.fn(); + const channelId = 'channel_id'; + + // Set up initial data + const models = (await Promise.all([ + operator.handleChannel({ + channels: [TestHelper.fakeChannel({id: channelId, name: General.DEFAULT_CHANNEL})], + prepareRecordsOnly: true, + }), + operator.handleUsers({ + users: [TestHelper.fakeUser({id: userId, roles: ''})], + prepareRecordsOnly: true, + }), + ])).flat(); + await operator.batchRecords(models, 'test'); + + // Set up config + await operator.handleConfigs({ + configs: [{id: 'ExperimentalTownSquareIsReadOnly', value: 'false'}], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + const result = observeIsReadOnlyChannel(database, channelId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + }); + + it('should return false when user is system admin', async () => { + const subscriptionNext = jest.fn(); + const channelId = 'channel_id'; + + // Set up initial data + const models = (await Promise.all([ + operator.handleChannel({ + channels: [TestHelper.fakeChannel({id: channelId, name: General.DEFAULT_CHANNEL})], + prepareRecordsOnly: true, + }), + operator.handleUsers({ + users: [TestHelper.fakeUser({id: userId, roles: 'system_admin'})], + prepareRecordsOnly: true, + }), + ])).flat(); + await operator.batchRecords(models, 'test'); + + // Set up config + await operator.handleConfigs({ + configs: [{id: 'ExperimentalTownSquareIsReadOnly', value: 'true'}], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + const result = observeIsReadOnlyChannel(database, channelId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + }); + + it('should return false when channel is undefined', async () => { + const subscriptionNext = jest.fn(); + const channelId = 'nonexistent_channel'; + + // Set up initial data + const models = await operator.handleUsers({ + users: [TestHelper.fakeUser({id: userId, roles: ''})], + prepareRecordsOnly: true, + }); + await operator.batchRecords(models, 'test'); + + // Set up config + await operator.handleConfigs({ + configs: [{id: 'ExperimentalTownSquareIsReadOnly', value: 'true'}], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + const result = observeIsReadOnlyChannel(database, channelId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + }); +}); diff --git a/app/queries/servers/channel.test.ts b/app/queries/servers/channel.test.ts index 1f3bd5a8e..c24f0b41d 100644 --- a/app/queries/servers/channel.test.ts +++ b/app/queries/servers/channel.test.ts @@ -8,7 +8,6 @@ import {of as of$} from 'rxjs'; import {General, Permissions} from '@constants'; import {MM_TABLES} from '@constants/database'; -import DatabaseManager from '@database/manager'; import ServerDataOperator from '@database/operator/server_data_operator'; import TestHelper from '@test/test_helper'; import {hasPermission} from '@utils/role'; @@ -63,8 +62,6 @@ import {prepareChannels, queryChannelMembers, queryChannelsForAutocomplete, observeChannelMembers, - observeMyChannelUnreads, - prepareAllMyChannels, } from './channel'; import {queryRoles} from './role'; import {getCurrentChannelId, observeCurrentChannelId, observeCurrentUserId} from './system'; @@ -1562,216 +1559,3 @@ describe('Channel Observations', () => { }); }); }); - -describe('observeMyChannelUnreads', () => { - const teamId = 'team_id'; - const userId = 'user_id'; - const serverUrl = 'baseHandler.test.com'; - const waitTime = 50; - let database: Database; - let operator: ServerDataOperator; - - jest.restoreAllMocks(); - - beforeEach(async () => { - await DatabaseManager.init([serverUrl]); - const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); - database = serverDatabaseAndOperator.database; - operator = serverDatabaseAndOperator.operator; - }); - - afterEach(async () => { - await DatabaseManager.deleteServerDatabase(serverUrl); - }); - - it('should return true when there are unread channels that are not muted', async () => { - const subscriptionNext = jest.fn(); - const notify_props = TestHelper.fakeChannelNotifyProps({mark_unread: 'all'}); - const result = observeMyChannelUnreads(database, teamId); - result.subscribe({next: subscriptionNext}); - - TestHelper.wait(waitTime); - - // Subscription always returns the first value - expect(subscriptionNext).toHaveBeenCalledWith(false); - subscriptionNext.mockClear(); - - let models = (await Promise.all((await prepareAllMyChannels( - operator, - [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 20})], - [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], - false, - )))).flat(); - await operator.batchRecords(models, 'test'); - TestHelper.wait(waitTime); - - // No change - expect(subscriptionNext).not.toHaveBeenCalled(); - - models = (await Promise.all((await prepareAllMyChannels( - operator, - [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], - [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], - false, - )))).flat(); - await operator.batchRecords(models, 'test'); - TestHelper.wait(waitTime); - - expect(subscriptionNext).toHaveBeenCalledWith(true); - subscriptionNext.mockClear(); - - models = (await Promise.all((await prepareAllMyChannels( - operator, - [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], - [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 30})], - false, - )))).flat(); - await operator.batchRecords(models, 'test'); - TestHelper.wait(waitTime); - - expect(subscriptionNext).toHaveBeenCalledWith(false); - }); - - it('should return false when all unread channels are muted', async () => { - const subscriptionNext = jest.fn(); - const notify_props = TestHelper.fakeChannelNotifyProps({mark_unread: General.MENTION}); - const result = observeMyChannelUnreads(database, teamId); - result.subscribe({next: subscriptionNext}); - - TestHelper.wait(waitTime); - - // Subscription always returns the first value - expect(subscriptionNext).toHaveBeenCalledWith(false); - subscriptionNext.mockClear(); - - let models = (await Promise.all((await prepareAllMyChannels( - operator, - [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 20})], - [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], - false, - )))).flat(); - await operator.batchRecords(models, 'test'); - TestHelper.wait(waitTime); - - // No change - expect(subscriptionNext).not.toHaveBeenCalled(); - - models = (await Promise.all((await prepareAllMyChannels( - operator, - [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], - [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], - false, - )))).flat(); - await operator.batchRecords(models, 'test'); - TestHelper.wait(waitTime); - - expect(subscriptionNext).not.toHaveBeenCalled(); - - models = (await Promise.all((await prepareAllMyChannels( - operator, - [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], - [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 30})], - false, - )))).flat(); - await operator.batchRecords(models, 'test'); - TestHelper.wait(waitTime); - - expect(subscriptionNext).not.toHaveBeenCalled(); - }); - - it('missing notify props are considered unmuted', async () => { - const subscriptionNext = jest.fn(); - const notify_props = {}; - const result = observeMyChannelUnreads(database, teamId); - result.subscribe({next: subscriptionNext}); - - TestHelper.wait(waitTime); - - // Subscription always returns the first value - expect(subscriptionNext).toHaveBeenCalledWith(false); - subscriptionNext.mockClear(); - - let models = (await Promise.all((await prepareAllMyChannels( - operator, - [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 20})], - [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], - false, - )))).flat(); - await operator.batchRecords(models, 'test'); - TestHelper.wait(waitTime); - - // No change - expect(subscriptionNext).not.toHaveBeenCalled(); - - models = (await Promise.all((await prepareAllMyChannels( - operator, - [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], - [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], - false, - )))).flat(); - await operator.batchRecords(models, 'test'); - TestHelper.wait(waitTime); - - expect(subscriptionNext).toHaveBeenCalledWith(true); - subscriptionNext.mockClear(); - - models = (await Promise.all((await prepareAllMyChannels( - operator, - [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], - [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 30})], - false, - )))).flat(); - await operator.batchRecords(models, 'test'); - TestHelper.wait(waitTime); - - expect(subscriptionNext).toHaveBeenCalledWith(false); - }); - - it('should not retrigger the subscription when there are changes in other teams', async () => { - const subscriptionNext = jest.fn(); - const otherTeamId = 'other_team_id'; - const notify_props = TestHelper.fakeChannelNotifyProps({mark_unread: General.MENTION}); - const result = observeMyChannelUnreads(database, teamId); - result.subscribe({next: subscriptionNext}); - - TestHelper.wait(waitTime); - - // Subscription always returns the first value - expect(subscriptionNext).toHaveBeenCalledWith(false); - subscriptionNext.mockClear(); - - let models = (await Promise.all((await prepareAllMyChannels( - operator, - [TestHelper.fakeChannel({id: 'channel1', team_id: otherTeamId, total_msg_count: 20})], - [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], - false, - )))).flat(); - await operator.batchRecords(models, 'test'); - TestHelper.wait(waitTime); - - // No change - expect(subscriptionNext).not.toHaveBeenCalled(); - - models = (await Promise.all((await prepareAllMyChannels( - operator, - [TestHelper.fakeChannel({id: 'channel1', team_id: otherTeamId, total_msg_count: 30})], - [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], - false, - )))).flat(); - await operator.batchRecords(models, 'test'); - TestHelper.wait(waitTime); - - expect(subscriptionNext).not.toHaveBeenCalled(); - - models = (await Promise.all((await prepareAllMyChannels( - operator, - [TestHelper.fakeChannel({id: 'channel1', team_id: otherTeamId, total_msg_count: 30})], - [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 30})], - false, - )))).flat(); - await operator.batchRecords(models, 'test'); - TestHelper.wait(waitTime); - - expect(subscriptionNext).not.toHaveBeenCalled(); - }); -}); diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index 09e0d221d..b237caea7 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -4,18 +4,20 @@ /* eslint-disable max-lines */ import {Database, Model, Q, Query, Relation} from '@nozbe/watermelondb'; -import {of as of$, Observable} from 'rxjs'; +import {of as of$, Observable, combineLatest} from 'rxjs'; import {map as map$, switchMap, distinctUntilChanged, combineLatestWith} from 'rxjs/operators'; import {General, Permissions} from '@constants'; import {MM_TABLES} from '@constants/database'; import {sanitizeLikeString} from '@helpers/database'; +import {isDefaultChannel} from '@utils/channel'; import {hasPermission} from '@utils/role'; +import {isSystemAdmin} from '@utils/user'; import {prepareDeletePost} from './post'; import {queryRoles} from './role'; -import {observeCurrentChannelId, getCurrentChannelId, observeCurrentUserId} from './system'; -import {observeTeammateNameDisplay} from './user'; +import {observeCurrentChannelId, getCurrentChannelId, observeCurrentUserId, observeConfigBooleanValue} from './system'; +import {observeCurrentUser, observeTeammateNameDisplay} from './user'; import type ServerDataOperator from '@database/operator/server_data_operator'; import type {Clause} from '@nozbe/watermelondb/QueryDescription'; @@ -340,7 +342,7 @@ export const getDefaultChannelForTeam = async (database: Database, teamId: strin Q.sortBy('display_name', Q.asc), ).fetch(); - const defaultChannel = myChannels.find((c) => c.name === General.DEFAULT_CHANNEL); + const defaultChannel = myChannels.find((c) => isDefaultChannel(c)); const myFirstTeamChannel = myChannels[0]; if (defaultChannel || canIJoinPublicChannelsInTeam) { @@ -751,3 +753,12 @@ export const queryChannelMembers = (database: Database, channelId: string) => { export const observeChannelMembers = (database: Database, channelId: string) => { return queryChannelMembers(database, channelId).observe(); }; + +export const observeIsReadOnlyChannel = (database: Database, channelId: string) => { + const channel = observeChannel(database, channelId); + const experimentalTownSquareIsReadOnly = observeConfigBooleanValue(database, 'ExperimentalTownSquareIsReadOnly'); + const user = observeCurrentUser(database); + return combineLatest([channel, user, experimentalTownSquareIsReadOnly]).pipe( + switchMap(([c, u, readOnly]) => of$(isDefaultChannel(c) && !isSystemAdmin(u?.roles || '') && readOnly)), + ); +}; diff --git a/app/queries/servers/role.ts b/app/queries/servers/role.ts index ae1c05d81..4561c1d56 100644 --- a/app/queries/servers/role.ts +++ b/app/queries/servers/role.ts @@ -6,7 +6,7 @@ import {of as of$, combineLatest} from 'rxjs'; import {switchMap, distinctUntilChanged} from 'rxjs/operators'; import {Database as DatabaseConstants, General, Permissions} from '@constants'; -import {isDMorGM} from '@utils/channel'; +import {isDefaultChannel, isDMorGM} from '@utils/channel'; import {hasPermission} from '@utils/role'; import {observeChannel, observeMyChannelRoles} from './channel'; @@ -91,7 +91,7 @@ export function observePermissionForPost(database: Database, post: PostModel, us export function observeCanManageChannelMembers(database: Database, channelId: string, user: UserModel) { return observeChannel(database, channelId).pipe( switchMap((c) => { - if (!c || c.deleteAt !== 0 || isDMorGM(c) || c.name === General.DEFAULT_CHANNEL) { + if (!c || c.deleteAt !== 0 || isDMorGM(c) || isDefaultChannel(c)) { return of$(false); } diff --git a/app/screens/channel/channel_post_list/intro/intro.tsx b/app/screens/channel/channel_post_list/intro/intro.tsx index a628bf91e..24903ee69 100644 --- a/app/screens/channel/channel_post_list/intro/intro.tsx +++ b/app/screens/channel/channel_post_list/intro/intro.tsx @@ -9,6 +9,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useDidUpdate from '@hooks/did_update'; import EphemeralStore from '@store/ephemeral_store'; +import {isDefaultChannel} from '@utils/channel'; import DirectChannel from './direct_channel'; import PublicOrPrivateChannel from './public_or_private_channel'; @@ -42,7 +43,7 @@ const Intro = ({channel, roles}: Props) => { return null; } - if (channel.type === General.OPEN_CHANNEL && channel.name === General.DEFAULT_CHANNEL) { + if (channel.type === General.OPEN_CHANNEL && isDefaultChannel(channel)) { return ( { - const isDefaultChannel = ch?.name === General.DEFAULT_CHANNEL; - return of$(!isDefaultChannel || (isDefaultChannel && u?.isGuest)); + const isDC = isDefaultChannel(ch); + return of$(!isDC || (isDC && u?.isGuest)); }), ); diff --git a/app/screens/channel_info/destructive_options/convert_private/index.ts b/app/screens/channel_info/destructive_options/convert_private/index.ts index a085aad5d..1868a9e7e 100644 --- a/app/screens/channel_info/destructive_options/convert_private/index.ts +++ b/app/screens/channel_info/destructive_options/convert_private/index.ts @@ -5,10 +5,11 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {combineLatestWith, switchMap} from 'rxjs/operators'; -import {General, Permissions} from '@constants'; +import {Permissions} from '@constants'; import {observeChannel} from '@queries/servers/channel'; import {observePermissionForChannel} from '@queries/servers/role'; import {observeCurrentUser} from '@queries/servers/user'; +import {isDefaultChannel} from '@utils/channel'; import ConvertPrivate from './convert_private'; @@ -24,7 +25,7 @@ const enhanced = withObservables(['channelId'], ({channelId, database}: Props) = const canConvert = channel.pipe( combineLatestWith(currentUser), switchMap(([ch, u]) => { - if (!ch || !u || ch.name === General.DEFAULT_CHANNEL) { + if (!ch || !u || isDefaultChannel(ch)) { return of$(false); } diff --git a/app/screens/post_options/index.ts b/app/screens/post_options/index.ts index 4bd993489..f140d18ac 100644 --- a/app/screens/post_options/index.ts +++ b/app/screens/post_options/index.ts @@ -5,22 +5,21 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {combineLatest, of as of$, Observable} from 'rxjs'; import {switchMap} from 'rxjs/operators'; -import {General, Permissions, Post, Screens} from '@constants'; +import {Permissions, Post, Screens} from '@constants'; import {AppBindingLocations} from '@constants/apps'; import {MAX_ALLOWED_REACTIONS} from '@constants/emoji'; import AppsManager from '@managers/apps_manager'; -import {observeChannel} from '@queries/servers/channel'; +import {observeChannel, observeIsReadOnlyChannel} from '@queries/servers/channel'; import {observePost, observePostSaved} from '@queries/servers/post'; import {observeReactionsForPost} from '@queries/servers/reaction'; import {observePermissionForChannel, observePermissionForPost} from '@queries/servers/role'; -import {observeConfigBooleanValue, observeConfigIntValue, observeConfigValue, observeLicense} from '@queries/servers/system'; +import {observeConfigIntValue, observeConfigValue, observeLicense} from '@queries/servers/system'; import {observeIsCRTEnabled, observeThreadById} from '@queries/servers/thread'; import {observeCurrentUser} from '@queries/servers/user'; import {toMilliseconds} from '@utils/datetime'; import {isMinimumServerVersion} from '@utils/helpers'; import {isFromWebhook, isSystemMessage} from '@utils/post'; import {getPostIdsForCombinedUserActivityPost} from '@utils/post_list'; -import {isSystemAdmin} from '@utils/user'; import PostOptions from './post_options'; @@ -92,10 +91,7 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sour return observePermissionForPost(database, post, u, isOwner ? Permissions.DELETE_POST : Permissions.DELETE_OTHERS_POSTS, false); })); - const experimentalTownSquareIsReadOnly = observeConfigBooleanValue(database, 'ExperimentalTownSquareIsReadOnly'); - const channelIsReadOnly = combineLatest([currentUser, channel, experimentalTownSquareIsReadOnly]).pipe(switchMap(([u, c, readOnly]) => { - return of$(c?.name === General.DEFAULT_CHANNEL && (u && !isSystemAdmin(u.roles)) && readOnly); - })); + const channelIsReadOnly = observeIsReadOnlyChannel(database, post.channelId); const isUnderMaxAllowedReactions = observeReactionsForPost(database, post.id).pipe( // eslint-disable-next-line max-nested-callbacks diff --git a/app/screens/user_profile/index.ts b/app/screens/user_profile/index.ts index e070d7d8f..84f968260 100644 --- a/app/screens/user_profile/index.ts +++ b/app/screens/user_profile/index.ts @@ -12,6 +12,7 @@ import {queryDisplayNamePreferences} from '@queries/servers/preference'; import {observeCanManageChannelMembers, observePermissionForChannel} from '@queries/servers/role'; import {observeConfigBooleanValue, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system'; import {observeTeammateNameDisplay, observeCurrentUser, observeUser, observeUserIsChannelAdmin, observeUserIsTeamAdmin} from '@queries/servers/user'; +import {isDefaultChannel} from '@utils/channel'; import {isSystemAdmin} from '@utils/user'; import UserProfile from './user_profile'; @@ -30,8 +31,8 @@ const enhanced = withObservables([], ({channelId, database, userId}: EnhancedPro const user = observeUser(database, userId); const teammateDisplayName = observeTeammateNameDisplay(database); const isChannelAdmin = channelId ? observeUserIsChannelAdmin(database, userId, channelId) : of$(false); - const isDefaultChannel = channel ? channel.pipe( - switchMap((c) => of$(c?.name === General.DEFAULT_CHANNEL)), + const isDC = channel ? channel.pipe( + switchMap((c) => of$(isDefaultChannel(c))), ) : of$(false); const isDirectMessage = channelId ? channel.pipe( switchMap((c) => of$(c?.type === General.DM_CHANNEL)), @@ -60,7 +61,7 @@ const enhanced = withObservables([], ({channelId, database, userId}: EnhancedPro enablePostUsernameOverride, isChannelAdmin, isCustomStatusEnabled, - isDefaultChannel, + isDefaultChannel: isDC, isDirectMessage, isMilitaryTime, isSystemAdmin: systemAdmin, diff --git a/app/utils/channel/index.test.ts b/app/utils/channel/index.test.ts index 87a2cfec8..f17147309 100644 --- a/app/utils/channel/index.test.ts +++ b/app/utils/channel/index.test.ts @@ -6,7 +6,20 @@ import {createIntl} from 'react-intl'; import {Channel, General, Permissions} from '@constants'; import {hasPermission} from '@utils/role'; -import {compareNotifyProps, filterChannelsMatchingTerm, generateChannelNameFromDisplayName, getDirectChannelName, isArchived, isDMorGM, isTypeDMorGM, selectDefaultChannelForTeam, sortChannelsByDisplayName, sortChannelsModelByDisplayName, validateDisplayName} from '.'; +import { + compareNotifyProps, + filterChannelsMatchingTerm, + generateChannelNameFromDisplayName, + getDirectChannelName, + isArchived, + isDefaultChannel, + isDMorGM, + isTypeDMorGM, + selectDefaultChannelForTeam, + sortChannelsByDisplayName, + sortChannelsModelByDisplayName, + validateDisplayName, +} from '.'; import type ChannelModel from '@typings/database/models/servers/channel'; @@ -244,3 +257,18 @@ describe('filterChannelsMatchingTerm', () => { }); }); +describe('isDefaultChannel', () => { + it('should return true if channel is the default channel', () => { + const channel = {name: General.DEFAULT_CHANNEL} as Channel; // using casting instead of TestHelper to avoid circular dependency + expect(isDefaultChannel(channel)).toBe(true); + }); + + it('should return false if channel is not the default channel', () => { + const channel = {name: 'random'} as Channel; // using casting instead of TestHelper to avoid circular dependency + expect(isDefaultChannel(channel)).toBe(false); + }); + + it('should return false if channel is undefined', () => { + expect(isDefaultChannel(undefined)).toBe(false); + }); +}); diff --git a/app/utils/channel/index.ts b/app/utils/channel/index.ts index c896e9ad0..c2b37d230 100644 --- a/app/utils/channel/index.ts +++ b/app/utils/channel/index.ts @@ -45,7 +45,7 @@ export function selectDefaultChannelForTeam(chan if (roles) { canIJoinPublicChannelsInTeam = hasPermission(roles, Permissions.JOIN_PUBLIC_CHANNELS); } - const defaultChannel = channels?.find((c) => c.name === General.DEFAULT_CHANNEL); + const defaultChannel = channels?.find(isDefaultChannel); const membershipIds = new Set(memberships.map((m) => m.channel_id)); const iAmMemberOfTheTeamDefaultChannel = Boolean(defaultChannel && membershipIds.has(defaultChannel.id)); const myFirstTeamChannel = channels?.filter((c) => @@ -158,3 +158,7 @@ export function filterChannelsMatchingTerm(channels: Channel[], term: string): C displayName.startsWith(lowercasedTerm); }); } + +export function isDefaultChannel(channel: Channel | ChannelModel | undefined): boolean { + return channel?.name === General.DEFAULT_CHANNEL; +}