[MM-49540] Message Priority Phase 3 (#7142)

* Init

* i18 and types

* Acknowledge button, api

* Ack button + display ackd users

* Saves priority on draft and addresses some comments

* Addresses review comments round 2

* Moves fetching userprofiles upon opening ACKs

* Adds metadata column in drafts table

+ Addresses some more review comments.

* Small refactor according to review comments

* Addresses some review comments

* Addresses some review comments

* Uses local action when ACKing

* Fixes first time selecting priority and other

* Updates snapshots

* Fixes i18n

* Fixes ts errors

---------

Co-authored-by: Anurag Shivarathri <anurag6713@gmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Kyriakos Z
2023-04-27 11:22:03 +00:00
committed by GitHub
parent e6254885ee
commit ab4f65020a
54 changed files with 2029 additions and 575 deletions

View File

@@ -155,3 +155,37 @@ export const removeDraft = async (serverUrl: string, channelId: string, rootId =
return {error};
}
};
export async function updateDraftPriority(serverUrl: string, channelId: string, rootId: string, postPriority: PostPriority, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const draft = await getDraft(database, channelId, rootId);
if (!draft) {
const newDraft: Draft = {
channel_id: channelId,
root_id: rootId,
metadata: {
priority: postPriority,
},
};
return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly});
}
draft.prepareUpdate((d) => {
d.metadata = {
...d.metadata,
priority: postPriority,
};
});
if (!prepareRecordsOnly) {
await operator.batchRecords([draft], 'updateDraftPriority');
}
return {draft};
} catch (error) {
logError('Failed updateDraftPriority', error);
return {error};
}
}

View File

@@ -5,7 +5,7 @@ import {fetchPostAuthors} from '@actions/remote/post';
import {ActionType, Post} from '@constants';
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getPostById, prepareDeletePost, queryPostsById} from '@queries/servers/post';
import {countUsersFromMentions, getPostById, prepareDeletePost, queryPostsById} from '@queries/servers/post';
import {getCurrentUserId} from '@queries/servers/system';
import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread';
import {generateId} from '@utils/general';
@@ -249,6 +249,72 @@ export async function getPosts(serverUrl: string, ids: string[], sort?: Q.SortOr
}
}
export async function addPostAcknowledgement(serverUrl: string, postId: string, userId: string, acknowledgedAt: number, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const post = await getPostById(database, postId);
if (!post) {
throw new Error('Post not found');
}
// Check if the post has already been acknowledged by the user
const isAckd = post.metadata?.acknowledgements?.find((a) => a.user_id === userId);
if (isAckd) {
return {error: false};
}
const acknowledgements = [...(post.metadata?.acknowledgements || []), {
user_id: userId,
acknowledged_at: acknowledgedAt,
post_id: postId,
}];
const model = post.prepareUpdate((p) => {
p.metadata = {
...p.metadata,
acknowledgements,
};
});
if (!prepareRecordsOnly) {
await operator.batchRecords([model], 'addPostAcknowledgement');
}
return {model};
} catch (error) {
logError('Failed addPostAcknowledgement', error);
return {error};
}
}
export async function removePostAcknowledgement(serverUrl: string, postId: string, userId: string, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const post = await getPostById(database, postId);
if (!post) {
throw new Error('Post not found');
}
const model = post.prepareUpdate((record) => {
record.metadata = {
...post.metadata,
acknowledgements: post.metadata?.acknowledgements?.filter(
(a) => a.user_id !== userId,
) || [],
};
});
if (!prepareRecordsOnly) {
await operator.batchRecords([model], 'removePostAcknowledgement');
}
return {model};
} catch (error) {
logError('Failed removePostAcknowledgement', error);
return {error};
}
}
export async function deletePosts(serverUrl: string, postIds: string[]) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
@@ -276,3 +342,12 @@ export async function deletePosts(serverUrl: string, postIds: string[]) {
return {error};
}
}
export function getUsersCountFromMentions(serverUrl: string, mentions: string[]): Promise<number> {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
return countUsersFromMentions(database, mentions);
} catch (error) {
return Promise.resolve(0);
}
}

View File

@@ -7,7 +7,7 @@
import {DeviceEventEmitter} from 'react-native';
import {markChannelAsUnread, updateLastPostAt} from '@actions/local/channel';
import {removePost, storePostsForChannel} from '@actions/local/post';
import {addPostAcknowledgement, removePost, removePostAcknowledgement, storePostsForChannel} from '@actions/local/post';
import {addRecentReaction} from '@actions/local/reactions';
import {createThreadFromNewPost} from '@actions/local/thread';
import {ActionType, Events, General, Post, ServerErrors} from '@constants';
@@ -23,6 +23,7 @@ import {getPostById, getRecentPostsInChannel} from '@queries/servers/post';
import {getCurrentUserId, getCurrentChannelId} from '@queries/servers/system';
import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread';
import {queryAllUsers} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store';
import {setFetchingThreadState} from '@store/fetching_thread_store';
import {getValidEmojis, matchEmoticons} from '@utils/emoji/helpers';
import {isServerError} from '@utils/errors';
@@ -500,41 +501,32 @@ export async function fetchPostsSince(serverUrl: string, channelId: string, sinc
}
export const fetchPostAuthors = async (serverUrl: string, posts: Post[], fetchOnly = false): Promise<AuthorsRequest> => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const client = NetworkManager.getClient(serverUrl);
const currentUserId = await getCurrentUserId(operator.database);
const users = await queryAllUsers(operator.database).fetch();
const existingUserIds = new Set<string>();
const existingUserNames = new Set<string>();
let excludeUsername;
users.forEach((u) => {
existingUserIds.add(u.id);
existingUserNames.add(u.username);
if (u.id === currentUserId) {
excludeUsername = u.username;
const currentUserId = await getCurrentUserId(database);
const users = await queryAllUsers(database).fetch();
const existingUserIds = new Set<string>();
const existingUserNames = new Set<string>();
let excludeUsername;
users.forEach((u) => {
existingUserIds.add(u.id);
existingUserNames.add(u.username);
if (u.id === currentUserId) {
excludeUsername = u.username;
}
});
const usernamesToLoad = getNeededAtMentionedUsernames(existingUserNames, posts, excludeUsername);
const userIdsToLoad = new Set<string>();
for (const p of posts) {
const {user_id} = p;
if (user_id !== currentUserId) {
userIdsToLoad.add(user_id);
}
}
});
const usernamesToLoad = getNeededAtMentionedUsernames(existingUserNames, posts, excludeUsername);
const userIdsToLoad = new Set<string>();
for (const p of posts) {
const {user_id} = p;
if (user_id !== currentUserId) {
userIdsToLoad.add(user_id);
}
}
try {
const promises: Array<Promise<UserProfile[]>> = [];
if (userIdsToLoad.size) {
promises.push(client.getProfilesByIds(Array.from(userIdsToLoad)));
@@ -1130,3 +1122,38 @@ export async function fetchPinnedPosts(serverUrl: string, channelId: string) {
return {error};
}
}
export async function acknowledgePost(serverUrl: string, postId: string) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const client = NetworkManager.getClient(serverUrl);
EphemeralStore.setAcknowledgingPost(postId);
const userId = await getCurrentUserId(database);
const {acknowledged_at: acknowledgedAt} = await client.acknowledgePost(postId, userId);
return addPostAcknowledgement(serverUrl, postId, userId, acknowledgedAt, false);
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
} finally {
EphemeralStore.unsetAcknowledgingPost(postId);
}
}
export async function unacknowledgePost(serverUrl: string, postId: string) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const client = NetworkManager.getClient(serverUrl);
EphemeralStore.setUnacknowledgingPost(postId);
const userId = await getCurrentUserId(database);
await client.unacknowledgePost(postId, userId);
return removePostAcknowledgement(serverUrl, postId, userId, false);
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
} finally {
EphemeralStore.unsetUnacknowledgingPost(postId);
}
}

View File

@@ -62,7 +62,7 @@ import {handleChannelConvertedEvent, handleChannelCreatedEvent,
handleUserRemovedFromChannelEvent} from './channel';
import {handleGroupMemberAddEvent, handleGroupMemberDeleteEvent, handleGroupReceivedEvent, handleGroupTeamAssociatedEvent, handleGroupTeamDissociateEvent} from './group';
import {handleOpenDialogEvent} from './integrations';
import {handleNewPostEvent, handlePostDeleted, handlePostEdited, handlePostUnread} from './posts';
import {handleNewPostEvent, handlePostAcknowledgementAdded, handlePostAcknowledgementRemoved, handlePostDeleted, handlePostEdited, handlePostUnread} from './posts';
import {handlePreferenceChangedEvent, handlePreferencesChangedEvent, handlePreferencesDeletedEvent} from './preferences';
import {handleAddCustomEmoji, handleReactionRemovedFromPostEvent, handleReactionAddedToPostEvent} from './reactions';
import {handleUserRoleUpdatedEvent, handleTeamMemberRoleUpdatedEvent, handleRoleUpdatedEvent} from './roles';
@@ -177,6 +177,13 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) {
handlePostUnread(serverUrl, msg);
break;
case WebsocketEvents.POST_ACKNOWLEDGEMENT_ADDED:
handlePostAcknowledgementAdded(serverUrl, msg);
break;
case WebsocketEvents.POST_ACKNOWLEDGEMENT_REMOVED:
handlePostAcknowledgementRemoved(serverUrl, msg);
break;
case WebsocketEvents.LEAVE_TEAM:
handleLeaveTeamEvent(serverUrl, msg);
break;

View File

@@ -4,11 +4,12 @@
import {DeviceEventEmitter} from 'react-native';
import {storeMyChannelsForTeam, markChannelAsUnread, markChannelAsViewed, updateLastPostAt} from '@actions/local/channel';
import {markPostAsDeleted} from '@actions/local/post';
import {addPostAcknowledgement, markPostAsDeleted, removePostAcknowledgement} from '@actions/local/post';
import {createThreadFromNewPost, updateThread} from '@actions/local/thread';
import {fetchChannelStats, fetchMyChannel} from '@actions/remote/channel';
import {fetchPostAuthors, fetchPostById} from '@actions/remote/post';
import {fetchThread} from '@actions/remote/thread';
import {fetchMissingProfilesByIds} from '@actions/remote/user';
import {ActionType, Events, Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {getChannelById, getMyChannel} from '@queries/servers/channel';
@@ -326,3 +327,41 @@ export async function handlePostUnread(serverUrl: string, msg: WebSocketMessage)
markChannelAsUnread(serverUrl, channelId, delta, mentions, lastViewedAt);
}
}
export async function handlePostAcknowledgementAdded(serverUrl: string, msg: WebSocketMessage) {
try {
const acknowledgement = JSON.parse(msg.data.acknowledgement);
const {user_id, post_id, acknowledged_at} = acknowledgement;
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
const currentUserId = getCurrentUserId(database);
if (EphemeralStore.isAcknowledgingPost(post_id) && currentUserId === user_id) {
return;
}
addPostAcknowledgement(serverUrl, post_id, user_id, acknowledged_at);
fetchMissingProfilesByIds(serverUrl, [user_id]);
} catch (error) {
// Do nothing
}
}
export async function handlePostAcknowledgementRemoved(serverUrl: string, msg: WebSocketMessage) {
try {
const acknowledgement = JSON.parse(msg.data.acknowledgement);
const {user_id, post_id} = acknowledgement;
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
const currentUserId = getCurrentUserId(database);
if (EphemeralStore.isUnacknowledgingPost(post_id) && currentUserId === user_id) {
return;
}
await removePostAcknowledgement(serverUrl, post_id, user_id);
} catch (error) {
// Do nothing
}
}

View File

@@ -31,6 +31,8 @@ export interface ClientPostsMix {
searchPosts: (teamId: string, terms: string, isOrSearch: boolean) => Promise<PostResponse>;
doPostAction: (postId: string, actionId: string, selectedOption?: string) => Promise<any>;
doPostActionWithCookie: (postId: string, actionId: string, actionCookie: string, selectedOption?: string) => Promise<any>;
acknowledgePost: (postId: string, userId: string) => Promise<PostAcknowledgement>;
unacknowledgePost: (postId: string, userId: string) => Promise<any>;
}
const ClientPosts = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
@@ -240,6 +242,20 @@ const ClientPosts = <TBase extends Constructor<ClientBase>>(superclass: TBase) =
{method: 'post', body: msg},
);
};
acknowledgePost = async (postId: string, userId: string) => {
return this.doFetch(
`${this.getUserRoute(userId)}/posts/${postId}/ack`,
{method: 'post'},
);
};
unacknowledgePost = async (postId: string, userId: string) => {
return this.doFetch(
`${this.getUserRoute(userId)}/posts/${postId}/ack`,
{method: 'delete'},
);
};
};
export default ClientPosts;

