diff --git a/app/actions/local/channel_bookmark.ts b/app/actions/local/channel_bookmark.ts new file mode 100644 index 000000000..ff2479756 --- /dev/null +++ b/app/actions/local/channel_bookmark.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DatabaseManager from '@database/manager'; +import {getMyChannel} from '@queries/servers/channel'; +import {logError} from '@utils/log'; + +async function handleBookmarks(serverUrl: string, bookmarks: ChannelBookmarkWithFileInfo[], prepareRecordsOnly = false) { + if (!bookmarks.length) { + return {models: undefined}; + } + + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const myChannel = await getMyChannel(database, bookmarks[0].channel_id); + if (!myChannel) { + return {models: undefined}; + } + + const models = await operator.handleChannelBookmark({bookmarks, prepareRecordsOnly}); + return {models}; +} + +export async function handleBookmarkAddedOrDeleted(serverUrl: string, msg: WebSocketMessage, prepareRecordsOnly = false) { + try { + const bookmark: ChannelBookmarkWithFileInfo = JSON.parse(msg.data.bookmark); + return handleBookmarks(serverUrl, [bookmark], prepareRecordsOnly); + } catch (error) { + logError('cannot handle bookmark added websocket event', error); + return {error}; + } +} + +export async function handleBookmarkEdited(serverUrl: string, msg: WebSocketMessage, prepareRecordsOnly = false) { + try { + const edited: UpdateChannelBookmarkResponse = JSON.parse(msg.data.bookmarks); + const bookmarks = [edited.updated]; + if (edited.deleted) { + bookmarks.push(edited.deleted); + } + return handleBookmarks(serverUrl, bookmarks, prepareRecordsOnly); + } catch (error) { + logError('cannot handle bookmark updated websocket event', error); + return {error}; + } +} + +export async function handleBookmarkSorted(serverUrl: string, msg: WebSocketMessage, prepareRecordsOnly = false) { + try { + const bookmarks: ChannelBookmarkWithFileInfo[] = JSON.parse(msg.data.bookmarks); + return handleBookmarks(serverUrl, bookmarks, prepareRecordsOnly); + } catch (error) { + logError('cannot handle bookmark sorted websocket event', error); + return {error}; + } +} diff --git a/app/actions/local/file.ts b/app/actions/local/file.ts index 649ef9666..a4fdba9a7 100644 --- a/app/actions/local/file.ts +++ b/app/actions/local/file.ts @@ -34,3 +34,12 @@ export const updateLocalFilePath = async (serverUrl: string, fileId: string, loc } }; +export const getLocalFileInfo = async (serverUrl: string, fileId: string) => { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const file = await getFileById(database, fileId); + return {file}; + } catch (error) { + return {error}; + } +}; diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 3d0e36bad..6b64322a1 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -31,6 +31,7 @@ import {logDebug, logError, logInfo} from '@utils/log'; import {showMuteChannelSnackbar} from '@utils/snack_bar'; import {displayGroupMessageName, displayUsername} from '@utils/user'; +import {fetchChannelBookmarks} from './channel_bookmark'; import {fetchGroupsForChannelIfConstrained} from './groups'; import {fetchPostsForChannel} from './post'; import {openChannelIfNeeded, savePreference} from './preference'; @@ -98,6 +99,7 @@ export async function fetchChannelMembersByIds(serverUrl: string, channelId: str return {error}; } } + export async function updateChannelMemberSchemeRoles(serverUrl: string, channelId: string, userId: string, isSchemeUser: boolean, isSchemeAdmin: boolean, fetchOnly = false) { try { const client = NetworkManager.getClient(serverUrl); @@ -1049,6 +1051,7 @@ export async function switchToChannelById(serverUrl: string, channelId: string, DeviceEventEmitter.emit(Events.CHANNEL_SWITCH, true); fetchPostsForChannel(serverUrl, channelId); + fetchChannelBookmarks(serverUrl, channelId); await switchToChannel(serverUrl, channelId, teamId, skipLastUnread); openChannelIfNeeded(serverUrl, channelId); markChannelAsRead(serverUrl, channelId); diff --git a/app/actions/remote/channel_bookmark.ts b/app/actions/remote/channel_bookmark.ts new file mode 100644 index 000000000..825295ad5 --- /dev/null +++ b/app/actions/remote/channel_bookmark.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DatabaseManager from '@database/manager'; +import NetworkManager from '@managers/network_manager'; +import websocketManager from '@managers/websocket_manager'; +import {getBookmarksSince, getChannelBookmarkById} from '@queries/servers/channel_bookmark'; +import {getConfigValue} from '@queries/servers/system'; +import {getFullErrorMessage} from '@utils/errors'; +import {logError} from '@utils/log'; + +import {forceLogoutIfNecessary} from './session'; + +export async function fetchChannelBookmarks(serverUrl: string, channelId: string, fetchOnly = false) { + try { + const client = NetworkManager.getClient(serverUrl); + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + const bookmarksEnabled = (await getConfigValue(database, 'FeatureFlagChannelBookmarks')) === 'true'; + if (!bookmarksEnabled) { + return {bookmarks: []}; + } + + const since = await getBookmarksSince(database, channelId); + const bookmarks = await client.getChannelBookmarksForChannel(channelId, since); + + if (!fetchOnly && bookmarks.length) { + await operator.handleChannelBookmark({bookmarks, prepareRecordsOnly: false}); + } + + return {bookmarks}; + } catch (error) { + logError('error on fetchChannelBookmarks', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +} + +export async function createChannelBookmark(serverUrl: string, channelId: string, bookmark: ChannelBookmark, fetchOnly = false) { + try { + const client = NetworkManager.getClient(serverUrl); + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const ws = websocketManager.getClient(serverUrl); + + const created = await client.createChannelBookmark(channelId, bookmark, ws?.getConnectionId()); + if (!fetchOnly) { + await operator.handleChannelBookmark({bookmarks: [created], prepareRecordsOnly: false}); + } + return {bookmark: created}; + } catch (error) { + logError('error on createChannelBookmark', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +} + +export async function editChannelBookmark(serverUrl: string, bookmark: ChannelBookmark, fetchOnly = false) { + try { + const client = NetworkManager.getClient(serverUrl); + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const ws = websocketManager.getClient(serverUrl); + + const result = await client.updateChannelBookmark(bookmark.channel_id, bookmark, ws?.getConnectionId()); + const bookmarks = [result.updated]; + if (result.deleted) { + bookmarks.push(result.deleted); + } + if (!fetchOnly) { + await operator.handleChannelBookmark({bookmarks, prepareRecordsOnly: false}); + } + return {bookmarks: result}; + } catch (error) { + logError('error on editChannelBookmark', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +} + +export async function deleteChannelBookmark(serverUrl: string, channelId: string, bookmarkId: string, fetchOnly = false) { + try { + const client = NetworkManager.getClient(serverUrl); + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const ws = websocketManager.getClient(serverUrl); + + const result = await client.deleteChannelBookmark(channelId, bookmarkId, ws?.getConnectionId()); + + const bookmark = await getChannelBookmarkById(database, bookmarkId); + if (bookmark && !fetchOnly) { + const b = bookmark.toApi(); + b.delete_at = Date.now(); + await operator.handleChannelBookmark({bookmarks: [b], prepareRecordsOnly: false}); + } + + return {bookmarks: result}; + } catch (error) { + logError('error on deleteChannelBookmark', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +} diff --git a/app/actions/remote/file.ts b/app/actions/remote/file.ts index 14461f108..0a4fefb98 100644 --- a/app/actions/remote/file.ts +++ b/app/actions/remote/file.ts @@ -23,16 +23,17 @@ export const downloadProfileImage = (serverUrl: string, userId: string, lastPict export const uploadFile = ( serverUrl: string, - file: FileInfo, + file: FileInfo | ExtractedFileInfo, channelId: string, onProgress: (fractionCompleted: number, bytesRead?: number | null | undefined) => void = () => {/*Do Nothing*/}, onComplete: (response: ClientResponse) => void = () => {/*Do Nothing*/}, onError: (response: ClientResponseError) => void = () => {/*Do Nothing*/}, skipBytes = 0, + isBookmark = false, ) => { try { const client = NetworkManager.getClient(serverUrl); - return {cancel: client.uploadPostAttachment(file, channelId, onProgress, onComplete, onError, skipBytes)}; + return {cancel: client.uploadAttachment(file, channelId, onProgress, onComplete, onError, skipBytes, isBookmark)}; } catch (error) { logDebug('error on uploadFile', getFullErrorMessage(error)); return {error}; diff --git a/app/actions/remote/search.ts b/app/actions/remote/search.ts index 86ab63a8c..42b81e799 100644 --- a/app/actions/remote/search.ts +++ b/app/actions/remote/search.ts @@ -153,7 +153,9 @@ export const searchFiles = async (serverUrl: string, teamId: string, params: Fil return acc; }, {}); files.forEach((f) => { - f.postProps = idToPost[f.post_id]?.props; + if (f.post_id) { + f.postProps = idToPost[f.post_id]?.props; + } }); return {files, channels}; } catch (error) { diff --git a/app/actions/websocket/event.ts b/app/actions/websocket/event.ts new file mode 100644 index 000000000..fa4aedd86 --- /dev/null +++ b/app/actions/websocket/event.ts @@ -0,0 +1,298 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as bookmark from '@actions/local/channel_bookmark'; +import * as calls from '@calls/connection/websocket_event_handlers'; +import {WebsocketEvents} from '@constants'; + +import * as category from './category'; +import * as channel from './channel'; +import * as group from './group'; +import {handleOpenDialogEvent} from './integrations'; +import * as posts from './posts'; +import * as preferences from './preferences'; +import {handleAddCustomEmoji, handleReactionRemovedFromPostEvent, handleReactionAddedToPostEvent} from './reactions'; +import {handleUserRoleUpdatedEvent, handleTeamMemberRoleUpdatedEvent, handleRoleUpdatedEvent} from './roles'; +import {handleLicenseChangedEvent, handleConfigChangedEvent} from './system'; +import * as teams from './teams'; +import {handleThreadUpdatedEvent, handleThreadReadChangedEvent, handleThreadFollowChangedEvent} from './threads'; +import {handleUserUpdatedEvent, handleUserTypingEvent, handleStatusChangedEvent} from './users'; + +export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMessage) { + switch (msg.event) { + case WebsocketEvents.POSTED: + case WebsocketEvents.EPHEMERAL_MESSAGE: + posts.handleNewPostEvent(serverUrl, msg); + break; + case WebsocketEvents.POST_EDITED: + posts.handlePostEdited(serverUrl, msg); + break; + case WebsocketEvents.POST_DELETED: + posts.handlePostDeleted(serverUrl, msg); + break; + case WebsocketEvents.POST_UNREAD: + posts.handlePostUnread(serverUrl, msg); + break; + case WebsocketEvents.POST_ACKNOWLEDGEMENT_ADDED: + posts.handlePostAcknowledgementAdded(serverUrl, msg); + break; + case WebsocketEvents.POST_ACKNOWLEDGEMENT_REMOVED: + posts.handlePostAcknowledgementRemoved(serverUrl, msg); + break; + + case WebsocketEvents.LEAVE_TEAM: + teams.handleLeaveTeamEvent(serverUrl, msg); + break; + case WebsocketEvents.UPDATE_TEAM: + teams.handleUpdateTeamEvent(serverUrl, msg); + break; + case WebsocketEvents.ADDED_TO_TEAM: + teams.handleUserAddedToTeamEvent(serverUrl, msg); + break; + case WebsocketEvents.DELETE_TEAM: + teams.handleTeamArchived(serverUrl, msg); + break; + case WebsocketEvents.RESTORE_TEAM: + teams.handleTeamRestored(serverUrl, msg); + break; + + case WebsocketEvents.USER_ADDED: + channel.handleUserAddedToChannelEvent(serverUrl, msg); + break; + case WebsocketEvents.USER_REMOVED: + channel.handleUserRemovedFromChannelEvent(serverUrl, msg); + break; + case WebsocketEvents.USER_UPDATED: + handleUserUpdatedEvent(serverUrl, msg); + break; + case WebsocketEvents.ROLE_UPDATED: + handleRoleUpdatedEvent(serverUrl, msg); + break; + case WebsocketEvents.USER_ROLE_UPDATED: + handleUserRoleUpdatedEvent(serverUrl, msg); + break; + case WebsocketEvents.MEMBERROLE_UPDATED: + handleTeamMemberRoleUpdatedEvent(serverUrl, msg); + break; + + case WebsocketEvents.CATEGORY_CREATED: + category.handleCategoryCreatedEvent(serverUrl, msg); + break; + case WebsocketEvents.CATEGORY_UPDATED: + category.handleCategoryUpdatedEvent(serverUrl, msg); + break; + case WebsocketEvents.CATEGORY_ORDER_UPDATED: + category.handleCategoryOrderUpdatedEvent(serverUrl, msg); + break; + case WebsocketEvents.CATEGORY_DELETED: + category.handleCategoryDeletedEvent(serverUrl, msg); + break; + + case WebsocketEvents.CHANNEL_CREATED: + channel.handleChannelCreatedEvent(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_DELETED: + channel.handleChannelDeletedEvent(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_UNARCHIVED: + channel.handleChannelUnarchiveEvent(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_UPDATED: + channel.handleChannelUpdatedEvent(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_CONVERTED: + channel.handleChannelConvertedEvent(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_VIEWED: + channel.handleChannelViewedEvent(serverUrl, msg); + break; + case WebsocketEvents.MULTIPLE_CHANNELS_VIEWED: + channel.handleMultipleChannelsViewedEvent(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_MEMBER_UPDATED: + channel.handleChannelMemberUpdatedEvent(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_SCHEME_UPDATED: + // Do nothing, handled by CHANNEL_UPDATED due to changes in the channel scheme. + break; + case WebsocketEvents.DIRECT_ADDED: + case WebsocketEvents.GROUP_ADDED: + channel.handleDirectAddedEvent(serverUrl, msg); + break; + + case WebsocketEvents.PREFERENCE_CHANGED: + preferences.handlePreferenceChangedEvent(serverUrl, msg); + break; + case WebsocketEvents.PREFERENCES_CHANGED: + preferences.handlePreferencesChangedEvent(serverUrl, msg); + break; + case WebsocketEvents.PREFERENCES_DELETED: + preferences.handlePreferencesDeletedEvent(serverUrl, msg); + break; + + case WebsocketEvents.STATUS_CHANGED: + handleStatusChangedEvent(serverUrl, msg); + break; + case WebsocketEvents.TYPING: + handleUserTypingEvent(serverUrl, msg); + break; + + case WebsocketEvents.REACTION_ADDED: + handleReactionAddedToPostEvent(serverUrl, msg); + break; + case WebsocketEvents.REACTION_REMOVED: + handleReactionRemovedFromPostEvent(serverUrl, msg); + break; + case WebsocketEvents.EMOJI_ADDED: + handleAddCustomEmoji(serverUrl, msg); + break; + + case WebsocketEvents.LICENSE_CHANGED: + handleLicenseChangedEvent(serverUrl, msg); + break; + case WebsocketEvents.CONFIG_CHANGED: + handleConfigChangedEvent(serverUrl, msg); + break; + + case WebsocketEvents.OPEN_DIALOG: + handleOpenDialogEvent(serverUrl, msg); + break; + case WebsocketEvents.APPS_FRAMEWORK_REFRESH_BINDINGS: + break; + + case WebsocketEvents.THREAD_UPDATED: + handleThreadUpdatedEvent(serverUrl, msg); + break; + case WebsocketEvents.THREAD_READ_CHANGED: + handleThreadReadChangedEvent(serverUrl, msg); + break; + case WebsocketEvents.THREAD_FOLLOW_CHANGED: + handleThreadFollowChangedEvent(serverUrl, msg); + break; + + // Calls ws events: + case WebsocketEvents.CALLS_CHANNEL_ENABLED: + calls.handleCallChannelEnabled(serverUrl, msg); + break; + case WebsocketEvents.CALLS_CHANNEL_DISABLED: + calls.handleCallChannelDisabled(serverUrl, msg); + break; + + // DEPRECATED in favour of user_joined (since v0.21.0) + case WebsocketEvents.CALLS_USER_CONNECTED: + calls.handleCallUserConnected(serverUrl, msg); + break; + + // DEPRECATED in favour of user_left (since v0.21.0) + case WebsocketEvents.CALLS_USER_DISCONNECTED: + calls.handleCallUserDisconnected(serverUrl, msg); + break; + + case WebsocketEvents.CALLS_USER_JOINED: + calls.handleCallUserJoined(serverUrl, msg); + break; + case WebsocketEvents.CALLS_USER_LEFT: + calls.handleCallUserLeft(serverUrl, msg); + break; + case WebsocketEvents.CALLS_USER_MUTED: + calls.handleCallUserMuted(serverUrl, msg); + break; + case WebsocketEvents.CALLS_USER_UNMUTED: + calls.handleCallUserUnmuted(serverUrl, msg); + break; + case WebsocketEvents.CALLS_USER_VOICE_ON: + calls.handleCallUserVoiceOn(msg); + break; + case WebsocketEvents.CALLS_USER_VOICE_OFF: + calls.handleCallUserVoiceOff(msg); + break; + case WebsocketEvents.CALLS_CALL_START: + calls.handleCallStarted(serverUrl, msg); + break; + case WebsocketEvents.CALLS_SCREEN_ON: + calls.handleCallScreenOn(serverUrl, msg); + break; + case WebsocketEvents.CALLS_SCREEN_OFF: + calls.handleCallScreenOff(serverUrl, msg); + break; + case WebsocketEvents.CALLS_USER_RAISE_HAND: + calls.handleCallUserRaiseHand(serverUrl, msg); + break; + case WebsocketEvents.CALLS_USER_UNRAISE_HAND: + calls.handleCallUserUnraiseHand(serverUrl, msg); + break; + case WebsocketEvents.CALLS_CALL_END: + calls.handleCallEnded(serverUrl, msg); + break; + case WebsocketEvents.CALLS_USER_REACTED: + calls.handleCallUserReacted(serverUrl, msg); + break; + + // DEPRECATED in favour of CALLS_JOB_STATE (since v2.15.0) + case WebsocketEvents.CALLS_RECORDING_STATE: + calls.handleCallRecordingState(serverUrl, msg); + break; + case WebsocketEvents.CALLS_JOB_STATE: + calls.handleCallJobState(serverUrl, msg); + break; + case WebsocketEvents.CALLS_HOST_CHANGED: + calls.handleCallHostChanged(serverUrl, msg); + break; + case WebsocketEvents.CALLS_USER_DISMISSED_NOTIFICATION: + calls.handleUserDismissedNotification(serverUrl, msg); + break; + case WebsocketEvents.CALLS_CAPTION: + calls.handleCallCaption(serverUrl, msg); + break; + case WebsocketEvents.CALLS_HOST_MUTE: + calls.handleHostMute(serverUrl, msg); + break; + case WebsocketEvents.CALLS_HOST_LOWER_HAND: + calls.handleHostLowerHand(serverUrl, msg); + break; + case WebsocketEvents.CALLS_HOST_REMOVED: + calls.handleHostRemoved(serverUrl, msg); + break; + case WebsocketEvents.CALLS_CALL_STATE: + calls.handleCallState(serverUrl, msg); + break; + case WebsocketEvents.GROUP_RECEIVED: + group.handleGroupReceivedEvent(serverUrl, msg); + break; + case WebsocketEvents.GROUP_MEMBER_ADD: + group.handleGroupMemberAddEvent(serverUrl, msg); + break; + case WebsocketEvents.GROUP_MEMBER_DELETE: + group.handleGroupMemberDeleteEvent(serverUrl, msg); + break; + case WebsocketEvents.GROUP_ASSOCIATED_TO_TEAM: + group.handleGroupTeamAssociatedEvent(serverUrl, msg); + break; + case WebsocketEvents.GROUP_DISSOCIATED_TO_TEAM: + group.handleGroupTeamDissociateEvent(serverUrl, msg); + break; + case WebsocketEvents.GROUP_ASSOCIATED_TO_CHANNEL: + break; + case WebsocketEvents.GROUP_DISSOCIATED_TO_CHANNEL: + break; + + // Plugins + case WebsocketEvents.PLUGIN_STATUSES_CHANGED: + case WebsocketEvents.PLUGIN_ENABLED: + case WebsocketEvents.PLUGIN_DISABLED: + // Do nothing, this event doesn't need logic in the mobile app + break; + + // bookmarks + case WebsocketEvents.CHANNEL_BOOKMARK_CREATED: + case WebsocketEvents.CHANNEL_BOOKMARK_DELETED: + bookmark.handleBookmarkAddedOrDeleted(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_BOOKMARK_UPDATED: + bookmark.handleBookmarkEdited(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_BOOKMARK_SORTED: + bookmark.handleBookmarkSorted(serverUrl, msg); + break; + } +} diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 96d173242..0c233c6dd 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -14,36 +14,8 @@ import {fetchPostsForChannel, fetchPostThread} from '@actions/remote/post'; import {openAllUnreadChannels} from '@actions/remote/preference'; import {autoUpdateTimezone} from '@actions/remote/user'; import {loadConfigAndCalls} from '@calls/actions/calls'; -import { - handleCallCaption, - handleCallChannelDisabled, - handleCallChannelEnabled, - handleCallEnded, - handleCallHostChanged, - handleCallJobState, - handleCallRecordingState, - handleCallScreenOff, - handleCallScreenOn, - handleCallStarted, - handleCallState, - handleCallUserConnected, - handleCallUserDisconnected, - handleCallUserJoined, - handleCallUserLeft, - handleCallUserMuted, - handleCallUserRaiseHand, - handleCallUserReacted, - handleCallUserUnmuted, - handleCallUserUnraiseHand, - handleCallUserVoiceOff, - handleCallUserVoiceOn, - handleHostLowerHand, - handleHostMute, - handleHostRemoved, - handleUserDismissedNotification, -} from '@calls/connection/websocket_event_handlers'; import {isSupportedServerCalls} from '@calls/utils'; -import {Screens, WebsocketEvents} from '@constants'; +import {Screens} from '@constants'; import DatabaseManager from '@database/manager'; import AppsManager from '@managers/apps_manager'; import {getActiveServerUrl} from '@queries/app/servers'; @@ -64,58 +36,6 @@ import {setTeamLoading} from '@store/team_load_store'; import {isTablet} from '@utils/helpers'; import {logDebug, logInfo} from '@utils/log'; -import { - handleCategoryCreatedEvent, - handleCategoryDeletedEvent, - handleCategoryOrderUpdatedEvent, - handleCategoryUpdatedEvent, -} from './category'; -import { - handleChannelConvertedEvent, handleChannelCreatedEvent, - handleChannelDeletedEvent, - handleChannelMemberUpdatedEvent, - handleChannelUnarchiveEvent, - handleChannelUpdatedEvent, - handleChannelViewedEvent, - handleMultipleChannelsViewedEvent, - handleDirectAddedEvent, - handleUserAddedToChannelEvent, - handleUserRemovedFromChannelEvent, -} from './channel'; -import { - handleGroupMemberAddEvent, - handleGroupMemberDeleteEvent, - handleGroupReceivedEvent, - handleGroupTeamAssociatedEvent, - handleGroupTeamDissociateEvent, -} from './group'; -import {handleOpenDialogEvent} from './integrations'; -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'; -import {handleLicenseChangedEvent, handleConfigChangedEvent} from './system'; -import { - handleLeaveTeamEvent, - handleUserAddedToTeamEvent, - handleUpdateTeamEvent, - handleTeamArchived, - handleTeamRestored, -} from './teams'; -import {handleThreadUpdatedEvent, handleThreadReadChangedEvent, handleThreadFollowChangedEvent} from './threads'; -import {handleUserUpdatedEvent, handleUserTypingEvent, handleStatusChangedEvent} from './users'; - export async function handleFirstConnect(serverUrl: string) { registerDeviceToken(serverUrl); autoUpdateTimezone(serverUrl); @@ -192,300 +112,6 @@ async function doReconnect(serverUrl: string) { return undefined; } -export async function handleEvent(serverUrl: string, msg: WebSocketMessage) { - switch (msg.event) { - case WebsocketEvents.POSTED: - case WebsocketEvents.EPHEMERAL_MESSAGE: - handleNewPostEvent(serverUrl, msg); - break; - - case WebsocketEvents.POST_EDITED: - handlePostEdited(serverUrl, msg); - break; - - case WebsocketEvents.POST_DELETED: - handlePostDeleted(serverUrl, msg); - break; - - case WebsocketEvents.POST_UNREAD: - 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; - case WebsocketEvents.UPDATE_TEAM: - handleUpdateTeamEvent(serverUrl, msg); - break; - case WebsocketEvents.ADDED_TO_TEAM: - handleUserAddedToTeamEvent(serverUrl, msg); - break; - - case WebsocketEvents.USER_ADDED: - handleUserAddedToChannelEvent(serverUrl, msg); - break; - case WebsocketEvents.USER_REMOVED: - handleUserRemovedFromChannelEvent(serverUrl, msg); - break; - case WebsocketEvents.USER_UPDATED: - handleUserUpdatedEvent(serverUrl, msg); - break; - case WebsocketEvents.ROLE_UPDATED: - handleRoleUpdatedEvent(serverUrl, msg); - break; - - case WebsocketEvents.USER_ROLE_UPDATED: - handleUserRoleUpdatedEvent(serverUrl, msg); - break; - - case WebsocketEvents.MEMBERROLE_UPDATED: - handleTeamMemberRoleUpdatedEvent(serverUrl, msg); - break; - - case WebsocketEvents.CATEGORY_CREATED: - handleCategoryCreatedEvent(serverUrl, msg); - break; - case WebsocketEvents.CATEGORY_UPDATED: - handleCategoryUpdatedEvent(serverUrl, msg); - break; - case WebsocketEvents.CATEGORY_ORDER_UPDATED: - handleCategoryOrderUpdatedEvent(serverUrl, msg); - break; - case WebsocketEvents.CATEGORY_DELETED: - handleCategoryDeletedEvent(serverUrl, msg); - break; - - case WebsocketEvents.CHANNEL_CREATED: - handleChannelCreatedEvent(serverUrl, msg); - break; - - case WebsocketEvents.CHANNEL_DELETED: - handleChannelDeletedEvent(serverUrl, msg); - break; - case WebsocketEvents.CHANNEL_UNARCHIVED: - handleChannelUnarchiveEvent(serverUrl, msg); - break; - - case WebsocketEvents.CHANNEL_UPDATED: - handleChannelUpdatedEvent(serverUrl, msg); - break; - - case WebsocketEvents.CHANNEL_CONVERTED: - handleChannelConvertedEvent(serverUrl, msg); - break; - - case WebsocketEvents.CHANNEL_VIEWED: - handleChannelViewedEvent(serverUrl, msg); - break; - - case WebsocketEvents.MULTIPLE_CHANNELS_VIEWED: - handleMultipleChannelsViewedEvent(serverUrl, msg); - break; - - case WebsocketEvents.CHANNEL_MEMBER_UPDATED: - handleChannelMemberUpdatedEvent(serverUrl, msg); - break; - - case WebsocketEvents.CHANNEL_SCHEME_UPDATED: - // Do nothing, handled by CHANNEL_UPDATED due to changes in the channel scheme. - break; - - case WebsocketEvents.DIRECT_ADDED: - case WebsocketEvents.GROUP_ADDED: - handleDirectAddedEvent(serverUrl, msg); - break; - - case WebsocketEvents.PREFERENCE_CHANGED: - handlePreferenceChangedEvent(serverUrl, msg); - break; - - case WebsocketEvents.PREFERENCES_CHANGED: - handlePreferencesChangedEvent(serverUrl, msg); - break; - - case WebsocketEvents.PREFERENCES_DELETED: - handlePreferencesDeletedEvent(serverUrl, msg); - break; - - case WebsocketEvents.STATUS_CHANGED: - handleStatusChangedEvent(serverUrl, msg); - break; - case WebsocketEvents.TYPING: - handleUserTypingEvent(serverUrl, msg); - break; - - case WebsocketEvents.REACTION_ADDED: - handleReactionAddedToPostEvent(serverUrl, msg); - break; - - case WebsocketEvents.REACTION_REMOVED: - handleReactionRemovedFromPostEvent(serverUrl, msg); - break; - - case WebsocketEvents.EMOJI_ADDED: - handleAddCustomEmoji(serverUrl, msg); - break; - - case WebsocketEvents.LICENSE_CHANGED: - handleLicenseChangedEvent(serverUrl, msg); - break; - - case WebsocketEvents.CONFIG_CHANGED: - handleConfigChangedEvent(serverUrl, msg); - break; - - case WebsocketEvents.OPEN_DIALOG: - handleOpenDialogEvent(serverUrl, msg); - break; - - case WebsocketEvents.DELETE_TEAM: - handleTeamArchived(serverUrl, msg); - break; - - case WebsocketEvents.RESTORE_TEAM: - handleTeamRestored(serverUrl, msg); - break; - - case WebsocketEvents.THREAD_UPDATED: - handleThreadUpdatedEvent(serverUrl, msg); - break; - - case WebsocketEvents.THREAD_READ_CHANGED: - handleThreadReadChangedEvent(serverUrl, msg); - break; - - case WebsocketEvents.THREAD_FOLLOW_CHANGED: - handleThreadFollowChangedEvent(serverUrl, msg); - break; - - case WebsocketEvents.APPS_FRAMEWORK_REFRESH_BINDINGS: - break; - - // return dispatch(handleRefreshAppsBindings()); - - // Calls ws events: - case WebsocketEvents.CALLS_CHANNEL_ENABLED: - handleCallChannelEnabled(serverUrl, msg); - break; - case WebsocketEvents.CALLS_CHANNEL_DISABLED: - handleCallChannelDisabled(serverUrl, msg); - break; - - // DEPRECATED in favour of user_joined (since v0.21.0) - case WebsocketEvents.CALLS_USER_CONNECTED: - handleCallUserConnected(serverUrl, msg); - break; - - // DEPRECATED in favour of user_left (since v0.21.0) - case WebsocketEvents.CALLS_USER_DISCONNECTED: - handleCallUserDisconnected(serverUrl, msg); - break; - - case WebsocketEvents.CALLS_USER_JOINED: - handleCallUserJoined(serverUrl, msg); - break; - case WebsocketEvents.CALLS_USER_LEFT: - handleCallUserLeft(serverUrl, msg); - break; - case WebsocketEvents.CALLS_USER_MUTED: - handleCallUserMuted(serverUrl, msg); - break; - case WebsocketEvents.CALLS_USER_UNMUTED: - handleCallUserUnmuted(serverUrl, msg); - break; - case WebsocketEvents.CALLS_USER_VOICE_ON: - handleCallUserVoiceOn(msg); - break; - case WebsocketEvents.CALLS_USER_VOICE_OFF: - handleCallUserVoiceOff(msg); - break; - case WebsocketEvents.CALLS_CALL_START: - handleCallStarted(serverUrl, msg); - break; - case WebsocketEvents.CALLS_SCREEN_ON: - handleCallScreenOn(serverUrl, msg); - break; - case WebsocketEvents.CALLS_SCREEN_OFF: - handleCallScreenOff(serverUrl, msg); - break; - case WebsocketEvents.CALLS_USER_RAISE_HAND: - handleCallUserRaiseHand(serverUrl, msg); - break; - case WebsocketEvents.CALLS_USER_UNRAISE_HAND: - handleCallUserUnraiseHand(serverUrl, msg); - break; - case WebsocketEvents.CALLS_CALL_END: - handleCallEnded(serverUrl, msg); - break; - case WebsocketEvents.CALLS_USER_REACTED: - handleCallUserReacted(serverUrl, msg); - break; - - // DEPRECATED in favour of CALLS_JOB_STATE (since v2.15.0) - case WebsocketEvents.CALLS_RECORDING_STATE: - handleCallRecordingState(serverUrl, msg); - break; - case WebsocketEvents.CALLS_JOB_STATE: - handleCallJobState(serverUrl, msg); - break; - case WebsocketEvents.CALLS_HOST_CHANGED: - handleCallHostChanged(serverUrl, msg); - break; - case WebsocketEvents.CALLS_USER_DISMISSED_NOTIFICATION: - handleUserDismissedNotification(serverUrl, msg); - break; - case WebsocketEvents.CALLS_CAPTION: - handleCallCaption(serverUrl, msg); - break; - case WebsocketEvents.CALLS_HOST_MUTE: - handleHostMute(serverUrl, msg); - break; - case WebsocketEvents.CALLS_HOST_LOWER_HAND: - handleHostLowerHand(serverUrl, msg); - break; - case WebsocketEvents.CALLS_HOST_REMOVED: - handleHostRemoved(serverUrl, msg); - break; - case WebsocketEvents.CALLS_CALL_STATE: - handleCallState(serverUrl, msg); - break; - - case WebsocketEvents.GROUP_RECEIVED: - handleGroupReceivedEvent(serverUrl, msg); - break; - case WebsocketEvents.GROUP_MEMBER_ADD: - handleGroupMemberAddEvent(serverUrl, msg); - break; - case WebsocketEvents.GROUP_MEMBER_DELETE: - handleGroupMemberDeleteEvent(serverUrl, msg); - break; - case WebsocketEvents.GROUP_ASSOCIATED_TO_TEAM: - handleGroupTeamAssociatedEvent(serverUrl, msg); - break; - case WebsocketEvents.GROUP_DISSOCIATED_TO_TEAM: - handleGroupTeamDissociateEvent(serverUrl, msg); - break; - case WebsocketEvents.GROUP_ASSOCIATED_TO_CHANNEL: - break; - case WebsocketEvents.GROUP_DISSOCIATED_TO_CHANNEL: - break; - - // Plugins - case WebsocketEvents.PLUGIN_STATUSES_CHANGED: - case WebsocketEvents.PLUGIN_ENABLED: - case WebsocketEvents.PLUGIN_DISABLED: - // Do nothing, this event doesn't need logic in the mobile app - break; - } -} - async function fetchPostDataIfNeeded(serverUrl: string) { try { const isActiveServer = (await getActiveServerUrl()) === serverUrl; diff --git a/app/client/rest/base.ts b/app/client/rest/base.ts index 5259b9361..0240d3f67 100644 --- a/app/client/rest/base.ts +++ b/app/client/rest/base.ts @@ -131,6 +131,14 @@ export default class ClientBase { return `${this.getChannelsRoute()}/${channelId}`; } + getChannelBookmarksRoute(channelId: string) { + return `${this.getChannelRoute(channelId)}/bookmarks`; + } + + getChannelBookmarkRoute(channelId: string, bookmarkId: string) { + return `${this.getChannelBookmarksRoute(channelId)}/${bookmarkId}`; + } + getSharedChannelsRoute() { return `${this.urlVersion}/sharedchannels`; } diff --git a/app/client/rest/channel_bookmark.ts b/app/client/rest/channel_bookmark.ts new file mode 100644 index 000000000..07dd0a27e --- /dev/null +++ b/app/client/rest/channel_bookmark.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {buildQueryString} from '@utils/helpers'; + +import type ClientBase from './base'; + +export interface ClientChannelBookmarksMix { + createChannelBookmark(channelId: string, bookmark: ChannelBookmark, connectionId?: string): Promise; + updateChannelBookmark(channelId: string, bookmark: ChannelBookmark, connectionId?: string): Promise; + updateChannelBookmarkSortOrder(channelId: string, bookmarkId: string, newSortOrder: number, connectionId?: string): Promise; + deleteChannelBookmark(channelId: string, bookmarkId: string, connectionId?: string): Promise; + getChannelBookmarksForChannel(channelId: string, since: number): Promise; +} + +const ClientChannelBookmarks = >(superclass: TBase) => class extends superclass { + createChannelBookmark = async (channelId: string, bookmark: ChannelBookmark, connectionId = '') => { + return this.doFetch( + this.getChannelBookmarksRoute(channelId), + { + method: 'post', + body: bookmark, + headers: {'Connection-Id': connectionId}, + }, + ); + }; + + updateChannelBookmark(channelId: string, bookmark: ChannelBookmark, connectionId?: string) { + return this.doFetch( + this.getChannelBookmarkRoute(channelId, bookmark.id), + { + method: 'patch', + body: bookmark, + headers: {'Connection-Id': connectionId}, + }, + ); + } + + updateChannelBookmarkSortOrder(channelId: string, bookmarkId: string, newSortOrder: number, connectionId?: string) { + return this.doFetch( + `${this.getChannelBookmarkRoute(channelId, bookmarkId)}/sort_order`, + { + method: 'post', + body: newSortOrder, + headers: {'Connection-Id': connectionId}, + }, + ); + } + + deleteChannelBookmark(channelId: string, bookmarkId: string, connectionId?: string) { + return this.doFetch( + this.getChannelBookmarkRoute(channelId, bookmarkId), + { + method: 'delete', + headers: {'Connection-Id': connectionId}, + }, + ); + } + + getChannelBookmarksForChannel(channelId: string, since: number) { + return this.doFetch( + `${this.getChannelBookmarksRoute(channelId)}${buildQueryString({bookmarks_since: since})}`, + {method: 'get'}, + ); + } +}; + +export default ClientChannelBookmarks; diff --git a/app/client/rest/channels.ts b/app/client/rest/channels.ts index b3904b74c..2d909b80b 100644 --- a/app/client/rest/channels.ts +++ b/app/client/rest/channels.ts @@ -132,14 +132,16 @@ const ClientChannels = >(superclass: TBase getChannel = async (channelId: string) => { return this.doFetch( - `${this.getChannelRoute(channelId)}`, + this.getChannelRoute(channelId), {method: 'get'}, ); }; getChannelByName = async (teamId: string, channelName: string, includeDeleted = false) => { return this.doFetch( - `${this.getTeamRoute(teamId)}/channels/name/${channelName}?include_deleted=${includeDeleted}`, + `${this.getTeamRoute(teamId)}/channels/name/${channelName}${buildQueryString({ + include_deleted: includeDeleted, + })}`, {method: 'get'}, ); }; @@ -153,14 +155,20 @@ const ClientChannels = >(superclass: TBase getChannels = async (teamId: string, page = 0, perPage = PER_PAGE_DEFAULT) => { return this.doFetch( - `${this.getTeamRoute(teamId)}/channels${buildQueryString({page, per_page: perPage})}`, + `${this.getTeamRoute(teamId)}/channels${buildQueryString({ + page, + per_page: perPage, + })}`, {method: 'get'}, ); }; getArchivedChannels = async (teamId: string, page = 0, perPage = PER_PAGE_DEFAULT) => { return this.doFetch( - `${this.getTeamRoute(teamId)}/channels/deleted${buildQueryString({page, per_page: perPage})}`, + `${this.getTeamRoute(teamId)}/channels/deleted${buildQueryString({ + page, + per_page: perPage, + })}`, {method: 'get'}, ); }; @@ -172,11 +180,11 @@ const ClientChannels = >(superclass: TBase ); }; - getMyChannels = async (teamId: string, includeDeleted = false, lastDeleteAt = 0) => { + getMyChannels = async (teamId: string, includeDeleted = false, since = 0) => { return this.doFetch( `${this.getUserRoute('me')}/teams/${teamId}/channels${buildQueryString({ include_deleted: includeDeleted, - last_delete_at: lastDeleteAt, + last_delete_at: since, })}`, {method: 'get'}, ); diff --git a/app/client/rest/files.ts b/app/client/rest/files.ts index 036ec582f..d899acdf0 100644 --- a/app/client/rest/files.ts +++ b/app/client/rest/files.ts @@ -11,13 +11,14 @@ export interface ClientFilesMix { getFileThumbnailUrl: (fileId: string, timestamp: number) => string; getFilePreviewUrl: (fileId: string, timestamp: number) => string; getFilePublicLink: (fileId: string) => Promise<{link: string}>; - uploadPostAttachment: ( - file: FileInfo, + uploadAttachment: ( + file: FileInfo | ExtractedFileInfo, channelId: string, onProgress: (fractionCompleted: number, bytesRead?: number | null | undefined) => void, onComplete: (response: ClientResponse) => void, onError: (response: ClientResponseError) => void, skipBytes?: number, + isBookmark?: boolean, ) => () => void; searchFiles: (teamId: string, terms: string) => Promise; searchFilesWithParams: (teamId: string, FileSearchParams: string) => Promise; @@ -58,15 +59,19 @@ const ClientFiles = >(superclass: TBase) = ); }; - uploadPostAttachment = ( - file: FileInfo, + uploadAttachment = ( + file: FileInfo | ExtractedFileInfo, channelId: string, onProgress: (fractionCompleted: number, bytesRead?: number | null | undefined) => void, onComplete: (response: ClientResponse) => void, onError: (response: ClientResponseError) => void, skipBytes = 0, + isBookmark = false, ) => { - const url = this.getFilesRoute(); + let url = this.getFilesRoute(); + if (isBookmark) { + url = `${url}?bookmark=true`; + } const options: UploadRequestOptions = { skipBytes, method: 'POST', diff --git a/app/client/rest/index.ts b/app/client/rest/index.ts index 382afcbfd..e023d8ca3 100644 --- a/app/client/rest/index.ts +++ b/app/client/rest/index.ts @@ -8,6 +8,7 @@ import mix from '@utils/mix'; import ClientApps, {type ClientAppsMix} from './apps'; import ClientBase from './base'; import ClientCategories, {type ClientCategoriesMix} from './categories'; +import ClientChannelBookmarks, {type ClientChannelBookmarksMix} from './channel_bookmark'; import ClientChannels, {type ClientChannelsMix} from './channels'; import {DEFAULT_LIMIT_AFTER, DEFAULT_LIMIT_BEFORE, HEADER_X_VERSION_ID} from './constants'; import ClientEmojis, {type ClientEmojisMix} from './emojis'; @@ -29,6 +30,7 @@ interface Client extends ClientBase, ClientAppsMix, ClientCategoriesMix, ClientChannelsMix, + ClientChannelBookmarksMix, ClientEmojisMix, ClientFilesMix, ClientGeneralMix, @@ -49,6 +51,7 @@ class Client extends mix(ClientBase).with( ClientApps, ClientCategories, ClientChannels, + ClientChannelBookmarks, ClientEmojis, ClientFiles, ClientGeneral, diff --git a/app/client/websocket/index.ts b/app/client/websocket/index.ts index 3070b0684..181e8ed9f 100644 --- a/app/client/websocket/index.ts +++ b/app/client/websocket/index.ts @@ -366,4 +366,8 @@ export default class WebSocketClient { public isConnected(): boolean { return this.conn?.readyState === WebSocketReadyState.OPEN; } + + public getConnectionId(): string { + return this.connectionId; + } } diff --git a/app/components/button/index.tsx b/app/components/button/index.tsx index 51f305940..0d9aa5f68 100644 --- a/app/components/button/index.tsx +++ b/app/components/button/index.tsx @@ -3,10 +3,11 @@ import {Button as ElementButton} from '@rneui/base'; import React, {useMemo, type ReactNode} from 'react'; -import {type StyleProp, StyleSheet, Text, type TextStyle, View, type ViewStyle} from 'react-native'; +import {type StyleProp, StyleSheet, Text, type TextStyle, View, type ViewStyle, type Insets} from 'react-native'; import CompassIcon from '@components/compass_icon'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; +import {changeOpacity} from '@utils/theme'; type ConditionalProps = | {iconName: string; iconSize: number} | {iconName?: never; iconSize?: never} @@ -22,6 +23,8 @@ type Props = ConditionalProps & { onPress: () => void; text: string; iconComponent?: ReactNode; + disabled?: boolean; + hitSlop?: Insets; } const styles = StyleSheet.create({ @@ -43,6 +46,8 @@ const Button = ({ iconName, iconSize, iconComponent, + disabled, + hitSlop, }: Props) => { const bgStyle = useMemo(() => [ buttonBackgroundStyle(theme, size, emphasis, buttonType, buttonState), @@ -63,6 +68,14 @@ const Button = ({ [iconSize], ); + let buttonContainerStyle = StyleSheet.flatten(bgStyle); + if (disabled) { + buttonContainerStyle = { + ...buttonContainerStyle, + backgroundColor: changeOpacity(buttonContainerStyle.backgroundColor! as string, 0.4), + }; + } + let icon: ReactNode; if (iconComponent) { @@ -80,9 +93,11 @@ const Button = ({ return ( {icon} diff --git a/app/components/channel_bookmarks/add_bookmark.tsx b/app/components/channel_bookmarks/add_bookmark.tsx new file mode 100644 index 000000000..2a71f4a68 --- /dev/null +++ b/app/components/channel_bookmarks/add_bookmark.tsx @@ -0,0 +1,163 @@ +// 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 {Alert, View, type Insets} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import Button from '@components/button'; +import {ITEM_HEIGHT} from '@components/option_item'; +import {Screens} from '@constants'; +import {useTheme} from '@context/theme'; +import {TITLE_HEIGHT} from '@screens/bottom_sheet'; +import {bottomSheet, showModal} from '@screens/navigation'; +import {bottomSheetSnapPoint} from '@utils/helpers'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import CompassIcon from '../compass_icon'; + +import AddBookmarkOptions from './add_bookmark_options'; + +type Props = { + bookmarksCount: number; + canUploadFiles: boolean; + channelId: string; + currentUserId: string; + showLarge: boolean; +} + +const MAX_BOOKMARKS_PER_CHANNEL = 50; +const hitSlop: Insets = {top: 10, bottom: 10, left: 10, right: 10}; + +const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ + container: { + alignSelf: 'flex-start', + marginBottom: 16, + }, + largeButton: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + height: 32, + paddingHorizontal: 8, + paddingVertical: 0, + borderRadius: 16, + }, + largeButtonText: { + color: theme.centerChannelColor, + lineHeight: undefined, + marginTop: undefined, + ...typography('Body', 100, 'SemiBold'), + paddingRight: 6, + }, + largeButtonIcon: { + color: changeOpacity(theme.centerChannelColor, 0.56), + paddingRight: 4, + paddingTop: 3, + }, + smallButton: { + backgroundColor: undefined, + paddingHorizontal: undefined, + paddingVertical: undefined, + alignItems: 'flex-end', + justifyContent: 'center', + margin: undefined, + top: 3, + right: 0, + }, + smallButtonText: { + color: theme.centerChannelColor, + lineHeight: undefined, + marginTop: undefined, + ...typography('Body', 100, 'SemiBold'), + marginRight: undefined, + padding: undefined, + }, + smallButtonIcon: { + color: changeOpacity(theme.centerChannelColor, 0.56), + }, +})); + +const AddBookmark = ({bookmarksCount, channelId, currentUserId, canUploadFiles, showLarge}: Props) => { + const theme = useTheme(); + const {formatMessage} = useIntl(); + const {bottom} = useSafeAreaInsets(); + const styles = getStyleSheet(theme); + + const onPress = useCallback(() => { + if (bookmarksCount >= MAX_BOOKMARKS_PER_CHANNEL) { + Alert.alert( + formatMessage({id: 'channel_info.add_bookmark', defaultMessage: 'Add a bookmark'}), + formatMessage({ + id: 'channel_info.add_bookmark.max_reached', + defaultMessage: 'This channel has reached the maximum number of bookmarks ({count}).', + }, {count: MAX_BOOKMARKS_PER_CHANNEL}), + ); + return; + } + + if (!canUploadFiles) { + const title = formatMessage({id: 'screens.channel_bookmark_add', defaultMessage: 'Add a bookmark'}); + const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor); + const closeButtonId = 'close-channel-bookmark-add'; + + const options = { + topBar: { + leftButtons: [{ + id: closeButtonId, + icon: closeButton, + testID: 'close.channel_bookmark_add.button', + }], + }, + }; + showModal(Screens.CHANNEL_BOOKMARK, title, { + channelId, + closeButtonId, + type: 'link', + ownerId: currentUserId, + }, options); + return; + } + + const renderContent = () => ( + + ); + + bottomSheet({ + title: formatMessage({id: 'channel_info.add_bookmark', defaultMessage: 'Add a bookmark'}), + renderContent, + snapPoints: [1, bottomSheetSnapPoint(1, (2 * ITEM_HEIGHT), bottom) + TITLE_HEIGHT], + theme, + closeButtonId: 'close-channel-quick-actions', + }); + }, [bottom, bookmarksCount, canUploadFiles, currentUserId, channelId, theme]); + + const button = ( + + + ); +}; + +export default BookmarkDocument; diff --git a/app/components/channel_bookmarks/channel_bookmark/bookmark_icon.tsx b/app/components/channel_bookmarks/channel_bookmark/bookmark_icon.tsx new file mode 100644 index 000000000..ef1070526 --- /dev/null +++ b/app/components/channel_bookmarks/channel_bookmark/bookmark_icon.tsx @@ -0,0 +1,68 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Image, type ImageStyle} from 'expo-image'; +import React, {useState, useCallback} from 'react'; +import {type StyleProp, type TextStyle, type ViewStyle} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import Emoji from '@components/emoji'; +import FileIcon from '@components/files/file_icon'; +import {useTheme} from '@context/theme'; + +type Props = { + emoji?: string; + emojiSize: number; + emojiStyle?: StyleProp; + file?: FileInfo | ExtractedFileInfo; + iconSize: number; + imageStyle?: StyleProp; + imageUrl?: string; + genericStyle: StyleProp; +} + +const BookmarkIcon = ({emoji, emojiSize, emojiStyle, file, genericStyle, iconSize, imageStyle, imageUrl}: Props) => { + const theme = useTheme(); + const [hasImageError, setHasImageError] = useState(false); + + const handleImageError = useCallback(() => { + setHasImageError(true); + }, []); + + if (file && !emoji && !hasImageError) { + return ( + + ); + } else if (imageUrl && !emoji && !hasImageError) { + return ( + + ); + } else if (emoji) { + return ( + + ); + } + + return ( + + ); +}; + +export default BookmarkIcon; diff --git a/app/components/channel_bookmarks/channel_bookmark/bookmark_options.tsx b/app/components/channel_bookmarks/channel_bookmark/bookmark_options.tsx new file mode 100644 index 000000000..ae96992bb --- /dev/null +++ b/app/components/channel_bookmarks/channel_bookmark/bookmark_options.tsx @@ -0,0 +1,259 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Clipboard from '@react-native-clipboard/clipboard'; +import React, {useCallback, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {Alert, Text, View} from 'react-native'; +import Share from 'react-native-share'; + +import {deleteChannelBookmark} from '@actions/remote/channel_bookmark'; +import {fetchPublicLink} from '@actions/remote/file'; +import CompassIcon from '@components/compass_icon'; +import OptionItem from '@components/option_item'; +import {Screens} from '@constants'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import DownloadWithAction from '@screens/gallery/footer/download_with_action'; +import {dismissBottomSheet, showModal, showOverlay} from '@screens/navigation'; +import {getFullErrorMessage} from '@utils/errors'; +import {isImage, isVideo} from '@utils/file'; +import {showSnackBar} from '@utils/snack_bar'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; +import type FileModel from '@typings/database/models/servers/file'; +import type {GalleryAction, GalleryFileType, GalleryItemType} from '@typings/screens/gallery'; + +type Props = { + bookmark: ChannelBookmarkModel; + canCopyPublicLink: boolean; + canDeleteBookmarks: boolean; + canDownloadFiles: boolean; + canEditBookmarks: boolean; + file?: FileModel; + setAction: React.Dispatch>; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + flex: {flex: 1}, + header: { + marginBottom: 12, + }, + headerText: { + color: theme.centerChannelColor, + ...typography('Heading', 600, 'SemiBold'), + }, +})); + +const ChannelBookmarkOptions = ({ + bookmark, + canCopyPublicLink, + canDeleteBookmarks, + canDownloadFiles, + canEditBookmarks, + file, + setAction, +}: Props) => { + const serverUrl = useServerUrl(); + const theme = useTheme(); + const isTablet = useIsTablet(); + const intl = useIntl(); + const styles = getStyleSheet(theme); + const canShare = canDownloadFiles || bookmark.type === 'link'; + + const isVideoFile = useMemo(() => isVideo(file), [file]); + const isImageFile = useMemo(() => isImage(file), [file]); + const galleryItem = useMemo(() => { + if (file) { + const fileInfo = file.toFileInfo(bookmark.ownerId); + let type: GalleryFileType = 'file'; + if (isImageFile) { + type = 'image'; + } else if (isVideoFile) { + type = 'video'; + } + const item: GalleryItemType = { + ...fileInfo, + id: fileInfo.id!, + type, + lastPictureUpdate: 0, + uri: '', + }; + + return item; + } + return null; + }, [bookmark, file]); + + const handleDelete = useCallback(async () => { + const {error} = await deleteChannelBookmark(serverUrl, bookmark.channelId, bookmark.id); + if (error) { + Alert.alert( + intl.formatMessage({id: 'channel_bookmark.delete.failed_title', defaultMessage: 'Error deleting bookmark'}), + intl.formatMessage({id: 'channel_bookmark.delete.failed_detail', defaultMessage: 'Details: {error}'}, { + error: getFullErrorMessage(error), + }), + ); + return; + } + + await dismissBottomSheet(); + }, [bookmark, intl, serverUrl]); + + const onCopy = useCallback(async () => { + await dismissBottomSheet(); + + if (bookmark.type === 'link' && bookmark.linkUrl) { + Clipboard.setString(bookmark.linkUrl); + showSnackBar({barType: 'LINK_COPIED'}); + return; + } + + try { + const publicLink = await fetchPublicLink(serverUrl, bookmark.fileId!); + if ('link' in publicLink) { + Clipboard.setString(publicLink.link); + showSnackBar({barType: 'LINK_COPIED'}); + } else { + showSnackBar({barType: 'LINK_COPY_FAILED'}); + } + } catch { + showSnackBar({barType: 'LINK_COPY_FAILED'}); + } + }, [bookmark, serverUrl]); + + const onDelete = useCallback(async () => { + Alert.alert( + intl.formatMessage({id: 'channel_bookmark.delete.confirm_title', defaultMessage: 'Delete bookmark'}), + intl.formatMessage({id: 'channel_bookmark.delete.confirm', defaultMessage: 'You sure want to delete the bookmark {displayName}?'}, { + displayName: bookmark.displayName, + }), + [{ + text: intl.formatMessage({id: 'channel_bookmark.delete.yes', defaultMessage: 'Yes'}), + style: 'destructive', + isPreferred: true, + onPress: handleDelete, + }, { + text: intl.formatMessage({id: 'channel_bookmark.add.file_cancel', defaultMessage: 'Cancel'}), + style: 'cancel', + }], + ); + }, [bookmark, handleDelete]); + + const onEdit = useCallback(async () => { + await dismissBottomSheet(); + + const title = intl.formatMessage({id: 'screens.channel_bookmark_edit', defaultMessage: 'Edit bookmark'}); + const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor); + const closeButtonId = 'close-channel_bookmark_edit'; + + const options = { + topBar: { + leftButtons: [{ + id: closeButtonId, + icon: closeButton, + testID: 'close.channel_bookmark_edit.button', + }], + }, + }; + + showModal(Screens.CHANNEL_BOOKMARK, title, { + bookmark: bookmark.toApi(), + canDeleteBookmarks, + channelId: bookmark.channelId, + closeButtonId, + file: file?.toFileInfo(bookmark.ownerId), + ownerId: bookmark.ownerId, + type: bookmark.type, + }, options); + }, [bookmark, canDeleteBookmarks, file, intl, theme]); + + const onShare = useCallback(async () => { + await dismissBottomSheet(); + + if (bookmark.type === 'file') { + if (file) { + setAction('sharing'); + showOverlay(Screens.GENERIC_OVERLAY, { + children: ( + + ), + }, {}, bookmark.id); + } + return; + } + + if (bookmark.type === 'link') { + const title = bookmark.displayName; + const url = bookmark.linkUrl!; + Share.open({ + title, + message: title, + url, + showAppsToView: true, + }).catch(() => { + // do nothing + }); + } + }, [bookmark, file, serverUrl, setAction]); + + return ( + <> + {!isTablet && ( + + + {bookmark.displayName} + + + )} + + {canEditBookmarks && + + } + {canCopyPublicLink && + + } + {canShare && + + } + {canDeleteBookmarks && + + } + + + ); +}; + +export default ChannelBookmarkOptions; diff --git a/app/components/channel_bookmarks/channel_bookmark/channel_bookmark.tsx b/app/components/channel_bookmarks/channel_bookmark/channel_bookmark.tsx new file mode 100644 index 000000000..db6f07a3f --- /dev/null +++ b/app/components/channel_bookmarks/channel_bookmark/channel_bookmark.tsx @@ -0,0 +1,169 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useManagedConfig} from '@mattermost/react-native-emm'; +import {Button} from '@rneui/base'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {useIntl, type IntlShape} from 'react-intl'; +import {Alert, StyleSheet} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import {ITEM_HEIGHT} from '@components/option_item'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {useGalleryItem} from '@hooks/gallery'; +import {TITLE_HEIGHT} from '@screens/bottom_sheet'; +import {bottomSheet, dismissOverlay} from '@screens/navigation'; +import {handleDeepLink, matchDeepLink} from '@utils/deep_link'; +import {isDocument} from '@utils/file'; +import {bottomSheetSnapPoint} from '@utils/helpers'; +import {normalizeProtocol, tryOpenURL} from '@utils/url'; + +import BookmarkDetails from './bookmark_details'; +import BookmarkDocument from './bookmark_document'; +import ChannelBookmarkOptions from './bookmark_options'; + +import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; +import type FileModel from '@typings/database/models/servers/file'; +import type {GalleryAction} from '@typings/screens/gallery'; + +type Props = { + bookmark: ChannelBookmarkModel; + canDeleteBookmarks: boolean; + canDownloadFiles: boolean; + canEditBookmarks: boolean; + file?: FileModel; + galleryIdentifier: string; + index?: number; + onPress?: (index: number) => void; + publicLinkEnabled: boolean; + siteURL: string; +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + paddingVertical: 6, + height: 48, + }, + button: {backgroundColor: 'transparent'}, +}); + +const openLink = async (href: string, serverUrl: string, siteURL: string, intl: IntlShape) => { + const url = normalizeProtocol(href); + if (!url) { + return; + } + + const match = matchDeepLink(url, serverUrl, siteURL); + + const onLinkError = () => { + Alert.alert( + intl.formatMessage({ + id: 'mobile.link.error.title', + defaultMessage: 'Error', + }), + intl.formatMessage({ + id: 'mobile.link.error.text', + defaultMessage: 'Unable to open the link.', + }), + ); + }; + + if (match) { + const {error} = await handleDeepLink(match.url, intl); + if (error) { + tryOpenURL(match.url, onLinkError); + } + } else { + tryOpenURL(url, onLinkError); + } +}; + +const ChannelBookmark = ({ + bookmark, canDeleteBookmarks, canDownloadFiles, canEditBookmarks, + file, galleryIdentifier, index, onPress, publicLinkEnabled, siteURL, +}: Props) => { + const theme = useTheme(); + const managedConfig = useManagedConfig(); + const serverUrl = useServerUrl(); + const intl = useIntl(); + const {bottom} = useSafeAreaInsets(); + const [action, setAction] = useState('none'); + const isDocumentFile = useMemo(() => isDocument(file), [file]); + const canCopyPublicLink = Boolean((bookmark.type === 'link' || (file?.id && publicLinkEnabled)) && managedConfig.copyAndPasteProtection !== 'true'); + + const handlePress = useCallback(() => { + if (bookmark.linkUrl) { + openLink(bookmark.linkUrl, siteURL, serverUrl, intl); + return; + } + + onPress?.(index || 0); + }, [bookmark, index, intl, onPress, serverUrl, siteURL]); + + const handleLongPress = useCallback(() => { + const canShare = canDownloadFiles || bookmark.type === 'link'; + const count = [canCopyPublicLink, canDeleteBookmarks, canShare, canEditBookmarks]. + filter((e) => e).length; + + const renderContent = () => ( + + ); + + bottomSheet({ + title: bookmark.displayName, + renderContent, + snapPoints: [1, bottomSheetSnapPoint(1, (count * ITEM_HEIGHT), bottom) + TITLE_HEIGHT], + theme, + closeButtonId: 'close-channel-bookmark-actions', + }); + }, [bookmark, bottom, canCopyPublicLink, canDeleteBookmarks, canDownloadFiles, canEditBookmarks, file, theme]); + + const {onGestureEvent, ref} = useGalleryItem(galleryIdentifier, index || 0, handlePress); + + useEffect(() => { + if (action === 'none' && bookmark.id) { + dismissOverlay(bookmark.id); + } + }, [action]); + + if (isDocumentFile) { + return ( + + ); + } + + return ( + + ); +}; + +export default ChannelBookmark; diff --git a/app/components/channel_bookmarks/channel_bookmark/index.tsx b/app/components/channel_bookmarks/channel_bookmark/index.tsx new file mode 100644 index 000000000..81833f8d9 --- /dev/null +++ b/app/components/channel_bookmarks/channel_bookmark/index.tsx @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; +import {switchMap} from 'rxjs'; + +import {observeFileById} from '@queries/servers/file'; +import {observeConfigValue} from '@queries/servers/system'; + +import ChannelBookmark from './channel_bookmark'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; + +type Props = WithDatabaseArgs & { + bookmark: ChannelBookmarkModel; +} + +const enhanced = withObservables([], ({bookmark, database}: Props) => { + const observed = bookmark.observe(); + const file = observed.pipe( + switchMap((b) => observeFileById(database, b.fileId || '')), + ); + + return { + bookmark: observed, + file, + siteURL: observeConfigValue(database, 'SiteURL'), + }; +}); + +export default withDatabase(enhanced(ChannelBookmark)); diff --git a/app/components/channel_bookmarks/channel_bookmarks.tsx b/app/components/channel_bookmarks/channel_bookmarks.tsx new file mode 100644 index 000000000..2812c3cc0 --- /dev/null +++ b/app/components/channel_bookmarks/channel_bookmarks.tsx @@ -0,0 +1,179 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {LinearGradient} from 'expo-linear-gradient'; +import React, {useCallback, useMemo, useState} from 'react'; +import {FlatList, View, type ListRenderItemInfo, type NativeSyntheticEvent, type NativeScrollEvent} from 'react-native'; +import Animated from 'react-native-reanimated'; + +import {GalleryInit} from '@context/gallery'; +import {useTheme} from '@context/theme'; +import {useChannelBookmarkFiles} from '@hooks/files'; +import ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; +import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import AddBookmark from './add_bookmark'; +import ChannelBookmark from './channel_bookmark'; + +type Props = { + bookmarks: ChannelBookmarkModel[]; + canAddBookmarks: boolean; + canDeleteBookmarks: boolean; + canDownloadFiles: boolean; + canEditBookmarks: boolean; + canUploadFiles: boolean; + channelId: string; + currentUserId: string; + publicLinkEnabled: boolean; + showInInfo: boolean; + separator?: boolean; +} + +const GRADIENT_LOCATIONS = [0, 0.64, 1]; +const SCROLL_OFFSET = 10; + +const isCloseToBottom = ({layoutMeasurement, contentOffset, contentSize}: NativeScrollEvent) => { + return layoutMeasurement.width + contentOffset.x <= contentSize.width - SCROLL_OFFSET; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + flexDirection: 'row', + }, + separator: { + height: 1, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + marginTop: 8, + marginBottom: 24, + }, + emptyItemSeparator: { + width: 20, + }, + addContainer: { + width: 40, + alignContent: 'center', + }, + gradient: { + height: 48, + width: 68, + position: 'absolute', + right: 0, + }, + channelView: { + paddingHorizontal: 8, + }, +})); + +const ChannelBookmarks = ({ + bookmarks, canAddBookmarks, canDeleteBookmarks, canDownloadFiles, canEditBookmarks, canUploadFiles, + channelId, currentUserId, publicLinkEnabled, showInInfo, separator = true, +}: Props) => { + const galleryIdentifier = `${channelId}-bookmarks`; + const theme = useTheme(); + const styles = getStyleSheet(theme); + const files = useChannelBookmarkFiles(bookmarks, publicLinkEnabled); + const [allowEndFade, setAllowEndFade] = useState(true); + + const attachmentIndex = useCallback((fileId: string) => { + return files.findIndex((file) => file.id === fileId) || 0; + }, [files]); + + const handlePreviewPress = useCallback(preventDoubleTap((idx: number) => { + if (files.length) { + const items = files.map((f) => fileToGalleryItem(f, f.user_id)); + openGalleryAtIndex(galleryIdentifier, idx, items); + } + }), [files]); + + const renderItem = useCallback(({item}: ListRenderItemInfo) => { + return ( + + ); + }, [ + attachmentIndex, bookmarks, canDownloadFiles, canDeleteBookmarks, canEditBookmarks, + handlePreviewPress, publicLinkEnabled, + ]); + + const renderItemSeparator = useCallback(() => (), []); + + const onScrolled = useCallback((e: NativeSyntheticEvent) => { + setAllowEndFade(isCloseToBottom(e.nativeEvent)); + }, []); + + const gradientColors = useMemo(() => [ + theme.centerChannelBg, + changeOpacity(theme.centerChannelBg, 0.6458), + changeOpacity(theme.centerChannelBg, 0), + ], [theme]); + + if (!bookmarks.length && showInInfo && canAddBookmarks) { + return ( + + ); + } + + if (bookmarks.length) { + return ( + + + + + {canAddBookmarks && + + {allowEndFade && + + } + + + } + + {separator && + + } + + + ); + } + + return null; +}; + +export default ChannelBookmarks; diff --git a/app/components/channel_bookmarks/index.ts b/app/components/channel_bookmarks/index.ts new file mode 100644 index 000000000..1248da186 --- /dev/null +++ b/app/components/channel_bookmarks/index.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; + +import {observeBookmarks, observeCanDeleteBookmarks, observeCanEditBookmarks} from '@queries/servers/channel_bookmark'; +import {observeCanDownloadFiles, observeCanUploadFiles, observeConfigBooleanValue, observeCurrentUserId} from '@queries/servers/system'; + +import ChannelBookmarks from './channel_bookmarks'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type Props = WithDatabaseArgs & { + channelId: string; +} + +const enhanced = withObservables([], ({channelId, database}: Props) => { + return { + bookmarks: observeBookmarks(database, channelId), + canDeleteBookmarks: observeCanDeleteBookmarks(database, channelId), + canDownloadFiles: observeCanDownloadFiles(database), + canEditBookmarks: observeCanEditBookmarks(database, channelId), + canUploadFiles: observeCanUploadFiles(database), + currentUserId: observeCurrentUserId(database), + publicLinkEnabled: observeConfigBooleanValue(database, 'EnablePublicLink'), + }; +}); + +export default withDatabase(enhanced(ChannelBookmarks)); diff --git a/app/components/document/index.tsx b/app/components/document/index.tsx new file mode 100644 index 000000000..667556344 --- /dev/null +++ b/app/components/document/index.tsx @@ -0,0 +1,168 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {deleteAsync} from 'expo-file-system'; +import {forwardRef, useImperativeHandle, useRef, useState, type ReactNode, useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {Platform, StatusBar, type StatusBarStyle} from 'react-native'; +import FileViewer from 'react-native-file-viewer'; +import tinyColor from 'tinycolor2'; + +import {downloadFile} from '@actions/remote/file'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {alertDownloadDocumentDisabled, alertDownloadFailed, alertFailedToOpenDocument} from '@utils/document'; +import {getFullErrorMessage, isErrorWithMessage} from '@utils/errors'; +import {fileExists, getLocalFilePathFromFile} from '@utils/file'; +import {logDebug} from '@utils/log'; + +import type {ClientResponse, ProgressPromise} from '@mattermost/react-native-network-client'; + +export type DocumentRef = { + handlePreviewPress: () => void; +} + +type DocumentProps = { + canDownloadFiles: boolean; + file: FileInfo; + children: ReactNode; + onProgress: (progress: number) => void; +} + +const Document = forwardRef(({canDownloadFiles, children, onProgress, file}: DocumentProps, ref) => { + const intl = useIntl(); + const serverUrl = useServerUrl(); + const theme = useTheme(); + const [didCancel, setDidCancel] = useState(false); + const [downloading, setDownloading] = useState(false); + const [preview, setPreview] = useState(false); + const downloadTask = useRef>(); + + const cancelDownload = () => { + setDidCancel(true); + if (downloadTask.current?.cancel) { + downloadTask.current.cancel(); + } + }; + + const downloadAndPreviewFile = useCallback(async () => { + setDidCancel(false); + let path; + let exists = false; + + try { + path = decodeURIComponent(file.localPath || ''); + if (path) { + exists = await fileExists(path); + } + + if (!exists) { + path = getLocalFilePathFromFile(serverUrl, file); + exists = await fileExists(path); + } + + if (exists) { + openDocument(); + } else { + setDownloading(true); + downloadTask.current = downloadFile(serverUrl, file.id!, path!); + downloadTask.current?.progress?.(onProgress); + + await downloadTask.current; + onProgress(1); + openDocument(); + } + } catch (error) { + if (path) { + deleteAsync(path, {idempotent: true}); + } + setDownloading(false); + onProgress(0); + + if (!isErrorWithMessage(error) || error.message !== 'cancelled') { + logDebug('error on downloadAndPreviewFile', getFullErrorMessage(error)); + alertDownloadFailed(intl); + } + } + }, [file, onProgress]); + + const setStatusBarColor = useCallback((style: StatusBarStyle = 'light-content') => { + if (Platform.OS === 'ios') { + if (style) { + StatusBar.setBarStyle(style, true); + } else { + const headerColor = tinyColor(theme.sidebarHeaderBg); + let barStyle: StatusBarStyle = 'light-content'; + if (headerColor.isLight() && Platform.OS === 'ios') { + barStyle = 'dark-content'; + } + StatusBar.setBarStyle(barStyle, true); + } + } + }, [theme]); + + const openDocument = useCallback(async () => { + if (!didCancel && !preview) { + let path = decodeURIComponent(file.localPath || ''); + let exists = false; + if (path) { + exists = await fileExists(path); + } + + if (!exists) { + path = getLocalFilePathFromFile(serverUrl, file); + } + + setPreview(true); + setStatusBarColor('dark-content'); + FileViewer.open(path!.replace('file://', ''), { + displayName: decodeURIComponent(file.name), + onDismiss: onDonePreviewingFile, + showOpenWithDialog: true, + showAppsSuggestions: true, + }).then(() => { + setDownloading(false); + onProgress(0); + }).catch(() => { + alertFailedToOpenDocument(file, intl); + onDonePreviewingFile(); + + if (path) { + deleteAsync(path, {idempotent: true}); + } + }); + } + }, [didCancel, preview, file, onProgress, setStatusBarColor]); + + const handlePreviewPress = useCallback(async () => { + if (!canDownloadFiles) { + alertDownloadDocumentDisabled(intl); + return; + } + + if (downloading) { + onProgress(0); + cancelDownload(); + setDownloading(false); + } else { + downloadAndPreviewFile(); + } + }, [canDownloadFiles, downloadAndPreviewFile, downloading, intl, onProgress, openDocument]); + + const onDonePreviewingFile = () => { + onProgress(0); + setDownloading(false); + setPreview(false); + setStatusBarColor(); + }; + + useImperativeHandle(ref, () => ({ + handlePreviewPress, + }), [handlePreviewPress]); + + return children; +}); + +Document.displayName = 'Document'; + +export default Document; diff --git a/app/components/files/document_file.tsx b/app/components/files/document_file.tsx index 1d5763c1a..dbf65bdb7 100644 --- a/app/components/files/document_file.tsx +++ b/app/components/files/document_file.tsx @@ -1,34 +1,18 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {deleteAsync} from 'expo-file-system'; import React, {forwardRef, useImperativeHandle, useRef, useState} from 'react'; -import {useIntl} from 'react-intl'; -import {Platform, StatusBar, type StatusBarStyle, StyleSheet, TouchableOpacity, View} from 'react-native'; -import FileViewer from 'react-native-file-viewer'; -import tinyColor from 'tinycolor2'; +import {StyleSheet, TouchableOpacity, View} from 'react-native'; +import Document, {type DocumentRef} from '@components/document'; import ProgressBar from '@components/progress_bar'; -import {DOWNLOAD_TIMEOUT} from '@constants/network'; -import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; -import NetworkManager from '@managers/network_manager'; -import {alertDownloadDocumentDisabled, alertDownloadFailed, alertFailedToOpenDocument} from '@utils/document'; -import {getFullErrorMessage, isErrorWithMessage} from '@utils/errors'; -import {fileExists, getLocalFilePathFromFile} from '@utils/file'; -import {logDebug} from '@utils/log'; import FileIcon from './file_icon'; -import type {Client} from '@client/rest'; -import type {ClientResponse, ProgressPromise} from '@mattermost/react-native-network-client'; - -export type DocumentFileRef = { - handlePreviewPress: () => void; -} - type DocumentFileProps = { backgroundColor?: string; + disabled?: boolean; canDownloadFiles: boolean; file: FileInfo; } @@ -43,122 +27,13 @@ const styles = StyleSheet.create({ }, }); -const DocumentFile = forwardRef(({backgroundColor, canDownloadFiles, file}: DocumentFileProps, ref) => { - const intl = useIntl(); - const serverUrl = useServerUrl(); +const DocumentFile = forwardRef(({backgroundColor, canDownloadFiles, disabled = false, file}: DocumentFileProps, ref) => { const theme = useTheme(); - const [didCancel, setDidCancel] = useState(false); - const [downloading, setDownloading] = useState(false); - const [preview, setPreview] = useState(false); const [progress, setProgress] = useState(0); - let client: Client | undefined; - try { - client = NetworkManager.getClient(serverUrl); - } catch { - // do nothing - } - const downloadTask = useRef>(); - - const cancelDownload = () => { - setDidCancel(true); - if (downloadTask.current?.cancel) { - downloadTask.current.cancel(); - } - }; - - const downloadAndPreviewFile = async () => { - setDidCancel(false); - let path; - - try { - path = getLocalFilePathFromFile(serverUrl, file); - const exists = await fileExists(path); - if (exists) { - openDocument(); - } else { - setDownloading(true); - downloadTask.current = client?.apiClient.download(client?.getFileRoute(file.id!), path!.replace('file://', ''), {timeoutInterval: DOWNLOAD_TIMEOUT}); - downloadTask.current?.progress?.(setProgress); - - await downloadTask.current; - setProgress(1); - openDocument(); - } - } catch (error) { - if (path) { - deleteAsync(path, {idempotent: true}); - } - setDownloading(false); - setProgress(0); - - if (!isErrorWithMessage(error) || error.message !== 'cancelled') { - logDebug('error on downloadAndPreviewFile', getFullErrorMessage(error)); - alertDownloadFailed(intl); - } - } - }; + const document = useRef(null); const handlePreviewPress = async () => { - if (!canDownloadFiles) { - alertDownloadDocumentDisabled(intl); - return; - } - - if (downloading && progress < 1) { - cancelDownload(); - } else if (downloading) { - setProgress(0); - setDidCancel(true); - setDownloading(false); - } else { - downloadAndPreviewFile(); - } - }; - - const onDonePreviewingFile = () => { - setProgress(0); - setDownloading(false); - setPreview(false); - setStatusBarColor(); - }; - - const openDocument = () => { - if (!didCancel && !preview) { - const path = getLocalFilePathFromFile(serverUrl, file); - setPreview(true); - setStatusBarColor('dark-content'); - FileViewer.open(path!, { - displayName: file.name, - onDismiss: onDonePreviewingFile, - showOpenWithDialog: true, - showAppsSuggestions: true, - }).then(() => { - setDownloading(false); - setProgress(0); - }).catch(() => { - alertFailedToOpenDocument(file, intl); - onDonePreviewingFile(); - - if (path) { - deleteAsync(path, {idempotent: true}); - } - }); - } - }; - - const setStatusBarColor = (style: StatusBarStyle = 'light-content') => { - if (Platform.OS === 'ios') { - if (style) { - StatusBar.setBarStyle(style, true); - } else { - const headerColor = tinyColor(theme.sidebarHeaderBg); - let barStyle: StatusBarStyle = 'light-content'; - if (headerColor.isLight() && Platform.OS === 'ios') { - barStyle = 'dark-content'; - } - StatusBar.setBarStyle(barStyle, true); - } - } + document.current?.handlePreviewPress(); }; useImperativeHandle(ref, () => ({ @@ -173,13 +48,13 @@ const DocumentFile = forwardRef(({background ); let fileAttachmentComponent = icon; - if (downloading) { + if (progress) { fileAttachmentComponent = ( <> {icon} @@ -188,9 +63,19 @@ const DocumentFile = forwardRef(({background } return ( - - {fileAttachmentComponent} - + + + {fileAttachmentComponent} + + ); }); diff --git a/app/components/files/file.tsx b/app/components/files/file.tsx index 4f3e8efa6..bd3ce0d40 100644 --- a/app/components/files/file.tsx +++ b/app/components/files/file.tsx @@ -11,7 +11,7 @@ import {useGalleryItem} from '@hooks/gallery'; import {isDocument, isImage, isVideo} from '@utils/file'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import DocumentFile, {type DocumentFileRef} from './document_file'; +import DocumentFile from './document_file'; import FileIcon from './file_icon'; import FileInfo from './file_info'; import FileOptionsIcon from './file_options_icon'; @@ -19,6 +19,8 @@ import ImageFile from './image_file'; import ImageFileOverlay from './image_file_overlay'; import VideoFile from './video_file'; +import type {DocumentRef} from '@components/document'; + type FileProps = { canDownloadFiles: boolean; file: FileInfo; @@ -36,6 +38,7 @@ type FileProps = { showDate?: boolean; updateFileForGallery: (idx: number, file: FileInfo) => void; asCard?: boolean; + isPressDisabled?: boolean; }; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -79,8 +82,9 @@ const File = ({ showDate = false, updateFileForGallery, wrapperWidth = 300, + isPressDisabled = false, }: FileProps) => { - const document = useRef(null); + const document = useRef(null); const theme = useTheme(); const style = getStyleSheet(theme); @@ -101,6 +105,7 @@ const File = ({ const renderCardWithImage = (fileIcon: JSX.Element) => { const fileInfo = ( + + @@ -184,6 +196,7 @@ const File = ({ const fileInfo = ( { }; }); -const FileInfo = ({file, channelName, showDate, onPress}: FileInfoProps) => { +const FileInfo = ({disabled, file, channelName, showDate, onPress}: FileInfoProps) => { const theme = useTheme(); const style = getStyleSheet(theme); + return ( - + - {file.name.trim()} + {decodeURIComponent(file.name.trim())} {channelName && diff --git a/app/components/files/image_file.tsx b/app/components/files/image_file.tsx index 9c95945d2..c604ef7cb 100644 --- a/app/components/files/image_file.tsx +++ b/app/components/files/image_file.tsx @@ -5,7 +5,7 @@ import {LinearGradient} from 'expo-linear-gradient'; import React, {useCallback, useMemo, useState} from 'react'; import {StyleSheet, useWindowDimensions, View} from 'react-native'; -import {buildFilePreviewUrl, buildFileThumbnailUrl} from '@actions/remote/file'; +import {buildFilePreviewUrl, buildFileThumbnailUrl, buildFileUrl} from '@actions/remote/file'; import CompassIcon from '@components/compass_icon'; import ProgressiveImage from '@components/progressive_image'; import {useServerUrl} from '@context/server'; @@ -99,12 +99,17 @@ const ImageFile = ({ } else if (file.id) { if (file.mini_preview && file.mime_type) { props.thumbnailUri = `data:${file.mime_type};base64,${file.mini_preview}`; - } else { + } else if (file.has_preview_image) { props.thumbnailUri = buildFileThumbnailUrl(serverUrl, file.id); } - props.imageUri = buildFilePreviewUrl(serverUrl, file.id); + if (file.has_preview_image) { + props.imageUri = buildFilePreviewUrl(serverUrl, file.id); + } else { + props.imageUri = buildFileUrl(serverUrl, file.id, file.update_at); + } props.inViewPort = inViewPort; } + return props; }; diff --git a/app/components/floating_text_input_label/index.tsx b/app/components/floating_text_input_label/index.tsx index 7f8a91b3d..69914bc28 100644 --- a/app/components/floating_text_input_label/index.tsx +++ b/app/components/floating_text_input_label/index.tsx @@ -59,7 +59,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ maxWidth: 315, }, readOnly: { - backgroundColor: changeOpacity(theme.centerChannelBg, 0.16), + backgroundColor: changeOpacity(theme.centerChannelColor, 0.16), }, smallLabel: { fontSize: 10, diff --git a/app/components/post_list/more_messages/more_messages.tsx b/app/components/post_list/more_messages/more_messages.tsx index e98632e89..cd18f4cb0 100644 --- a/app/components/post_list/more_messages/more_messages.tsx +++ b/app/components/post_list/more_messages/more_messages.tsx @@ -13,6 +13,7 @@ import FormattedText from '@components/formatted_text'; import TouchableWithFeedback from '@components/touchable_with_feedback'; import {Events} from '@constants'; import {useServerUrl} from '@context/server'; +import {useIsTablet} from '@hooks/device'; import useDidUpdate from '@hooks/did_update'; import EphemeralStore from '@store/ephemeral_store'; import {makeStyleSheetFromTheme, hexToHue, changeOpacity} from '@utils/theme'; @@ -116,6 +117,7 @@ const MoreMessages = ({ }: Props) => { const serverUrl = useServerUrl(); const insets = useSafeAreaInsets(); + const isTablet = useIsTablet(); const pressed = useRef(false); const resetting = useRef(false); const initialScroll = useRef(false); @@ -127,7 +129,7 @@ const MoreMessages = ({ const callsAdjustment = useCallsAdjustment(serverUrl, channelId); // The final top: - const adjustedTop = insets.top + callsAdjustment; + const adjustedTop = (isTablet ? 0 : insets.top) + callsAdjustment; const BARS_FACTOR = Math.abs((1) / (HIDDEN_TOP - SHOWN_TOP)); diff --git a/app/constants/database.ts b/app/constants/database.ts index d302c280a..428eb690d 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -13,6 +13,7 @@ export const MM_TABLES = { CATEGORY: 'Category', CATEGORY_CHANNEL: 'CategoryChannel', CHANNEL: 'Channel', + CHANNEL_BOOKMARK: 'ChannelBookmark', CHANNEL_INFO: 'ChannelInfo', CHANNEL_MEMBERSHIP: 'ChannelMembership', CONFIG: 'Config', diff --git a/app/constants/permissions.ts b/app/constants/permissions.ts index 1b8637957..22ff91433 100644 --- a/app/constants/permissions.ts +++ b/app/constants/permissions.ts @@ -153,4 +153,10 @@ export default { }, MANAGE_BOTS: 'manage_bots', MANAGE_OTHERS_BOTS: 'manage_others_bots', + ADD_BOOKMARK_PUBLIC_CHANNEL: 'add_bookmark_public_channel', + ADD_BOOKMARK_PRIVATE_CHANNEL: 'add_bookmark_private_channel', + EDIT_BOOKMARK_PUBLIC_CHANNEL: 'edit_bookmark_public_channel', + EDIT_BOOKMARK_PRIVATE_CHANNEL: 'edit_bookmark_private_channel', + DELETE_BOOKMARK_PUBLIC_CHANNEL: 'delete_bookmark_public_channel', + DELETE_BOOKMARK_PRIVATE_CHANNEL: 'delete_bookmark_private_channel', }; diff --git a/app/constants/screens.ts b/app/constants/screens.ts index a65396577..8b4496c95 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -75,6 +75,8 @@ export const THREAD = 'Thread'; export const THREAD_FOLLOW_BUTTON = 'ThreadFollowButton'; export const THREAD_OPTIONS = 'ThreadOptions'; export const USER_PROFILE = 'UserProfile'; +export const CHANNEL_BOOKMARK = 'ChannelBookmarkAddOrEdit'; +export const GENERIC_OVERLAY = 'GenericOverlay'; export default { ABOUT, @@ -87,6 +89,7 @@ export default { CALL_HOST_CONTROLS, CHANNEL, CHANNEL_ADD_MEMBERS, + CHANNEL_BOOKMARK, CHANNEL_FILES, CHANNEL_INFO, CHANNEL_NOTIFICATION_PREFERENCES, @@ -151,6 +154,7 @@ export default { THREAD_FOLLOW_BUTTON, THREAD_OPTIONS, USER_PROFILE, + GENERIC_OVERLAY, } as const; export const MODAL_SCREENS_WITHOUT_BACK = new Set([ @@ -174,6 +178,7 @@ export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set([ PERMALINK, REVIEW_APP, SNACK_BAR, + GENERIC_OVERLAY, ]); export const SCREENS_AS_BOTTOM_SHEET = new Set([ diff --git a/app/constants/snack_bar.ts b/app/constants/snack_bar.ts index 57491b93f..f36053ab0 100644 --- a/app/constants/snack_bar.ts +++ b/app/constants/snack_bar.ts @@ -10,6 +10,7 @@ export const SNACK_BAR_TYPE = keyMirror({ FOLLOW_THREAD: null, INFO_COPIED: null, LINK_COPIED: null, + LINK_COPY_FAILED: null, MESSAGE_COPIED: null, MUTE_CHANNEL: null, REMOVE_CHANNEL_USER: null, @@ -65,6 +66,13 @@ export const SNACK_BAR_CONFIG: Record = { canUndo: false, type: MESSAGE_TYPE.SUCCESS, }, + LINK_COPY_FAILED: { + id: t('gallery.copy_link.failed'), + defaultMessage: 'Failed to copy link to clipboard', + iconName: 'link-variant', + canUndo: false, + type: MESSAGE_TYPE.ERROR, + }, MESSAGE_COPIED: { id: t('snack.bar.message.copied'), defaultMessage: 'Text copied to clipboard', diff --git a/app/constants/view.ts b/app/constants/view.ts index 50ee021a0..d42ab6914 100644 --- a/app/constants/view.ts +++ b/app/constants/view.ts @@ -27,6 +27,7 @@ export const CALL_ERROR_BAR_HEIGHT = 52; export const CALL_NOTIFICATION_BAR_HEIGHT = 40; export const ANNOUNCEMENT_BAR_HEIGHT = 40; +export const BOOKMARKS_BAR_HEIGHT = 48; export const HOME_PADDING = { paddingLeft: 18, diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index 100773417..d4034e213 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -100,5 +100,9 @@ const WebsocketEvents = { GROUP_DISSOCIATED_TO_TEAM: 'received_group_not_associated_to_team', GROUP_ASSOCIATED_TO_CHANNEL: 'received_group_associated_to_channel', GROUP_DISSOCIATED_TO_CHANNEL: 'received_group_not_associated_to_channel', + CHANNEL_BOOKMARK_CREATED: 'channel_bookmark_created', + CHANNEL_BOOKMARK_UPDATED: 'channel_bookmark_updated', + CHANNEL_BOOKMARK_SORTED: 'channel_bookmark_sorted', + CHANNEL_BOOKMARK_DELETED: 'channel_bookmark_deleted', }; export default WebsocketEvents; diff --git a/app/database/manager/__mocks__/index.ts b/app/database/manager/__mocks__/index.ts index 8c9d2eada..a58b052ea 100644 --- a/app/database/manager/__mocks__/index.ts +++ b/app/database/manager/__mocks__/index.ts @@ -11,7 +11,7 @@ import {DatabaseType, MIGRATION_EVENTS, MM_TABLES} from '@constants/database'; import AppDatabaseMigrations from '@database/migration/app'; import ServerDatabaseMigrations from '@database/migration/server'; import {InfoModel, GlobalModel, ServersModel} from '@database/models/app'; -import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel, +import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel, GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, @@ -47,7 +47,7 @@ class DatabaseManager { constructor() { this.appModels = [InfoModel, GlobalModel, ServersModel]; this.serverModels = [ - CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel, + CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel, GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, diff --git a/app/database/manager/index.ts b/app/database/manager/index.ts index 8d029910f..e5edf058b 100644 --- a/app/database/manager/index.ts +++ b/app/database/manager/index.ts @@ -12,7 +12,7 @@ import {DatabaseType, MIGRATION_EVENTS, MM_TABLES} from '@constants/database'; import AppDatabaseMigrations from '@database/migration/app'; import ServerDatabaseMigrations from '@database/migration/server'; import {InfoModel, GlobalModel, ServersModel} from '@database/models/app'; -import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, +import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, @@ -44,7 +44,7 @@ class DatabaseManager { constructor() { this.appModels = [InfoModel, GlobalModel, ServersModel]; this.serverModels = [ - CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel, + CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel, GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, diff --git a/app/database/migration/server/index.ts b/app/database/migration/server/index.ts index 6fc926695..73a2a34cd 100644 --- a/app/database/migration/server/index.ts +++ b/app/database/migration/server/index.ts @@ -4,13 +4,37 @@ // NOTE : To implement migration, please follow this document // https://nozbe.github.io/WatermelonDB/Advanced/Migrations.html -import {addColumns, schemaMigrations} from '@nozbe/watermelondb/Schema/migrations'; +import {addColumns, createTable, schemaMigrations} from '@nozbe/watermelondb/Schema/migrations'; import {MM_TABLES} from '@constants/database'; -const {CHANNEL_INFO, DRAFT, POST} = MM_TABLES.SERVER; +const {CHANNEL_BOOKMARK, CHANNEL_INFO, DRAFT, POST} = MM_TABLES.SERVER; export default schemaMigrations({migrations: [ + { + toVersion: 4, + steps: [ + createTable({ + name: CHANNEL_BOOKMARK, + columns: [ + {name: 'create_at', type: 'number'}, + {name: 'update_at', type: 'number'}, + {name: 'delete_at', type: 'number'}, + {name: 'channel_id', type: 'string', isIndexed: true}, + {name: 'owner_id', type: 'string'}, + {name: 'file_id', type: 'string', isOptional: true}, + {name: 'display_name', type: 'string'}, + {name: 'sort_order', type: 'number'}, + {name: 'link_url', type: 'string', isOptional: true}, + {name: 'image_url', type: 'string', isOptional: true}, + {name: 'emoji', type: 'string', isOptional: true}, + {name: 'type', type: 'string'}, + {name: 'original_id', type: 'string', isOptional: true}, + {name: 'parent_id', type: 'string', isOptional: true}, + ], + }), + ], + }, { toVersion: 3, steps: [ diff --git a/app/database/models/server/channel.ts b/app/database/models/server/channel.ts index 80c40fcfd..420e19b5a 100644 --- a/app/database/models/server/channel.ts +++ b/app/database/models/server/channel.ts @@ -9,6 +9,7 @@ import {MM_TABLES} from '@constants/database'; import type {Query, Relation} from '@nozbe/watermelondb'; import type CategoryChannelModel from '@typings/database/models/servers/category_channel'; import type ChannelModelInterface from '@typings/database/models/servers/channel'; +import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; import type ChannelInfoModel from '@typings/database/models/servers/channel_info'; import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership'; import type DraftModel from '@typings/database/models/servers/draft'; @@ -21,6 +22,7 @@ import type UserModel from '@typings/database/models/servers/user'; const { CATEGORY_CHANNEL, CHANNEL, + CHANNEL_BOOKMARK, CHANNEL_INFO, CHANNEL_MEMBERSHIP, DRAFT, @@ -41,6 +43,9 @@ export default class ChannelModel extends Model implements ChannelModelInterface /** associations : Describes every relationship to this table. */ static associations: Associations = { + /** A CHANNEL can be associated with multiple CHANNEL_BOOKMARK (relationship is 1:N) */ + [CHANNEL_BOOKMARK]: {type: 'has_many', foreignKey: 'channel_id'}, + /** A CHANNEL can be associated with multiple CHANNEL_MEMBERSHIP (relationship is 1:N) */ [CHANNEL_MEMBERSHIP]: {type: 'has_many', foreignKey: 'channel_id'}, @@ -109,6 +114,9 @@ export default class ChannelModel extends Model implements ChannelModelInterface /** drafts : All drafts for this channel */ @children(DRAFT) drafts!: Query; + /** bookmarks : All bookmarks for this channel */ + @children(CHANNEL_BOOKMARK) bookmarks!: Query; + /** posts : All posts made in that channel */ @children(POST) posts!: Query; diff --git a/app/database/models/server/channel_bookmark.ts b/app/database/models/server/channel_bookmark.ts new file mode 100644 index 000000000..084b23dae --- /dev/null +++ b/app/database/models/server/channel_bookmark.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {field, immutableRelation} from '@nozbe/watermelondb/decorators'; +import Model, {type Associations} from '@nozbe/watermelondb/Model'; + +import {MM_TABLES} from '@constants/database'; + +import type {Relation} from '@nozbe/watermelondb'; +import type ChannelModel from '@typings/database/models/servers/channel'; +import type ChannelBookmarkInterface from '@typings/database/models/servers/channel_bookmark'; +import type FileModel from '@typings/database/models/servers/file'; +import type UserModel from '@typings/database/models/servers/user'; + +const { + CHANNEL, + CHANNEL_BOOKMARK, + FILE, + USER, +} = MM_TABLES.SERVER; + +/** + * The Channel model represents a channel in the Mattermost app. + */ +export default class ChannelBookmarkModel extends Model implements ChannelBookmarkInterface { + /** table (name) : Channel */ + static table = CHANNEL_BOOKMARK; + + /** associations : Describes every relationship to this table. */ + static associations: Associations = { + + /** A CHANNEL can be associated to CHANNEL_BOOKMARK (relationship is 1:N) */ + [CHANNEL]: {type: 'belongs_to', key: 'channel_id'}, + + /** A USER can create multiple CHANNEL_BOOKMARK (relationship is 1:N) */ + [USER]: {type: 'belongs_to', key: 'owner_id'}, + + /** A FILE is associated with one CHANNEL_BOOKMARK**/ + [FILE]: {type: 'has_many', foreignKey: 'file_id'}, + + }; + + /** create_at : The creation date for this channel bookmark */ + @field('create_at') createAt!: number; + + /** update_at : The timestamp to when this channel bookmark was last updated on the server */ + @field('update_at') updateAt!: number; + + /** delete_at : The deletion/archived date of this channel bookmark */ + @field('delete_at') deleteAt!: number; + + /** channel_id : The channel to which this bookmarks belongs */ + @field('channel_id') channelId!: string; + + /** owner_id : The user who created this channel bookmark */ + @field('owner_id') ownerId!: string; + + /** file_id : The file attached this channel bookmark */ + @field('file_id') fileId?: string; + + /** display_name : The channel bookmark display name (e.g. Important document ) */ + @field('display_name') displayName!: string; + + /** sort_order : the order in which the bookmark is displayed in the UI. */ + @field('sort_order') sortOrder!: number; + + /** link_url : The channel bookmark url if of type link */ + @field('link_url') linkUrl?: string; + + /** image_url : The channel bookmark image url if of type link (optional) */ + @field('image_url') imageUrl?: string; + + /** emoji : The channel bookmark emoji (optional) */ + @field('emoji') emoji?: string; + + /** type : The channel bookmark type it can be link or file */ + @field('type') type!: ChannelBookmarkType; + + /** original_id : The channel bookmark original identifier before it was edited */ + @field('original_id') originalId?: string; + + /** parent_id : The channel bookmark parent in case is nested */ + @field('parent_id') parentId?: string; + + /** channel : The CHANNEL to which this CHANNEL_BOOKMARK belongs */ + @immutableRelation(CHANNEL, 'channel_id') channel!: Relation; + + /** creator : The USER who created this CHANNEL_BOOKMARK */ + @immutableRelation(USER, 'owner_id') owner!: Relation; + + /** file : The FILE attached to this CHANNEL_BOOKMARK */ + @immutableRelation(FILE, 'file_id') file!: Relation; + + toApi = () => { + const b: ChannelBookmark = { + id: this.id, + create_at: this.createAt, + update_at: this.updateAt, + delete_at: this.deleteAt, + channel_id: this.channelId, + owner_id: this.ownerId, + file_id: this.fileId, + display_name: this.displayName, + sort_order: this.sortOrder, + link_url: this.linkUrl, + image_url: this.imageUrl, + emoji: this.emoji, + type: this.type, + original_id: this.originalId, + parent_id: this.parentId, + }; + + return b; + }; +} diff --git a/app/database/models/server/file.ts b/app/database/models/server/file.ts index 45c62e497..96ba7fc2a 100644 --- a/app/database/models/server/file.ts +++ b/app/database/models/server/file.ts @@ -10,7 +10,7 @@ import type {Relation} from '@nozbe/watermelondb'; import type FileModelInterface from '@typings/database/models/servers/file'; import type PostModel from '@typings/database/models/servers/post'; -const {FILE, POST} = MM_TABLES.SERVER; +const {CHANNEL_BOOKMARK, FILE, POST} = MM_TABLES.SERVER; /** * The File model works in pair with the Post model. It hosts information about the files attached to a Post @@ -22,6 +22,9 @@ export default class FileModel extends Model implements FileModelInterface { /** associations : Describes every relationship to this table. */ static associations: Associations = { + /** A CHANNEL_BOOKMARK has a 1:1 relationship with FILE. */ + [CHANNEL_BOOKMARK]: {type: 'has_many', foreignKey: 'file_id'}, + /** A POST has a 1:N relationship with FILE. */ [POST]: {type: 'belongs_to', key: 'post_id'}, }; diff --git a/app/database/models/server/index.ts b/app/database/models/server/index.ts index 8368b53da..6ea0df906 100644 --- a/app/database/models/server/index.ts +++ b/app/database/models/server/index.ts @@ -3,9 +3,10 @@ export {default as CategoryModel} from './category'; export {default as CategoryChannelModel} from './category_channel'; +export {default as ChannelModel} from './channel'; +export {default as ChannelBookmarkModel} from './channel_bookmark'; export {default as ChannelInfoModel} from './channel_info'; export {default as ChannelMembershipModel} from './channel_membership'; -export {default as ChannelModel} from './channel'; export {default as ConfigModel} from './config'; export {default as CustomEmojiModel} from './custom_emoji'; export {default as DraftModel} from './draft'; diff --git a/app/database/models/server/user.ts b/app/database/models/server/user.ts index c9c22154e..1edbaba54 100644 --- a/app/database/models/server/user.ts +++ b/app/database/models/server/user.ts @@ -20,6 +20,7 @@ import type {UserMentionKey, HighlightWithoutNotificationKey} from '@typings/glo const { CHANNEL, + CHANNEL_BOOKMARK, CHANNEL_MEMBERSHIP, POST, PREFERENCE, @@ -43,6 +44,9 @@ export default class UserModel extends Model implements UserModelInterface { /** USER has a 1:N relationship with CHANNEL. A user can create multiple channels */ [CHANNEL]: {type: 'has_many', foreignKey: 'creator_id'}, + /** USER has a 1:N relationship with CHANNEL_BOOKMARK. A user can create multiple channels */ + [CHANNEL_BOOKMARK]: {type: 'has_many', foreignKey: 'owner_id'}, + /** USER has a 1:N relationship with CHANNEL_MEMBERSHIP. A user can be part of multiple channels */ [CHANNEL_MEMBERSHIP]: {type: 'has_many', foreignKey: 'user_id'}, diff --git a/app/database/operator/base_data_operator/index.ts b/app/database/operator/base_data_operator/index.ts index aace2f2aa..1b77b665f 100644 --- a/app/database/operator/base_data_operator/index.ts +++ b/app/database/operator/base_data_operator/index.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Database, Q} from '@nozbe/watermelondb'; +import {Database, Model, Q} from '@nozbe/watermelondb'; import {OperationType} from '@constants/database'; import { @@ -11,7 +11,6 @@ import { } from '@database/operator/utils/general'; import {logWarning} from '@utils/log'; -import type Model from '@nozbe/watermelondb/Model'; import type { HandleRecordsArgs, OperationArgs, @@ -40,10 +39,11 @@ export default class BaseDataOperator { * the same value. Hence, prior to that we query the database and pick only those values that are 'new' from the 'Raw' array. * @param {ProcessRecordsArgs} inputsArg * @param {RawValue[]} inputsArg.createOrUpdateRawValues + * @param {RawValue[]} inputsArg.deleteRawValues * @param {string} inputsArg.tableName * @param {string} inputsArg.fieldName * @param {(existing: Model, newElement: RawValue) => boolean} inputsArg.buildKeyRecordBy - * @returns {Promise<{ProcessRecordResults}>} + * @returns {Promise<{ProcessRecordResults}>} */ processRecords = async ({createOrUpdateRawValues = [], deleteRawValues = [], tableName, buildKeyRecordBy, fieldName, shouldUpdate}: ProcessRecordsArgs): Promise> => { const getRecords = async (rawValues: RawValue[]) => { @@ -77,7 +77,7 @@ export default class BaseDataOperator { // for create or update flow const createOrUpdateRaws = await getRecords(createOrUpdateRawValues); - const recordsByKeys = createOrUpdateRaws.reduce((result: Record, record) => { + const recordsByKeys = createOrUpdateRaws.reduce((result: Record, record) => { // @ts-expect-error object with string key const key = buildKeyRecordBy?.(record) || record[fieldName]; result[key] = record; @@ -125,9 +125,9 @@ export default class BaseDataOperator { * @param {string} prepareRecord.tableName * @param {RawValue[]} prepareRecord.createRaws * @param {RawValue[]} prepareRecord.updateRaws - * @param {Model[]} prepareRecord.deleteRaws - * @param {(TransformerArgs) => Promise;} transformer - * @returns {Promise} + * @param {T extends Model[]} prepareRecord.deleteRaws + * @param {(TransformerArgs) => Promise;} transformer + * @returns {Promise} */ prepareRecords = async ({tableName, createRaws, deleteRaws, updateRaws, transformer}: OperationArgs): Promise => { if (!this.database) { @@ -210,7 +210,7 @@ export default class BaseDataOperator { * @returns {Promise} */ async handleRecords({buildKeyRecordBy, fieldName, transformer, createOrUpdateRawValues, deleteRawValues = [], tableName, prepareRecordsOnly = true, shouldUpdate}: HandleRecordsArgs, description: string): Promise { - if (!createOrUpdateRawValues.length) { + if (!createOrUpdateRawValues.length && !deleteRawValues.length) { logWarning( `An empty "rawValues" array has been passed to the handleRecords method for tableName ${tableName}`, ); diff --git a/app/database/operator/server_data_operator/handlers/channel.test.ts b/app/database/operator/server_data_operator/handlers/channel.test.ts index d5c3b5df8..eccb9fc47 100644 --- a/app/database/operator/server_data_operator/handlers/channel.test.ts +++ b/app/database/operator/server_data_operator/handlers/channel.test.ts @@ -184,7 +184,7 @@ describe('*** Operator: Channel Handlers tests ***', () => { await operator.handleMyChannel({ channels, myChannels, - prepareRecordsOnly: false, + prepareRecordsOnly: true, }); expect(spyOnHandleRecords).toHaveBeenCalledTimes(1); @@ -192,7 +192,7 @@ describe('*** Operator: Channel Handlers tests ***', () => { fieldName: 'id', createOrUpdateRawValues: myChannels, tableName: 'MyChannel', - prepareRecordsOnly: false, + prepareRecordsOnly: true, buildKeyRecordBy: buildMyChannelKey, transformer: transformMyChannelRecord, }, 'handleMyChannel'); @@ -253,7 +253,7 @@ describe('*** Operator: Channel Handlers tests ***', () => { prepareRecordsOnly: false, }); - expect(updated[0].lastFetchedAt).toBe(123456789); + expect(updated[0]).toHaveProperty('lastFetchedAt', 123456789); }); it('=> HandleChannelMembership: should write to the CHANNEL_MEMBERSHIP table', async () => { diff --git a/app/database/operator/server_data_operator/handlers/channel.ts b/app/database/operator/server_data_operator/handlers/channel.ts index c5b750721..b5f7f3cbe 100644 --- a/app/database/operator/server_data_operator/handlers/channel.ts +++ b/app/database/operator/server_data_operator/handlers/channel.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Database, Q} from '@nozbe/watermelondb'; +import {Database, Model, Q} from '@nozbe/watermelondb'; import {MM_TABLES} from '@constants/database'; import { @@ -9,6 +9,7 @@ import { buildChannelMembershipKey, } from '@database/operator/server_data_operator/comparators'; import { + transformChannelBookmarkRecord, transformChannelInfoRecord, transformChannelMembershipRecord, transformChannelRecord, @@ -17,10 +18,11 @@ import { } from '@database/operator/server_data_operator/transformers/channel'; import {getUniqueRawsBy} from '@database/operator/utils/general'; import {getIsCRTEnabled} from '@queries/servers/thread'; +import ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; import {logWarning} from '@utils/log'; import type ServerDataOperatorBase from '.'; -import type {HandleChannelArgs, HandleChannelInfoArgs, HandleChannelMembershipArgs, HandleMyChannelArgs, HandleMyChannelSettingsArgs} from '@typings/database/database'; +import type {HandleChannelArgs, HandleChannelBookmarkArgs, HandleChannelInfoArgs, HandleChannelMembershipArgs, HandleMyChannelArgs, HandleMyChannelSettingsArgs} from '@typings/database/database'; import type ChannelModel from '@typings/database/models/servers/channel'; import type ChannelInfoModel from '@typings/database/models/servers/channel_info'; import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership'; @@ -29,6 +31,7 @@ import type MyChannelSettingsModel from '@typings/database/models/servers/my_cha const { CHANNEL, + CHANNEL_BOOKMARK, CHANNEL_INFO, CHANNEL_MEMBERSHIP, MY_CHANNEL, @@ -37,10 +40,11 @@ const { export interface ChannelHandlerMix { handleChannel: ({channels, prepareRecordsOnly}: HandleChannelArgs) => Promise; + handleChannelBookmark: ({bookmarks, prepareRecordsOnly}: HandleChannelBookmarkArgs) => Promise; handleChannelMembership: ({channelMemberships, prepareRecordsOnly}: HandleChannelMembershipArgs) => Promise; handleMyChannelSettings: ({settings, prepareRecordsOnly}: HandleMyChannelSettingsArgs) => Promise; handleChannelInfo: ({channelInfos, prepareRecordsOnly}: HandleChannelInfoArgs) => Promise; - handleMyChannel: ({channels, myChannels, isCRTEnabled, prepareRecordsOnly}: HandleMyChannelArgs) => Promise; + handleMyChannel: ({channels, myChannels, isCRTEnabled, prepareRecordsOnly}: HandleMyChannelArgs) => Promise; } const ChannelHandler = >(superclass: TBase) => class extends superclass { @@ -217,7 +221,7 @@ const ChannelHandler = >(super * @param {boolean} myChannelsArgs.prepareRecordsOnly * @returns {Promise} */ - handleMyChannel = async ({channels, myChannels, isCRTEnabled, prepareRecordsOnly = true}: HandleMyChannelArgs): Promise => { + handleMyChannel = async ({channels, myChannels, isCRTEnabled, prepareRecordsOnly = true}: HandleMyChannelArgs): Promise => { if (!myChannels?.length) { logWarning( 'An empty or undefined "myChannels" array has been passed to the handleMyChannel method', @@ -235,7 +239,6 @@ const ChannelHandler = >(super } const isCRT = isCRTEnabled ?? await getIsCRTEnabled(this.database); - const channelMap = channels.reduce((result: Record, channel) => { result[channel.id] = channel; return result; @@ -353,6 +356,87 @@ const ChannelHandler = >(super tableName: CHANNEL_MEMBERSHIP, }, 'handleChannelMembership'); }; + + /** + * handleChannelMembership: Handler responsible for the Create/Update/Delete operations occurring on the CHANNEL_BOOKMARK table from the 'Server' schema + * @param {HandleChannelBookmarkArgs} channelBookmarkArgs + * @param {ChannelBookmark[]} channelBookmarkArgs.bookmarks + * @param {boolean} channelBookmarkArgs.prepareRecordsOnly + * @returns {Promise} + */ + handleChannelBookmark = async ({bookmarks, prepareRecordsOnly}: HandleChannelBookmarkArgs): Promise => { + if (!bookmarks?.length) { + logWarning( + 'An empty or undefined "bookmarks" array has been passed to the handleChannelBookmark method', + ); + return []; + } + + const uniqueRaws = getUniqueRawsBy({raws: bookmarks, key: 'id'}) as ChannelBookmarkWithFileInfo[]; + const keys = uniqueRaws.map((c) => c.id); + const db: Database = this.database; + const existing = await db.get(CHANNEL_BOOKMARK).query( + Q.where('id', Q.oneOf(keys)), + ).fetch(); + const bookmarkMap = new Map(existing.map((b) => [b.id, b])); + const files: FileInfo[] = []; + const raws = uniqueRaws.reduce<{createOrUpdateRaws: ChannelBookmarkWithFileInfo[]; deleteRaws: ChannelBookmarkWithFileInfo[]}>((res, b) => { + const e = bookmarkMap.get(b.id); + if (!e) { + if (!b.delete_at) { + res.createOrUpdateRaws.push(b); + if (b.file) { + files.push(b.file); + } + } + return res; + } + + if (e.updateAt !== b.update_at) { + res.createOrUpdateRaws.push(b); + if (b.file) { + files.push(b.file); + } + } + + if (b.delete_at) { + res.deleteRaws.push(b); + if (b.file) { + b.file.delete_at = b.delete_at; + files.push(b.file); + } + } + + return res; + }, {createOrUpdateRaws: [], deleteRaws: []}); + + if (!raws.createOrUpdateRaws.length && !raws.deleteRaws.length) { + return []; + } + + const preparedBookmarks = await this.handleRecords({ + fieldName: 'id', + transformer: transformChannelBookmarkRecord, + createOrUpdateRawValues: raws.createOrUpdateRaws, + deleteRawValues: raws.deleteRaws, + tableName: CHANNEL_BOOKMARK, + prepareRecordsOnly: true, + }, 'handleChannelBookmark'); + + const batch: Model[] = [...preparedBookmarks]; + if (files.length) { + const bookmarkFiles = await this.handleFiles({files, prepareRecordsOnly: true}); + if (bookmarkFiles.length) { + batch.push(...bookmarkFiles); + } + } + + if (batch.length && !prepareRecordsOnly) { + await this.batchRecords(batch, 'handleChannelBookmark'); + } + + return batch; + }; }; export default ChannelHandler; diff --git a/app/database/operator/server_data_operator/handlers/index.test.ts b/app/database/operator/server_data_operator/handlers/index.test.ts index 9ce3d4744..09b02c8a3 100644 --- a/app/database/operator/server_data_operator/handlers/index.test.ts +++ b/app/database/operator/server_data_operator/handlers/index.test.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import DatabaseManager from '@database/manager'; +import {shouldUpdateFileRecord} from '@database/operator/server_data_operator/comparators/files'; import { transformConfigRecord, transformCustomEmojiRecord, @@ -9,7 +10,7 @@ import { transformSystemRecord, } from '@database/operator/server_data_operator/transformers/general'; -import type ServerDataOperator from '..'; +import type ServerDataOperator from '@database/operator/server_data_operator/index'; describe('*** DataOperator: Base Handlers tests ***', () => { let operator: ServerDataOperator; @@ -120,6 +121,59 @@ describe('*** DataOperator: Base Handlers tests ***', () => { }, 'handleConfigs'); }); + it('=> HandleFiles: should write to the FILE table', async () => { + expect.assertions(1); + + const spyOnprocessRecords = jest.spyOn(operator, 'processRecords'); + + const files = [{ + id: 'f1oxe5rtepfs7n3zifb4sso7po', + user_id: '89ertha8xpfsumpucqppy5knao', + post_id: 'a7ebyw883trm884p1qcgt8yw4a', + create_at: 1608270920357, + update_at: 1608270920357, + delete_at: 0, + name: '4qtwrg.jpg', + extension: 'jpg', + size: 89208, + mime_type: 'image/jpeg', + width: 500, + height: 656, + has_preview_image: true, + mini_preview: + '/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABAAEAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AN/T/iZp+pX15FpUmnwLbXtpJpyy2sQLw8CcBXA+bksCDnHGOaf4W+P3xIshbQ6loB8RrbK11f3FpbBFW3ZwiFGHB2kr25BIOeCPPbX4S3407T7rTdDfxFNIpDyRaw9lsB4OECHGR15yO4GK6fRPhR4sGmSnxAs8NgchNOjvDPsjz8qSHA37cDk5JPPFdlOpTdPlcVt/Ku1lrvr17b67EPnjrH8/626H/9k=', + }, { + id: 'f1oxe5rtepfs7n3zifb4sso7po', + user_id: 'bookmark', + create_at: 1608270920357, + update_at: 1608270920357, + delete_at: 1608270920357, + name: '4qtwrg.jpg', + extension: 'jpg', + size: 89208, + mime_type: 'image/jpeg', + width: 500, + height: 656, + has_preview_image: true, + mini_preview: + '/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABAAEAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AN/T/iZp+pX15FpUmnwLbXtpJpyy2sQLw8CcBXA+bksCDnHGOaf4W+P3xIshbQ6loB8RrbK11f3FpbBFW3ZwiFGHB2kr25BIOeCPPbX4S3407T7rTdDfxFNIpDyRaw9lsB4OECHGR15yO4GK6fRPhR4sGmSnxAs8NgchNOjvDPsjz8qSHA37cDk5JPPFdlOpTdPlcVt/Ku1lrvr17b67EPnjrH8/626H/9k=', + }, + ]; + + await operator.handleFiles({ + files, + prepareRecordsOnly: false, + }); + + expect(spyOnprocessRecords).toHaveBeenCalledWith({ + fieldName: 'id', + createOrUpdateRawValues: files.filter((f) => !f.delete_at), + deleteRawValues: files.filter((f) => f.delete_at), + tableName: 'File', + shouldUpdate: shouldUpdateFileRecord, + }); + }); + it('=> No table name: should not call execute if tableName is invalid', async () => { expect.assertions(3); diff --git a/app/database/operator/server_data_operator/handlers/index.ts b/app/database/operator/server_data_operator/handlers/index.ts index 15ce6f282..8da5fbd39 100644 --- a/app/database/operator/server_data_operator/handlers/index.ts +++ b/app/database/operator/server_data_operator/handlers/index.ts @@ -3,9 +3,11 @@ import {MM_TABLES} from '@constants/database'; import BaseDataOperator from '@database/operator/base_data_operator'; +import {shouldUpdateFileRecord} from '@database/operator/server_data_operator/comparators/files'; import { transformConfigRecord, transformCustomEmojiRecord, + transformFileRecord, transformRoleRecord, transformSystemRecord, } from '@database/operator/server_data_operator/transformers/general'; @@ -16,13 +18,14 @@ import {sanitizeReactions} from '../../utils/reaction'; import {transformReactionRecord} from '../transformers/reaction'; import type {Model} from '@nozbe/watermelondb'; -import type {HandleConfigArgs, HandleCustomEmojiArgs, HandleReactionsArgs, HandleRoleArgs, HandleSystemArgs, OperationArgs} from '@typings/database/database'; +import type {HandleConfigArgs, HandleCustomEmojiArgs, HandleFilesArgs, HandleReactionsArgs, HandleRoleArgs, HandleSystemArgs, OperationArgs} from '@typings/database/database'; import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; +import type FileModel from '@typings/database/models/servers/file'; import type ReactionModel from '@typings/database/models/servers/reaction'; import type RoleModel from '@typings/database/models/servers/role'; import type SystemModel from '@typings/database/models/servers/system'; -const {SERVER: {CONFIG, CUSTOM_EMOJI, ROLE, SYSTEM, REACTION}} = MM_TABLES; +const {SERVER: {CONFIG, CUSTOM_EMOJI, FILE, ROLE, SYSTEM, REACTION}} = MM_TABLES; export default class ServerDataOperatorBase extends BaseDataOperator { handleRole = async ({roles, prepareRecordsOnly = true}: HandleRoleArgs) => { @@ -151,6 +154,58 @@ export default class ServerDataOperatorBase extends BaseDataOperator { return batchRecords; }; + /** + * handleFiles: Handler responsible for the Create/Update operations occurring on the File table from the 'Server' schema + * @param {HandleFilesArgs} handleFiles + * @param {RawFile[]} handleFiles.files + * @param {boolean} handleFiles.prepareRecordsOnly + * @returns {Promise} + */ + handleFiles = async ({files, prepareRecordsOnly}: HandleFilesArgs): Promise => { + if (!files?.length) { + logWarning( + 'An empty or undefined "files" array has been passed to the handleFiles method', + ); + return []; + } + + const raws = files.reduce<{createOrUpdateFiles: FileInfo[]; deleteFiles: FileInfo[]}>((res, f) => { + if (f.delete_at) { + res.deleteFiles.push(f); + } else { + res.createOrUpdateFiles.push(f); + } + + return res; + }, {createOrUpdateFiles: [], deleteFiles: []}); + + const processedFiles = (await this.processRecords({ + createOrUpdateRawValues: raws.createOrUpdateFiles, + tableName: FILE, + fieldName: 'id', + deleteRawValues: raws.deleteFiles, + shouldUpdate: shouldUpdateFileRecord, + })); + + const preparedFiles = await this.prepareRecords({ + createRaws: processedFiles.createRaws, + updateRaws: processedFiles.updateRaws, + deleteRaws: processedFiles.deleteRaws, + transformer: transformFileRecord, + tableName: FILE, + }); + + if (prepareRecordsOnly) { + return preparedFiles; + } + + if (preparedFiles?.length) { + await this.batchRecords(preparedFiles, 'handleFiles'); + } + + return preparedFiles; + }; + /** * execute: Handles the Create/Update operations on an table. * @param {OperationArgs} execute diff --git a/app/database/operator/server_data_operator/handlers/post.ts b/app/database/operator/server_data_operator/handlers/post.ts index 2b0d92e3e..56c28571a 100644 --- a/app/database/operator/server_data_operator/handlers/post.ts +++ b/app/database/operator/server_data_operator/handlers/post.ts @@ -6,16 +6,15 @@ import {Q} from '@nozbe/watermelondb'; import {ActionType} from '@constants'; import {MM_TABLES} from '@constants/database'; import {buildDraftKey} from '@database/operator/server_data_operator/comparators'; -import {shouldUpdateFileRecord} from '@database/operator/server_data_operator/comparators/files'; import { transformDraftRecord, - transformFileRecord, transformPostInThreadRecord, transformPostRecord, transformPostsInChannelRecord, } from '@database/operator/server_data_operator/transformers/post'; import {getRawRecordPairs, getUniqueRawsBy, getValidRecordsForUpdate} from '@database/operator/utils/general'; import {createPostsChain, getPostListEdges} from '@database/operator/utils/post'; +import FileModel from '@typings/database/models/servers/file'; import {logWarning} from '@utils/log'; import type ServerDataOperatorBase from '.'; @@ -23,7 +22,6 @@ import type Database from '@nozbe/watermelondb/Database'; import type Model from '@nozbe/watermelondb/Model'; import type {HandleDraftArgs, HandleFilesArgs, HandlePostsArgs, RecordPair} from '@typings/database/database'; import type DraftModel from '@typings/database/models/servers/draft'; -import type FileModel from '@typings/database/models/servers/file'; import type PostModel from '@typings/database/models/servers/post'; import type PostsInChannelModel from '@typings/database/models/servers/posts_in_channel'; import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread'; @@ -31,7 +29,6 @@ import type ReactionModel from '@typings/database/models/servers/reaction'; const { DRAFT, - FILE, POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD, @@ -291,47 +288,6 @@ const PostHandler = >(supercla return batch; }; - /** - * handleFiles: Handler responsible for the Create/Update operations occurring on the File table from the 'Server' schema - * @param {HandleFilesArgs} handleFiles - * @param {RawFile[]} handleFiles.files - * @param {boolean} handleFiles.prepareRecordsOnly - * @returns {Promise} - */ - handleFiles = async ({files, prepareRecordsOnly}: HandleFilesArgs): Promise => { - if (!files?.length) { - logWarning( - 'An empty or undefined "files" array has been passed to the handleFiles method', - ); - return []; - } - - const processedFiles = (await this.processRecords({ - createOrUpdateRawValues: files, - tableName: FILE, - fieldName: 'id', - deleteRawValues: [], - shouldUpdate: shouldUpdateFileRecord, - })); - - const postFiles = await this.prepareRecords({ - createRaws: processedFiles.createRaws, - updateRaws: processedFiles.updateRaws, - transformer: transformFileRecord, - tableName: FILE, - }); - - if (prepareRecordsOnly) { - return postFiles; - } - - if (postFiles?.length) { - await this.batchRecords(postFiles, 'handleFiles'); - } - - return postFiles; - }; - /** * handlePostsInThread: Handler responsible for the Create/Update operations occurring on the PostsInThread table from the 'Server' schema * @param {Record} postsMap diff --git a/app/database/operator/server_data_operator/transformers/channel.ts b/app/database/operator/server_data_operator/transformers/channel.ts index 4c124c691..671d0f42c 100644 --- a/app/database/operator/server_data_operator/transformers/channel.ts +++ b/app/database/operator/server_data_operator/transformers/channel.ts @@ -7,6 +7,7 @@ import {extractChannelDisplayName} from '@helpers/database'; import type {TransformerArgs} from '@typings/database/database'; import type ChannelModel from '@typings/database/models/servers/channel'; +import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; import type ChannelInfoModel from '@typings/database/models/servers/channel_info'; import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership'; import type MyChannelModel from '@typings/database/models/servers/my_channel'; @@ -14,6 +15,7 @@ import type MyChannelSettingsModel from '@typings/database/models/servers/my_cha const { CHANNEL, + CHANNEL_BOOKMARK, CHANNEL_INFO, CHANNEL_MEMBERSHIP, MY_CHANNEL, @@ -178,3 +180,44 @@ export const transformChannelMembershipRecord = ({action, database, value}: Tran fieldsMapper, }) as Promise; }; + +/** + * transformChannelBookmarkRecord: Prepares a record of the SERVER database 'Channel' table for update or create actions. + * @param {DataFactory} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const transformChannelBookmarkRecord = ({action, database, value}: TransformerArgs): Promise => { + const raw = value.raw as ChannelBookmark; + const record = value.record as ChannelBookmarkModel; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + const fieldsMapper = (bookmark: ChannelBookmarkModel) => { + bookmark._raw.id = isCreateAction ? (raw?.id ?? bookmark.id) : record.id; + bookmark.createAt = raw.create_at; + bookmark.deleteAt = raw.delete_at; + bookmark.updateAt = raw.update_at; + bookmark.channelId = raw.channel_id; + bookmark.ownerId = raw.owner_id; + bookmark.fileId = raw.file_id; + + bookmark.displayName = raw.display_name; + bookmark.sortOrder = raw.sort_order; + bookmark.linkUrl = raw.link_url; + bookmark.imageUrl = raw.image_url; + bookmark.emoji = raw.emoji; + bookmark.type = raw.type; + bookmark.originalId = raw.original_id; + bookmark.parentId = raw.parent_id; + }; + + return prepareBaseRecord({ + action, + database, + tableName: CHANNEL_BOOKMARK, + value, + fieldsMapper, + }) as Promise; +}; diff --git a/app/database/operator/server_data_operator/transformers/general.test.ts b/app/database/operator/server_data_operator/transformers/general.test.ts index 12e25c6e1..6121db4f4 100644 --- a/app/database/operator/server_data_operator/transformers/general.test.ts +++ b/app/database/operator/server_data_operator/transformers/general.test.ts @@ -4,6 +4,7 @@ import {OperationType} from '@constants/database'; import { transformCustomEmojiRecord, + transformFileRecord, transformRoleRecord, transformSystemRecord, } from '@database/operator/server_data_operator/transformers/general'; @@ -83,3 +84,37 @@ describe('*** CustomEmoj Prepare Records Test ***', () => { }); }); +describe('*** Files Prepare Records Test ***', () => { + it('=> transformFileRecord: should return an array of type File', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'post_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await transformFileRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'file-id', + post_id: 'ps81iqbddesfby8jayz7owg4yypoo', + name: 'test_file', + extension: '.jpg', + has_preview_image: true, + mime_type: 'image/jpeg', + size: 1000, + create_at: 1609253011321, + delete_at: 1609253011321, + height: 20, + width: 20, + update_at: 1609253011321, + user_id: 'wqyby5r5pinxxdqhoaomtacdhc', + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.table).toBe('File'); + }); +}); diff --git a/app/database/operator/server_data_operator/transformers/general.ts b/app/database/operator/server_data_operator/transformers/general.ts index d15778076..8264739b7 100644 --- a/app/database/operator/server_data_operator/transformers/general.ts +++ b/app/database/operator/server_data_operator/transformers/general.ts @@ -7,11 +7,13 @@ import {prepareBaseRecord} from '@database/operator/server_data_operator/transfo import type {TransformerArgs} from '@typings/database/database'; import type ConfigModel from '@typings/database/models/servers/config'; import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; +import type FileModel from '@typings/database/models/servers/file'; import type RoleModel from '@typings/database/models/servers/role'; import type SystemModel from '@typings/database/models/servers/system'; const { CUSTOM_EMOJI, + FILE, ROLE, SYSTEM, CONFIG, @@ -121,3 +123,38 @@ export const transformConfigRecord = ({action, database, value}: TransformerArgs fieldsMapper, }) as Promise; }; + +/** + * transformFileRecord: Prepares a record of the SERVER database 'Files' table for update or create actions. + * @param {TransformerArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const transformFileRecord = ({action, database, value}: TransformerArgs): Promise => { + const raw = value.raw as FileInfo; + const record = value.record as FileModel; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + const fieldsMapper = (file: FileModel) => { + file._raw.id = isCreateAction ? (raw.id || file.id) : record.id; + file.postId = raw.post_id || ''; + file.name = raw.name; + file.extension = raw.extension; + file.size = raw.size; + file.mimeType = raw?.mime_type ?? ''; + file.width = raw?.width || record?.width || 0; + file.height = raw?.height || record?.height || 0; + file.imageThumbnail = raw?.mini_preview || record?.imageThumbnail || ''; + file.localPath = raw?.localPath || record?.localPath || null; + }; + + return prepareBaseRecord({ + action, + database, + tableName: FILE, + value, + fieldsMapper, + }) as Promise; +}; diff --git a/app/database/operator/server_data_operator/transformers/post.test.ts b/app/database/operator/server_data_operator/transformers/post.test.ts index 53bea8c62..6f018d2fb 100644 --- a/app/database/operator/server_data_operator/transformers/post.test.ts +++ b/app/database/operator/server_data_operator/transformers/post.test.ts @@ -4,7 +4,6 @@ import {OperationType} from '@constants/database'; import { transformDraftRecord, - transformFileRecord, transformPostInThreadRecord, transformPostRecord, transformPostsInChannelRecord, @@ -75,39 +74,6 @@ describe('*** POST Prepare Records Test ***', () => { expect(preparedRecords!.collection.table).toBe('PostsInThread'); }); - it('=> transformFileRecord: should return an array of type File', async () => { - expect.assertions(3); - - const database = await createTestConnection({databaseName: 'post_prepare_records', setActive: true}); - expect(database).toBeTruthy(); - - const preparedRecords = await transformFileRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - id: 'file-id', - post_id: 'ps81iqbddesfby8jayz7owg4yypoo', - name: 'test_file', - extension: '.jpg', - has_preview_image: true, - mime_type: 'image/jpeg', - size: 1000, - create_at: 1609253011321, - delete_at: 1609253011321, - height: 20, - width: 20, - update_at: 1609253011321, - user_id: 'wqyby5r5pinxxdqhoaomtacdhc', - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.table).toBe('File'); - }); - it('=> transformDraftRecord: should return an array of type Draft', async () => { expect.assertions(3); diff --git a/app/database/operator/server_data_operator/transformers/post.ts b/app/database/operator/server_data_operator/transformers/post.ts index 0b42b8b6a..7bd63609d 100644 --- a/app/database/operator/server_data_operator/transformers/post.ts +++ b/app/database/operator/server_data_operator/transformers/post.ts @@ -6,14 +6,12 @@ import {prepareBaseRecord} from '@database/operator/server_data_operator/transfo import type{TransformerArgs} from '@typings/database/database'; import type DraftModel from '@typings/database/models/servers/draft'; -import type FileModel from '@typings/database/models/servers/file'; import type PostModel from '@typings/database/models/servers/post'; import type PostsInChannelModel from '@typings/database/models/servers/posts_in_channel'; import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread'; const { DRAFT, - FILE, POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD, @@ -94,41 +92,6 @@ export const transformPostInThreadRecord = ({action, database, value}: Transform }) as Promise; }; -/** - * transformFileRecord: Prepares a record of the SERVER database 'Files' table for update or create actions. - * @param {TransformerArgs} operator - * @param {Database} operator.database - * @param {RecordPair} operator.value - * @returns {Promise} - */ -export const transformFileRecord = ({action, database, value}: TransformerArgs): Promise => { - const raw = value.raw as FileInfo; - const record = value.record as FileModel; - const isCreateAction = action === OperationType.CREATE; - - // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database - const fieldsMapper = (file: FileModel) => { - file._raw.id = isCreateAction ? (raw.id || file.id) : record.id; - file.postId = raw.post_id; - file.name = raw.name; - file.extension = raw.extension; - file.size = raw.size; - file.mimeType = raw?.mime_type ?? ''; - file.width = raw?.width || record?.width || 0; - file.height = raw?.height || record?.height || 0; - file.imageThumbnail = raw?.mini_preview || record?.imageThumbnail || ''; - file.localPath = raw?.localPath || record?.localPath || null; - }; - - return prepareBaseRecord({ - action, - database, - tableName: FILE, - value, - fieldsMapper, - }) as Promise; -}; - /** * transformDraftRecord: Prepares a record of the SERVER database 'Draft' table for update or create actions. * @param {TransformerArgs} operator diff --git a/app/database/schema/server/index.ts b/app/database/schema/server/index.ts index 8220f092e..7f0203c16 100644 --- a/app/database/schema/server/index.ts +++ b/app/database/schema/server/index.ts @@ -6,9 +6,10 @@ import {type AppSchema, appSchema} from '@nozbe/watermelondb'; import { CategorySchema, CategoryChannelSchema, + ChannelSchema, + ChannelBookmarkSchema, ChannelInfoSchema, ChannelMembershipSchema, - ChannelSchema, ConfigSchema, CustomEmojiSchema, DraftSchema, @@ -39,13 +40,14 @@ import { } from './table_schemas'; export const serverSchema: AppSchema = appSchema({ - version: 3, + version: 4, tables: [ CategorySchema, CategoryChannelSchema, + ChannelSchema, + ChannelBookmarkSchema, ChannelInfoSchema, ChannelMembershipSchema, - ChannelSchema, ConfigSchema, CustomEmojiSchema, DraftSchema, diff --git a/app/database/schema/server/table_schemas/channel_bookmark.ts b/app/database/schema/server/table_schemas/channel_bookmark.ts new file mode 100644 index 000000000..c7d62188d --- /dev/null +++ b/app/database/schema/server/table_schemas/channel_bookmark.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {tableSchema} from '@nozbe/watermelondb'; + +import {MM_TABLES} from '@constants/database'; + +const {CHANNEL_BOOKMARK} = MM_TABLES.SERVER; + +export default tableSchema({ + name: CHANNEL_BOOKMARK, + columns: [ + {name: 'create_at', type: 'number'}, + {name: 'update_at', type: 'number'}, + {name: 'delete_at', type: 'number'}, + {name: 'channel_id', type: 'string', isIndexed: true}, + {name: 'owner_id', type: 'string'}, + {name: 'file_id', type: 'string', isOptional: true}, + {name: 'display_name', type: 'string'}, + {name: 'sort_order', type: 'number'}, + {name: 'link_url', type: 'string', isOptional: true}, + {name: 'image_url', type: 'string', isOptional: true}, + {name: 'emoji', type: 'string', isOptional: true}, + {name: 'type', type: 'string'}, + {name: 'original_id', type: 'string', isOptional: true}, + {name: 'parent_id', type: 'string', isOptional: true}, + ], +}); diff --git a/app/database/schema/server/table_schemas/index.ts b/app/database/schema/server/table_schemas/index.ts index 27676546e..ae6337034 100644 --- a/app/database/schema/server/table_schemas/index.ts +++ b/app/database/schema/server/table_schemas/index.ts @@ -3,9 +3,10 @@ export {default as CategorySchema} from './category'; export {default as CategoryChannelSchema} from './category_channel'; +export {default as ChannelSchema} from './channel'; +export {default as ChannelBookmarkSchema} from './channel_bookmark'; export {default as ChannelInfoSchema} from './channel_info'; export {default as ChannelMembershipSchema} from './channel_membership'; -export {default as ChannelSchema} from './channel'; export {default as CustomEmojiSchema} from './custom_emoji'; export {default as DraftSchema} from './draft'; export {default as FileSchema} from './file'; diff --git a/app/database/schema/server/test.ts b/app/database/schema/server/test.ts index e64685048..da7cc1fc6 100644 --- a/app/database/schema/server/test.ts +++ b/app/database/schema/server/test.ts @@ -11,6 +11,7 @@ const { CATEGORY, CATEGORY_CHANNEL, CHANNEL, + CHANNEL_BOOKMARK, CHANNEL_INFO, CHANNEL_MEMBERSHIP, CONFIG, @@ -45,7 +46,7 @@ const { describe('*** Test schema for SERVER database ***', () => { it('=> The SERVER SCHEMA should strictly match', () => { expect(serverSchema).toEqual({ - version: 3, + version: 4, unsafeSql: undefined, tables: { [CATEGORY]: { @@ -136,6 +137,43 @@ describe('*** Test schema for SERVER database ***', () => { {name: 'update_at', type: 'number'}, ], }, + [CHANNEL_BOOKMARK]: { + name: CHANNEL_BOOKMARK, + unsafeSql: undefined, + columns: { + create_at: {name: 'create_at', type: 'number'}, + update_at: {name: 'update_at', type: 'number'}, + delete_at: {name: 'delete_at', type: 'number'}, + channel_id: {name: 'channel_id', type: 'string', isIndexed: true}, + owner_id: {name: 'owner_id', type: 'string'}, + file_id: {name: 'file_id', type: 'string', isOptional: true}, + display_name: {name: 'display_name', type: 'string'}, + sort_order: {name: 'sort_order', type: 'number'}, + link_url: {name: 'link_url', type: 'string', isOptional: true}, + image_url: {name: 'image_url', type: 'string', isOptional: true}, + emoji: {name: 'emoji', type: 'string', isOptional: true}, + type: {name: 'type', type: 'string'}, + original_id: {name: 'original_id', type: 'string', isOptional: true}, + parent_id: {name: 'parent_id', type: 'string', isOptional: true}, + + }, + columnArray: [ + {name: 'create_at', type: 'number'}, + {name: 'update_at', type: 'number'}, + {name: 'delete_at', type: 'number'}, + {name: 'channel_id', type: 'string', isIndexed: true}, + {name: 'owner_id', type: 'string'}, + {name: 'file_id', type: 'string', isOptional: true}, + {name: 'display_name', type: 'string'}, + {name: 'sort_order', type: 'number'}, + {name: 'link_url', type: 'string', isOptional: true}, + {name: 'image_url', type: 'string', isOptional: true}, + {name: 'emoji', type: 'string', isOptional: true}, + {name: 'type', type: 'string'}, + {name: 'original_id', type: 'string', isOptional: true}, + {name: 'parent_id', type: 'string', isOptional: true}, + ], + }, [CHANNEL_MEMBERSHIP]: { name: CHANNEL_MEMBERSHIP, unsafeSql: undefined, diff --git a/app/hooks/files.ts b/app/hooks/files.ts index 6daa81947..49da59e51 100644 --- a/app/hooks/files.ts +++ b/app/hooks/files.ts @@ -1,11 +1,53 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {useMemo} from 'react'; +import {useEffect, useMemo, useState} from 'react'; +import {getLocalFileInfo} from '@actions/local/file'; import {buildFilePreviewUrl, buildFileUrl} from '@actions/remote/file'; import {useServerUrl} from '@context/server'; import {isGif, isImage, isVideo} from '@utils/file'; +import {getImageSize} from '@utils/gallery'; + +import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; + +const getFileInfo = async (serverUrl: string, bookmarks: ChannelBookmarkModel[], publicLinkEnabled: boolean, cb: (files: FileInfo[]) => void) => { + const fileInfos: FileInfo[] = []; + for await (const b of bookmarks) { + if (b.fileId) { + const res = await getLocalFileInfo(serverUrl, b.fileId); + if (res.file) { + const fileInfo = res.file.toFileInfo(b.ownerId); + const imageFile = isImage(fileInfo); + const videoFile = isVideo(fileInfo); + + let uri; + if (imageFile || (videoFile && publicLinkEnabled)) { + if (fileInfo.localPath) { + uri = fileInfo.localPath; + } else { + uri = (isGif(fileInfo) || (imageFile && !fileInfo.has_preview_image) || videoFile) ? buildFileUrl(serverUrl, fileInfo.id!) : buildFilePreviewUrl(serverUrl, fileInfo.id!); + } + } else { + uri = fileInfo.localPath || buildFileUrl(serverUrl, fileInfo.id!); + } + + let {width, height} = fileInfo; + if (imageFile && !width) { + const size = await getImageSize(uri); + width = size.width; + height = size.height; + } + + fileInfos.push({...fileInfo, uri, width, height}); + } + } + } + + if (fileInfos.length) { + cb(fileInfos); + } +}; export const useImageAttachments = (filesInfo: FileInfo[], publicLinkEnabled: boolean) => { const serverUrl = useServerUrl(); @@ -35,3 +77,13 @@ export const useImageAttachments = (filesInfo: FileInfo[], publicLinkEnabled: bo }, [filesInfo, publicLinkEnabled]); }; +export const useChannelBookmarkFiles = (bookmarks: ChannelBookmarkModel[], publicLinkEnabled: boolean) => { + const serverUrl = useServerUrl(); + const [files, setFiles] = useState([]); + + useEffect(() => { + getFileInfo(serverUrl, bookmarks, publicLinkEnabled, setFiles); + }, [serverUrl, bookmarks, publicLinkEnabled]); + + return files; +}; diff --git a/app/managers/websocket_manager.ts b/app/managers/websocket_manager.ts index b40f94db2..1a3538525 100644 --- a/app/managers/websocket_manager.ts +++ b/app/managers/websocket_manager.ts @@ -9,7 +9,8 @@ import {distinctUntilChanged} from 'rxjs/operators'; import {setCurrentUserStatus} from '@actions/local/user'; import {fetchStatusByIds} from '@actions/remote/user'; -import {handleEvent, handleFirstConnect, handleReconnect} from '@actions/websocket'; +import {handleFirstConnect, handleReconnect} from '@actions/websocket'; +import {handleWebSocketEvent} from '@actions/websocket/event'; import WebSocketClient from '@client/websocket'; import {General} from '@constants'; import DatabaseManager from '@database/manager'; @@ -84,7 +85,7 @@ class WebsocketManager { const client = new WebSocketClient(serverUrl, bearerToken); client.setFirstConnectCallback(() => this.onFirstConnect(serverUrl)); - client.setEventCallback((evt: any) => handleEvent(serverUrl, evt)); + client.setEventCallback((evt: WebSocketMessage) => handleWebSocketEvent(serverUrl, evt)); //client.setMissedEventsCallback(() => {}) Nothing to do on missedEvents callback client.setReconnectCallback(() => this.onReconnect(serverUrl)); diff --git a/app/products/calls/components/floating_call_container.tsx b/app/products/calls/components/floating_call_container.tsx index d5c264efa..3ab1741f5 100644 --- a/app/products/calls/components/floating_call_container.tsx +++ b/app/products/calls/components/floating_call_container.tsx @@ -8,7 +8,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'; import CurrentCallBar from '@calls/components/current_call_bar'; import {IncomingCallsContainer} from '@calls/components/incoming_calls_container'; import JoinCallBanner from '@calls/components/join_call_banner'; -import {DEFAULT_HEADER_HEIGHT, TABLET_HEADER_HEIGHT} from '@constants/view'; +import {BOOKMARKS_BAR_HEIGHT, DEFAULT_HEADER_HEIGHT, TABLET_HEADER_HEIGHT} from '@constants/view'; import {useServerUrl} from '@context/server'; import {useIsTablet} from '@hooks/device'; @@ -28,6 +28,7 @@ type Props = { isInACall?: boolean; threadScreen?: boolean; channelsScreen?: boolean; + includeBookmarkBar?: boolean; } const FloatingCallContainer = ({ @@ -37,6 +38,7 @@ const FloatingCallContainer = ({ isInACall, threadScreen, channelsScreen, + includeBookmarkBar, }: Props) => { const serverUrl = useServerUrl(); const insets = useSafeAreaInsets(); @@ -45,7 +47,7 @@ const FloatingCallContainer = ({ const topBarForTablet = (isTablet && !threadScreen) ? TABLET_HEADER_HEIGHT : 0; const topBarChannel = (!isTablet && !threadScreen) ? DEFAULT_HEADER_HEIGHT : 0; const wrapperTop = { - top: insets.top + topBarForTablet + topBarChannel, + top: insets.top + topBarForTablet + topBarChannel + (includeBookmarkBar ? BOOKMARKS_BAR_HEIGHT : 0), }; const wrapperBottom = { bottom: 8, diff --git a/app/products/calls/hooks.ts b/app/products/calls/hooks.ts index bab65f87f..5bf8ae65a 100644 --- a/app/products/calls/hooks.ts +++ b/app/products/calls/hooks.ts @@ -115,14 +115,16 @@ const micPermission = Platform.select({ export const usePermissionsChecker = (micPermissionsGranted: boolean) => { const appState = useAppState(); + const [hasPermission, setHasPermission] = useState(micPermissionsGranted); useEffect(() => { const asyncFn = async () => { if (appState === 'active') { - const hasPermission = (await Permissions.check(micPermission)) === Permissions.RESULTS.GRANTED; - if (hasPermission) { + const result = (await Permissions.check(micPermission)) === Permissions.RESULTS.GRANTED; + setHasPermission(result); + if (result) { initializeVoiceTrack(); - setMicPermissionsGranted(hasPermission); + setMicPermissionsGranted(result); } } }; @@ -130,6 +132,8 @@ export const usePermissionsChecker = (micPermissionsGranted: boolean) => { asyncFn(); } }, [appState]); + + return hasPermission; }; export const useCallsAdjustment = (serverUrl: string, channelId: string): number => { @@ -139,6 +143,7 @@ export const useCallsAdjustment = (serverUrl: string, channelId: string): number const globalCallsState = useGlobalCallsState(); const currentCall = useCurrentCall(); const [numServers, setNumServers] = useState(1); + const micPermissionsGranted = usePermissionsChecker(globalCallsState.micPermissionsGranted); const dismissed = Boolean(callsState.calls[channelId]?.dismissed[callsState.myUserId]); const inCurrentCall = currentCall?.id === channelId; const joinCallBannerVisible = Boolean(channelsWithCalls[channelId]) && !dismissed && !inCurrentCall; @@ -153,7 +158,7 @@ export const useCallsAdjustment = (serverUrl: string, channelId: string): number // Do we have calls banners? const currentCallBarVisible = Boolean(currentCall); - const micPermissionsError = !globalCallsState.micPermissionsGranted && (currentCall && !currentCall.micPermissionsErrorDismissed); + const micPermissionsError = !micPermissionsGranted && (currentCall && !currentCall.micPermissionsErrorDismissed); const callQualityAlert = Boolean(currentCall?.callQualityAlert); const incomingCallsShowing = incomingCalls.filter((ic) => ic.channelID !== channelId); const notificationBarHeight = CALL_NOTIFICATION_BAR_HEIGHT + (numServers > 1 ? 8 : 0); diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index 71c9b2225..fd846912e 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -20,6 +20,7 @@ import {observeTeammateNameDisplay} from './user'; import type ServerDataOperator from '@database/operator/server_data_operator'; import type {Clause} from '@nozbe/watermelondb/QueryDescription'; import type ChannelModel from '@typings/database/models/servers/channel'; +import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; import type ChannelInfoModel from '@typings/database/models/servers/channel_info'; import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership'; import type MyChannelModel from '@typings/database/models/servers/my_channel'; @@ -28,6 +29,29 @@ import type UserModel from '@typings/database/models/servers/user'; const {SERVER: {CHANNEL, MY_CHANNEL, CHANNEL_MEMBERSHIP, MY_CHANNEL_SETTINGS, CHANNEL_INFO, USER, TEAM}} = MM_TABLES; +type ChannelMembershipsExtended = Pick; + +function prepareChannels( + operator: ServerDataOperator, + channels?: Channel[], + channelInfos?: ChannelInfo[], + channelMemberships?: ChannelMembershipsExtended[], + memberships?: ChannelMembership[], + isCRTEnabled?: boolean, +): Array> { + try { + const channelRecords = operator.handleChannel({channels, prepareRecordsOnly: true}); + const channelInfoRecords = operator.handleChannelInfo({channelInfos, prepareRecordsOnly: true}); + const membershipRecords = operator.handleChannelMembership({channelMemberships, prepareRecordsOnly: true}); + const myChannelRecords = operator.handleMyChannel({channels, myChannels: memberships, prepareRecordsOnly: true, isCRTEnabled}); + const myChannelSettingsRecords = operator.handleMyChannelSettings({settings: memberships, prepareRecordsOnly: true}); + + return [channelRecords, channelInfoRecords, membershipRecords, myChannelRecords, myChannelSettingsRecords]; + } catch { + return []; + } +} + export function prepareMissingChannelsForAllTeams(operator: ServerDataOperator, channels: Channel[], channelMembers: ChannelMembership[], isCRTEnabled?: boolean): Array> { const channelInfos: ChannelInfo[] = []; const channelMap: Record = {}; @@ -54,17 +78,7 @@ export function prepareMissingChannelsForAllTeams(operator: ServerDataOperator, }; }); - try { - const channelRecords = operator.handleChannel({channels, prepareRecordsOnly: true}); - const channelInfoRecords = operator.handleChannelInfo({channelInfos, prepareRecordsOnly: true}); - const membershipRecords = operator.handleChannelMembership({channelMemberships: memberships, prepareRecordsOnly: true}); - const myChannelRecords = operator.handleMyChannel({channels, myChannels: memberships, prepareRecordsOnly: true, isCRTEnabled}); - const myChannelSettingsRecords = operator.handleMyChannelSettings({settings: memberships, prepareRecordsOnly: true}); - - return [channelRecords, channelInfoRecords, membershipRecords, myChannelRecords, myChannelSettingsRecords]; - } catch { - return []; - } + return prepareChannels(operator, channels, channelInfos, memberships, memberships, isCRTEnabled); } export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, teamId: string, channels: Channel[], channelMembers: ChannelMembership[], isCRTEnabled?: boolean) => { @@ -117,17 +131,7 @@ export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, tea }); } - try { - const channelRecords = operator.handleChannel({channels, prepareRecordsOnly: true}); - const channelInfoRecords = operator.handleChannelInfo({channelInfos, prepareRecordsOnly: true}); - const membershipRecords = operator.handleChannelMembership({channelMemberships: channelMembers, prepareRecordsOnly: true}); - const myChannelRecords = operator.handleMyChannel({channels, myChannels: memberships, prepareRecordsOnly: true, isCRTEnabled}); - const myChannelSettingsRecords = operator.handleMyChannelSettings({settings: memberships, prepareRecordsOnly: true}); - - return [channelRecords, channelInfoRecords, membershipRecords, myChannelRecords, myChannelSettingsRecords]; - } catch { - return []; - } + return prepareChannels(operator, channels, channelInfos, channelMembers, memberships, isCRTEnabled); }; export const prepareDeleteChannel = async (channel: ChannelModel): Promise => { @@ -169,6 +173,27 @@ export const prepareDeleteChannel = async (channel: ChannelModel): Promise { + const preparedModels: Model[] = [bookmark.prepareDestroyPermanently()]; + try { + if (bookmark.fileId) { + const file = await bookmark.file.fetch(); + preparedModels.push(file.prepareDestroyPermanently()); + } + } catch { + // Record not found, do nothing + } return preparedModels; }; diff --git a/app/queries/servers/channel_bookmark.ts b/app/queries/servers/channel_bookmark.ts new file mode 100644 index 000000000..9baeeecde --- /dev/null +++ b/app/queries/servers/channel_bookmark.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q, type Database} from '@nozbe/watermelondb'; +import {of as of$} from 'rxjs'; +import {distinctUntilChanged, switchMap, combineLatestWith} from 'rxjs/operators'; + +import {General, Permissions} from '@constants'; +import {MM_TABLES} from '@constants/database'; +import ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; +import {isDMorGM} from '@utils/channel'; +import {isMinimumServerVersion} from '@utils/helpers'; + +import {observeChannel} from './channel'; +import {observePermissionForChannel} from './role'; +import {observeConfigValue} from './system'; +import {observeCurrentUser} from './user'; + +const {CHANNEL_BOOKMARK} = MM_TABLES.SERVER; + +const observeHasPermissionToBookmarks = ( + database: Database, + channelId: string, + public_permission: string, + private_permission: string, +) => { + const serverVersion = observeConfigValue(database, 'Version'); + const currentUser = observeCurrentUser(database); + + return observeChannel(database, channelId).pipe( + combineLatestWith(currentUser, serverVersion), + switchMap(([c, user, version]) => { + if (!c || !user || c.deleteAt !== 0 || user?.isGuest || !isMinimumServerVersion(version || '', 9, 4)) { + return of$(false); + } + + if (isDMorGM(c)) { + return of$(true); + } + + const permission = c.type === General.OPEN_CHANNEL ? public_permission : private_permission; + return observePermissionForChannel(database, c, user, permission, true); + }), + distinctUntilChanged(), + ); +}; + +export const observeCanAddBookmarks = (database: Database, channelId: string) => { + return observeHasPermissionToBookmarks( + database, + channelId, + Permissions.ADD_BOOKMARK_PUBLIC_CHANNEL, + Permissions.ADD_BOOKMARK_PRIVATE_CHANNEL, + ); +}; + +export const observeCanEditBookmarks = (database: Database, channelId: string) => { + return observeHasPermissionToBookmarks( + database, + channelId, + Permissions.EDIT_BOOKMARK_PUBLIC_CHANNEL, + Permissions.EDIT_BOOKMARK_PRIVATE_CHANNEL, + ); +}; + +export const observeCanDeleteBookmarks = (database: Database, channelId: string) => { + return observeHasPermissionToBookmarks( + database, + channelId, + Permissions.DELETE_BOOKMARK_PUBLIC_CHANNEL, + Permissions.DELETE_BOOKMARK_PRIVATE_CHANNEL, + ); +}; + +export const getChannelBookmarkById = async (database: Database, bookmarkId: string) => { + try { + const bookmark = await database.get(CHANNEL_BOOKMARK).find(bookmarkId); + return bookmark; + } catch { + return undefined; + } +}; + +export const queryBookmarks = (database: Database, channelId: string) => { + return database.get(CHANNEL_BOOKMARK).query( + Q.and( + Q.where('channel_id', channelId), + Q.where('delete_at', Q.eq(0)), + ), + Q.sortBy('sort_order', Q.asc), + ); +}; + +export const getBookmarksSince = async (database: Database, channelId: string) => { + try { + const result = await database.get(CHANNEL_BOOKMARK).query( + Q.unsafeSqlQuery( + `SELECT + COALESCE( + MAX ( + MAX(COALESCE(create_at, 0)), + MAX(COALESCE(update_at, 0)), + MAX(COALESCE(delete_at, 0)) + ) + 1, 0) as mostRecent + FROM ChannelBookmark + WHERE channel_id='${channelId}'`), + ).unsafeFetchRaw(); + + return result?.[0]?.mostRecent ?? 0; + } catch { + return 0; + } +}; + +export const observeBookmarks = (database: Database, channelId: string) => { + return queryBookmarks(database, channelId).observeWithColumns(['file_id']); +}; diff --git a/app/queries/servers/entry.ts b/app/queries/servers/entry.ts index ec5149b85..60ed5ffb0 100644 --- a/app/queries/servers/entry.ts +++ b/app/queries/servers/entry.ts @@ -33,6 +33,7 @@ type PrepareModelsArgs = { } const { + CHANNEL_BOOKMARK, POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD, @@ -100,6 +101,7 @@ export async function truncateCrtRelatedTables(serverUrl: string): Promise<{erro [`DELETE FROM ${THREAD_PARTICIPANT}`, []], [`DELETE FROM ${TEAM_THREADS_SYNC}`, []], [`DELETE FROM ${MY_CHANNEL}`, []], + [`DELETE FROM ${CHANNEL_BOOKMARK}`, []], ], }); }); diff --git a/app/queries/servers/file.ts b/app/queries/servers/file.ts index 7226058d2..501181954 100644 --- a/app/queries/servers/file.ts +++ b/app/queries/servers/file.ts @@ -2,6 +2,8 @@ // See LICENSE.txt for license information. import {Database, Q} from '@nozbe/watermelondb'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; import {MM_TABLES} from '@constants/database'; @@ -18,6 +20,12 @@ export const getFileById = async (database: Database, fileId: string) => { } }; +export const observeFileById = (database: Database, id: string) => { + return database.get(FILE).query(Q.where('id', id), Q.take(1)).observe().pipe( + switchMap((result) => (result.length ? result[0].observe() : of$(undefined))), + ); +}; + export const queryFilesForPost = (database: Database, postId: string) => { return database.get(FILE).query( Q.where('post_id', postId), diff --git a/app/screens/channel/channel.tsx b/app/screens/channel/channel.tsx index 2d0909c93..55e071864 100644 --- a/app/screens/channel/channel.tsx +++ b/app/screens/channel/channel.tsx @@ -40,6 +40,7 @@ type ChannelProps = { currentUserId: string; channelType: ChannelType; hasGMasDMFeature: boolean; + includeBookmarkBar?: boolean; }; const edges: Edge[] = ['left', 'right']; @@ -63,6 +64,7 @@ const Channel = ({ channelType, currentUserId, hasGMasDMFeature, + includeBookmarkBar, }: ChannelProps) => { useGMasDMNotice(currentUserId, channelType, dismissedGMasDMNotice, hasGMasDMFeature); const isTablet = useIsTablet(); @@ -124,6 +126,7 @@ const Channel = ({ componentId={componentId} callsEnabledInChannel={isCallsEnabledInChannel} isTabletView={isTabletView} + shouldRenderBookmarks={shouldRender} /> {shouldRender && <> @@ -145,12 +148,13 @@ const Channel = ({ /> } - {showFloatingCallContainer && + {showFloatingCallContainer && shouldRender && } diff --git a/app/screens/channel/header/bookmarks.tsx b/app/screens/channel/header/bookmarks.tsx new file mode 100644 index 000000000..21a6da487 --- /dev/null +++ b/app/screens/channel/header/bookmarks.tsx @@ -0,0 +1,74 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {View} from 'react-native'; + +import ChannelBookmarks from '@components/channel_bookmarks'; +import {useTheme} from '@context/theme'; +import {useDefaultHeaderHeight} from '@hooks/header'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + canAddBookmarks: boolean; + channelId: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + backgroundColor: theme.sidebarBg, + width: '100%', + position: 'absolute', + }, + content: { + backgroundColor: theme.centerChannelBg, + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + }, + separator: { + height: 1, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + }, + separatorContainer: { + backgroundColor: theme.centerChannelBg, + zIndex: 1, + }, + padding: { + paddingTop: 2, + }, + paddingHorizontal: { + paddingHorizontal: 10, + }, +})); + +const ChannelHeaderBookmarks = ({canAddBookmarks, channelId}: Props) => { + const theme = useTheme(); + const defaultHeight = useDefaultHeaderHeight(); + const styles = getStyleSheet(theme); + + const containerStyle = useMemo(() => ({ + ...styles.content, + top: defaultHeight, + zIndex: 1, + }), [defaultHeight]); + + return ( + + + + + + + + + + + ); +}; + +export default ChannelHeaderBookmarks; diff --git a/app/screens/channel/header/header.tsx b/app/screens/channel/header/header.tsx index 7d83a064a..27b584980 100644 --- a/app/screens/channel/header/header.tsx +++ b/app/screens/channel/header/header.tsx @@ -24,17 +24,21 @@ import {preventDoubleTap} from '@utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; +import ChannelHeaderBookmarks from './bookmarks'; import QuickActions, {MARGIN, SEPARATOR_HEIGHT} from './quick_actions'; import type {HeaderRightButton} from '@components/navigation_header/header'; import type {AvailableScreens} from '@typings/screens/navigation'; type ChannelProps = { + canAddBookmarks: boolean; channelId: string; channelType: ChannelType; customStatus?: UserCustomStatus; + isBookmarksEnabled: boolean; isCustomStatusEnabled: boolean; isCustomStatusExpired: boolean; + hasBookmarks: boolean; componentId?: AvailableScreens; displayName: string; isOwnDirectMessage: boolean; @@ -43,6 +47,7 @@ type ChannelProps = { teamId: string; callsEnabledInChannel: boolean; isTabletView?: boolean; + shouldRenderBookmarks: boolean; }; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ @@ -71,9 +76,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ })); const ChannelHeader = ({ - channelId, channelType, componentId, customStatus, displayName, - isCustomStatusEnabled, isCustomStatusExpired, isOwnDirectMessage, memberCount, - searchTerm, teamId, callsEnabledInChannel, isTabletView, + canAddBookmarks, channelId, channelType, componentId, customStatus, displayName, hasBookmarks, + isBookmarksEnabled, isCustomStatusEnabled, isCustomStatusExpired, isOwnDirectMessage, memberCount, + searchTerm, teamId, callsEnabledInChannel, isTabletView, shouldRenderBookmarks, }: ChannelProps) => { const intl = useIntl(); const isTablet = useIsTablet(); @@ -244,6 +249,12 @@ const ChannelHeader = ({ + {isBookmarksEnabled && hasBookmarks && shouldRenderBookmarks && + + } ); }; diff --git a/app/screens/channel/header/index.ts b/app/screens/channel/header/index.ts index e59210e96..64ee1767c 100644 --- a/app/screens/channel/header/index.ts +++ b/app/screens/channel/header/index.ts @@ -4,10 +4,11 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; import {of as of$} from 'rxjs'; -import {combineLatestWith, switchMap} from 'rxjs/operators'; +import {combineLatestWith, distinctUntilChanged, switchMap} from 'rxjs/operators'; import {General} from '@constants'; import {observeChannel, observeChannelInfo} from '@queries/servers/channel'; +import {observeCanAddBookmarks, queryBookmarks} from '@queries/servers/channel_bookmark'; import {observeConfigBooleanValue, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system'; import {observeUser} from '@queries/servers/user'; import { @@ -77,11 +78,21 @@ const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps const memberCount = channelInfo.pipe( combineLatestWith(dmUser), switchMap(([ci, dm]) => of$(dm ? undefined : ci?.memberCount))); + const hasBookmarks = queryBookmarks(database, channelId).observeCount(false).pipe( + switchMap((count) => of$(count > 0)), + distinctUntilChanged(), + ); + + const isBookmarksEnabled = observeConfigBooleanValue(database, 'FeatureFlagChannelBookmarks'); + const canAddBookmarks = observeCanAddBookmarks(database, channelId); return { + canAddBookmarks, channelType, customStatus, displayName, + hasBookmarks, + isBookmarksEnabled, isCustomStatusEnabled, isCustomStatusExpired, isOwnDirectMessage, diff --git a/app/screens/channel/index.tsx b/app/screens/channel/index.tsx index 094659b1b..049452fde 100644 --- a/app/screens/channel/index.tsx +++ b/app/screens/channel/index.tsx @@ -2,15 +2,16 @@ // See LICENSE.txt for license information. import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; -import {of as of$, switchMap} from 'rxjs'; +import {combineLatestWith, distinctUntilChanged, of as of$, switchMap} from 'rxjs'; import {observeCallStateInChannel, observeIsCallsEnabledInChannel} from '@calls/observers'; import {Preferences} from '@constants'; import {withServerUrl} from '@context/server'; import {observeCurrentChannel} from '@queries/servers/channel'; +import {queryBookmarks} from '@queries/servers/channel_bookmark'; import {observeHasGMasDMFeature} from '@queries/servers/features'; import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; -import {observeCurrentChannelId, observeCurrentUserId} from '@queries/servers/system'; +import {observeConfigBooleanValue, observeCurrentChannelId, observeCurrentUserId} from '@queries/servers/system'; import Channel from './channel'; @@ -26,6 +27,21 @@ const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => { const channelType = observeCurrentChannel(database).pipe(switchMap((c) => of$(c?.type))); const currentUserId = observeCurrentUserId(database); const hasGMasDMFeature = observeHasGMasDMFeature(database); + const isBookmarksEnabled = observeConfigBooleanValue(database, 'FeatureFlagChannelBookmarks'); + const hasBookmarks = (count: number) => of$(count > 0); + const includeBookmarkBar = channelId.pipe( + combineLatestWith(isBookmarksEnabled), + switchMap(([cId, enabled]) => { + if (!enabled) { + return of$(false); + } + + return queryBookmarks(database, cId).observeCount(false).pipe( + switchMap(hasBookmarks), + distinctUntilChanged(), + ); + }), + ); return { channelId, @@ -35,6 +51,7 @@ const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => { channelType, currentUserId, hasGMasDMFeature, + includeBookmarkBar, }; }); diff --git a/app/screens/channel_bookmark/components/bookmark_detail.tsx b/app/screens/channel_bookmark/components/bookmark_detail.tsx new file mode 100644 index 000000000..3f68a4c5e --- /dev/null +++ b/app/screens/channel_bookmark/components/bookmark_detail.tsx @@ -0,0 +1,140 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Button} from '@rneui/base'; +import React, {useCallback, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {TextInput, View} from 'react-native'; + +import BookmarkIcon from '@components/channel_bookmarks/channel_bookmark/bookmark_icon'; +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import {Screens} from '@constants'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import {openAsBottomSheet} from '@screens/navigation'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + disabled: boolean; + emoji?: string; + file?: ExtractedFileInfo; + imageUrl?: string; + setBookmarkDisplayName: (displayName: string) => void; + setBookmarkEmoji: (emoji?: string) => void; + title: string; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + title: { + color: theme.centerChannelColor, + marginBottom: 8, + ...typography('Heading', 100, 'SemiBold'), + }, + container: { + flexDirection: 'row', + }, + disabled: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.16), + }, + iconContainer: { + borderWidth: 1, + paddingLeft: 16, + paddingRight: 8, + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + borderRightWidth: 0, + borderTopLeftRadius: 4, + borderBottomLeftRadius: 4, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + }, + iconButton: { + backgroundColor: 'transparent', + alignItems: 'center', + justifyContent: 'center', + }, + imageContainer: {width: 28, height: 28, marginRight: 2}, + image: {width: 24, height: 24}, + input: { + borderBottomRightRadius: 4, + borderTopRightRadius: 4, + borderWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + paddingVertical: 12, + paddingHorizontal: 16, + flex: 1, + color: theme.centerChannelColor, + ...typography('Body', 200), + lineHeight: undefined, + }, + genericBookmark: { + alignSelf: 'center', + top: 2, + }, +})); + +const BookmarkDetail = ({disabled, emoji, file, imageUrl, setBookmarkDisplayName, setBookmarkEmoji, title}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const isTablet = useIsTablet(); + const paddingStyle = useMemo(() => ({paddingHorizontal: isTablet ? 42 : 0}), [isTablet]); + const styles = getStyleSheet(theme); + + const openEmojiPicker = useCallback(() => { + openAsBottomSheet({ + closeButtonId: 'close-add-emoji', + screen: Screens.EMOJI_PICKER, + theme, + title: intl.formatMessage({id: 'channel_bookmark.add.emoji', defaultMessage: 'Add emoji'}), + props: { + onEmojiPress: setBookmarkEmoji, + imageUrl, + file, + }, + }); + }, [imageUrl, file, theme, setBookmarkEmoji]); + + return ( + + + + + + + + ); +}; + +export default BookmarkDetail; diff --git a/app/screens/channel_bookmark/components/bookmark_file/bookmark_file.tsx b/app/screens/channel_bookmark/components/bookmark_file/bookmark_file.tsx new file mode 100644 index 000000000..e77550b09 --- /dev/null +++ b/app/screens/channel_bookmark/components/bookmark_file/bookmark_file.tsx @@ -0,0 +1,360 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Button} from '@rneui/base'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {View, Text, Platform, type Insets} from 'react-native'; +import {Shadow} from 'react-native-shadow-2'; + +import {uploadFile} from '@actions/remote/file'; +import CompassIcon from '@app/components/compass_icon'; +import FileIcon from '@app/components/files/file_icon'; +import ProgressBar from '@app/components/progress_bar'; +import FormattedText from '@components/formatted_text'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import {fileSizeWarning, getExtensionFromMime, getFormattedFileSize} from '@utils/file'; +import PickerUtil from '@utils/file/file_picker'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type {ClientResponse} from '@mattermost/react-native-network-client'; + +type Props = { + channelId: string; + close: () => void; + disabled: boolean; + initialFile?: FileInfo; + maxFileSize: number; + setBookmark: (file: ExtractedFileInfo) => void; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + viewContainer: { + marginTop: 32, + marginBottom: 24, + width: '100%', + flex: 0, + }, + title: { + color: theme.centerChannelColor, + marginBottom: 8, + ...typography('Heading', 100, 'SemiBold'), + }, + shadowContainer: { + alignItems: 'center', + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + borderWidth: 1, + borderRadius: 4, + }, + fileContainer: { + height: 64, + flexDirection: 'row', + paddingLeft: 12, + alignItems: 'center', + }, + fileInfoContainer: { + paddingHorizontal: 16, + flex: 1, + justifyContent: 'center', + }, + filename: { + color: theme.centerChannelColor, + ...typography('Body', 200, 'SemiBold'), + }, + fileInfo: { + color: changeOpacity(theme.centerChannelColor, 0.64), + textTransform: 'uppercase', + ...typography('Body', 75), + }, + uploadError: { + color: theme.errorTextColor, + ...typography('Body', 75), + }, + retry: { + paddingRight: 20, + height: 40, + justifyContent: 'center', + }, + removeContainer: { + position: 'absolute', + elevation: 11, + top: -18, + right: -12, + width: 24, + height: 24, + }, + removeButton: { + borderRadius: 12, + alignSelf: 'center', + marginTop: Platform.select({ + ios: 5.4, + android: 4.75, + }), + backgroundColor: theme.centerChannelBg, + width: 24, + height: 25, + }, + uploading: { + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 75), + }, + progressContainer: { + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.1)', + bottom: 2, + borderBottomRightRadius: 4, + borderBottomLeftRadius: 4, + }, + progress: { + borderRadius: 4, + borderTopRightRadius: 0, + borderTopLeftRadius: 0, + }, +})); + +const shadowSides = {top: false, bottom: true, end: true, start: false}; +const hitSlop: Insets = {top: 10, bottom: 10, left: 10, right: 10}; + +const BookmarkFile = ({channelId, close, disabled, initialFile, maxFileSize, setBookmark}: Props) => { + const theme = useTheme(); + const intl = useIntl(); + const isTablet = useIsTablet(); + const serverUrl = useServerUrl(); + const [file, setFile] = useState(initialFile); + const [error, setError] = useState(''); + const [progress, setProgress] = useState(0); + const [uploading, setUploading] = useState(false); + const [failed, setFailed] = useState(false); + const styles = getStyleSheet(theme); + const subContainerStyle = useMemo(() => [styles.viewContainer, {paddingHorizontal: isTablet ? 42 : 0}], [isTablet]); + const cancelUpload = useRef<() => void | undefined>(); + + const onProgress = useCallback((p: number, bytes: number) => { + if (!file) { + return; + } + + const f: ExtractedFileInfo = {...file}; + f.bytesRead = bytes; + + setProgress(p); + setFile(f); + }, []); + + const onComplete = useCallback((response: ClientResponse) => { + cancelUpload.current = undefined; + if (response.code !== 201 || !response.data) { + setUploadError(); + return; + } + + const data = response.data.file_infos as FileInfo[] | undefined; + if (!data?.length) { + setUploadError(); + return; + } + + const fileInfo = data[0]; + setFile(fileInfo); + setBookmark(fileInfo); + setUploading(false); + setProgress(1); + setFailed(false); + setError(''); + }, []); + + const onError = useCallback(() => { + cancelUpload.current = undefined; + setUploadError(); + }, []); + + const setUploadError = useCallback(() => { + setProgress(0); + setUploading(false); + setFailed(true); + + setError(intl.formatMessage({ + id: 'channel_bookmark.add.file_upload_error', + defaultMessage: 'Error uploading file. Please try again.', + })); + }, [file, intl]); + + const startUpload = useCallback((fileInfo: FileInfo | ExtractedFileInfo) => { + setUploading(true); + setProgress(0); + + const {cancel, error: uploadError} = uploadFile( + serverUrl, + fileInfo, + channelId, + onProgress, + onComplete, + onError, + fileInfo.bytesRead, + true, + ); + + if (cancel) { + cancelUpload.current = cancel; + } + + if (uploadError) { + setUploadError(); + cancelUpload.current?.(); + } + }, [channelId, onProgress, onComplete, onError, serverUrl]); + + const browseFile = useCallback(async () => { + const picker = new PickerUtil(intl, (files) => { + if (files.length) { + const f = files[0]; + const extension = getExtensionFromMime(f.mime_type) || ''; + const fileWithExtension: ExtractedFileInfo = {...f, extension}; + setFile(fileWithExtension); + startUpload(fileWithExtension); + } + }); + + const res = await picker.attachFileFromFiles(undefined, false); + if (res.error) { + close(); + } + }, [close, startUpload]); + + const removeAndUpload = useCallback(() => { + cancelUpload.current?.(); + browseFile(); + }, [file, browseFile]); + + const retry = useCallback(() => { + cancelUpload.current?.(); + if (file) { + startUpload(file); + } + }, [file, startUpload]); + + useEffect(() => { + if (!initialFile) { + browseFile(); + } + + return () => { + cancelUpload.current?.(); + }; + }, []); + + useEffect(() => { + if (uploading) { + return; + } + + if (!file?.id && (file?.size || 0) > maxFileSize) { + setError(fileSizeWarning(intl, maxFileSize)); + return; + } + + if (!file?.id && file?.name) { + setBookmark(file); + } + }, [file, intl, maxFileSize, uploading]); + + let info; + if (error) { + info = ( + + {error} + + ); + } else if (uploading) { + info = ( + + ); + } else if (file) { + info = ( + + {`${file.extension} ${getFormattedFileSize(file.size || 0)}`} + + ); + } + + if (file) { + return ( + + + + + + + + {decodeURIComponent(file.name.trim())} + + {info} + + {failed && + + } + + + + + + + + {uploading && + + + + } + + ); + } + + return null; +}; + +export default BookmarkFile; diff --git a/app/screens/channel_bookmark/components/bookmark_file/index.ts b/app/screens/channel_bookmark/components/bookmark_file/index.ts new file mode 100644 index 000000000..723083e70 --- /dev/null +++ b/app/screens/channel_bookmark/components/bookmark_file/index.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; + +import {DEFAULT_SERVER_MAX_FILE_SIZE} from '@constants/post_draft'; +import {observeConfigIntValue} from '@queries/servers/system'; + +import BookmarkFile from './bookmark_file'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + return { + maxFileSize: observeConfigIntValue(database, 'MaxFileSize', DEFAULT_SERVER_MAX_FILE_SIZE), + }; +}); + +export default withDatabase(enhanced(BookmarkFile)); diff --git a/app/screens/channel_bookmark/components/bookmark_link.tsx b/app/screens/channel_bookmark/components/bookmark_link.tsx new file mode 100644 index 000000000..a4d59ca47 --- /dev/null +++ b/app/screens/channel_bookmark/components/bookmark_link.tsx @@ -0,0 +1,133 @@ +// 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 {Platform, View} from 'react-native'; + +import {useIsTablet} from '@app/hooks/device'; +import FloatingTextInput from '@components/floating_text_input_label'; +import FormattedText from '@components/formatted_text'; +import Loading from '@components/loading'; +import {useTheme} from '@context/theme'; +import {debounce} from '@helpers/api/general'; +import useDidUpdate from '@hooks/did_update'; +import {fetchOpenGraph} from '@utils/opengraph'; +import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; +import {getUrlAfterRedirect} from '@utils/url'; + +type Props = { + disabled: boolean; + initialUrl?: string; + resetBookmark: () => void; + setBookmark: (url: string, title: string, imageUrl: string) => void; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + viewContainer: { + marginVertical: 32, + width: '100%', + }, + description: { + marginTop: 8, + }, + descriptionText: { + ...typography('Body', 75), + color: changeOpacity(theme.centerChannelColor, 0.56), + }, + loading: { + alignItems: 'flex-end', + justifyContent: 'center', + }, +})); + +const BookmarkLink = ({disabled, initialUrl = '', resetBookmark, setBookmark}: Props) => { + const theme = useTheme(); + const intl = useIntl(); + const isTablet = useIsTablet(); + const [error, setError] = useState(''); + const [url, setUrl] = useState(initialUrl); + const [loading, setLoading] = useState(false); + const styles = getStyleSheet(theme); + const keyboard = (Platform.OS === 'android') ? 'default' : 'url'; + const subContainerStyle = useMemo(() => [styles.viewContainer, {paddingHorizontal: isTablet ? 42 : 0}], [isTablet, styles]); + const descContainer = useMemo(() => [styles.description, {paddingHorizontal: isTablet ? 42 : 0}], [isTablet, styles]); + + const validateAndFetchOG = useCallback(debounce(async (text: string) => { + setLoading(true); + let link = await getUrlAfterRedirect(text, false); + + if (link.error) { + link = await getUrlAfterRedirect(text, true); + } + + if (link.url) { + const result = await fetchOpenGraph(link.url, true); + const title = result.title || text; + const imageUrl = result.favIcon || result.imageURL || ''; + setLoading(false); + setBookmark(link.url, title, imageUrl); + return; + } + setError(intl.formatMessage({ + id: 'channel_bookmark_add.link.invalid', + defaultMessage: 'Please enter a valid link', + })); + setLoading(false); + }, 500), [intl]); + + const onChangeText = useCallback((text: string) => { + resetBookmark(); + setUrl(text); + setError(''); + }, [resetBookmark]); + + const onSubmitEditing = useCallback(() => { + if (url) { + validateAndFetchOG(url); + } + }, [url, error]); + + useDidUpdate(debounce(() => { + onSubmitEditing(); + }, 300), [onSubmitEditing]); + + return ( + + + } + /> + + + + + ); +}; + +export default BookmarkLink; diff --git a/app/screens/channel_bookmark/index.tsx b/app/screens/channel_bookmark/index.tsx new file mode 100644 index 000000000..81f310494 --- /dev/null +++ b/app/screens/channel_bookmark/index.tsx @@ -0,0 +1,323 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {Alert, View, type AlertButton} from 'react-native'; +import {SafeAreaView, type Edge} from 'react-native-safe-area-context'; + +import {addRecentReaction} from '@actions/local/reactions'; +import {createChannelBookmark, deleteChannelBookmark, editChannelBookmark} from '@actions/remote/channel_bookmark'; +import Button from '@components/button'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation'; +import {getFullErrorMessage} from '@utils/errors'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import BookmarkDetail from './components/bookmark_detail'; +import AddBookmarkFile from './components/bookmark_file'; +import BookmarkLink from './components/bookmark_link'; + +import type {AvailableScreens} from '@typings/screens/navigation'; + +type Props = { + bookmark?: ChannelBookmark; + canDeleteBookmarks?: boolean; + channelId: string; + closeButtonId: string; + componentId: AvailableScreens; + file?: FileInfo; + ownerId: string; + type: ChannelBookmarkType; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + content: { + flex: 1, + paddingHorizontal: 20, + paddingBottom: 16, + }, + progress: { + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.1)', + borderRadius: 4, + paddingLeft: 3, + marginTop: 12, + }, + deleteBg: {backgroundColor: changeOpacity(theme.errorTextColor, 0.16)}, + deleteContainer: {paddingTop: 32}, + deleteText: {color: theme.errorTextColor}, +})); + +const RIGHT_BUTTON = buildNavigationButton('edit-bookmark', 'channel_bookmark.edit.save_button'); +const edges: Edge[] = ['bottom', 'left', 'right']; +const emptyBookmark: ChannelBookmark = { + id: '', + create_at: 0, + update_at: 0, + delete_at: 0, + channel_id: '', + owner_id: '', + display_name: '', + sort_order: 0, + type: 'link', +}; + +const ChannelBookmarkAddOrEdit = ({ + bookmark: original, + canDeleteBookmarks = false, + channelId, + closeButtonId, + componentId, + file: originalFile, + ownerId, + type, +}: Props) => { + const {formatMessage} = useIntl(); + const theme = useTheme(); + const serverUrl = useServerUrl(); + const styles = getStyleSheet(theme); + const [bookmark, setBookmark] = useState(original); + const [file, setFile] = useState(originalFile); + const [isSaving, setIsSaving] = useState(false); + + const enableSaveButton = useCallback((enabled: boolean) => { + setButtons(componentId, { + rightButtons: [{ + ...RIGHT_BUTTON, + color: theme.sidebarHeaderTextColor, + text: formatMessage({id: 'channel_bookmark.edit.save_button', defaultMessage: 'Save'}), + enabled, + }], + }); + }, [formatMessage, theme]); + + const setBookmarkToSave = useCallback((b?: ChannelBookmark) => { + enableSaveButton((b?.type === 'link' && Boolean(b?.link_url)) || (b?.type === 'file' && Boolean(b.file_id))); + setBookmark(b); + }, []); + + const handleError = useCallback((error: string, buttons?: AlertButton[]) => { + const title = original ? formatMessage({id: 'channel_bookmark.edit.failed_title', defaultMessage: 'Error editing bookmark'}) : formatMessage({id: 'channel_bookmark.add.failed_title', defaultMessage: 'Error adding bookmark'}); + Alert.alert( + title, + formatMessage({ + id: 'channel_bookmark.add_edit.failed_desc', + defaultMessage: 'Details: {error}', + }, {error}), + buttons, + ); + setIsSaving(false); + const enabled = Boolean(bookmark?.display_name && + ((bookmark?.type === 'link' && Boolean(bookmark?.link_url)) || (bookmark?.type === 'file' && Boolean(bookmark.file_id)))); + enableSaveButton(enabled); + }, [bookmark, enableSaveButton, formatMessage]); + + const close = useCallback(() => { + return dismissModal({componentId}); + }, [componentId]); + + const createBookmark = useCallback(async (b: ChannelBookmark) => { + const res = await createChannelBookmark(serverUrl, channelId, b); + if (res.bookmark) { + close(); + return; + } + + handleError((res.error as Error).message); + }, [channelId, handleError, serverUrl]); + + const updateBookmark = useCallback(async (b: ChannelBookmark) => { + const res = await editChannelBookmark(serverUrl, b); + if (res.bookmarks) { + close(); + return; + } + + handleError((res.error as Error).message); + }, [handleError, serverUrl]); + + const setLinkBookmark = useCallback((url: string, title: string, imageUrl: string) => { + const b: ChannelBookmark = { + ...(bookmark || emptyBookmark), + owner_id: ownerId, + channel_id: channelId, + link_url: url, + image_url: imageUrl, + display_name: title, + type: 'link', + }; + + setBookmarkToSave(b); + }, [bookmark, channelId, setBookmarkToSave, ownerId]); + + const setFileBookmark = useCallback((f: ExtractedFileInfo) => { + const b: ChannelBookmark = { + ...(bookmark || emptyBookmark), + owner_id: ownerId, + channel_id: channelId, + display_name: decodeURIComponent(f.name), + type: 'file', + file_id: f.id, + }; + setBookmarkToSave(b); + setFile(f); + }, [bookmark, channelId, ownerId]); + + const setBookmarkDisplayName = useCallback((displayName: string) => { + if (bookmark) { + setBookmark((prev) => ({ + ...(prev!), + display_name: displayName, + })); + } + + enableSaveButton(Boolean(displayName)); + }, [bookmark, enableSaveButton]); + + const setBookmarkEmoji = useCallback((emoji?: string) => { + if (bookmark) { + setBookmark((prev) => ({ + ...prev!, + emoji, + })); + + const prevEmoji = original ? original.emoji : ''; + if (prevEmoji !== emoji) { + enableSaveButton(true); + } + } + + if (emoji) { + addRecentReaction(serverUrl, [emoji]); + } + }, [bookmark, enableSaveButton, serverUrl]); + + const resetBookmark = useCallback(() => { + setBookmarkToSave(original); + setFile(originalFile); + }, [setBookmarkToSave]); + + const onSaveBookmark = useCallback(async () => { + if (bookmark) { + enableSaveButton(false); + setIsSaving(true); + if (original) { + updateBookmark(bookmark); + return; + } + + createBookmark(bookmark); + } + }, [bookmark, createBookmark, updateBookmark]); + + const handleDelete = useCallback(async () => { + if (bookmark) { + setIsSaving(true); + enableSaveButton(false); + const {error} = await deleteChannelBookmark(serverUrl, bookmark.channel_id, bookmark.id); + if (error) { + Alert.alert( + formatMessage({id: 'channel_bookmark.delete.failed_title', defaultMessage: 'Error deleting bookmark'}), + formatMessage({ + id: 'channel_bookmark.add_edit.failed_desc', + defaultMessage: 'Details: {error}', + }, {error: getFullErrorMessage(error)}), + ); + setIsSaving(false); + enableSaveButton(true); + return; + } + + close(); + } + }, [bookmark, serverUrl, close]); + + const onDelete = useCallback(async () => { + if (bookmark) { + Alert.alert( + formatMessage({id: 'channel_bookmark.delete.confirm_title', defaultMessage: 'Delete bookmark'}), + formatMessage({id: 'channel_bookmark.delete.confirm', defaultMessage: 'You sure want to delete the bookmark {displayName}?'}, { + displayName: bookmark.display_name, + }), + [{ + text: formatMessage({id: 'channel_bookmark.delete.yes', defaultMessage: 'Yes'}), + style: 'destructive', + isPreferred: true, + onPress: handleDelete, + }, { + text: formatMessage({id: 'channel_bookmark.add.file_cancel', defaultMessage: 'Cancel'}), + style: 'cancel', + }], + ); + } + }, [bookmark, handleDelete]); + + useEffect(() => { + enableSaveButton(false); + }, []); + + useNavButtonPressed(RIGHT_BUTTON.id, componentId, onSaveBookmark, [bookmark]); + useNavButtonPressed(closeButtonId, componentId, close, [close]); + useAndroidHardwareBackHandler(componentId, close); + + return ( + + {type === 'link' && + + } + {type === 'file' && + + } + {Boolean(bookmark) && + <> + + {canDeleteBookmarks && + +