mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-05 18:38:54 +00:00
* MM-70813 - support multi value attribute chips * add missing tests * adjust e2e test * update overflowSheet testID to match BottomSheetContent rendering * resolve attribute placeholders in the native channel banner fallback * channel info listing, overflow sheet, chip recolour, banner resubscriptions * show every header attribute and value in the +N overflow sheet * single banner pipeline, channel info memo and dedupe, utils placement, sheet row styles, e2e flag writes * honor channel banner toggle and drop classification color once its token is removed from the banner --------- Co-authored-by: Mattermost Build <build@mattermost.com>
217 lines
8.6 KiB
TypeScript
217 lines
8.6 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {Q, type Database} from '@nozbe/watermelondb';
|
|
import {combineLatest, of as of$, type Observable} from 'rxjs';
|
|
import {distinctUntilChanged, map, switchMap} from 'rxjs/operators';
|
|
|
|
import {License} from '@constants';
|
|
import {CHANNEL_ATTRIBUTE_OBJECT_TYPE, FEATURE_FLAG_CHANNEL_ATTRIBUTES} from '@constants/channel_attributes';
|
|
import {
|
|
CLASSIFICATIONS_FIELD_NAME,
|
|
CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID,
|
|
FEATURE_FLAG_CLASSIFICATION_MARKINGS,
|
|
} from '@constants/classification';
|
|
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
|
import {
|
|
deriveChannelAttributeBanner,
|
|
renderNativeBannerText,
|
|
resolveChannelAttributes,
|
|
resolvedAttributesEqual,
|
|
type ChannelAttributeBannerState,
|
|
type ResolvedChannelAttribute,
|
|
} from '@utils/channel_attributes';
|
|
import {deriveClassificationBannerState} from '@utils/classification';
|
|
|
|
import {getConfigValue, observeConfigBooleanValue, observeIsMinimumLicenseTier} from './system';
|
|
|
|
import type {PropertyFieldModel, PropertyValueModel, SystemModel} from '@database/models/server';
|
|
|
|
const {SERVER: {PROPERTY_FIELD, PROPERTY_VALUE, SYSTEM}} = MM_TABLES;
|
|
|
|
export const getPropertyFieldsByNames = (database: Database, names: string[]) => {
|
|
return database.get<PropertyFieldModel>(PROPERTY_FIELD).query(Q.where('name', Q.oneOf(names))).fetch();
|
|
};
|
|
|
|
export const getPropertyFieldsByGroupId = (database: Database, groupId: string) => {
|
|
return database.get<PropertyFieldModel>(PROPERTY_FIELD).query(Q.where('group_id', groupId)).fetch();
|
|
};
|
|
|
|
export const getPropertyFieldsByIds = (database: Database, ids: string[]) => {
|
|
return database.get<PropertyFieldModel>(PROPERTY_FIELD).query(Q.where('id', Q.oneOf(ids))).fetch();
|
|
};
|
|
|
|
export const getPropertyValuesByFieldId = (database: Database, fieldId: string) => {
|
|
return database.get<PropertyValueModel>(PROPERTY_VALUE).query(Q.where('field_id', fieldId)).fetch();
|
|
};
|
|
|
|
/**
|
|
* The values stored against one target that belong to the access_control group.
|
|
*
|
|
* Scoped by group on purpose: several features keep per-channel values in this
|
|
* table, so anything that deletes "everything for this target" has to be narrowed
|
|
* or it takes another feature's data with it. Returns nothing when the group id is
|
|
* not yet known, which is the safe direction for a destructive caller.
|
|
*/
|
|
export const getAccessControlValuesForTarget = async (database: Database, targetId: string) => {
|
|
const groupId = await getAccessControlGroupId(database);
|
|
if (!groupId) {
|
|
return [];
|
|
}
|
|
|
|
return database.get<PropertyValueModel>(PROPERTY_VALUE).query(
|
|
Q.where('target_id', targetId),
|
|
Q.where('group_id', groupId),
|
|
).fetch();
|
|
};
|
|
|
|
/**
|
|
* Returns true when at least one of the two access_control features is on.
|
|
*
|
|
* A pure DB read — no remote I/O — so it belongs in the query layer rather than
|
|
* in the remote action file that imports it.
|
|
*/
|
|
export const isAccessControlPropertiesEnabled = async (database: Database) => {
|
|
const [classification, channelAttributes] = await Promise.all([
|
|
getConfigValue(database, FEATURE_FLAG_CLASSIFICATION_MARKINGS),
|
|
getConfigValue(database, FEATURE_FLAG_CHANNEL_ATTRIBUTES),
|
|
]);
|
|
|
|
return classification === 'true' || channelAttributes === 'true';
|
|
};
|
|
|
|
export const observeClassificationFields = (database: Database) => {
|
|
return database.get<PropertyFieldModel>(PROPERTY_FIELD).query(
|
|
Q.where('name', CLASSIFICATIONS_FIELD_NAME),
|
|
Q.where('delete_at', 0),
|
|
).observeWithColumns(['update_at', 'delete_at', 'attrs']);
|
|
};
|
|
|
|
export const observePropertyValuesByTargetId = (database: Database, targetId: string) => {
|
|
return database.get<PropertyValueModel>(PROPERTY_VALUE).query(
|
|
Q.where('target_id', targetId),
|
|
Q.where('delete_at', 0),
|
|
).observeWithColumns(['value', 'update_at', 'delete_at']);
|
|
};
|
|
|
|
export const observeClassificationBannerState = (database: Database) => {
|
|
return combineLatest([
|
|
observeClassificationFields(database),
|
|
observePropertyValuesByTargetId(database, CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID),
|
|
]).pipe(
|
|
map(([fields, values]) => deriveClassificationBannerState(fields, values)),
|
|
distinctUntilChanged((a, b) => a.visible === b.visible && a.levelName === b.levelName && a.color === b.color),
|
|
);
|
|
};
|
|
|
|
const EMPTY_FIELDS: PropertyFieldModel[] = [];
|
|
|
|
/**
|
|
* Whether channel attributes are available on this server.
|
|
*
|
|
* Gated on the ChannelAttributes flag and an Enterprise Advanced licence. A
|
|
* server that predates the flag does not send it, so the flag read doubles as
|
|
* the version gate and no version constant is needed.
|
|
*
|
|
* Note this is stricter than the server, which gates the access_control group at
|
|
* Enterprise tier. The tighter tier is a product decision; making the two agree
|
|
* needs a feature-scoped check server-side, because the group hook is shared
|
|
* with Custom Profile Attributes and Classification Markings.
|
|
*/
|
|
export const observeChannelAttributesEnabled = (database: Database) => {
|
|
const flag = observeConfigBooleanValue(database, 'FeatureFlagChannelAttributes', false);
|
|
const isLicensed = observeIsMinimumLicenseTier(database, License.SKU_SHORT_NAME.EnterpriseAdvanced);
|
|
|
|
return combineLatest([flag, isLicensed]).pipe(
|
|
map(([enabled, licensed]) => enabled && licensed),
|
|
distinctUntilChanged(),
|
|
);
|
|
};
|
|
|
|
/**
|
|
* The resolved id of the access_control property group, or '' before anything has
|
|
* learned it. Persisted in the System table, so it survives a restart and is
|
|
* available offline.
|
|
*/
|
|
export const observeAccessControlGroupId = (database: Database) => {
|
|
return database.get<SystemModel>(SYSTEM).query(
|
|
Q.where('id', SYSTEM_IDENTIFIERS.ACCESS_CONTROL_GROUP_ID),
|
|
).observeWithColumns(['value']).pipe(
|
|
map((systems) => (systems[0]?.value as string | undefined) ?? ''),
|
|
distinctUntilChanged(),
|
|
);
|
|
};
|
|
|
|
export const getAccessControlGroupId = async (database: Database) => {
|
|
const systems = await database.get<SystemModel>(SYSTEM).query(
|
|
Q.where('id', SYSTEM_IDENTIFIERS.ACCESS_CONTROL_GROUP_ID),
|
|
).fetch();
|
|
|
|
return (systems[0]?.value as string | undefined) ?? '';
|
|
};
|
|
|
|
/**
|
|
* Every channel attribute definition, scoped to the access_control group.
|
|
*
|
|
* The group scope is not optional. Property rows carry a group_id but nothing
|
|
* maps a group name to it locally, and managed channel categories also stores
|
|
* channel-object fields in this table — so filtering on object_type alone would
|
|
* treat a sidebar category field as a channel attribute.
|
|
*/
|
|
export const observeChannelAttributeFields = (database: Database) => {
|
|
return observeAccessControlGroupId(database).pipe(
|
|
switchMap((groupId) => {
|
|
if (!groupId) {
|
|
return of$(EMPTY_FIELDS);
|
|
}
|
|
|
|
return database.get<PropertyFieldModel>(PROPERTY_FIELD).query(
|
|
Q.where('group_id', groupId),
|
|
Q.where('object_type', CHANNEL_ATTRIBUTE_OBJECT_TYPE),
|
|
Q.where('delete_at', 0),
|
|
).observeWithColumns(['update_at', 'delete_at', 'attrs']);
|
|
}),
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Every channel attribute paired with this channel's value, in display order.
|
|
*
|
|
* Ordering happens in JS rather than in the query: attrs is a JSON string column,
|
|
* so sort_order is not reachable from SQL.
|
|
*/
|
|
export const observeResolvedChannelAttributes = (
|
|
database: Database,
|
|
channelId: string,
|
|
): Observable<ResolvedChannelAttribute[]> => {
|
|
return combineLatest([
|
|
observeChannelAttributeFields(database),
|
|
observePropertyValuesByTargetId(database, channelId),
|
|
]).pipe(
|
|
map(([fields, values]) => resolveChannelAttributes(fields, values)),
|
|
distinctUntilChanged(resolvedAttributesEqual),
|
|
);
|
|
};
|
|
|
|
export const observeChannelAttributeBanner = (
|
|
database: Database,
|
|
channelId: string,
|
|
bannerInfo?: ChannelBannerInfo,
|
|
): Observable<ChannelAttributeBannerState> => {
|
|
return combineLatest([
|
|
observeChannelAttributeFields(database),
|
|
observePropertyValuesByTargetId(database, channelId),
|
|
observeChannelAttributesEnabled(database),
|
|
]).pipe(
|
|
map(([fields, values, attributesEnabled]) => ({
|
|
...deriveChannelAttributeBanner(fields, values, bannerInfo, attributesEnabled),
|
|
nativeText: renderNativeBannerText(fields, values, bannerInfo?.text, attributesEnabled),
|
|
})),
|
|
distinctUntilChanged((a, b) => a.hasBanner === b.hasBanner &&
|
|
a.banner?.text === b.banner?.text &&
|
|
a.banner?.background_color === b.banner?.background_color &&
|
|
a.nativeText === b.nativeText),
|
|
);
|
|
};
|
|
|