Channel Bookmarks (#7750)

This commit is contained in:
Elias Nahum 2024-02-05 17:07:01 +08:00 committed by GitHub
parent 302c273fa1
commit 2c5c878829
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
114 changed files with 4474 additions and 866 deletions

View file

@ -85,3 +85,7 @@ export const storePushDisabledInServerAcknowledged = async (serverUrl: string) =
export const removePushDisabledInServerAcknowledged = async (serverUrl: string) => {
return storeGlobal(`${GLOBAL_IDENTIFIERS.PUSH_DISABLED_ACK}${serverUrl}`, null, false);
};
export const setAppInactiveSince = async (time: number) => {
return storeGlobal(GLOBAL_IDENTIFIERS.APP_INACTIVE_SINCE, time, false);
};

View file

@ -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<any>, 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<any>, 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<any>, 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};
}
}

View file

@ -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};
}
};

View file

@ -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);
@ -1038,6 +1040,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);

View file

@ -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};
}
}

View file

@ -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};

View file

@ -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) {

View file

@ -0,0 +1,289 @@
// 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.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.DELETE_TEAM:
teams.handleTeamArchived(serverUrl, msg);
break;
case WebsocketEvents.RESTORE_TEAM:
teams.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;
// 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;
case WebsocketEvents.CALLS_RECORDING_STATE:
calls.handleCallRecordingState(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.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;
// 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;
}
}

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {setAppInactiveSince} from '@actions/app/global';
import {markChannelAsViewed} from '@actions/local/channel';
import {dataRetentionCleanup} from '@actions/local/systems';
import {markChannelAsRead} from '@actions/remote/channel';
@ -13,29 +14,10 @@ import {
import {fetchPostsForChannel, fetchPostThread} from '@actions/remote/post';
import {openAllUnreadChannels} from '@actions/remote/preference';
import {autoUpdateTimezone} from '@actions/remote/user';
import {getAppInactiveSince} from '@app/queries/app/global';
import {loadConfigAndCalls} from '@calls/actions/calls';
import {
handleCallChannelDisabled,
handleCallChannelEnabled,
handleCallEnded,
handleCallHostChanged,
handleCallRecordingState,
handleCallScreenOff,
handleCallScreenOn,
handleCallStarted, handleCallUserConnected, handleCallUserDisconnected,
handleCallUserJoined,
handleCallUserLeft,
handleCallUserMuted,
handleCallUserRaiseHand,
handleCallUserReacted,
handleCallUserUnmuted,
handleCallUserUnraiseHand,
handleCallUserVoiceOff,
handleCallUserVoiceOn,
handleUserDismissedNotification,
} from '@calls/connection/websocket_event_handlers';
import {isSupportedServerCalls} from '@calls/utils';
import {Screens, WebsocketEvents} from '@constants';
import {Screens} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import AppsManager from '@managers/apps_manager';
@ -56,58 +38,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);
@ -147,7 +77,11 @@ async function doReconnect(serverUrl: string) {
const {database} = operator;
const lastDisconnectedAt = await getWebSocketLastDisconnected(database);
let lastDisconnectedAt = await getWebSocketLastDisconnected(database);
if (!lastDisconnectedAt) {
lastDisconnectedAt = await getAppInactiveSince();
}
setAppInactiveSince(0);
resetWebSocketLastDisconnected(operator);
const currentTeamId = await getCurrentTeamId(database);
@ -197,280 +131,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;
case WebsocketEvents.CALLS_RECORDING_STATE:
handleCallRecordingState(serverUrl, msg);
break;
case WebsocketEvents.CALLS_HOST_CHANGED:
handleCallHostChanged(serverUrl, msg);
break;
case WebsocketEvents.CALLS_USER_DISMISSED_NOTIFICATION:
handleUserDismissedNotification(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 {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);

View file

@ -134,6 +134,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`;
}

View file

@ -0,0 +1,77 @@
// 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<ChannelBookmarkWithFileInfo>;
updateChannelBookmark(channelId: string, bookmark: ChannelBookmark, connectionId?: string): Promise<UpdateChannelBookmarkResponse>;
updateChannelBookmarkSortOrder(channelId: string, bookmarkId: string, newSortOrder: number, connectionId?: string): Promise<ChannelBookmarkWithFileInfo[]>;
deleteChannelBookmark(channelId: string, bookmarkId: string, connectionId?: string): Promise<ChannelBookmarkWithFileInfo>;
getChannelBookmarksForChannel(channelId: string, since: number): Promise<ChannelBookmarkWithFileInfo[]>;
}
const ClientChannelBookmarks = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
createChannelBookmark = async (channelId: string, bookmark: ChannelBookmark, connectionId = '') => {
this.analytics?.trackAPI('api_channels_bookmark_create', {channel_id: channelId});
return this.doFetch(
this.getChannelBookmarksRoute(channelId),
{
method: 'post',
body: bookmark,
headers: {'Connection-Id': connectionId},
},
);
};
updateChannelBookmark(channelId: string, bookmark: ChannelBookmark, connectionId?: string) {
this.analytics?.trackAPI('api_channels_bookmark_update', {channel_id: channelId, bookmark_id: bookmark.id});
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) {
this.analytics?.trackAPI('api_channels_bookmark_sort_order', {channel_id: channelId, bookmark_id: bookmarkId});
return this.doFetch(
`${this.getChannelBookmarkRoute(channelId, bookmarkId)}/sort_order`,
{
method: 'post',
body: newSortOrder,
headers: {'Connection-Id': connectionId},
},
);
}
deleteChannelBookmark(channelId: string, bookmarkId: string, connectionId?: string) {
this.analytics?.trackAPI('api_channels_bookmark_delete', {channel_id: channelId, bookmark_id: bookmarkId});
return this.doFetch(
this.getChannelBookmarkRoute(channelId, bookmarkId),
{
method: 'delete',
headers: {'Connection-Id': connectionId},
},
);
}
getChannelBookmarksForChannel(channelId: string, since: number) {
this.analytics?.trackAPI('api_channels_bookmark_get', {channel_id: channelId});
return this.doFetch(
`${this.getChannelBookmarksRoute(channelId)}${buildQueryString({bookmarks_since: since})}`,
{method: 'get'},
);
}
};
export default ClientChannelBookmarks;

View file

@ -152,14 +152,16 @@ const ClientChannels = <TBase extends Constructor<ClientBase>>(superclass: TBase
this.analytics?.trackAPI('api_channel_get', {channel_id: channelId});
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'},
);
};
@ -175,14 +177,20 @@ const ClientChannels = <TBase extends Constructor<ClientBase>>(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'},
);
};
@ -194,11 +202,11 @@ const ClientChannels = <TBase extends Constructor<ClientBase>>(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'},
);

View file

@ -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<FileSearchRequest>;
searchFilesWithParams: (teamId: string, FileSearchParams: string) => Promise<FileSearchRequest>;
@ -58,15 +59,19 @@ const ClientFiles = <TBase extends Constructor<ClientBase>>(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',

View file

@ -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,

View file

@ -383,4 +383,8 @@ export default class WebSocketClient {
public isConnected(): boolean {
return this.conn?.readyState === WebSocketReadyState.OPEN; //|| (!this.stop && this.connectFailCount <= 2);
}
public getConnectionId(): string {
return this.connectionId;
}
}

View file

@ -2,11 +2,12 @@
// See LICENSE.txt for license information.
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 RNButton from 'react-native-button';
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 (
<RNButton
containerStyle={bgStyle}
containerStyle={buttonContainerStyle}
onPress={onPress}
testID={testID}
disabled={disabled}
hitSlop={hitSlop}
>
<View style={containerStyle}>
{icon}

View file

@ -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 = () => (
<AddBookmarkOptions
channelId={channelId}
currentUserId={currentUserId}
/>
);
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 = (
<Button
backgroundStyle={showLarge ? styles.largeButton : styles.smallButton}
onPress={onPress}
hitSlop={hitSlop}
text={showLarge ? formatMessage({id: 'channel_info.add_bookmark', defaultMessage: 'Add a bookmark'}) : ''}
textStyle={showLarge ? styles.largeButtonText : styles.smallButtonText}
theme={theme}
iconComponent={
<CompassIcon
name='plus'
size={showLarge ? 16 : 20}
style={showLarge ? styles.largeButtonIcon : styles.smallButtonIcon}
/>
}
/>
);
if (showLarge) {
return (<View style={styles.container}>{button}</View>);
}
return button;
};
export default AddBookmark;

View file

@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import BookmarkType from '@components/channel_bookmarks/bookmark_type';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
channelId: string;
currentUserId: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
flex: {flex: 1},
listHeader: {marginBottom: 12},
listHeaderText: {
color: theme.centerChannelColor,
...typography('Heading', 600, 'SemiBold'),
},
}));
const AddBookmarkOptions = ({channelId, currentUserId}: Props) => {
const theme = useTheme();
const isTablet = useIsTablet();
const styles = getStyleSheet(theme);
return (
<>
{!isTablet && (
<View style={styles.listHeader}>
<FormattedText
id='channel_info.add_bookmark'
defaultMessage={'Add a bookmark'}
style={styles.listHeaderText}
/>
</View>
)}
<View style={styles.flex}>
<BookmarkType
channelId={channelId}
type='link'
ownerId={currentUserId}
/>
<BookmarkType
channelId={channelId}
type='file'
ownerId={currentUserId}
/>
</View>
</>
);
};
export default AddBookmarkOptions;

View file

@ -0,0 +1,61 @@
// 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 CompassIcon from '@components/compass_icon';
import OptionItem from '@components/option_item';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {dismissBottomSheet, showModal} from '@screens/navigation';
type Props = {
channelId: string;
type: ChannelBookmarkType;
ownerId: string;
}
const BookmarkType = ({channelId, type, ownerId}: Props) => {
const {formatMessage} = useIntl();
const theme = useTheme();
const onPress = useCallback(async () => {
await dismissBottomSheet();
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, ownerId}, options);
}, [channelId, theme, type, ownerId]);
let icon;
let label;
if (type === 'link') {
icon = 'link-variant';
label = formatMessage({id: 'channel_info.add_bookmark.link', defaultMessage: 'Add a link'});
} else {
icon = 'paperclip';
label = formatMessage({id: 'channel_info.add_bookmark.file', defaultMessage: 'Attach a file'});
}
return (
<OptionItem
action={onPress}
label={label}
icon={icon}
type='default'
/>
);
};
export default BookmarkType;

View file

@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo, type ReactNode} from 'react';
import {View, Text, Platform} from 'react-native';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import BookmarkIcon from './bookmark_icon';
import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark';
import type FileModel from '@typings/database/models/servers/file';
type Props = {
bookmark: ChannelBookmarkModel;
children?: ReactNode;
file?: FileModel;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
row: {
flexDirection: 'row',
alignItems: 'center',
},
imageContainer: {
width: 24,
height: 24,
justifyContent: 'center',
},
image: {
width: 20,
height: 20,
},
text: {
color: theme.centerChannelColor,
...typography('Body', 100, 'SemiBold'),
marginLeft: 2,
},
emoji: {
alignSelf: 'center',
top: Platform.select({android: -2}),
},
genericBookmark: {
top: 1,
},
}));
const BookmarkDetails = ({bookmark, children, file}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const fileInfo = useMemo(() => file?.toFileInfo(bookmark.ownerId), [file, bookmark.ownerId]);
return (
<View style={styles.row}>
<View style={styles.imageContainer}>
<BookmarkIcon
emoji={bookmark.emoji}
emojiSize={Platform.select({ios: 20, default: 19})}
emojiStyle={styles.emoji}
file={fileInfo}
genericStyle={styles.genericBookmark}
iconSize={24}
imageStyle={styles.image}
imageUrl={bookmark.imageUrl}
/>
{children}
</View>
<Text style={styles.text}>{bookmark.displayName}</Text>
</View>
);
};
export default BookmarkDetails;

View file

@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef, useState} from 'react';
import {StyleSheet, View} from 'react-native';
import Button from 'react-native-button';
import ProgressBar from '@app/components/progress_bar';
import {useTheme} from '@app/context/theme';
import Document, {type DocumentRef} from '@components/document';
import BookmarkDetails from './bookmark_details';
import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark';
import type FileModel from '@typings/database/models/servers/file';
type Props = {
bookmark: ChannelBookmarkModel;
canDownloadFiles: boolean;
file: FileModel;
onLongPress: () => void;
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
paddingVertical: 6,
},
progress: {
justifyContent: 'flex-end',
},
});
const BookmarkDocument = ({bookmark, canDownloadFiles, file, onLongPress}: Props) => {
const [progress, setProgress] = useState(0);
const document = useRef<DocumentRef>(null);
const theme = useTheme();
const handlePress = useCallback(async () => {
if (document.current) {
document.current.handlePreviewPress();
}
}, []);
return (
<Document
canDownloadFiles={canDownloadFiles}
file={file.toFileInfo(bookmark.ownerId)}
onProgress={setProgress}
ref={document}
>
<Button
onPress={handlePress}
onLongPress={onLongPress}
containerStyle={styles.container}
>
<BookmarkDetails
bookmark={bookmark}
file={file}
>
<View style={[StyleSheet.absoluteFill, styles.progress]}>
{progress > 0 &&
<ProgressBar
progress={progress}
color={theme.buttonBg}
/>
}
</View>
</BookmarkDetails>
</Button>
</Document>
);
};
export default BookmarkDocument;

View file

@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useCallback} from 'react';
import {type StyleProp, type TextStyle, type ViewStyle} from 'react-native';
import FastImage, {type ImageStyle} from 'react-native-fast-image';
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<TextStyle>;
file?: FileInfo | ExtractedFileInfo;
iconSize: number;
imageStyle?: StyleProp<ImageStyle>;
imageUrl?: string;
genericStyle: StyleProp<ViewStyle>;
}
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 (
<FileIcon
file={file}
iconSize={iconSize}
smallImage={true}
/>
);
} else if (imageUrl && !emoji && !hasImageError) {
return (
<FastImage
source={{uri: imageUrl}}
style={imageStyle}
onError={handleImageError}
/>
);
} else if (emoji) {
return (
<Emoji
emojiName={emoji!}
size={emojiSize}
textStyle={emojiStyle}
/>
);
}
return (
<CompassIcon
name='book-outline'
size={22}
color={theme.centerChannelColor}
style={genericStyle}
/>
);
};
export default BookmarkIcon;

View file

@ -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<React.SetStateAction<GalleryAction>>;
}
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: (
<DownloadWithAction
action={'sharing'}
galleryView={false}
item={galleryItem!}
setAction={setAction}
/>
),
}, {}, 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 && (
<View style={styles.header}>
<Text
style={styles.headerText}
numberOfLines={1}
>
{bookmark.displayName}
</Text>
</View>
)}
<View style={styles.flex}>
{canEditBookmarks &&
<OptionItem
action={onEdit}
label={intl.formatMessage({id: 'channel_bookmark.edit_option', defaultMessage: 'Edit'})}
icon='pencil-outline'
type='default'
/>
}
{canCopyPublicLink &&
<OptionItem
action={onCopy}
label={intl.formatMessage({id: 'channel_bookmark.copy_option', defaultMessage: 'Copy Link'})}
icon='content-copy'
type='default'
/>
}
{canShare &&
<OptionItem
action={onShare}
label={intl.formatMessage({id: 'channel_bookmark.share_option', defaultMessage: 'Share'})}
icon='share-variant-outline'
type='default'
/>
}
{canDeleteBookmarks &&
<OptionItem
action={onDelete}
destructive={true}
label={intl.formatMessage({id: 'channel_bookmark.delete_option', defaultMessage: 'Delete'})}
icon='trash-can-outline'
type='default'
/>
}
</View>
</>
);
};
export default ChannelBookmarkOptions;

View file

@ -0,0 +1,165 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useManagedConfig} from '@mattermost/react-native-emm';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {useIntl, type IntlShape} from 'react-intl';
import {Alert, StyleSheet} from 'react-native';
import Button from 'react-native-button';
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,
},
});
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<ManagedConfig>();
const serverUrl = useServerUrl();
const intl = useIntl();
const {bottom} = useSafeAreaInsets();
const [action, setAction] = useState<GalleryAction>('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 = () => (
<ChannelBookmarkOptions
bookmark={bookmark}
canCopyPublicLink={canCopyPublicLink}
canDeleteBookmarks={canDeleteBookmarks}
canDownloadFiles={canDownloadFiles}
canEditBookmarks={canEditBookmarks}
file={file}
setAction={setAction}
/>
);
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 (
<BookmarkDocument
bookmark={bookmark}
canDownloadFiles={canDownloadFiles}
file={file!}
onLongPress={handleLongPress}
/>
);
}
return (
<Button
containerStyle={styles.container}
onPress={onGestureEvent}
onLongPress={handleLongPress}
ref={ref}
>
<BookmarkDetails
bookmark={bookmark}
file={file}
/>
</Button>
);
};
export default ChannelBookmark;

View file

@ -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));

View file

@ -0,0 +1,181 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useState} from 'react';
import {FlatList, View, type ListRenderItemInfo, type NativeSyntheticEvent, type NativeScrollEvent} from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
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<ChannelBookmarkModel>) => {
return (
<ChannelBookmark
bookmark={item}
canDeleteBookmarks={canDeleteBookmarks}
canDownloadFiles={canDownloadFiles}
canEditBookmarks={canEditBookmarks}
galleryIdentifier={galleryIdentifier}
index={item.fileId ? attachmentIndex(item.fileId) : undefined}
onPress={handlePreviewPress}
publicLinkEnabled={publicLinkEnabled}
/>
);
}, [
attachmentIndex, bookmarks, canDownloadFiles, canDeleteBookmarks, canEditBookmarks,
handlePreviewPress, publicLinkEnabled,
]);
const renderItemSeparator = useCallback(() => (<View style={styles.emptyItemSeparator}/>), []);
const onScrolled = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
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 (
<AddBookmark
bookmarksCount={0}
canUploadFiles={canUploadFiles}
channelId={channelId}
currentUserId={currentUserId}
showLarge={true}
/>
);
}
if (bookmarks.length) {
return (
<GalleryInit galleryIdentifier={galleryIdentifier}>
<Animated.View>
<Animated.View style={[styles.container, showInInfo ? undefined : styles.channelView]}>
<FlatList
bounces={true}
alwaysBounceHorizontal={false}
data={bookmarks}
horizontal={true}
renderItem={renderItem}
ItemSeparatorComponent={renderItemSeparator}
onScroll={onScrolled}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{height: 48, alignItems: 'center'}}
/>
{canAddBookmarks &&
<View style={styles.addContainer}>
{allowEndFade &&
<LinearGradient
angle={290}
useAngle={true}
locations={GRADIENT_LOCATIONS}
colors={gradientColors}
style={styles.gradient}
pointerEvents={'none'}
/>
}
<AddBookmark
bookmarksCount={bookmarks.length}
canUploadFiles={canUploadFiles}
channelId={channelId}
currentUserId={currentUserId}
showLarge={false}
/>
</View>
}
</Animated.View>
{separator &&
<View style={styles.separator}/>
}
</Animated.View>
</GalleryInit>
);
}
return null;
};
export default ChannelBookmarks;

View file

@ -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));

View file

@ -0,0 +1,169 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
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 FileSystem from 'react-native-fs';
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 {emptyFunction} from '@utils/general';
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<DocumentRef, DocumentProps>(({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<ProgressPromise<ClientResponse>>();
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) {
FileSystem.unlink(path).catch(emptyFunction);
}
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) {
FileSystem.unlink(path).catch(emptyFunction);
}
});
}
}, [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;

View file

@ -2,34 +2,17 @@
// See LICENSE.txt for license information.
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 FileSystem from 'react-native-fs';
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 {emptyFunction} from '@utils/general';
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;
}
@ -44,122 +27,13 @@ const styles = StyleSheet.create({
},
});
const DocumentFile = forwardRef<DocumentFileRef, DocumentFileProps>(({backgroundColor, canDownloadFiles, file}: DocumentFileProps, ref) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const DocumentFile = forwardRef<DocumentRef, DocumentFileProps>(({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<ProgressPromise<ClientResponse>>();
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) {
FileSystem.unlink(path).catch(emptyFunction);
}
setDownloading(false);
setProgress(0);
if (!isErrorWithMessage(error) || error.message !== 'cancelled') {
logDebug('error on downloadAndPreviewFile', getFullErrorMessage(error));
alertDownloadFailed(intl);
}
}
};
const document = useRef<DocumentRef>(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) {
FileSystem.unlink(path).catch(emptyFunction);
}
});
}
};
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, () => ({
@ -174,13 +48,13 @@ const DocumentFile = forwardRef<DocumentFileRef, DocumentFileProps>(({background
);
let fileAttachmentComponent = icon;
if (downloading) {
if (progress) {
fileAttachmentComponent = (
<>
{icon}
<View style={[StyleSheet.absoluteFill, styles.progress]}>
<ProgressBar
progress={progress || 0.1}
progress={progress}
color={theme.buttonBg}
/>
</View>
@ -189,9 +63,19 @@ const DocumentFile = forwardRef<DocumentFileRef, DocumentFileProps>(({background
}
return (
<TouchableOpacity onPress={handlePreviewPress}>
{fileAttachmentComponent}
</TouchableOpacity>
<Document
canDownloadFiles={canDownloadFiles}
file={file}
onProgress={setProgress}
ref={document}
>
<TouchableOpacity
disabled={disabled}
onPress={handlePreviewPress}
>
{fileAttachmentComponent}
</TouchableOpacity>
</Document>
);
});

View file

@ -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,9 @@ import ImageFile from './image_file';
import ImageFileOverlay from './image_file_overlay';
import VideoFile from './video_file';
import type {DocumentRef} from '@components/document';
import type {ResizeMode} from 'react-native-fast-image';
type FileProps = {
canDownloadFiles: boolean;
file: FileInfo;
@ -36,6 +39,8 @@ type FileProps = {
showDate?: boolean;
updateFileForGallery: (idx: number, file: FileInfo) => void;
asCard?: boolean;
resizeMode?: ResizeMode;
isPressDisabled?: boolean;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@ -79,8 +84,10 @@ const File = ({
showDate = false,
updateFileForGallery,
wrapperWidth = 300,
resizeMode = 'cover',
isPressDisabled = false,
}: FileProps) => {
const document = useRef<DocumentFileRef>(null);
const document = useRef<DocumentRef>(null);
const theme = useTheme();
const style = getStyleSheet(theme);
@ -101,6 +108,7 @@ const File = ({
const renderCardWithImage = (fileIcon: JSX.Element) => {
const fileInfo = (
<FileInfo
disabled={isPressDisabled}
file={file}
showDate={showDate}
channelName={channelName}
@ -127,14 +135,17 @@ const File = ({
let fileComponent;
if (isVideo(file) && publicLinkEnabled) {
const renderVideoFile = (
<TouchableWithoutFeedback onPress={onGestureEvent}>
<TouchableWithoutFeedback
disabled={isPressDisabled}
onPress={onGestureEvent}
>
<Animated.View style={[styles, asCard ? style.imageVideo : null]}>
<VideoFile
file={file}
forwardRef={ref}
inViewPort={inViewPort}
isSingleImage={isSingleImage}
resizeMode={'cover'}
resizeMode={resizeMode}
wrapperWidth={wrapperWidth}
updateFileForGallery={updateFileForGallery}
index={index}
@ -151,14 +162,17 @@ const File = ({
fileComponent = asCard ? renderCardWithImage(renderVideoFile) : renderVideoFile;
} else if (isImage(file)) {
const renderImageFile = (
<TouchableWithoutFeedback onPress={onGestureEvent}>
<TouchableWithoutFeedback
onPress={onGestureEvent}
disabled={isPressDisabled}
>
<Animated.View style={[styles, asCard ? style.imageVideo : null]}>
<ImageFile
file={file}
forwardRef={ref}
inViewPort={inViewPort}
isSingleImage={isSingleImage}
resizeMode={'cover'}
resizeMode={resizeMode}
wrapperWidth={wrapperWidth}
/>
{Boolean(nonVisibleImagesCount) &&
@ -177,6 +191,7 @@ const File = ({
<DocumentFile
ref={document}
canDownloadFiles={canDownloadFiles}
disabled={isPressDisabled}
file={file}
/>
</View>
@ -184,6 +199,7 @@ const File = ({
const fileInfo = (
<FileInfo
disabled={isPressDisabled}
file={file}
showDate={showDate}
channelName={channelName}
@ -207,6 +223,7 @@ const File = ({
const touchableWithPreview = (
<TouchableWithFeedback
onPress={handlePreviewPress}
disabled={isPressDisabled}
type={'opacity'}
>
<FileIcon

View file

@ -12,7 +12,7 @@ type FileIconProps = {
backgroundColor?: string;
defaultImage?: boolean;
failed?: boolean;
file?: FileInfo;
file?: FileInfo | ExtractedFileInfo;
iconColor?: string;
iconSize?: number;
smallImage?: boolean;

View file

@ -11,6 +11,7 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type FileInfoProps = {
disabled?: boolean;
file: FileInfo;
showDate: boolean;
channelName?: string ;
@ -58,18 +59,22 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const FileInfo = ({file, channelName, showDate, onPress}: FileInfoProps) => {
const FileInfo = ({disabled, file, channelName, showDate, onPress}: FileInfoProps) => {
const theme = useTheme();
const style = getStyleSheet(theme);
return (
<View style={style.attachmentContainer}>
<TouchableOpacity onPress={onPress}>
<TouchableOpacity
disabled={disabled}
onPress={onPress}
>
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={style.fileName}
>
{file.name.trim()}
{decodeURIComponent(file.name.trim())}
</Text>
<View style={style.fileDownloadContainer}>
{channelName &&

View file

@ -5,7 +5,7 @@ import React, {useCallback, useMemo, useState} from 'react';
import {StyleSheet, useWindowDimensions, View} from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
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;
};

View file

@ -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,

View file

@ -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';
@ -115,6 +116,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);
@ -126,7 +128,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));

View file

@ -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',
@ -83,6 +84,7 @@ export const GLOBAL_IDENTIFIERS = {
LAST_VIEWED_CHANNEL: 'lastViewedChannel',
LAST_VIEWED_THREAD: 'lastViewedThread',
PUSH_DISABLED_ACK: 'pushDisabledAck',
APP_INACTIVE_SINCE: 'appInactiveSince',
};
export enum OperationType {

View file

@ -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',
};

View file

@ -72,6 +72,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,
@ -82,6 +84,7 @@ export default {
CALL,
CHANNEL,
CHANNEL_ADD_MEMBERS,
CHANNEL_BOOKMARK,
CHANNEL_FILES,
CHANNEL_INFO,
CHANNEL_NOTIFICATION_PREFERENCES,
@ -145,6 +148,7 @@ export default {
THREAD_FOLLOW_BUTTON,
THREAD_OPTIONS,
USER_PROFILE,
GENERIC_OVERLAY,
} as const;
export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([
@ -168,6 +172,7 @@ export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set<string>([
PERMALINK,
REVIEW_APP,
SNACK_BAR,
GENERIC_OVERLAY,
]);
export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([

View file

@ -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<string, SnackBarConfig> = {
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',

View file

@ -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,

View file

@ -91,5 +91,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;

View file

@ -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,

View file

@ -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,
@ -45,7 +45,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,

View file

@ -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: [

View file

@ -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<DraftModel>;
/** bookmarks : All bookmarks for this channel */
@children(CHANNEL_BOOKMARK) bookmarks!: Query<ChannelBookmarkModel>;
/** posts : All posts made in that channel */
@children(POST) posts!: Query<PostModel>;

View file

@ -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<ChannelModel>;
/** creator : The USER who created this CHANNEL_BOOKMARK */
@immutableRelation(USER, 'owner_id') owner!: Relation<UserModel>;
/** file : The FILE attached to this CHANNEL_BOOKMARK */
@immutableRelation(FILE, 'file_id') file!: Relation<FileModel>;
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;
};
}

View file

@ -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'},
};

View file

@ -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';

View file

@ -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'},

View file

@ -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<T>}>}
*/
processRecords = async <T extends Model>({createOrUpdateRawValues = [], deleteRawValues = [], tableName, buildKeyRecordBy, fieldName, shouldUpdate}: ProcessRecordsArgs): Promise<ProcessRecordResults<T>> => {
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<string, Model>, record) => {
const recordsByKeys = createOrUpdateRaws.reduce((result: Record<string, T>, 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<Model>;} transformer
* @returns {Promise<Model[]>}
* @param {T extends Model[]} prepareRecord.deleteRaws
* @param {(TransformerArgs) => Promise<T extends Model>;} transformer
* @returns {Promise<T extends Model[]>}
*/
prepareRecords = async <T extends Model>({tableName, createRaws, deleteRaws, updateRaws, transformer}: OperationArgs<T>): Promise<T[]> => {
if (!this.database) {
@ -210,7 +210,7 @@ export default class BaseDataOperator {
* @returns {Promise<Model[]>}
*/
async handleRecords<T extends Model>({buildKeyRecordBy, fieldName, transformer, createOrUpdateRawValues, deleteRawValues = [], tableName, prepareRecordsOnly = true, shouldUpdate}: HandleRecordsArgs<T>, description: string): Promise<T[]> {
if (!createOrUpdateRawValues.length) {
if (!createOrUpdateRawValues.length && !deleteRawValues.length) {
logWarning(
`An empty "rawValues" array has been passed to the handleRecords method for tableName ${tableName}`,
);

View file

@ -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 () => {

View file

@ -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<ChannelModel[]>;
handleChannelBookmark: ({bookmarks, prepareRecordsOnly}: HandleChannelBookmarkArgs) => Promise<Model[]>;
handleChannelMembership: ({channelMemberships, prepareRecordsOnly}: HandleChannelMembershipArgs) => Promise<ChannelMembershipModel[]>;
handleMyChannelSettings: ({settings, prepareRecordsOnly}: HandleMyChannelSettingsArgs) => Promise<MyChannelSettingsModel[]>;
handleChannelInfo: ({channelInfos, prepareRecordsOnly}: HandleChannelInfoArgs) => Promise<ChannelInfoModel[]>;
handleMyChannel: ({channels, myChannels, isCRTEnabled, prepareRecordsOnly}: HandleMyChannelArgs) => Promise<MyChannelModel[]>;
handleMyChannel: ({channels, myChannels, isCRTEnabled, prepareRecordsOnly}: HandleMyChannelArgs) => Promise<Model[]>;
}
const ChannelHandler = <TBase extends Constructor<ServerDataOperatorBase>>(superclass: TBase) => class extends superclass {
@ -217,7 +221,7 @@ const ChannelHandler = <TBase extends Constructor<ServerDataOperatorBase>>(super
* @param {boolean} myChannelsArgs.prepareRecordsOnly
* @returns {Promise<MyChannelModel[]>}
*/
handleMyChannel = async ({channels, myChannels, isCRTEnabled, prepareRecordsOnly = true}: HandleMyChannelArgs): Promise<MyChannelModel[]> => {
handleMyChannel = async ({channels, myChannels, isCRTEnabled, prepareRecordsOnly = true}: HandleMyChannelArgs): Promise<Model[]> => {
if (!myChannels?.length) {
logWarning(
'An empty or undefined "myChannels" array has been passed to the handleMyChannel method',
@ -235,7 +239,6 @@ const ChannelHandler = <TBase extends Constructor<ServerDataOperatorBase>>(super
}
const isCRT = isCRTEnabled ?? await getIsCRTEnabled(this.database);
const channelMap = channels.reduce((result: Record<string, Channel>, channel) => {
result[channel.id] = channel;
return result;
@ -353,6 +356,87 @@ const ChannelHandler = <TBase extends Constructor<ServerDataOperatorBase>>(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<ChannelBookmarkModel[]>}
*/
handleChannelBookmark = async ({bookmarks, prepareRecordsOnly}: HandleChannelBookmarkArgs): Promise<Model[]> => {
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<ChannelBookmarkModel>(CHANNEL_BOOKMARK).query(
Q.where('id', Q.oneOf(keys)),
).fetch();
const bookmarkMap = new Map<string, ChannelBookmarkModel>(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;

View file

@ -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);

View file

@ -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<FileModel[]>}
*/
handleFiles = async ({files, prepareRecordsOnly}: HandleFilesArgs): Promise<FileModel[]> => {
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<FileModel>({
createOrUpdateRawValues: raws.createOrUpdateFiles,
tableName: FILE,
fieldName: 'id',
deleteRawValues: raws.deleteFiles,
shouldUpdate: shouldUpdateFileRecord,
}));
const preparedFiles = await this.prepareRecords<FileModel>({
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

View file

@ -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 = <TBase extends Constructor<ServerDataOperatorBase>>(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<FileModel[]>}
*/
handleFiles = async ({files, prepareRecordsOnly}: HandleFilesArgs): Promise<FileModel[]> => {
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<string, Post[]>} postsMap

View file

@ -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<ChannelMembershipModel>;
};
/**
* 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<ChannelBookmarkModel>}
*/
export const transformChannelBookmarkRecord = ({action, database, value}: TransformerArgs): Promise<ChannelBookmarkModel> => {
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<ChannelBookmarkModel>;
};

View file

@ -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');
});
});

View file

@ -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<ConfigModel>;
};
/**
* 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<FileModel>}
*/
export const transformFileRecord = ({action, database, value}: TransformerArgs): Promise<FileModel> => {
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<FileModel>;
};

View file

@ -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);

View file

@ -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<PostsInThreadModel>;
};
/**
* 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<FileModel>}
*/
export const transformFileRecord = ({action, database, value}: TransformerArgs): Promise<FileModel> => {
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<FileModel>;
};
/**
* transformDraftRecord: Prepares a record of the SERVER database 'Draft' table for update or create actions.
* @param {TransformerArgs} operator

View file

@ -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,

View file

@ -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},
],
});

View file

@ -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';

View file

@ -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,

View file

@ -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<FileInfo[]>([]);
useEffect(() => {
getFileInfo(serverUrl, bookmarks, publicLinkEnabled, setFiles);
}, [serverUrl, bookmarks, publicLinkEnabled]);
return files;
};

View file

@ -8,9 +8,11 @@ import BackgroundTimer from 'react-native-background-timer';
import {BehaviorSubject} from 'rxjs';
import {distinctUntilChanged} from 'rxjs/operators';
import {setAppInactiveSince} from '@actions/app/global';
import {setCurrentUserStatus} from '@actions/local/user';
import {fetchStatusByIds} from '@actions/remote/user';
import {handleClose, handleEvent, handleFirstConnect, handleReconnect} from '@actions/websocket';
import {handleClose, 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';
@ -53,6 +55,9 @@ class WebsocketManager {
);
AppState.addEventListener('change', this.onAppStateChange);
AppState.addEventListener('blur', () => {
setAppInactiveSince(Date.now());
});
NetInfo.addEventListener(this.onNetStateChange);
};
@ -77,7 +82,7 @@ class WebsocketManager {
const client = new WebSocketClient(serverUrl, bearerToken, storedLastDisconnect);
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));
@ -230,6 +235,7 @@ class WebsocketManager {
this.cancelAllConnections();
if (!isActive && !this.isBackgroundTimerRunning) {
setAppInactiveSince(Date.now());
this.isBackgroundTimerRunning = true;
this.cancelAllConnections();
this.backgroundIntervalId = BackgroundTimer.setInterval(() => {

View file

@ -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,

View file

@ -106,14 +106,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);
}
}
};
@ -121,6 +123,8 @@ export const usePermissionsChecker = (micPermissionsGranted: boolean) => {
asyncFn();
}
}, [appState]);
return hasPermission;
};
export const useCallsAdjustment = (serverUrl: string, channelId: string): number => {
@ -130,6 +134,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;
@ -144,7 +149,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);

View file

@ -100,3 +100,12 @@ export const observeTutorialWatched = (tutorial: string) => {
switchMap((v) => of$(Boolean(v))),
);
};
export const getAppInactiveSince = async () => {
try {
const records = await queryGlobalValue(GLOBAL_IDENTIFIERS.APP_INACTIVE_SINCE)?.fetch();
return (parseInt(records?.[0]?.value || 0, 10) || 0);
} catch {
return 0;
}
};

View file

@ -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<ChannelMembership, 'user_id' | 'channel_id' | 'scheme_admin'>;
function prepareChannels(
operator: ServerDataOperator,
channels?: Channel[],
channelInfos?: ChannelInfo[],
channelMemberships?: ChannelMembershipsExtended[],
memberships?: ChannelMembership[],
isCRTEnabled?: boolean,
): Array<Promise<Model[]>> {
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<Promise<Model[]>> {
const channelInfos: ChannelInfo[] = [];
const channelMap: Record<string, Channel> = {};
@ -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<Model[]> => {
@ -169,6 +173,27 @@ export const prepareDeleteChannel = async (channel: ChannelModel): Promise<Model
}
}
const bookmarks = await channel.bookmarks?.fetch();
if (bookmarks?.length) {
for await (const bookmark of bookmarks) {
const prepareBookmarks = await prepareDeleteBookmarks(bookmark);
preparedModels.push(...prepareBookmarks);
}
}
return preparedModels;
};
export const prepareDeleteBookmarks = async (bookmark: ChannelBookmarkModel) => {
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;
};

View file

@ -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<ChannelBookmarkModel>(CHANNEL_BOOKMARK).find(bookmarkId);
return bookmark;
} catch {
return undefined;
}
};
export const queryBookmarks = (database: Database, channelId: string) => {
return database.get<ChannelBookmarkModel>(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']);
};

View file

@ -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}`, []],
],
});
});

