forked from Ivasoft/mattermost-mobile
Compare commits
11 Commits
main
...
MM-49540_p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a32fd7b9f | ||
|
|
fddf44072d | ||
|
|
4b30ce3f9a | ||
|
|
76398ca2c8 | ||
|
|
81c89d51c2 | ||
|
|
b2f90f9b52 | ||
|
|
ab92d224c7 | ||
|
|
8d109715f8 | ||
|
|
7154ce75c2 | ||
|
|
3534383507 | ||
|
|
849e05e2c5 |
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
81
app/components/post_draft/draft_input/header.tsx
Normal file
81
app/components/post_draft/draft_input/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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. You’ll need to change who you’ve 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. You’ll 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 they’ve 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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
186
app/components/post_list/post/body/acknowledgements/index.tsx
Normal file
186
app/components/post_list/post/body/acknowledgements/index.tsx
Normal 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;
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -35,7 +35,7 @@ const style = StyleSheet.create({
|
||||
});
|
||||
|
||||
type Props = {
|
||||
label: PostPriorityData['priority'];
|
||||
label: PostPriority['priority'];
|
||||
};
|
||||
|
||||
const PostPriorityLabel = ({label}: Props) => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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}))),
|
||||
|
||||
@@ -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;
|
||||
|
||||
90
app/screens/post_priority_picker/footer.tsx
Normal file
90
app/screens/post_priority_picker/footer.tsx
Normal 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;
|
||||
21
app/screens/post_priority_picker/index.ts
Normal file
21
app/screens/post_priority_picker/index.ts
Normal 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};
|
||||
@@ -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;
|
||||
258
app/screens/post_priority_picker/post_priority_picker.tsx
Normal file
258
app/screens/post_priority_picker/post_priority_picker.tsx
Normal 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);
|
||||
@@ -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});
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 they’ve 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. You’ll need to change who you’ve 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. You’ll 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",
|
||||
|
||||
5
types/api/config.d.ts
vendored
5
types/api/config.d.ts
vendored
@@ -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
15
types/api/posts.d.ts
vendored
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
1
types/database/raw_values.d.ts
vendored
1
types/database/raw_values.d.ts
vendored
@@ -21,6 +21,7 @@ type Draft = {
|
||||
files?: FileInfo[];
|
||||
message?: string;
|
||||
root_id: string;
|
||||
metadata?: PostMetadata;
|
||||
};
|
||||
|
||||
type MyTeam = {
|
||||
|
||||
Reference in New Issue
Block a user