mattermost-mobile-upstream/app/store/ephemeral_store.ts
Pablo Vélez 54731fa6ac
Mm 70299 channel labels display phase 1 (#10113)
* MM-70299 - display channel attributes in header, info and banner

* cover and expand channel attributes feature with UI and E2E tests

* Implement channel attributes lock and update related tests and API methods

* Enhance error logging and improve type definitions for channel attributes

* Handle error from setAccessControlGroupId in fetchAccessControlAttributeFields

* Refactor fetchAccessControlAttributeFields to include system ID handling and improve batch record persistence

* Address code review feedback: extract feature-flag constants, move DB-only helpers to query/local layers, refactor attribute chip styles, fix colour spelling, and add test coverage for value pruning and subtitle component

* Fix error handling in removeStoredFields function and delete obsolete end-to-end test file

* Add tests for removeStoredFields function and enhance property queries

* Fix removeStoredFields to recover group ID from classification anchor and clean all owned fields

* Fix batchRecords silently swallowing write errors in fetchAccessControlAttributeFields

* Remove unused visibility check for ChannelAttributeLabels in e2e tests

* Fix classification fetch cache and channel-attributes E2E lock contention

Do not stamp the classification banner cache when both access-control flags
are off, so turning a flag back on after a reload refetches fields.

Share the classification lock with the channel-attributes Detox suite and
stop unsetting ClassificationMarkings. Skip flag-on tests when the server
cannot enable ChannelAttributes.

Co-authored-by: mattermost-code <matty-code@mattermost.com>

* Scope access_control field prune to owned object types

handlePropertyFields(groupId) deletes every field in the group. The fetch
only returns system and channel fields, so same-group user/session rows
were removed on each refresh. Prune only system and channel fields, matching
removeStoredFields.

Co-authored-by: mattermost-code <matty-code@mattermost.com>

* fail channel attributes tests when feature enablement fails instead of silently reporting them as passed

* Compose all channel attribute values in banners with template resolution, mixed field support, and matching web colors

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: mattermost-code <matty-code@mattermost.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
2026-09-17 21:37:43 +02:00

562 lines
20 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DeviceEventEmitter} from 'react-native';
import {BehaviorSubject} from 'rxjs';
import {Events} from '@constants';
import {CLASSIFICATION_BANNER_CACHE_TTL} from '@constants/classification';
import {getDefaultThemeByAppearance} from '@context/theme';
import {toMilliseconds} from '@utils/datetime';
const TIME_TO_CLEAR_WEBSOCKET_ACTIONS = toMilliseconds({seconds: 30});
const TIME_TO_CLEAR_RECENTLY_UNSAVED_SAVED_POSTS = toMilliseconds({minutes: 2});
class EphemeralStoreSingleton {
private themeSubject = new BehaviorSubject<Theme>(getDefaultThemeByAppearance());
creatingChannel = false;
creatingDMorGMTeammates: string[] = [];
noticeShown = new Set<string>();
private pushProxyVerification: {[serverUrl: string]: string | undefined} = {};
private canJoinOtherTeams: {[serverUrl: string]: BehaviorSubject<boolean>} = {};
private loadingMessagesForChannel: {[serverUrl: string]: Set<string>} = {};
private websocketEditingPost: {[serverUrl: string]: {[id: string]: {post: Post; timeout: NodeJS.Timeout} | undefined} | undefined} = {};
private websocketRemovingPost: {[serverUrl: string]: Set<string> | undefined} = {};
private recentlyUnsavedSavedPosts: {[serverUrl: string]: {[postId: string]: NodeJS.Timeout} | undefined} = {};
private recentlyUnsavedSavedPostsState: {[serverUrl: string]: BehaviorSubject<Set<string>> | undefined} = {};
private recentlyUnsavedSavedPostSubjects: {[serverUrl: string]: {[postId: string]: BehaviorSubject<boolean>} | undefined} = {};
// As of today, the server sends a duplicated event to add the user to the team.
// If we do not handle this, this ends up showing some errors in the database, apart
// of the extra computation time. We use this to track the events that are being handled
// and make sure we only handle one.
private addingTeam = new Set<string>();
private joiningChannels = new Set<string>();
private leavingChannels = new Set<string>();
private archivingChannels = new Set<string>();
private convertingChannels = new Set<string>();
private switchingToChannel = new Set<string>();
private acknowledgingPost = new Set<string>();
private unacknowledgingPost = new Set<string>();
private currentThreadId = '';
private currentChannelOldestVisibleCreateAt: number | undefined = undefined;
private currentFileViewerPostId = '';
private currentPlaybookRunId = '';
private notificationTapped = false;
private enablingCRT = false;
// There are some corner cases where the react context is not loaded (and therefore
// launch will be called) but the notification callbacks are registered. This is used
// so the notification is processed only once (preferably on launch).
private processingNotification = '';
// This is used to track the channels that have their playbooks synced with the server.
// This is used to avoid fetching the playbooks for the same channel multiple times.
// It is cleared any time the connection with the server is lost.
private channelPlaybooksSynced: {[serverUrl: string]: Set<string>} = {};
private managedCategoryPropertyIds: {[serverUrl: string]: {groupId: string; fieldId: string} | undefined} = {};
// Track when the classification banner fields were last fetched per server, so
// the global banner can skip redundant fetches within the TTL. In-memory only
// (cleared on app restart) and cleared per server on logout.
private classificationBannerFetchedAt: {[serverUrl: string]: number | undefined} = {};
// Track classification option ids we've already forced a field refresh for, so an
// option that is genuinely gone server-side does not trigger an infinite re-fetch loop.
private classificationFieldSyncAttempted: {[serverUrl: string]: Set<string>} = {};
// Channels whose attribute values have been fetched from the server, so a
// repeated channel switch does not refetch. Values stay fresh afterwards via
// websocket events; the set is cleared when the connection drops.
private channelAttributeValuesSynced: {[serverUrl: string]: Set<string>} = {};
// Track how many translations are being executed at the same time on the channel.
// We limit this to avoid overwhelming the device.
private runningTranslations = new Set<string>();
addRunningTranslation = (postId: string) => {
this.runningTranslations.add(postId);
};
removeRunningTranslation = (postId: string) => {
this.runningTranslations.delete(postId);
};
totalRunningTranslations = () => {
return this.runningTranslations.size;
};
// Track files that have been rejected by plugins (transient state)
// Maps file ID to rejection reason
private rejectedFiles = new Map<string, string>();
setProcessingNotification = (v: string) => {
this.processingNotification = v;
};
getProcessingNotification = () => this.processingNotification;
addLoadingMessagesForChannel = (serverUrl: string, channelId: string) => {
if (!this.loadingMessagesForChannel[serverUrl]) {
this.loadingMessagesForChannel[serverUrl] = new Set();
}
DeviceEventEmitter.emit(Events.LOADING_CHANNEL_POSTS, {serverUrl, channelId, value: true});
this.loadingMessagesForChannel[serverUrl].add(channelId);
};
stopLoadingMessagesForChannel = (serverUrl: string, channelId: string) => {
DeviceEventEmitter.emit(Events.LOADING_CHANNEL_POSTS, {serverUrl, channelId, value: false});
this.loadingMessagesForChannel[serverUrl]?.delete(channelId);
};
isLoadingMessagesForChannel = (serverUrl: string, channelId: string) => {
return Boolean(this.loadingMessagesForChannel[serverUrl]?.has(channelId));
};
// Ephemeral control for out of order websocket events
addEditingPost = (serverUrl: string, post: Post) => {
if (this.websocketRemovingPost[serverUrl]?.has(post.id)) {
return;
}
const lastEdit = this.websocketEditingPost[serverUrl]?.[post.id];
if (lastEdit && post.edit_at < lastEdit.post.update_at) {
return;
}
if (!this.websocketEditingPost[serverUrl]) {
this.websocketEditingPost[serverUrl] = {};
}
const serverEditing = this.websocketEditingPost[serverUrl]!;
if (lastEdit?.timeout) {
clearTimeout(lastEdit.timeout);
}
const timeout = setTimeout(() => {
delete serverEditing[post.id];
}, TIME_TO_CLEAR_WEBSOCKET_ACTIONS);
serverEditing[post.id] = {post, timeout};
};
addRemovingPost = (serverUrl: string, postId: string) => {
if (this.websocketRemovingPost[serverUrl]?.has(postId)) {
return;
}
if (this.websocketEditingPost[serverUrl]?.[postId]) {
clearTimeout(this.websocketEditingPost[serverUrl]![postId]!.timeout);
delete this.websocketEditingPost[serverUrl]![postId];
}
if (!this.websocketRemovingPost[serverUrl]) {
this.websocketRemovingPost[serverUrl] = new Set();
}
setTimeout(() => {
this.websocketRemovingPost[serverUrl]?.delete(postId);
}, TIME_TO_CLEAR_WEBSOCKET_ACTIONS);
this.websocketRemovingPost[serverUrl]?.add(postId);
};
getLastPostWebsocketEvent = (serverUrl: string, postId: string) => {
if (this.websocketRemovingPost[serverUrl]?.has(postId)) {
return {deleted: true, post: undefined};
}
if (this.websocketEditingPost[serverUrl]?.[postId]) {
return {deleted: false, post: this.websocketEditingPost[serverUrl]![postId]!.post};
}
return undefined;
};
getRecentlyUnsavedSavedPostSubject = (serverUrl: string, postId: string) => {
if (!this.recentlyUnsavedSavedPostSubjects[serverUrl]) {
this.recentlyUnsavedSavedPostSubjects[serverUrl] = {};
}
const serverSubjects = this.recentlyUnsavedSavedPostSubjects[serverUrl]!;
if (!serverSubjects[postId]) {
serverSubjects[postId] = new BehaviorSubject(false);
}
return serverSubjects[postId]!;
};
observeRecentlyUnsavedSavedPost = (serverUrl: string, postId: string) => {
return this.getRecentlyUnsavedSavedPostSubject(serverUrl, postId).asObservable();
};
getRecentlyUnsavedSavedPostsSubject = (serverUrl: string) => {
if (!this.recentlyUnsavedSavedPostsState[serverUrl]) {
this.recentlyUnsavedSavedPostsState[serverUrl] = new BehaviorSubject(new Set());
}
return this.recentlyUnsavedSavedPostsState[serverUrl]!;
};
observeRecentlyUnsavedSavedPosts = (serverUrl: string) => {
return this.getRecentlyUnsavedSavedPostsSubject(serverUrl).asObservable();
};
addRecentlyUnsavedSavedPost = (serverUrl: string, postId: string) => {
if (!this.recentlyUnsavedSavedPosts[serverUrl]) {
this.recentlyUnsavedSavedPosts[serverUrl] = {};
}
const serverPosts = this.recentlyUnsavedSavedPosts[serverUrl]!;
const subject = this.getRecentlyUnsavedSavedPostSubject(serverUrl, postId);
const stateSubject = this.getRecentlyUnsavedSavedPostsSubject(serverUrl);
const existingTimeout = serverPosts[postId];
if (existingTimeout) {
clearTimeout(existingTimeout);
}
subject.next(true);
stateSubject.next(new Set([...stateSubject.value, postId]));
serverPosts[postId] = setTimeout(() => {
subject.next(false);
const nextState = new Set(stateSubject.value);
nextState.delete(postId);
stateSubject.next(nextState);
this.removeRecentlyUnsavedSavedPostSubject(serverUrl, postId);
delete serverPosts[postId];
if (!Object.keys(serverPosts).length) {
delete this.recentlyUnsavedSavedPosts[serverUrl];
}
}, TIME_TO_CLEAR_RECENTLY_UNSAVED_SAVED_POSTS);
};
private removeRecentlyUnsavedSavedPostSubject = (serverUrl: string, postId: string) => {
const serverSubjects = this.recentlyUnsavedSavedPostSubjects[serverUrl];
if (!serverSubjects?.[postId]) {
return;
}
serverSubjects[postId]!.complete();
delete serverSubjects[postId];
if (!Object.keys(serverSubjects).length) {
delete this.recentlyUnsavedSavedPostSubjects[serverUrl];
}
};
clearRecentlyUnsavedSavedPost = (serverUrl: string, postId: string) => {
this.recentlyUnsavedSavedPostSubjects[serverUrl]?.[postId]?.next(false);
const stateSubject = this.recentlyUnsavedSavedPostsState[serverUrl];
if (stateSubject?.value.has(postId)) {
const nextState = new Set(stateSubject.value);
nextState.delete(postId);
stateSubject.next(nextState);
}
const timeout = this.recentlyUnsavedSavedPosts[serverUrl]?.[postId];
if (timeout) {
clearTimeout(timeout);
delete this.recentlyUnsavedSavedPosts[serverUrl]![postId];
if (!Object.keys(this.recentlyUnsavedSavedPosts[serverUrl]!).length) {
delete this.recentlyUnsavedSavedPosts[serverUrl];
}
}
this.removeRecentlyUnsavedSavedPostSubject(serverUrl, postId);
};
isRecentlyUnsavedSavedPost = (serverUrl: string, postId: string) => {
return Boolean(this.recentlyUnsavedSavedPosts[serverUrl]?.[postId]);
};
// Ephemeral control when (un)archiving a channel locally
addArchivingChannel = (channelId: string) => {
this.archivingChannels.add(channelId);
};
isArchivingChannel = (channelId: string) => {
return this.archivingChannels.has(channelId);
};
removeArchivingChannel = (channelId: string) => {
this.archivingChannels.delete(channelId);
};
// Ephemeral control when converting a channel to private locally
addConvertingChannel = (channelId: string) => {
this.convertingChannels.add(channelId);
};
isConvertingChannel = (channelId: string) => {
return this.convertingChannels.has(channelId);
};
removeConvertingChannel = (channelId: string) => {
this.convertingChannels.delete(channelId);
};
// Ephemeral control when leaving a channel locally
addLeavingChannel = (channelId: string) => {
this.leavingChannels.add(channelId);
};
isLeavingChannel = (channelId: string) => {
return this.leavingChannels.has(channelId);
};
removeLeavingChannel = (channelId: string) => {
this.leavingChannels.delete(channelId);
};
// Ephemeral control when joining a channel locally
addJoiningChannel = (channelId: string) => {
this.joiningChannels.add(channelId);
};
isJoiningChannel = (channelId: string) => {
return this.joiningChannels.has(channelId);
};
removeJoiningChannel = (channelId: string) => {
this.joiningChannels.delete(channelId);
};
// Ephemeral control when adding a team locally
startAddingToTeam = (teamId: string) => {
this.addingTeam.add(teamId);
};
finishAddingToTeam = (teamId: string) => {
this.addingTeam.delete(teamId);
};
isAddingToTeam = (teamId: string) => {
return this.addingTeam.has(teamId);
};
// Ephemeral for push proxy state
setPushProxyVerificationState = (serverUrl: string, state: string) => {
this.pushProxyVerification[serverUrl] = state;
};
getPushProxyVerificationState = (serverUrl: string) => {
return this.pushProxyVerification[serverUrl];
};
// Ephemeral for the last viewed thread
getCurrentThreadId = () => {
return this.currentThreadId;
};
setCurrentThreadId = (id: string) => {
this.currentThreadId = id;
};
getCurrentChannelOldestVisibleCreateAt = () => this.currentChannelOldestVisibleCreateAt;
setCurrentChannelOldestVisibleCreateAt = (value: number | undefined) => {
this.currentChannelOldestVisibleCreateAt = value;
};
// Protects the backing post from eviction while a file viewer is open.
getCurrentFileViewerPostId = () => this.currentFileViewerPostId;
setCurrentFileViewerPostId = (id: string) => {
this.currentFileViewerPostId = id;
};
clearCurrentFileViewerPostId = () => {
this.currentFileViewerPostId = '';
};
// Protects the viewed playbook run from rolling cache eviction.
getCurrentPlaybookRunId = () => this.currentPlaybookRunId;
setCurrentPlaybookRunId = (id: string) => {
this.currentPlaybookRunId = id;
};
// Ephemeral control when (un)archiving a channel locally
addSwitchingToChannel = (channelId: string) => {
this.switchingToChannel.add(channelId);
};
isSwitchingToChannel = (channelId: string) => {
return this.switchingToChannel.has(channelId);
};
removeSwitchingToChannel = (channelId: string) => {
this.switchingToChannel.delete(channelId);
};
setEnablingCRT = (value: boolean) => {
this.enablingCRT = value;
};
isEnablingCRT = () => {
return this.enablingCRT;
};
private getCanJoinOtherTeamsSubject = (serverUrl: string) => {
if (!this.canJoinOtherTeams[serverUrl]) {
this.canJoinOtherTeams[serverUrl] = new BehaviorSubject(false);
}
return this.canJoinOtherTeams[serverUrl];
};
observeCanJoinOtherTeams = (serverUrl: string) => {
return this.getCanJoinOtherTeamsSubject(serverUrl).asObservable();
};
setCanJoinOtherTeams = (serverUrl: string, value: boolean) => {
this.getCanJoinOtherTeamsSubject(serverUrl).next(value);
};
setNotificationTapped = (value: boolean) => {
this.notificationTapped = value;
};
wasNotificationTapped = () => {
return this.notificationTapped;
};
setAcknowledgingPost = (postId: string) => {
this.acknowledgingPost.add(postId);
};
unsetAcknowledgingPost = (postId: string) => {
this.acknowledgingPost.delete(postId);
};
isAcknowledgingPost = (postId: string) => {
return this.acknowledgingPost.has(postId);
};
setUnacknowledgingPost = (postId: string) => {
this.unacknowledgingPost.add(postId);
};
unsetUnacknowledgingPost = (postId: string) => {
this.unacknowledgingPost.delete(postId);
};
isUnacknowledgingPost = (postId: string) => {
return this.unacknowledgingPost.has(postId);
};
getChannelPlaybooksSynced = (serverUrl: string, channelId: string) => {
return this.channelPlaybooksSynced[serverUrl]?.has(channelId) ?? false;
};
setChannelPlaybooksSynced = (serverUrl: string, channelId: string) => {
if (!this.channelPlaybooksSynced[serverUrl]) {
this.channelPlaybooksSynced[serverUrl] = new Set();
}
this.channelPlaybooksSynced[serverUrl]?.add(channelId);
};
unsetChannelPlaybooksSynced = (serverUrl: string, channelId: string) => {
this.channelPlaybooksSynced[serverUrl]?.delete(channelId);
};
clearChannelPlaybooksSynced = (serverUrl: string) => {
delete this.channelPlaybooksSynced[serverUrl];
};
observeTheme = () => {
return this.themeSubject.asObservable();
};
setTheme = (theme: Theme) => {
this.themeSubject.next(theme);
};
getTheme = () => {
return this.themeSubject.value;
};
getManagedCategoryPropertyIds = (serverUrl: string) => {
return this.managedCategoryPropertyIds[serverUrl];
};
setManagedCategoryPropertyIds = (serverUrl: string, ids: {groupId: string; fieldId: string}) => {
this.managedCategoryPropertyIds[serverUrl] = ids;
};
clearManagedCategoryPropertyIds = (serverUrl: string) => {
delete this.managedCategoryPropertyIds[serverUrl];
};
// Ephemeral cache for the classification banner fields fetch
shouldFetchClassificationBanner = (serverUrl: string) => {
const ts = this.classificationBannerFetchedAt[serverUrl];
if (ts === undefined) {
return true;
}
return Date.now() - ts >= CLASSIFICATION_BANNER_CACHE_TTL;
};
setClassificationBannerFetched = (serverUrl: string) => {
this.classificationBannerFetchedAt[serverUrl] = Date.now();
};
getClassificationFieldSyncAttempted = (serverUrl: string, optionId: string) => {
return this.classificationFieldSyncAttempted[serverUrl]?.has(optionId) ?? false;
};
setClassificationFieldSyncAttempted = (serverUrl: string, optionId: string) => {
if (!this.classificationFieldSyncAttempted[serverUrl]) {
this.classificationFieldSyncAttempted[serverUrl] = new Set();
}
this.classificationFieldSyncAttempted[serverUrl]?.add(optionId);
};
clearClassificationCache = (serverUrl: string) => {
delete this.classificationBannerFetchedAt[serverUrl];
delete this.classificationFieldSyncAttempted[serverUrl];
};
getChannelAttributeValuesSynced = (serverUrl: string, channelId: string) => {
return this.channelAttributeValuesSynced[serverUrl]?.has(channelId) ?? false;
};
setChannelAttributeValuesSynced = (serverUrl: string, channelId: string) => {
if (!this.channelAttributeValuesSynced[serverUrl]) {
this.channelAttributeValuesSynced[serverUrl] = new Set();
}
this.channelAttributeValuesSynced[serverUrl]?.add(channelId);
};
unsetChannelAttributeValuesSynced = (serverUrl: string, channelId: string) => {
this.channelAttributeValuesSynced[serverUrl]?.delete(channelId);
};
clearChannelAttributeValuesSynced = (serverUrl: string) => {
delete this.channelAttributeValuesSynced[serverUrl];
};
// Ephemeral control for rejected files
addRejectedFile = (fileId: string, rejectionReason?: string) => {
this.rejectedFiles.set(fileId, rejectionReason || '');
// Emit event so components can re-render with the updated rejection status
DeviceEventEmitter.emit(Events.FILE_REJECTED, {fileId});
};
isFileRejected = (fileId: string) => {
return this.rejectedFiles.has(fileId);
};
getRejectionReason = (fileId: string) => {
return this.rejectedFiles.get(fileId);
};
clearRejectedFiles = () => {
this.rejectedFiles.clear();
};
}
const EphemeralStore = new EphemeralStoreSingleton();
export default EphemeralStore;