View File

@@ -39,11 +39,23 @@ const enhance = withObservables(['channel', 'showTeamName', 'shouldHighlightActi
const currentUserId = observeCurrentUserId(database);
const myChannel = observeMyChannel(database, channel.id);
const hasDraft = shouldHighlightState ?
queryDraft(database, channel.id).observeWithColumns(['message', 'files']).pipe(
switchMap((draft) => of$(draft.length > 0)),
distinctUntilChanged(),
) : of$(false);
const hasDraft = shouldHighlightState ? queryDraft(database, channel.id).observeWithColumns(['message', 'files', 'metadata']).pipe(
switchMap((drafts) => {
if (!drafts.length) {
return of$(false);
}
const draft = drafts[0];
const standardPriority = draft?.metadata?.priority?.priority === '';
if (!draft.message && !draft.files.length && standardPriority) {
return of$(false);
}
return of$(true);
}),
distinctUntilChanged(),
) : of$(false);
const isActive = shouldHighlightActive ?
observeCurrentChannelId(database).pipe(

View File

@@ -27,7 +27,7 @@ const OptionType = {
...TouchableOptionTypes,
} as const;
type OptionType = typeof OptionType[keyof typeof OptionType];
export type OptionType = typeof OptionType[keyof typeof OptionType];
export const ITEM_HEIGHT = 48;
@@ -108,6 +108,7 @@ export type OptionItemProps = {
info?: string;
inline?: boolean;
label: string;
labelContainerStyle?: StyleProp<ViewStyle>;
onRemove?: () => void;
optionDescriptionTextStyle?: StyleProp<TextStyle>;
optionLabelTextStyle?: StyleProp<TextStyle>;
@@ -130,6 +131,7 @@ const OptionItem = ({
info,
inline = false,
label,
labelContainerStyle,
onRemove,
optionDescriptionTextStyle,
optionLabelTextStyle,
@@ -238,7 +240,7 @@ const OptionItem = ({
onLayout={onLayout}
>
<View style={styles.row}>
<View style={styles.labelContainer}>
<View style={[styles.labelContainer, labelContainerStyle]}>
{Boolean(icon) && (
<View style={styles.iconContainer}>
<OptionIcon

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Platform, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import PostPriorityLabel from '@components/post_priority/post_priority_label';
import {PostPriorityColors} from '@constants/post';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
postPriority: PostPriority;
noMentionsError: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: 12,
gap: 7,
},
error: {
color: PostPriorityColors.URGENT,
},
acknowledgements: {
color: theme.onlineIndicator,
},
paddingTopStyle: {
paddingTop: Platform.select({ios: 6, android: 8}),
},
}));
export default function DraftInputHeader({
postPriority,
noMentionsError,
}: Props) {
const theme = useTheme();
const hasLabels = postPriority.priority !== '' || postPriority.requested_ack;
const style = getStyleSheet(theme);
return (
<View style={[style.container, hasLabels ? style.paddingTopStyle : undefined]}>
{postPriority.priority && (
<PostPriorityLabel label={postPriority.priority}/>
)}
{postPriority.requested_ack && (
<>
<CompassIcon
color={theme.onlineIndicator}
name='check-circle-outline'
size={14}
/>
{!postPriority.priority && (
<FormattedText
id='requested_ack.title'
defaultMessage='Request Acknowledgements'
style={{color: theme.onlineIndicator}}
/>
)}
</>
)}
{postPriority.persistent_notifications && (
<>
<CompassIcon
color={PostPriorityColors.URGENT}
name='bell-ring-outline'
size={14}
/>
{noMentionsError && (
<FormattedText
id='persistent_notifications.error.no_mentions.title'
defaultMessage='Recipients must be @mentioned'
style={style.error}
/>
)}
</>
)}
</View>
);
}

View File

@@ -1,12 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef} from 'react';
import React, {useCallback, useMemo, useRef} from 'react';
import {useIntl} from 'react-intl';
import {type LayoutChangeEvent, Platform, ScrollView, View} from 'react-native';
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
import PostPriorityLabel from '@components/post_priority/post_priority_label';
import {General} from '@constants';
import {MENTIONS_REGEX} from '@constants/autocomplete';
import {PostPriorityType} from '@constants/post';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {persistentNotificationsConfirmation} from '@utils/post';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import PostInput from '../post_input';
@@ -15,18 +20,23 @@ import SendAction from '../send_action';
import Typing from '../typing';
import Uploads from '../uploads';
import Header from './header';
import type {PasteInputRef} from '@mattermost/react-native-paste-input';
type Props = {
testID?: string;
channelId: string;
channelType?: ChannelType;
rootId?: string;
currentUserId: string;
canShowPostPriority?: boolean;
// Post Props
postPriority: PostPriorityData;
updatePostPriority: (postPriority: PostPriorityData) => void;
postPriority: PostPriority;
updatePostPriority: (postPriority: PostPriority) => void;
persistentNotificationInterval: number;
persistentNotificationMaxRecipients: number;
// Cursor Position Handler
updateCursorPosition: React.Dispatch<React.SetStateAction<number>>;
@@ -97,6 +107,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
export default function DraftInput({
testID,
channelId,
channelType,
currentUserId,
canShowPostPriority,
files,
@@ -113,8 +124,12 @@ export default function DraftInput({
updatePostInputTop,
postPriority,
updatePostPriority,
persistentNotificationInterval,
persistentNotificationMaxRecipients,
setIsFocused,
}: Props) {
const intl = useIntl();
const serverUrl = useServerUrl();
const theme = useTheme();
const handleLayout = useCallback((e: LayoutChangeEvent) => {
@@ -132,6 +147,31 @@ export default function DraftInput({
const sendActionTestID = `${testID}.send_action`;
const style = getStyleSheet(theme);
const persistenNotificationsEnabled = postPriority.persistent_notifications && postPriority.priority === PostPriorityType.URGENT;
const {noMentionsError, mentionsList} = useMemo(() => {
let error = false;
let mentions: string[] = [];
if (
channelType !== General.DM_CHANNEL &&
persistenNotificationsEnabled
) {
mentions = (value.match(MENTIONS_REGEX) || []);
error = mentions.length === 0;
}
return {noMentionsError: error, mentionsList: mentions};
}, [channelType, persistenNotificationsEnabled, value]);
const handleSendMessage = useCallback(async () => {
if (persistenNotificationsEnabled) {
persistentNotificationsConfirmation(serverUrl, value, mentionsList, intl, sendMessage, persistentNotificationMaxRecipients, persistentNotificationInterval);
} else {
sendMessage();
}
}, [serverUrl, mentionsList, persistenNotificationsEnabled, persistentNotificationMaxRecipients, sendMessage, value]);
const sendActionDisabled = !canSend || noMentionsError;
return (
<>
<Typing
@@ -156,11 +196,10 @@ export default function DraftInput({
overScrollMode={'never'}
disableScrollViewPanResponder={true}
>
{Boolean(postPriority?.priority) && (
<View style={style.postPriorityLabel}>
<PostPriorityLabel label={postPriority!.priority}/>
</View>
)}
<Header
noMentionsError={noMentionsError}
postPriority={postPriority}
/>
<PostInput
testID={postInputTestID}
channelId={channelId}
@@ -171,7 +210,7 @@ export default function DraftInput({
updateValue={updateValue}
value={value}
addFiles={addFiles}
sendMessage={sendMessage}
sendMessage={handleSendMessage}
inputRef={inputRef}
setIsFocused={setIsFocused}
/>
@@ -196,8 +235,8 @@ export default function DraftInput({
/>
<SendAction
testID={sendActionTestID}
disabled={!canSend}
sendMessage={sendMessage}
disabled={sendActionDisabled}
sendMessage={handleSendMessage}
/>
</View>
</ScrollView>

View File

@@ -9,7 +9,7 @@ import {switchMap} from 'rxjs/operators';
import {General, Permissions} from '@constants';
import {observeChannel} from '@queries/servers/channel';
import {queryDraft} from '@queries/servers/drafts';
import {queryDraft, observeFirstDraft} from '@queries/servers/drafts';
import {observePermissionForChannel} from '@queries/servers/role';
import {observeConfigBooleanValue, observeCurrentChannelId} from '@queries/servers/system';
import {observeCurrentUser, observeUser} from '@queries/servers/user';
@@ -18,7 +18,6 @@ import {isSystemAdmin, getUserIdFromChannelName} from '@utils/user';
import PostDraft from './post_draft';
import type {WithDatabaseArgs} from '@typings/database/database';
import type DraftModel from '@typings/database/models/servers/draft';
type OwnProps = {
channelId: string;
@@ -26,8 +25,6 @@ type OwnProps = {
rootId?: string;
}
const observeFirst = (v: DraftModel[]) => v[0]?.observe() || of$(undefined);
const enhanced = withObservables(['channelId', 'rootId', 'channelIsArchived'], (ownProps: WithDatabaseArgs & OwnProps) => {
const {database, rootId = ''} = ownProps;
let channelId = of$(ownProps.channelId);
@@ -36,8 +33,8 @@ const enhanced = withObservables(['channelId', 'rootId', 'channelIsArchived'], (
}
const draft = channelId.pipe(
switchMap((cId) => queryDraft(database, cId, rootId).observeWithColumns(['message', 'files']).pipe(
switchMap(observeFirst),
switchMap((cId) => queryDraft(database, cId, rootId).observeWithColumns(['message', 'files', 'metadata']).pipe(
switchMap(observeFirstDraft),
)),
);

View File

@@ -5,7 +5,8 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React from 'react';
import {observeCanUploadFiles, observeIsPostPriorityEnabled, observeMaxFileCount} from '@queries/servers/system';
import {observeIsPostPriorityEnabled} from '@queries/servers/post';
import {observeCanUploadFiles, observeMaxFileCount} from '@queries/servers/system';
import QuickActions from './quick_actions';

View File

@@ -3,22 +3,21 @@
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {StyleSheet} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {Keyboard, StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import PostPriorityPicker, {COMPONENT_HEIGHT} from '@components/post_priority/post_priority_picker';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Screens} from '@constants';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {bottomSheet, dismissBottomSheet} from '@screens/navigation';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {useIsTablet} from '@hooks/device';
import {openAsBottomSheet} from '@screens/navigation';
import {changeOpacity} from '@utils/theme';
type Props = {
testID?: string;
postPriority: PostPriorityData;
updatePostPriority: (postPriority: PostPriorityData) => void;
postPriority: PostPriority;
updatePostPriority: (postPriority: PostPriority) => void;
}
const style = StyleSheet.create({
@@ -29,40 +28,34 @@ const style = StyleSheet.create({
},
});
const POST_PRIORITY_PICKER_BUTTON = 'close-post-priority-picker';
export default function PostPriorityAction({
testID,
postPriority,
updatePostPriority,
}: Props) {
const intl = useIntl();
const isTablet = useIsTablet();
const theme = useTheme();
const {bottom} = useSafeAreaInsets();
const handlePostPriorityPicker = useCallback((postPriorityData: PostPriorityData) => {
updatePostPriority(postPriorityData);
dismissBottomSheet();
}, [updatePostPriority]);
const renderContent = useCallback(() => {
return (
<PostPriorityPicker
data={{
priority: postPriority?.priority || '',
}}
onSubmit={handlePostPriorityPicker}
/>
);
}, [handlePostPriorityPicker, postPriority]);
const onPress = useCallback(() => {
bottomSheet({
title: intl.formatMessage({id: 'post_priority.picker.title', defaultMessage: 'Message priority'}),
renderContent,
snapPoints: [1, bottomSheetSnapPoint(1, COMPONENT_HEIGHT, bottom)],
Keyboard.dismiss();
const title = isTablet ? intl.formatMessage({id: 'post_priority.picker.title', defaultMessage: 'Message priority'}) : '';
openAsBottomSheet({
closeButtonId: POST_PRIORITY_PICKER_BUTTON,
screen: Screens.POST_PRIORITY_PICKER,
theme,
closeButtonId: 'post-priority-close-id',
title,
props: {
postPriority,
updatePostPriority,
closeButtonId: POST_PRIORITY_PICKER_BUTTON,
},
});
}, [intl, renderContent, theme, bottom]);
}, [intl, postPriority, updatePostPriority, theme]);
const iconName = 'alert-circle-outline';
const iconColor = changeOpacity(theme.centerChannelColor, 0.64);

View File

@@ -22,8 +22,8 @@ type Props = {
value: string;
updateValue: (value: string) => void;
addFiles: (file: FileInfo[]) => void;
postPriority: PostPriorityData;
updatePostPriority: (postPriority: PostPriorityData) => void;
postPriority: PostPriority;
updatePostPriority: (postPriority: PostPriority) => void;
focus: () => void;
}

View File

@@ -10,11 +10,12 @@ import {General, Permissions} from '@constants';
import {MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft';
import {observeChannel, observeChannelInfo, observeCurrentChannel} from '@queries/servers/channel';
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
import {observeFirstDraft, queryDraft} from '@queries/servers/drafts';
import {observePermissionForChannel} from '@queries/servers/role';
import {observeConfigBooleanValue, observeConfigIntValue, observeCurrentUserId} from '@queries/servers/system';
import {observeUser} from '@queries/servers/user';
import SendHandler from './send_handler';
import SendHandler, {INITIAL_PRIORITY} from './send_handler';
import type {WithDatabaseArgs} from '@typings/database/database';
@@ -36,15 +37,28 @@ const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) =>
const currentUserId = observeCurrentUserId(database);
const currentUser = currentUserId.pipe(
switchMap((id) => observeUser(database, id),
));
switchMap((id) => observeUser(database, id)),
);
const userIsOutOfOffice = currentUser.pipe(
switchMap((u) => of$(u?.status === General.OUT_OF_OFFICE)),
);
const postPriority = queryDraft(database, channelId, rootId).observeWithColumns(['metadata']).pipe(
switchMap(observeFirstDraft),
switchMap((d) => {
if (!d?.metadata?.priority) {
return of$(INITIAL_PRIORITY);
}
return of$(d.metadata.priority);
}),
);
const enableConfirmNotificationsToChannel = observeConfigBooleanValue(database, 'EnableConfirmNotificationsToChannel');
const isTimezoneEnabled = observeConfigBooleanValue(database, 'ExperimentalTimezone');
const maxMessageLength = observeConfigIntValue(database, 'MaxPostSize', MAX_MESSAGE_LENGTH_FALLBACK);
const persistentNotificationInterval = observeConfigIntValue(database, 'PersistentNotificationInterval');
const persistentNotificationMaxRecipients = observeConfigIntValue(database, 'PersistentNotificationMaxRecipients');
const useChannelMentions = combineLatest([channel, currentUser]).pipe(
switchMap(([c, u]) => {
@@ -57,6 +71,7 @@ const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) =>
);
const channelInfo = channel.pipe(switchMap((c) => (c ? observeChannelInfo(database, c.id) : of$(undefined))));
const channelType = channel.pipe(switchMap((c) => of$(c?.type)));
const membersCount = channelInfo.pipe(
switchMap((i) => (i ? of$(i.memberCount) : of$(0))),
);
@@ -64,6 +79,7 @@ const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) =>
const customEmojis = queryAllCustomEmojis(database).observe();
return {
channelType,
currentUserId,
enableConfirmNotificationsToChannel,
isTimezoneEnabled,
@@ -72,6 +88,9 @@ const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) =>
userIsOutOfOffice,
useChannelMentions,
customEmojis,
persistentNotificationInterval,
persistentNotificationMaxRecipients,
postPriority,
};
});

View File

@@ -5,6 +5,7 @@ import React, {useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter} from 'react-native';
import {updateDraftPriority} from '@actions/local/draft';
import {getChannelTimezones} from '@actions/remote/channel';
import {executeCommand, handleGotoLocation} from '@actions/remote/command';
import {createPost} from '@actions/remote/post';
@@ -28,6 +29,7 @@ import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji
type Props = {
testID?: string;
channelId: string;
channelType?: ChannelType;
rootId: string;
canShowPostPriority?: boolean;
setIsFocused: (isFocused: boolean) => void;
@@ -52,15 +54,21 @@ type Props = {
updatePostInputTop: (top: number) => void;
addFiles: (file: FileInfo[]) => void;
uploadFileError: React.ReactNode;
persistentNotificationInterval: number;
persistentNotificationMaxRecipients: number;
postPriority: PostPriority;
}
const INITIAL_PRIORITY = {
export const INITIAL_PRIORITY = {
priority: PostPriorityType.STANDARD,
requested_ack: false,
persistent_notifications: false,
};
export default function SendHandler({
testID,
channelId,
channelType,
currentUserId,
enableConfirmNotificationsToChannel,
files,
@@ -81,13 +89,15 @@ export default function SendHandler({
updateCursorPosition,
updatePostInputTop,
setIsFocused,
persistentNotificationInterval,
persistentNotificationMaxRecipients,
postPriority,
}: Props) {
const intl = useIntl();
const serverUrl = useServerUrl();
const [channelTimezoneCount, setChannelTimezoneCount] = useState(0);
const [sendingMessage, setSendingMessage] = useState(false);
const [postPriority, setPostPriority] = useState<PostPriorityData>(INITIAL_PRIORITY);
const canSend = useCallback(() => {
if (sendingMessage) {
@@ -114,6 +124,10 @@ export default function SendHandler({
setSendingMessage(false);
}, [serverUrl, rootId, clearDraft]);
const handlePostPriority = useCallback((priority: PostPriority) => {
updateDraftPriority(serverUrl, channelId, rootId, priority);
}, [serverUrl, rootId]);
const doSubmitMessage = useCallback(() => {
const postFiles = files.filter((f) => !f.failed);
const post = {
@@ -123,7 +137,11 @@ export default function SendHandler({
message: value,
} as Post;
if (Object.keys(postPriority).length) {
if (!rootId && (
postPriority.priority ||
postPriority.requested_ack ||
postPriority.persistent_notifications)
) {
post.metadata = {
priority: postPriority,
};
@@ -133,7 +151,6 @@ export default function SendHandler({
clearDraft();
setSendingMessage(false);
setPostPriority(INITIAL_PRIORITY);
DeviceEventEmitter.emit(Events.POST_LIST_SCROLL_TO_BOTTOM, rootId ? Screens.THREAD : Screens.CHANNEL);
}, [files, currentUserId, channelId, rootId, value, clearDraft, postPriority]);
@@ -253,6 +270,7 @@ export default function SendHandler({
<DraftInput
testID={testID}
channelId={channelId}
channelType={channelType}
currentUserId={currentUserId}
rootId={rootId}
canShowPostPriority={canShowPostPriority}
@@ -268,7 +286,9 @@ export default function SendHandler({
maxMessageLength={maxMessageLength}
updatePostInputTop={updatePostInputTop}
postPriority={postPriority}
updatePostPriority={setPostPriority}
updatePostPriority={handlePostPriority}
persistentNotificationInterval={persistentNotificationInterval}
persistentNotificationMaxRecipients={persistentNotificationMaxRecipients}
setIsFocused={setIsFocused}
/>
);

View File

@@ -8,7 +8,7 @@ import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
import {observeSavedPostsByIds} from '@queries/servers/post';
import {observeSavedPostsByIds, observeIsPostAcknowledgementsEnabled} from '@queries/servers/post';
import {observeConfigBooleanValue} from '@queries/servers/system';
import {observeCurrentUser} from '@queries/servers/user';
import {mapCustomEmojiNames} from '@utils/emoji/helpers';
@@ -30,6 +30,7 @@ const enhancedWithoutPosts = withObservables([], ({database}: WithDatabaseArgs)
customEmojiNames: queryAllCustomEmojis(database).observeWithColumns(['name']).pipe(
switchMap((customEmojis) => of$(mapCustomEmojiNames(customEmojis))),
),
isPostAcknowledgementEnabled: observeIsPostAcknowledgementsEnabled(database),
};
});

View File

@@ -0,0 +1,194 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {View, Text, TouchableOpacity, useWindowDimensions} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {acknowledgePost, unacknowledgePost} from '@actions/remote/post';
import {fetchMissingProfilesByIds} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {useServerUrl} from '@context/server';
import {useIsTablet} from '@hooks/device';
import {TITLE_HEIGHT} from '@screens/bottom_sheet/content';
import {bottomSheet} from '@screens/navigation';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {moreThan5minAgo} from '@utils/post';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import UsersList from './users_list';
import {USER_ROW_HEIGHT} from './users_list/user_list_item';
import type {BottomSheetProps} from '@gorhom/bottom-sheet';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
currentUserId: UserModel['id'];
currentUserTimezone: UserModel['timezone'];
hasReactions: boolean;
location: string;
post: PostModel;
theme: Theme;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
alignItems: 'center',
borderRadius: 4,
backgroundColor: changeOpacity(theme.onlineIndicator, 0.12),
flexDirection: 'row',
height: 32,
justifyContent: 'center',
paddingHorizontal: 8,
},
containerActive: {
backgroundColor: theme.onlineIndicator,
},
text: {
...typography('Body', 100, 'SemiBold'),
color: theme.onlineIndicator,
},
textActive: {
color: '#fff',
},
icon: {
marginRight: 4,
},
divider: {
width: 1,
height: 32,
marginHorizontal: 8,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.16),
},
listHeaderText: {
marginBottom: 12,
color: theme.centerChannelColor,
...typography('Heading', 600, 'SemiBold'),
},
};
});
const Acknowledgements = ({currentUserId, currentUserTimezone, hasReactions, location, post, theme}: Props) => {
const intl = useIntl();
const isTablet = useIsTablet();
const {bottom} = useSafeAreaInsets();
const serverUrl = useServerUrl();
const {height} = useWindowDimensions();
const style = getStyleSheet(theme);
const isCurrentAuthor = post.userId === currentUserId;
const acknowledgements = post.metadata?.acknowledgements || [];
const acknowledgedAt = useMemo(() => {
if (acknowledgements.length > 0) {
const ack = acknowledgements.find((item) => item.user_id === currentUserId);
if (ack) {
return ack.acknowledged_at;
}
}
return 0;
}, [acknowledgements]);
const handleOnPress = useCallback(() => {
if ((acknowledgedAt && moreThan5minAgo(acknowledgedAt)) || isCurrentAuthor) {
return;
}
if (acknowledgedAt) {
unacknowledgePost(serverUrl, post.id);
} else {
acknowledgePost(serverUrl, post.id);
}
}, [acknowledgedAt, isCurrentAuthor, post.id, serverUrl]);
const handleOnLongPress = useCallback(async () => {
if (!acknowledgements.length) {
return;
}
const userAcknowledgements: Record<string, number> = {};
const userIds: string[] = [];
acknowledgements.forEach((item) => {
userAcknowledgements[item.user_id] = item.acknowledged_at;
userIds.push(item.user_id);
});
try {
fetchMissingProfilesByIds(serverUrl, userIds);
} catch (e) {
return;
}
const renderContent = () => (
<>
{!isTablet && (
<FormattedText
id='mobile.acknowledgements.header'
defaultMessage={'Acknowledgements'}
style={style.listHeaderText}
/>
)}
<UsersList
channelId={post.channelId}
location={location}
userAcknowledgements={userAcknowledgements}
userIds={userIds}
timezone={currentUserTimezone || undefined}
/>
</>
);
const snapPoint1 = bottomSheetSnapPoint(Math.min(userIds.length, 5), USER_ROW_HEIGHT, bottom) + TITLE_HEIGHT;
const snapPoint2 = height * 0.8;
const snapPoints: BottomSheetProps['snapPoints'] = [1, Math.min(snapPoint1, snapPoint2)];
if (userIds.length > 5 && snapPoint1 < snapPoint2) {
snapPoints.push(snapPoint2);
}
bottomSheet({
closeButtonId: 'close-ack-users-list',
renderContent,
initialSnapIndex: 1,
snapPoints,
title: intl.formatMessage({id: 'mobile.acknowledgements.header', defaultMessage: 'Acknowledgements'}),
theme,
});
}, [bottom, intl, isTablet, acknowledgements, theme, location, post.channelId, currentUserTimezone]);
return (
<>
<TouchableOpacity
onPress={handleOnPress}
onLongPress={handleOnLongPress}
style={[style.container, acknowledgedAt ? style.containerActive : undefined]}
>
<CompassIcon
color={acknowledgedAt ? '#fff' : theme.onlineIndicator}
name='check-circle-outline'
size={24}
style={style.icon}
/>
{isCurrentAuthor || acknowledgements.length ? (
<Text style={[style.text, acknowledgedAt ? style.textActive : undefined]}>
{acknowledgements.length}
</Text>
) : (
<FormattedText
id='post_priority.button.acknowledge'
defaultMessage='Acknowledge'
style={style.text}
/>
)}
</TouchableOpacity>
{hasReactions && <View style={style.divider}/>}
</>
);
};
export default Acknowledgements;

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import compose from 'lodash/fp/compose';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeCurrentUser} from '@queries/servers/user';
import Acknowledgements from './acknowledgements';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], (ownProps: WithDatabaseArgs) => {
const database = ownProps.database;
const currentUser = observeCurrentUser(database);
return {
currentUserId: currentUser.pipe(switchMap((c) => of$(c?.id))),
currentUserTimezone: currentUser.pipe(switchMap((c) => of$(c?.timezone))),
};
});
export default compose(
withDatabase,
enhanced,
)(Acknowledgements);

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {queryUsersById} from '@queries/servers/user';
import UsersList from './users_list';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
userIds: string[];
};
const enhanced = withObservables(['userIds'], ({database, userIds}: Props) => {
return {
users: queryUsersById(database, userIds).observe(),
};
});
export default withDatabase(enhanced(UsersList));

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard} from 'react-native';
import FormattedRelativeTime from '@components/formatted_relative_time';
import UserItem from '@components/user_item';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {dismissBottomSheet, openAsBottomSheet} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type UserModel from '@typings/database/models/servers/user';
export const USER_ROW_HEIGHT = 60;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
paddingLeft: 0,
height: USER_ROW_HEIGHT,
},
pictureContainer: {
alignItems: 'flex-start',
width: 40,
},
time: {
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75),
},
}));
type Props = {
channelId: string;
location: string;
user: UserModel;
userAcknowledgement: number;
timezone?: UserTimezone;
}
const UserListItem = ({
channelId,
location,
timezone,
user,
userAcknowledgement,
}: Props) => {
const intl = useIntl();
const theme = useTheme();
const style = getStyleSheet(theme);
const handleUserPress = useCallback(async (userProfile: UserProfile) => {
if (userProfile) {
await dismissBottomSheet(Screens.BOTTOM_SHEET);
const screen = Screens.USER_PROFILE;
const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
const closeButtonId = 'close-user-profile';
const props = {closeButtonId, location, userId: userProfile.id, channelId};
Keyboard.dismiss();
openAsBottomSheet({screen, title, theme, closeButtonId, props});
}
}, [channelId, location]);
return (
<UserItem
FooterComponent={
<FormattedRelativeTime
value={userAcknowledgement}
timezone={timezone}
style={style.time}
/>
}
containerStyle={style.container}
onUserPress={handleUserPress}
size={40}
user={user}
/>
);
};
export default UserListItem;

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef} from 'react';
import {FlatList} from 'react-native-gesture-handler';
import UserListItem from './user_list_item';
import type UserModel from '@typings/database/models/servers/user';
import type {ListRenderItemInfo} from 'react-native';
type Props = {
channelId: string;
location: string;
users: UserModel[];
userAcknowledgements: Record<string, number>;
timezone?: UserTimezone;
};
const UsersList = ({channelId, location, users, userAcknowledgements, timezone}: Props) => {
const listRef = useRef<FlatList>(null);
const renderItem = useCallback(({item}: ListRenderItemInfo<UserModel>) => (
<UserListItem
channelId={channelId}
location={location}
user={item}
userAcknowledgement={userAcknowledgements[item.id]}
timezone={timezone}
/>
), [channelId, location, timezone]);
return (
<FlatList
data={users}
ref={listRef}
renderItem={renderItem}
overScrollMode={'always'}
/>
);
};
export default UsersList;

View File

@@ -12,6 +12,7 @@ import {THREAD} from '@constants/screens';
import {isEdited as postEdited, isPostFailed} from '@utils/post';
import {makeStyleSheetFromTheme} from '@utils/theme';
import Acknowledgements from './acknowledgements';
import AddMembers from './add_members';
import Content from './content';
import Failed from './failed';
@@ -33,6 +34,7 @@ type BodyProps = {
isJumboEmoji: boolean;
isLastReply?: boolean;
isPendingOrFailed: boolean;
isPostAcknowledgementEnabled?: boolean;
isPostAddChannelMember: boolean;
location: string;
post: PostModel;
@@ -43,6 +45,13 @@ type BodyProps = {
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
ackAndReactionsContainer: {
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
alignContent: 'flex-start',
marginTop: 12,
},
messageBody: {
paddingVertical: 2,
flex: 1,
@@ -76,7 +85,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const Body = ({
appsEnabled, hasFiles, hasReactions, highlight, highlightReplyBar,
isCRTEnabled, isEphemeral, isFirstReply, isJumboEmoji, isLastReply, isPendingOrFailed, isPostAddChannelMember,
isCRTEnabled, isEphemeral, isFirstReply, isJumboEmoji, isLastReply, isPendingOrFailed, isPostAcknowledgementEnabled, isPostAddChannelMember,
location, post, searchPatterns, showAddReaction, theme,
}: BodyProps) => {
const style = getStyleSheet(theme);
@@ -158,6 +167,8 @@ const Body = ({
);
}
const acknowledgementsVisible = isPostAcknowledgementEnabled && post.metadata?.priority?.requested_ack;
const reactionsVisible = hasReactions && showAddReaction;
if (!hasBeenDeleted) {
body = (
<View style={style.messageBody}>
@@ -180,13 +191,25 @@ const Body = ({
isReplyPost={isReplyPost}
/>
}
{hasReactions && showAddReaction &&
<Reactions
location={location}
post={post}
theme={theme}
/>
}
{(acknowledgementsVisible || reactionsVisible) && (
<View style={style.ackAndReactionsContainer}>
{acknowledgementsVisible && (
<Acknowledgements
hasReactions={hasReactions}
location={location}
post={post}
theme={theme}
/>
)}
{reactionsVisible && (
<Reactions
location={location}
post={post}
theme={theme}
/>
)}
</View>
)}
</View>
);
}

View File

@@ -3,7 +3,7 @@
import React, {useCallback, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, TouchableOpacity, View} from 'react-native';
import {Keyboard, TouchableOpacity} from 'react-native';
import {addReaction, removeReaction} from '@actions/remote/reactions';
import CompassIcon from '@components/compass_icon';
@@ -50,13 +50,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
paddingHorizontal: 6,
width: 36,
},
reactionsContainer: {
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
alignContent: 'flex-start',
marginTop: 12,
},
};
});
@@ -171,7 +164,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
}
return (
<View style={styles.reactionsContainer}>
<>
{
Array.from(sortedReactions).map((r) => {
const reaction = reactionsByName.get(r);
@@ -189,7 +182,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
})
}
{addMoreReactions}
</View>
</>
);
};

View File

@@ -9,10 +9,9 @@ import {switchMap, distinctUntilChanged} from 'rxjs/operators';
import {Permissions, Preferences, Screens} from '@constants';
import {queryFilesForPost} from '@queries/servers/file';
import {observePost, observePostAuthor, queryPostsBetween} from '@queries/servers/post';
import {observePost, observePostAuthor, queryPostsBetween, observeIsPostPriorityEnabled} from '@queries/servers/post';
import {queryReactionsForPost} from '@queries/servers/reaction';
import {observeCanManageChannelMembers, observePermissionForPost} from '@queries/servers/role';
import {observeIsPostPriorityEnabled} from '@queries/servers/system';
import {observeThreadById} from '@queries/servers/thread';
import {observeCurrentUser} from '@queries/servers/user';
import {areConsecutivePosts, isPostEphemeral} from '@utils/post';

View File

@@ -51,6 +51,7 @@ type PostProps = {
isCRTEnabled?: boolean;
isEphemeral: boolean;
isFirstReply?: boolean;
isPostAcknowledgementEnabled?: boolean;
isSaved?: boolean;
isLastReply?: boolean;
isPostAddChannelMember: boolean;
@@ -109,7 +110,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const Post = ({
appsEnabled, canDelete, currentUser, customEmojiNames, differentThreadSequence, hasFiles, hasReplies, highlight, highlightPinnedOrSaved = true, highlightReplyBar,
isCRTEnabled, isConsecutivePost, isEphemeral, isFirstReply, isSaved, isLastReply, isPostAddChannelMember, isPostPriorityEnabled,
isCRTEnabled, isConsecutivePost, isEphemeral, isFirstReply, isSaved, isLastReply, isPostAcknowledgementEnabled, isPostAddChannelMember, isPostPriorityEnabled,
location, post, rootId, hasReactions, searchPatterns, shouldRenderReplyButton, skipSavedHeader, skipPinnedHeader, showAddReaction = true, style,
testID, thread, previousPost,
}: PostProps) => {
@@ -312,6 +313,7 @@ const Post = ({
isJumboEmoji={isJumboEmoji}
isLastReply={isLastReply}
isPendingOrFailed={isPendingOrFailed}
isPostAcknowledgementEnabled={isPostAcknowledgementEnabled}
isPostAddChannelMember={isPostAddChannelMember}
location={location}
post={post}

View File

@@ -36,6 +36,7 @@ type Props = {
highlightedId?: PostModel['id'];
highlightPinnedOrSaved?: boolean;
isCRTEnabled?: boolean;
isPostAcknowledgementEnabled?: boolean;
isTimezoneEnabled: boolean;
lastViewedAt: number;
location: string;
@@ -97,6 +98,7 @@ const PostList = ({
highlightedId,
highlightPinnedOrSaved = true,
isCRTEnabled,
isPostAcknowledgementEnabled,
isTimezoneEnabled,
lastViewedAt,
location,
@@ -276,6 +278,7 @@ const PostList = ({
appsEnabled,
customEmojiNames,
isCRTEnabled,
isPostAcknowledgementEnabled,
highlight: highlightedId === post.id,
highlightPinnedOrSaved,
isSaved: post.isSaved,
@@ -294,7 +297,7 @@ const PostList = ({
return (<Post {...postProps}/>);
}
}
}, [appsEnabled, currentTimezone, customEmojiNames, highlightPinnedOrSaved, isCRTEnabled, isTimezoneEnabled, shouldRenderReplyButton, theme]);
}, [appsEnabled, currentTimezone, customEmojiNames, highlightPinnedOrSaved, isCRTEnabled, isPostAcknowledgementEnabled, isTimezoneEnabled, shouldRenderReplyButton, theme]);
const scrollToIndex = useCallback((index: number, animated = true, applyOffset = true) => {
listRef.current?.scrollToIndex({

View File

@@ -35,7 +35,7 @@ const style = StyleSheet.create({
});
type Props = {
label: PostPriorityData['priority'];
label: PostPriority['priority'];
};
const PostPriorityLabel = ({label}: Props) => {

View File

@@ -1,122 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {View} from 'react-native';
import FormattedText from '@components/formatted_text';
import {PostPriorityColors, PostPriorityType} from '@constants/post';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import PostPriorityPickerItem from './post_priority_picker_item';
type Props = {
data: PostPriorityData;
onSubmit: (data: PostPriorityData) => void;
};
export const COMPONENT_HEIGHT = 200;
const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
backgroundColor: theme.centerChannelBg,
height: 200,
},
titleContainer: {
alignItems: 'center',
flexDirection: 'row',
},
title: {
color: theme.centerChannelColor,
...typography('Heading', 600, 'SemiBold'),
},
betaContainer: {
backgroundColor: PostPriorityColors.IMPORTANT,
borderRadius: 4,
paddingHorizontal: 4,
marginLeft: 8,
},
beta: {
color: '#fff',
...typography('Body', 25, 'SemiBold'),
},
optionsContainer: {
paddingVertical: 12,
},
}));
const PostPriorityPicker = ({data, onSubmit}: Props) => {
const intl = useIntl();
const theme = useTheme();
const isTablet = useIsTablet();
const style = getStyle(theme);
// For now, we just have one option but the spec suggest we have more in the next phase
// const [data, setData] = React.useState<PostPriorityData>(defaultData);
const handleUpdatePriority = React.useCallback((priority: PostPriorityData['priority']) => {
onSubmit({priority: priority || ''});
}, [onSubmit]);
return (
<View style={style.container}>
{!isTablet &&
<View style={style.titleContainer}>
<FormattedText
id='post_priority.picker.title'
defaultMessage='Message priority'
style={style.title}
/>
<View style={style.betaContainer}>
<FormattedText
id='post_priority.picker.beta'
defaultMessage='BETA'
style={style.beta}
/>
</View>
</View>
}
<View style={style.optionsContainer}>
<PostPriorityPickerItem
action={handleUpdatePriority}
icon='message-text-outline'
label={intl.formatMessage({
id: 'post_priority.picker.label.standard',
defaultMessage: 'Standard',
})}
selected={data.priority === ''}
value={PostPriorityType.STANDARD}
/>
<PostPriorityPickerItem
action={handleUpdatePriority}
icon='alert-circle-outline'
iconColor={PostPriorityColors.IMPORTANT}
label={intl.formatMessage({
id: 'post_priority.picker.label.important',
defaultMessage: 'Important',
})}
selected={data.priority === PostPriorityType.IMPORTANT}
value={PostPriorityType.IMPORTANT}
/>
<PostPriorityPickerItem
action={handleUpdatePriority}
icon='alert-outline'
iconColor={PostPriorityColors.URGENT}
label={intl.formatMessage({
id: 'post_priority.picker.label.urgent',
defaultMessage: 'Urgent',
})}
selected={data.priority === PostPriorityType.URGENT}
value={PostPriorityType.URGENT}
/>
</View>
</View>
);
};
export default PostPriorityPicker;

View File

@@ -1,34 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import OptionItem, {type OptionItemProps} from '@components/option_item';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({
optionLabelTextStyle: {
color: theme.centerChannelColor,
...typography('Body', 200, 'Regular'),
},
}));
const PostPriorityPickerItem = (props: Omit<OptionItemProps, 'type'>) => {
const theme = useTheme();
const style = getStyle(theme);
const testID = `post_priority_picker_item.${props.value || 'standard'}`;
return (
<OptionItem
optionLabelTextStyle={style.optionLabelTextStyle}
testID={testID}
type='select'
{...props}
/>
);
};
export default PostPriorityPickerItem;

View File

@@ -1,9 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import React, {useCallback, useMemo, type ReactNode} from 'react';
import {useIntl} from 'react-intl';
import {StyleSheet, Text, TouchableOpacity, View} from 'react-native';
import {StyleSheet, Text, TouchableOpacity, View, type StyleProp, type ViewStyle} from 'react-native';
import CompassIcon from '@components/compass_icon';
import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
@@ -18,8 +18,11 @@ import {displayUsername, getUserCustomStatus, isBot, isCustomStatusExpired, isGu
import type UserModel from '@typings/database/models/servers/user';
type AtMentionItemProps = {
FooterComponent?: ReactNode;
user: UserProfile | UserModel;
containerStyle?: StyleProp<ViewStyle>;
currentUserId: string;
size?: number;
testID?: string;
isCustomStatusEnabled: boolean;
showBadges?: boolean;
@@ -35,6 +38,13 @@ type AtMentionItemProps = {
const getThemedStyles = makeStyleSheetFromTheme((theme: Theme) => {
return {
rowPicture: {
marginRight: 10,
marginLeft: 2,
width: 24,
alignItems: 'center',
justifyContent: 'center',
},
rowFullname: {
...typography('Body', 200),
color: theme.centerChannelColor,
@@ -56,6 +66,13 @@ const nonThemedStyles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
},
rowInfoBaseContainer: {
flex: 1,
},
rowInfoContainer: {
flex: 1,
flexDirection: 'row',
},
icon: {
marginLeft: 4,
},
@@ -71,8 +88,11 @@ const nonThemedStyles = StyleSheet.create({
});
const UserItem = ({
FooterComponent,
user,
containerStyle,
currentUserId,
size = 24,
testID,
isCustomStatusEnabled,
showBadges = false,
@@ -107,7 +127,7 @@ const UserItem = ({
const userItemTestId = `${testID}.${user?.id}`;
const containerStyle = useMemo(() => {
const containerViewStyle = useMemo(() => {
return [
nonThemedStyles.row,
{
@@ -133,68 +153,72 @@ const UserItem = ({
>
<View
ref={viewRef}
style={containerStyle}
style={[containerViewStyle, containerStyle]}
testID={userItemTestId}
>
<ProfilePicture
author={user}
size={24}
size={size}
showStatus={false}
testID={`${userItemTestId}.profile_picture`}
containerStyle={nonThemedStyles.profile}
/>
<Text
style={style.rowFullname}
numberOfLines={1}
testID={`${userItemTestId}.display_name`}
>
{nonBreakingString(displayName)}
{Boolean(showTeammateDisplay) && (
<View style={nonThemedStyles.rowInfoBaseContainer}>
<View style={nonThemedStyles.rowInfoContainer}>
<Text
style={style.rowUsername}
testID={`${userItemTestId}.username`}
style={style.rowFullname}
numberOfLines={1}
testID={`${userItemTestId}.display_name`}
>
{nonBreakingString(` @${user!.username}`)}
{nonBreakingString(displayName)}
{Boolean(showTeammateDisplay) && (
<Text
style={style.rowUsername}
testID={`${userItemTestId}.username`}
>
{nonBreakingString(` @${user!.username}`)}
</Text>
)}
{Boolean(deleteAt) && (
<Text
style={style.rowUsername}
testID={`${userItemTestId}.deactivated`}
>
{nonBreakingString(` ${intl.formatMessage({id: 'mobile.user_list.deactivated', defaultMessage: 'Deactivated'})}`)}
</Text>
)}
</Text>
)}
{Boolean(deleteAt) && (
<Text
style={style.rowUsername}
testID={`${userItemTestId}.deactivated`}
>
{nonBreakingString(` ${intl.formatMessage({id: 'mobile.user_list.deactivated', defaultMessage: 'Deactivated'})}`)}
</Text>
)}
</Text>
{showBadges && bot && (
<BotTag
testID={`${userItemTestId}.bot.tag`}
style={nonThemedStyles.tag}
/>
)}
{showBadges && guest && (
<GuestTag
testID={`${userItemTestId}.guest.tag`}
style={nonThemedStyles.tag}
/>
)}
{Boolean(isCustomStatusEnabled && !bot && customStatus?.emoji && !customStatusExpired) && (
<CustomStatusEmoji
customStatus={customStatus!}
style={nonThemedStyles.icon}
/>
)}
{shared && (
<CompassIcon
name={'circle-multiple-outline'}
size={16}
color={theme.centerChannelColor}
style={nonThemedStyles.icon}
/>
)}
<View style={nonThemedStyles.flex}/>
{Boolean(rightDecorator) && rightDecorator}
{showBadges && bot && (
<BotTag
testID={`${userItemTestId}.bot.tag`}
style={nonThemedStyles.tag}
/>
)}
{showBadges && guest && (
<GuestTag
testID={`${userItemTestId}.guest.tag`}
style={nonThemedStyles.tag}
/>
)}
{Boolean(isCustomStatusEnabled && !bot && customStatus?.emoji && !customStatusExpired) && (
<CustomStatusEmoji
customStatus={customStatus!}
style={nonThemedStyles.icon}
/>
)}
{shared && (
<CompassIcon
name={'circle-multiple-outline'}
size={16}
color={theme.centerChannelColor}
style={nonThemedStyles.icon}
/>
)}
<View style={nonThemedStyles.flex}/>
{Boolean(rightDecorator) && rightDecorator}
</View>
{FooterComponent}
</View>
</View>
</TouchableOpacity>
);

View File

@@ -153,17 +153,20 @@ exports[`components/channel_list_row should show no results 1`] = `
<View
style={
[
{
"alignItems": "center",
"flexDirection": "row",
"height": 40,
"paddingTop": 4,
"paddingVertical": 8,
},
{
"opacity": 1,
"paddingHorizontal": 20,
},
[
{
"alignItems": "center",
"flexDirection": "row",
"height": 40,
"paddingTop": 4,
"paddingVertical": 8,
},
{
"opacity": 1,
"paddingHorizontal": 20,
},
],
undefined,
]
}
testID="create_direct_message.user_list.user_item.1.1"
@@ -196,44 +199,61 @@ exports[`components/channel_list_row should show no results 1`] = `
}
/>
</View>
<Text
numberOfLines={1}
style={
{
"color": "#3f4350",
"flex": 0,
"flexShrink": 1,
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="create_direct_message.user_list.user_item.1.1.display_name"
>
johndoe
</Text>
<View
style={
{
"flex": 1,
}
}
/>
<View
style={
{
"alignItems": "center",
"justifyContent": "center",
"marginLeft": 12,
}
}
>
<Icon
color="rgba(63,67,80,0.32)"
name="circle-outline"
size={28}
/>
<View
style={
{
"flex": 1,
"flexDirection": "row",
}
}
>
<Text
numberOfLines={1}
style={
{
"color": "#3f4350",
"flex": 0,
"flexShrink": 1,
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="create_direct_message.user_list.user_item.1.1.display_name"
>
johndoe
</Text>
<View
style={
{
"flex": 1,
}
}
/>
<View
style={
{
"alignItems": "center",
"justifyContent": "center",
"marginLeft": 12,
}
}
>
<Icon
color="rgba(63,67,80,0.32)"
name="circle-outline"
size={28}
/>
</View>
</View>
</View>
</View>
</View>
@@ -418,17 +438,20 @@ exports[`components/channel_list_row should show results and tutorial 1`] = `
<View
style={
[
{
"alignItems": "center",
"flexDirection": "row",
"height": 40,
"paddingTop": 4,
"paddingVertical": 8,
},
{
"opacity": 1,
"paddingHorizontal": 20,
},
[
{
"alignItems": "center",
"flexDirection": "row",
"height": 40,
"paddingTop": 4,
"paddingVertical": 8,
},
{
"opacity": 1,
"paddingHorizontal": 20,
},
],
undefined,
]
}
testID="create_direct_message.user_list.user_item.1.1"
@@ -461,44 +484,61 @@ exports[`components/channel_list_row should show results and tutorial 1`] = `
}
/>
</View>
<Text
numberOfLines={1}
style={
{
"color": "#3f4350",
"flex": 0,
"flexShrink": 1,
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="create_direct_message.user_list.user_item.1.1.display_name"
>
johndoe
</Text>
<View
style={
{
"flex": 1,
}
}
/>
<View
style={
{
"alignItems": "center",
"justifyContent": "center",
"marginLeft": 12,
}
}
>
<Icon
color="rgba(63,67,80,0.32)"
name="circle-outline"
size={28}
/>
<View
style={
{
"flex": 1,
"flexDirection": "row",
}
}
>
<Text
numberOfLines={1}
style={
{
"color": "#3f4350",
"flex": 0,
"flexShrink": 1,
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="create_direct_message.user_list.user_item.1.1.display_name"
>
johndoe
</Text>
<View
style={
{
"flex": 1,
}
}
/>
<View
style={
{
"alignItems": "center",
"justifyContent": "center",
"marginLeft": 12,
}
}
>
<Icon
color="rgba(63,67,80,0.32)"
name="circle-outline"
size={28}
/>
</View>
</View>
</View>
</View>
</View>
@@ -763,17 +803,20 @@ exports[`components/channel_list_row should show results no tutorial 1`] = `
<View
style={
[
{
"alignItems": "center",
"flexDirection": "row",
"height": 40,
"paddingTop": 4,
"paddingVertical": 8,
},
{
"opacity": 1,
"paddingHorizontal": 20,
},
[
{
"alignItems": "center",
"flexDirection": "row",
"height": 40,
"paddingTop": 4,
"paddingVertical": 8,
},
{
"opacity": 1,
"paddingHorizontal": 20,
},
],
undefined,
]
}
testID="create_direct_message.user_list.user_item.1.1"
@@ -806,44 +849,61 @@ exports[`components/channel_list_row should show results no tutorial 1`] = `
}
/>
</View>
<Text
numberOfLines={1}
style={
{
"color": "#3f4350",
"flex": 0,
"flexShrink": 1,
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="create_direct_message.user_list.user_item.1.1.display_name"
>
johndoe
</Text>
<View
style={
{
"flex": 1,
}
}
/>
<View
style={
{
"alignItems": "center",
"justifyContent": "center",
"marginLeft": 12,
}
}
>
<Icon
color="rgba(63,67,80,0.32)"
name="circle-outline"
size={28}
/>
<View
style={
{
"flex": 1,
"flexDirection": "row",
}
}
>
<Text
numberOfLines={1}
style={
{
"color": "#3f4350",
"flex": 0,
"flexShrink": 1,
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="create_direct_message.user_list.user_item.1.1.display_name"
>
johndoe
</Text>
<View
style={
{
"flex": 1,
}
}
/>
<View
style={
{
"alignItems": "center",
"justifyContent": "center",
"marginLeft": 12,
}
}
>
<Icon
color="rgba(63,67,80,0.32)"
name="circle-outline"
size={28}
/>
</View>
</View>
</View>
</View>
</View>
@@ -1060,17 +1120,20 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`]
<View
style={
[
{
"alignItems": "center",
"flexDirection": "row",
"height": 40,
"paddingTop": 4,
"paddingVertical": 8,
},
{
"opacity": 1,
"paddingHorizontal": 20,
},
[
{
"alignItems": "center",
"flexDirection": "row",
"height": 40,
"paddingTop": 4,
"paddingVertical": 8,
},
{
"opacity": 1,
"paddingHorizontal": 20,
},
],
undefined,
]
}
testID="create_direct_message.user_list.user_item.1.1"
@@ -1103,44 +1166,61 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`]
}
/>
</View>
<Text
numberOfLines={1}
style={
{
"color": "#3f4350",
"flex": 0,
"flexShrink": 1,
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="create_direct_message.user_list.user_item.1.1.display_name"
>
johndoe
</Text>
<View
style={
{
"flex": 1,
}
}
/>
<View
style={
{
"alignItems": "center",
"justifyContent": "center",
"marginLeft": 12,
}
}
>
<Icon
color="rgba(63,67,80,0.32)"
name="circle-outline"
size={28}
/>
<View
style={
{
"flex": 1,
"flexDirection": "row",
}
}
>
<Text
numberOfLines={1}
style={
{
"color": "#3f4350",
"flex": 0,
"flexShrink": 1,
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="create_direct_message.user_list.user_item.1.1.display_name"
>
johndoe
</Text>
<View
style={
{
"flex": 1,
}
}
/>
<View
style={
{
"alignItems": "center",
"justifyContent": "center",
"marginLeft": 12,
}
}
>
<Icon
color="rgba(63,67,80,0.32)"
name="circle-outline"
size={28}
/>
</View>
</View>
</View>
</View>
</View>
@@ -1230,17 +1310,20 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`]
<View
style={
[
{
"alignItems": "center",
"flexDirection": "row",
"height": 40,
"paddingTop": 4,
"paddingVertical": 8,
},
{
"opacity": 1,
"paddingHorizontal": 20,
},
[
{
"alignItems": "center",
"flexDirection": "row",
"height": 40,
"paddingTop": 4,
"paddingVertical": 8,
},
{
"opacity": 1,
"paddingHorizontal": 20,
},
],
undefined,
]
}
testID="create_direct_message.user_list.user_item.2.2"
@@ -1273,44 +1356,61 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`]
}
/>
</View>
<Text
numberOfLines={1}
style={
{
"color": "#3f4350",
"flex": 0,
"flexShrink": 1,
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="create_direct_message.user_list.user_item.2.2.display_name"
>
rocky
</Text>
<View
style={
{
"flex": 1,
}
}
/>
<View
style={
{
"alignItems": "center",
"justifyContent": "center",
"marginLeft": 12,
}
}
>
<Icon
color="rgba(63,67,80,0.32)"
name="circle-outline"
size={28}
/>
<View
style={
{
"flex": 1,
"flexDirection": "row",
}
}
>
<Text
numberOfLines={1}
style={
{
"color": "#3f4350",
"flex": 0,
"flexShrink": 1,
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="create_direct_message.user_list.user_item.2.2.display_name"
>
rocky
</Text>
<View
style={
{
"flex": 1,
}
}
/>
<View
style={
{
"alignItems": "center",
"justifyContent": "center",
"marginLeft": 12,
}
}
>
<Icon
color="rgba(63,67,80,0.32)"
name="circle-outline"
size={28}
/>
</View>
</View>
</View>
</View>
</View>

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {MENTIONS_REGEX} from './autocomplete';
describe('test regular expressions', () => {
test.each([
['test @mention', ['@mention']],
['test @mention.', ['@mention']],
['test @mention_', ['@mention_']],
['test @user_.name', ['@user_.name']],
['test @user_.name.', ['@user_.name']],
['test@mention', null],
['test @mention1 @mention2 mentions', ['@mention1', '@mention2']],
['test @mention1 @mention2. Mentions...', ['@mention1', '@mention2']],
['where is @jessica.hyde?', ['@jessica.hyde']],
['where is @jessica.hyde.', ['@jessica.hyde']],
['test @user.name. @user2.name', ['@user.name', '@user2.name']],
['test @user.name.@user2.name', ['@user.name', '@user2.name']],
['non latin @桜 mention', ['@桜']],
['@γεια non latin', ['@γεια']],
['non latin @γεια.', ['@γεια']],
// since word boundaries don't work with non latin characters
// the following is a known bug
['άντε@γεια.com', ['@γεια.com']],
])('MENTIONS_REGEX %s => %s', (text, expected) => {
expect(text.match(MENTIONS_REGEX)).toEqual(expected);
});
});

View File

@@ -17,6 +17,10 @@ export const ALL_SEARCH_FLAGS_REGEX = /\b\w+:/g;
export const CODE_REGEX = /(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)| *(`{3,}|~{3,})[ .]*(\S+)? *\n([\s\S]*?\s*)\3 *(?:\n+|$)/g;
export const MENTIONS_REGEX = /(?:\B|\b_+)@([\p{L}0-9.\-_]+)(?<![.])/gui;
export const SPECIAL_MENTIONS_REGEX = /(?:\B|\b_+)@(channel|all|here)(?!(\.|-|_)*[^\W_])/gi;
export const MAX_LIST_HEIGHT = 230;
export const MAX_LIST_TABLET_DIFF = 90;
@@ -30,4 +34,6 @@ export default {
CODE_REGEX,
DATE_MENTION_SEARCH_REGEX,
MAX_LIST_HEIGHT,
MENTIONS_REGEX,
SPECIAL_MENTIONS_REGEX,
};

View File

@@ -41,6 +41,7 @@ export const ONBOARDING = 'Onboarding';
export const PERMALINK = 'Permalink';
export const PINNED_MESSAGES = 'PinnedMessages';
export const POST_OPTIONS = 'PostOptions';
export const POST_PRIORITY_PICKER = 'PostPriorityPicker';
export const REACTIONS = 'Reactions';
export const REVIEW_APP = 'ReviewApp';
export const SAVED_MESSAGES = 'SavedMessages';
@@ -111,6 +112,7 @@ export default {
PERMALINK,
PINNED_MESSAGES,
POST_OPTIONS,
POST_PRIORITY_PICKER,
REACTIONS,
REVIEW_APP,
SAVED_MESSAGES,
@@ -168,6 +170,7 @@ export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
BOTTOM_SHEET,
EMOJI_PICKER,
POST_OPTIONS,
POST_PRIORITY_PICKER,
THREAD_OPTIONS,
REACTIONS,
USER_PROFILE,

View File

@@ -5,6 +5,8 @@ import Calls from '@constants/calls';
const WebsocketEvents = {
POSTED: 'posted',
POST_ACKNOWLEDGEMENT_ADDED: 'post_acknowledgement_added',
POST_ACKNOWLEDGEMENT_REMOVED: 'post_acknowledgement_removed',
POST_EDITED: 'post_edited',
POST_DELETED: 'post_deleted',
POST_UNREAD: 'post_unread',

View File

@@ -41,7 +41,12 @@ export const transformPostRecord = ({action, database, value}: TransformerArgs):
post.updateAt = raw.update_at;
post.isPinned = Boolean(raw.is_pinned);
post.message = raw.message;
post.metadata = raw.metadata && Object.keys(raw.metadata).length ? raw.metadata : null;
// When we extract the posts from the threads, we don't get the metadata
// So, it might not be present in the raw post, so we use the one from the record
const metadata = raw.metadata ?? post.metadata;
post.metadata = metadata && Object.keys(metadata).length ? metadata : null;
post.userId = raw.user_id;
post.originalId = raw.original_id;
post.pendingPostId = raw.pending_post_id;
@@ -132,6 +137,7 @@ export const transformFileRecord = ({action, database, value}: TransformerArgs):
*/
export const transformDraftRecord = ({action, database, value}: TransformerArgs): Promise<DraftModel> => {
const emptyFileInfo: FileInfo[] = [];
const emptyPostMetadata: PostMetadata = {};
const raw = value.raw as Draft;
// We use the raw id as Draft is client side only and we would only be creating/deleting drafts
@@ -141,6 +147,7 @@ export const transformDraftRecord = ({action, database, value}: TransformerArgs)
draft.message = raw?.message ?? '';
draft.channelId = raw?.channel_id ?? '';
draft.files = raw?.files ?? emptyFileInfo;
draft.metadata = raw?.metadata ?? emptyPostMetadata;
};
return prepareBaseRecord({

View File

@@ -2,35 +2,29 @@
// See LICENSE.txt for license information.
import {General} from '@constants';
import {MENTIONS_REGEX} from '@constants/autocomplete';
export const getNeededAtMentionedUsernames = (usernames: Set<string>, posts: Post[], excludeUsername?: string) => {
const usernamesToLoad = new Set<string>();
const pattern = /\B@(([a-z0-9_.-]*[a-z0-9_])[.-]*)/gi;
posts.forEach((p) => {
let match;
while ((match = pattern.exec(p.message)) !== null) {
const lowercaseMatch1 = match[1].toLowerCase();
const lowercaseMatch2 = match[2].toLowerCase();
while ((match = MENTIONS_REGEX.exec(p.message)) !== null) {
const lowercaseMatch = match[1].toLowerCase();
// match[1] is the matched mention including trailing punctuation
// match[2] is the matched mention without trailing punctuation
if (General.SPECIAL_MENTIONS.has(lowercaseMatch2)) {
if (General.SPECIAL_MENTIONS.has(lowercaseMatch)) {
continue;
}
if (lowercaseMatch1 === excludeUsername || lowercaseMatch2 === excludeUsername) {
if (lowercaseMatch === excludeUsername) {
continue;
}
if (usernames.has(lowercaseMatch1) || usernames.has(lowercaseMatch2)) {
if (usernames.has(lowercaseMatch)) {
continue;
}
// If there's no trailing punctuation, this will only add 1 item to the set
usernamesToLoad.add(lowercaseMatch1);
usernamesToLoad.add(lowercaseMatch2);
usernamesToLoad.add(lowercaseMatch);
}
});

View File

@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import {Database, Q} from '@nozbe/watermelondb';
import {of as of$} from 'rxjs';
import {MM_TABLES} from '@constants/database';
@@ -25,3 +26,7 @@ export const queryDraft = (database: Database, channelId: string, rootId = '') =
Q.where('root_id', rootId),
);
};
export function observeFirstDraft(v: DraftModel[]) {
return v[0]?.observe() || of$(undefined);
}

View File

@@ -2,13 +2,15 @@
// See LICENSE.txt for license information.
import {Database, Model, Q, Query} from '@nozbe/watermelondb';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {of as of$, combineLatestWith} from 'rxjs';
import {switchMap, distinctUntilChanged} from 'rxjs/operators';
import {MM_TABLES} from '@constants/database';
import {queryGroupsByNames} from './group';
import {querySavedPostsPreferences} from './preference';
import {observeUser} from './user';
import {getConfigValue, observeConfigBooleanValue} from './system';
import {queryUsersByUsername, observeUser, observeCurrentUser} from './user';
import type PostModel from '@typings/database/models/servers/post';
import type PostInChannelModel from '@typings/database/models/servers/posts_in_channel';
@@ -230,3 +232,52 @@ export const observeSavedPostsByIds = (database: Database, postIds: string[]) =>
switchMap((prefs) => of$(new Set(prefs.map((p) => p.name)))),
);
};
export const getIsPostPriorityEnabled = async (database: Database) => {
const featureFlag = await getConfigValue(database, 'FeatureFlagPostPriority');
const cfg = await getConfigValue(database, 'PostPriority');
return featureFlag === 'true' && cfg === 'true';
};
export const getIsPostAcknowledgementsEnabled = async (database: Database) => {
const cfg = await getConfigValue(database, 'PostAcknowledgements');
return cfg === 'true';
};
export const observeIsPostPriorityEnabled = (database: Database) => {
const featureFlag = observeConfigBooleanValue(database, 'FeatureFlagPostPriority');
const cfg = observeConfigBooleanValue(database, 'PostPriority');
return featureFlag.pipe(
combineLatestWith(cfg),
switchMap(([ff, c]) => of$(ff && c)),
distinctUntilChanged(),
);
};
export const observeIsPostAcknowledgementsEnabled = (database: Database) => {
return observeConfigBooleanValue(database, 'PostAcknowledgements');
};
export const observePersistentNotificationsEnabled = (database: Database) => {
const user = observeCurrentUser(database);
const enabledForAll = observeConfigBooleanValue(database, 'AllowPersistentNotifications');
const enabledForGuests = observeConfigBooleanValue(database, 'AllowPersistentNotificationsForGuests');
return user.pipe(
combineLatestWith(enabledForAll, enabledForGuests),
switchMap(([u, forAll, forGuests]) => {
if (u?.isGuest) {
return of$(forAll && forGuests);
}
return of$(forAll);
}),
distinctUntilChanged(),
);
};
export const countUsersFromMentions = async (database: Database, mentions: string[]) => {
const groupsQuery = queryGroupsByNames(database, mentions).fetch();
const usersQuery = queryUsersByUsername(database, mentions).fetchCount();
const [groups, usersCount] = await Promise.all([groupsQuery, usersQuery]);
return groups.reduce((acc, v) => acc + v.memberCount, usersCount);
};

View File

@@ -5,7 +5,7 @@ import {Database, Q} from '@nozbe/watermelondb';
import {of as of$, Observable, combineLatest} from 'rxjs';
import {switchMap, distinctUntilChanged} from 'rxjs/operators';
import {Config, Preferences} from '@constants';
import {Preferences} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {PUSH_PROXY_STATUS_UNKNOWN} from '@constants/push_proxy';
import {isMinimumServerVersion} from '@utils/helpers';
@@ -239,15 +239,6 @@ export const observeConfigIntValue = (database: Database, key: keyof ClientConfi
);
};
export const observeIsPostPriorityEnabled = (database: Database) => {
const featureFlag = observeConfigValue(database, 'FeatureFlagPostPriority');
const cfg = observeConfigValue(database, 'PostPriority');
return combineLatest([featureFlag, cfg]).pipe(
switchMap(([ff, c]) => of$(ff === Config.TRUE && c === Config.TRUE)),
distinctUntilChanged(),
);
};
export const observeLicense = (database: Database): Observable<ClientLicense | undefined> => {
return querySystemValue(database, SYSTEM_IDENTIFIERS.LICENSE).observe().pipe(
switchMap((result) => (result.length ? result[0].observe() : of$({value: undefined}))),

View File

@@ -176,6 +176,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.POST_OPTIONS:
screen = withServerDatabase(require('@screens/post_options').default);
break;
case Screens.POST_PRIORITY_PICKER:
screen = withServerDatabase(require('@screens/post_priority_picker').default);
break;
case Screens.REACTIONS:
screen = withServerDatabase(require('@screens/reactions').default);
break;

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import OptionItem, {type OptionItemProps, type OptionType} from '@components/option_item';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
labelContainer: {
alignItems: 'flex-start',
},
optionLabelText: {
color: theme.centerChannelColor,
...typography('Body', 200, 'Regular'),
},
}));
type Props = Omit<OptionItemProps, 'type'> & {
type?: OptionType;
}
const PickerOption = ({type, ...rest}: Props) => {
const theme = useTheme();
const style = getStyleSheet(theme);
const testID = `post_priority_picker_item.${rest.value || 'standard'}`;
return (
<OptionItem
labelContainerStyle={style.labelContainer}
optionLabelTextStyle={style.optionLabelText}
testID={testID}
type={type || 'select'}
{...rest}
/>
);
};
export default PickerOption;

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BottomSheetFooter, type BottomSheetFooterProps} from '@gorhom/bottom-sheet';
import React from 'react';
import {Platform, TouchableOpacity, View} from 'react-native';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
export type Props = BottomSheetFooterProps & {
onCancel: () => void;
onSubmit: () => void;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
backgroundColor: theme.centerChannelBg,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.16),
borderTopWidth: 1,
paddingTop: 20,
flexDirection: 'row',
paddingHorizontal: 20,
},
cancelButton: {
alignItems: 'center',
backgroundColor: changeOpacity(theme.buttonBg, 0.08),
borderRadius: 4,
flex: 1,
paddingVertical: 15,
},
cancelButtonText: {
color: theme.buttonBg,
...typography('Body', 200, 'SemiBold'),
},
applyButton: {
alignItems: 'center',
backgroundColor: theme.buttonBg,
borderRadius: 4,
flex: 1,
marginLeft: 8,
paddingVertical: 15,
},
applyButtonText: {
color: theme.buttonColor,
...typography('Body', 200, 'SemiBold'),
},
}));
const PostPriorityPickerFooter = ({onCancel, onSubmit, ...props}: Props) => {
const theme = useTheme();
const style = getStyleSheet(theme);
const isTablet = useIsTablet();
return (
<BottomSheetFooter {...props}>
<View
style={[style.container, {
paddingBottom: Platform.select({ios: (isTablet ? 20 : 32), android: 20}),
}]}
>
<TouchableOpacity
onPress={onCancel}
style={style.cancelButton}
>
<FormattedText
id='post_priority.picker.cancel'
defaultMessage='Cancel'
style={style.cancelButtonText}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={onSubmit}
style={style.applyButton}
>
<FormattedText
id='post_priority.picker.apply'
defaultMessage='Apply'
style={style.applyButtonText}
/>
</TouchableOpacity>
</View>
</BottomSheetFooter>
);
};
export default PostPriorityPickerFooter;

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {observeIsPostAcknowledgementsEnabled, observePersistentNotificationsEnabled} from '@queries/servers/post';
import PostPriorityPicker from './post_priority_picker';
import type {Database} from '@nozbe/watermelondb';
const enhanced = withObservables([], ({database}: {database: Database}) => {
return {
isPostAcknowledgementEnabled: observeIsPostAcknowledgementsEnabled(database),
isPersistenNotificationsEnabled: observePersistentNotificationsEnabled(database),
};
});
export default withDatabase(enhanced(PostPriorityPicker));

View File

@@ -0,0 +1,229 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import FormattedText from '@components/formatted_text';
import {Screens} from '@constants';
import {PostPriorityColors, PostPriorityType} from '@constants/post';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import BottomSheet from '@screens/bottom_sheet';
import {dismissBottomSheet} from '@screens/navigation';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import PickerOption from './components/picker_option';
import Footer from './footer';
import {labels} from './utils';
import type {BottomSheetFooterProps} from '@gorhom/bottom-sheet';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
componentId: AvailableScreens;
isPostAcknowledgementEnabled: boolean;
isPersistenNotificationsEnabled: boolean;
postPriority: PostPriority;
updatePostPriority: (data: PostPriority) => void;
closeButtonId: string;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
backgroundColor: theme.centerChannelBg,
},
titleContainer: {
alignItems: 'center',
flexDirection: 'row',
},
title: {
color: theme.centerChannelColor,
...typography('Heading', 600, 'SemiBold'),
},
betaContainer: {
backgroundColor: PostPriorityColors.IMPORTANT,
borderRadius: 4,
paddingHorizontal: 4,
marginLeft: 8,
},
beta: {
color: '#fff',
...typography('Body', 25, 'SemiBold'),
},
optionsContainer: {
paddingTop: 12,
},
optionsSeparator: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
height: 1,
},
toggleOptionContainer: {
marginTop: 16,
},
}));
const PostPriorityPicker = ({
componentId,
isPostAcknowledgementEnabled,
isPersistenNotificationsEnabled,
postPriority,
updatePostPriority,
closeButtonId,
}: Props) => {
const {bottom} = useSafeAreaInsets();
const intl = useIntl();
const isTablet = useIsTablet();
const theme = useTheme();
const [data, setData] = useState<PostPriority>(postPriority);
const style = getStyleSheet(theme);
const closeBottomSheet = useCallback(() => {
return dismissBottomSheet(Screens.POST_PRIORITY_PICKER);
}, []);
useNavButtonPressed(closeButtonId, componentId, closeBottomSheet, []);
useAndroidHardwareBackHandler(componentId, closeBottomSheet);
const displayPersistentNotifications = isPersistenNotificationsEnabled && data.priority === PostPriorityType.URGENT;
const snapPoints = useMemo(() => {
let COMPONENT_HEIGHT = 280;
if (isPostAcknowledgementEnabled) {
COMPONENT_HEIGHT += 75;
if (displayPersistentNotifications) {
COMPONENT_HEIGHT += 75;
}
}
return [1, bottomSheetSnapPoint(1, COMPONENT_HEIGHT, bottom)];
}, [displayPersistentNotifications, isPostAcknowledgementEnabled, bottom]);
const handleUpdatePriority = useCallback((priority: PostPriority['priority']) => {
setData((prevData) => ({
...prevData,
priority,
persistent_notifications: undefined, // Uncheck if checked already
}));
}, []);
const handleUpdateRequestedAck = useCallback((requested_ack: boolean) => {
setData((prevData) => ({...prevData, requested_ack}));
}, [data]);
const handleUpdatePersistentNotifications = useCallback((persistent_notifications: boolean) => {
setData((prevData) => ({...prevData, persistent_notifications}));
}, [data]);
const handleSubmit = useCallback(() => {
updatePostPriority(data);
closeBottomSheet();
}, [data]);
const renderContent = () => (
<View style={style.container}>
{!isTablet &&
<View style={style.titleContainer}>
<FormattedText
id='post_priority.picker.title'
defaultMessage='Message priority'
style={style.title}
/>
<View style={style.betaContainer}>
<FormattedText
id='post_priority.picker.beta'
defaultMessage='BETA'
style={style.beta}
/>
</View>
</View>
}
<View style={style.optionsContainer}>
<PickerOption
action={handleUpdatePriority}
icon='message-text-outline'
label={intl.formatMessage(labels.standard.label)}
selected={data.priority === ''}
value={PostPriorityType.STANDARD}
/>
<PickerOption
action={handleUpdatePriority}
icon='alert-circle-outline'
iconColor={PostPriorityColors.IMPORTANT}
label={intl.formatMessage(labels.important.label)}
selected={data.priority === PostPriorityType.IMPORTANT}
value={PostPriorityType.IMPORTANT}
/>
<PickerOption
action={handleUpdatePriority}
icon='alert-outline'
iconColor={PostPriorityColors.URGENT}
label={intl.formatMessage(labels.urgent.label)}
selected={data.priority === PostPriorityType.URGENT}
value={PostPriorityType.URGENT}
/>
{(isPostAcknowledgementEnabled) && (
<>
<View style={style.optionsSeparator}/>
<View style={style.toggleOptionContainer}>
<PickerOption
action={handleUpdateRequestedAck}
label={intl.formatMessage(labels.requestAck.label)}
description={intl.formatMessage(labels.requestAck.description)}
icon='check-circle-outline'
type='toggle'
selected={data.requested_ack}
/>
</View>
{displayPersistentNotifications && (
<View style={style.toggleOptionContainer}>
<PickerOption
action={handleUpdatePersistentNotifications}
label={intl.formatMessage(labels.persistentNotifications.label)}
description={intl.formatMessage(labels.persistentNotifications.description)}
icon='bell-ring-outline'
type='toggle'
selected={data.persistent_notifications}
/>
</View>
)}
</>
)}
</View>
</View>
);
const renderFooter = (props: BottomSheetFooterProps) => (
<Footer
{...props}
onCancel={closeBottomSheet}
onSubmit={handleSubmit}
/>
);
return (
<BottomSheet
renderContent={renderContent}
closeButtonId={closeButtonId}
componentId={Screens.POST_PRIORITY_PICKER}
footerComponent={renderFooter}
initialSnapIndex={1}
snapPoints={snapPoints}
testID='post_options'
/>
);
};
export default PostPriorityPicker;

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {t} from '@app/i18n';
export const labels = {
standard: {
label: {
id: t('post_priority.picker.label.standard'),
defaultMessage: 'Standard',
},
},
urgent: {
label: {
id: t('post_priority.picker.label.urgent'),
defaultMessage: 'Urgent',
},
},
important: {
label: {
id: t('post_priority.picker.label.important'),
defaultMessage: 'Important',
},
},
requestAck: {
label: {
id: t('post_priority.picker.label.request_ack'),
defaultMessage: 'Request acknowledgement',
},
description: {
id: t('post_priority.picker.label.request_ack.description'),
defaultMessage: 'An acknowledgement button appears with your message.',
},
},
persistentNotifications: {
label: {
id: t('post_priority.picker.label.persistent_notifications'),
defaultMessage: 'Send persistent notifications',
},
description: {
id: t('post_priority.picker.label.persistent_notifications.description'),
defaultMessage: 'Recipients are notified every five minutes until they acknowledge or reply.',
},
},
};

View File

@@ -28,6 +28,8 @@ class EphemeralStore {
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 notificationTapped = false;
private enablingCRT = false;
@@ -219,6 +221,30 @@ class EphemeralStore {
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);
};
}
export default new EphemeralStore();

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as utils from './index';
describe('post utils', () => {
test.each([
['@here where is Jessica Hyde', true],
['@all where is Jessica Hyde', true],
['@channel where is Jessica Hyde', true],
['where is Jessica Hyde @here', true],
['where is Jessica Hyde @all', true],
['where is Jessica Hyde @channel', true],
['where is Jessica @here Hyde', true],
['where is Jessica @all Hyde', true],
['where is Jessica @channel Hyde', true],
['where is Jessica Hyde\n@here', true],
['where is Jessica Hyde\n@all', true],
['where is Jessica Hyde\n@channel', true],
['where is Jessica\n@here Hyde', true],
['where is Jessica\n@all Hyde', true],
['where is Jessica\n@channel Hyde', true],
['where is Jessica Hyde @her', false],
['where is Jessica Hyde @al', false],
['where is Jessica Hyde @chann', false],
['where is Jessica Hyde@here', false],
['where is Jessica Hyde@all', false],
['where is Jessica Hyde@channel', false],
['where is Jessica @hereHyde', false],
['where is Jessica @allHyde', false],
['where is Jessica @channelHyde', false],
['@herewhere is Jessica Hyde@here', false],
['@allwhere is Jessica Hyde@all', false],
['@channelwhere is Jessica Hyde@channel', false],
['where is Jessica Hyde here', false],
['where is Jessica Hyde all', false],
['where is Jessica Hyde channel', false],
['where is Jessica Hyde', false],
])('hasSpecialMentions: %s => %s', (message, expected) => {
expect(utils.hasSpecialMentions(message)).toBe(expected);
});
});

View File

@@ -1,13 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Alert, type AlertButton} from 'react-native';
import {getUsersCountFromMentions} from '@actions/local/post';
import {Post} from '@constants';
import {SPECIAL_MENTIONS_REGEX} from '@constants/autocomplete';
import {POST_TIME_TO_FAIL} from '@constants/post';
import {DEFAULT_LOCALE} from '@i18n';
import {toMilliseconds} from '@utils/datetime';
import {displayUsername} from '@utils/user';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
import type {IntlShape} from 'react-intl';
export function areConsecutivePosts(post: PostModel, previousPost: PostModel) {
let consecutive = false;
@@ -85,3 +91,92 @@ export const getLastFetchedAtFromPosts = (posts?: Post[]) => {
return Math.max(maxTimestamp, timestamp);
}, 0) || 0;
};
export const moreThan5minAgo = (time: number) => {
return Date.now() - time > toMilliseconds({minutes: 5});
};
export function hasSpecialMentions(message: string): boolean {
const result = SPECIAL_MENTIONS_REGEX.test(message);
// https://stackoverflow.com/questions/1520800/why-does-a-regexp-with-global-flag-give-wrong-results
SPECIAL_MENTIONS_REGEX.lastIndex = 0;
return result;
}
export async function persistentNotificationsConfirmation(serverUrl: string, value: string, mentionsList: string[], intl: IntlShape, sendMessage: () => void, persistentNotificationMaxRecipients: number, persistentNotificationInterval: number) {
let title = '';
let description = '';
let buttons: AlertButton[] = [{
text: intl.formatMessage({
id: 'persistent_notifications.error.okay',
defaultMessage: 'Okay',
}),
style: 'cancel',
}];
if (hasSpecialMentions(value)) {
description = intl.formatMessage({
id: 'persistent_notifications.error.special_mentions',
defaultMessage: 'Cannot use @channel, @all or @here to mention recipients of persistent notifications.',
});
} else {
// removes the @ from the mention
const formattedMentionsList = mentionsList.map((mention) => mention.slice(1));
const usersCount = await getUsersCountFromMentions(serverUrl, formattedMentionsList);
if (usersCount > persistentNotificationMaxRecipients) {
title = intl.formatMessage({
id: 'persistent_notifications.error.max_recipients.title',
defaultMessage: 'Too many recipients',
});
description = intl.formatMessage({
id: 'persistent_notifications.error.max_recipients.description',
defaultMessage: 'You can send persistent notifications to a maximum of {max} recipients. There are {count} recipients mentioned in your message. Youll need to change who youve mentioned before you can send.',
}, {
max: persistentNotificationMaxRecipients,
count: mentionsList.length,
});
} else if (usersCount === 0) {
title = intl.formatMessage({
id: 'persistent_notifications.error.no_mentions.title',
defaultMessage: 'Recipients must be @mentioned',
});
description = intl.formatMessage({
id: 'persistent_notifications.error.no_mentions.description',
defaultMessage: 'There are no recipients mentioned in your message. Youll need add mentions to be able to send persistent notifications.',
});
} else {
title = intl.formatMessage({
id: 'persistent_notifications.confirm.title',
defaultMessage: 'Send persistent notifications',
});
description = intl.formatMessage({
id: 'persistent_notifications.confirm.description',
defaultMessage: '@mentioned recipients will be notified every {interval, plural, one {minute} other {{interval} minutes}} until theyve acknowledged or replied to the message.',
}, {
interval: persistentNotificationInterval,
});
buttons = [{
text: intl.formatMessage({
id: 'persistent_notifications.confirm.cancel',
defaultMessage: 'Cancel',
}),
style: 'cancel',
},
{
text: intl.formatMessage({
id: 'persistent_notifications.confirm.send',
defaultMessage: 'Send',
}),
onPress: sendMessage,
}];
}
}
Alert.alert(
title,
description,
buttons,
);
}

View File

@@ -408,6 +408,7 @@
"mentions.empty.title": "No Mentions yet",
"mobile.about.appVersion": "App Version: {version} (Build {number})",
"mobile.account.settings.save": "Save",
"mobile.acknowledgements.header": "Acknowledgements",
"mobile.action_menu.select": "Select an option",
"mobile.add_team.create_team": "Create a new team",
"mobile.add_team.join_team": "Join Another Team",
@@ -795,6 +796,16 @@
"permalink.show_dialog_warn.description": "You are about to join {channel} without explicitly being added by the channel admin. Are you sure you wish to join this private channel?",
"permalink.show_dialog_warn.join": "Join",
"permalink.show_dialog_warn.title": "Join private channel",
"persistent_notifications.confirm.cancel": "Cancel",
"persistent_notifications.confirm.description": "@mentioned recipients will be notified every {interval, plural, one {minute} other {{interval} minutes}} until theyve acknowledged or replied to the message.",
"persistent_notifications.confirm.send": "Send",
"persistent_notifications.confirm.title": "Send persistent notifications",
"persistent_notifications.error.max_recipients.description": "You can send persistent notifications to a maximum of {max} recipients. There are {count} recipients mentioned in your message. Youll need to change who youve mentioned before you can send.",
"persistent_notifications.error.max_recipients.title": "Too many recipients",
"persistent_notifications.error.no_mentions.description": "There are no recipients mentioned in your message. Youll need add mentions to be able to send persistent notifications.",
"persistent_notifications.error.no_mentions.title": "Recipients must be @mentioned",
"persistent_notifications.error.okay": "Okay",
"persistent_notifications.error.special_mentions": "Cannot use @channel, @all or @here to mention recipients of persistent notifications.",
"pinned_messages.empty.paragraph": "To pin important messages, long-press on a message and choose Pin To Channel. Pinned messages will be visible to everyone in this channel.",
"pinned_messages.empty.title": "No pinned messages yet",
"plus_menu.browse_channels.title": "Browse Channels",
@@ -817,10 +828,17 @@
"post_info.guest": "Guest",
"post_info.system": "System",
"post_message_view.edited": "(edited)",
"post_priority.button.acknowledge": "Acknowledge",
"post_priority.label.important": "IMPORTANT",
"post_priority.label.urgent": "URGENT",
"post_priority.picker.apply": "Apply",
"post_priority.picker.beta": "BETA",
"post_priority.picker.cancel": "Cancel",
"post_priority.picker.label.important": "Important",
"post_priority.picker.label.persistent_notifications": "Send persistent notifications",
"post_priority.picker.label.persistent_notifications.description": "Recipients are notified every five minutes until they acknowledge or reply.",
"post_priority.picker.label.request_ack": "Request acknowledgement",
"post_priority.picker.label.request_ack.description": "Recipients are notified every five minutes until they acknowledge or reply.",
"post_priority.picker.label.standard": "Standard",
"post_priority.picker.label.urgent": "Urgent",
"post_priority.picker.title": "Message priority",
@@ -835,6 +853,7 @@
"rate.error.title": "Error",
"rate.subtitle": "Let us know what you think.",
"rate.title": "Enjoying Mattermost?",
"requested_ack.title": "Request Acknowledgements",
"saved_messages.empty.paragraph": "To save something for later, long-press on a message and choose Save from the menu. Saved messages are only visible to you.",
"saved_messages.empty.title": "No saved messages yet",
"screen.channel_files.header.recent_files": "Recent Files",

View File

@@ -153,6 +153,11 @@ interface ClientConfig {
PluginsEnabled: string;
PostEditTimeLimit: string;
PostPriority: string;
PostAcknowledgements: string;
AllowPersistentNotifications: string;
PersistentNotificationMaxRecipients: string;
PersistentNotificationInterval: string;
AllowPersistentNotificationsForGuests: string;
PrivacyPolicyLink: string;
ReportAProblemLink: string;
RequireEmailVerification: string;

15
types/api/posts.d.ts vendored
View File

@@ -20,8 +20,16 @@ type PostType =
type PostEmbedType = 'image' | 'message_attachment' | 'opengraph';
type PostPriorityData = {
priority: ''|'urgent'|'important';
type PostAcknowledgement = {
post_id: string;
user_id: string;
acknowledged_at: number;
}
type PostPriority = {
priority: '' | 'urgent' | 'important';
requested_ack?: boolean;
persistent_notifications?: boolean;
};
type PostEmbed = {
@@ -38,12 +46,13 @@ type PostImage = {
};
type PostMetadata = {
acknowledgements?: PostAcknowledgement[];
embeds?: PostEmbed[];
emojis?: CustomEmoji[];
files?: FileInfo[];
images?: Dictionary<PostImage | undefined>;
reactions?: Reaction[];
priority?: PostPriorityData;
priority?: PostPriority;
};
type Post = {

View File

@@ -22,6 +22,7 @@ type Draft = {
files?: FileInfo[];
message?: string;
root_id: string;
metadata?: PostMetadata;
};
type MyTeam = {