Compare commits

...

11 Commits

Author SHA1 Message Date
koox00
1a32fd7b9f Merge remote-tracking branch 'upstream/main' into MM-49540_phase_3 2023-03-07 21:01:29 +02:00
koox00
fddf44072d Saves priority on draft and addresses some comments 2023-03-07 21:00:14 +02:00
koox00
4b30ce3f9a Merge remote-tracking branch 'upstream/main' into MM-49540_phase_3 2023-03-06 16:57:50 +02:00
koox00
76398ca2c8 Merge remote-tracking branch 'upstream/main' into MM-49540_phase_3 2023-02-16 17:44:40 +02:00
koox00
81c89d51c2 Merge remote-tracking branch 'upstream/main' into MM-49540_phase_3 2023-02-15 14:45:15 +02:00
koox00
b2f90f9b52 Merge remote-tracking branch 'upstream/main' into MM-49540_phase_3 2023-02-15 14:31:33 +02:00
Anurag Shivarathri
ab92d224c7 Ack button + display ackd users 2023-02-02 22:37:39 +05:30
Anurag Shivarathri
8d109715f8 Acknowledge button, api 2023-01-21 10:49:38 +05:30
Mattermost Build
7154ce75c2 Merge branch 'main' into MM-49540_phase_3 2023-01-20 07:55:16 +02:00
Anurag Shivarathri
3534383507 i18 and types 2023-01-12 12:41:09 +05:30
Anurag Shivarathri
849e05e2c5 Init 2023-01-12 12:03:21 +05:30
45 changed files with 1519 additions and 340 deletions

View File

@@ -155,3 +155,40 @@ 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) {
if (!postPriority) {
return {};
}
const newDraft: Draft = {
channel_id: channelId,
root_id: rootId,
metadata: {
priority: postPriority,
},
};
return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly});
}
draft.prepareUpdate((d) => {
d.metadata = {
priority: postPriority,
};
});
if (!prepareRecordsOnly) {
await operator.batchRecords([draft], 'updateDraftPriority');
}
return {draft};
} catch (error) {
logError('Failed updateDraftPriority', error);
return {error};
}
}

View File

@@ -248,6 +248,72 @@ export async function getPosts(serverUrl: string, ids: string[]) {
}
}
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);

View File

