From ab4f65020a7b8d51e49288218af1e0665fe1de60 Mon Sep 17 00:00:00 2001 From: Kyriakos Z <3829551+koox00@users.noreply.github.com> Date: Thu, 27 Apr 2023 11:22:03 +0000 Subject: [PATCH] [MM-49540] Message Priority Phase 3 (#7142) * Init * i18 and types * Acknowledge button, api * Ack button + display ackd users * Saves priority on draft and addresses some comments * Addresses review comments round 2 * Moves fetching userprofiles upon opening ACKs * Adds metadata column in drafts table + Addresses some more review comments. * Small refactor according to review comments * Addresses some review comments * Addresses some review comments * Uses local action when ACKing * Fixes first time selecting priority and other * Updates snapshots * Fixes i18n * Fixes ts errors --------- Co-authored-by: Anurag Shivarathri Co-authored-by: Mattermost Build --- app/actions/local/draft.ts | 34 ++ app/actions/local/post.ts | 77 ++- app/actions/remote/post.ts | 91 +-- app/actions/websocket/index.ts | 9 +- app/actions/websocket/posts.ts | 41 +- app/client/rest/posts.ts | 16 + app/components/channel_item/index.ts | 22 +- app/components/option_item/index.tsx | 6 +- .../post_draft/draft_input/header.tsx | 84 +++ .../post_draft/draft_input/index.tsx | 63 ++- app/components/post_draft/index.ts | 9 +- .../post_draft/quick_actions/index.ts | 3 +- .../post_priority_action/index.tsx | 53 +- .../quick_actions/quick_actions.tsx | 4 +- .../post_draft/send_handler/index.ts | 25 +- .../post_draft/send_handler/send_handler.tsx | 30 +- app/components/post_list/index.ts | 3 +- .../acknowledgements/acknowledgements.tsx | 194 +++++++ .../post/body/acknowledgements/index.ts | 29 + .../body/acknowledgements/users_list/index.ts | 23 + .../users_list/user_list_item.tsx | 84 +++ .../users_list/users_list.tsx | 43 ++ app/components/post_list/post/body/index.tsx | 39 +- .../post/body/reactions/reactions.tsx | 13 +- app/components/post_list/post/index.ts | 3 +- app/components/post_list/post/post.tsx | 4 +- app/components/post_list/post_list.tsx | 5 +- .../post_priority/post_priority_label.tsx | 2 +- .../post_priority_picker/index.tsx | 122 ---- .../post_priority_picker_item.tsx | 34 -- app/components/user_item/user_item.tsx | 132 +++-- .../__snapshots__/index.test.tsx.snap | 520 +++++++++++------- app/constants/autocomplete.test.ts | 32 ++ app/constants/autocomplete.ts | 6 + app/constants/screens.ts | 3 + app/constants/websocket.ts | 2 + .../server_data_operator/transformers/post.ts | 9 +- app/helpers/api/user.ts | 20 +- app/queries/servers/drafts.ts | 5 + app/queries/servers/post.ts | 57 +- app/queries/servers/system.ts | 11 +- app/screens/index.tsx | 3 + .../components/picker_option.tsx | 42 ++ app/screens/post_priority_picker/footer.tsx | 90 +++ app/screens/post_priority_picker/index.ts | 20 + .../post_priority_picker.tsx | 229 ++++++++ app/screens/post_priority_picker/utils.ts | 45 ++ app/store/ephemeral_store.ts | 26 + app/utils/post/index.test.ts | 52 ++ app/utils/post/index.ts | 95 ++++ assets/base/i18n/en.json | 19 + types/api/config.d.ts | 5 + types/api/posts.d.ts | 15 +- types/database/raw_values.d.ts | 1 + 54 files changed, 2029 insertions(+), 575 deletions(-) create mode 100644 app/components/post_draft/draft_input/header.tsx create mode 100644 app/components/post_list/post/body/acknowledgements/acknowledgements.tsx create mode 100644 app/components/post_list/post/body/acknowledgements/index.ts create mode 100644 app/components/post_list/post/body/acknowledgements/users_list/index.ts create mode 100644 app/components/post_list/post/body/acknowledgements/users_list/user_list_item.tsx create mode 100644 app/components/post_list/post/body/acknowledgements/users_list/users_list.tsx delete mode 100644 app/components/post_priority/post_priority_picker/index.tsx delete mode 100644 app/components/post_priority/post_priority_picker/post_priority_picker_item.tsx create mode 100644 app/constants/autocomplete.test.ts create mode 100644 app/screens/post_priority_picker/components/picker_option.tsx create mode 100644 app/screens/post_priority_picker/footer.tsx create mode 100644 app/screens/post_priority_picker/index.ts create mode 100644 app/screens/post_priority_picker/post_priority_picker.tsx create mode 100644 app/screens/post_priority_picker/utils.ts create mode 100644 app/utils/post/index.test.ts diff --git a/app/actions/local/draft.ts b/app/actions/local/draft.ts index 599875d0a..3530aeca4 100644 --- a/app/actions/local/draft.ts +++ b/app/actions/local/draft.ts @@ -155,3 +155,37 @@ export const removeDraft = async (serverUrl: string, channelId: string, rootId = return {error}; } }; + +export async function updateDraftPriority(serverUrl: string, channelId: string, rootId: string, postPriority: PostPriority, prepareRecordsOnly = false) { + try { + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const draft = await getDraft(database, channelId, rootId); + if (!draft) { + const newDraft: Draft = { + channel_id: channelId, + root_id: rootId, + metadata: { + priority: postPriority, + }, + }; + + return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly}); + } + + draft.prepareUpdate((d) => { + d.metadata = { + ...d.metadata, + priority: postPriority, + }; + }); + + if (!prepareRecordsOnly) { + await operator.batchRecords([draft], 'updateDraftPriority'); + } + + return {draft}; + } catch (error) { + logError('Failed updateDraftPriority', error); + return {error}; + } +} diff --git a/app/actions/local/post.ts b/app/actions/local/post.ts index e15d366d4..7adbc6771 100644 --- a/app/actions/local/post.ts +++ b/app/actions/local/post.ts @@ -5,7 +5,7 @@ import {fetchPostAuthors} from '@actions/remote/post'; import {ActionType, Post} from '@constants'; import {MM_TABLES} from '@constants/database'; import DatabaseManager from '@database/manager'; -import {getPostById, prepareDeletePost, queryPostsById} from '@queries/servers/post'; +import {countUsersFromMentions, getPostById, prepareDeletePost, queryPostsById} from '@queries/servers/post'; import {getCurrentUserId} from '@queries/servers/system'; import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread'; import {generateId} from '@utils/general'; @@ -249,6 +249,72 @@ export async function getPosts(serverUrl: string, ids: string[], sort?: Q.SortOr } } +export async function addPostAcknowledgement(serverUrl: string, postId: string, userId: string, acknowledgedAt: number, prepareRecordsOnly = false) { + try { + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const post = await getPostById(database, postId); + if (!post) { + throw new Error('Post not found'); + } + + // Check if the post has already been acknowledged by the user + const isAckd = post.metadata?.acknowledgements?.find((a) => a.user_id === userId); + if (isAckd) { + return {error: false}; + } + + const acknowledgements = [...(post.metadata?.acknowledgements || []), { + user_id: userId, + acknowledged_at: acknowledgedAt, + post_id: postId, + }]; + + const model = post.prepareUpdate((p) => { + p.metadata = { + ...p.metadata, + acknowledgements, + }; + }); + + if (!prepareRecordsOnly) { + await operator.batchRecords([model], 'addPostAcknowledgement'); + } + + return {model}; + } catch (error) { + logError('Failed addPostAcknowledgement', error); + return {error}; + } +} + +export async function removePostAcknowledgement(serverUrl: string, postId: string, userId: string, prepareRecordsOnly = false) { + try { + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const post = await getPostById(database, postId); + if (!post) { + throw new Error('Post not found'); + } + + const model = post.prepareUpdate((record) => { + record.metadata = { + ...post.metadata, + acknowledgements: post.metadata?.acknowledgements?.filter( + (a) => a.user_id !== userId, + ) || [], + }; + }); + + if (!prepareRecordsOnly) { + await operator.batchRecords([model], 'removePostAcknowledgement'); + } + + return {model}; + } catch (error) { + logError('Failed removePostAcknowledgement', error); + return {error}; + } +} + export async function deletePosts(serverUrl: string, postIds: string[]) { try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -276,3 +342,12 @@ export async function deletePosts(serverUrl: string, postIds: string[]) { return {error}; } } + +export function getUsersCountFromMentions(serverUrl: string, mentions: string[]): Promise { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + return countUsersFromMentions(database, mentions); + } catch (error) { + return Promise.resolve(0); + } +} diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index b6d4e11a8..48b5a9e4d 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -7,7 +7,7 @@ import {DeviceEventEmitter} from 'react-native'; import {markChannelAsUnread, updateLastPostAt} from '@actions/local/channel'; -import {removePost, storePostsForChannel} from '@actions/local/post'; +import {addPostAcknowledgement, removePost, removePostAcknowledgement, storePostsForChannel} from '@actions/local/post'; import {addRecentReaction} from '@actions/local/reactions'; import {createThreadFromNewPost} from '@actions/local/thread'; import {ActionType, Events, General, Post, ServerErrors} from '@constants'; @@ -23,6 +23,7 @@ import {getPostById, getRecentPostsInChannel} from '@queries/servers/post'; import {getCurrentUserId, getCurrentChannelId} from '@queries/servers/system'; import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread'; import {queryAllUsers} from '@queries/servers/user'; +import EphemeralStore from '@store/ephemeral_store'; import {setFetchingThreadState} from '@store/fetching_thread_store'; import {getValidEmojis, matchEmoticons} from '@utils/emoji/helpers'; import {isServerError} from '@utils/errors'; @@ -500,41 +501,32 @@ export async function fetchPostsSince(serverUrl: string, channelId: string, sinc } export const fetchPostAuthors = async (serverUrl: string, posts: Post[], fetchOnly = false): Promise => { - 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(); - const existingUserNames = new Set(); - 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(); + const existingUserNames = new Set(); + 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(); + for (const p of posts) { + const {user_id} = p; + if (user_id !== currentUserId) { + userIdsToLoad.add(user_id); + } } - }); - const usernamesToLoad = getNeededAtMentionedUsernames(existingUserNames, posts, excludeUsername); - const userIdsToLoad = new Set(); - for (const p of posts) { - const {user_id} = p; - if (user_id !== currentUserId) { - userIdsToLoad.add(user_id); - } - } - - try { const promises: Array> = []; if (userIdsToLoad.size) { promises.push(client.getProfilesByIds(Array.from(userIdsToLoad))); @@ -1130,3 +1122,38 @@ export async function fetchPinnedPosts(serverUrl: string, channelId: string) { return {error}; } } + +export async function acknowledgePost(serverUrl: string, postId: string) { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const client = NetworkManager.getClient(serverUrl); + EphemeralStore.setAcknowledgingPost(postId); + + const userId = await getCurrentUserId(database); + const {acknowledged_at: acknowledgedAt} = await client.acknowledgePost(postId, userId); + + return addPostAcknowledgement(serverUrl, postId, userId, acknowledgedAt, false); + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } finally { + EphemeralStore.unsetAcknowledgingPost(postId); + } +} + +export async function unacknowledgePost(serverUrl: string, postId: string) { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const client = NetworkManager.getClient(serverUrl); + EphemeralStore.setUnacknowledgingPost(postId); + const userId = await getCurrentUserId(database); + await client.unacknowledgePost(postId, userId); + + return removePostAcknowledgement(serverUrl, postId, userId, false); + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } finally { + EphemeralStore.unsetUnacknowledgingPost(postId); + } +} diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index d8200e76e..2c1c52713 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -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; diff --git a/app/actions/websocket/posts.ts b/app/actions/websocket/posts.ts index b5ae15186..21e113ae4 100644 --- a/app/actions/websocket/posts.ts +++ b/app/actions/websocket/posts.ts @@ -4,11 +4,12 @@ import {DeviceEventEmitter} from 'react-native'; import {storeMyChannelsForTeam, markChannelAsUnread, markChannelAsViewed, updateLastPostAt} from '@actions/local/channel'; -import {markPostAsDeleted} from '@actions/local/post'; +import {addPostAcknowledgement, markPostAsDeleted, removePostAcknowledgement} from '@actions/local/post'; import {createThreadFromNewPost, updateThread} from '@actions/local/thread'; import {fetchChannelStats, fetchMyChannel} from '@actions/remote/channel'; import {fetchPostAuthors, fetchPostById} from '@actions/remote/post'; import {fetchThread} from '@actions/remote/thread'; +import {fetchMissingProfilesByIds} from '@actions/remote/user'; import {ActionType, Events, Screens} from '@constants'; import DatabaseManager from '@database/manager'; import {getChannelById, getMyChannel} from '@queries/servers/channel'; @@ -326,3 +327,41 @@ export async function handlePostUnread(serverUrl: string, msg: WebSocketMessage) markChannelAsUnread(serverUrl, channelId, delta, mentions, lastViewedAt); } } + +export async function handlePostAcknowledgementAdded(serverUrl: string, msg: WebSocketMessage) { + try { + const acknowledgement = JSON.parse(msg.data.acknowledgement); + const {user_id, post_id, acknowledged_at} = acknowledgement; + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return; + } + const currentUserId = getCurrentUserId(database); + if (EphemeralStore.isAcknowledgingPost(post_id) && currentUserId === user_id) { + return; + } + + addPostAcknowledgement(serverUrl, post_id, user_id, acknowledged_at); + fetchMissingProfilesByIds(serverUrl, [user_id]); + } catch (error) { + // Do nothing + } +} + +export async function handlePostAcknowledgementRemoved(serverUrl: string, msg: WebSocketMessage) { + try { + const acknowledgement = JSON.parse(msg.data.acknowledgement); + const {user_id, post_id} = acknowledgement; + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return; + } + const currentUserId = getCurrentUserId(database); + if (EphemeralStore.isUnacknowledgingPost(post_id) && currentUserId === user_id) { + return; + } + await removePostAcknowledgement(serverUrl, post_id, user_id); + } catch (error) { + // Do nothing + } +} diff --git a/app/client/rest/posts.ts b/app/client/rest/posts.ts index 945570828..ba0be13e1 100644 --- a/app/client/rest/posts.ts +++ b/app/client/rest/posts.ts @@ -31,6 +31,8 @@ export interface ClientPostsMix { searchPosts: (teamId: string, terms: string, isOrSearch: boolean) => Promise; doPostAction: (postId: string, actionId: string, selectedOption?: string) => Promise; doPostActionWithCookie: (postId: string, actionId: string, actionCookie: string, selectedOption?: string) => Promise; + acknowledgePost: (postId: string, userId: string) => Promise; + unacknowledgePost: (postId: string, userId: string) => Promise; } const ClientPosts = >(superclass: TBase) => class extends superclass { @@ -240,6 +242,20 @@ const ClientPosts = >(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; diff --git a/app/components/channel_item/index.ts b/app/components/channel_item/index.ts index 9683b61aa..841cdd3fa 100644 --- a/app/components/channel_item/index.ts +++ b/app/components/channel_item/index.ts @@ -39,11 +39,23 @@ const enhance = withObservables(['channel', 'showTeamName', 'shouldHighlightActi const currentUserId = observeCurrentUserId(database); const myChannel = observeMyChannel(database, channel.id); - const hasDraft = shouldHighlightState ? - queryDraft(database, channel.id).observeWithColumns(['message', 'files']).pipe( - switchMap((draft) => of$(draft.length > 0)), - distinctUntilChanged(), - ) : of$(false); + const hasDraft = shouldHighlightState ? queryDraft(database, channel.id).observeWithColumns(['message', 'files', 'metadata']).pipe( + switchMap((drafts) => { + if (!drafts.length) { + return of$(false); + } + + const draft = drafts[0]; + const standardPriority = draft?.metadata?.priority?.priority === ''; + + if (!draft.message && !draft.files.length && standardPriority) { + return of$(false); + } + + return of$(true); + }), + distinctUntilChanged(), + ) : of$(false); const isActive = shouldHighlightActive ? observeCurrentChannelId(database).pipe( diff --git a/app/components/option_item/index.tsx b/app/components/option_item/index.tsx index ac73d6b63..a0b5faa31 100644 --- a/app/components/option_item/index.tsx +++ b/app/components/option_item/index.tsx @@ -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; onRemove?: () => void; optionDescriptionTextStyle?: StyleProp; optionLabelTextStyle?: StyleProp; @@ -130,6 +131,7 @@ const OptionItem = ({ info, inline = false, label, + labelContainerStyle, onRemove, optionDescriptionTextStyle, optionLabelTextStyle, @@ -238,7 +240,7 @@ const OptionItem = ({ onLayout={onLayout} > - + {Boolean(icon) && ( ({ + container: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: 12, + gap: 7, + }, + error: { + color: PostPriorityColors.URGENT, + }, + acknowledgements: { + color: theme.onlineIndicator, + }, + paddingTopStyle: { + paddingTop: Platform.select({ios: 6, android: 8}), + }, +})); + +export default function DraftInputHeader({ + postPriority, + noMentionsError, +}: Props) { + const theme = useTheme(); + const hasLabels = postPriority.priority !== '' || postPriority.requested_ack; + const style = getStyleSheet(theme); + + return ( + + {postPriority.priority && ( + + )} + {postPriority.requested_ack && ( + <> + + {!postPriority.priority && ( + + )} + + )} + {postPriority.persistent_notifications && ( + <> + + {noMentionsError && ( + + )} + + )} + + ); +} diff --git a/app/components/post_draft/draft_input/index.tsx b/app/components/post_draft/draft_input/index.tsx index e489bb8f1..cb3c5c77c 100644 --- a/app/components/post_draft/draft_input/index.tsx +++ b/app/components/post_draft/draft_input/index.tsx @@ -1,12 +1,17 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useRef} from 'react'; +import React, {useCallback, useMemo, useRef} from 'react'; +import {useIntl} from 'react-intl'; import {type LayoutChangeEvent, Platform, ScrollView, View} from 'react-native'; import {type Edge, SafeAreaView} from 'react-native-safe-area-context'; -import PostPriorityLabel from '@components/post_priority/post_priority_label'; +import {General} from '@constants'; +import {MENTIONS_REGEX} from '@constants/autocomplete'; +import {PostPriorityType} from '@constants/post'; +import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; +import {persistentNotificationsConfirmation} from '@utils/post'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import PostInput from '../post_input'; @@ -15,18 +20,23 @@ import SendAction from '../send_action'; import Typing from '../typing'; import Uploads from '../uploads'; +import Header from './header'; + import type {PasteInputRef} from '@mattermost/react-native-paste-input'; type Props = { testID?: string; channelId: string; + channelType?: ChannelType; rootId?: string; currentUserId: string; canShowPostPriority?: boolean; // Post Props - postPriority: PostPriorityData; - updatePostPriority: (postPriority: PostPriorityData) => void; + postPriority: PostPriority; + updatePostPriority: (postPriority: PostPriority) => void; + persistentNotificationInterval: number; + persistentNotificationMaxRecipients: number; // Cursor Position Handler updateCursorPosition: React.Dispatch>; @@ -97,6 +107,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { export default function DraftInput({ testID, channelId, + channelType, currentUserId, canShowPostPriority, files, @@ -113,8 +124,12 @@ export default function DraftInput({ updatePostInputTop, postPriority, updatePostPriority, + persistentNotificationInterval, + persistentNotificationMaxRecipients, setIsFocused, }: Props) { + const intl = useIntl(); + const serverUrl = useServerUrl(); const theme = useTheme(); const handleLayout = useCallback((e: LayoutChangeEvent) => { @@ -132,6 +147,31 @@ export default function DraftInput({ const sendActionTestID = `${testID}.send_action`; const style = getStyleSheet(theme); + const persistenNotificationsEnabled = postPriority.persistent_notifications && postPriority.priority === PostPriorityType.URGENT; + const {noMentionsError, mentionsList} = useMemo(() => { + let error = false; + let mentions: string[] = []; + if ( + channelType !== General.DM_CHANNEL && + persistenNotificationsEnabled + ) { + mentions = (value.match(MENTIONS_REGEX) || []); + error = mentions.length === 0; + } + + return {noMentionsError: error, mentionsList: mentions}; + }, [channelType, persistenNotificationsEnabled, value]); + + const handleSendMessage = useCallback(async () => { + if (persistenNotificationsEnabled) { + persistentNotificationsConfirmation(serverUrl, value, mentionsList, intl, sendMessage, persistentNotificationMaxRecipients, persistentNotificationInterval); + } else { + sendMessage(); + } + }, [serverUrl, mentionsList, persistenNotificationsEnabled, persistentNotificationMaxRecipients, sendMessage, value]); + + const sendActionDisabled = !canSend || noMentionsError; + return ( <> - {Boolean(postPriority?.priority) && ( - - - - )} +
@@ -196,8 +235,8 @@ export default function DraftInput({ /> diff --git a/app/components/post_draft/index.ts b/app/components/post_draft/index.ts index bea3d30d6..45b0626e3 100644 --- a/app/components/post_draft/index.ts +++ b/app/components/post_draft/index.ts @@ -9,7 +9,7 @@ import {switchMap} from 'rxjs/operators'; import {General, Permissions} from '@constants'; import {observeChannel} from '@queries/servers/channel'; -import {queryDraft} from '@queries/servers/drafts'; +import {queryDraft, observeFirstDraft} from '@queries/servers/drafts'; import {observePermissionForChannel} from '@queries/servers/role'; import {observeConfigBooleanValue, observeCurrentChannelId} from '@queries/servers/system'; import {observeCurrentUser, observeUser} from '@queries/servers/user'; @@ -18,7 +18,6 @@ import {isSystemAdmin, getUserIdFromChannelName} from '@utils/user'; import PostDraft from './post_draft'; import type {WithDatabaseArgs} from '@typings/database/database'; -import type DraftModel from '@typings/database/models/servers/draft'; type OwnProps = { channelId: string; @@ -26,8 +25,6 @@ type OwnProps = { rootId?: string; } -const observeFirst = (v: DraftModel[]) => v[0]?.observe() || of$(undefined); - const enhanced = withObservables(['channelId', 'rootId', 'channelIsArchived'], (ownProps: WithDatabaseArgs & OwnProps) => { const {database, rootId = ''} = ownProps; let channelId = of$(ownProps.channelId); @@ -36,8 +33,8 @@ const enhanced = withObservables(['channelId', 'rootId', 'channelIsArchived'], ( } const draft = channelId.pipe( - switchMap((cId) => queryDraft(database, cId, rootId).observeWithColumns(['message', 'files']).pipe( - switchMap(observeFirst), + switchMap((cId) => queryDraft(database, cId, rootId).observeWithColumns(['message', 'files', 'metadata']).pipe( + switchMap(observeFirstDraft), )), ); diff --git a/app/components/post_draft/quick_actions/index.ts b/app/components/post_draft/quick_actions/index.ts index 415d6359e..957923a72 100644 --- a/app/components/post_draft/quick_actions/index.ts +++ b/app/components/post_draft/quick_actions/index.ts @@ -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'; diff --git a/app/components/post_draft/quick_actions/post_priority_action/index.tsx b/app/components/post_draft/quick_actions/post_priority_action/index.tsx index 05a0d164c..2da7a6ada 100644 --- a/app/components/post_draft/quick_actions/post_priority_action/index.tsx +++ b/app/components/post_draft/quick_actions/post_priority_action/index.tsx @@ -3,22 +3,21 @@ import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; -import {StyleSheet} from 'react-native'; -import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {Keyboard, StyleSheet} from 'react-native'; import CompassIcon from '@components/compass_icon'; -import PostPriorityPicker, {COMPONENT_HEIGHT} from '@components/post_priority/post_priority_picker'; import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {Screens} from '@constants'; import {ICON_SIZE} from '@constants/post_draft'; import {useTheme} from '@context/theme'; -import {bottomSheet, dismissBottomSheet} from '@screens/navigation'; -import {bottomSheetSnapPoint} from '@utils/helpers'; +import {useIsTablet} from '@hooks/device'; +import {openAsBottomSheet} from '@screens/navigation'; import {changeOpacity} from '@utils/theme'; type Props = { testID?: string; - postPriority: PostPriorityData; - updatePostPriority: (postPriority: PostPriorityData) => void; + postPriority: PostPriority; + updatePostPriority: (postPriority: PostPriority) => void; } const style = StyleSheet.create({ @@ -29,40 +28,34 @@ const style = StyleSheet.create({ }, }); +const POST_PRIORITY_PICKER_BUTTON = 'close-post-priority-picker'; + export default function PostPriorityAction({ testID, postPriority, updatePostPriority, }: Props) { const intl = useIntl(); + const isTablet = useIsTablet(); const theme = useTheme(); - const {bottom} = useSafeAreaInsets(); - - const handlePostPriorityPicker = useCallback((postPriorityData: PostPriorityData) => { - updatePostPriority(postPriorityData); - dismissBottomSheet(); - }, [updatePostPriority]); - - const renderContent = useCallback(() => { - return ( - - ); - }, [handlePostPriorityPicker, postPriority]); const onPress = useCallback(() => { - bottomSheet({ - title: intl.formatMessage({id: 'post_priority.picker.title', defaultMessage: 'Message priority'}), - renderContent, - snapPoints: [1, bottomSheetSnapPoint(1, COMPONENT_HEIGHT, bottom)], + Keyboard.dismiss(); + + const title = isTablet ? intl.formatMessage({id: 'post_priority.picker.title', defaultMessage: 'Message priority'}) : ''; + + openAsBottomSheet({ + closeButtonId: POST_PRIORITY_PICKER_BUTTON, + screen: Screens.POST_PRIORITY_PICKER, theme, - closeButtonId: 'post-priority-close-id', + title, + props: { + postPriority, + updatePostPriority, + closeButtonId: POST_PRIORITY_PICKER_BUTTON, + }, }); - }, [intl, renderContent, theme, bottom]); + }, [intl, postPriority, updatePostPriority, theme]); const iconName = 'alert-circle-outline'; const iconColor = changeOpacity(theme.centerChannelColor, 0.64); diff --git a/app/components/post_draft/quick_actions/quick_actions.tsx b/app/components/post_draft/quick_actions/quick_actions.tsx index 944a166d0..a77cc645b 100644 --- a/app/components/post_draft/quick_actions/quick_actions.tsx +++ b/app/components/post_draft/quick_actions/quick_actions.tsx @@ -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; } diff --git a/app/components/post_draft/send_handler/index.ts b/app/components/post_draft/send_handler/index.ts index c80f1b592..2de5561d4 100644 --- a/app/components/post_draft/send_handler/index.ts +++ b/app/components/post_draft/send_handler/index.ts @@ -10,11 +10,12 @@ import {General, Permissions} from '@constants'; import {MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft'; import {observeChannel, observeChannelInfo, observeCurrentChannel} from '@queries/servers/channel'; import {queryAllCustomEmojis} from '@queries/servers/custom_emoji'; +import {observeFirstDraft, queryDraft} from '@queries/servers/drafts'; import {observePermissionForChannel} from '@queries/servers/role'; import {observeConfigBooleanValue, observeConfigIntValue, observeCurrentUserId} from '@queries/servers/system'; import {observeUser} from '@queries/servers/user'; -import SendHandler from './send_handler'; +import SendHandler, {INITIAL_PRIORITY} from './send_handler'; import type {WithDatabaseArgs} from '@typings/database/database'; @@ -36,15 +37,28 @@ const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) => const currentUserId = observeCurrentUserId(database); const currentUser = currentUserId.pipe( - switchMap((id) => observeUser(database, id), - )); + switchMap((id) => observeUser(database, id)), + ); const userIsOutOfOffice = currentUser.pipe( switchMap((u) => of$(u?.status === General.OUT_OF_OFFICE)), ); + const postPriority = queryDraft(database, channelId, rootId).observeWithColumns(['metadata']).pipe( + switchMap(observeFirstDraft), + switchMap((d) => { + if (!d?.metadata?.priority) { + return of$(INITIAL_PRIORITY); + } + + return of$(d.metadata.priority); + }), + ); + const enableConfirmNotificationsToChannel = observeConfigBooleanValue(database, 'EnableConfirmNotificationsToChannel'); const isTimezoneEnabled = observeConfigBooleanValue(database, 'ExperimentalTimezone'); const maxMessageLength = observeConfigIntValue(database, 'MaxPostSize', MAX_MESSAGE_LENGTH_FALLBACK); + const persistentNotificationInterval = observeConfigIntValue(database, 'PersistentNotificationInterval'); + const persistentNotificationMaxRecipients = observeConfigIntValue(database, 'PersistentNotificationMaxRecipients'); const useChannelMentions = combineLatest([channel, currentUser]).pipe( switchMap(([c, u]) => { @@ -57,6 +71,7 @@ const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) => ); const channelInfo = channel.pipe(switchMap((c) => (c ? observeChannelInfo(database, c.id) : of$(undefined)))); + const channelType = channel.pipe(switchMap((c) => of$(c?.type))); const membersCount = channelInfo.pipe( switchMap((i) => (i ? of$(i.memberCount) : of$(0))), ); @@ -64,6 +79,7 @@ const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) => const customEmojis = queryAllCustomEmojis(database).observe(); return { + channelType, currentUserId, enableConfirmNotificationsToChannel, isTimezoneEnabled, @@ -72,6 +88,9 @@ const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) => userIsOutOfOffice, useChannelMentions, customEmojis, + persistentNotificationInterval, + persistentNotificationMaxRecipients, + postPriority, }; }); diff --git a/app/components/post_draft/send_handler/send_handler.tsx b/app/components/post_draft/send_handler/send_handler.tsx index 7c8c4b4a8..3c6e3fd10 100644 --- a/app/components/post_draft/send_handler/send_handler.tsx +++ b/app/components/post_draft/send_handler/send_handler.tsx @@ -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(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({ ); diff --git a/app/components/post_list/index.ts b/app/components/post_list/index.ts index ae7ea4f3b..ce6a541da 100644 --- a/app/components/post_list/index.ts +++ b/app/components/post_list/index.ts @@ -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), }; }); diff --git a/app/components/post_list/post/body/acknowledgements/acknowledgements.tsx b/app/components/post_list/post/body/acknowledgements/acknowledgements.tsx new file mode 100644 index 000000000..68abe08e8 --- /dev/null +++ b/app/components/post_list/post/body/acknowledgements/acknowledgements.tsx @@ -0,0 +1,194 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {View, Text, TouchableOpacity, useWindowDimensions} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import {acknowledgePost, unacknowledgePost} from '@actions/remote/post'; +import {fetchMissingProfilesByIds} from '@actions/remote/user'; +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import {useServerUrl} from '@context/server'; +import {useIsTablet} from '@hooks/device'; +import {TITLE_HEIGHT} from '@screens/bottom_sheet/content'; +import {bottomSheet} from '@screens/navigation'; +import {bottomSheetSnapPoint} from '@utils/helpers'; +import {moreThan5minAgo} from '@utils/post'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import UsersList from './users_list'; +import {USER_ROW_HEIGHT} from './users_list/user_list_item'; + +import type {BottomSheetProps} from '@gorhom/bottom-sheet'; +import type PostModel from '@typings/database/models/servers/post'; +import type UserModel from '@typings/database/models/servers/user'; + +type Props = { + currentUserId: UserModel['id']; + currentUserTimezone: UserModel['timezone']; + hasReactions: boolean; + location: string; + post: PostModel; + theme: Theme; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + alignItems: 'center', + borderRadius: 4, + backgroundColor: changeOpacity(theme.onlineIndicator, 0.12), + flexDirection: 'row', + height: 32, + justifyContent: 'center', + paddingHorizontal: 8, + }, + containerActive: { + backgroundColor: theme.onlineIndicator, + }, + text: { + ...typography('Body', 100, 'SemiBold'), + color: theme.onlineIndicator, + }, + textActive: { + color: '#fff', + }, + icon: { + marginRight: 4, + }, + divider: { + width: 1, + height: 32, + marginHorizontal: 8, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.16), + }, + listHeaderText: { + marginBottom: 12, + color: theme.centerChannelColor, + ...typography('Heading', 600, 'SemiBold'), + }, + }; +}); + +const Acknowledgements = ({currentUserId, currentUserTimezone, hasReactions, location, post, theme}: Props) => { + const intl = useIntl(); + const isTablet = useIsTablet(); + const {bottom} = useSafeAreaInsets(); + const serverUrl = useServerUrl(); + const {height} = useWindowDimensions(); + + const style = getStyleSheet(theme); + + const isCurrentAuthor = post.userId === currentUserId; + const acknowledgements = post.metadata?.acknowledgements || []; + + const acknowledgedAt = useMemo(() => { + if (acknowledgements.length > 0) { + const ack = acknowledgements.find((item) => item.user_id === currentUserId); + + if (ack) { + return ack.acknowledged_at; + } + } + return 0; + }, [acknowledgements]); + + const handleOnPress = useCallback(() => { + if ((acknowledgedAt && moreThan5minAgo(acknowledgedAt)) || isCurrentAuthor) { + return; + } + if (acknowledgedAt) { + unacknowledgePost(serverUrl, post.id); + } else { + acknowledgePost(serverUrl, post.id); + } + }, [acknowledgedAt, isCurrentAuthor, post.id, serverUrl]); + + const handleOnLongPress = useCallback(async () => { + if (!acknowledgements.length) { + return; + } + const userAcknowledgements: Record = {}; + const userIds: string[] = []; + + acknowledgements.forEach((item) => { + userAcknowledgements[item.user_id] = item.acknowledged_at; + userIds.push(item.user_id); + }); + + try { + fetchMissingProfilesByIds(serverUrl, userIds); + } catch (e) { + return; + } + + const renderContent = () => ( + <> + {!isTablet && ( + + )} + + + ); + + const snapPoint1 = bottomSheetSnapPoint(Math.min(userIds.length, 5), USER_ROW_HEIGHT, bottom) + TITLE_HEIGHT; + const snapPoint2 = height * 0.8; + const snapPoints: BottomSheetProps['snapPoints'] = [1, Math.min(snapPoint1, snapPoint2)]; + if (userIds.length > 5 && snapPoint1 < snapPoint2) { + snapPoints.push(snapPoint2); + } + + bottomSheet({ + closeButtonId: 'close-ack-users-list', + renderContent, + initialSnapIndex: 1, + snapPoints, + title: intl.formatMessage({id: 'mobile.acknowledgements.header', defaultMessage: 'Acknowledgements'}), + theme, + }); + }, [bottom, intl, isTablet, acknowledgements, theme, location, post.channelId, currentUserTimezone]); + + return ( + <> + + + {isCurrentAuthor || acknowledgements.length ? ( + + {acknowledgements.length} + + ) : ( + + )} + + {hasReactions && } + + ); +}; + +export default Acknowledgements; diff --git a/app/components/post_list/post/body/acknowledgements/index.ts b/app/components/post_list/post/body/acknowledgements/index.ts new file mode 100644 index 000000000..ad60590f2 --- /dev/null +++ b/app/components/post_list/post/body/acknowledgements/index.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import compose from 'lodash/fp/compose'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {observeCurrentUser} from '@queries/servers/user'; + +import Acknowledgements from './acknowledgements'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables([], (ownProps: WithDatabaseArgs) => { + const database = ownProps.database; + const currentUser = observeCurrentUser(database); + + return { + currentUserId: currentUser.pipe(switchMap((c) => of$(c?.id))), + currentUserTimezone: currentUser.pipe(switchMap((c) => of$(c?.timezone))), + }; +}); + +export default compose( + withDatabase, + enhanced, +)(Acknowledgements); diff --git a/app/components/post_list/post/body/acknowledgements/users_list/index.ts b/app/components/post_list/post/body/acknowledgements/users_list/index.ts new file mode 100644 index 000000000..9ae05d9f0 --- /dev/null +++ b/app/components/post_list/post/body/acknowledgements/users_list/index.ts @@ -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)); diff --git a/app/components/post_list/post/body/acknowledgements/users_list/user_list_item.tsx b/app/components/post_list/post/body/acknowledgements/users_list/user_list_item.tsx new file mode 100644 index 000000000..55d621a23 --- /dev/null +++ b/app/components/post_list/post/body/acknowledgements/users_list/user_list_item.tsx @@ -0,0 +1,84 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {Keyboard} from 'react-native'; + +import FormattedRelativeTime from '@components/formatted_relative_time'; +import UserItem from '@components/user_item'; +import {Screens} from '@constants'; +import {useTheme} from '@context/theme'; +import {dismissBottomSheet, openAsBottomSheet} from '@screens/navigation'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type UserModel from '@typings/database/models/servers/user'; + +export const USER_ROW_HEIGHT = 60; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + paddingLeft: 0, + height: USER_ROW_HEIGHT, + }, + pictureContainer: { + alignItems: 'flex-start', + width: 40, + }, + time: { + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 75), + }, +})); + +type Props = { + channelId: string; + location: string; + user: UserModel; + userAcknowledgement: number; + timezone?: UserTimezone; +} + +const UserListItem = ({ + channelId, + location, + timezone, + user, + userAcknowledgement, +}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const style = getStyleSheet(theme); + + const handleUserPress = useCallback(async (userProfile: UserProfile) => { + if (userProfile) { + await dismissBottomSheet(Screens.BOTTOM_SHEET); + const screen = Screens.USER_PROFILE; + const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}); + const closeButtonId = 'close-user-profile'; + const props = {closeButtonId, location, userId: userProfile.id, channelId}; + + Keyboard.dismiss(); + openAsBottomSheet({screen, title, theme, closeButtonId, props}); + } + }, [channelId, location]); + + return ( + + } + containerStyle={style.container} + onUserPress={handleUserPress} + size={40} + user={user} + /> + ); +}; + +export default UserListItem; diff --git a/app/components/post_list/post/body/acknowledgements/users_list/users_list.tsx b/app/components/post_list/post/body/acknowledgements/users_list/users_list.tsx new file mode 100644 index 000000000..39b6fa66a --- /dev/null +++ b/app/components/post_list/post/body/acknowledgements/users_list/users_list.tsx @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useRef} from 'react'; +import {FlatList} from 'react-native-gesture-handler'; + +import UserListItem from './user_list_item'; + +import type UserModel from '@typings/database/models/servers/user'; +import type {ListRenderItemInfo} from 'react-native'; + +type Props = { + channelId: string; + location: string; + users: UserModel[]; + userAcknowledgements: Record; + timezone?: UserTimezone; +}; + +const UsersList = ({channelId, location, users, userAcknowledgements, timezone}: Props) => { + const listRef = useRef(null); + + const renderItem = useCallback(({item}: ListRenderItemInfo) => ( + + ), [channelId, location, timezone]); + + return ( + + ); +}; + +export default UsersList; diff --git a/app/components/post_list/post/body/index.tsx b/app/components/post_list/post/body/index.tsx index 34cc42399..8673abd44 100644 --- a/app/components/post_list/post/body/index.tsx +++ b/app/components/post_list/post/body/index.tsx @@ -12,6 +12,7 @@ import {THREAD} from '@constants/screens'; import {isEdited as postEdited, isPostFailed} from '@utils/post'; import {makeStyleSheetFromTheme} from '@utils/theme'; +import Acknowledgements from './acknowledgements'; import AddMembers from './add_members'; import Content from './content'; import Failed from './failed'; @@ -33,6 +34,7 @@ type BodyProps = { isJumboEmoji: boolean; isLastReply?: boolean; isPendingOrFailed: boolean; + isPostAcknowledgementEnabled?: boolean; isPostAddChannelMember: boolean; location: string; post: PostModel; @@ -43,6 +45,13 @@ type BodyProps = { const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { + ackAndReactionsContainer: { + flex: 1, + flexDirection: 'row', + flexWrap: 'wrap', + alignContent: 'flex-start', + marginTop: 12, + }, messageBody: { paddingVertical: 2, flex: 1, @@ -76,7 +85,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { const Body = ({ appsEnabled, hasFiles, hasReactions, highlight, highlightReplyBar, - isCRTEnabled, isEphemeral, isFirstReply, isJumboEmoji, isLastReply, isPendingOrFailed, isPostAddChannelMember, + isCRTEnabled, isEphemeral, isFirstReply, isJumboEmoji, isLastReply, isPendingOrFailed, isPostAcknowledgementEnabled, isPostAddChannelMember, location, post, searchPatterns, showAddReaction, theme, }: BodyProps) => { const style = getStyleSheet(theme); @@ -158,6 +167,8 @@ const Body = ({ ); } + const acknowledgementsVisible = isPostAcknowledgementEnabled && post.metadata?.priority?.requested_ack; + const reactionsVisible = hasReactions && showAddReaction; if (!hasBeenDeleted) { body = ( @@ -180,13 +191,25 @@ const Body = ({ isReplyPost={isReplyPost} /> } - {hasReactions && showAddReaction && - - } + {(acknowledgementsVisible || reactionsVisible) && ( + + {acknowledgementsVisible && ( + + )} + {reactionsVisible && ( + + )} + + )} ); } diff --git a/app/components/post_list/post/body/reactions/reactions.tsx b/app/components/post_list/post/body/reactions/reactions.tsx index d43955545..9bda75844 100644 --- a/app/components/post_list/post/body/reactions/reactions.tsx +++ b/app/components/post_list/post/body/reactions/reactions.tsx @@ -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 ( - + <> { Array.from(sortedReactions).map((r) => { const reaction = reactionsByName.get(r); @@ -189,7 +182,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled, }) } {addMoreReactions} - + ); }; diff --git a/app/components/post_list/post/index.ts b/app/components/post_list/post/index.ts index 2989962c8..b009456cb 100644 --- a/app/components/post_list/post/index.ts +++ b/app/components/post_list/post/index.ts @@ -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'; diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index 83b988086..9d4708b9a 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -51,6 +51,7 @@ type PostProps = { isCRTEnabled?: boolean; isEphemeral: boolean; isFirstReply?: boolean; + isPostAcknowledgementEnabled?: boolean; isSaved?: boolean; isLastReply?: boolean; isPostAddChannelMember: boolean; @@ -109,7 +110,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { const Post = ({ appsEnabled, canDelete, currentUser, customEmojiNames, differentThreadSequence, hasFiles, hasReplies, highlight, highlightPinnedOrSaved = true, highlightReplyBar, - isCRTEnabled, isConsecutivePost, isEphemeral, isFirstReply, isSaved, isLastReply, isPostAddChannelMember, isPostPriorityEnabled, + isCRTEnabled, isConsecutivePost, isEphemeral, isFirstReply, isSaved, isLastReply, isPostAcknowledgementEnabled, isPostAddChannelMember, isPostPriorityEnabled, location, post, rootId, hasReactions, searchPatterns, shouldRenderReplyButton, skipSavedHeader, skipPinnedHeader, showAddReaction = true, style, testID, thread, previousPost, }: PostProps) => { @@ -312,6 +313,7 @@ const Post = ({ isJumboEmoji={isJumboEmoji} isLastReply={isLastReply} isPendingOrFailed={isPendingOrFailed} + isPostAcknowledgementEnabled={isPostAcknowledgementEnabled} isPostAddChannelMember={isPostAddChannelMember} location={location} post={post} diff --git a/app/components/post_list/post_list.tsx b/app/components/post_list/post_list.tsx index 2008aa964..4c8f22ff4 100644 --- a/app/components/post_list/post_list.tsx +++ b/app/components/post_list/post_list.tsx @@ -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 (); } } - }, [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({ diff --git a/app/components/post_priority/post_priority_label.tsx b/app/components/post_priority/post_priority_label.tsx index f1bac0af5..dbe413e00 100644 --- a/app/components/post_priority/post_priority_label.tsx +++ b/app/components/post_priority/post_priority_label.tsx @@ -35,7 +35,7 @@ const style = StyleSheet.create({ }); type Props = { - label: PostPriorityData['priority']; + label: PostPriority['priority']; }; const PostPriorityLabel = ({label}: Props) => { diff --git a/app/components/post_priority/post_priority_picker/index.tsx b/app/components/post_priority/post_priority_picker/index.tsx deleted file mode 100644 index ed054a71c..000000000 --- a/app/components/post_priority/post_priority_picker/index.tsx +++ /dev/null @@ -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(defaultData); - - const handleUpdatePriority = React.useCallback((priority: PostPriorityData['priority']) => { - onSubmit({priority: priority || ''}); - }, [onSubmit]); - - return ( - - {!isTablet && - - - - - - - } - - - - - - - ); -}; - -export default PostPriorityPicker; diff --git a/app/components/post_priority/post_priority_picker/post_priority_picker_item.tsx b/app/components/post_priority/post_priority_picker/post_priority_picker_item.tsx deleted file mode 100644 index 4f7335651..000000000 --- a/app/components/post_priority/post_priority_picker/post_priority_picker_item.tsx +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import OptionItem, {type OptionItemProps} from '@components/option_item'; -import {useTheme} from '@context/theme'; -import {makeStyleSheetFromTheme} from '@utils/theme'; -import {typography} from '@utils/typography'; - -const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({ - optionLabelTextStyle: { - color: theme.centerChannelColor, - ...typography('Body', 200, 'Regular'), - }, -})); - -const PostPriorityPickerItem = (props: Omit) => { - const theme = useTheme(); - const style = getStyle(theme); - - const testID = `post_priority_picker_item.${props.value || 'standard'}`; - - return ( - - ); -}; - -export default PostPriorityPickerItem; diff --git a/app/components/user_item/user_item.tsx b/app/components/user_item/user_item.tsx index 656bd9b33..add7246d0 100644 --- a/app/components/user_item/user_item.tsx +++ b/app/components/user_item/user_item.tsx @@ -1,9 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback, useMemo, type ReactNode} from 'react'; import {useIntl} from 'react-intl'; -import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'; +import {StyleSheet, Text, TouchableOpacity, View, type StyleProp, type ViewStyle} from 'react-native'; import CompassIcon from '@components/compass_icon'; import CustomStatusEmoji from '@components/custom_status/custom_status_emoji'; @@ -18,8 +18,11 @@ import {displayUsername, getUserCustomStatus, isBot, isCustomStatusExpired, isGu import type UserModel from '@typings/database/models/servers/user'; type AtMentionItemProps = { + FooterComponent?: ReactNode; user: UserProfile | UserModel; + containerStyle?: StyleProp; currentUserId: string; + size?: number; testID?: string; isCustomStatusEnabled: boolean; showBadges?: boolean; @@ -35,6 +38,13 @@ type AtMentionItemProps = { const getThemedStyles = makeStyleSheetFromTheme((theme: Theme) => { return { + rowPicture: { + marginRight: 10, + marginLeft: 2, + width: 24, + alignItems: 'center', + justifyContent: 'center', + }, rowFullname: { ...typography('Body', 200), color: theme.centerChannelColor, @@ -56,6 +66,13 @@ const nonThemedStyles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', }, + rowInfoBaseContainer: { + flex: 1, + }, + rowInfoContainer: { + flex: 1, + flexDirection: 'row', + }, icon: { marginLeft: 4, }, @@ -71,8 +88,11 @@ const nonThemedStyles = StyleSheet.create({ }); const UserItem = ({ + FooterComponent, user, + containerStyle, currentUserId, + size = 24, testID, isCustomStatusEnabled, showBadges = false, @@ -107,7 +127,7 @@ const UserItem = ({ const userItemTestId = `${testID}.${user?.id}`; - const containerStyle = useMemo(() => { + const containerViewStyle = useMemo(() => { return [ nonThemedStyles.row, { @@ -133,68 +153,72 @@ const UserItem = ({ > - - - {nonBreakingString(displayName)} - {Boolean(showTeammateDisplay) && ( + + - {nonBreakingString(` @${user!.username}`)} + {nonBreakingString(displayName)} + {Boolean(showTeammateDisplay) && ( + + {nonBreakingString(` @${user!.username}`)} + + )} + {Boolean(deleteAt) && ( + + {nonBreakingString(` ${intl.formatMessage({id: 'mobile.user_list.deactivated', defaultMessage: 'Deactivated'})}`)} + + )} - )} - {Boolean(deleteAt) && ( - - {nonBreakingString(` ${intl.formatMessage({id: 'mobile.user_list.deactivated', defaultMessage: 'Deactivated'})}`)} - - )} - - {showBadges && bot && ( - - )} - {showBadges && guest && ( - - )} - {Boolean(isCustomStatusEnabled && !bot && customStatus?.emoji && !customStatusExpired) && ( - - )} - {shared && ( - - )} - - {Boolean(rightDecorator) && rightDecorator} + {showBadges && bot && ( + + )} + {showBadges && guest && ( + + )} + {Boolean(isCustomStatusEnabled && !bot && customStatus?.emoji && !customStatusExpired) && ( + + )} + {shared && ( + + )} + + {Boolean(rightDecorator) && rightDecorator} + + {FooterComponent} + ); diff --git a/app/components/user_list/__snapshots__/index.test.tsx.snap b/app/components/user_list/__snapshots__/index.test.tsx.snap index 74c833e9d..e628c5f35 100644 --- a/app/components/user_list/__snapshots__/index.test.tsx.snap +++ b/app/components/user_list/__snapshots__/index.test.tsx.snap @@ -153,17 +153,20 @@ exports[`components/channel_list_row should show no results 1`] = ` - - johndoe - - - + + + johndoe + + + + + + @@ -418,17 +438,20 @@ exports[`components/channel_list_row should show results and tutorial 1`] = ` - - johndoe - - - + + + johndoe + + + + + + @@ -763,17 +803,20 @@ exports[`components/channel_list_row should show results no tutorial 1`] = ` - - johndoe - - - + + + johndoe + + + + + + @@ -1060,17 +1120,20 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`] - - johndoe - - - + + + johndoe + + + + + + @@ -1230,17 +1310,20 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`] - - rocky - - - + + + rocky + + + + + + diff --git a/app/constants/autocomplete.test.ts b/app/constants/autocomplete.test.ts new file mode 100644 index 000000000..ed49f6daa --- /dev/null +++ b/app/constants/autocomplete.test.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MENTIONS_REGEX} from './autocomplete'; + +describe('test regular expressions', () => { + test.each([ + ['test @mention', ['@mention']], + ['test @mention.', ['@mention']], + ['test @mention_', ['@mention_']], + ['test @user_.name', ['@user_.name']], + ['test @user_.name.', ['@user_.name']], + ['test@mention', null], + ['test @mention1 @mention2 mentions', ['@mention1', '@mention2']], + ['test @mention1 @mention2. Mentions...', ['@mention1', '@mention2']], + + ['where is @jessica.hyde?', ['@jessica.hyde']], + ['where is @jessica.hyde.', ['@jessica.hyde']], + ['test @user.name. @user2.name', ['@user.name', '@user2.name']], + ['test @user.name.@user2.name', ['@user.name', '@user2.name']], + + ['non latin @桜 mention', ['@桜']], + ['@γεια non latin', ['@γεια']], + ['non latin @γεια.', ['@γεια']], + + // since word boundaries don't work with non latin characters + // the following is a known bug + ['άντε@γεια.com', ['@γεια.com']], + ])('MENTIONS_REGEX %s => %s', (text, expected) => { + expect(text.match(MENTIONS_REGEX)).toEqual(expected); + }); +}); diff --git a/app/constants/autocomplete.ts b/app/constants/autocomplete.ts index d3abc316f..102f77f5b 100644 --- a/app/constants/autocomplete.ts +++ b/app/constants/autocomplete.ts @@ -17,6 +17,10 @@ export const ALL_SEARCH_FLAGS_REGEX = /\b\w+:/g; export const CODE_REGEX = /(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)| *(`{3,}|~{3,})[ .]*(\S+)? *\n([\s\S]*?\s*)\3 *(?:\n+|$)/g; +export const MENTIONS_REGEX = /(?:\B|\b_+)@([\p{L}0-9.\-_]+)(?([ BOTTOM_SHEET, EMOJI_PICKER, POST_OPTIONS, + POST_PRIORITY_PICKER, THREAD_OPTIONS, REACTIONS, USER_PROFILE, diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index 1de884664..ec1089e04 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -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', diff --git a/app/database/operator/server_data_operator/transformers/post.ts b/app/database/operator/server_data_operator/transformers/post.ts index 64222be62..0e43b4535 100644 --- a/app/database/operator/server_data_operator/transformers/post.ts +++ b/app/database/operator/server_data_operator/transformers/post.ts @@ -41,7 +41,12 @@ export const transformPostRecord = ({action, database, value}: TransformerArgs): post.updateAt = raw.update_at; post.isPinned = Boolean(raw.is_pinned); post.message = raw.message; - post.metadata = raw.metadata && Object.keys(raw.metadata).length ? raw.metadata : null; + + // When we extract the posts from the threads, we don't get the metadata + // So, it might not be present in the raw post, so we use the one from the record + const metadata = raw.metadata ?? post.metadata; + post.metadata = metadata && Object.keys(metadata).length ? metadata : null; + post.userId = raw.user_id; post.originalId = raw.original_id; post.pendingPostId = raw.pending_post_id; @@ -132,6 +137,7 @@ export const transformFileRecord = ({action, database, value}: TransformerArgs): */ export const transformDraftRecord = ({action, database, value}: TransformerArgs): Promise => { const emptyFileInfo: FileInfo[] = []; + const emptyPostMetadata: PostMetadata = {}; const raw = value.raw as Draft; // We use the raw id as Draft is client side only and we would only be creating/deleting drafts @@ -141,6 +147,7 @@ export const transformDraftRecord = ({action, database, value}: TransformerArgs) draft.message = raw?.message ?? ''; draft.channelId = raw?.channel_id ?? ''; draft.files = raw?.files ?? emptyFileInfo; + draft.metadata = raw?.metadata ?? emptyPostMetadata; }; return prepareBaseRecord({ diff --git a/app/helpers/api/user.ts b/app/helpers/api/user.ts index 890fa9ff9..79bd792dc 100644 --- a/app/helpers/api/user.ts +++ b/app/helpers/api/user.ts @@ -2,35 +2,29 @@ // See LICENSE.txt for license information. import {General} from '@constants'; +import {MENTIONS_REGEX} from '@constants/autocomplete'; export const getNeededAtMentionedUsernames = (usernames: Set, posts: Post[], excludeUsername?: string) => { const usernamesToLoad = new Set(); - const pattern = /\B@(([a-z0-9_.-]*[a-z0-9_])[.-]*)/gi; - posts.forEach((p) => { let match; - while ((match = pattern.exec(p.message)) !== null) { - const lowercaseMatch1 = match[1].toLowerCase(); - const lowercaseMatch2 = match[2].toLowerCase(); + while ((match = MENTIONS_REGEX.exec(p.message)) !== null) { + const lowercaseMatch = match[1].toLowerCase(); - // match[1] is the matched mention including trailing punctuation - // match[2] is the matched mention without trailing punctuation - if (General.SPECIAL_MENTIONS.has(lowercaseMatch2)) { + if (General.SPECIAL_MENTIONS.has(lowercaseMatch)) { continue; } - if (lowercaseMatch1 === excludeUsername || lowercaseMatch2 === excludeUsername) { + if (lowercaseMatch === excludeUsername) { continue; } - if (usernames.has(lowercaseMatch1) || usernames.has(lowercaseMatch2)) { + if (usernames.has(lowercaseMatch)) { continue; } - // If there's no trailing punctuation, this will only add 1 item to the set - usernamesToLoad.add(lowercaseMatch1); - usernamesToLoad.add(lowercaseMatch2); + usernamesToLoad.add(lowercaseMatch); } }); diff --git a/app/queries/servers/drafts.ts b/app/queries/servers/drafts.ts index b18b327c5..4822385e7 100644 --- a/app/queries/servers/drafts.ts +++ b/app/queries/servers/drafts.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {Database, Q} from '@nozbe/watermelondb'; +import {of as of$} from 'rxjs'; import {MM_TABLES} from '@constants/database'; @@ -25,3 +26,7 @@ export const queryDraft = (database: Database, channelId: string, rootId = '') = Q.where('root_id', rootId), ); }; + +export function observeFirstDraft(v: DraftModel[]) { + return v[0]?.observe() || of$(undefined); +} diff --git a/app/queries/servers/post.ts b/app/queries/servers/post.ts index 8148d8dee..38e52b2b7 100644 --- a/app/queries/servers/post.ts +++ b/app/queries/servers/post.ts @@ -2,13 +2,15 @@ // See LICENSE.txt for license information. import {Database, Model, Q, Query} from '@nozbe/watermelondb'; -import {of as of$} from 'rxjs'; -import {switchMap} from 'rxjs/operators'; +import {of as of$, combineLatestWith} from 'rxjs'; +import {switchMap, distinctUntilChanged} from 'rxjs/operators'; import {MM_TABLES} from '@constants/database'; +import {queryGroupsByNames} from './group'; import {querySavedPostsPreferences} from './preference'; -import {observeUser} from './user'; +import {getConfigValue, observeConfigBooleanValue} from './system'; +import {queryUsersByUsername, observeUser, observeCurrentUser} from './user'; import type PostModel from '@typings/database/models/servers/post'; import type PostInChannelModel from '@typings/database/models/servers/posts_in_channel'; @@ -230,3 +232,52 @@ export const observeSavedPostsByIds = (database: Database, postIds: string[]) => switchMap((prefs) => of$(new Set(prefs.map((p) => p.name)))), ); }; + +export const getIsPostPriorityEnabled = async (database: Database) => { + const featureFlag = await getConfigValue(database, 'FeatureFlagPostPriority'); + const cfg = await getConfigValue(database, 'PostPriority'); + return featureFlag === 'true' && cfg === 'true'; +}; + +export const getIsPostAcknowledgementsEnabled = async (database: Database) => { + const cfg = await getConfigValue(database, 'PostAcknowledgements'); + return cfg === 'true'; +}; + +export const observeIsPostPriorityEnabled = (database: Database) => { + const featureFlag = observeConfigBooleanValue(database, 'FeatureFlagPostPriority'); + const cfg = observeConfigBooleanValue(database, 'PostPriority'); + return featureFlag.pipe( + combineLatestWith(cfg), + switchMap(([ff, c]) => of$(ff && c)), + distinctUntilChanged(), + ); +}; + +export const observeIsPostAcknowledgementsEnabled = (database: Database) => { + return observeConfigBooleanValue(database, 'PostAcknowledgements'); +}; + +export const observePersistentNotificationsEnabled = (database: Database) => { + const user = observeCurrentUser(database); + const enabledForAll = observeConfigBooleanValue(database, 'AllowPersistentNotifications'); + const enabledForGuests = observeConfigBooleanValue(database, 'AllowPersistentNotificationsForGuests'); + return user.pipe( + combineLatestWith(enabledForAll, enabledForGuests), + switchMap(([u, forAll, forGuests]) => { + if (u?.isGuest) { + return of$(forAll && forGuests); + } + return of$(forAll); + }), + distinctUntilChanged(), + + ); +}; + +export const countUsersFromMentions = async (database: Database, mentions: string[]) => { + const groupsQuery = queryGroupsByNames(database, mentions).fetch(); + const usersQuery = queryUsersByUsername(database, mentions).fetchCount(); + const [groups, usersCount] = await Promise.all([groupsQuery, usersQuery]); + return groups.reduce((acc, v) => acc + v.memberCount, usersCount); +}; diff --git a/app/queries/servers/system.ts b/app/queries/servers/system.ts index 76cfa9ae7..0af3d5938 100644 --- a/app/queries/servers/system.ts +++ b/app/queries/servers/system.ts @@ -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 => { return querySystemValue(database, SYSTEM_IDENTIFIERS.LICENSE).observe().pipe( switchMap((result) => (result.length ? result[0].observe() : of$({value: undefined}))), diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 8a1a92e2c..6eb227ef1 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -176,6 +176,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { case Screens.POST_OPTIONS: screen = withServerDatabase(require('@screens/post_options').default); break; + case Screens.POST_PRIORITY_PICKER: + screen = withServerDatabase(require('@screens/post_priority_picker').default); + break; case Screens.REACTIONS: screen = withServerDatabase(require('@screens/reactions').default); break; diff --git a/app/screens/post_priority_picker/components/picker_option.tsx b/app/screens/post_priority_picker/components/picker_option.tsx new file mode 100644 index 000000000..74defc931 --- /dev/null +++ b/app/screens/post_priority_picker/components/picker_option.tsx @@ -0,0 +1,42 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import OptionItem, {type OptionItemProps, type OptionType} from '@components/option_item'; +import {useTheme} from '@context/theme'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + labelContainer: { + alignItems: 'flex-start', + }, + optionLabelText: { + color: theme.centerChannelColor, + ...typography('Body', 200, 'Regular'), + }, +})); + +type Props = Omit & { + type?: OptionType; +} + +const PickerOption = ({type, ...rest}: Props) => { + const theme = useTheme(); + const style = getStyleSheet(theme); + + const testID = `post_priority_picker_item.${rest.value || 'standard'}`; + + return ( + + ); +}; + +export default PickerOption; diff --git a/app/screens/post_priority_picker/footer.tsx b/app/screens/post_priority_picker/footer.tsx new file mode 100644 index 000000000..5a046a6de --- /dev/null +++ b/app/screens/post_priority_picker/footer.tsx @@ -0,0 +1,90 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {BottomSheetFooter, type BottomSheetFooterProps} from '@gorhom/bottom-sheet'; +import React from 'react'; +import {Platform, TouchableOpacity, View} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +export type Props = BottomSheetFooterProps & { + onCancel: () => void; + onSubmit: () => void; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + backgroundColor: theme.centerChannelBg, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.16), + borderTopWidth: 1, + paddingTop: 20, + flexDirection: 'row', + paddingHorizontal: 20, + }, + cancelButton: { + alignItems: 'center', + backgroundColor: changeOpacity(theme.buttonBg, 0.08), + borderRadius: 4, + flex: 1, + paddingVertical: 15, + }, + cancelButtonText: { + color: theme.buttonBg, + ...typography('Body', 200, 'SemiBold'), + }, + applyButton: { + alignItems: 'center', + backgroundColor: theme.buttonBg, + borderRadius: 4, + flex: 1, + marginLeft: 8, + paddingVertical: 15, + }, + applyButtonText: { + color: theme.buttonColor, + ...typography('Body', 200, 'SemiBold'), + }, +})); + +const PostPriorityPickerFooter = ({onCancel, onSubmit, ...props}: Props) => { + const theme = useTheme(); + const style = getStyleSheet(theme); + const isTablet = useIsTablet(); + + return ( + + + + + + + + + + + ); +}; + +export default PostPriorityPickerFooter; diff --git a/app/screens/post_priority_picker/index.ts b/app/screens/post_priority_picker/index.ts new file mode 100644 index 000000000..810b2e061 --- /dev/null +++ b/app/screens/post_priority_picker/index.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; + +import {observeIsPostAcknowledgementsEnabled, observePersistentNotificationsEnabled} from '@queries/servers/post'; + +import PostPriorityPicker from './post_priority_picker'; + +import type {Database} from '@nozbe/watermelondb'; + +const enhanced = withObservables([], ({database}: {database: Database}) => { + return { + isPostAcknowledgementEnabled: observeIsPostAcknowledgementsEnabled(database), + isPersistenNotificationsEnabled: observePersistentNotificationsEnabled(database), + }; +}); + +export default withDatabase(enhanced(PostPriorityPicker)); diff --git a/app/screens/post_priority_picker/post_priority_picker.tsx b/app/screens/post_priority_picker/post_priority_picker.tsx new file mode 100644 index 000000000..5925d4cae --- /dev/null +++ b/app/screens/post_priority_picker/post_priority_picker.tsx @@ -0,0 +1,229 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {View} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import FormattedText from '@components/formatted_text'; +import {Screens} from '@constants'; +import {PostPriorityColors, PostPriorityType} from '@constants/post'; +import {useTheme} from '@context/theme'; +import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import {useIsTablet} from '@hooks/device'; +import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import BottomSheet from '@screens/bottom_sheet'; +import {dismissBottomSheet} from '@screens/navigation'; +import {bottomSheetSnapPoint} from '@utils/helpers'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import PickerOption from './components/picker_option'; +import Footer from './footer'; +import {labels} from './utils'; + +import type {BottomSheetFooterProps} from '@gorhom/bottom-sheet'; +import type {AvailableScreens} from '@typings/screens/navigation'; + +type Props = { + componentId: AvailableScreens; + isPostAcknowledgementEnabled: boolean; + isPersistenNotificationsEnabled: boolean; + postPriority: PostPriority; + updatePostPriority: (data: PostPriority) => void; + closeButtonId: string; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + backgroundColor: theme.centerChannelBg, + }, + titleContainer: { + alignItems: 'center', + flexDirection: 'row', + }, + title: { + color: theme.centerChannelColor, + ...typography('Heading', 600, 'SemiBold'), + }, + betaContainer: { + backgroundColor: PostPriorityColors.IMPORTANT, + borderRadius: 4, + paddingHorizontal: 4, + marginLeft: 8, + }, + beta: { + color: '#fff', + ...typography('Body', 25, 'SemiBold'), + }, + + optionsContainer: { + paddingTop: 12, + }, + + optionsSeparator: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + height: 1, + }, + + toggleOptionContainer: { + marginTop: 16, + }, +})); + +const PostPriorityPicker = ({ + componentId, + isPostAcknowledgementEnabled, + isPersistenNotificationsEnabled, + postPriority, + updatePostPriority, + closeButtonId, +}: Props) => { + const {bottom} = useSafeAreaInsets(); + const intl = useIntl(); + const isTablet = useIsTablet(); + const theme = useTheme(); + const [data, setData] = useState(postPriority); + + const style = getStyleSheet(theme); + + const closeBottomSheet = useCallback(() => { + return dismissBottomSheet(Screens.POST_PRIORITY_PICKER); + }, []); + + useNavButtonPressed(closeButtonId, componentId, closeBottomSheet, []); + useAndroidHardwareBackHandler(componentId, closeBottomSheet); + + const displayPersistentNotifications = isPersistenNotificationsEnabled && data.priority === PostPriorityType.URGENT; + + const snapPoints = useMemo(() => { + let COMPONENT_HEIGHT = 280; + + if (isPostAcknowledgementEnabled) { + COMPONENT_HEIGHT += 75; + + if (displayPersistentNotifications) { + COMPONENT_HEIGHT += 75; + } + } + + return [1, bottomSheetSnapPoint(1, COMPONENT_HEIGHT, bottom)]; + }, [displayPersistentNotifications, isPostAcknowledgementEnabled, bottom]); + + const handleUpdatePriority = useCallback((priority: PostPriority['priority']) => { + setData((prevData) => ({ + ...prevData, + priority, + persistent_notifications: undefined, // Uncheck if checked already + })); + }, []); + + const handleUpdateRequestedAck = useCallback((requested_ack: boolean) => { + setData((prevData) => ({...prevData, requested_ack})); + }, [data]); + + const handleUpdatePersistentNotifications = useCallback((persistent_notifications: boolean) => { + setData((prevData) => ({...prevData, persistent_notifications})); + }, [data]); + + const handleSubmit = useCallback(() => { + updatePostPriority(data); + closeBottomSheet(); + }, [data]); + + const renderContent = () => ( + + {!isTablet && + + + + + + + } + + + + + {(isPostAcknowledgementEnabled) && ( + <> + + + + + {displayPersistentNotifications && ( + + + + )} + + )} + + + ); + + const renderFooter = (props: BottomSheetFooterProps) => ( +