View file

@ -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<FileModel>(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<FileModel>(FILE).query(
Q.where('post_id', postId),

View file

@ -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 &&
<FloatingCallContainer
channelId={channelId}
showJoinCallBanner={showJoinCallBanner}
showIncomingCalls={showIncomingCalls}
isInACall={isInACall}
includeBookmarkBar={includeBookmarkBar}
/>
}
</SafeAreaView>

View file

@ -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 (
<View style={containerStyle}>
<View style={styles.content}>
<View style={styles.paddingHorizontal}>
<ChannelBookmarks
channelId={channelId}
showInInfo={false}
canAddBookmarks={canAddBookmarks}
separator={false}
/>
</View>
</View>
<View style={styles.separatorContainer}>
<View style={styles.separator}/>
</View>
</View>
);
};
export default ChannelHeaderBookmarks;

View file

@ -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 = ({
<View style={contextStyle}>
<RoundedHeaderContext/>
</View>
{isBookmarksEnabled && hasBookmarks && shouldRenderBookmarks &&
<ChannelHeaderBookmarks
canAddBookmarks={canAddBookmarks}
channelId={channelId}
/>
}
</>
);
};

View file

@ -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,

View file

@ -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,
};
});

View file

@ -0,0 +1,134 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {TextInput, View} from 'react-native';
import Button from 'react-native-button';
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',
},
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 (
<View style={paddingStyle}>
<FormattedText
id='channel_bookmark.add.detail_title'
defaultMessage='Title'
style={styles.title}
/>
<View style={[styles.container, disabled && styles.disabled]}>
<Button
containerStyle={styles.iconContainer}
onPress={openEmojiPicker}
>
<View style={styles.imageContainer}>
<BookmarkIcon
emoji={emoji}
emojiSize={22}
file={file}
genericStyle={styles.genericBookmark}
iconSize={30}
imageStyle={styles.image}
imageUrl={imageUrl}
/>
</View>
<CompassIcon
size={12}
color={theme.centerChannelColor}
name='chevron-down'
/>
</Button>
<TextInput
editable={!disabled}
onChangeText={setBookmarkDisplayName}
value={title}
style={styles.input}
/>
</View>
</View>
);
};
export default BookmarkDetail;