@@ -19,7 +19,7 @@ import {extractRecordsForTable} from '@helpers/database';
import NetworkManager from '@managers/network_manager';
import {getMyChannel, prepareMissingChannelsForAllTeams, queryAllMyChannel} from '@queries/servers/channel';
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
import {getPostById, getRecentPostsInChannel} from '@queries/servers/post';
import {getIsPostAcknowledgementsEnabled, getIsPostPriorityEnabled, 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';
@@ -499,74 +499,80 @@ 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 isPostPriorityEnabled = await getIsPostPriorityEnabled(database);
const isPostAcknowledgementsEnabled = await getIsPostAcknowledgementsEnabled(database);
const fetchAckUsers = isPostPriorityEnabled && isPostAcknowledgementsEnabled;
try {
const promises: Array<Promise<UserProfile[]>> = [];
if (userIdsToLoad.size) {
promises.push(client.getProfilesByIds(Array.from(userIdsToLoad)));
}
if (usernamesToLoad.size) {
promises.push(client.getProfilesByUsernames(Array.from(usernamesToLoad)));
}
if (promises.length) {
const authorsResult = await Promise.allSettled(promises);
const result = authorsResult.reduce<UserProfile[][]>((acc, item) => {
if (item.status === 'fulfilled') {
acc.push(item.value);
}
return acc;
}, []);
const authors = result.flat();
if (!fetchOnly && authors.length) {
await operator.handleUsers({
users: authors,
prepareRecordsOnly: false,
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);
}
if (fetchAckUsers) {
p.metadata?.acknowledgements?.forEach((ack) => {
if (ack.user_id !== currentUserId && !existingUserIds.has(ack.user_id)) {
userIdsToLoad.add(ack.user_id);
}
});
}
return {authors};
}
return {authors: [] as UserProfile[]};
try {
const promises: Array<Promise<UserProfile[]>> = [];
if (userIdsToLoad.size) {
promises.push(client.getProfilesByIds(Array.from(userIdsToLoad)));
}
if (usernamesToLoad.size) {
promises.push(client.getProfilesByUsernames(Array.from(usernamesToLoad)));
}
if (promises.length) {
const authorsResult = await Promise.allSettled(promises);
const result = authorsResult.reduce<UserProfile[][]>((acc, item) => {
if (item.status === 'fulfilled') {
acc.push(item.value);
}
return acc;
}, []);
const authors = result.flat();
if (!fetchOnly && authors.length) {
await operator.handleUsers({
users: authors,
prepareRecordsOnly: false,
});
}
return {authors};
}
return {authors: [] as UserProfile[]};
} catch (error) {
logError('FETCH AUTHORS ERROR', error);
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
} catch (error) {
logError('FETCH AUTHORS ERROR', error);
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
@@ -1129,3 +1135,51 @@ export async function fetchPinnedPosts(serverUrl: string, channelId: string) {
return {error};
}
}
export async function acknowledgePost(serverUrl: string, postId: string) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const userId = await getCurrentUserId(operator.database);
const data = await client.acknowledgePost(postId, userId);
return {
data,
};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
}
export async function unacknowledgePost(serverUrl: string, postId: string) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const userId = await getCurrentUserId(operator.database);
const data = await client.unacknowledgePost(postId, userId);
return {
data,
};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
}

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';
@@ -300,3 +301,24 @@ 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;
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;
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

@@ -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,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Platform, StyleSheet, 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';
type Props = {
postPriority: PostPriority;
noMentionsError: boolean;
}
const style = StyleSheet.create({
container: {
flexDirection: 'row',
marginLeft: 12,
marginTop: Platform.select({
ios: 3,
android: 10,
}),
},
labelContainer: {
marginRight: 7,
},
ackContainer: {
marginRight: 7,
},
notificationsContainer: {
flexDirection: 'row',
},
error: {
color: PostPriorityColors.URGENT,
marginLeft: 7,
},
});
export default function DraftInputHeader({
postPriority,
noMentionsError,
}: Props) {
const theme = useTheme();
return (
<View style={style.container}>
<View style={style.labelContainer}>
<PostPriorityLabel label={postPriority!.priority}/>
</View>
{postPriority.requested_ack && (
<View style={style.ackContainer}>
<CompassIcon
color={theme.onlineIndicator}
name='check-circle-outline'
size={14}
/>
</View>
)}
{postPriority.persistent_notifications && (
<View style={style.notificationsContainer}>
<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>
);
}

View File

@@ -1,12 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef} from 'react';
import {LayoutChangeEvent, Platform, ScrollView, View} from 'react-native';
import React, {useCallback, useMemo, useRef} from 'react';
import {useIntl} from 'react-intl';
import {Alert, LayoutChangeEvent, Platform, ScrollView, View} from 'react-native';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import PostPriorityLabel from '@components/post_priority/post_priority_label';
import {General} from '@constants';
import {MENTIONS_REGEX, SPECIAL_MENTIONS_REGEX} from '@constants/autocomplete';
import {PostPriorityType} from '@constants/post';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import DatabaseManager from '@database/manager';
import {getUsersCountFromMentions} from '@queries/servers/post';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import PostInput from '../post_input';
@@ -15,18 +21,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 +108,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
export default function DraftInput({
testID,
channelId,
channelType,
currentUserId,
canShowPostPriority,
files,
@@ -113,10 +125,16 @@ export default function DraftInput({
updatePostInputTop,
postPriority,
updatePostPriority,
persistentNotificationInterval,
persistentNotificationMaxRecipients,
setIsFocused,
}: Props) {
const intl = useIntl();
const serverUrl = useServerUrl();
const theme = useTheme();
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
const handleLayout = useCallback((e: LayoutChangeEvent) => {
updatePostInputTop(e.nativeEvent.layout.height);
}, []);
@@ -132,6 +150,102 @@ 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) {
let title = '';
let description = '';
let error = true;
if (new RegExp(SPECIAL_MENTIONS_REGEX).test(value)) {
description = intl.formatMessage({
id: 'persistent_notifications.error.special_mentions',
defaultMessage: 'Cannot use @channel, @all or @here to mention recipients of persistent notifications.',
});
} else {
const formattedMentionsList = mentionsList.map((mention) => mention.slice(1));
const usersCount = database ? await getUsersCountFromMentions(database, formattedMentionsList) : 0;
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 {
error = false;
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 {1 minute} other {{interval} minutes}} until theyve acknowledged or replied to the message.',
}, {
interval: persistentNotificationInterval,
});
}
}
Alert.alert(
title,
description,
error ? [{
text: intl.formatMessage({
id: 'persistent_notifications.error.okay',
defaultMessage: 'Okay',
}),
style: 'cancel',
}] : [
{
text: intl.formatMessage({
id: 'persistent_notifications.confirm.cancel',
defaultMessage: 'Cancel',
}),
style: 'cancel',
},
{
text: intl.formatMessage({
id: 'persistent_notifications.confirm.send',
defaultMessage: 'Send',
}),
onPress: sendMessage,
},
],
);
} else {
sendMessage();
}
}, [database, mentionsList, persistenNotificationsEnabled, persistentNotificationMaxRecipients, sendMessage, value]);
const sendActionDisabled = !canSend || noMentionsError;
return (
<>
<Typing
@@ -156,11 +270,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}
@@ -196,8 +309,8 @@ export default function DraftInput({
/>
<SendAction
testID={sendActionTestID}
disabled={!canSend}
sendMessage={sendMessage}
disabled={sendActionDisabled}
sendMessage={handleSendMessage}
/>
</View>
</ScrollView>

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,22 @@
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 {POST_PRIORITY_PICKER_BUTTON} from '@screens/post_priority_picker';
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({
@@ -35,34 +35,25 @@ export default function PostPriorityAction({
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,
},
});
}, [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,13 +10,15 @@ 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 {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';
import type DraftModel from '@typings/database/models/servers/draft';
type OwnProps = {
rootId: string;
@@ -24,6 +26,8 @@ type OwnProps = {
channelIsArchived?: boolean;
}
const observeFirst = (v: DraftModel[]) => v[0]?.observe() || of$(undefined);
const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) => {
const database = ownProps.database;
const {rootId, channelId} = ownProps;
@@ -42,9 +46,22 @@ const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) =>
switchMap((u) => of$(u?.status === General.OUT_OF_OFFICE)),
);
const postPriority = queryDraft(database, channelId, rootId).observeWithColumns(['metadata']).pipe(
switchMap(observeFirst),
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 +74,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 +82,7 @@ const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) =>
const customEmojis = queryAllCustomEmojis(database).observe();
return {
channelType,
currentUserId,
enableConfirmNotificationsToChannel,
isTimezoneEnabled,
@@ -72,6 +91,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,186 @@
// 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} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {acknowledgePost, unacknowledgePost} from '@actions/remote/post';
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 = {
currentUser: UserModel;
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),
},
listHeader: {
marginBottom: 12,
},
listHeaderText: {
color: theme.centerChannelColor,
...typography('Heading', 600, 'SemiBold'),
},
};
});
const Acknowledgements = ({currentUser, hasReactions, location, post, theme}: Props) => {
const intl = useIntl();
const isTablet = useIsTablet();
const {bottom} = useSafeAreaInsets();
const serverUrl = useServerUrl();
const style = getStyleSheet(theme);
const isCurrentAuthor = post.userId === currentUser.id;
const acknowledgements = post.metadata?.acknowledgements || [];
const acknowledgedAt = useMemo(() => {
if (acknowledgements.length > 0) {
const ack = acknowledgements.find((item) => item.user_id === currentUser.id);
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, serverUrl]);
const handleOnLongPress = useCallback(() => {
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);
});
const renderContent = () => (
<>
{!isTablet && (
<View style={style.listHeader}>
<FormattedText
id='mobile.participants.header'
defaultMessage={'Thread Participants'}
style={style.listHeaderText}
/>
</View>
)}
<UsersList
channelId={post.channelId}
location={location}
userAcknowledgements={userAcknowledgements}
userIds={userIds}
/>
</>
);
const snapPoints: BottomSheetProps['snapPoints'] = [1, bottomSheetSnapPoint(Math.min(userIds.length, 5), USER_ROW_HEIGHT, bottom) + TITLE_HEIGHT];
if (userIds.length > 5) {
snapPoints.push('80%');
}
bottomSheet({
closeButtonId: 'close-ack-users-list',
renderContent,
initialSnapIndex: 1,
snapPoints,
title: intl.formatMessage({id: 'mobile.participants.header', defaultMessage: 'Thread Participants'}),
theme,
});
}, [bottom, intl, isTablet, acknowledgements, theme, location, post.channelId]);
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,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,77 @@
// 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 {Keyboard, StyleSheet, Text, TouchableOpacity, View} from 'react-native';
import UserItem from '@components/user_item';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {dismissBottomSheet, openAsBottomSheet} from '@screens/navigation';
import {typography} from '@utils/typography';
import type UserModel from '@typings/database/models/servers/user';
export const USER_ROW_HEIGHT = 60;
const style = StyleSheet.create({
container: {
paddingLeft: 0,
height: USER_ROW_HEIGHT,
},
pictureContainer: {
alignItems: 'flex-start',
width: 40,
},
ackContainer: {
paddingLeft: 4,
},
time: {
...typography('Body', 75),
},
});
type Props = {
channelId: string;
location: string;
user: UserModel;
userAcknowledgement: number;
}
const UserListItem = ({channelId, location, user, userAcknowledgement}: Props) => {
const intl = useIntl();
const theme = useTheme();
const openUserProfile = async () => {
if (user) {
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: user.id, channelId};
Keyboard.dismiss();
openAsBottomSheet({screen, title, theme, closeButtonId, props});
}
};
return (
<TouchableOpacity onPress={openUserProfile}>
<UserItem
FooterComponent={
<View style={style.ackContainer}>
<Text style={style.time}>
{userAcknowledgement}
</Text>
</View>
}
containerStyle={style.container}
pictureContainerStyle={style.pictureContainer}
size={40}
user={user}
/>
</TouchableOpacity>
);
};
export default UserListItem;

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef, useState} from 'react';
import {ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent, PanResponder} from 'react-native';
import {FlatList} from 'react-native-gesture-handler';
import UserListItem from './user_list_item';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
channelId: string;
location: string;
users: UserModel[];
userAcknowledgements: Record<string, number>;
};
const UsersList = ({channelId, location, users, userAcknowledgements}: Props) => {
const [enabled, setEnabled] = useState(false);
const [direction, setDirection] = useState<'down' | 'up'>('down');
const listRef = useRef<FlatList>(null);
const prevOffset = useRef(0);
const panResponder = useRef(PanResponder.create({
onMoveShouldSetPanResponderCapture: (evt, g) => {
const dir = prevOffset.current < g.dy ? 'down' : 'up';
prevOffset.current = g.dy;
if (!enabled && dir === 'up') {
setEnabled(true);
}
setDirection(dir);
return false;
},
})).current;
const onScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
if (e.nativeEvent.contentOffset.y <= 0 && enabled && direction === 'down') {
setEnabled(false);
listRef.current?.scrollToOffset({animated: true, offset: 0});
}
}, [enabled, direction]);
const renderItem = useCallback(({item}: ListRenderItemInfo<UserModel>) => (
<UserListItem
channelId={channelId}
location={location}
user={item}
userAcknowledgement={userAcknowledgements[item.id]}
/>
), [channelId, location, userAcknowledgements]);
return (
<FlatList
data={users}
ref={listRef}
renderItem={renderItem}
onScroll={onScroll}
overScrollMode={'always'}
scrollEnabled={enabled}
scrollEventThrottle={60}
{...panResponder.panHandlers}
/>
);
};
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';
@@ -19,10 +20,12 @@ import Message from './message';
import Reactions from './reactions';
import type PostModel from '@typings/database/models/servers/post';
import type UserProfile from '@typings/database/models/servers/user';
import type {SearchPattern} from '@typings/global/markdown';
type BodyProps = {
appsEnabled: boolean;
currentUser: UserProfile;
hasFiles: boolean;
hasReactions: boolean;
highlight: boolean;
@@ -33,6 +36,7 @@ type BodyProps = {
isJumboEmoji: boolean;
isLastReply?: boolean;
isPendingOrFailed: boolean;
isPostAcknowledgementEnabled?: boolean;
isPostAddChannelMember: boolean;
location: string;
post: PostModel;
@@ -43,6 +47,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,
@@ -75,8 +86,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
});
const Body = ({
appsEnabled, hasFiles, hasReactions, highlight, highlightReplyBar,
isCRTEnabled, isEphemeral, isFirstReply, isJumboEmoji, isLastReply, isPendingOrFailed, isPostAddChannelMember,
appsEnabled, currentUser, hasFiles, hasReactions, highlight, highlightReplyBar,
isCRTEnabled, isEphemeral, isFirstReply, isJumboEmoji, isLastReply, isPendingOrFailed, isPostAcknowledgementEnabled, isPostAddChannelMember,
location, post, searchPatterns, showAddReaction, theme,
}: BodyProps) => {
const style = getStyleSheet(theme);
@@ -158,6 +169,8 @@ const Body = ({
);
}
const acknowledgementsVisible = isPostAcknowledgementEnabled && post.metadata?.priority?.requested_ack;
const reactionsVisible = hasReactions && showAddReaction;
if (!hasBeenDeleted) {
body = (
<View style={style.messageBody}>
@@ -180,13 +193,26 @@ const Body = ({
isReplyPost={isReplyPost}
/>
}
{hasReactions && showAddReaction &&
<Reactions
location={location}
post={post}
theme={theme}
/>
}
{(acknowledgementsVisible || reactionsVisible) && (
<View style={style.ackAndReactionsContainer}>
{acknowledgementsVisible && (
<Acknowledgements
currentUser={currentUser}
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) => {
@@ -302,6 +303,7 @@ const Post = ({
body = (
<Body
appsEnabled={appsEnabled}
currentUser={currentUser}
hasFiles={hasFiles}
hasReactions={hasReactions}
highlight={Boolean(highlightedStyle)}
@@ -312,6 +314,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,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import React, {ReactNode, useMemo} from 'react';
import {IntlShape, useIntl} from 'react-intl';
import {StyleProp, Text, View, ViewStyle} from 'react-native';
@@ -19,10 +19,12 @@ import {getUserCustomStatus, isBot, isCustomStatusExpired, isGuest, isShared} fr
import type UserModel from '@typings/database/models/servers/user';
type AtMentionItemProps = {
FooterComponent?: ReactNode;
user?: UserProfile | UserModel;
containerStyle?: StyleProp<ViewStyle>;
currentUserId: string;
showFullName: boolean;
size?: number;
testID?: string;
isCustomStatusEnabled: boolean;
pictureContainerStyle?: StyleProp<ViewStyle>;
@@ -66,6 +68,13 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
alignItems: 'center',
justifyContent: 'center',
},
rowInfoBaseContainer: {
flex: 1,
},
rowInfoContainer: {
flex: 1,
flexDirection: 'row',
},
rowInfo: {
flexDirection: 'row',
overflow: 'hidden',
@@ -90,9 +99,11 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
const UserItem = ({
containerStyle,
FooterComponent,
user,
currentUserId,
showFullName,
size = 24,
testID,
isCustomStatusEnabled,
pictureContainerStyle,
@@ -133,63 +144,68 @@ const UserItem = ({
<View style={[style.rowPicture, pictureContainerStyle]}>
<ProfilePicture
author={user}
size={24}
size={size}
showStatus={false}
testID={`${userItemTestId}.profile_picture`}
/>
</View>
<View
style={[style.rowInfo, {maxWidth: shared ? '75%' : '85%'}]}
>
{bot && <BotTag testID={`${userItemTestId}.bot.tag`}/>}
{guest && <GuestTag testID={`${userItemTestId}.guest.tag`}/>}
{Boolean(name.length) &&
<Text
style={style.rowFullname}
numberOfLines={1}
testID={`${userItemTestId}.display_name`}
<View style={style.rowInfoBaseContainer}>
<View style={style.rowInfoContainer}>
<View
style={[style.rowInfo, {maxWidth: shared ? '75%' : '85%'}]}
>
{name}
</Text>
}
{isCurrentUser &&
<FormattedText
id='suggestion.mention.you'
defaultMessage=' (you)'
style={style.rowUsername}
testID={`${userItemTestId}.current_user_indicator`}
/>
}
{Boolean(user) && (
<Text
style={usernameTextStyle}
numberOfLines={1}
testID={`${userItemTestId}.username`}
>
{` @${user!.username}`}
</Text>
)}
{bot && <BotTag testID={`${userItemTestId}.bot.tag`}/>}
{guest && <GuestTag testID={`${userItemTestId}.guest.tag`}/>}
{Boolean(name.length) &&
<Text
style={style.rowFullname}
numberOfLines={1}
testID={`${userItemTestId}.display_name`}
>
{name}
</Text>
}
{isCurrentUser &&
<FormattedText
id='suggestion.mention.you'
defaultMessage=' (you)'
style={style.rowUsername}
testID={`${userItemTestId}.current_user_indicator`}
/>
}
{Boolean(user) && (
<Text
style={usernameTextStyle}
numberOfLines={1}
testID={`${userItemTestId}.username`}
>
{` @${user!.username}`}
</Text>
)}
</View>
{Boolean(isCustomStatusEnabled && !bot && customStatus?.emoji && !customStatusExpired) && (
<CustomStatusEmoji
customStatus={customStatus!}
style={style.icon}
testID={userItemTestId}
/>
)}
{shared && (
<ChannelIcon
name={name}
isActive={false}
isArchived={false}
isInfo={true}
isUnread={false}
size={18}
shared={true}
type={General.DM_CHANNEL}
style={style.icon}
/>
)}
</View>
{FooterComponent}
</View>
{Boolean(isCustomStatusEnabled && !bot && customStatus?.emoji && !customStatusExpired) && (
<CustomStatusEmoji
customStatus={customStatus!}
style={style.icon}
testID={userItemTestId}
/>
)}
{shared && (
<ChannelIcon
name={name}
isActive={false}
isArchived={false}
isInfo={true}
isUnread={false}
size={18}
shared={true}
type={General.DM_CHANNEL}
style={style.icon}
/>
)}
</View>
);
};

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_+)@([a-z0-9.\-_]+)/gi;
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

@@ -40,6 +40,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';
@@ -109,6 +110,7 @@ export default {
PERMALINK,
PINNED_MESSAGES,
POST_OPTIONS,
POST_PRIORITY_PICKER,
REACTIONS,
REVIEW_APP,
SAVED_MESSAGES,
@@ -165,6 +167,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;

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$, combineLatest} 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,53 @@ 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 combineLatest([featureFlag, cfg]).pipe(
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 combineLatest([user, enabledForAll, enabledForGuests]).pipe(
switchMap(([u, forAll, forGuests]) => {
if (u?.isGuest) {
return of$(forAll && forGuests);
}
return of$(forAll);
}),
distinctUntilChanged(),
);
};
export const getUsersCountFromMentions = 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]);
let count = usersCount;
groups.forEach((group) => {
count += group.memberCount;
});
return count;
};

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

@@ -170,6 +170,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,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BottomSheetFooter, 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, isTablet: boolean) => ({
container: {
backgroundColor: theme.centerChannelBg,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.16),
borderTopWidth: 1,
paddingTop: 20,
paddingBottom: Platform.select({ios: (isTablet ? 20 : 32), android: 20}),
},
buttonsContainer: {
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, useIsTablet());
return (
<BottomSheetFooter {...props}>
<View style={style.container}>
<View style={style.buttonsContainer}>
<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>
</View>
</BottomSheetFooter>
);
};
export default PostPriorityPickerFooter;

View File

@@ -0,0 +1,21 @@
// 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, {POST_PRIORITY_PICKER_BUTTON} 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));
export {POST_PRIORITY_PICKER_BUTTON};

View File

@@ -3,32 +3,40 @@
import React from 'react';
import OptionItem, {OptionItemProps} from '@components/option_item';
import OptionItem, {OptionItemProps, OptionType} 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: {
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
labelContainer: {
alignItems: 'flex-start',
},
optionLabelText: {
color: theme.centerChannelColor,
...typography('Body', 200, 'Regular'),
},
}));
const PostPriorityPickerItem = (props: Omit<OptionItemProps, 'type'>) => {
type Props = Omit<OptionItemProps, 'type'> & {
type?: OptionType;
}
const PickerOption = (props: Props) => {
const theme = useTheme();
const style = getStyle(theme);
const style = getStyleSheet(theme);
const testID = `post_priority_picker_item.${props.value || 'standard'}`;
return (
<OptionItem
optionLabelTextStyle={style.optionLabelTextStyle}
labelContainerStyle={style.labelContainer}
optionLabelTextStyle={style.optionLabelText}
testID={testID}
type='select'
{...props}
type={props.type || 'select'}
/>
);
};
export default PostPriorityPickerItem;
export default PickerOption;

View File

@@ -0,0 +1,258 @@
// 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 {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 Footer from './footer';
import PickerOption from './picker_option';
import type {BottomSheetFooterProps} from '@gorhom/bottom-sheet';
export const POST_PRIORITY_PICKER_BUTTON = 'close-post-priority-picker';
type Props = {
componentId: string;
isPostAcknowledgementEnabled: boolean;
isPersistenNotificationsEnabled: boolean;
postPriority: PostPriority;
updatePostPriority: (data: PostPriority) => void;
};
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,
}: Props) => {
const {bottom} = useSafeAreaInsets();
const intl = useIntl();
const isTablet = useIsTablet();
const theme = useTheme();
const style = getStyleSheet(theme);
const close = useCallback(() => {
return dismissBottomSheet(Screens.POST_PRIORITY_PICKER);
}, []);
useNavButtonPressed(POST_PRIORITY_PICKER_BUTTON, componentId, close, []);
const [data, setData] = useState<PostPriority>(postPriority);
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]);
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);
close();
}, [data]);
const renderAcknowledgementOption = () => (
<View style={style.toggleOptionContainer}>
<PickerOption
action={handleUpdateRequestedAck}
label={
intl.formatMessage({
id: 'post_priority.picker.label.request_ack',
defaultMessage: 'Request acknowledgement',
})
}
description={
intl.formatMessage({
id: 'post_priority.picker.label.request_ack.description',
defaultMessage: 'An acknowledgement button appears with your message.',
})
}
icon='check-circle-outline'
type='toggle'
selected={data.requested_ack}
/>
</View>
);
const renderPersistentNotificationsOption = () => (
<View style={style.toggleOptionContainer}>
<PickerOption
action={handleUpdatePersistentNotifications}
label={
intl.formatMessage({
id: 'post_priority.picker.label.persistent_notifications',
defaultMessage: 'Send persistent notifications',
})
}
description={
intl.formatMessage({
id: 'post_priority.picker.label.persistent_notifications.description',
defaultMessage: 'Recipients are notified every five minutes until they acknowledge or reply.',
})
}
icon='bell-ring-outline'
type='toggle'
selected={data.persistent_notifications}
/>
</View>
);
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({
id: 'post_priority.picker.label.standard',
defaultMessage: 'Standard',
})}
selected={data.priority === ''}
value={PostPriorityType.STANDARD}
/>
<PickerOption
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}
/>
<PickerOption
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}
/>
{(isPostAcknowledgementEnabled) && (
<>
<View style={style.optionsSeparator}/>
{renderAcknowledgementOption()}
{displayPersistentNotifications && renderPersistentNotificationsOption()}
</>
)}
</View>
</View>
);
const renderFooter = (props: BottomSheetFooterProps) => (
<Footer
{...props}
onCancel={close}
onSubmit={handleSubmit}
/>
);
return (
<BottomSheet
renderContent={renderContent}
closeButtonId={POST_PRIORITY_PICKER_BUTTON}
componentId={Screens.POST_PRIORITY_PICKER}
footerComponent={renderFooter}
initialSnapIndex={1}
snapPoints={snapPoints}
testID='post_options'
/>
);
};
export default React.memo(PostPriorityPicker);

