mattermost-mobile-upstream/app/actions/remote/entry/common.ts
Harshil Sharma 4e2a508774
Some checks are pending
ci / lint-typecheck (push) Waiting to run
ci / detox-lint-typecheck (push) Waiting to run
ci / test (push) Waiting to run
Access channel action (#10223)
* MM-68219 - Add redacted files placeholder and related functionality

* add feature flag control and e2e tests

* adjust placeholder design, polish code, fix permalink preview issue

* polish code and add missing test

* use import path with right path

* gate post refetch behind the feature flag, refetch the open thread and avoid observeCount

* restore filesInfo for the permalink files prop added upstream

* fix stale redaction on background servers, consume the flag on every fetch path and share the viewport emitter

* replace the in-memory redaction flag with a persisted per-post epoch so ABAC file decisions survive restarts and stale posts stay hidden

* raise the redaction epoch on every resync, capture the epoch counter, only invalidate channels on role changes, and store the newer epoch when a re-verified post is otherwise unchanged

* store superseded responses unverified instead of dropping them, keep the since-fetch when re-verifying, verify permalink embeds against the linked channel, and capture the websocket epoch on arrival

* nvalidate file decisions when a user attribute field changes or all its values are cleared

* batch channel invalidations, close file viewers only when affected, reset the epoch on unstamped redaction changes, and only  re-verify the newest page's attachment posts

* re-check older channel posts a page at a time instead of one request per post

* skip the unstamped epoch lookup for posts with no files, redacted count or permalink

* MM-70905 - disable and grey out the attachment button when an ABAC permission policy denies uploads in the channel

* First draft of updated read permission handling

* keep the attribute-view retry after later invalidations, retry linked posts unless refused for the current epoch, and stop older payloads from rolling back edits via the redaction check

* improved Data fetches

* fixes

* Fixed test

* Fixed archived channel issue

* Removed re-sync on team switch

* Define message enhancement

* Coderabbit fixes

* React to permission_policy_updated only for the actions it touched

The server now lists the rule actions a permission policy change touched.
Every connected client gets this event, so each step runs only when an
action it depends on changed:

- download_file_attachment raises the redaction epoch, which also
  revalidates the render decisions
- any other cached render-decision action only expires the decisions
- channel_read_access reconciles the channel list

An empty list (an auto-add toggle) does nothing, and a server that
predates the list is treated as having changed every action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FvkCrND4n9oyehQUa9V5QH

* Handled empty channel list

* Fixed channel access races and tablet kick fallback

Read stored channel IDs before the team channel list request so channels joined or left mid-request are not wrongly purged or restored, and set the fallback channel instead of the kicked one as current on a tablet when the server is not active.

* Minor fix

* Moved pending channel access checks to EphemeralStore

* Moved channel access revoked sheet helpers to navigation utils

* Gated channel access enforcement on the ChannelPermissionActions feature flag

* Reported a channel lost to read access on reconnect as revoked access

* Addressed review comments on channel read access

Jittered the channel list reconcile after a permission policy update, logged
non-request failures in channel access checks as errors, and set the channel
read access version to 12.2.0.

* Implemented write actions (#10224)

* Implemented write actions

* Lint fix

* Test that write and management policy changes only revalidate decisions

A permission_policy_updated event naming channel_write_access or
channel_management_access expires the cached render decisions those gates
read, without raising the redaction epoch or reconciling the channel list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FvkCrND4n9oyehQUa9V5QH

* Shared the channel access enforcement check

* Addressed review comments on channel write access

Hid pinning and the acknowledge button while a write access policy denies
the channel, as the server refuses both, and moved the read-only notice to
app/components since two screens share it.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Expect no Manage button on archived channel members

The members screen now renders the Manage header button only when the
user can manage and remove members, which is never the case for an
archived channel. MM-T1719_1 still expected the button to render and
failed on detox-ios and detox-android with "Got: was null". Assert that
neither the Manage button nor the remove controls exist instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Fj5x2Znwhwx94X9g76Hny

---------

Co-authored-by: Pablo Andrés Vélez Vidal <pablovv2012@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-11-05 17:56:33 +05:30

435 lines
20 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {nativeApplicationVersion} from 'expo-application';
import {RESULTS, checkNotifications} from 'react-native-permissions';
import {deletePostsForChannel, deletePostsForChannelsWithAutotranslation} from '@actions/local/channel';
import {fetchChannelById, fetchMyChannelsForTeam, handleKickFromChannel, type MyChannelsRequest} from '@actions/remote/channel';
import {type MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
import {fetchRoles} from '@actions/remote/role';
import {fetchConfigAndLicense, fetchDataRetentionPolicy} from '@actions/remote/systems';
import {fetchMyTeams, handleKickFromTeam, type MyTeamsRequest} from '@actions/remote/team';
import {fetchMe, type MyUserRequest} from '@actions/remote/user';
import {Events, General, Preferences, Screens, ServerErrors} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import {PUSH_PROXY_RESPONSE_NOT_AVAILABLE, PUSH_PROXY_RESPONSE_UNKNOWN, PUSH_PROXY_STATUS_NOT_AVAILABLE, PUSH_PROXY_STATUS_UNKNOWN, PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy';
import DatabaseManager from '@database/manager';
import {getPreferenceValue} from '@helpers/api/preference';
import {selectDefaultTeam} from '@helpers/api/team';
import {DEFAULT_LOCALE} from '@i18n';
import NetworkManager from '@managers/network_manager';
import {getDeviceToken, getVoIPDeviceToken} from '@queries/app/global';
import {getChannelById, queryChannelsById, queryMyChannelsByChannelIds} from '@queries/servers/channel';
import {prepareEntryModels, truncateCrtRelatedTables} from '@queries/servers/entry';
import {getHasCRTChanged} from '@queries/servers/preference';
import {isChannelAccessEnforced} from '@queries/servers/render_permissions';
import {getCurrentChannelId, getCurrentTeamId, getIsDataRetentionEnabled, getPushVerificationStatus, getLastFullSync, setCurrentTeamAndChannelId, getConfigValue} from '@queries/servers/system';
import {getTeamChannelHistory} from '@queries/servers/team';
import {getCurrentUser} from '@queries/servers/user';
import {NavigationStore} from '@store/navigation_store';
import {isDefaultChannel, isDMorGM, sortChannelsByDisplayName} from '@utils/channel';
import {getFullErrorMessage, getServerError} from '@utils/errors';
import {isMinimumServerVersion, isTablet} from '@utils/helpers';
import {logDebug, logError} from '@utils/log';
import {processIsCRTEnabled} from '@utils/thread';
import type {Database, Model} from '@nozbe/watermelondb';
export type AppEntryData = {
initialTeamId: string;
teamData: MyTeamsRequest;
chData?: MyChannelsRequest;
prefData: MyPreferencesRequest;
meData: MyUserRequest;
removeTeamIds?: string[];
removeChannelIds?: string[];
isCRTEnabled: boolean;
initialChannelId?: string;
gmConverted: boolean;
}
export type AppEntryError = {
error: unknown;
}
export type EntryResponse = {
models: Model[];
initialTeamId: string;
initialChannelId: string;
prefData: MyPreferencesRequest;
teamData: MyTeamsRequest;
chData?: MyChannelsRequest;
meData?: MyUserRequest;
gmConverted: boolean;
} | {
error: unknown;
}
export const entry = async (serverUrl: string, teamId?: string, channelId?: string, since = 0, groupLabel?: RequestGroupLabel): Promise<EntryResponse> => {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const result = await entryRest(serverUrl, teamId, channelId, since, groupLabel);
// Fetch data retention policies
if (!result.error) {
const isDataRetentionEnabled = await getIsDataRetentionEnabled(database);
if (isDataRetentionEnabled) {
fetchDataRetentionPolicy(serverUrl, false, groupLabel);
}
}
return result;
};
const entryRest = async (serverUrl: string, teamId?: string, channelId?: string, since = 0, groupLabel?: RequestGroupLabel) => {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
let lastDisconnectedAt = since || await getLastFullSync(database);
const [confResp, prefData] = await Promise.all([
fetchConfigAndLicense(serverUrl, false, groupLabel),
fetchMyPreferences(serverUrl, true, groupLabel),
]);
const isCRTEnabled = Boolean(prefData.preferences && processIsCRTEnabled(prefData.preferences, confResp.config?.CollapsedThreads, confResp.config?.FeatureFlagCollapsedThreads, confResp.config?.Version));
if (prefData.preferences) {
const crtToggled = await getHasCRTChanged(database, prefData.preferences);
if (crtToggled) {
const currentServerUrl = await DatabaseManager.getActiveServerUrl();
const isSameServer = currentServerUrl === serverUrl;
if (isSameServer) {
lastDisconnectedAt = 0;
}
const {error} = await truncateCrtRelatedTables(serverUrl);
if (error) {
throw new Error(`Resetting CRT on ${serverUrl} failed`);
}
}
}
// let's start fetching in parallel all we can
const promises: [Promise<MyTeamsRequest>, Promise<MyUserRequest>] = [
fetchMyTeams(serverUrl, true, groupLabel),
fetchMe(serverUrl, true, groupLabel),
];
const [teamData, meData] = await Promise.all(promises);
const error = confResp.error || prefData.error || teamData.error || meData.error;
if (error) {
return {error};
}
let initialTeamId = teamId || '';
let initialChannelId = channelId || '';
let gmConverted = false;
if (channelId) {
const existingChannel = await getChannelById(database, channelId);
if (existingChannel && existingChannel.type === General.GM_CHANNEL) {
// Okay, so now we know the channel exists in mobile app's database as a GM.
// We now need to also check if channel on server is actually a private channel,
// and if so, which team does it belong to now. That team will become the
// active team on mobile app after this point.
const fetchResult = await fetchChannelById(serverUrl, channelId);
// Although you can convert GM only to a private channel, a private channel can further be converted to a public channel.
// So between the mobile app being on the GM and reconnecting,
// it may have become either a public or a private channel. So we need to check for both.
if (fetchResult.channel?.type === General.PRIVATE_CHANNEL || fetchResult.channel?.type === General.OPEN_CHANNEL) {
initialTeamId = fetchResult.channel.team_id;
initialChannelId = channelId;
gmConverted = true;
}
}
}
if (!teamData.error && teamData.teams?.length === 0) {
initialTeamId = '';
}
const inTeam = teamData.teams?.find((t) => t.id === initialTeamId);
if (initialTeamId && !inTeam && !teamData.error) {
initialTeamId = '';
}
if (!initialTeamId && teamData.teams?.length && teamData.memberships?.length) {
// If no initial team was set in the database but got teams in the response
const teamOrderPreference = getPreferenceValue<string>(prefData.preferences || [], Preferences.CATEGORIES.TEAMS_ORDER, '', '');
const teamMembers = new Set(teamData.memberships.filter((m) => m.delete_at === 0).map((m) => m.team_id));
const myTeams = teamData.teams.filter((t) => teamMembers.has(t.id));
const defaultTeam = selectDefaultTeam(myTeams, meData.user?.locale || DEFAULT_LOCALE, teamOrderPreference, confResp.config?.ExperimentalPrimaryTeam);
if (defaultTeam?.id) {
initialTeamId = defaultTeam.id;
}
}
let chData: MyChannelsRequest = {
channels: [],
memberships: [],
categories: [],
};
if (initialTeamId) {
chData = await fetchMyChannelsForTeam(serverUrl, initialTeamId, false, lastDisconnectedAt, true, false, isCRTEnabled, groupLabel);
}
initialChannelId = await entryInitialChannelId(database, initialChannelId, teamId, initialTeamId, meData?.user?.locale || '', chData?.channels, chData?.memberships);
const initialTeamData = initialTeamId ? {
teams: teamData.teams?.filter((t) => t.id === initialTeamId),
memberships: teamData.memberships?.filter((m) => m.team_id === initialTeamId),
} : teamData;
const dt = Date.now();
await handleAutotranslationChanges(serverUrl, meData, chData);
const rolesRequest = fetchRoles(serverUrl, teamData.memberships, chData?.memberships, meData?.user, false, false, groupLabel);
const modelPromises = await prepareEntryModels({operator, teamData: initialTeamData, chData, prefData, meData, isCRTEnabled});
const models = (await Promise.all(modelPromises)).flat();
const {error: rolesError} = await rolesRequest;
if (rolesError) {
logDebug('entryRest: failed to fetch roles', groupLabel, getFullErrorMessage(rolesError));
}
logDebug('Process models on entry', groupLabel, models.length, `${Date.now() - dt}ms`);
return {models, initialChannelId, initialTeamId, prefData, teamData, chData, meData, gmConverted};
} catch (error) {
logError('entryRest', groupLabel, error);
return {error};
}
};
export async function handleAutotranslationChanges(serverUrl: string, meData: MyUserRequest | undefined, chData: MyChannelsRequest | undefined) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
// Config changes already handled in storeConfigAndLicense
const enableAutoTranslation = (await getConfigValue(operator.database, 'EnableAutoTranslation')) === 'true';
if (!enableAutoTranslation) {
return;
}
// If user locale changed, delete all the posts for all channels with autotranslation enabled
// so the posts can be refetch with the new translation
if (meData) {
const currentUser = await getCurrentUser(database);
if (currentUser && meData.user?.locale !== currentUser.locale) {
await deletePostsForChannelsWithAutotranslation(serverUrl);
return;
}
}
if (chData) {
// If a channel stop being autotranslated by the admin, delete the posts for that channel
// so the posts can be refetch without the translation
const newChannels = chData.channels || [];
const channels = await queryChannelsById(database, newChannels.map((c) => c.id)).fetch();
const chMap = new Map(channels.map((c) => [c.id, c]));
const promises = [];
for (const ch of newChannels) {
const channel = chMap.get(ch.id);
if (channel && channel.autotranslation && !ch.autotranslation) { // Autotranslation disabled
promises.push(deletePostsForChannel(serverUrl, ch.id, true));
}
}
const chModels = (await Promise.all(promises)).map((m) => m.models).flat();
if (chModels.length) {
await operator.batchRecords(chModels, 'handleAutotranslationChanges');
}
// If a channel starts or stops being autotranslated by the user, delete the posts for that channel
// so the posts can be refetch with or without the translation
const newMemberships = chData.memberships || [];
const memberships = await queryMyChannelsByChannelIds(database, newMemberships.map((m) => m.channel_id)).fetch();
const membershipMap = new Map(memberships.map((m) => [m.id, m]));
for (const m of newMemberships) {
const membership = membershipMap.get(m.channel_id);
if (membership && membership.autotranslationDisabled !== Boolean(m.autotranslation_disabled)) { // Autotranslation modified
promises.push(deletePostsForChannel(serverUrl, m.channel_id, true));
}
}
const membershipModels = (await Promise.all(promises)).map((m) => m.models).flat();
if (membershipModels.length) {
await operator.batchRecords(membershipModels, 'handleAutotranslationChanges');
}
}
} catch (error) {
logError('handleAutotranslationChanges', getFullErrorMessage(error));
}
}
export async function entryInitialChannelId(database: Database, requestedChannelId = '', requestedTeamId = '', initialTeamId: string, locale: string, channels?: Channel[], memberships?: ChannelMember[]) {
const membershipIds = new Set(memberships?.map((m) => m.channel_id));
const requestedChannel = channels?.find((c) => (c.id === requestedChannelId) && membershipIds.has(c.id));
// If team and channel are the requested, return the channel
if (initialTeamId === requestedTeamId && requestedChannel) {
return requestedChannelId;
}
// DM or GMs don't care about changes in teams, so return directly
if (requestedChannel && isDMorGM(requestedChannel)) {
return requestedChannelId;
}
// Check if we are still members of any channel on the history
const teamChannelHistory = await getTeamChannelHistory(database, initialTeamId);
for (const c of teamChannelHistory) {
if (membershipIds.has(c) || c === Screens.GLOBAL_THREADS || c === Screens.GLOBAL_DRAFTS) {
return c;
}
}
// Check if we are member of the default channel.
const defaultChannel = channels?.find((c) => isDefaultChannel(c) && c.team_id === initialTeamId);
const iAmMemberOfTheTeamDefaultChannel = Boolean(defaultChannel && membershipIds.has(defaultChannel.id));
if (iAmMemberOfTheTeamDefaultChannel) {
return defaultChannel!.id;
}
// Get the first channel of the list, based on the locale.
const myFirstTeamChannel = channels?.filter((c) =>
c.team_id === requestedTeamId &&
c.type === General.OPEN_CHANNEL &&
membershipIds.has(c.id),
).sort(sortChannelsByDisplayName.bind(null, locale))[0];
return myFirstTeamChannel?.id || '';
}
export const setExtraSessionProps = async (serverUrl: string, groupLabel?: RequestGroupLabel) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const serverVersion = await getConfigValue(database, 'Version');
const deviceToken = await getDeviceToken();
const voipDeviceToken = await getVoIPDeviceToken();
// For new servers, we want to send all the information.
// For old servers, we only want to send the information when there
// is a device token. Sending the rest of the information should not
// create any issue.
if (isMinimumServerVersion(serverVersion, 10, 1, 0) || deviceToken) {
const res = await checkNotifications();
const granted = res.status === RESULTS.GRANTED || res.status === RESULTS.LIMITED;
const client = NetworkManager.getClient(serverUrl);
client.setExtraSessionProps(deviceToken, !granted, nativeApplicationVersion, voipDeviceToken || undefined, groupLabel);
}
return {};
} catch (error) {
logDebug('error on setExtraSessionProps', getFullErrorMessage(error));
return {error};
}
};
export async function verifyPushProxy(serverUrl: string, groupLabel?: RequestGroupLabel) {
const deviceId = await getDeviceToken();
if (!deviceId) {
return;
}
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const ppVerification = await getPushVerificationStatus(database);
if (
ppVerification !== PUSH_PROXY_STATUS_UNKNOWN &&
ppVerification !== ''
) {
return;
}
const client = NetworkManager.getClient(serverUrl);
const response = await client.ping(deviceId, undefined, groupLabel);
const canReceiveNotifications = response?.data?.CanReceiveNotifications;
switch (canReceiveNotifications) {
case PUSH_PROXY_RESPONSE_NOT_AVAILABLE:
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.PUSH_VERIFICATION_STATUS, value: PUSH_PROXY_STATUS_NOT_AVAILABLE}], prepareRecordsOnly: false});
return;
case PUSH_PROXY_RESPONSE_UNKNOWN:
return;
default:
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.PUSH_VERIFICATION_STATUS, value: PUSH_PROXY_STATUS_VERIFIED}], prepareRecordsOnly: false});
}
} catch (error) {
logDebug('error on verifyPushProxy', getFullErrorMessage(error));
// Do nothing
}
}
// A sync leaves out a channel the user can no longer read just as it leaves out one they left, so the
// server is asked why the open channel is gone: a read denial is then reported as such, not as a removal.
async function kickFromMissingChannel(serverUrl: string, database: Database, channelId: string) {
let event: string | undefined;
const channel = await getChannelById(database, channelId);
if (channel && !isDMorGM(channel) && await isChannelAccessEnforced(database)) {
const {error} = await fetchChannelById(serverUrl, channelId);
if (getServerError(error) === ServerErrors.CHANNEL_ACCESS_DENIED) {
event = Events.CHANNEL_ACCESS_REVOKED;
}
}
await handleKickFromChannel(serverUrl, channelId, event);
}
export async function handleEntryAfterLoadNavigation(
serverUrl: string,
teamMembers: TeamMembership[],
channelMembers: ChannelMember[],
currentTeamId: string,
currentChannelId: string,
initialTeamId: string,
initialChannelId: string,
gmConverted: boolean,
) {
try {
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const currentTeamIdAfterLoad = await getCurrentTeamId(database);
const currentChannelIdAfterLoad = await getCurrentChannelId(database);
const mountedScreens = NavigationStore.getScreensInStack();
const isChannelScreenMounted = mountedScreens.includes(Screens.CHANNEL);
const isThreadsMounted = mountedScreens.includes(Screens.THREAD);
const tabletDevice = isTablet();
if (!currentTeamIdAfterLoad) {
// First load or no team
if (tabletDevice) {
await setCurrentTeamAndChannelId(operator, initialTeamId, initialChannelId);
} else {
await setCurrentTeamAndChannelId(operator, initialTeamId, '');
}
} else if (currentTeamIdAfterLoad !== currentTeamId) {
// Switched teams while loading
if (!teamMembers.find((t) => t.team_id === currentTeamIdAfterLoad && t.delete_at === 0)) {
await handleKickFromTeam(serverUrl, currentTeamIdAfterLoad);
}
} else if (currentTeamIdAfterLoad !== initialTeamId) {
if (gmConverted) {
await setCurrentTeamAndChannelId(operator, initialTeamId, currentChannelId);
} else {
await handleKickFromTeam(serverUrl, currentTeamIdAfterLoad);
}
} else if (currentChannelIdAfterLoad !== currentChannelId) {
// Switched channels while loading
if (!channelMembers.find((m) => m.channel_id === currentChannelIdAfterLoad)) {
if (tabletDevice || isChannelScreenMounted || isThreadsMounted) {
await kickFromMissingChannel(serverUrl, database, currentChannelIdAfterLoad);
} else {
await setCurrentTeamAndChannelId(operator, initialTeamId, initialChannelId);
}
}
} else if (currentChannelIdAfterLoad && currentChannelIdAfterLoad !== initialChannelId) {
if (tabletDevice || isChannelScreenMounted || isThreadsMounted) {
await kickFromMissingChannel(serverUrl, database, currentChannelIdAfterLoad);
} else {
await setCurrentTeamAndChannelId(operator, initialTeamId, initialChannelId);
}
}
} catch (error) {
logDebug('could not manage the entry after load navigation', error);
}
}