View file

@ -0,0 +1,360 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {View, Text, Platform, type Insets} from 'react-native';
import Button from 'react-native-button';
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<ExtractedFileInfo|undefined>(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 = (
<Text style={styles.uploadError}>
{error}
</Text>
);
} else if (uploading) {
info = (
<FormattedText
id='channel_bookmark.add.file_uploading'
defaultMessage='Uploading... ({progress}%)'
values={{progress: Math.ceil(progress * 100)}}
style={styles.uploading}
/>
);
} else if (file) {
info = (
<Text style={styles.fileInfo}>
{`${file.extension} ${getFormattedFileSize(file.size || 0)}`}
</Text>
);
}
if (file) {
return (
<View style={subContainerStyle}>
<FormattedText
id='channel_bookmark.add.file_title'
defaultMessage='Attachment'
style={styles.title}
/>
<Shadow
style={styles.shadowContainer}
startColor='rgba(61, 60, 64, 0.08)'
distance={4}
sides={shadowSides}
>
<View style={styles.fileContainer}>
<FileIcon file={file}/>
<View style={styles.fileInfoContainer}>
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={styles.filename}
>
{decodeURIComponent(file.name.trim())}
</Text>
{info}
</View>
{failed &&
<Button
onPress={retry}
containerStyle={styles.retry}
>
<CompassIcon
color={changeOpacity(theme.centerChannelColor, 0.56)}
name='refresh'
size={20}
/>
</Button>
}
</View>
<TouchableWithFeedback
disabled={disabled}
hitSlop={hitSlop}
style={styles.removeContainer}
onPress={removeAndUpload}
type='opacity'
>
<View style={styles.removeButton}>
<CompassIcon
name='close-circle'
color={changeOpacity(theme.centerChannelColor, disabled ? 0.16 : 0.56)}
size={24}
/>
</View>
</TouchableWithFeedback>
</Shadow>
{uploading &&
<View style={styles.progressContainer}>
<ProgressBar
progress={progress}
color={theme.buttonBg}
style={styles.progress}
/>
</View>
}
</View>
);
}
return null;
};
export default BookmarkFile;