View File

@@ -4,6 +4,7 @@
import {Post} from '@constants';
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';
@@ -85,3 +86,7 @@ export const getLastFetchedAtFromPosts = (posts?: Post[]) => {
return Math.max(maxTimestamp, timestamp);
}, 0) || 0;
};
export const moreThan5minAgo = (time: number) => {
return Date.now() - time > toMilliseconds({minutes: 5});
};

View File

@@ -53,7 +53,7 @@ export function getComponents(inColor: string): {red: number; green: number; blu
};
}
export function makeStyleSheetFromTheme<T extends NamedStyles<T>>(getStyleFromTheme: (a: Theme) => T): (a: Theme) => T {
export function makeStyleSheetFromTheme<T extends NamedStyles<T>>(getStyleFromTheme: (a: Theme, ...args: unknown[]) => T): (a: Theme, ...args: unknown[]) => T {
let lastTheme: Theme;
let style: T;
return (theme: Theme) => {

View File

@@ -776,6 +776,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",
@@ -798,12 +808,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.cancel": "Cancel",
"post_priority.picker.beta": "BETA",
"post_priority.picker.label.important": "Important",
"post_priority.picker.label.standard": "Standard",
"post_priority.picker.label.urgent": "Urgent",
"post_priority.picker.label.request_ack": "Request acknowledgement",
"post_priority.picker.label.request_ack.description": "An acknowledgement button appears with your message.",
"post_priority.picker.title": "Message priority",
"post.options.title": "Options",
"post.reactions.title": "Reactions",

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>;
reactions?: Reaction[];
priority?: PostPriorityData;
priority?: PostPriority;
};
type Post = {

View File

@@ -25,6 +25,8 @@ declare class DraftModel extends Model {
/** files : The files field will hold an array of files object that have not yet been uploaded and persisted within the FILE table */
files: FileInfo[];
metadata?: PostMetadata;
}
export default DraftModel;

View File

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