Compare commits

...

12 Commits

Author SHA1 Message Date
Daniel Espino García
15fd6925b3 Performance fixes and fix manual sort (#7190)
* Performance fixes and fix manual sort

* Fix test

* Use combineLatestWith

* Revert unread on top
2023-03-07 19:25:25 +01:00
Daniel Espino García
571070e284 Fix race condition when the same websocket gets initialized twice (#7185)
* Fix race condition when the same websocket gets initialized twice

* Bump network library
2023-03-07 19:13:19 +01:00
Elias Nahum
ab8a43032e Refactor category channels to react to setting changes and apply the correct order (#7170)
* Refactor category channels to react to setting changes and apply the correct order

* feedback review
2023-03-03 15:54:12 +02:00
Elias Nahum
6904be23da Fix push notification token registration race/missing (#7183) 2023-03-03 12:14:32 +02:00
Elias Nahum
6bc7c05ccb support WS connection over TLS1.3 (#7182)
* support WS connection over TLS1.3

* fix updateDraftMessage on unmount
2023-03-03 11:33:48 +02:00
Elias Nahum
4b142483a5 Fix display name when open own DM (#7181) 2023-03-02 16:58:31 +02:00
Elias Nahum
63674e2a43 fix entry for tablets (#7179) 2023-03-02 16:56:26 +02:00
Elias Nahum
cdaf1f50e7 use sourceScreen instead of location in post options (#7176) 2023-03-02 12:47:58 +02:00
Elias Nahum
10735dcbf1 trigger Search when hardware keyboard enter key is pressed (#7174) 2023-03-01 15:20:02 +02:00
Elias Nahum
619decd253 Fix potential reaction crash (#7172) 2023-03-01 15:19:55 +02:00
Elias Nahum
55f18bcfc3 ignore leading and trailing spaces when editing profile (#7173) 2023-03-01 15:19:47 +02:00
Elias Nahum
870336142a Fix iOS push notification when set as generic message with sender name (#7171) 2023-03-01 15:19:39 +02:00
36 changed files with 469 additions and 306 deletions

View File

@@ -11,6 +11,7 @@ import com.mattermost.helpers.database_extension.queryCurrentUserId
import com.nozbe.watermelondb.Database
import java.text.Collator
import java.util.Locale
import kotlin.math.max
suspend fun PushNotificationDataRunnable.Companion.fetchMyChannel(db: Database, serverUrl: String, channelId: String, isCRTEnabled: Boolean): Triple<ReadableMap?, ReadableMap?, ReadableArray?> {
val channel = fetch(serverUrl, "/api/v4/channels/$channelId")

View File

@@ -1,8 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {markChannelAsViewed} from '@actions/local/channel';
import {fetchMissingDirectChannelsInfo, fetchMyChannelsForTeam, handleKickFromChannel, markChannelAsRead, MyChannelsRequest} from '@actions/remote/channel';
import {fetchMissingDirectChannelsInfo, fetchMyChannelsForTeam, handleKickFromChannel, MyChannelsRequest} from '@actions/remote/channel';
import {fetchGroupsForMember} from '@actions/remote/groups';
import {fetchPostsForUnreadChannels} from '@actions/remote/post';
import {MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
@@ -440,9 +439,9 @@ export async function handleEntryAfterLoadNavigation(
if (!currentTeamIdAfterLoad) {
// First load or no team
if (tabletDevice) {
await setCurrentTeamAndChannelId(operator, initialTeamId, '');
} else {
await setCurrentTeamAndChannelId(operator, initialTeamId, initialChannelId);
} else {
await setCurrentTeamAndChannelId(operator, initialTeamId, '');
}
} else if (currentTeamIdAfterLoad !== currentTeamId) {
// Switched teams while loading
@@ -466,9 +465,6 @@ export async function handleEntryAfterLoadNavigation(
} else {
await setCurrentTeamAndChannelId(operator, initialTeamId, initialChannelId);
}
} else if (tabletDevice && initialChannelId === currentChannelId) {
await markChannelAsRead(serverUrl, initialChannelId);
markChannelAsViewed(serverUrl, initialChannelId);
}
} catch (error) {
logDebug('could not manage the entry after load navigation', error);

View File

@@ -130,6 +130,13 @@ async function doReconnect(serverUrl: string) {
if (models?.length) {
await operator.batchRecords(models, 'doReconnect');
}
const tabletDevice = await isTablet();
if (tabletDevice && initialChannelId === currentChannelId) {
await markChannelAsRead(serverUrl, initialChannelId);
markChannelAsViewed(serverUrl, initialChannelId);
}
logInfo('WEBSOCKET RECONNECT MODELS BATCHING TOOK', `${Date.now() - dt}ms`);
setTeamLoading(serverUrl, false);

View File

@@ -6,7 +6,7 @@ import {Platform} from 'react-native';
import {WebsocketEvents} from '@constants';
import DatabaseManager from '@database/manager';
import {getConfig} from '@queries/servers/system';
import {getConfigValue} from '@queries/servers/system';
import {hasReliableWebsocket} from '@utils/config';
import {toMilliseconds} from '@utils/datetime';
import {logError, logInfo, logWarning} from '@utils/log';
@@ -79,8 +79,12 @@ export default class WebSocketClient {
return;
}
const config = await getConfig(database);
const connectionUrl = (config.WebsocketURL || this.serverUrl) + '/api/v4/websocket';
const [websocketUrl, version, reliableWebsocketConfig] = await Promise.all([
getConfigValue(database, 'WebsocketURL'),
getConfigValue(database, 'Version'),
getConfigValue(database, 'EnableReliableWebSockets'),
]);
const connectionUrl = (websocketUrl || this.serverUrl) + '/api/v4/websocket';
if (this.connectingCallback) {
this.connectingCallback();
@@ -101,7 +105,7 @@ export default class WebSocketClient {
this.url = connectionUrl;
const reliableWebSockets = hasReliableWebsocket(config);
const reliableWebSockets = hasReliableWebsocket(version, reliableWebsocketConfig);
if (reliableWebSockets) {
// Add connection id, and last_sequence_number to the query param.
// We cannot also send it as part of the auth_challenge, because the session cookie is already sent with the request.
@@ -129,6 +133,11 @@ export default class WebSocketClient {
headers.Authorization = `Bearer ${this.token}`;
}
const {client} = await getOrCreateWebSocketClient(this.url, {headers, timeoutInterval: WEBSOCKET_TIMEOUT});
// Check again if the client is the same, to avoid race conditions
if (this.conn === client) {
return;
}
this.conn = client;
} catch (error) {
return;

View File

@@ -283,9 +283,9 @@ export default function PostInput({
});
return () => {
listener.remove();
updateDraftMessage(serverUrl, channelId, rootId, value); // safe draft on unmount
updateDraftMessage(serverUrl, channelId, rootId, lastNativeValue.current); // safe draft on unmount
};
}, [updateValue, value, channelId, rootId]);
}, [updateValue, channelId, rootId]);
useEffect(() => {
if (value !== lastNativeValue.current) {

View File

@@ -108,7 +108,7 @@ const CombinedUserActivity = ({
return;
}
const passProps = {post};
const passProps = {post, sourceScreen: location};
Keyboard.dismiss();
const title = isTablet ? intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}) : '';
@@ -117,7 +117,7 @@ const CombinedUserActivity = ({
} else {
showModalOverCurrentContext(Screens.POST_OPTIONS, passProps, bottomSheetModalOptions(theme));
}
}, [post, canDelete, isTablet, intl]);
}, [post, canDelete, isTablet, intl, location]);
const renderMessage = (postType: string, userIds: string[], actorId: string) => {
let actor = '';

View File

@@ -86,11 +86,11 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
if (reaction) {
const emojiAlias = getEmojiFirstAlias(reaction.emojiName);
if (acc.has(emojiAlias)) {
const rs = acc.get(emojiAlias);
const rs = acc.get(emojiAlias)!;
// eslint-disable-next-line max-nested-callbacks
const present = rs!.findIndex((r) => r.userId === reaction.userId) > -1;
const present = rs.findIndex((r) => r.userId === reaction.userId) > -1;
if (!present) {
rs!.push(reaction);
rs.push(reaction);
}
} else {
acc.set(emojiAlias, [reaction]);
@@ -105,7 +105,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
}, new Map<string, ReactionModel[]>());
return {reactionsByName, highlightedReactions};
}, [sortedReactions]);
}, [sortedReactions, reactions]);
const handleAddReactionToPost = (emoji: string) => {
addReaction(serverUrl, postId, emoji);
@@ -178,7 +178,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
return (
<Reaction
key={r}
count={reaction!.length}
count={reaction?.length || 1}
emojiName={r}
highlight={highlightedReactions.includes(r)}
onPress={handleReactionPress}

View File

@@ -150,6 +150,7 @@ export default function SelectedUsers({
const [isVisible, setIsVisible] = useState(false);
const numberSelectedIds = Object.keys(selectedIds).length;
const bottomSpace = (dimensions.height - containerHeight - modalPosition);
const bottomPaddingBottom = isTablet ? CHIP_HEIGHT_WITH_MARGIN : 0;
const users = useMemo(() => {
const u = [];
@@ -172,8 +173,8 @@ export default function SelectedUsers({
}, [selectedIds, teammateNameDisplay, onRemove]);
const totalPanelHeight = useDerivedValue(() => (
isVisible ? panelHeight.value + BUTTON_HEIGHT : 0
), [isVisible, isTablet]);
isVisible ? panelHeight.value + BUTTON_HEIGHT + bottomPaddingBottom : 0
), [isVisible, isTablet, bottomPaddingBottom]);
const marginBottom = useMemo(() => {
let margin = keyboard.height && Platform.OS === 'ios' ? keyboard.height - insets.bottom : 0;
@@ -208,7 +209,7 @@ export default function SelectedUsers({
}, [onPress]);
const onLayout = useCallback((e: LayoutChangeEvent) => {
panelHeight.value = Math.min(PANEL_MAX_HEIGHT, e.nativeEvent.layout.height);
panelHeight.value = Math.min(PANEL_MAX_HEIGHT + bottomPaddingBottom, e.nativeEvent.layout.height);
}, []);
const androidMaxHeight = Platform.select({
@@ -235,8 +236,8 @@ export default function SelectedUsers({
const animatedViewStyle = useAnimatedStyle(() => ({
height: withTiming(totalPanelHeight.value + insets.bottom, {duration: 250}),
borderWidth: isVisible ? 1 : 0,
maxHeight: isVisible ? PANEL_MAX_HEIGHT + BUTTON_HEIGHT + insets.bottom : 0,
}), [isVisible, insets]);
maxHeight: isVisible ? PANEL_MAX_HEIGHT + BUTTON_HEIGHT + bottomPaddingBottom + insets.bottom : 0,
}), [isVisible, insets, bottomPaddingBottom]);
const animatedButtonStyle = useAnimatedStyle(() => ({
opacity: withTiming(isVisible ? 1 : 0, {duration: isVisible ? 500 : 100}),

View File

@@ -14,7 +14,7 @@ import TeamList from './team_list';
type Props = {
iconPad?: boolean;
canJoinOtherTeams: boolean;
teamsCount: number;
hasMoreThanOneTeam: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@@ -36,8 +36,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
export default function TeamSidebar({iconPad, canJoinOtherTeams, teamsCount}: Props) {
const initialWidth = teamsCount > 1 ? TEAM_SIDEBAR_WIDTH : 0;
export default function TeamSidebar({iconPad, canJoinOtherTeams, hasMoreThanOneTeam}: Props) {
const initialWidth = hasMoreThanOneTeam ? TEAM_SIDEBAR_WIDTH : 0;
const width = useSharedValue(initialWidth);
const marginTop = useSharedValue(iconPad ? 44 : 0);
const theme = useTheme();
@@ -58,8 +58,8 @@ export default function TeamSidebar({iconPad, canJoinOtherTeams, teamsCount}: Pr
}, [iconPad]);
useEffect(() => {
width.value = teamsCount > 1 ? TEAM_SIDEBAR_WIDTH : 0;
}, [teamsCount]);
width.value = hasMoreThanOneTeam ? TEAM_SIDEBAR_WIDTH : 0;
}, [hasMoreThanOneTeam]);
return (
<Animated.View style={[styles.container, transform]}>

View File

@@ -3,6 +3,8 @@
export const CATEGORIES_TO_KEEP: Record<string, string> = {
ADVANCED_SETTINGS: 'advanced_settings',
CHANNEL_APPROXIMATE_VIEW_TIME: 'channel_approximate_view_time',
CHANNEL_OPEN_TIME: 'channel_open_time',
DIRECT_CHANNEL_SHOW: 'direct_channel_show',
GROUP_CHANNEL_SHOW: 'group_channel_show',
DISPLAY_SETTINGS: 'display_settings',

View File

@@ -246,10 +246,11 @@ const ChannelHandler = <TBase extends Constructor<ServerDataOperatorBase>>(super
const totalMsg = isCRT ? channel.total_msg_count_root! : channel.total_msg_count;
const myMsgCount = isCRT ? my.msg_count_root! : my.msg_count;
const msgCount = Math.max(0, totalMsg - myMsgCount);
const lastPostAt = isCRT ? (channel.last_root_post_at || channel.last_post_at) : channel.last_post_at;
my.msg_count = msgCount;
my.mention_count = isCRT ? my.mention_count_root! : my.mention_count;
my.is_unread = msgCount > 0;
my.last_post_at = (isCRT ? (channel.last_root_post_at || channel.last_post_at) : channel.last_post_at) || 0;
my.last_post_at = lastPostAt;
}
}
@@ -271,7 +272,7 @@ const ChannelHandler = <TBase extends Constructor<ServerDataOperatorBase>>(super
}
const chan = channelMap[my.channel_id];
const lastPostAt = (isCRT ? chan.last_root_post_at : chan.last_post_at) || 0;
const lastPostAt = isCRT ? (chan.last_root_post_at || chan.last_post_at) : chan.last_post_at;
if ((chan && e.lastPostAt < lastPostAt) ||
e.isUnread !== my.is_unread || e.lastViewedAt < my.last_viewed_at ||
e.roles !== my.roles

View File

@@ -37,22 +37,22 @@ class PushNotifications {
configured = false;
init(register: boolean) {
if (register) {
this.registerIfNeeded();
}
Notifications.events().registerNotificationOpened(this.onNotificationOpened);
Notifications.events().registerRemoteNotificationsRegistered(this.onRemoteNotificationsRegistered);
Notifications.events().registerNotificationReceivedBackground(this.onNotificationReceivedBackground);
Notifications.events().registerNotificationReceivedForeground(this.onNotificationReceivedForeground);
if (register) {
this.registerIfNeeded();
}
}
async registerIfNeeded() {
const isRegistered = await Notifications.isRegisteredForRemoteNotifications();
if (!isRegistered) {
await requestNotifications(['alert', 'sound', 'badge']);
Notifications.registerRemoteNotifications();
}
Notifications.registerRemoteNotifications();
}
createReplyCategory = () => {

View File

@@ -11,14 +11,11 @@ import {makeCategoryChannelId} from '@utils/categories';
import {pluckUnique} from '@utils/helpers';
import {logDebug} from '@utils/log';
import {observeChannelsByLastPostAt} from './channel';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type CategoryModel from '@typings/database/models/servers/category';
import type CategoryChannelModel from '@typings/database/models/servers/category_channel';
import type ChannelModel from '@typings/database/models/servers/channel';
const {SERVER: {CATEGORY, CATEGORY_CHANNEL, CHANNEL}} = MM_TABLES;
const {SERVER: {CATEGORY, CATEGORY_CHANNEL}} = MM_TABLES;
export const getCategoryById = async (database: Database, categoryId: string) => {
try {
@@ -144,24 +141,3 @@ export const observeIsChannelFavorited = (database: Database, teamId: string, ch
distinctUntilChanged(),
);
};
export const observeChannelsByCategoryChannelSortOrder = (database: Database, category: CategoryModel, excludeIds?: string[]) => {
return category.categoryChannelsBySortOrder.observeWithColumns(['sort_order']).pipe(
switchMap((categoryChannels) => {
const ids = categoryChannels.map((cc) => cc.channelId);
const idsStr = `'${ids.join("','")}'`;
const exclude = excludeIds?.length ? `AND c.id NOT IN ('${excludeIds.join("','")}')` : '';
return database.get<ChannelModel>(CHANNEL).query(
Q.unsafeSqlQuery(`SELECT DISTINCT c.* FROM ${CHANNEL} c INNER JOIN
${CATEGORY_CHANNEL} cc ON cc.channel_id=c.id AND c.id IN (${idsStr}) ${exclude}
ORDER BY cc.sort_order`),
).observe();
}),
);
};
export const observeChannelsByLastPostAtInCategory = (database: Database, category: CategoryModel, excludeIds?: string[]) => {
return category.myChannels.observeWithColumns(['last_post_at']).pipe(
switchMap((myChannels) => observeChannelsByLastPostAt(database, myChannels, excludeIds)),
);
};

View File

@@ -11,6 +11,7 @@ import {General, Permissions} from '@constants';
import {MM_TABLES} from '@constants/database';
import {sanitizeLikeString} from '@helpers/database';
import {hasPermission} from '@utils/role';
import {getUserIdFromChannelName} from '@utils/user';
import {prepareDeletePost} from './post';
import {queryRoles} from './role';
@@ -437,10 +438,6 @@ export const observeNotifyPropsByChannels = (database: Database, channels: Chann
);
};
export const queryChannelsByNames = (database: Database, names: string[]) => {
return database.get<ChannelModel>(CHANNEL).query(Q.where('name', Q.oneOf(names)));
};
export const queryMyChannelUnreads = (database: Database, currentTeamId: string) => {
return database.get<MyChannelModel>(MY_CHANNEL).query(
Q.on(
@@ -453,40 +450,42 @@ export const queryMyChannelUnreads = (database: Database, currentTeamId: string)
Q.where('delete_at', Q.eq(0)),
),
),
Q.where('is_unread', Q.eq(true)),
Q.or(
Q.where('is_unread', Q.eq(true)),
Q.where('mentions_count', Q.gte(0)),
),
Q.sortBy('last_post_at', Q.desc),
);
};
export const queryEmptyDirectAndGroupChannels = (database: Database) => {
return database.get<MyChannelModel>(MY_CHANNEL).query(
Q.on(
CHANNEL,
Q.where('team_id', Q.eq('')),
),
Q.where('last_post_at', Q.eq(0)),
);
};
export const observeArchivedDirectChannels = (database: Database, currentUserId: string) => {
const deactivatedIds = database.get<UserModel>(USER).query(
const deactivated = database.get<UserModel>(USER).query(
Q.where('delete_at', Q.gt(0)),
).observe().pipe(
switchMap((users) => of$(users.map((u) => u.id))),
);
).observe();
return deactivatedIds.pipe(
switchMap((dIds) => {
return deactivated.pipe(
switchMap((users) => {
const usersMap = new Map(users.map((u) => [u.id, u]));
return database.get<ChannelModel>(CHANNEL).query(
Q.on(
CHANNEL_MEMBERSHIP,
Q.and(
Q.where('user_id', Q.notEq(currentUserId)),
Q.where('user_id', Q.oneOf(dIds)),
Q.where('user_id', Q.oneOf(Array.from(usersMap.keys()))),
),
),
Q.where('type', 'D'),
).observe();
).observe().pipe(
switchMap((channels) => {
// eslint-disable-next-line max-nested-callbacks
return of$(new Map(channels.map((c) => {
const teammateId = getUserIdFromChannelName(currentUserId, c.name);
const user = usersMap.get(teammateId);
return [c.id, user];
})));
}),
);
}),
);
};
@@ -639,13 +638,13 @@ export const observeIsMutedSetting = (database: Database, channelId: string) =>
return observeChannelSettings(database, channelId).pipe(switchMap((s) => of$(s?.notifyProps?.mark_unread === General.MENTION)));
};
export const observeChannelsByLastPostAt = (database: Database, myChannels: MyChannelModel[], excludeIds?: string[]) => {
export const observeChannelsByLastPostAt = (database: Database, myChannels: MyChannelModel[]) => {
const ids = myChannels.map((c) => c.id);
const idsStr = `'${ids.join("','")}'`;
const exclude = excludeIds?.length ? `AND c.id NOT IN ('${excludeIds.join("','")}')` : '';
return database.get<ChannelModel>(CHANNEL).query(
Q.unsafeSqlQuery(`SELECT DISTINCT c.* FROM ${CHANNEL} c INNER JOIN
${MY_CHANNEL} mc ON mc.id=c.id AND c.id IN (${idsStr}) ${exclude}
${MY_CHANNEL} mc ON mc.id=c.id AND c.id IN (${idsStr})
ORDER BY CASE mc.last_post_at WHEN 0 THEN c.create_at ELSE mc.last_post_at END DESC`),
).observe();
};

View File

@@ -197,8 +197,8 @@ export default function CreateDirectMessage({
setSelectedIds((current) => removeProfileFromList(current, id));
}, []);
const createDirectChannel = useCallback(async (id: string): Promise<boolean> => {
const user = selectedIds[id];
const createDirectChannel = useCallback(async (id: string, selectedUser?: UserProfile): Promise<boolean> => {
const user = selectedUser || selectedIds[id];
const displayName = displayUsername(user, intl.locale, teammateNameDisplay);
const result = await makeDirectChannel(serverUrl, id, displayName);
@@ -219,7 +219,7 @@ export default function CreateDirectMessage({
return !result.error;
}, [serverUrl]);
const startConversation = useCallback(async (selectedId?: {[id: string]: boolean}) => {
const startConversation = useCallback(async (selectedId?: {[id: string]: boolean}, selectedUser?: UserProfile) => {
if (startingConversation) {
return;
}
@@ -233,7 +233,7 @@ export default function CreateDirectMessage({
} else if (idsToUse.length > 1) {
success = await createGroupChannel(idsToUse);
} else {
success = await createDirectChannel(idsToUse[0]);
success = await createDirectChannel(idsToUse[0], selectedUser);
}
if (success) {
@@ -249,7 +249,7 @@ export default function CreateDirectMessage({
[currentUserId]: true,
};
startConversation(selectedId);
startConversation(selectedId, user);
} else {
clearSearch();
setSelectedIds((current) => {

View File

@@ -102,6 +102,7 @@ const EditProfile = ({
popTopScreen(componentId);
}
}, []);
const enableSaveButton = useCallback((value: boolean) => {
if (!isTablet) {
const buttons = {
@@ -114,18 +115,19 @@ const EditProfile = ({
}
setCanSave(value);
}, [componentId, rightButton]);
const submitUser = useCallback(preventDoubleTap(async () => {
enableSaveButton(false);
setError(undefined);
setUpdating(true);
try {
const newUserInfo: Partial<UserProfile> = {
email: userInfo.email,
first_name: userInfo.firstName,
last_name: userInfo.lastName,
nickname: userInfo.nickname,
position: userInfo.position,
username: userInfo.username,
email: userInfo.email.trim(),
first_name: userInfo.firstName.trim(),
last_name: userInfo.lastName.trim(),
nickname: userInfo.nickname.trim(),
position: userInfo.position.trim(),
username: userInfo.username.trim(),
};
const localPath = changedProfilePicture.current?.localPath;
const profileImageRemoved = changedProfilePicture.current?.isRemoved;

View File

@@ -12,29 +12,9 @@ import TestHelper from '@test/test_helper';
import CategoryBody from '.';
import type CategoryModel from '@typings/database/models/servers/category';
import type CategoryChannelModel from '@typings/database/models/servers/category_channel';
import type ChannelModel from '@typings/database/models/servers/channel';
const {SERVER: {CATEGORY}} = MM_TABLES;
jest.mock('@queries/servers/categories', () => {
const Queries = jest.requireActual('@queries/servers/categories');
const switchMap = jest.requireActual('rxjs/operators').switchMap;
const mQ = jest.requireActual('@nozbe/watermelondb').Q;
return {
...Queries,
observeChannelsByCategoryChannelSortOrder: (database: Database, category: CategoryModel, excludeIds?: string[]) => {
return category.categoryChannelsBySortOrder.observeWithColumns(['sort_order']).pipe(
switchMap((categoryChannels: CategoryChannelModel[]) => {
const ids = categoryChannels.filter((cc) => excludeIds?.includes(cc.channelId)).map((cc) => cc.channelId);
return database.get<ChannelModel>('Channel').query(mQ.where('id', mQ.oneOf(ids))).observe();
}),
);
},
};
});
describe('components/channel_list/categories/body', () => {
let database: Database;
let category: CategoryModel;

View File

@@ -7,7 +7,6 @@ import Animated, {Easing, useAnimatedStyle, useSharedValue, withTiming} from 're
import {fetchDirectChannelsInfo} from '@actions/remote/channel';
import ChannelItem from '@components/channel_item';
import {DMS_CATEGORY} from '@constants/categories';
import {useServerUrl} from '@context/server';
import {isDMorGM} from '@utils/channel';
@@ -17,7 +16,6 @@ import type ChannelModel from '@typings/database/models/servers/channel';
type Props = {
sortedChannels: ChannelModel[];
category: CategoryModel;
limit: number;
onChannelSwitch: (channelId: string) => void;
unreadIds: Set<string>;
unreadsOnTop: boolean;
@@ -25,16 +23,13 @@ type Props = {
const extractKey = (item: ChannelModel) => item.id;
const CategoryBody = ({sortedChannels, unreadIds, unreadsOnTop, category, limit, onChannelSwitch}: Props) => {
const CategoryBody = ({sortedChannels, unreadIds, unreadsOnTop, category, onChannelSwitch}: Props) => {
const serverUrl = useServerUrl();
const ids = useMemo(() => {
const filteredChannels = unreadsOnTop ? sortedChannels.filter((c) => !unreadIds.has(c.id)) : sortedChannels;
if (category.type === DMS_CATEGORY && limit > 0) {
return filteredChannels.slice(0, limit);
}
return filteredChannels;
}, [category.type, limit, sortedChannels, unreadIds, unreadsOnTop]);
}, [category.type, sortedChannels, unreadIds, unreadsOnTop]);
const unreadChannels = useMemo(() => {
return unreadsOnTop ? [] : ids.filter((c) => unreadIds.has(c.id));

View File

@@ -1,20 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database, Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {map, switchMap, combineLatestWith} from 'rxjs/operators';
import {of as of$, Observable} from 'rxjs';
import {switchMap, combineLatestWith, distinctUntilChanged} from 'rxjs/operators';
import {General, Preferences} from '@constants';
import {Preferences} from '@constants';
import {DMS_CATEGORY} from '@constants/categories';
import {getSidebarPreferenceAsBool} from '@helpers/api/preference';
import {observeChannelsByCategoryChannelSortOrder, observeChannelsByLastPostAtInCategory} from '@queries/servers/categories';
import {observeArchivedDirectChannels, observeNotifyPropsByChannels, queryChannelsByNames, queryEmptyDirectAndGroupChannels} from '@queries/servers/channel';
import {observeArchivedDirectChannels, observeNotifyPropsByChannels} from '@queries/servers/channel';
import {queryPreferencesByCategoryAndName, querySidebarPreferences} from '@queries/servers/preference';
import {observeCurrentChannelId, observeCurrentUserId, observeLastUnreadChannelId} from '@queries/servers/system';
import {getDirectChannelName} from '@utils/channel';
import {ChannelWithMyChannel, filterArchivedChannels, filterAutoclosedDMs, filterManuallyClosedDms, getUnreadIds, sortChannels} from '@utils/categories';
import CategoryBody from './category_body';
@@ -24,10 +22,6 @@ import type ChannelModel from '@typings/database/models/servers/channel';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
import type PreferenceModel from '@typings/database/models/servers/preference';
type ChannelData = Pick<ChannelModel, 'id' | 'displayName'> & {
isMuted: boolean;
};
type EnhanceProps = {
category: CategoryModel;
locale: string;
@@ -35,87 +29,45 @@ type EnhanceProps = {
isTablet: boolean;
} & WithDatabaseArgs
const sortAlpha = (locale: string, a: ChannelData, b: ChannelData) => {
if (a.isMuted && !b.isMuted) {
return 1;
} else if (!a.isMuted && b.isMuted) {
return -1;
}
return a.displayName.localeCompare(b.displayName, locale, {numeric: true});
};
const filterArchived = (channels: Array<ChannelModel | null>, currentChannelId: string) => {
return channels.filter((c): c is ChannelModel => c != null && ((c.deleteAt > 0 && c.id === currentChannelId) || !c.deleteAt));
};
const buildAlphaData = (channels: ChannelModel[], notifyProps: Record<string, Partial<ChannelNotifyProps>>, locale: string) => {
const chanelsById = channels.reduce((result: Record<string, ChannelModel>, c) => {
result[c.id] = c;
return result;
}, {});
const combined = channels.map((c) => {
const s = notifyProps[c.id];
return {
id: c.id,
displayName: c.displayName,
isMuted: s?.mark_unread === General.MENTION,
};
});
combined.sort(sortAlpha.bind(null, locale));
return of$(combined.map((cdata) => chanelsById[cdata.id]));
};
const observeSortedChannels = (database: Database, category: CategoryModel, excludeIds: string[], locale: string) => {
switch (category.sorting) {
case 'alpha': {
const channels = category.channels.extend(Q.where('id', Q.notIn(excludeIds))).observeWithColumns(['display_name']);
const notifyProps = channels.pipe(switchMap((cs) => observeNotifyPropsByChannels(database, cs)));
return combineLatest([channels, notifyProps]).pipe(
switchMap(([cs, np]) => buildAlphaData(cs, np, locale)),
);
}
case 'manual': {
return observeChannelsByCategoryChannelSortOrder(database, category, excludeIds);
}
default:
return observeChannelsByLastPostAtInCategory(database, category, excludeIds);
}
};
const mapPrefName = (prefs: PreferenceModel[]) => of$(prefs.map((p) => p.name));
const mapChannelIds = (channels: ChannelModel[] | MyChannelModel[]) => of$(channels.map((c) => c.id));
const withUserId = withObservables([], ({database}: WithDatabaseArgs) => ({currentUserId: observeCurrentUserId(database)}));
const enhance = withObservables(['category', 'isTablet', 'locale'], ({category, locale, isTablet, database, currentUserId}: EnhanceProps) => {
const dmMap = (p: PreferenceModel) => getDirectChannelName(p.name, currentUserId);
const observeCategoryChannels = (category: CategoryModel, myChannels: Observable<MyChannelModel[]>) => {
const channels = category.channels.observeWithColumns(['create_at', 'display_name']);
const manualSort = category.categoryChannelsBySortOrder.observeWithColumns(['sort_order']);
return myChannels.pipe(
combineLatestWith(channels, manualSort),
switchMap(([my, cs, sorted]) => {
const channelMap = new Map<string, ChannelModel>(cs.map((c) => [c.id, c]));
const categoryChannelMap = new Map<string, number>(sorted.map((s) => [s.channelId, s.sortOrder]));
return of$(my.reduce<ChannelWithMyChannel[]>((result, myChannel) => {
const channel = channelMap.get(myChannel.id);
if (channel) {
const channelWithMyChannel: ChannelWithMyChannel = {
channel,
myChannel,
sortOrder: categoryChannelMap.get(myChannel.id) || 0,
};
result.push(channelWithMyChannel);
}
const currentChannelId = observeCurrentChannelId(database);
return result;
}, []));
}),
);
};
const hiddenDmIds = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.DIRECT_CHANNEL_SHOW, undefined, 'false').
observeWithColumns(['value']).pipe(
switchMap((prefs: PreferenceModel[]) => {
const names = prefs.map(dmMap);
const channels = queryChannelsByNames(database, names).observe();
const enhanced = withObservables([], ({category, currentUserId, database, isTablet, locale}: EnhanceProps) => {
const categoryMyChannels = category.myChannels.observeWithColumns(['last_post_at', 'is_unread']);
const channelsWithMyChannel = observeCategoryChannels(category, categoryMyChannels);
const currentChannelId = isTablet ? observeCurrentChannelId(database) : of$('');
const lastUnreadId = isTablet ? observeLastUnreadChannelId(database) : of$(undefined);
return channels.pipe(
switchMap(mapChannelIds),
);
}),
const unreadsOnTop = querySidebarPreferences(database, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS).
observeWithColumns(['value']).
pipe(
switchMap((prefs: PreferenceModel[]) => of$(getSidebarPreferenceAsBool(prefs, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS))),
);
const emptyDmIds = queryEmptyDirectAndGroupChannels(database).observeWithColumns(['last_post_at']).pipe(
switchMap(mapChannelIds),
);
const archivedDmIds = observeArchivedDirectChannels(database, currentUserId).pipe(
switchMap(mapChannelIds),
);
let limit = of$(Preferences.CHANNEL_SIDEBAR_LIMIT_DMS_DEFAULT);
if (category.type === DMS_CATEGORY) {
limit = querySidebarPreferences(database, Preferences.CHANNEL_SIDEBAR_LIMIT_DMS).
@@ -126,54 +78,61 @@ const enhance = withObservables(['category', 'isTablet', 'locale'], ({category,
);
}
const unreadsOnTop = querySidebarPreferences(database, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS).
observeWithColumns(['value']).
pipe(
switchMap((prefs: PreferenceModel[]) => of$(getSidebarPreferenceAsBool(prefs, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS))),
);
const lastUnreadId = isTablet ? observeLastUnreadChannelId(database) : of$(undefined);
const hiddenChannelIds = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.GROUP_CHANNEL_SHOW, undefined, 'false').
observeWithColumns(['value']).pipe(
switchMap(mapPrefName),
combineLatestWith(hiddenDmIds, emptyDmIds, archivedDmIds, lastUnreadId),
switchMap(([hIds, hDmIds, eDmIds, aDmIds, excludeId]) => {
const hidden = new Set(hIds.concat(hDmIds, eDmIds, aDmIds));
if (excludeId) {
hidden.delete(excludeId);
}
return of$(hidden);
}),
);
const sortedChannels = hiddenChannelIds.pipe(
switchMap((excludeIds) => observeSortedChannels(database, category, Array.from(excludeIds), locale)),
combineLatestWith(currentChannelId),
map(([channels, ccId]) => filterArchived(channels, ccId)),
const notifyPropsPerChannel = categoryMyChannels.pipe(
// eslint-disable-next-line max-nested-callbacks
switchMap((mc) => observeNotifyPropsByChannels(database, mc)),
);
const unreadChannels = category.myChannels.observeWithColumns(['mentions_count', 'is_unread']);
const notifyProps = unreadChannels.pipe(switchMap((myChannels) => observeNotifyPropsByChannels(database, myChannels)));
const unreadIds = unreadChannels.pipe(
combineLatestWith(notifyProps, lastUnreadId),
map(([my, settings, lastUnread]) => {
return my.reduce<Set<string>>((set, m) => {
const isMuted = settings[m.id]?.mark_unread === 'mention';
if ((isMuted && m.mentionsCount) || (!isMuted && m.isUnread) || m.id === lastUnread) {
set.add(m.id);
}
return set;
}, new Set());
const hiddenDmPrefs = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.DIRECT_CHANNEL_SHOW, undefined, 'false').
observeWithColumns(['value']);
const hiddenGmPrefs = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.GROUP_CHANNEL_SHOW, undefined, 'false').
observeWithColumns(['value']);
const manuallyClosedPrefs = hiddenDmPrefs.pipe(
combineLatestWith(hiddenGmPrefs),
switchMap(([dms, gms]) => of$(dms.concat(gms))),
);
const approxViewTimePrefs = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.CHANNEL_APPROXIMATE_VIEW_TIME, undefined).
observeWithColumns(['value']);
const openTimePrefs = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.CHANNEL_OPEN_TIME, undefined).
observeWithColumns(['value']);
const autoclosePrefs = approxViewTimePrefs.pipe(
combineLatestWith(openTimePrefs),
switchMap(([viewTimes, openTimes]) => of$(viewTimes.concat(openTimes))),
);
const categorySorting = category.observe().pipe(
switchMap((c) => of$(c.sorting)),
distinctUntilChanged(),
);
const deactivated = (category.type === DMS_CATEGORY) ? observeArchivedDirectChannels(database, currentUserId) : of$(undefined);
const sortedChannels = channelsWithMyChannel.pipe(
combineLatestWith(categorySorting, currentChannelId, lastUnreadId, notifyPropsPerChannel, manuallyClosedPrefs, autoclosePrefs, deactivated, limit),
switchMap(([cwms, sorting, channelId, unreadId, notifyProps, manuallyClosedDms, autoclose, deactivatedDMS, maxDms]) => {
let channelsW = cwms;
channelsW = filterArchivedChannels(channelsW, channelId);
channelsW = filterManuallyClosedDms(channelsW, notifyProps, manuallyClosedDms, currentUserId, unreadId);
channelsW = filterAutoclosedDMs(category.type, maxDms, channelId, channelsW, autoclose, notifyProps, deactivatedDMS, unreadId);
return of$(sortChannels(sorting, channelsW, notifyProps, locale));
}),
);
const unreadIds = channelsWithMyChannel.pipe(
combineLatestWith(notifyPropsPerChannel, lastUnreadId),
switchMap(([cwms, notifyProps, unreadId]) => {
return of$(getUnreadIds(cwms, notifyProps, unreadId));
}),
);
return {
limit,
sortedChannels,
unreadsOnTop,
unreadIds,
category,
sortedChannels,
unreadIds,
unreadsOnTop,
};
});
export default withDatabase(withUserId(enhance(CategoryBody)));
export default withDatabase(withUserId(enhanced(CategoryBody)));

View File

@@ -54,7 +54,7 @@ const enhanced = withObservables(['currentTeamId', 'isTablet', 'onlyUnreads'], (
const channels = myUnreadChannels.pipe(switchMap((myChannels) => observeChannelsByLastPostAt(database, myChannels)));
const channelsMap = channels.pipe(switchMap((cs) => of$(makeChannelsMap(cs))));
return queryMyChannelUnreads(database, currentTeamId).observeWithColumns(['last_post_at', 'is_unread']).pipe(
return myUnreadChannels.pipe(
combineLatestWith(channelsMap, notifyProps),
map(filterAndSortMyChannels),
combineLatestWith(lastUnread),

View File

@@ -4,7 +4,7 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {Permissions} from '@constants';
import {observePermissionForTeam} from '@queries/servers/role';
@@ -25,6 +25,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const canJoinChannels = combineLatest([currentUser, team]).pipe(
switchMap(([u, t]) => observePermissionForTeam(database, t, u, Permissions.JOIN_PUBLIC_CHANNELS, true)),
distinctUntilChanged(),
);
const canCreatePublicChannels = combineLatest([currentUser, team]).pipe(
@@ -37,6 +38,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const canCreateChannels = combineLatest([canCreatePublicChannels, canCreatePrivateChannels]).pipe(
switchMap(([open, priv]) => of$(open || priv)),
distinctUntilChanged(),
);
const canAddUserToTeam = combineLatest([currentUser, team]).pipe(
@@ -48,9 +50,11 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
canJoinChannels,
canInvitePeople: combineLatest([enableOpenServer, canAddUserToTeam]).pipe(
switchMap(([openServer, addUser]) => of$(openServer && addUser)),
distinctUntilChanged(),
),
displayName: team.pipe(
switchMap((t) => of$(t?.displayName)),
distinctUntilChanged(),
),
pushProxyStatus: observePushVerificationStatus(database),
};

View File

@@ -33,8 +33,8 @@ describe('components/categories_list', () => {
it('should render', () => {
const wrapper = renderWithEverything(
<CategoriesList
teamsCount={1}
channelsCount={1}
moreThanOneTeam={false}
hasChannels={true}
/>,
{database},
);
@@ -46,8 +46,8 @@ describe('components/categories_list', () => {
const wrapper = renderWithEverything(
<CategoriesList
isCRTEnabled={true}
teamsCount={1}
channelsCount={1}
moreThanOneTeam={false}
hasChannels={true}
/>,
{database},
);
@@ -67,8 +67,8 @@ describe('components/categories_list', () => {
jest.useFakeTimers();
const wrapper = renderWithEverything(
<CategoriesList
teamsCount={0}
channelsCount={1}
moreThanOneTeam={false}
hasChannels={true}
/>,
{database},
);
@@ -89,8 +89,8 @@ describe('components/categories_list', () => {
jest.useFakeTimers();
const wrapper = renderWithEverything(
<CategoriesList
teamsCount={1}
channelsCount={0}
moreThanOneTeam={true}
hasChannels={false}
/>,
{database},
);

View File

@@ -27,28 +27,28 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
}));
type ChannelListProps = {
channelsCount: number;
hasChannels: boolean;
iconPad?: boolean;
isCRTEnabled?: boolean;
teamsCount: number;
moreThanOneTeam: boolean;
};
const getTabletWidth = (teamsCount: number) => {
return TABLET_SIDEBAR_WIDTH - (teamsCount > 1 ? TEAM_SIDEBAR_WIDTH : 0);
const getTabletWidth = (moreThanOneTeam: boolean) => {
return TABLET_SIDEBAR_WIDTH - (moreThanOneTeam ? TEAM_SIDEBAR_WIDTH : 0);
};
const CategoriesList = ({channelsCount, iconPad, isCRTEnabled, teamsCount}: ChannelListProps) => {
const CategoriesList = ({hasChannels, iconPad, isCRTEnabled, moreThanOneTeam}: ChannelListProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const {width} = useWindowDimensions();
const isTablet = useIsTablet();
const tabletWidth = useSharedValue(isTablet ? getTabletWidth(teamsCount) : 0);
const tabletWidth = useSharedValue(isTablet ? getTabletWidth(moreThanOneTeam) : 0);
useEffect(() => {
if (isTablet) {
tabletWidth.value = getTabletWidth(teamsCount);
tabletWidth.value = getTabletWidth(moreThanOneTeam);
}
}, [isTablet && teamsCount]);
}, [isTablet && moreThanOneTeam]);
const tabletStyle = useAnimatedStyle(() => {
if (!isTablet) {
@@ -61,7 +61,7 @@ const CategoriesList = ({channelsCount, iconPad, isCRTEnabled, teamsCount}: Chan
}, [isTablet, width]);
const content = useMemo(() => {
if (channelsCount < 1) {
if (!hasChannels) {
return (<LoadChannelsError/>);
}

View File

@@ -29,9 +29,10 @@ import Servers from './servers';
import type {LaunchType} from '@typings/launch';
type ChannelProps = {
channelsCount: number;
hasChannels: boolean;
isCRTEnabled: boolean;
teamsCount: number;
hasTeams: boolean;
hasMoreThanOneTeam: boolean;
isLicensed: boolean;
showToS: boolean;
launchType: LaunchType;
@@ -126,10 +127,10 @@ const ChannelListScreen = (props: ChannelProps) => {
}, [theme, insets.top]);
useEffect(() => {
if (!props.teamsCount) {
if (!props.hasTeams) {
resetToTeams();
}
}, [Boolean(props.teamsCount)]);
}, [Boolean(props.hasTeams)]);
useEffect(() => {
const back = BackHandler.addEventListener('hardwareBackPress', handleBackPress);
@@ -176,13 +177,13 @@ const ChannelListScreen = (props: ChannelProps) => {
>
<TeamSidebar
iconPad={canAddOtherServers}
teamsCount={props.teamsCount}
hasMoreThanOneTeam={props.hasMoreThanOneTeam}
/>
<CategoriesList
iconPad={canAddOtherServers && props.teamsCount <= 1}
iconPad={canAddOtherServers && !props.hasMoreThanOneTeam}
isCRTEnabled={props.isCRTEnabled}
teamsCount={props.teamsCount}
channelsCount={props.channelsCount}
moreThanOneTeam={props.hasMoreThanOneTeam}
hasChannels={props.hasChannels}
/>
{isTablet &&
<AdditionalTabletView/>

View File

@@ -4,7 +4,7 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {queryAllMyChannelsForTeam} from '@queries/servers/channel';
import {observeCurrentTeamId, observeLicense} from '@queries/servers/system';
@@ -21,11 +21,22 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
switchMap((lcs) => (lcs ? of$(lcs.IsLicensed === 'true') : of$(false))),
);
const teamsCount = queryMyTeams(database).observeCount(false);
return {
isCRTEnabled: observeIsCRTEnabled(database),
teamsCount: queryMyTeams(database).observeCount(false),
channelsCount: observeCurrentTeamId(database).pipe(
hasTeams: teamsCount.pipe(
switchMap((v) => of$(v > 0)),
distinctUntilChanged(),
),
hasMoreThanOneTeam: teamsCount.pipe(
switchMap((v) => of$(v > 1)),
distinctUntilChanged(),
),
hasChannels: observeCurrentTeamId(database).pipe(
switchMap((id) => (id ? queryAllMyChannelsForTeam(database, id).observeCount(false) : of$(0))),
switchMap((v) => of$(v > 0)),
distinctUntilChanged(),
),
isLicensed,
showToS: observeShowToS(database),

View File

@@ -5,6 +5,7 @@ import {useIsFocused, useNavigation} from '@react-navigation/native';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {FlatList, LayoutChangeEvent, Platform, StyleSheet, ViewStyle} from 'react-native';
import HWKeyboardEvent from 'react-native-hw-keyboard-event';
import Animated, {useAnimatedStyle, useDerivedValue, withTiming} from 'react-native-reanimated';
import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
@@ -16,12 +17,14 @@ import FreezeScreen from '@components/freeze_screen';
import Loading from '@components/loading';
import NavigationHeader from '@components/navigation_header';
import RoundedHeaderContext from '@components/rounded_header_context';
import {Screens} from '@constants';
import {BOTTOM_TAB_HEIGHT} from '@constants/view';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useKeyboardHeight} from '@hooks/device';
import useDidUpdate from '@hooks/did_update';
import {useCollapsibleHeader} from '@hooks/header';
import NavigationStore from '@store/navigation_store';
import {FileFilter, FileFilters, filterFileExtensions} from '@utils/file';
import {TabTypes, TabType} from '@utils/search';
@@ -317,6 +320,19 @@ const SearchScreen = ({teamId, teams}: Props) => {
}
}, [isFocused]);
useEffect(() => {
const listener = HWKeyboardEvent.onHWKeyPressed((keyEvent: {pressedKey: string}) => {
const topScreen = NavigationStore.getVisibleScreen();
if (topScreen === Screens.HOME && isFocused && keyEvent.pressedKey === 'enter') {
searchRef.current?.blur();
onSubmit();
}
});
return () => {
listener.remove();
};
}, [onSubmit]);
return (
<FreezeScreen freeze={!isFocused}>
<NavigationHeader

View File

@@ -31,12 +31,13 @@ import type ChannelModel from '@typings/database/models/servers/channel';
import type PostModel from '@typings/database/models/servers/post';
import type ReactionModel from '@typings/database/models/servers/reaction';
import type UserModel from '@typings/database/models/servers/user';
import type {AvailableScreens} from '@typings/screens/navigation';
type EnhancedProps = WithDatabaseArgs & {
combinedPost?: Post | PostModel;
post: PostModel;
showAddReaction: boolean;
location: string;
sourceScreen: AvailableScreens;
serverUrl: string;
}
@@ -75,7 +76,7 @@ const withPost = withObservables([], ({post, database}: {post: Post | PostModel}
};
});
const enhanced = withObservables([], ({combinedPost, post, showAddReaction, location, database, serverUrl}: EnhancedProps) => {
const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sourceScreen, database, serverUrl}: EnhancedProps) => {
const channel = observeChannel(database, post.channelId);
const channelIsArchived = channel.pipe(switchMap((ch: ChannelModel) => of$(ch.deleteAt !== 0)));
const currentUser = observeCurrentUser(database);
@@ -112,7 +113,7 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, loca
);
const canReply = combineLatest([channelIsArchived, channelIsReadOnly, canPostPermission]).pipe(switchMap(([isArchived, isReadOnly, canPost]) => {
return of$(!isArchived && !isReadOnly && location !== Screens.THREAD && !isSystemMessage(post) && canPost);
return of$(!isArchived && !isReadOnly && sourceScreen !== Screens.THREAD && !isSystemMessage(post) && canPost);
}));
const canPin = combineLatest([channelIsArchived, channelIsReadOnly]).pipe(switchMap(([isArchived, isReadOnly]) => {

View File

@@ -1,6 +1,200 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {General, Preferences} from '@constants';
import {DMS_CATEGORY} from '@constants/categories';
import {getPreferenceAsBool} from '@helpers/api/preference';
import {isDMorGM} from '@utils/channel';
import {getUserIdFromChannelName} from '@utils/user';
import type ChannelModel from '@typings/database/models/servers/channel';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
import type PreferenceModel from '@typings/database/models/servers/preference';
import type UserModel from '@typings/database/models/servers/user';
export type ChannelWithMyChannel = {
channel: ChannelModel;
myChannel: MyChannelModel;
sortOrder: number;
}
export function makeCategoryChannelId(teamId: string, channelId: string) {
return `${teamId}_${channelId}`;
}
export const isUnreadChannel = (myChannel: MyChannelModel, notifyProps?: Partial<ChannelNotifyProps>, lastUnreadChannelId?: string) => {
const isMuted = notifyProps?.mark_unread === General.MENTION;
return (isMuted && myChannel.mentionsCount) || (!isMuted && myChannel.isUnread) || (myChannel.id === lastUnreadChannelId);
};
export const filterArchivedChannels = (channelsWithMyChannel: ChannelWithMyChannel[], currentChannelId: string) => {
return channelsWithMyChannel.filter((cwm) => cwm.channel.deleteAt === 0 || cwm.channel.id === currentChannelId);
};
export const filterAutoclosedDMs = (
categoryType: CategoryType, limit: number, currentChannelId: string,
channelsWithMyChannel: ChannelWithMyChannel[], preferences: PreferenceModel[],
notifyPropsPerChannel: Record<string, Partial<ChannelNotifyProps>>,
deactivatedDMs?: Map<string, UserModel | undefined >,
lastUnreadChannelId?: string,
) => {
if (categoryType !== DMS_CATEGORY) {
// Only autoclose DMs that haven't been assigned to a category
return channelsWithMyChannel;
}
const prefMap = preferences.reduce((acc, v) => {
const existing = acc.get(v.name);
acc.set(v.name, Math.max((v.value as unknown as number) || 0, existing || 0));
return acc;
}, new Map<string, number>());
const getLastViewedAt = (cwm: ChannelWithMyChannel) => {
// The server only ever sets the last_viewed_at to the time of the last post in channel, so we may need
// to use the preferences added for the previous version of autoclosing DMs.
const id = cwm.channel.id;
return Math.max(
cwm.myChannel.lastViewedAt,
prefMap.get(id) || 0,
);
};
let unreadCount = 0;
let visibleChannels = channelsWithMyChannel.filter((cwm) => {
const {channel, myChannel} = cwm;
if (myChannel.isUnread) {
unreadCount++;
// Unread DMs/GMs are always visible
return true;
}
if (channel.id === currentChannelId) {
return true;
}
// DMs with deactivated users will be visible if you're currently viewing them and they were opened
// since the user was deactivated
if (channel.type === General.DM_CHANNEL) {
const lastViewedAt = getLastViewedAt(cwm);
const teammate = deactivatedDMs?.get(channel.id);
if (teammate && teammate.deleteAt > lastViewedAt) {
return false;
}
}
return true;
});
visibleChannels.sort((cwmA, cwmB) => {
const channelA = cwmA.channel;
const channelB = cwmB.channel;
const myChannelA = cwmA.myChannel;
const myChannelB = cwmB.myChannel;
// Should always prioritise the current channel
if (channelA.id === currentChannelId) {
return -1;
} else if (channelB.id === currentChannelId) {
return 1;
}
// Second priority is for unread channels
const isUnreadA = isUnreadChannel(myChannelA, notifyPropsPerChannel[myChannelA.id], lastUnreadChannelId);
const isUnreadB = isUnreadChannel(myChannelB, notifyPropsPerChannel[myChannelB.id], lastUnreadChannelId);
if (isUnreadA && !isUnreadB) {
return -1;
} else if (isUnreadB && !isUnreadA) {
return 1;
}
// Third priority is last_viewed_at
const channelAlastViewed = getLastViewedAt(cwmA) || 0;
const channelBlastViewed = getLastViewedAt(cwmB) || 0;
if (channelAlastViewed > channelBlastViewed) {
return -1;
} else if (channelBlastViewed > channelAlastViewed) {
return 1;
}
return 0;
});
// The limit of DMs user specifies to be rendered in the sidebar
const remaining = Math.max(limit, unreadCount);
visibleChannels = visibleChannels.slice(0, remaining);
return visibleChannels;
};
export const filterManuallyClosedDms = (
channelsWithMyChannel: ChannelWithMyChannel[],
notifyPropsPerChannel: Record<string, Partial<ChannelNotifyProps>>,
preferences: PreferenceModel[],
currentUserId: string,
lastUnreadChannelId?: string,
) => {
return channelsWithMyChannel.filter((cwm) => {
const {channel, myChannel} = cwm;
if (!isDMorGM(channel)) {
return true;
} else if (!myChannel.lastPostAt) {
// If the direct channel does not have posts we hide it
return false;
}
if (isUnreadChannel(myChannel, notifyPropsPerChannel[myChannel.id], lastUnreadChannelId)) {
// Unread DMs/GMs are always visible
return true;
}
if (channel.type === General.DM_CHANNEL) {
const teammateId = getUserIdFromChannelName(currentUserId, channel.name);
return getPreferenceAsBool(preferences, Preferences.CATEGORIES.DIRECT_CHANNEL_SHOW, teammateId, true);
}
return getPreferenceAsBool(preferences, Preferences.CATEGORIES.GROUP_CHANNEL_SHOW, channel.id, true);
});
};
const sortChannelsByName = (notifyPropsPerChannel: Record<string, Partial<ChannelNotifyProps>>, locale: string) => {
return (a: ChannelWithMyChannel, b: ChannelWithMyChannel) => {
// Sort muted channels last
const aMuted = notifyPropsPerChannel[a.channel.id]?.mark_unread === General.MENTION;
const bMuted = notifyPropsPerChannel[b.channel.id]?.mark_unread === General.MENTION;
if (aMuted && !bMuted) {
return 1;
} else if (!aMuted && bMuted) {
return -1;
}
// And then sort alphabetically
return a.channel.displayName.localeCompare(b.channel.displayName, locale, {numeric: true});
};
};
export const sortChannels = (sorting: CategorySorting, channelsWithMyChannel: ChannelWithMyChannel[], notifyPropsPerChannel: Record<string, Partial<ChannelNotifyProps>>, locale: string) => {
if (sorting === 'recent') {
return channelsWithMyChannel.sort((cwmA, cwmB) => {
return cwmB.myChannel.lastPostAt - cwmA.myChannel.lastPostAt;
}).map((cwm) => cwm.channel);
} else if (sorting === 'manual') {
return channelsWithMyChannel.sort((cwmA, cwmB) => {
return cwmA.sortOrder - cwmB.sortOrder;
}).map((cwm) => cwm.channel);
}
const sortByName = sortChannelsByName(notifyPropsPerChannel, locale);
return channelsWithMyChannel.sort(sortByName).map((cwm) => cwm.channel);
};
export const getUnreadIds = (cwms: ChannelWithMyChannel[], notifyPropsPerChannel: Record<string, Partial<ChannelNotifyProps>>, lastUnreadId?: string) => {
return cwms.reduce<Set<string>>((result, cwm) => {
if (isUnreadChannel(cwm.myChannel, notifyPropsPerChannel, lastUnreadId)) {
result.add(cwm.channel.id);
}
return result;
}, new Set());
};

View File

@@ -3,10 +3,10 @@
import {isMinimumServerVersion} from './helpers';
export function hasReliableWebsocket(config: ClientConfig) {
if (isMinimumServerVersion(config.Version, 6, 5)) {
export function hasReliableWebsocket(version?: string, reliableWebsocketsConfig?: string) {
if (version && isMinimumServerVersion(version, 6, 5)) {
return true;
}
return config.EnableReliableWebSockets === 'true';
return reliableWebsocketsConfig === 'true';
}

View File

@@ -200,7 +200,7 @@ export function doesMatchNamedEmoji(emojiName: string) {
return false;
}
export const getEmojiFirstAlias = (emoji: string) => {
export const getEmojiFirstAlias = (emoji: string): string => {
return getEmojiByName(emoji, [])?.short_names?.[0] || emoji;
};

View File

@@ -83,8 +83,9 @@ class NotificationService: UNNotificationServiceExtension {
let isCRTEnabled = notification.userInfo["is_crt_enabled"] as? Bool ?? false
let rootId = notification.userInfo["root_id"] as? String ?? ""
let channelName = notification.userInfo["channel_name"] as? String ?? ""
let message = (notification.userInfo["message"] as? String ?? "")
let senderName = notification.userInfo["sender_name"] as? String
let channelName = notification.userInfo["channel_name"] as? String
var message = (notification.userInfo["message"] as? String ?? "")
let overrideUsername = notification.userInfo["override_username"] as? String
let senderId = notification.userInfo["sender_id"] as? String
let senderIdentifier = overrideUsername ?? senderId
@@ -94,11 +95,18 @@ class NotificationService: UNNotificationServiceExtension {
if isCRTEnabled && !rootId.isEmpty {
conversationId = rootId
}
if channelName == nil && message == "",
let senderName = senderName,
let body = bestAttemptContent?.body {
message = body.replacingOccurrences(of: "\(senderName) ", with: "")
bestAttemptContent?.body = message
}
let handle = INPersonHandle(value: senderIdentifier, type: .unknown)
let sender = INPerson(personHandle: handle,
nameComponents: nil,
displayName: channelName,
displayName: channelName ?? senderName,
image: avatar,
contactIdentifier: nil,
customIdentifier: nil)

View File

@@ -54,7 +54,7 @@ target 'Mattermost' do
pod 'simdjson', path: '../node_modules/@nozbe/simdjson'
# TODO: Remove this once upstream PR https://github.com/daltoniam/Starscream/pull/871 is merged
pod 'Starscream', :git => 'https://github.com/mattermost/Starscream.git', :commit => '2770c931b2758f26e29b937d547a23122e9c6583'
pod 'Starscream', :git => 'https://github.com/mattermost/Starscream.git', :commit => '9575b6781d1262247096af73617ae3acb2b139a0'
end

View File

@@ -709,7 +709,7 @@ DEPENDENCIES:
- RNSVG (from `../node_modules/react-native-svg`)
- RNVectorIcons (from `../node_modules/react-native-vector-icons`)
- "simdjson (from `../node_modules/@nozbe/simdjson`)"
- Starscream (from `https://github.com/mattermost/Starscream.git`, commit `2770c931b2758f26e29b937d547a23122e9c6583`)
- Starscream (from `https://github.com/mattermost/Starscream.git`, commit `9575b6781d1262247096af73617ae3acb2b139a0`)
- "WatermelonDB (from `../node_modules/@nozbe/watermelondb`)"
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
@@ -908,7 +908,7 @@ EXTERNAL SOURCES:
simdjson:
:path: "../node_modules/@nozbe/simdjson"
Starscream:
:commit: 2770c931b2758f26e29b937d547a23122e9c6583
:commit: 9575b6781d1262247096af73617ae3acb2b139a0
:git: https://github.com/mattermost/Starscream.git
WatermelonDB:
:path: "../node_modules/@nozbe/watermelondb"
@@ -917,7 +917,7 @@ EXTERNAL SOURCES:
CHECKOUT OPTIONS:
Starscream:
:commit: 2770c931b2758f26e29b937d547a23122e9c6583
:commit: 9575b6781d1262247096af73617ae3acb2b139a0
:git: https://github.com/mattermost/Starscream.git
SPEC CHECKSUMS:
@@ -1033,6 +1033,6 @@ SPEC CHECKSUMS:
Yoga: 5ed1699acbba8863755998a4245daa200ff3817b
YogaKit: f782866e155069a2cca2517aafea43200b01fd5a
PODFILE CHECKSUM: 9f76739ed16bbdc0f4b1049ecb366fb5d23a0f3a
PODFILE CHECKSUM: 831b649321a4d14a86a074af619aa779ebc048c4
COCOAPODS: 1.11.3

14
package-lock.json generated
View File

@@ -19,7 +19,7 @@
"@gorhom/bottom-sheet": "4.4.5",
"@mattermost/compass-icons": "0.1.35",
"@mattermost/react-native-emm": "1.3.5",
"@mattermost/react-native-network-client": "1.3.1",
"@mattermost/react-native-network-client": "1.3.2",
"@mattermost/react-native-paste-input": "0.6.2",
"@mattermost/react-native-turbo-log": "0.2.3",
"@mattermost/react-native-turbo-mailer": "0.2.4",
@@ -3241,9 +3241,9 @@
}
},
"node_modules/@mattermost/react-native-network-client": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@mattermost/react-native-network-client/-/react-native-network-client-1.3.1.tgz",
"integrity": "sha512-DtwVLV/NUE6MkXOlVZG+4QJXou6nHMdmsxnP1+RqhOeSw5jJlQvxmQgxzxvxLpaWOag+wgB1zpDulGNbr/Cz6Q==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@mattermost/react-native-network-client/-/react-native-network-client-1.3.2.tgz",
"integrity": "sha512-3GFNzMXZWlIXXDYQLIJlKRf+HUZKP0F7mpZ1rSTgoTmUeFdqde4uRiU/L96COg34rAdeFRFrgpk0DxEnT7NiVg==",
"dependencies": {
"validator": "13.9.0",
"zod": "3.20.6"
@@ -24297,9 +24297,9 @@
"requires": {}
},
"@mattermost/react-native-network-client": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@mattermost/react-native-network-client/-/react-native-network-client-1.3.1.tgz",
"integrity": "sha512-DtwVLV/NUE6MkXOlVZG+4QJXou6nHMdmsxnP1+RqhOeSw5jJlQvxmQgxzxvxLpaWOag+wgB1zpDulGNbr/Cz6Q==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@mattermost/react-native-network-client/-/react-native-network-client-1.3.2.tgz",
"integrity": "sha512-3GFNzMXZWlIXXDYQLIJlKRf+HUZKP0F7mpZ1rSTgoTmUeFdqde4uRiU/L96COg34rAdeFRFrgpk0DxEnT7NiVg==",
"requires": {
"validator": "13.9.0",
"zod": "3.20.6"

View File

@@ -16,7 +16,7 @@
"@gorhom/bottom-sheet": "4.4.5",
"@mattermost/compass-icons": "0.1.35",
"@mattermost/react-native-emm": "1.3.5",
"@mattermost/react-native-network-client": "1.3.1",
"@mattermost/react-native-network-client": "1.3.2",
"@mattermost/react-native-paste-input": "0.6.2",
"@mattermost/react-native-turbo-log": "0.2.3",
"@mattermost/react-native-turbo-mailer": "0.2.4",

View File

@@ -25,7 +25,7 @@ declare class CategoryModel extends Model {
displayName: string;
/** type : The type of category */
type: string;
type: CategoryType;
/** sort_order : The sort order for this category */
sortOrder: number;