View file

@ -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));

View file

@ -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 (
<View style={subContainerStyle}>
<FloatingTextInput
autoCapitalize={'none'}
autoCorrect={false}
disableFullscreenUI={true}
editable={!disabled}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
keyboardType={keyboard}
returnKeyType='go'
label={intl.formatMessage({id: 'channel_bookmark_add.link', defaultMessage: 'Link'})}
onChangeText={onChangeText}
theme={theme}
error={error}
showErrorIcon={true}
value={url}
onSubmitEditing={onSubmitEditing}
endAdornment={loading &&
<Loading
size='small'
color={theme.buttonBg}
containerStyle={styles.loading}
/>
}
/>
<View style={descContainer}>
<FormattedText
id='channel_bookmark_add.link.input.description'
defaultMessage='Add a link to any post, file, or any external link'
style={styles.descriptionText}
testID='channel_bookmark_add.link.input.description'
/>
</View>
</View>
);
};
export default BookmarkLink;

View file

@ -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<ChannelBookmark|undefined>(original);
const [file, setFile] = useState<ExtractedFileInfo|undefined>(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 (
<SafeAreaView
edges={edges}
style={styles.content}
testID='channel_bookmark.screen'
>
{type === 'link' &&
<BookmarkLink
disabled={isSaving}
initialUrl={original?.link_url}
setBookmark={setLinkBookmark}
resetBookmark={resetBookmark}
/>
}
{type === 'file' &&
<AddBookmarkFile
channelId={channelId}
close={close}
disabled={isSaving}
initialFile={originalFile}
setBookmark={setFileBookmark}
/>
}
{Boolean(bookmark) &&
<>
<BookmarkDetail
disabled={isSaving}
emoji={bookmark?.emoji}
imageUrl={bookmark?.image_url}
title={bookmark?.display_name || ''}
file={file}
setBookmarkDisplayName={setBookmarkDisplayName}
setBookmarkEmoji={setBookmarkEmoji}
/>
{canDeleteBookmarks &&
<View style={styles.deleteContainer}>
<Button
buttonType='destructive'
size='m'
text='Delete bookmark'
iconName='trash-can-outline'
textStyle={styles.deleteText}
backgroundStyle={styles.deleteBg}
iconSize={18}
onPress={onDelete}
theme={theme}
disabled={isSaving}
/>
</View>
}
</>
}
</SafeAreaView>
);
};
export default ChannelBookmarkAddOrEdit;

View file

@ -8,6 +8,7 @@ import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
import ChannelInfoEnableCalls from '@calls/components/channel_info_enable_calls';
import ChannelActions from '@components/channel_actions';
import ConvertToChannelLabel from '@components/channel_actions/convert_to_channel/convert_to_channel_label';
import ChannelBookmarks from '@components/channel_bookmarks';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
@ -25,17 +26,19 @@ import Title from './title';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
canAddBookmarks: boolean;
canEnableDisableCalls: boolean;
canManageMembers: boolean;
canManageSettings: boolean;
channelId: string;
closeButtonId: string;
componentId: AvailableScreens;
type?: ChannelType;
canEnableDisableCalls: boolean;
isBookmarksEnabled: boolean;
isCallsEnabledInChannel: boolean;
canManageMembers: boolean;
isCRTEnabled: boolean;
canManageSettings: boolean;
isGuestUser: boolean;
isConvertGMFeatureAvailable: boolean;
isCRTEnabled: boolean;
isGuestUser: boolean;
type?: ChannelType;
}
const edges: Edge[] = ['bottom', 'left', 'right'];
@ -56,17 +59,19 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
}));
const ChannelInfo = ({
isCRTEnabled,
canAddBookmarks,
canEnableDisableCalls,
canManageMembers,
canManageSettings,
channelId,
closeButtonId,
componentId,
type,
canEnableDisableCalls,
isBookmarksEnabled,
isCallsEnabledInChannel,
canManageMembers,
canManageSettings,
isGuestUser,
isConvertGMFeatureAvailable,
isCRTEnabled,
isGuestUser,
type,
}: Props) => {
const theme = useTheme();
const serverUrl = useServerUrl();
@ -101,6 +106,13 @@ const ChannelInfo = ({
channelId={channelId}
type={type}
/>
{isBookmarksEnabled &&
<ChannelBookmarks
channelId={channelId}
canAddBookmarks={canAddBookmarks}
showInInfo={true}
/>
}
<ChannelActions
channelId={channelId}
inModal={true}

View file

@ -9,8 +9,10 @@ import {observeIsCallsEnabledInChannel} from '@calls/observers';
import {observeCallsConfig} from '@calls/state';
import {withServerUrl} from '@context/server';
import {observeCurrentChannel} from '@queries/servers/channel';
import {observeCanAddBookmarks} from '@queries/servers/channel_bookmark';
import {observeCanManageChannelMembers, observeCanManageChannelSettings} from '@queries/servers/role';
import {
observeConfigBooleanValue,
observeConfigValue,
observeCurrentChannelId,
observeCurrentTeamId,
@ -59,7 +61,8 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => {
switchMap(([uId, chId]) => observeUserIsChannelAdmin(database, uId, chId)),
distinctUntilChanged(),
);
const callsGAServer = observeConfigValue(database, 'Version').pipe(
const serverVersion = observeConfigValue(database, 'Version');
const callsGAServer = serverVersion.pipe(
switchMap((v) => of$(isMinimumServerVersion(v || '', 7, 6))),
);
const dmOrGM = type.pipe(switchMap((t) => of$(isTypeDMorGM(t))));
@ -117,17 +120,27 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => {
distinctUntilChanged(),
);
const isConvertGMFeatureAvailable = observeConfigValue(database, 'Version').pipe(
const isConvertGMFeatureAvailable = serverVersion.pipe(
switchMap((version) => of$(isMinimumServerVersion(version || '', 9, 1))),
);
const isBookmarksEnabled = observeConfigBooleanValue(database, 'FeatureFlagChannelBookmarks');
const canAddBookmarks = channelId.pipe(
switchMap((cId) => {
return observeCanAddBookmarks(database, cId);
}),
);
return {
type,
canEnableDisableCalls,
isCallsEnabledInChannel,
canAddBookmarks,
canManageMembers,
isCRTEnabled: observeIsCRTEnabled(database),
canManageSettings,
isBookmarksEnabled,
isCallsEnabledInChannel,
isCRTEnabled: observeIsCRTEnabled(database),
isGuestUser,
isConvertGMFeatureAvailable,
};

View file

@ -72,7 +72,6 @@ const Field = ({
const formattedLabel = isOptional ? `${label} ${optionalText}` : label;
const textInputStyle = isDisabled ? style.disabledStyle : undefined;
const subContainer = [style.viewContainer, {paddingHorizontal: isTablet ? 42 : 20}];
const fieldInputTestId = isDisabled ? `${testID}.input.disabled` : `${testID}.input`;
@ -97,7 +96,6 @@ const Field = ({
value={value}
ref={fieldRef}
onSubmitEditing={onSubmitEditing}
textInputStyle={textInputStyle}
{...props}
/>
</View>

View file

@ -15,6 +15,8 @@ import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
componentId: AvailableScreens;
onEmojiPress: (emoji: string) => void;
imageUrl?: string;
file?: ExtractedFileInfo;
closeButtonId: string;
};
@ -24,7 +26,7 @@ const style = StyleSheet.create({
},
});
const EmojiPickerScreen = ({closeButtonId, componentId, onEmojiPress}: Props) => {
const EmojiPickerScreen = ({closeButtonId, componentId, file, imageUrl, onEmojiPress}: Props) => {
const handleEmojiPress = useCallback((emoji: string) => {
onEmojiPress(emoji);
DeviceEventEmitter.emit(Events.CLOSE_BOTTOM_SHEET);
@ -34,6 +36,8 @@ const EmojiPickerScreen = ({closeButtonId, componentId, onEmojiPress}: Props) =>
return (
<Picker
onEmojiPress={handleEmojiPress}
imageUrl={imageUrl}
file={file}
testID='emoji_picker'
/>
);

View file

@ -31,11 +31,13 @@ type Props = {
customEmojis: CustomEmojiModel[];
customEmojisEnabled: boolean;
onEmojiPress: (emoji: string) => void;
imageUrl?: string;
file?: ExtractedFileInfo;
recentEmojis: string[];
testID?: string;
}
const Picker = ({customEmojis, customEmojisEnabled, onEmojiPress, recentEmojis, testID = ''}: Props) => {
const Picker = ({customEmojis, customEmojisEnabled, file, imageUrl, onEmojiPress, recentEmojis, testID = ''}: Props) => {
const theme = useTheme();
const serverUrl = useServerUrl();
const [searchTerm, setSearchTerm] = useState<string|undefined>();
@ -68,6 +70,8 @@ const Picker = ({customEmojis, customEmojisEnabled, onEmojiPress, recentEmojis,
<EmojiSections
customEmojis={customEmojis}
customEmojisEnabled={customEmojisEnabled}
imageUrl={imageUrl}
file={file}
onEmojiPress={onEmojiPress}
recentEmojis={recentEmojis}
/>

View file

@ -5,10 +5,13 @@ import {BottomSheetSectionList} from '@gorhom/bottom-sheet';
import {chunk} from 'lodash';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {type ListRenderItemInfo, type NativeScrollEvent, type NativeSyntheticEvent, SectionList, type SectionListData, StyleSheet, View} from 'react-native';
import FastImage from 'react-native-fast-image';
import sectionListGetItemLayout from 'react-native-section-list-get-item-layout';
import {fetchCustomEmojis} from '@actions/remote/custom_emoji';
import FileIcon from '@components/files/file_icon';
import TouchableEmoji from '@components/touchable_emoji';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {EMOJIS_PER_PAGE} from '@constants/emoji';
import {useServerUrl} from '@context/server';
import {useIsTablet} from '@hooks/device';
@ -65,15 +68,28 @@ const styles = StyleSheet.create(({
height: EMOJI_SIZE,
width: EMOJI_SIZE,
},
imageEmoji: {
width: 28,
height: 28,
},
}));
type Props = {
customEmojis: CustomEmojiModel[];
customEmojisEnabled: boolean;
imageUrl?: string;
file?: ExtractedFileInfo;
onEmojiPress: (emoji: string) => void;
recentEmojis: string[];
}
type ImageEmojiProps = {
onEmojiPress: (emoji: string) => void;
file?: ExtractedFileInfo;
imageUrl?: string;
path: string;
}
CategoryNames.forEach((name: string) => {
if (CategoryTranslations.has(name) && CategoryMessage.has(name)) {
categoryToI18n[name] = {
@ -90,7 +106,36 @@ const emptyEmoji: EmojiAlias = {
aliases: [],
};
const EmojiSections = ({customEmojis, customEmojisEnabled, onEmojiPress, recentEmojis}: Props) => {
const ImageEmoji = ({file, imageUrl, onEmojiPress, path}: ImageEmojiProps) => {
const onPress = useCallback(() => {
onEmojiPress('');
}, [onEmojiPress]);
return (
<View style={styles.row}>
<View style={styles.emoji}>
<TouchableWithFeedback onPress={onPress}>
<>
{Boolean(file) &&
<FileIcon
file={file}
iconSize={30}
/>
}
{Boolean(imageUrl) &&
<FastImage
source={{uri: path}}
style={styles.imageEmoji}
/>
}
</>
</TouchableWithFeedback>
</View>
</View>
);
};
const EmojiSections = ({customEmojis, customEmojisEnabled, file, imageUrl, onEmojiPress, recentEmojis}: Props) => {
const serverUrl = useServerUrl();
const isTablet = useIsTablet();
const {currentIndex, selectedIndex} = useEmojiCategoryBar();
@ -105,7 +150,7 @@ const EmojiSections = ({customEmojis, customEmojisEnabled, onEmojiPress, recentE
const sections: EmojiSection[] = useMemo(() => {
const emojisPerRow = isTablet ? EMOJIS_PER_ROW_TABLET : EMOJIS_PER_ROW;
return CategoryNames.map((category) => {
const sectionsArray = CategoryNames.map<EmojiSection>((category) => {
const emojiIndices = EmojiIndicesByCategory.get('default')?.get(category);
let data: EmojiAlias[][];
@ -150,7 +195,34 @@ const EmojiSections = ({customEmojis, customEmojisEnabled, onEmojiPress, recentE
key: category,
};
}).filter((s: EmojiSection) => s.data.length);
}, [customEmojis, customEmojisEnabled, isTablet]);
if (imageUrl || file) {
sectionsArray.unshift({
data: [[{
aliases: [],
name: imageUrl || file?.name || '',
short_name: imageUrl || file?.name || '',
category: 'image',
}]],
defaultMessage: 'Default',
icon: 'bookmark-outline',
id: 'emoji_picker.default',
key: 'default',
renderItem: ({item}: ListRenderItemInfo<EmojiAlias[]>) => {
return (
<ImageEmoji
file={file}
onEmojiPress={onEmojiPress}
imageUrl={imageUrl}
path={item[0].name}
/>
);
},
});
}
return sectionsArray;
}, [customEmojis, customEmojisEnabled, isTablet, imageUrl, file]);
useEffect(() => {
setEmojiCategoryBarIcons(sections.map((s) => ({

View file

@ -42,7 +42,7 @@ const SectionHeader = ({section}: Props) => {
<FormattedText
style={styles.sectionTitle}
id={section.id}
defaultMessage={section.icon}
defaultMessage={section.defaultMessage}
/>
</View>
);

View file

@ -78,6 +78,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.CHANNEL:
screen = withServerDatabase(require('@screens/channel').default);
break;
case Screens.CHANNEL_BOOKMARK:
screen = withServerDatabase(require('@screens/channel_bookmark').default);
break;
case Screens.CHANNEL_NOTIFICATION_PREFERENCES:
screen = withServerDatabase(require('@screens/channel_notification_preferences').default);
break;
@ -268,6 +271,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.CALL:
screen = withServerDatabase(require('@calls/screens/call_screen').default);
break;
case Screens.GENERIC_OVERLAY:
screen = withServerDatabase(require('@screens/overlay').default);
break;
}
if (screen) {

View file

@ -729,7 +729,7 @@ export function setButtons(componentId: AvailableScreens, buttons: NavButtons =
mergeNavigationOptions(componentId, options);
}
export function showOverlay(name: AvailableScreens, passProps = {}, options: Options = {}) {
export function showOverlay(name: AvailableScreens, passProps = {}, options: Options = {}, id?: string) {
if (!isScreenRegistered(name)) {
return;
}
@ -746,6 +746,7 @@ export function showOverlay(name: AvailableScreens, passProps = {}, options: Opt
Navigation.showOverlay({
component: {
id,
name,
passProps,
options: merge(defaultOptions, options),
@ -753,7 +754,7 @@ export function showOverlay(name: AvailableScreens, passProps = {}, options: Opt
});
}
export async function dismissOverlay(componentId: AvailableScreens) {
export async function dismissOverlay(componentId: string) {
try {
await Navigation.dismissOverlay(componentId);
} catch (error) {

View file

@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {type ReactNode} from 'react';
type Props = {
children: ReactNode;
}
const Overlay = ({children}: Props) => {
return children;
};
export default Overlay;

View file

@ -260,7 +260,7 @@ export default class FilePickerUtil {
};
private buildUri = async (doc: DocumentPickerResponse) => {
let uri: string = doc.uri;
let uri: string = doc.fileCopyUri || doc.uri;
if (Platform.OS === 'android') {
if (doc.fileCopyUri) {
@ -273,10 +273,9 @@ export default class FilePickerUtil {
return {doc: undefined};
}
}
doc.uri = uri;
}
doc.uri = uri;
return {doc};
};
@ -329,10 +328,13 @@ export default class FilePickerUtil {
);
await this.prepareFileUpload(docs);
return {error: undefined};
} catch (error) {
// Do nothing
return {error};
}
}
return {error: 'no permission'};
};
attachFileFromPhotoGallery = async (selectionLimit = 1) => {

View file

@ -332,7 +332,7 @@ export function getFormattedFileSize(bytes: number): string {
return `${bytes} B`;
}
export function getFileType(file: FileInfo): string {
export function getFileType(file: FileInfo | ExtractedFileInfo): string {
if (!file || !file.extension) {
return 'other';
}

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import {DeviceEventEmitter, Keyboard, NativeModules, Platform} from 'react-native';
import {DeviceEventEmitter, Image, Keyboard, NativeModules, Platform} from 'react-native';
import {Navigation, type Options, type OptionsLayout} from 'react-native-navigation';
import {measure, type AnimatedRef} from 'react-native-reanimated';
@ -176,3 +176,9 @@ export const workletNoopTrue = () => {
return true;
};
export const getImageSize = (uri: string) => {
return new Promise<{width: number; height: number}>((resolve, reject) => {
Image.getSize(uri, (width, height) => resolve({width, height}), reject);
});
};

View file

@ -1,11 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import htmlParser from 'fast-html-parser';
import {decode} from 'html-entities';
import urlParse from 'url-parse';
export type BestImage = {
secure_url?: string;
url?: string;
};
export type OpenGraph = {
link: string;
title?: string;
imageURL?: string;
favIcon?: string;
error?: any;
}
type LinkRelIcon = {
href: string;
sizes?: string;
}
const metaTags: Record<string, string> = {
title: 'title',
description: 'description',
ogUrl: 'og:url',
ogType: 'og:type',
ogTitle: 'og:title',
ogDescription: 'og:description',
ogImage: 'og:image',
ogVideo: 'og:video',
ogVideoType: 'og:video:type',
ogVideoWidth: 'og:video:width',
ogVideoHeight: 'og:video:height',
ogVideoUrl: 'og:video:url',
twitterPlayer: 'twitter:player',
twitterPlayerWidth: 'twitter:player:width',
twitterPlayerHeight: 'twitter:player:height',
twitterPlayerStream: 'twitter:player:stream',
twitterCard: 'twitter:card',
twitterDomain: 'twitter:domain',
twitterUrl: 'twitter:url',
twitterTitle: 'twitter:title',
twitterDescription: 'twitter:description',
twitterImage: 'twitter:image',
};
export function getDistanceBW2Points(point1: Record<string, any>, point2: Record<string, any>, xAttr = 'x', yAttr = 'y') {
return Math.sqrt(Math.pow(point1[xAttr] - point2[xAttr], 2) + Math.pow(point1[yAttr] - point2[yAttr], 2));
}
@ -26,3 +68,122 @@ export function getNearestPoint(pivotPoint: {height: number; width: number}, poi
}
return nearestPoint as BestImage;
}
const fetchRaw = async (url: string) => {
try {
const res = await fetch(url, {
headers: {
'User-Agent': 'OpenGraph',
'Cache-Control': 'no-cache',
Accept: '*/*',
Connection: 'keep-alive',
},
});
if (!res.ok) {
return res;
}
return (await res.text()) as any;
} catch (error: any) {
return {message: error.message};
}
};
const getFavIcon = (url: string, html: string) => {
const getSize = (el: LinkRelIcon) => {
return (el.sizes && el.sizes[0] && parseInt(el.sizes[0], 10)) || 0;
};
const root = htmlParser.parse(html);
const icons = (root.querySelectorAll('link')).reduce<LinkRelIcon[]>((r, e) => {
if (e.attributes.rel === 'icon' || e.attributes.rel === 'shortcut icon') {
const {href, sizes} = e.attributes;
r.push({href, sizes});
}
return r;
}, []).sort((a, b) => {
return getSize(b) - getSize(a);
});
if (icons.length) {
const icon = icons[0].href;
let parsed = urlParse(icon);
if (!parsed.host) {
parsed = urlParse(url);
return `${parsed.protocol}//${parsed.host}${icon}`;
}
return icon;
}
const parsed = urlParse(url);
return `${parsed.protocol}//${parsed.host}/favicon.ico`;
};
export const fetchOpenGraph = async (url: string, includeFavIcon = false): Promise<OpenGraph> => {
const {
ogTitle,
ogImage,
} = metaTags;
try {
const html = await fetchRaw(url);
if (html.message) {
throw Error(html.message);
}
let siteTitle = '';
const tagTitle = html.match(
/<title[^>]*>[\r\n\t\s]*([^<]+)[\r\n\t\s]*<\/title>/gim,
);
siteTitle = tagTitle[0].replace(
/<title[^>]*>[\r\n\t\s]*([^<]+)[\r\n\t\s]*<\/title>/gim,
'$1',
);
const og = [];
const metas: any = html.match(/<meta[^>]+>/gim);
// There is no else statement
/* istanbul ignore else */
if (metas) {
for (let meta of metas) {
meta = meta.replace(/\s*\/?>$/, ' />');
const zname = meta.replace(/[\s\S]*(property|name)\s*=\s*([\s\S]+)/, '$2');
const name = (/^["']/).test(zname) ? zname.substr(1, zname.slice(1).indexOf(zname[0])) : zname.substr(0, zname.search(/[\s\t]/g));
const valid = Boolean(Object.keys(metaTags).filter((m: any) => metaTags[m].toLowerCase() === name.toLowerCase()).length);
// There is no else statement
/* istanbul ignore else */
if (valid) {
const zcontent = meta.replace(/[\s\S]*(content)\s*=\s*([\s\S]+)/, '$2');
const content = (/^["']/).test(zcontent) ? zcontent.substr(1, zcontent.slice(1).indexOf(zcontent[0])) : zcontent.substr(0, zcontent.search(/[\s\t]/g));
og.push({name, value: content === 'undefined' ? null : content});
}
}
}
const result: OpenGraph = {link: url};
const data = og.reduce(
(chain: any, meta: any) => ({...chain, [meta.name]: decode(meta.value)}),
{url},
);
// Image
result.imageURL = data[ogImage] ? data[ogImage] : null;
result.favIcon = includeFavIcon ? getFavIcon(url, html) : undefined;
// Title
data[ogTitle] = data[ogTitle] ? data[ogTitle] : siteTitle;
result.title = data[ogTitle];
return result;
} catch (error: any) {
return {
link: url,
error,
};
}
};

View file

@ -19,7 +19,7 @@ export function isValidUrl(url = '') {
export function sanitizeUrl(url: string, useHttp = false) {
let preUrl = urlParse(url, true);
let protocol = preUrl.protocol;
let protocol = useHttp ? 'http:' : preUrl.protocol;
if (!preUrl.host || preUrl.protocol === 'file:') {
preUrl = urlParse('https://' + stripTrailingSlashes(url), true);
@ -34,6 +34,18 @@ export function sanitizeUrl(url: string, useHttp = false) {
);
}
export async function getUrlAfterRedirect(url: string, useHttp = false) {
const link = sanitizeUrl(url, useHttp);
try {
const result = await fetch(link, {
method: 'HEAD',
});
return {url: result.url};
} catch (error) {
return {error};
}
}
export async function getServerUrlAfterRedirect(serverUrl: string, useHttp = false) {
let url = sanitizeUrl(serverUrl, useHttp);

View file

@ -96,6 +96,28 @@
"camera_type.video.option": "Record Video",
"center_panel.archived.closeChannel": "Close Channel",
"channel_add_members.add_members.button": "Add Members",
"channel_bookmark_add.link": "Link",
"channel_bookmark_add.link.input.description": "Add a link to any post, file, or any external link",
"channel_bookmark_add.link.invalid": "Please enter a valid link",
"channel_bookmark.add_edit.failed_desc": "Details: {error}",
"channel_bookmark.add.detail_title": "Title",
"channel_bookmark.add.emoji": "Add emoji",
"channel_bookmark.add.failed_title": "Error adding bookmark",
"channel_bookmark.add.file_cancel": "Cancel",
"channel_bookmark.add.file_title": "Attachment",
"channel_bookmark.add.file_upload_error": "Error uploading file. Please try again.",
"channel_bookmark.add.file_uploading": "Uploading... ({progress}%)",
"channel_bookmark.copy_option": "Copy Link",
"channel_bookmark.delete_option": "Delete",
"channel_bookmark.delete.confirm": "You sure want to delete the bookmark {displayName}?",
"channel_bookmark.delete.confirm_title": "Delete bookmark",
"channel_bookmark.delete.failed_detail": "Details: {error}",
"channel_bookmark.delete.failed_title": "Error deleting bookmark",
"channel_bookmark.delete.yes": "Yes",
"channel_bookmark.edit_option": "Edit",
"channel_bookmark.edit.failed_title": "Error editing bookmark",
"channel_bookmark.edit.save_button": "Save",
"channel_bookmark.share_option": "Share",
"channel_files.empty.paragraph": "Files posted in this channel will show here.",
"channel_files.empty.title": "No files yet",
"channel_files.noFiles.paragraph": "This channel doesn't contain any files with the applied filters",
@ -103,6 +125,10 @@
"channel_header.directchannel.you": "{displayName} (you)",
"channel_header.info": "View info",
"channel_header.member_count": "{count, plural, one {# member} other {# members}}",
"channel_info.add_bookmark": "Add a bookmark",
"channel_info.add_bookmark.file": "Attach a file",
"channel_info.add_bookmark.link": "Add a link",
"channel_info.add_bookmark.max_reached": "This channel has reached the maximum number of bookmarks.",
"channel_info.add_members": "Add members",
"channel_info.alert_retry": "Try Again",
"channel_info.alertNo": "No",
@ -780,7 +806,6 @@
"notification_settings.mention.reply": "Send reply notifications for",
"notification_settings.mentions": "Mentions",
"notification_settings.mentions_replies": "Mentions and Replies",
"notification_settings.mentions..keywordsDescription": "Other words that trigger a mention",
"notification_settings.mentions.channelWide": "Channel-wide mentions",
"notification_settings.mentions.keywords": "Enter other keywords",
"notification_settings.mentions.keywords_mention": "Keywords that trigger mentions",
@ -927,6 +952,8 @@
"screen.search.results.filter.title": "Filter by file type",
"screen.search.results.filter.videos": "Videos",
"screen.search.title": "Search",
"screens.channel_bookmark_add": "Add a bookmark",
"screens.channel_bookmark_edit": "Edit bookmark",
"screens.channel_edit": "Edit Channel",
"screens.channel_edit_header": "Edit Channel Header",
"screens.channel_info": "Channel Info",

View file

@ -42,8 +42,6 @@
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>

Some files were not shown because too many files have changed in this diff Show more