This commit is contained in:
Julian Mondragon 2023-01-15 16:40:21 -05:00
commit 5816406040
197 changed files with 3961 additions and 2351 deletions

View file

@ -39,7 +39,7 @@ commands:
steps:
- add_ssh_keys:
fingerprints:
- "59:4d:99:5e:1c:6d:30:36:6d:60:76:88:ff:a7:ab:63"
- "03:1c:a7:07:35:bc:57:e4:1d:6c:e1:2c:4b:be:09:6d"
- run:
name: Clone the mobile private repo
command: git clone git@github.com:mattermost/mattermost-mobile-private.git ~/mattermost-mobile-private
@ -309,13 +309,13 @@ jobs:
- save:
filename: "*.apk"
# build-android-release:
# executor: android
# steps:
# - build-android
# - persist
# - save:
# filename: "*.apk"
build-android-release:
executor: android
steps:
- build-android
- persist
- save:
filename: "*.apk"
build-android-pr:
executor: android
@ -326,27 +326,27 @@ jobs:
- save:
filename: "*.apk"
# build-android-unsigned:
# executor: android
# steps:
# - checkout:
# path: ~/mattermost-mobile
# - npm-dependencies
# - assets
# - fastlane-dependencies:
# for: android
# - gradle-dependencies
# - run:
# name: Jetify Android libraries
# command: ./node_modules/.bin/jetify
# - run:
# working_directory: fastlane
# name: Run fastlane to build unsigned android
# no_output_timeout: 30m
# command: bundle exec fastlane android unsigned
# - persist
# - save:
# filename: "*.apk"
build-android-unsigned:
executor: android
steps:
- checkout:
path: ~/mattermost-mobile
- npm-dependencies
- assets
- fastlane-dependencies:
for: android
- gradle-dependencies
- run:
name: Jetify Android libraries
command: ./node_modules/.bin/jetify
- run:
working_directory: fastlane
name: Run fastlane to build unsigned android
no_output_timeout: 30m
command: bundle exec fastlane android unsigned
- persist
- save:
filename: "*.apk"
build-ios-beta:
executor:
@ -358,13 +358,13 @@ jobs:
- save:
filename: "*.ipa"
# build-ios-release:
# executor: ios
# steps:
# - build-ios
# - persist
# - save:
# filename: "*.ipa"
build-ios-release:
executor: ios
steps:
- build-ios
- persist
- save:
filename: "*.ipa"
build-ios-pr:
executor: ios
@ -375,63 +375,64 @@ jobs:
- save:
filename: "*.ipa"
# build-ios-unsigned:
# executor: ios
# steps:
# - checkout:
# path: ~/mattermost-mobile
# - npm-dependencies
# - pods-dependencies
# - assets
# - fastlane-dependencies:
# for: ios
# - run:
# working_directory: fastlane
# name: Run fastlane to build unsigned iOS
# no_output_timeout: 30m
# command: |
# HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
# bundle exec fastlane ios unsigned
# - persist_to_workspace:
# root: ~/
# paths:
# - mattermost-mobile/*.ipa
# - save:
# filename: "*.ipa"
build-ios-unsigned:
executor: ios
steps:
- checkout:
path: ~/mattermost-mobile
- npm-dependencies
- pods-dependencies
- assets
- fastlane-dependencies:
for: ios
- run:
working_directory: fastlane
name: Run fastlane to build unsigned iOS
no_output_timeout: 30m
command: |
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
bundle exec fastlane ios unsigned
- persist_to_workspace:
root: ~/
paths:
- mattermost-mobile/*.ipa
- save:
filename: "*.ipa"
# build-ios-simulator:
# executor: ios
# steps:
# - checkout:
# path: ~/mattermost-mobile
# - npm-dependencies
# - pods-dependencies
# - assets
# - fastlane-dependencies:
# for: ios
# - run:
# working_directory: fastlane
# name: Run fastlane to build unsigned x86_64 iOS app for iPhone simulator
# no_output_timeout: 30m
# command: |
# HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
# bundle exec fastlane ios simulator
# - persist_to_workspace:
# root: ~/
# paths:
# - mattermost-mobile/Mattermost-simulator-x86_64.app.zip
# - save:
# filename: "Mattermost-simulator-x86_64.app.zip"
build-ios-simulator:
executor: ios
steps:
- checkout:
path: ~/mattermost-mobile
- npm-dependencies
- pods-dependencies
- assets
- fastlane-dependencies:
for: ios
- run:
working_directory: fastlane
name: Run fastlane to build unsigned x86_64 iOS app for iPhone simulator
no_output_timeout: 30m
command: |
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
bundle exec fastlane ios simulator
- persist_to_workspace:
root: ~/
paths:
- mattermost-mobile/Mattermost-simulator-x86_64.app.zip
- save:
filename: "Mattermost-simulator-x86_64.app.zip"
# deploy-android-release:
# executor:
# name: android
# resource_class: medium
# steps:
# - deploy-to-store:
# task: "Deploy to Google Play"
# target: android
# file: "*.apk"
deploy-android-release:
executor:
name: android
resource_class: medium
steps:
- deploy-to-store:
task: "Deploy to Google Play"
target: android
file: "*.apk"
env: "SUPPLY_TRACK=beta"
deploy-android-beta:
executor:
@ -444,13 +445,14 @@ jobs:
file: "*.apk"
env: "SUPPLY_TRACK=alpha"
# deploy-ios-release:
# executor: ios
# steps:
# - deploy-to-store:
# task: "Deploy to TestFlight"
# target: ios
# file: "*.ipa"
deploy-ios-release:
executor: ios
steps:
- deploy-to-store:
task: "Deploy to TestFlight"
target: ios
file: "*.ipa"
env: ""
deploy-ios-beta:
executor: ios
@ -461,17 +463,17 @@ jobs:
file: "*.ipa"
env: ""
# github-release:
# executor:
# name: android
# resource_class: medium
# steps:
# - attach_workspace:
# at: ~/
# - run:
# name: Create GitHub release
# working_directory: fastlane
# command: bundle exec fastlane github
github-release:
executor:
name: android
resource_class: medium
steps:
- attach_workspace:
at: ~/
- run:
name: Create GitHub release
working_directory: fastlane
command: bundle exec fastlane github
workflows:
version: 2
@ -483,26 +485,24 @@ workflows:
# requires:
# - test
# - build-android-release:
# context: mattermost-mobile-android-release
# requires:
# - test
# filters:
# branches:
# only:
# - /^build-\d+$/
# - /^build-android-\d+$/
# - /^build-android-release-\d+$/
# - deploy-android-release:
# context: mattermost-mobile-android-release
# requires:
# - build-android-release
# filters:
# branches:
# only:
# - /^build-\d+$/
# - /^build-android-\d+$/
# - /^build-android-release-\d+$/
- build-android-release:
context: mattermost-mobile-android-release
requires:
- test
filters:
branches:
only:
- /^build-release-\d+$/
- /^build-android-release-\d+$/
- deploy-android-release:
context: mattermost-mobile-android-release
requires:
- build-android-release
filters:
branches:
only:
- /^build-release-\d+$/
- /^build-android-release-\d+$/
- build-android-beta:
context: mattermost-mobile-android-beta
@ -523,26 +523,24 @@ workflows:
- /^build-android-\d+$/
- /^build-android-beta-\d+$/
# - build-ios-release:
# context: mattermost-mobile-ios-release
# requires:
# - test
# filters:
# branches:
# only:
# - /^build-\d+$/
# - /^build-ios-\d+$/
# - /^build-ios-release-\d+$/
# - deploy-ios-release:
# context: mattermost-mobile-ios-release
# requires:
# - build-ios-release
# filters:
# branches:
# only:
# - /^build-\d+$/
# - /^build-ios-\d+$/
# - /^build-ios-release-\d+$/
- build-ios-release:
context: mattermost-mobile-ios-release
requires:
- test
filters:
branches:
only:
- /^build-release-\d+$/
- /^build-ios-release-\d+$/
- deploy-ios-release:
context: mattermost-mobile-ios-release
requires:
- build-ios-release
filters:
branches:
only:
- /^build-release-\d+$/
- /^build-ios-release-\d+$/
- build-ios-beta:
context: mattermost-mobile-ios-beta
@ -578,43 +576,41 @@ workflows:
branches:
only: /^(build|ios)-pr-.*/
# - build-android-unsigned:
# context: mattermost-mobile-unsigned
# requires:
# - test
# filters:
# tags:
# only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
# branches:
# only: unsigned
# - build-ios-unsigned:
# context: mattermost-mobile-unsigned
# requires:
# - test
# filters:
# tags:
# only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
# branches:
# only: unsigned
# - build-ios-simulator:
# context: mattermost-mobile-unsigned
# requires:
# - test
# filters:
# branches:
# only:
# - /^build-\d+$/
# - /^build-ios-\d+$/
# - /^build-ios-beta-\d+$/
# - /^build-ios-sim-\d+$/
- build-android-unsigned:
context: mattermost-mobile-unsigned
requires:
- test
filters:
tags:
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
branches:
only: unsigned
- build-ios-unsigned:
context: mattermost-mobile-unsigned
requires:
- test
filters:
tags:
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
branches:
only: unsigned
- build-ios-simulator:
context: mattermost-mobile-unsigned
requires:
- test
filters:
branches:
only:
- /^build-\d+$/
- /^build-ios-sim-\d+$/
# - github-release:
# context: mattermost-mobile-unsigned
# requires:
# - build-android-unsigned
# - build-ios-unsigned
# filters:
# tags:
# only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
# branches:
# only: unsigned
- github-release:
context: mattermost-mobile-unsigned
requires:
- build-android-unsigned
- build-ios-unsigned
filters:
tags:
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
branches:
only: unsigned

View file

@ -2962,28 +2962,6 @@ IN THE SOFTWARE.
"""
---
## reanimated-bottom-sheet
This product contains a modified version of 'reanimated-bottom-sheet' by Michał Osadnik.
Highly configurable component imitating native bottom sheet behavior, with fully native 60 FPS animations!
* HOMEPAGE:
* https://github.com/osdnk/react-native-reanimated-bottom-sheet
* LICENSE: MIT
Copyright 2019 present Michał Osadnik
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
---
## semver

View file

@ -145,7 +145,7 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 446
versionCode 452
versionName "2.0.0"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'

View file

@ -6,6 +6,7 @@ import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.text.TextUtils;
import android.webkit.MimeTypeMap;
import androidx.annotation.NonNull;
@ -20,8 +21,12 @@ import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.mattermost.helpers.Credentials;
import com.reactlibrary.createthumbnail.CreateThumbnailModule;
import com.mattermost.helpers.RealPathUtil;
import java.io.File;
@ -29,6 +34,7 @@ import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.net.URL;
import java.nio.channels.FileChannel;
public class MattermostManagedModule extends ReactContextBaseJavaModule {
@ -206,6 +212,30 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule {
}
}
@ReactMethod
public void createThumbnail(ReadableMap options, Promise promise) {
try {
WritableMap optionsMap = Arguments.createMap();
optionsMap.merge(options);
String url = options.hasKey("url") ? options.getString("url") : "";
URL videoUrl = new URL(url);
String serverUrl = videoUrl.getProtocol() + "://" + videoUrl.getHost() + ":" + videoUrl.getPort();
String token = Credentials.getCredentialsForServerSync(this.reactContext, serverUrl);
if (!TextUtils.isEmpty(token)) {
WritableMap headers = Arguments.createMap();
if (optionsMap.hasKey("headers")) {
headers.merge(optionsMap.getMap("headers"));
}
headers.putString("Authorization", "Bearer " + token);
optionsMap.putMap("headers", headers);
}
CreateThumbnailModule thumb = new CreateThumbnailModule(this.reactContext);
thumb.create(optionsMap.copy(), promise);
} catch (Exception e) {
promise.reject("CreateThumbnail_ERROR", e);
}
}
private static class SaveDataTask extends GuardedResultAsyncTask<Object> {
private final WeakReference<Context> weakContext;
private final String fromFile;

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Tutorial} from '@constants';
import {GLOBAL_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {logError} from '@utils/log';
@ -22,16 +23,20 @@ export const storeDeviceToken = async (token: string, prepareRecordsOnly = false
return storeGlobal(GLOBAL_IDENTIFIERS.DEVICE_TOKEN, token, prepareRecordsOnly);
};
export const storeMultiServerTutorial = async (prepareRecordsOnly = false) => {
return storeGlobal(GLOBAL_IDENTIFIERS.MULTI_SERVER_TUTORIAL, 'true', prepareRecordsOnly);
};
export const storeOnboardingViewedValue = async (value = true) => {
return storeGlobal(GLOBAL_IDENTIFIERS.ONBOARDING, value, false);
};
export const storeMultiServerTutorial = async (prepareRecordsOnly = false) => {
return storeGlobal(Tutorial.MULTI_SERVER, 'true', prepareRecordsOnly);
};
export const storeProfileLongPressTutorial = async (prepareRecordsOnly = false) => {
return storeGlobal(GLOBAL_IDENTIFIERS.PROFILE_LONG_PRESS_TUTORIAL, 'true', prepareRecordsOnly);
return storeGlobal(Tutorial.PROFILE_LONG_PRESS, 'true', prepareRecordsOnly);
};
export const storeSkinEmojiSelectorTutorial = async (prepareRecordsOnly = false) => {
return storeGlobal(Tutorial.EMOJI_SKIN_SELECTOR, 'true', prepareRecordsOnly);
};
export const storeDontAskForReview = async (prepareRecordsOnly = false) => {

View file

@ -76,7 +76,7 @@ export async function switchToChannel(serverUrl: string, channelId: string, team
}
models = (await Promise.all(modelPromises)).flat();
const {member: viewedAt} = await markChannelAsViewed(serverUrl, channelId, true);
const {member: viewedAt} = await markChannelAsViewed(serverUrl, channelId, false, true);
if (viewedAt) {
models.push(viewedAt);
}
@ -160,7 +160,7 @@ export async function selectAllMyChannelIds(serverUrl: string) {
}
}
export async function markChannelAsViewed(serverUrl: string, channelId: string, prepareRecordsOnly = false) {
export async function markChannelAsViewed(serverUrl: string, channelId: string, onlyCounts = false, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const member = await getMyChannel(database, channelId);
@ -172,8 +172,10 @@ export async function markChannelAsViewed(serverUrl: string, channelId: string,
m.isUnread = false;
m.mentionsCount = 0;
m.manuallyUnread = false;
m.viewedAt = member.lastViewedAt;
m.lastViewedAt = Date.now();
if (!onlyCounts) {
m.viewedAt = member.lastViewedAt;
m.lastViewedAt = Date.now();
}
});
PushNotifications.removeChannelNotifications(serverUrl, channelId);
if (!prepareRecordsOnly) {

View file

@ -87,10 +87,17 @@ const debouncedFetchEmojiByNames = debounce(async (serverUrl: string) => {
promises.push(client.getCustomEmojiByName(name));
}
const emojis = await Promise.all(promises);
try {
await operator.handleCustomEmojis({emojis, prepareRecordsOnly: false});
const emojisResult = await Promise.allSettled(promises);
const emojis = emojisResult.reduce<CustomEmoji[]>((result, e) => {
if (e.status === 'fulfilled') {
result.push(e.value);
}
return result;
}, []);
if (emojis.length) {
await operator.handleCustomEmojis({emojis, prepareRecordsOnly: false});
}
return {error: undefined};
} catch (error) {
return {error};

View file

@ -8,6 +8,7 @@ import {prepareCommonSystemValues, getCurrentTeamId, getWebSocketLastDisconnecte
import {getCurrentUser} from '@queries/servers/user';
import {setTeamLoading} from '@store/team_load_store';
import {deleteV1Data} from '@utils/file';
import {isTablet} from '@utils/helpers';
import {logInfo} from '@utils/log';
import {handleEntryAfterLoadNavigation, registerDeviceToken, syncOtherServers, verifyPushProxy} from './common';
@ -29,7 +30,7 @@ export async function appEntry(serverUrl: string, since = 0, isUpgrade = false)
// clear lastUnreadChannelId
const removeLastUnreadChannelId = await prepareCommonSystemValues(operator, {lastUnreadChannelId: ''});
if (removeLastUnreadChannelId) {
operator.batchRecords(removeLastUnreadChannelId);
await operator.batchRecords(removeLastUnreadChannelId);
}
const {database} = operator;
@ -47,7 +48,12 @@ export async function appEntry(serverUrl: string, since = 0, isUpgrade = false)
const {models, initialTeamId, initialChannelId, prefData, teamData, chData, meData} = entryData;
if (isUpgrade && meData?.user) {
const me = await prepareCommonSystemValues(operator, {currentUserId: meData.user.id});
const isTabletDevice = await isTablet();
const me = await prepareCommonSystemValues(operator, {
currentUserId: meData.user.id,
currentTeamId: initialTeamId,
currentChannelId: isTabletDevice ? initialChannelId : undefined,
});
if (me?.length) {
await operator.batchRecords(me);
}
@ -84,8 +90,8 @@ export async function upgradeEntry(serverUrl: string) {
const error = configAndLicense.error || entryData.error;
if (!error) {
DatabaseManager.updateServerIdentifier(serverUrl, configAndLicense.config!.DiagnosticId);
DatabaseManager.setActiveServerDatabase(serverUrl);
await DatabaseManager.updateServerIdentifier(serverUrl, configAndLicense.config!.DiagnosticId);
await DatabaseManager.setActiveServerDatabase(serverUrl);
deleteV1Data();
}

View file

@ -436,7 +436,7 @@ export async function fetchPostsBefore(serverUrl: string, channelId: string, pos
await operator.batchRecords(models);
} catch (error) {
logError('FETCH AUTHORS ERROR', error);
logError('FETCH POSTS BEFORE ERROR', error);
}
}
@ -544,9 +544,15 @@ export const fetchPostAuthors = async (serverUrl: string, posts: Post[], fetchOn
}
if (promises.length) {
const result = await Promise.all(promises);
const authors = result.flat();
const authorsResult = await Promise.allSettled(promises);
const result = authorsResult.reduce<UserProfile[][]>((acc, item) => {
if (item.status === 'fulfilled') {
acc.push(item.value);
}
return acc;
}, []);
const authors = result.flat();
if (!fetchOnly && authors.length) {
await operator.handleUsers({
users: authors,

View file

@ -178,3 +178,19 @@ export const setDirectChannelVisible = async (serverUrl: string, channelId: stri
return {error};
}
};
export const savePreferredSkinTone = async (serverUrl: string, skinCode: string) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const userId = await getCurrentUserId(database);
const pref: PreferenceType = {
user_id: userId,
category: Preferences.CATEGORY_EMOJI,
name: Preferences.EMOJI_SKINTONE,
value: skinCode,
};
return savePreference(serverUrl, [pref]);
} catch (error) {
return {error};
}
};

View file

@ -128,7 +128,7 @@ export async function handleChannelViewedEvent(serverUrl: string, msg: any) {
const currentChannelId = await getCurrentChannelId(database);
if (activeServerUrl !== serverUrl || (currentChannelId !== channelId && !EphemeralStore.isSwitchingToChannel(channelId))) {
await markChannelAsViewed(serverUrl, channelId, false);
await markChannelAsViewed(serverUrl, channelId);
}
} catch {
// do nothing

View file

@ -444,7 +444,7 @@ async function fetchPostDataIfNeeded(serverUrl: string) {
await fetchPostsForChannel(serverUrl, currentChannelId);
markChannelAsRead(serverUrl, currentChannelId);
if (!EphemeralStore.wasNotificationTapped()) {
markChannelAsViewed(serverUrl, currentChannelId);
markChannelAsViewed(serverUrl, currentChannelId, true);
}
EphemeralStore.setNotificationTapped(false);
}

View file

@ -143,7 +143,7 @@ export async function handleNewPostEvent(serverUrl: string, msg: WebSocketMessag
markChannelAsRead(serverUrl, post.channel_id);
} else if (markAsViewed) {
preparedMyChannelHack(myChannel);
const {member: viewedAt} = await markChannelAsViewed(serverUrl, post.channel_id, true);
const {member: viewedAt} = await markChannelAsViewed(serverUrl, post.channel_id, false, true);
if (viewedAt) {
models.push(viewedAt);
}
@ -164,8 +164,13 @@ export async function handleNewPostEvent(serverUrl: string, msg: WebSocketMessag
}
}
let actionType: string = ActionType.POSTS.RECEIVED_NEW;
if (isCRTEnabled && post.root_id) {
actionType = ActionType.POSTS.RECEIVED_IN_THREAD;
}
const postModels = await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
actionType,
order: [post.id],
posts: [post],
prepareRecordsOnly: true,
@ -203,8 +208,14 @@ export async function handlePostEdited(serverUrl: string, msg: WebSocketMessage)
models.push(...authorsModels);
}
let actionType: string = ActionType.POSTS.RECEIVED_NEW;
const isCRTEnabled = await getIsCRTEnabled(operator.database);
if (isCRTEnabled && post.root_id) {
actionType = ActionType.POSTS.RECEIVED_IN_THREAD;
}
const postModels = await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
actionType,
order: [post.id],
posts: [post],
prepareRecordsOnly: true,

View file

@ -2,12 +2,22 @@
// See LICENSE.txt for license information.
import {markTeamThreadsAsRead, processReceivedThreads, updateThread} from '@actions/local/thread';
import {getCurrentTeamId} from '@app/queries/servers/system';
import DatabaseManager from '@database/manager';
import EphemeralStore from '@store/ephemeral_store';
export async function handleThreadUpdatedEvent(serverUrl: string, msg: WebSocketMessage): Promise<void> {
try {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
const thread: Thread = JSON.parse(msg.data.thread);
const teamId = msg.broadcast.team_id;
let teamId = msg.broadcast.team_id;
if (!teamId) {
teamId = await getCurrentTeamId(database);
}
// Mark it as following
thread.is_following = true;

View file

@ -33,6 +33,7 @@ export default class WebSocketClient {
private firstConnectCallback?: () => void;
private missedEventsCallback?: () => void;
private reconnectCallback?: () => void;
private reliableReconnectCallback?: () => void;
private errorCallback?: Function;
private closeCallback?: (connectFailCount: number, lastDisconnect: number) => void;
private connectingCallback?: () => void;
@ -148,8 +149,11 @@ export default class WebSocketClient {
logInfo('websocket re-established connection to', this.url);
if (!reliableWebSockets && this.reconnectCallback) {
this.reconnectCallback();
} else if (reliableWebSockets && this.serverSequence && this.missedEventsCallback) {
this.missedEventsCallback();
} else if (reliableWebSockets) {
this.reliableReconnectCallback?.();
if (this.serverSequence && this.missedEventsCallback) {
this.missedEventsCallback();
}
}
} else if (this.firstConnectCallback) {
logInfo('websocket connected to', this.url);
@ -295,6 +299,10 @@ export default class WebSocketClient {
this.reconnectCallback = callback;
}
public setReliableReconnectCallback(callback: () => void) {
this.reliableReconnectCallback = callback;
}
public setErrorCallback(callback: Function) {
this.errorCallback = callback;
}

View file

@ -9,6 +9,7 @@ import {
View,
} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {dismissAnnouncement} from '@actions/local/systems';
import CompassIcon from '@components/compass_icon';
@ -17,6 +18,7 @@ import {ANNOUNCEMENT_BAR_HEIGHT} from '@constants/view';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {bottomSheet} from '@screens/navigation';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {getMarkdownTextStyles} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -82,6 +84,7 @@ const AnnouncementBanner = ({
const intl = useIntl();
const serverUrl = useServerUrl();
const height = useSharedValue(0);
const {bottom} = useSafeAreaInsets();
const theme = useTheme();
const [visible, setVisible] = useState(false);
const style = getStyle(theme);
@ -100,19 +103,20 @@ const AnnouncementBanner = ({
defaultMessage: 'Announcement',
});
let snapPoint = SNAP_POINT_WITHOUT_DISMISS;
if (allowDismissal) {
snapPoint += DISMISS_BUTTON_HEIGHT;
}
const snapPoint = bottomSheetSnapPoint(
1,
SNAP_POINT_WITHOUT_DISMISS + (allowDismissal ? DISMISS_BUTTON_HEIGHT : 0),
bottom,
);
bottomSheet({
closeButtonId: CLOSE_BUTTON_ID,
title,
renderContent,
snapPoints: [snapPoint, 10],
snapPoints: [1, snapPoint],
theme,
});
}, [theme.sidebarHeaderTextColor, intl.locale, renderContent, allowDismissal]);
}, [theme.sidebarHeaderTextColor, intl.locale, renderContent, allowDismissal, bottom]);
const handleDismiss = useCallback(() => {
dismissAnnouncement(serverUrl, bannerText);

View file

@ -1,11 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BottomSheetScrollView} from '@gorhom/bottom-sheet';
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {Text, View} from 'react-native';
import {ScrollView, Text, View} from 'react-native';
import Button from 'react-native-button';
import {ScrollView} from 'react-native-gesture-handler';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {dismissAnnouncement} from '@actions/local/systems';
@ -84,6 +84,8 @@ const ExpandedAnnouncementBanner = ({
return [style.container, {marginBottom: insets.bottom + 10}];
}, [style, insets.bottom]);
const Scroll = useMemo(() => (isTablet ? ScrollView : BottomSheetScrollView), [isTablet]);
return (
<View style={containerStyle}>
{!isTablet && (
@ -94,7 +96,7 @@ const ExpandedAnnouncementBanner = ({
})}
</Text>
)}
<ScrollView
<Scroll
style={style.scrollContainer}
>
<Markdown
@ -106,7 +108,7 @@ const ExpandedAnnouncementBanner = ({
theme={theme}
location={Screens.BOTTOM_SHEET}
/>
</ScrollView>
</Scroll>
<Button
containerStyle={buttonStyles.okay.button}
onPress={close}

View file

@ -546,7 +546,7 @@ export class ParsedCommand {
this.incomplete += c;
this.i++;
if (escaped) {
//TODO: handle \n, \t, other escaped chars
//TODO: handle \n, \t, other escaped chars https://mattermost.atlassian.net/browse/MM-43476
escaped = false;
}
break;
@ -735,7 +735,7 @@ export class ParsedCommand {
this.incomplete += c;
this.i++;
if (escaped) {
//TODO: handle \n, \t, other escaped chars
//TODO: handle \n, \t, other escaped chars https://mattermost.atlassian.net/browse/MM-43476
escaped = false;
}
break;
@ -1079,7 +1079,7 @@ export class AppCommandParser {
}
// Add "Execute Current Command" suggestion
// TODO get full text from SuggestionBox
// TODO get full text from SuggestionBox https://mattermost.atlassian.net/browse/MM-43477
const executableStates: string[] = [
ParseState.EndCommand,
ParseState.CommandSeparator,

View file

@ -19,7 +19,7 @@ import IntegrationsManager from '@managers/integrations_manager';
import {AppCommandParser} from './app_command_parser/app_command_parser';
import SlashSuggestionItem from './slash_suggestion_item';
// TODO: Remove when all below commands have been implemented
// TODO: Remove when all below commands have been implemented https://mattermost.atlassian.net/browse/MM-43478
const COMMANDS_TO_IMPLEMENT_LATER = ['collapse', 'expand', 'logout'];
const NON_MOBILE_COMMANDS = ['shortcuts', 'search', 'settings'];

View file

@ -24,12 +24,12 @@ type Props = {
testID?: string;
}
const OPTIONS_HEIGHT = 62;
export const CHANNEL_ACTIONS_OPTIONS_HEIGHT = 62;
const styles = StyleSheet.create({
wrapper: {
flexDirection: 'row',
height: OPTIONS_HEIGHT,
height: CHANNEL_ACTIONS_OPTIONS_HEIGHT,
},
separator: {
width: 8,

View file

@ -106,6 +106,7 @@ const ConnectionBanner = ({
}
return () => {
clearTimeoutRef(openTimeout);
clearTimeoutRef(closeTimeout);
};
}, []);
@ -125,7 +126,7 @@ const ConnectionBanner = ({
}
}, [isConnected]);
useEffect(() => {
useDidUpdate(() => {
if (appState === 'active') {
if (!isConnected && !visible) {
if (!openTimeout.current) {
@ -138,10 +139,11 @@ const ConnectionBanner = ({
}
}
} else {
setVisible(false);
clearTimeoutRef(openTimeout);
clearTimeoutRef(closeTimeout);
}
}, [appState]);
}, [appState === 'active']);
useEffect(() => {
height.value = withTiming(visible ? ANNOUNCEMENT_BAR_HEIGHT : 0, {
@ -149,12 +151,6 @@ const ConnectionBanner = ({
});
}, [visible]);
useEffect(() => {
return () => {
clearTimeoutRef(closeTimeout);
};
});
const bannerStyle = useAnimatedStyle(() => ({
height: height.value,
}));

View file

@ -57,7 +57,7 @@ const Emoji = (props: EmojiProps) => {
} catch {
// do nothing
}
} else if (name && !isUnicodeEmoji(name)) {
} else if (name && (name.length > 1 || !isUnicodeEmoji(name))) {
fetchCustomEmojiInBatch(serverUrl, name);
}
}

View file

@ -1,76 +0,0 @@
// 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 {KeyboardTrackingView} from 'react-native-keyboard-tracking-view';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import SectionIcon from './icon';
export const SCROLLVIEW_NATIVE_ID = 'emojiSelector';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
bottom: 10,
height: 35,
position: 'absolute',
width: '100%',
},
background: {
backgroundColor: theme.centerChannelBg,
},
pane: {
flexDirection: 'row',
borderRadius: 10,
paddingHorizontal: 10,
width: '100%',
borderColor: changeOpacity(theme.centerChannelColor, 0.3),
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
borderWidth: 1,
justifyContent: 'space-between',
},
}));
export type SectionIconType = {
key: string;
icon: string;
}
type Props = {
currentIndex: number;
sections: SectionIconType[];
scrollToIndex: (index: number) => void;
}
const EmojiSectionBar = ({currentIndex, sections, scrollToIndex}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<KeyboardTrackingView
scrollViewNativeID={SCROLLVIEW_NATIVE_ID}
normalList={true}
style={styles.container}
testID='emoji_picker.emoji_sections.section_bar'
>
<View style={styles.background}>
<View style={styles.pane}>
{sections.map((section, index) => (
<SectionIcon
currentIndex={currentIndex}
key={section.key}
icon={section.icon}
index={index}
scrollToIndex={scrollToIndex}
theme={theme}
/>
))}
</View>
</View>
</KeyboardTrackingView>
);
};
export default EmojiSectionBar;

View file

@ -1,35 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {StyleProp, ViewStyle} from 'react-native';
import Emoji from '@components/emoji';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {preventDoubleTap} from '@utils/tap';
type Props = {
name: string;
onEmojiPress: (emoji: string) => void;
size?: number;
style: StyleProp<ViewStyle>;
}
const TouchableEmoji = ({name, onEmojiPress, size = 30, style}: Props) => {
const onPress = useCallback(preventDoubleTap(() => onEmojiPress(name)), []);
return (
<TouchableWithFeedback
onPress={onPress}
style={style}
type={'opacity'}
>
<Emoji
emojiName={name}
size={size}
/>
</TouchableWithFeedback>
);
};
export default React.memo(TouchableEmoji);

View file

@ -2,11 +2,10 @@
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {StyleSheet, useWindowDimensions, View} from 'react-native';
import {createThumbnail} from 'react-native-create-thumbnail';
import {StyleSheet, useWindowDimensions, View, NativeModules} from 'react-native';
import {updateLocalFile} from '@actions/local/file';
import {buildFilePreviewUrl, fetchPublicLink} from '@actions/remote/file';
import {buildFilePreviewUrl, buildFileUrl} from '@actions/remote/file';
import CompassIcon from '@components/compass_icon';
import ProgressiveImage from '@components/progressive_image';
import {useServerUrl} from '@context/server';
@ -18,6 +17,7 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import FileIcon from './file_icon';
import type {ResizeMode} from 'react-native-fast-image';
const {createThumbnail} = NativeModules.MattermostManaged;
type Props = {
index: number;
@ -84,11 +84,9 @@ const VideoFile = ({
try {
const exists = data.mini_preview ? await fileExists(data.mini_preview) : false;
if (!data.mini_preview || !exists) {
// We use the public link to avoid having to pass the token through a third party
// library
const publicUri = await fetchPublicLink(serverUrl, data.id!);
if (('link') in publicUri) {
const {path: uri, height, width} = await createThumbnail({url: data.localPath || publicUri.link, timeStamp: 2000});
const videoUrl = buildFileUrl(serverUrl, data.id!);
if (videoUrl) {
const {path: uri, height, width} = await createThumbnail({url: data.localPath || videoUrl, timeStamp: 2000});
data.mini_preview = uri;
data.height = height;
data.width = width;

View file

@ -69,7 +69,7 @@ const AtMention = ({
const intl = useIntl();
const managedConfig = useManagedConfig<ManagedConfig>();
const theme = useTheme();
const insets = useSafeAreaInsets();
const {bottom} = useSafeAreaInsets();
const serverUrl = useServerUrl();
const user = useMemo(() => {
@ -92,6 +92,7 @@ const AtMention = ({
// @ts-expect-error: The model constructor is hidden within WDB type definition
return new UserModel(database.get(USER), {username: ''});
}, [users, mentionName]);
const userMentionKeys = useMemo(() => {
if (mentionKeys) {
return mentionKeys;
@ -195,12 +196,12 @@ const AtMention = ({
bottomSheet({
closeButtonId: 'close-at-mention',
renderContent,
snapPoints: [bottomSheetSnapPoint(2, ITEM_HEIGHT, insets.bottom), 10],
snapPoints: [1, bottomSheetSnapPoint(2, ITEM_HEIGHT, bottom)],
title: intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}),
theme,
});
}
}, [managedConfig, intl, theme, insets]);
}, [managedConfig, intl, theme, bottom]);
const mentionTextStyle = [];

View file

@ -71,7 +71,7 @@ const MarkdownCodeBlock = ({language = '', content, textStyle}: MarkdownCodeBloc
const intl = useIntl();
const managedConfig = useManagedConfig<ManagedConfig>();
const theme = useTheme();
const insets = useSafeAreaInsets();
const {bottom} = useSafeAreaInsets();
const style = getStyleSheet(theme);
const SyntaxHighlighter = useMemo(() => {
if (!syntaxHighlighter) {
@ -85,7 +85,7 @@ const MarkdownCodeBlock = ({language = '', content, textStyle}: MarkdownCodeBloc
const screen = Screens.CODE;
const passProps = {
code: content,
language,
language: getHighlightLanguageFromNameOrAlias(language),
textStyle,
};
@ -147,12 +147,12 @@ const MarkdownCodeBlock = ({language = '', content, textStyle}: MarkdownCodeBloc
bottomSheet({
closeButtonId: 'close-code-block',
renderContent,
snapPoints: [bottomSheetSnapPoint(2, ITEM_HEIGHT, insets.bottom), 10],
snapPoints: [1, bottomSheetSnapPoint(2, ITEM_HEIGHT, bottom)],
title: intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}),
theme,
});
}
}, [managedConfig, intl, insets, theme]);
}, [managedConfig, intl, bottom, theme]);
const trimContent = (text: string) => {
const lines = text.split('\n');

View file

@ -75,7 +75,7 @@ const MarkdownImage = ({
}: MarkdownImageProps) => {
const intl = useIntl();
const isTablet = useIsTablet();
const insets = useSafeAreaInsets();
const {bottom} = useSafeAreaInsets();
const theme = useTheme();
const style = getStyleSheet(theme);
const managedConfig = useManagedConfig<ManagedConfig>();
@ -181,12 +181,12 @@ const MarkdownImage = ({
bottomSheet({
closeButtonId: 'close-mardown-image',
renderContent,
snapPoints: [bottomSheetSnapPoint(2, ITEM_HEIGHT, insets.bottom), 10],
snapPoints: [1, bottomSheetSnapPoint(2, ITEM_HEIGHT, bottom)],
title: intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}),
theme,
});
}
}, [managedConfig, intl.locale, insets.bottom, theme]);
}, [managedConfig, intl.locale, bottom, theme]);
const handleOnError = useCallback(() => {
setFailed(true);

View file

@ -90,7 +90,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const LatexCodeBlock = ({content, theme}: Props) => {
const intl = useIntl();
const insets = useSafeAreaInsets();
const {bottom} = useSafeAreaInsets();
const managedConfig = useManagedConfig<ManagedConfig>();
const styles = getStyleSheet(theme);
const languageDisplayName = getHighlightLanguageName('latex');
@ -161,12 +161,12 @@ const LatexCodeBlock = ({content, theme}: Props) => {
bottomSheet({
closeButtonId: 'close-code-block',
renderContent,
snapPoints: [bottomSheetSnapPoint(2, ITEM_HEIGHT, insets.bottom), 10],
snapPoints: [1, bottomSheetSnapPoint(2, ITEM_HEIGHT, bottom)],
title: intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}),
theme,
});
}
}, [managedConfig?.copyAndPasteProtection, intl, insets, theme]);
}, [managedConfig?.copyAndPasteProtection, intl, bottom, theme]);
const onRenderErrorMessage = useCallback(({error}: {error: Error}) => {
return <Text style={styles.errorText}>{'Render error: ' + error.message}</Text>;

View file

@ -46,7 +46,7 @@ const parseLinkLiteral = (literal: string) => {
const MarkdownLink = ({children, experimentalNormalizeMarkdownLinks, href, siteURL}: MarkdownLinkProps) => {
const intl = useIntl();
const insets = useSafeAreaInsets();
const {bottom} = useSafeAreaInsets();
const managedConfig = useManagedConfig<ManagedConfig>();
const serverUrl = useServerUrl();
const theme = useTheme();
@ -140,12 +140,12 @@ const MarkdownLink = ({children, experimentalNormalizeMarkdownLinks, href, siteU
bottomSheet({
closeButtonId: 'close-mardown-link',
renderContent,
snapPoints: [bottomSheetSnapPoint(2, ITEM_HEIGHT, insets.bottom), 10],
snapPoints: [1, bottomSheetSnapPoint(2, ITEM_HEIGHT, bottom)],
title: intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}),
theme,
});
}
}, [managedConfig, intl, insets, theme]);
}, [managedConfig, intl, bottom, theme]);
const renderChildren = experimentalNormalizeMarkdownLinks ? parseChildren() : children;

View file

@ -69,11 +69,10 @@ export default function CameraQuickAction({
return;
}
const snap = bottomSheetSnapPoint(2, ITEM_HEIGHT, bottom);
bottomSheet({
title: intl.formatMessage({id: 'mobile.camera_type.title', defaultMessage: 'Camera options'}),
renderContent,
snapPoints: [TITLE_HEIGHT + snap, 10],
snapPoints: [1, bottomSheetSnapPoint(2, ITEM_HEIGHT, bottom) + TITLE_HEIGHT],
theme,
closeButtonId: 'camera-close-id',
});

View file

@ -4,13 +4,15 @@
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {StyleSheet} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import CompassIcon from '@components/compass_icon';
import PostPriorityPicker from '@components/post_priority/post_priority_picker';
import PostPriorityPicker, {COMPONENT_HEIGHT} from '@components/post_priority/post_priority_picker';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {bottomSheet, dismissBottomSheet} from '@screens/navigation';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {changeOpacity} from '@utils/theme';
type Props = {
@ -34,6 +36,7 @@ export default function PostPriorityAction({
}: Props) {
const intl = useIntl();
const theme = useTheme();
const {bottom} = useSafeAreaInsets();
const handlePostPriorityPicker = useCallback((postPriorityData: PostPriorityData) => {
updatePostPriority(postPriorityData);
@ -55,11 +58,11 @@ export default function PostPriorityAction({
bottomSheet({
title: intl.formatMessage({id: 'post_priority.picker.title', defaultMessage: 'Message priority'}),
renderContent,
snapPoints: [275, 10],
snapPoints: [1, bottomSheetSnapPoint(1, COMPONENT_HEIGHT, bottom)],
theme,
closeButtonId: 'post-priority-close-id',
});
}, [intl, renderContent, theme]);
}, [intl, renderContent, theme, bottom]);
const iconName = 'alert-circle-outline';
const iconColor = changeOpacity(theme.centerChannelColor, 0.64);

View file

@ -7,22 +7,32 @@ import React from 'react';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
import {observeSavedPostsByIds} from '@queries/servers/post';
import {observeConfigBooleanValue} from '@queries/servers/system';
import {observeCurrentUser} from '@queries/servers/user';
import {mapCustomEmojiNames} from '@utils/emoji/helpers';
import {getTimezone} from '@utils/user';
import PostList from './post_list';
import type {WithDatabaseArgs} from '@typings/database/database';
import type PostModel from '@typings/database/models/servers/post';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const enhanced = withObservables(['posts'], ({database, posts}: {posts: PostModel[]} & WithDatabaseArgs) => {
const currentUser = observeCurrentUser(database);
const postIds = posts.map((p) => p.id);
return {
appsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'),
isTimezoneEnabled: observeConfigBooleanValue(database, 'ExperimentalTimezone'),
currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone || null))))),
currentUserId: currentUser.pipe((switchMap((user) => of$(user?.id)))),
currentUsername: currentUser.pipe((switchMap((user) => of$(user?.username)))),
savedPostIds: observeSavedPostsByIds(database, postIds),
customEmojiNames: queryAllCustomEmojis(database).observe().pipe(
switchMap((customEmojis) => of$(mapCustomEmojiNames(customEmojis))),
),
};
});

View file

@ -17,14 +17,14 @@ import {useIsTablet} from '@hooks/device';
import {makeStyleSheetFromTheme, hexToHue} from '@utils/theme';
import {typography} from '@utils/typography';
import type PostModel from '@typings/database/models/servers/post';
import type {PostList} from '@typings/components/post_list';
type Props = {
channelId: string;
isCRTEnabled?: boolean;
isManualUnread?: boolean;
newMessageLineIndex: number;
posts: Array<string | PostModel>;
posts: PostList;
registerScrollEndIndexListener: (fn: (endIndex: number) => void) => () => void;
registerViewableItemsListener: (fn: (viewableItems: ViewToken[]) => void) => () => void;
rootId?: string;
@ -188,7 +188,7 @@ const MoreMessages = ({
return;
}
const readCount = posts.slice(0, lastViewableIndex).filter((v) => typeof v !== 'string').length;
const readCount = posts.slice(0, lastViewableIndex).filter((v) => v.type === 'post').length;
const totalUnread = localUnreadCount.current - readCount;
if (lastViewableIndex >= newMessageLineIndex) {
resetCount();

View file

@ -9,7 +9,6 @@ import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type NewMessagesLineProps = {
moreMessages: boolean;
style?: StyleProp<ViewStyle>;
theme: Theme;
testID?: string;
@ -39,34 +38,19 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
};
});
function NewMessagesLine({moreMessages, style, testID, theme}: NewMessagesLineProps) {
function NewMessagesLine({style, testID, theme}: NewMessagesLineProps) {
const styles = getStyleFromTheme(theme);
let text = (
<FormattedText
id='posts_view.newMsg'
defaultMessage='New Messages'
style={styles.text}
testID={testID}
/>
);
if (moreMessages) {
text = (
<FormattedText
id='mobile.posts_view.moreMsg'
defaultMessage='More New Messages Above'
style={styles.text}
testID={testID}
/>
);
}
return (
<View style={[styles.container, style]}>
<View style={styles.line}/>
<View style={styles.textContainer}>
{text}
<FormattedText
id='posts_view.newMsg'
defaultMessage='New Messages'
style={styles.text}
testID={testID}
/>
</View>
<View style={styles.line}/>
</View>

View file

@ -33,7 +33,7 @@ const styles = StyleSheet.create({
const Failed = ({post, theme}: FailedProps) => {
const intl = useIntl();
const insets = useSafeAreaInsets();
const {bottom} = useSafeAreaInsets();
const serverUrl = useServerUrl();
const onPress = useCallback(() => {
@ -69,11 +69,11 @@ const Failed = ({post, theme}: FailedProps) => {
bottomSheet({
closeButtonId: 'close-post-failed',
renderContent,
snapPoints: [bottomSheetSnapPoint(2, ITEM_HEIGHT, insets.bottom), 10],
snapPoints: [1, bottomSheetSnapPoint(2, ITEM_HEIGHT, bottom)],
title: intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}),
theme,
});
}, [insets]);
}, [bottom]);
return (
<TouchableOpacity

View file

@ -12,7 +12,7 @@ import {MAX_ALLOWED_REACTIONS} from '@constants/emoji';
import {useServerUrl} from '@context/server';
import {useIsTablet} from '@hooks/device';
import useDidUpdate from '@hooks/did_update';
import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation';
import {bottomSheetModalOptions, openAsBottomSheet, showModal, showModalOverCurrentContext} from '@screens/navigation';
import {getEmojiFirstAlias} from '@utils/emoji/helpers';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -61,7 +61,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
});
const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled, location, postId, reactions, theme}: ReactionsProps) => {
const intl = useIntl();
const {formatMessage} = useIntl();
const serverUrl = useServerUrl();
const isTablet = useIsTablet();
const pressed = useRef(false);
@ -112,16 +112,14 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
};
const handleAddReaction = useCallback(preventDoubleTap(() => {
const title = intl.formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'});
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
const passProps = {
closeButton,
onEmojiPress: handleAddReactionToPost,
};
showModal(Screens.EMOJI_PICKER, title, passProps);
}), [intl, theme]);
openAsBottomSheet({
closeButtonId: 'close-add-reaction',
screen: Screens.EMOJI_PICKER,
theme,
title: formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'}),
props: {onEmojiPress: handleAddReactionToPost},
});
}), [formatMessage, theme]);
const handleReactionPress = useCallback(async (emoji: string, remove: boolean) => {
pressed.current = true;
@ -143,7 +141,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
};
Keyboard.dismiss();
const title = isTablet ? intl.formatMessage({id: 'post.reactions.title', defaultMessage: 'Reactions'}) : '';
const title = isTablet ? formatMessage({id: 'post.reactions.title', defaultMessage: 'Reactions'}) : '';
if (!pressed.current) {
if (isTablet) {
@ -152,7 +150,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
showModalOverCurrentContext(screen, passProps, bottomSheetModalOptions(theme));
}
}
}, [intl, isTablet, location, postId, theme]);
}, [formatMessage, isTablet, location, postId, theme]);
let addMoreReactions = null;
const {reactionsByName, highlightedReactions} = buildReactionsMap();

View file

@ -4,36 +4,32 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React from 'react';
import {of as of$, combineLatest, Observable} from 'rxjs';
import {of as of$, combineLatest} from 'rxjs';
import {switchMap, distinctUntilChanged} from 'rxjs/operators';
import {Permissions, Preferences} from '@constants';
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
import {Permissions, Preferences, Screens} from '@constants';
import {observePostAuthor, queryPostsBetween} from '@queries/servers/post';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {observeCanManageChannelMembers, observePermissionForPost} from '@queries/servers/role';
import {observeIsPostPriorityEnabled, observeConfigBooleanValue} from '@queries/servers/system';
import {observeIsPostPriorityEnabled} from '@queries/servers/system';
import {observeThreadById} from '@queries/servers/thread';
import {observeCurrentUser} from '@queries/servers/user';
import {hasJumboEmojiOnly} from '@utils/emoji/helpers';
import {areConsecutivePosts, isPostEphemeral} from '@utils/post';
import Post from './post';
import type {Database} from '@nozbe/watermelondb';
import type {WithDatabaseArgs} from '@typings/database/database';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
import type PostModel from '@typings/database/models/servers/post';
import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread';
import type UserModel from '@typings/database/models/servers/user';
type PropsInput = WithDatabaseArgs & {
appsEnabled: boolean;
currentUser: UserModel;
isCRTEnabled?: boolean;
nextPost: PostModel | undefined;
post: PostModel;
previousPost: PostModel | undefined;
location: string;
}
function observeShouldHighlightReplyBar(database: Database, currentUser: UserModel, post: PostModel, postsInThread: PostsInThreadModel) {
@ -90,39 +86,35 @@ function isFirstReply(post: PostModel, previousPost?: PostModel) {
}
const withSystem = withObservables([], ({database}: WithDatabaseArgs) => ({
appsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'),
currentUser: observeCurrentUser(database),
}));
const withPost = withObservables(
['currentUser', 'isCRTEnabled', 'post', 'previousPost', 'nextPost'],
({currentUser, database, isCRTEnabled, post, previousPost, nextPost}: PropsInput) => {
let isJumboEmoji = of$(false);
({currentUser, database, isCRTEnabled, post, previousPost, nextPost, location}: PropsInput) => {
let isLastReply = of$(true);
let isPostAddChannelMember = of$(false);
const isOwner = currentUser.id === post.userId;
const author: Observable<UserModel | undefined | null> = observePostAuthor(database, post);
const author = post.userId ? observePostAuthor(database, post) : of$(undefined);
const canDelete = observePermissionForPost(database, post, currentUser, isOwner ? Permissions.DELETE_POST : Permissions.DELETE_OTHERS_POSTS, false);
const isEphemeral = of$(isPostEphemeral(post));
const isSaved = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_SAVED_POST, post.id).
observeWithColumns(['value']).pipe(
switchMap((pref) => of$(Boolean(pref.length))),
distinctUntilChanged(),
);
if (post.props?.add_channel_member && isPostEphemeral(post)) {
isPostAddChannelMember = observeCanManageChannelMembers(database, post, currentUser);
}
const highlightReplyBar = post.postsInThread.observe().pipe(
switchMap((postsInThreads: PostsInThreadModel[]) => {
if (postsInThreads.length) {
return observeShouldHighlightReplyBar(database, currentUser, post, postsInThreads[0]);
}
return of$(false);
}),
distinctUntilChanged(),
);
let highlightReplyBar = of$(false);
if (!isCRTEnabled && location === Screens.CHANNEL) {
highlightReplyBar = post.postsInThread.observe().pipe(
switchMap((postsInThreads: PostsInThreadModel[]) => {
if (postsInThreads.length) {
return observeShouldHighlightReplyBar(database, currentUser, post, postsInThreads[0]);
}
return of$(false);
}),
distinctUntilChanged(),
);
}
let differentThreadSequence = true;
if (post.rootId) {
@ -130,28 +122,20 @@ const withPost = withObservables(
isLastReply = of$(!(nextPost?.rootId === post.rootId));
}
if (post.message.length && !(/^\s{4}/).test(post.message)) {
isJumboEmoji = queryAllCustomEmojis(database).observe().pipe(
switchMap(
// eslint-disable-next-line max-nested-callbacks
(customEmojis: CustomEmojiModel[]) => of$(hasJumboEmojiOnly(post.message, customEmojis.map((c) => c.name))),
),
distinctUntilChanged(),
);
}
const hasReplies = observeHasReplies(post);
const hasReplies = observeHasReplies(post);//Need to review and understand
const isConsecutivePost = author.pipe(
switchMap((user) => of$(Boolean(post && previousPost && !user?.isBot && areConsecutivePosts(post, previousPost)))),
distinctUntilChanged(),
);
const hasFiles = post.files.observe().pipe(
switchMap((ff) => of$(Boolean(ff.length))),
const hasFiles = post.files.observeCount().pipe(
switchMap((c) => of$(c > 0)),
distinctUntilChanged(),
);
const hasReactions = post.reactions.observe().pipe(
switchMap((rr) => of$(Boolean(rr.length))),
const hasReactions = post.reactions.observeCount().pipe(
switchMap((c) => of$(c > 0)),
distinctUntilChanged(),
);
@ -164,8 +148,6 @@ const withPost = withObservables(
isConsecutivePost,
isEphemeral,
isFirstReply: of$(isFirstReply(post, previousPost)),
isSaved,
isJumboEmoji,
isLastReply,
isPostAddChannelMember,
isPostPriorityEnabled: observeIsPostPriorityEnabled(database),

View file

@ -17,7 +17,8 @@ import * as Screens from '@constants/screens';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation';
import {openAsBottomSheet} from '@screens/navigation';
import {hasJumboEmojiOnly} from '@utils/emoji/helpers';
import {fromAutoResponder, isFromWebhook, isPostFailed, isPostPendingOrFailed, isSystemMessage} from '@utils/post';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -39,6 +40,7 @@ type PostProps = {
appsEnabled: boolean;
canDelete: boolean;
currentUser: UserModel;
customEmojiNames: string[];
differentThreadSequence: boolean;
hasFiles: boolean;
hasReplies: boolean;
@ -50,7 +52,6 @@ type PostProps = {
isEphemeral: boolean;
isFirstReply?: boolean;
isSaved?: boolean;
isJumboEmoji: boolean;
isLastReply?: boolean;
isPostAddChannelMember: boolean;
isPostPriorityEnabled: boolean;
@ -107,8 +108,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
});
const Post = ({
appsEnabled, canDelete, currentUser, differentThreadSequence, hasFiles, hasReplies, highlight, highlightPinnedOrSaved = true, highlightReplyBar,
isCRTEnabled, isConsecutivePost, isEphemeral, isFirstReply, isSaved, isJumboEmoji, isLastReply, isPostAddChannelMember, isPostPriorityEnabled,
appsEnabled, canDelete, currentUser, customEmojiNames, differentThreadSequence, hasFiles, hasReplies, highlight, highlightPinnedOrSaved = true, highlightReplyBar,
isCRTEnabled, isConsecutivePost, isEphemeral, isFirstReply, isSaved, isLastReply, isPostAddChannelMember, isPostPriorityEnabled,
location, post, rootId, hasReactions, searchPatterns, shouldRenderReplyButton, skipSavedHeader, skipPinnedHeader, showAddReaction = true, style,
testID, thread, previousPost,
}: PostProps) => {
@ -136,6 +137,12 @@ const Post = ({
return false;
}, [isConsecutivePost, post, previousPost, isFirstReply]);
const isJumboEmoji = useMemo(() => {
if (post.message.length && !(/^\s{4}/).test(post.message)) {
return hasJumboEmojiOnly(post.message, customEmojiNames);
}
return false;
}, [customEmojiNames, post.message]);
const handlePostPress = () => {
if ([Screens.SAVED_MESSAGES, Screens.MENTIONS, Screens.SEARCH, Screens.PINNED_MESSAGES].includes(location)) {
@ -185,11 +192,13 @@ const Post = ({
const passProps = {sourceScreen: location, post, showAddReaction, serverUrl};
const title = isTablet ? intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}) : '';
if (isTablet) {
showModal(Screens.POST_OPTIONS, title, passProps, bottomSheetModalOptions(theme, 'close-post-options'));
} else {
showModalOverCurrentContext(Screens.POST_OPTIONS, passProps, bottomSheetModalOptions(theme));
}
openAsBottomSheet({
closeButtonId: 'close-post-options',
screen: Screens.POST_OPTIONS,
theme,
title,
props: passProps,
});
};
const [, rerender] = useState(false);

View file

@ -15,21 +15,23 @@ import ThreadOverview from '@components/post_list/thread_overview';
import {Events, Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, isThreadOverview, preparePostList, START_OF_NEW_MESSAGES} from '@utils/post_list';
import {getDateForDateLine, preparePostList} from '@utils/post_list';
import {INITIAL_BATCH_TO_RENDER, SCROLL_POSITION_CONFIG, VIEWABILITY_CONFIG} from './config';
import MoreMessages from './more_messages';
import PostListRefreshControl from './refresh_control';
import type {ViewableItemsChanged, ViewableItemsChangedListenerEvent} from '@typings/components/post_list';
import type {PostListItem, PostListOtherItem, ViewableItemsChanged, ViewableItemsChangedListenerEvent} from '@typings/components/post_list';
import type PostModel from '@typings/database/models/servers/post';
type Props = {
appsEnabled: boolean;
channelId: string;
contentContainerStyle?: StyleProp<ViewStyle>;
currentTimezone: string | null;
currentUserId: string;
currentUsername: string;
customEmojiNames: string[];
disablePullToRefresh?: boolean;
highlightedId?: PostModel['id'];
highlightPinnedOrSaved?: boolean;
@ -50,6 +52,7 @@ type Props = {
testID: string;
currentCallBarVisible?: boolean;
joinCallBannerVisible?: boolean;
savedPostIds: Set<string>;
}
type onScrollEndIndexListenerEvent = (endIndex: number) => void;
@ -61,7 +64,7 @@ type ScrollIndexFailed = {
};
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
const keyExtractor = (item: string | PostModel) => (typeof item === 'string' ? item : item.id);
const keyExtractor = (item: PostListItem | PostListOtherItem) => (item.type === 'post' ? item.value.id : item.value);
const styles = StyleSheet.create({
flex: {
@ -81,11 +84,13 @@ const styles = StyleSheet.create({
});
const PostList = ({
appsEnabled,
channelId,
contentContainerStyle,
currentTimezone,
currentUserId,
currentUsername,
customEmojiNames,
disablePullToRefresh,
footer,
header,
@ -106,6 +111,7 @@ const PostList = ({
testID,
currentCallBarVisible,
joinCallBannerVisible,
savedPostIds,
}: Props) => {
const listRef = useRef<FlatList<string | PostModel>>(null);
const onScrollEndIndexListener = useRef<onScrollEndIndexListenerEvent>();
@ -116,11 +122,11 @@ const PostList = ({
const theme = useTheme();
const serverUrl = useServerUrl();
const orderedPosts = useMemo(() => {
return preparePostList(posts, lastViewedAt, showNewMessageLine, currentUserId, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, currentTimezone, location === Screens.THREAD);
}, [posts, lastViewedAt, showNewMessageLine, currentTimezone, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, location]);
return preparePostList(posts, lastViewedAt, showNewMessageLine, currentUserId, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, currentTimezone, location === Screens.THREAD, savedPostIds);
}, [posts, lastViewedAt, showNewMessageLine, currentTimezone, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, location, savedPostIds]);
const initialIndex = useMemo(() => {
return orderedPosts.indexOf(START_OF_NEW_MESSAGES);
return orderedPosts.findIndex((i) => i.type === 'start-of-new-messages');
}, [orderedPosts]);
useEffect(() => {
@ -189,8 +195,8 @@ const PostList = ({
}
const viewableItemsMap = viewableItems.reduce((acc: Record<string, boolean>, {item, isViewable}) => {
if (isViewable) {
acc[`${location}-${item.id}`] = true;
if (isViewable && item.type === 'post') {
acc[`${location}-${item.value.id}`] = true;
}
return acc;
}, {});
@ -220,48 +226,40 @@ const PostList = ({
return removeListener;
}, []);
const renderItem = useCallback(({item, index}: ListRenderItemInfo<string | PostModel>) => {
if (typeof item === 'string') {
if (isStartOfNewMessages(item)) {
// postIds includes a date item after the new message indicator so 2
// needs to be added to the index for the length check to be correct.
const moreNewMessages = orderedPosts.length === index + 2;
// The date line and new message line each count for a line. So the
// goal of this is to check for the 3rd previous, which for the start
// of a thread would be null as it doesn't exist.
const checkForPostId = index < orderedPosts.length - 3;
const renderItem = useCallback(({item}: ListRenderItemInfo<PostListItem | PostListOtherItem>) => {
switch (item.type) {
case 'start-of-new-messages':
return (
<NewMessagesLine
key={item.value}
theme={theme}
moreMessages={moreNewMessages && checkForPostId}
testID={`${testID}.new_messages_line`}
style={styles.scale}
/>
);
} else if (isDateLine(item)) {
case 'date':
return (
<DateSeparator
date={getDateForDateLine(item)}
key={item.value}
date={getDateForDateLine(item.value)}
style={styles.scale}
timezone={isTimezoneEnabled ? currentTimezone : null}
/>
);
} else if (isThreadOverview(item)) {
case 'thread-overview':
return (
<ThreadOverview
key={item.value}
rootId={rootId!}
testID={`${testID}.thread_overview`}
style={styles.scale}
/>
);
}
if (isCombinedUserActivityPost(item)) {
case 'user-activity': {
const postProps = {
currentUsername,
postId: item,
key: item.value,
postId: item.value,
location,
style: Platform.OS === 'ios' ? styles.scale : styles.container,
testID: `${testID}.combined_user_activity`,
@ -271,71 +269,32 @@ const PostList = ({
return (<CombinedUserActivity {...postProps}/>);
}
default: {
const post = item.value;
const skipSaveddHeader = (location === Screens.THREAD && post.id === rootId);
const postProps = {
appsEnabled,
customEmojiNames,
isCRTEnabled,
highlight: highlightedId === post.id,
highlightPinnedOrSaved,
isSaved: post.isSaved,
key: post.id,
location,
nextPost: post.nextPost,
post,
previousPost: post.previousPost,
rootId,
shouldRenderReplyButton,
skipSaveddHeader,
style: styles.scale,
testID: `${testID}.post`,
};
return null;
}
let previousPost: PostModel|undefined;
let nextPost: PostModel|undefined;
const lastPosts = orderedPosts.slice(index + 1);
const immediateLastPost = lastPosts[0];
// Post after `Thread Overview` should show user avatar irrespective of being the consecutive post
// So we skip sending previous post to avoid the check for consecutive post
const skipFindingPreviousPost = (
location === Screens.THREAD &&
typeof immediateLastPost === 'string' &&
isThreadOverview(immediateLastPost)
);
if (!skipFindingPreviousPost) {
const prev = lastPosts.find((v) => typeof v !== 'string');
if (prev) {
previousPost = prev as PostModel;
return (<Post {...postProps}/>);
}
}
if (index > 0) {
const next = orderedPosts.slice(0, index);
for (let i = next.length - 1; i >= 0; i--) {
const v = next[i];
if (typeof v !== 'string') {
nextPost = v;
break;
}
}
}
// Skip rendering Flag for the root post in the thread as it is visible in the `Thread Overview`
const post = item;
const skipSaveddHeader = (
location === Screens.THREAD &&
post.id === rootId
);
const postProps = {
highlight: highlightedId === post.id,
highlightPinnedOrSaved,
location,
nextPost,
previousPost,
shouldRenderReplyButton,
skipSaveddHeader,
};
return (
<Post
isCRTEnabled={isCRTEnabled}
key={post.id}
post={post}
rootId={rootId}
style={styles.scale}
testID={`${testID}.post`}
{...postProps}
/>
);
}, [currentTimezone, highlightPinnedOrSaved, isCRTEnabled, isTimezoneEnabled, orderedPosts, shouldRenderReplyButton, theme]);
}, [appsEnabled, currentTimezone, customEmojiNames, highlightPinnedOrSaved, isCRTEnabled, isTimezoneEnabled, shouldRenderReplyButton, theme]);
const scrollToIndex = useCallback((index: number, animated = true, applyOffset = true) => {
listRef.current?.scrollToIndex({
@ -351,7 +310,7 @@ const PostList = ({
if (highlightedId && orderedPosts && !scrolledToHighlighted.current) {
scrolledToHighlighted.current = true;
// eslint-disable-next-line max-nested-callbacks
const index = orderedPosts.findIndex((p) => typeof p !== 'string' && p.id === highlightedId);
const index = orderedPosts.findIndex((p) => p.type === 'post' && p.value.id === highlightedId);
if (index >= 0 && listRef.current) {
listRef.current?.scrollToIndex({
animated: true,
@ -387,7 +346,7 @@ const PostList = ({
maxToRenderPerBatch={10}
nativeID={nativeID}
onEndReached={onEndReached}
onEndReachedThreshold={2}
onEndReachedThreshold={0.9}
onScroll={onScroll}
onScrollToIndexFailed={onScrollToIndexFailed}
onViewableItemsChanged={onViewableItemsChanged}

View file

@ -19,6 +19,8 @@ type Props = {
onSubmit: (data: PostPriorityData) => void;
};
export const COMPONENT_HEIGHT = 200;
const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
backgroundColor: theme.centerChannelBg,

View file

@ -3,7 +3,6 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import compose from 'lodash/fp/compose';
import {observeIsCRTEnabled} from '@queries/servers/thread';
@ -17,7 +16,4 @@ const enhance = withObservables([], ({database}: WithDatabaseArgs) => {
};
});
export default compose(
withDatabase,
enhance,
)(PostWithChannelInfo);
export default withDatabase(enhance(PostWithChannelInfo));

View file

@ -11,6 +11,8 @@ import ChannelInfo from './channel_info';
import type PostModel from '@typings/database/models/servers/post';
type Props = {
appsEnabled: boolean;
customEmojiNames: string[];
isCRTEnabled: boolean;
post: PostModel;
location: string;
@ -28,7 +30,7 @@ const styles = StyleSheet.create({
},
});
function PostWithChannelInfo({isCRTEnabled, post, location, testID}: Props) {
function PostWithChannelInfo({appsEnabled, customEmojiNames, isCRTEnabled, post, location, testID}: Props) {
return (
<View style={styles.container}>
<ChannelInfo
@ -37,6 +39,8 @@ function PostWithChannelInfo({isCRTEnabled, post, location, testID}: Props) {
/>
<View style={styles.content}>
<Post
appsEnabled={appsEnabled}
customEmojiNames={customEmojiNames}
isCRTEnabled={isCRTEnabled}
post={post}
location={location}

View file

@ -1,7 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {BottomSheetFlatList} from '@gorhom/bottom-sheet';
import React, {useCallback, useMemo} from 'react';
import {ListRenderItemInfo, StyleSheet, View} from 'react-native';
import {FlatList} from 'react-native-gesture-handler'; // Keep the FlatList from gesture handler so it works well with bottom sheet
@ -12,20 +13,21 @@ import TeamListItem from './team_list_item';
import type TeamModel from '@typings/database/models/servers/team';
type Props = {
teams: Array<Team|TeamModel>;
textColor?: string;
iconTextColor?: string;
iconBackgroundColor?: string;
onPress: (id: string) => void;
testID?: string;
selectedTeamId?: string;
onEndReached?: () => void;
iconTextColor?: string;
loading?: boolean;
onEndReached?: () => void;
onPress: (id: string) => void;
selectedTeamId?: string;
teams: Array<Team|TeamModel>;
testID?: string;
textColor?: string;
type?: BottomSheetList;
}
const styles = StyleSheet.create({
container: {
flexShrink: 1,
flexGrow: 1,
},
contentContainer: {
marginBottom: 4,
@ -34,7 +36,20 @@ const styles = StyleSheet.create({
const keyExtractor = (item: TeamModel) => item.id;
export default function TeamList({teams, textColor, iconTextColor, iconBackgroundColor, onPress, testID, selectedTeamId, onEndReached, loading = false}: Props) {
export default function TeamList({
iconBackgroundColor,
iconTextColor,
loading = false,
onEndReached,
onPress,
selectedTeamId,
teams,
testID,
textColor,
type = 'FlatList',
}: Props) {
const List = useMemo(() => (type === 'FlatList' ? FlatList : BottomSheetFlatList), [type]);
const renderTeam = useCallback(({item: t}: ListRenderItemInfo<Team|TeamModel>) => {
return (
<TeamListItem
@ -55,7 +70,7 @@ export default function TeamList({teams, textColor, iconTextColor, iconBackgroun
return (
<View style={styles.container}>
<FlatList
<List
data={teams}
renderItem={renderTeam}
keyExtractor={keyExtractor}

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 {StyleProp, View, ViewStyle} from 'react-native';
import Emoji from '@components/emoji';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {preventDoubleTap} from '@utils/tap';
import SkinnedEmoji from './skinned_emoji';
type Props = {
category?: string;
name: string;
onEmojiPress: (emoji: string) => void;
size?: number;
style?: StyleProp<ViewStyle>;
}
const CATEGORIES_WITH_SKINS = ['people-body'];
const hitSlop = {top: 10, bottom: 10, left: 10, right: 10};
const TouchableEmoji = ({category, name, onEmojiPress, size = 30, style}: Props) => {
const onPress = useCallback(preventDoubleTap(() => onEmojiPress(name)), []);
let emoji;
if (category && CATEGORIES_WITH_SKINS.includes(category)) {
emoji = (
<SkinnedEmoji
name={name}
size={size}
/>
);
} else {
emoji = (
<Emoji
emojiName={name}
size={size}
/>
);
}
return (
<View
style={style}
>
<TouchableWithFeedback
hitSlop={hitSlop}
onPress={onPress}
style={style}
type={'opacity'}
>
{emoji}
</TouchableWithFeedback>
</View>
);
};
export default React.memo(TouchableEmoji);

View file

@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {useEmojiSkinTone} from '@app/hooks/emoji_category_bar';
import Emoji from '@components/emoji';
import {skinCodes} from '@utils/emoji';
import {isValidNamedEmoji} from '@utils/emoji/helpers';
type Props = {
name: string;
size?: number;
}
const SkinnedEmoji = ({name, size = 30}: Props) => {
const skinTone = useEmojiSkinTone();
const emojiName = useMemo(() => {
const skinnedEmoji = `${name}_${skinCodes[skinTone]}`;
if (skinTone === 'default' || !isValidNamedEmoji(skinnedEmoji, [])) {
return name;
}
return skinnedEmoji;
}, [name, skinTone]);
return (
<Emoji
emojiName={emojiName}
size={size}
/>
);
};
export default React.memo(SkinnedEmoji);

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BottomSheetProps} from '@gorhom/bottom-sheet';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {StyleProp, Text, TouchableOpacity, View, ViewStyle} from 'react-native';
@ -123,12 +124,17 @@ const UserAvatarsStack = ({breakAt = 3, channelId, location, style: baseContaine
/>
</>
);
const snap = bottomSheetSnapPoint(Math.min(users.length, 5), USER_ROW_HEIGHT, bottom);
const snapPoints: BottomSheetProps['snapPoints'] = [1, bottomSheetSnapPoint(Math.min(users.length, 5), USER_ROW_HEIGHT, bottom) + TITLE_HEIGHT];
if (users.length > 5) {
snapPoints.push('80%');
}
bottomSheet({
closeButtonId: 'close-set-user-status',
renderContent,
initialSnapIndex: 1,
snapPoints: ['90%', TITLE_HEIGHT + snap, 10],
snapPoints,
title: intl.formatMessage({id: 'mobile.participants.header', defaultMessage: 'Thread Participants'}),
theme,
});

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BottomSheetFlatList} from '@gorhom/bottom-sheet';
import React, {useCallback, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent, PanResponder, StyleProp, StyleSheet, TouchableOpacity, ViewStyle} from 'react-native';
@ -16,6 +17,7 @@ import type UserModel from '@typings/database/models/servers/user';
type Props = {
channelId: string;
location: string;
type?: BottomSheetList;
users: UserModel[];
};
@ -58,8 +60,8 @@ const Item = ({channelId, containerStyle, location, user}: ItemProps) => {
);
};
const UsersList = ({channelId, location, users}: Props) => {
const [enabled, setEnabled] = useState(false);
const UsersList = ({channelId, location, type = 'FlatList', users}: Props) => {
const [enabled, setEnabled] = useState(type === 'BottomSheetFlatList');
const [direction, setDirection] = useState<'down' | 'up'>('down');
const listRef = useRef<FlatList>(null);
const prevOffset = useRef(0);
@ -91,6 +93,16 @@ const UsersList = ({channelId, location, users}: Props) => {
/>
), [channelId, location]);
if (type === 'BottomSheetFlatList') {
return (
<BottomSheetFlatList
data={users}
renderItem={renderItem}
overScrollMode={'always'}
/>
);
}
return (
<FlatList
data={users}

View file

@ -78,8 +78,6 @@ export const GLOBAL_IDENTIFIERS = {
DONT_ASK_FOR_REVIEW: 'dontAskForReview',
FIRST_LAUNCH: 'firstLaunch',
LAST_ASK_FOR_REVIEW: 'lastAskForReview',
MULTI_SERVER_TUTORIAL: 'multiServerTutorial',
PROFILE_LONG_PRESS_TUTORIAL: 'profileLongPressTutorial',
ONBOARDING: 'onboarding',
};

View file

@ -34,6 +34,7 @@ import ServerErrors from './server_errors';
import SnackBar from './snack_bar';
import Sso from './sso';
import SupportedServer from './supported_server';
import Tutorial from './tutorial';
import View from './view';
import WebsocketEvents from './websocket';
@ -71,6 +72,7 @@ export {
SnackBar,
Sso,
SupportedServer,
Tutorial,
View,
WebsocketEvents,
};

View file

@ -12,6 +12,7 @@ export const DEFAULT_EMOJIS = [
export const LARGE_ICON_SIZE = 32;
export const LARGE_CONTAINER_SIZE = 48;
export const REACTION_PICKER_HEIGHT = 48;
export const REACTION_PICKER_MARGIN = 16;
export const SMALL_CONTAINER_SIZE = 44;
export const SMALL_ICON_BREAKPOINT = 410;
export const SMALL_ICON_SIZE = 28;
@ -21,6 +22,7 @@ export default {
LARGE_ICON_SIZE,
LARGE_CONTAINER_SIZE,
REACTION_PICKER_HEIGHT,
REACTION_PICKER_MARGIN,
SMALL_CONTAINER_SIZE,
SMALL_ICON_BREAKPOINT,
SMALL_ICON_SIZE,

View file

@ -144,21 +144,24 @@ export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([
EDIT_POST,
EDIT_PROFILE,
EDIT_SERVER,
EMOJI_PICKER,
FIND_CHANNELS,
GALLERY,
INVITE,
PERMALINK,
REACTIONS,
]);
export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set<string>([
PERMALINK,
REVIEW_APP,
SNACK_BAR,
]);
export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
BOTTOM_SHEET,
EMOJI_PICKER,
POST_OPTIONS,
THREAD_OPTIONS,
PERMALINK,
REACTIONS,
SNACK_BAR,
USER_PROFILE,
]);

12
app/constants/tutorial.ts Normal file
View file

@ -0,0 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const MULTI_SERVER = 'multiServerTutorial';
export const PROFILE_LONG_PRESS = 'profileLongPressTutorial';
export const EMOJI_SKIN_SELECTOR = 'emojiSkinSelectorTutorial';
export default {
MULTI_SERVER,
PROFILE_LONG_PRESS,
EMOJI_SKIN_SELECTOR,
};

View file

@ -12,7 +12,7 @@ export const TEAM_SIDEBAR_WIDTH = 72;
export const TABLET_HEADER_HEIGHT = 44;
export const TABLET_SIDEBAR_WIDTH = 320;
export const IOS_STATUS_BAR_HEIGHT = 20;
export const STATUS_BAR_HEIGHT = 20;
export const DEFAULT_HEADER_HEIGHT = Platform.select({android: 56, default: 44});
export const LARGE_HEADER_TITLE_HEIGHT = 60;
export const SUBTITLE_HEIGHT = 24;
@ -25,8 +25,6 @@ export const JOIN_CALL_BAR_HEIGHT = 38;
export const CURRENT_CALL_BAR_HEIGHT = 74;
export const CALL_ERROR_BAR_HEIGHT = 62;
export const QUICK_OPTIONS_HEIGHT = 270;
export const ANNOUNCEMENT_BAR_HEIGHT = 40;
export default {
@ -41,11 +39,10 @@ export default {
TABLET_SIDEBAR_WIDTH,
TEAM_SIDEBAR_WIDTH,
TABLET_HEADER_HEIGHT,
IOS_STATUS_BAR_HEIGHT,
STATUS_BAR_HEIGHT,
DEFAULT_HEADER_HEIGHT,
LARGE_HEADER_TITLE_HEIGHT,
SUBTITLE_HEIGHT,
KEYBOARD_TRACKING_OFFSET,
QUICK_OPTIONS_HEIGHT,
};

View file

@ -22,6 +22,7 @@ import AppDataOperator from '@database/operator/app_data_operator';
import ServerDataOperator from '@database/operator/server_data_operator';
import {schema as appSchema} from '@database/schema/app';
import {serverSchema} from '@database/schema/server';
import {beforeUpgrade} from '@helpers/database/upgrade';
import {getActiveServer, getServer, getServerByIdentifier} from '@queries/app/servers';
import {querySystemValue} from '@queries/servers/system';
import {deleteLegacyFileCache} from '@utils/file';
@ -63,14 +64,17 @@ class DatabaseManager {
*/
public init = async (serverUrls: string[]): Promise<void> => {
await this.createAppDatabase();
const buildNumber = DeviceInfo.getBuildNumber();
const versionNumber = DeviceInfo.getVersion();
await beforeUpgrade.call(this, serverUrls, versionNumber, buildNumber);
for await (const serverUrl of serverUrls) {
await this.initServerDatabase(serverUrl);
}
this.appDatabase?.operator.handleInfo({
info: [{
build_number: DeviceInfo.getBuildNumber(),
build_number: buildNumber,
created_at: Date.now(),
version_number: DeviceInfo.getVersion(),
version_number: versionNumber,
}],
prepareRecordsOnly: false,
});
@ -428,7 +432,7 @@ class DatabaseManager {
*/
private deleteServerDatabaseFiles = async (serverUrl: string): Promise<void> => {
const databaseName = urlSafeBase64Encode(serverUrl);
this.deleteServerDatabaseFilesByName(databaseName);
return this.deleteServerDatabaseFilesByName(databaseName);
};
/**
@ -439,7 +443,7 @@ class DatabaseManager {
private deleteServerDatabaseFilesByName = async (databaseName: string): Promise<void> => {
if (Platform.OS === 'ios') {
// On iOS, we'll delete the *.db file under the shared app-group/databases folder
deleteIOSDatabase({databaseName});
await deleteIOSDatabase({databaseName});
return;
}
@ -449,14 +453,15 @@ class DatabaseManager {
const databaseShm = `${androidFilesDir}${databaseName}.db-shm`;
const databaseWal = `${androidFilesDir}${databaseName}.db-wal`;
FileSystem.unlink(databaseFile).catch(emptyFunction);
FileSystem.unlink(databaseShm).catch(emptyFunction);
FileSystem.unlink(databaseWal).catch(emptyFunction);
await FileSystem.unlink(databaseFile).catch(emptyFunction);
await FileSystem.unlink(databaseShm).catch(emptyFunction);
await FileSystem.unlink(databaseWal).catch(emptyFunction);
};
/**
* deleteServerDatabaseFilesByName: Removes the *.db file from the App-Group directory for iOS or the files directory for Android, given the database name
* renameDatabase: Renames the *.db file from the App-Group directory for iOS or the files directory for Android
* @param {string} databaseName
* @param {string} newDBName
* @returns {Promise<void>}
*/
private renameDatabase = async (databaseName: string, newDBName: string): Promise<void> => {

View file

@ -4,105 +4,6 @@
// NOTE : To implement migration, please follow this document
// https://nozbe.github.io/WatermelonDB/Advanced/Migrations.html
import {schemaMigrations, addColumns, createTable} from '@nozbe/watermelondb/Schema/migrations';
import {schemaMigrations} from '@nozbe/watermelondb/Schema/migrations';
import {MM_TABLES} from '@constants/database';
import {tableSchemaSpec as configSpec} from '@database/schema/server/table_schemas/config';
import {tableSchemaSpec as teamThreadsSyncSpec} from '@database/schema/server/table_schemas/team_threads_sync';
import {tableSchemaSpec as threadSpec} from '@database/schema/server/table_schemas/thread';
import {tableSchemaSpec as threadInTeamSpec} from '@database/schema/server/table_schemas/thread_in_team';
import {tableSchemaSpec as threadParticipantSpec} from '@database/schema/server/table_schemas/thread_participant';
const {SERVER: {
GROUP,
MY_CHANNEL,
TEAM,
THREAD,
THREAD_PARTICIPANT,
THREADS_IN_TEAM,
USER,
}} = MM_TABLES;
export default schemaMigrations({migrations: [
{
toVersion: 7,
steps: [
// Along with adding the new table - TeamThreadsSync,
// We need to clear the data in thread related tables (DROP & CREATE) to fetch the fresh data from the server
createTable({
...teamThreadsSyncSpec,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
unsafeSql: (baseSql) => {
return `
${baseSql}
DROP TABLE ${THREAD};
DROP TABLE ${THREADS_IN_TEAM};
DROP TABLE ${THREAD_PARTICIPANT};
`;
},
}),
createTable(threadSpec),
createTable(threadInTeamSpec),
createTable(threadParticipantSpec),
],
},
{
toVersion: 6,
steps: [
addColumns({
table: USER,
columns: [
{name: 'terms_of_service_id', type: 'string'},
{name: 'terms_of_service_create_at', type: 'number'},
],
}),
],
},
{
toVersion: 5,
steps: [
createTable(configSpec),
],
},
{
toVersion: 4,
steps: [
addColumns({
table: TEAM,
columns: [
{name: 'invite_id', type: 'string'},
],
}),
],
},
{
toVersion: 3,
steps: [
addColumns({
table: GROUP,
columns: [
{name: 'member_count', type: 'number'},
],
}),
],
},
{
toVersion: 2,
steps: [
addColumns({
table: MY_CHANNEL,
columns: [
{name: 'last_fetched_at', type: 'number', isIndexed: true},
],
}),
addColumns({
table: THREAD,
columns: [
{name: 'last_fetched_at', type: 'number', isIndexed: true},
],
}),
],
},
]});
export default schemaMigrations({migrations: []});

View file

@ -11,8 +11,6 @@ import type GlobalModelInterface from '@typings/database/models/app/global';
const {GLOBAL} = MM_TABLES.APP;
// TODO : add TS definitions to sanitizer function signature.
/**
* The Global model will act as a dictionary of name-value pairs. The value field can be a JSON object or any other
* data type. It will hold information that applies to the whole app ( e.g. sidebar settings for tablets)

View file

@ -23,6 +23,7 @@ export default class MyChannelModel extends Model implements MyChannelModelInter
static associations: Associations = {
[CHANNEL]: {type: 'belongs_to', key: 'id'},
[CATEGORY_CHANNEL]: {type: 'has_many', foreignKey: 'channel_id'},
[MY_CHANNEL_SETTINGS]: {type: 'has_many', foreignKey: 'id'},
};
/** last_post_at : The timestamp for any last post on this channel */

View file

@ -39,7 +39,7 @@ import {
} from './table_schemas';
export const serverSchema: AppSchema = appSchema({
version: 7,
version: 1,
tables: [
CategorySchema,
CategoryChannelSchema,

View file

@ -45,7 +45,7 @@ const {
describe('*** Test schema for SERVER database ***', () => {
it('=> The SERVER SCHEMA should strictly match', () => {
expect(serverSchema).toEqual({
version: 7,
version: 1,
unsafeSql: undefined,
tables: {
[CATEGORY]: {

View file

@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getLastInstalledVersion} from '@queries/app/info';
import {logError, logInfo} from '@utils/log';
import type {DatabaseManager} from '@typings/database/manager';
import type InfoModel from '@typings/database/models/app/info';
export async function beforeUpgrade(serverUrls: string[], versionNumber: string, buildNumber: string) {
const info = await getLastInstalledVersion();
const manager: DatabaseManager | undefined = this.serverDatabases ? this : undefined;
if (manager && serverUrls.length && info && (versionNumber !== info.versionNumber || buildNumber !== info.buildNumber)) {
await beforeUpgradeTo450(manager, serverUrls, info);
}
}
async function beforeUpgradeTo450(manager: DatabaseManager, serverUrls: string[], info: InfoModel) {
try {
const buildNumber = parseInt(info.buildNumber, 10);
if (info.versionNumber === '2.0.0' && buildNumber < 450) {
for await (const serverUrl of serverUrls) {
logInfo('Remove database before upgrading for', serverUrl);
await manager.deleteServerDatabaseFiles(serverUrl);
}
}
} catch (e) {
logError('Error running the upgrade before build 450', e);
}
}

View file

@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
import {BehaviorSubject} from 'rxjs';
export type EmojiCategoryBarIcon = {
key: string;
icon: string;
}
type EmojiCategoryBar = {
currentIndex: number;
selectedIndex?: number;
icons?: EmojiCategoryBarIcon[];
skinTone: string;
};
const defaultState: EmojiCategoryBar = {
icons: undefined,
currentIndex: 0,
selectedIndex: undefined,
skinTone: 'default',
};
const subject: BehaviorSubject<EmojiCategoryBar> = new BehaviorSubject(defaultState);
const getEmojiCategoryBarState = () => {
return subject.value;
};
export const selectEmojiCategoryBarSection = (index?: number) => {
const prevState = getEmojiCategoryBarState();
subject.next({
...prevState,
selectedIndex: index,
});
};
export const setEmojiCategoryBarSection = (index: number) => {
const prevState = getEmojiCategoryBarState();
subject.next({
...prevState,
currentIndex: index,
});
};
export const setEmojiCategoryBarIcons = (icons?: EmojiCategoryBarIcon[]) => {
const prevState = getEmojiCategoryBarState();
subject.next({
...prevState,
icons,
});
};
export const setEmojiSkinTone = (skinTone: string) => {
const prevState = getEmojiCategoryBarState();
subject.next({
...prevState,
skinTone,
});
};
export const useEmojiCategoryBar = () => {
const [state, setState] = useState(defaultState);
useEffect(() => {
const sub = subject.subscribe(setState);
return () => {
sub.unsubscribe();
subject.next(defaultState);
};
}, []);
return state;
};
export const useEmojiSkinTone = () => {
const [tone, setTone] = useState(defaultState.skinTone);
useEffect(() => {
const sub = subject.subscribe((state) => {
setTone(state.skinTone);
});
return () => {
sub.unsubscribe();
};
}, []);
return tone;
};

View file

@ -16,6 +16,20 @@ import {registerNavigationListeners} from '@screens/navigation';
let alreadyInitialized = false;
let serverCredentials: ServerCredential[];
// Fallback Polyfill for Promise.allSettle
Promise.allSettled = Promise.allSettled || (<T>(promises: Array<Promise<T>>) => Promise.all(
promises.map((p) => p.
then((value) => ({
status: 'fulfilled',
value,
})).
catch((reason) => ({
status: 'rejected',
reason,
})),
),
));
export async function initialize() {
if (!alreadyInitialized) {
alreadyInitialized = true;
@ -33,11 +47,11 @@ export async function initialize() {
export async function start() {
await initialize();
await WebsocketManager.init(serverCredentials);
PushNotifications.init(serverCredentials.length > 0);
registerNavigationListeners();
registerScreens();
initialLaunch();
await initialLaunch();
WebsocketManager.init(serverCredentials);
}

View file

@ -94,7 +94,7 @@ export const getServerCredentials = async (serverUrl: string): Promise<ServerCre
if (credentials) {
// TODO: Pre-Gekidou we were concatenating the deviceToken and the userId in
// credentials.username so we need to check the length of credentials.username.split(',').
// This check should be removed at some point.
// This check should be removed at some point. https://mattermost.atlassian.net/browse/MM-43483
const parts = credentials.username.split(',');
const userId = parts[parts.length - 1];

View file

@ -27,7 +27,7 @@ const initialNotificationTypes = [PushNotification.NOTIFICATION_TYPE.MESSAGE, Pu
export const initialLaunch = async () => {
const deepLinkUrl = await Linking.getInitialURL();
if (deepLinkUrl) {
launchAppFromDeepLink(deepLinkUrl, true);
await launchAppFromDeepLink(deepLinkUrl, true);
return;
}
@ -43,21 +43,21 @@ export const initialLaunch = async () => {
tapped = delivered.find((d) => (d as unknown as NotificationData).ack_id === notification?.payload.ack_id) == null;
}
if (initialNotificationTypes.includes(notification?.payload?.type) && tapped) {
launchAppFromNotification(convertToNotificationData(notification!), true);
await launchAppFromNotification(convertToNotificationData(notification!), true);
return;
}
launchApp({launchType: Launch.Normal, coldStart: true});
await launchApp({launchType: Launch.Normal, coldStart: true});
};
const launchAppFromDeepLink = (deepLinkUrl: string, coldStart = false) => {
const launchAppFromDeepLink = async (deepLinkUrl: string, coldStart = false) => {
const props = getLaunchPropsFromDeepLink(deepLinkUrl, coldStart);
launchApp(props);
return launchApp(props);
};
const launchAppFromNotification = async (notification: NotificationWithData, coldStart = false) => {
const props = await getLaunchPropsFromNotification(notification, coldStart);
launchApp(props);
return launchApp(props);
};
/**

View file

@ -96,7 +96,7 @@ class PushNotifications {
markThreadAsRead(serverUrl, payload.team_id, payload.post_id);
}
} else {
markChannelAsViewed(serverUrl, payload.channel_id, false);
markChannelAsViewed(serverUrl, payload.channel_id);
}
}
}

View file

@ -6,11 +6,7 @@ import DeviceInfo from 'react-native-device-info';
import LocalConfig from '@assets/config.json';
import {Device} from '@constants';
const isSystemAdmin = (roles: string) => {
// TODO: Replace this function with an utility function based on previous code
return roles === 'system_admin';
};
import {isSystemAdmin} from '@utils/user';
const clientMap: Record<string, Analytics> = {};
@ -28,7 +24,9 @@ export class Analytics {
};
async init(config: ClientConfig) {
this.analytics = require('@rudderstack/rudder-sdk-react-native').default;
if (LocalConfig.RudderApiKey) {
this.analytics = require('@rudderstack/rudder-sdk-react-native').default;
}
if (this.analytics) {
const {height, width} = Dimensions.get('window');
@ -123,10 +121,18 @@ export class Analytics {
}
trackAPI(event: string, props?: any) {
if (!this.analytics) {
return;
}
this.trackEvent('api', event, props);
}
trackCommand(event: string, command: string, errorMessage?: string) {
if (!this.analytics) {
return;
}
const sanitizedCommand = this.sanitizeCommand(command);
let props: any;
if (errorMessage) {
@ -139,6 +145,9 @@ export class Analytics {
}
trackAction(event: string, props?: any) {
if (!this.analytics) {
return;
}
this.trackEvent('action', event, props);
}

View file

@ -78,8 +78,9 @@ class SessionManager {
};
private clearCookiesForServer = async (serverUrl: string) => {
this.clearCookies(serverUrl, false);
if (Platform.OS === 'ios') {
this.clearCookies(serverUrl, false);
// Also delete any cookies that were set by react-native-webview
this.clearCookies(serverUrl, true);
} else if (Platform.OS === 'android') {

View file

@ -30,12 +30,12 @@ class WebsocketManager {
private connectionTimerIDs: Record<string, DebouncedFunc<() => void>> = {};
private isBackgroundTimerRunning = false;
private netConnected = false;
private previousAppState: AppStateStatus;
private previousActiveState: boolean;
private statusUpdatesIntervalIDs: Record<string, NodeJS.Timer> = {};
private backgroundIntervalId: number | undefined;
constructor() {
this.previousAppState = AppState.currentState;
this.previousActiveState = AppState.currentState === 'active';
}
public init = async (serverCredentials: ServerCredential[]) => {
@ -81,6 +81,7 @@ class WebsocketManager {
//client.setMissedEventsCallback(() => {}) Nothing to do on missedEvents callback
client.setReconnectCallback(() => this.onReconnect(serverUrl));
client.setReliableReconnectCallback(() => this.onReliableReconnect(serverUrl));
client.setCloseCallback((connectFailCount: number, lastDisconnect: number) => this.onWebsocketClose(serverUrl, connectFailCount, lastDisconnect));
if (this.netConnected && ['unknown', 'active'].includes(AppState.currentState)) {
@ -153,23 +154,27 @@ class WebsocketManager {
private onFirstConnect = (serverUrl: string) => {
this.startPeriodicStatusUpdates(serverUrl);
handleFirstConnect(serverUrl);
this.getConnectedSubject(serverUrl).next('connected');
handleFirstConnect(serverUrl);
};
private onReconnect = async (serverUrl: string) => {
this.startPeriodicStatusUpdates(serverUrl);
this.getConnectedSubject(serverUrl).next('connected');
await handleReconnect(serverUrl);
};
private onReliableReconnect = async (serverUrl: string) => {
this.getConnectedSubject(serverUrl).next('connected');
};
private onWebsocketClose = async (serverUrl: string, connectFailCount: number, lastDisconnect: number) => {
this.getConnectedSubject(serverUrl).next('not_connected');
if (connectFailCount <= 1) { // First fail
await setCurrentUserStatusOffline(serverUrl);
await handleClose(serverUrl, lastDisconnect);
this.stopPeriodicStatusUpdates(serverUrl);
this.getConnectedSubject(serverUrl).next('not_connected');
}
};
@ -205,14 +210,15 @@ class WebsocketManager {
}
private onAppStateChange = async (appState: AppStateStatus) => {
if (appState === this.previousAppState) {
const isActive = appState === 'active';
if (isActive === this.previousActiveState) {
return;
}
const isMain = isMainActivity();
this.cancelAllConnections();
if (appState !== 'active' && !this.isBackgroundTimerRunning) {
if (!isActive && !this.isBackgroundTimerRunning) {
this.isBackgroundTimerRunning = true;
this.cancelAllConnections();
this.backgroundIntervalId = BackgroundTimer.setInterval(() => {
@ -221,22 +227,22 @@ class WebsocketManager {
this.isBackgroundTimerRunning = false;
}, WAIT_TO_CLOSE);
this.previousAppState = appState;
this.previousActiveState = isActive;
return;
}
if (appState === 'active' && this.netConnected && isMain) { // Reopen the websockets only if there is connection
if (isActive && this.netConnected && isMain) { // Reopen the websockets only if there is connection
if (this.backgroundIntervalId) {
BackgroundTimer.clearInterval(this.backgroundIntervalId);
}
this.isBackgroundTimerRunning = false;
this.openAll();
this.previousAppState = appState;
this.previousActiveState = isActive;
return;
}
if (isMain) {
this.previousAppState = appState;
this.previousActiveState = isActive;
}
};
@ -248,7 +254,7 @@ class WebsocketManager {
this.netConnected = newState;
if (this.netConnected && this.previousAppState === 'active') { // Reopen the websockets only if the app is active
if (this.netConnected && this.previousActiveState) { // Reopen the websockets only if the app is active
this.openAll();
return;
}

View file

@ -256,7 +256,7 @@ const CallScreen = ({
}: Props) => {
const intl = useIntl();
const theme = useTheme();
const insets = useSafeAreaInsets();
const {bottom} = useSafeAreaInsets();
const {width, height} = useWindowDimensions();
const serverUrl = useServerUrl();
const {EnableRecordings} = useCallsConfig(serverUrl);
@ -407,11 +407,11 @@ const CallScreen = ({
await bottomSheet({
closeButtonId: 'close-other-actions',
renderContent,
snapPoints: [bottomSheetSnapPoint(items, ITEM_HEIGHT, insets.bottom), 10],
snapPoints: [1, bottomSheetSnapPoint(items, ITEM_HEIGHT, bottom)],
title: intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}),
theme,
});
}, [insets, intl, theme, isHost, EnableRecordings, waitingForRecording, recording, startRecording,
}, [bottom, intl, theme, isHost, EnableRecordings, waitingForRecording, recording, startRecording,
recordOptionTitle, stopRecording, stopRecordingOptionTitle, style, switchToThread, callThreadOptionTitle,
openChannelOptionTitle]);

View file

@ -21,7 +21,7 @@ const enhanced = withObservables([], () => {
switchMap((url) => of$(DatabaseManager.serverDatabases[url]?.database)),
);
// TODO: to be optimized
// TODO: to be optimized https://mattermost.atlassian.net/browse/MM-49338
const participantsDict = combineLatest([database, currentCall]).pipe(
switchMap(([db, call]) => (db && call ? queryUsersById(db, Object.keys(call.participants)).observeWithColumns(['nickname', 'username', 'first_name', 'last_name', 'last_picture_update']) : of$([])).pipe(
// eslint-disable-next-line max-nested-callbacks

View file

@ -31,17 +31,6 @@ export const queryGlobalValue = (key: string) => {
}
};
export const observeMultiServerTutorial = () => {
const query = queryGlobalValue(GLOBAL_IDENTIFIERS.MULTI_SERVER_TUTORIAL);
if (!query) {
return of$(false);
}
return query.observe().pipe(
switchMap((result) => (result.length ? result[0].observe() : of$(false))),
switchMap((v) => of$(Boolean(v))),
);
};
export const getOnboardingViewed = async (): Promise<boolean> => {
try {
const {database} = DatabaseManager.getAppDatabaseAndOperator();
@ -52,17 +41,6 @@ export const getOnboardingViewed = async (): Promise<boolean> => {
}
};
export const observeProfileLongPresTutorial = () => {
const query = queryGlobalValue(GLOBAL_IDENTIFIERS.PROFILE_LONG_PRESS_TUTORIAL);
if (!query) {
return of$(false);
}
return query.observe().pipe(
switchMap((result) => (result.length ? result[0].observe() : of$(false))),
switchMap((v) => of$(Boolean(v))),
);
};
export const getLastAskedForReview = async () => {
const records = await queryGlobalValue(GLOBAL_IDENTIFIERS.LAST_ASK_FOR_REVIEW)?.fetch();
if (!records?.[0]?.value) {
@ -85,3 +63,14 @@ export const getFirstLaunch = async () => {
return records[0].value;
};
export const observeTutorialWatched = (tutorial: string) => {
const query = queryGlobalValue(tutorial);
if (!query) {
return of$(false);
}
return query.observe().pipe(
switchMap((result) => (result.length ? result[0].observe() : of$(false))),
switchMap((v) => of$(Boolean(v))),
);
};

24
app/queries/app/info.ts Normal file
View file

@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import type InfoModel from '@typings/database/models/app/info';
const {APP: {INFO}} = MM_TABLES;
export const getLastInstalledVersion = async () => {
try {
const {database} = DatabaseManager.getAppDatabaseAndOperator();
const infos = await database.get<InfoModel>(INFO).query(
Q.sortBy('created_at', Q.desc),
Q.take(1),
).fetch();
return infos[0];
} catch {
return undefined;
}
};

View file

@ -465,6 +465,7 @@ export function observeMyChannelMentionCount(database: Database, teamId?: string
Q.on(CHANNEL, Q.and(
...conditions,
)),
Q.on(MY_CHANNEL_SETTINGS, Q.where('notify_props', Q.notLike('%"mark_unread":"mention"%'))),
).
observeWithColumns(columns).
pipe(

View file

@ -14,8 +14,9 @@ import {observeUser} from './user';
import type PostModel from '@typings/database/models/servers/post';
import type PostInChannelModel from '@typings/database/models/servers/posts_in_channel';
import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread';
import type PreferenceModel from '@typings/database/models/servers/preference';
const {SERVER: {POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD}} = MM_TABLES;
const {SERVER: {POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD, PREFERENCE}} = MM_TABLES;
export const prepareDeletePost = async (post: PostModel): Promise<Model[]> => {
const preparedModels: Model[] = [post.prepareDestroyPermanently()];
@ -74,7 +75,7 @@ export const observePost = (database: Database, postId: string) => {
};
export const observePostAuthor = (database: Database, post: PostModel) => {
return post.userId ? observeUser(database, post.userId) : of$(null);
return observeUser(database, post.userId);
};
export const observePostSaved = (database: Database, postId: string) => {
@ -216,3 +217,15 @@ export const queryPinnedPostsInChannel = (database: Database, channelId: string)
export const observePinnedPostsInChannel = (database: Database, channelId: string) => {
return queryPinnedPostsInChannel(database, channelId).observe();
};
export const observeSavedPostsByIds = (database: Database, postIds: string[]) => {
return database.get<PreferenceModel>(PREFERENCE).
query(
Q.and(
Q.where('category', Preferences.CATEGORY_SAVED_POST),
Q.where('name', Q.oneOf(postIds)),
),
).observeWithColumns(['name']).pipe(
switchMap((prefs) => of$(new Set(prefs.map((p) => p.name)))),
);
};

View file

@ -2,13 +2,14 @@
// See LICENSE.txt for license information.
import React from 'react';
import {GestureResponderEvent, StyleSheet, Text, View} from 'react-native';
import {GestureResponderEvent, Platform, Text, useWindowDimensions, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {changeOpacity} from '@utils/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
disabled?: boolean;
@ -18,22 +19,43 @@ type Props = {
text?: string;
}
const styles = StyleSheet.create({
button: {
display: 'flex',
flexDirection: 'row',
},
icon_container: {
width: 24,
height: 24,
top: -1,
marginRight: 4,
},
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
button: {
display: 'flex',
flexDirection: 'row',
},
buttonContainer: {
paddingHorizontal: 20,
},
container: {
backgroundColor: theme.centerChannelBg,
},
iconContainer: {
width: 24,
height: 24,
top: -1,
marginRight: 4,
},
separator: {
height: 1,
right: 20,
borderTopWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.08),
marginBottom: 20,
},
};
});
export default function BottomSheetButton({disabled = false, onPress, icon, testID, text}: Props) {
const theme = useTheme();
export const BUTTON_HEIGHT = 101;
function BottomSheetButton({disabled = false, onPress, icon, testID, text}: Props) {
const theme = useTheme();
const dimensions = useWindowDimensions();
const isTablet = useIsTablet();
const styles = getStyleSheet(theme);
const separatorWidth = Math.max(dimensions.width, 450);
const buttonType = disabled ? 'disabled' : 'default';
const styleButtonText = buttonTextStyle(theme, 'lg', 'primary', buttonType);
const styleButtonBackground = buttonBackgroundStyle(theme, 'lg', 'primary', buttonType);
@ -41,27 +63,35 @@ export default function BottomSheetButton({disabled = false, onPress, icon, test
const iconColor = disabled ? changeOpacity(theme.centerChannelColor, 0.32) : theme.buttonColor;
return (
<TouchableWithFeedback
onPress={onPress}
type='opacity'
style={[styles.button, styleButtonBackground]}
testID={testID}
>
{icon && (
<View style={styles.icon_container}>
<CompassIcon
size={24}
name={icon}
color={iconColor}
/>
</View>
)}
{text && (
<Text
style={styleButtonText}
>{text}</Text>
)}
<View style={styles.container}>
<View style={[styles.separator, {width: separatorWidth}]}/>
<View style={styles.buttonContainer}>
<TouchableWithFeedback
onPress={onPress}
type='opacity'
style={[styles.button, styleButtonBackground]}
testID={testID}
>
{icon && (
<View style={styles.iconContainer}>
<CompassIcon
size={24}
name={icon}
color={iconColor}
/>
</View>
)}
{text && (
<Text
style={styleButtonText}
>{text}</Text>
)}
</TouchableWithFeedback>
</TouchableWithFeedback>
<View style={{paddingBottom: Platform.select({ios: (isTablet ? 20 : 32), android: 20})}}/>
</View>
</View>
);
}
export default BottomSheetButton;

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import {GestureResponderEvent, Platform, Text, useWindowDimensions, View} from 'react-native';
import {GestureResponderEvent, Text, useWindowDimensions, View} from 'react-native';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
@ -33,7 +33,7 @@ export const TITLE_SEPARATOR_MARGIN_TABLET = 20;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
flexGrow: 1,
},
titleContainer: {
marginTop: TITLE_MARGIN_TOP,
@ -83,18 +83,15 @@ const BottomSheetContent = ({buttonText, buttonIcon, children, disableButton, on
{children}
</>
{showButton && (
<>
<View style={[styles.separator, {width: separatorWidth}]}/>
<Button
disabled={disableButton}
onPress={onPress}
icon={buttonIcon}
testID={buttonTestId}
text={buttonText}
/>
<View style={{paddingBottom: Platform.select({ios: (isTablet ? 20 : 32), android: 20})}}/>
</>
)}
<Button
disabled={disableButton}
onPress={onPress}
icon={buttonIcon}
testID={buttonTestId}
text={buttonText}
/>
)
}
</View>
);
};

View file

@ -1,13 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ReactNode, useCallback, useEffect, useRef} from 'react';
import {DeviceEventEmitter, Keyboard, StyleSheet, View} from 'react-native';
import {State, TapGestureHandler} from 'react-native-gesture-handler';
import {Navigation as RNN} from 'react-native-navigation';
import Animated, {Easing, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import RNBottomSheet from 'reanimated-bottom-sheet';
import BottomSheetM, {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetFooterProps} from '@gorhom/bottom-sheet';
import React, {ReactNode, useCallback, useEffect, useMemo, useRef} from 'react';
import {DeviceEventEmitter, Handle, InteractionManager, Keyboard, StyleProp, View, ViewStyle} from 'react-native';
import useNavButtonPressed from '@app/hooks/navigation_button_pressed';
import {Events} from '@constants';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
@ -18,36 +16,97 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import Indicator from './indicator';
type SlideUpPanelProps = {
import type {WithSpringConfig} from 'react-native-reanimated';
export {default as BottomSheetButton, BUTTON_HEIGHT} from './button';
export {default as BottomSheetContent, TITLE_HEIGHT} from './content';
type Props = {
closeButtonId?: string;
componentId: string;
contentStyle?: StyleProp<ViewStyle>;
initialSnapIndex?: number;
footerComponent?: React.FC<BottomSheetFooterProps>;
renderContent: () => ReactNode;
snapPoints?: Array<string | number>;
testID?: string;
}
export const PADDING_TOP_MOBILE = 20;
export const PADDING_TOP_TABLET = 8;
const PADDING_TOP_MOBILE = 20;
const PADDING_TOP_TABLET = 8;
const BottomSheet = ({closeButtonId, componentId, initialSnapIndex = 0, renderContent, snapPoints = ['90%', '50%', 50], testID}: SlideUpPanelProps) => {
const sheetRef = useRef<RNBottomSheet>(null);
export const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
bottomSheet: {
borderTopStartRadius: 24,
borderTopEndRadius: 24,
shadowOffset: {
width: 0,
height: 8,
},
shadowOpacity: 0.12,
shadowRadius: 24,
shadowColor: '#000',
elevation: 24,
},
bottomSheetBackground: {
backgroundColor: theme.centerChannelBg,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
},
content: {
flex: 1,
paddingHorizontal: 20,
paddingTop: PADDING_TOP_MOBILE,
},
contentTablet: {
paddingTop: PADDING_TOP_TABLET,
},
separator: {
height: 1,
borderTopWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.08),
},
};
});
export const animatedConfig: Omit<WithSpringConfig, 'velocity'> = {
damping: 50,
mass: 0.3,
stiffness: 121.6,
overshootClamping: true,
restSpeedThreshold: 0.3,
restDisplacementThreshold: 0.3,
};
const BottomSheet = ({
closeButtonId,
componentId,
contentStyle,
initialSnapIndex = 1,
footerComponent,
renderContent,
snapPoints = [1, '50%', '80%'],
testID,
}: Props) => {
const sheetRef = useRef<BottomSheetM>(null);
const isTablet = useIsTablet();
const theme = useTheme();
const firstRun = useRef(isTablet);
const lastSnap = snapPoints.length - 1;
const backdropOpacity = useSharedValue(0);
const styles = getStyleSheet(theme);
const interaction = useRef<Handle>();
const bottomSheetBackgroundStyle = useMemo(() => [
styles.bottomSheetBackground,
{borderWidth: isTablet ? 0 : 1},
], [isTablet, styles]);
const close = useCallback(() => {
if (firstRun.current) {
dismissModal({componentId});
}
dismissModal({componentId});
}, [componentId]);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.CLOSE_BOTTOM_SHEET, () => {
if (sheetRef.current) {
sheetRef.current.snapTo(lastSnap);
sheetRef.current.close();
} else {
close();
}
@ -56,86 +115,53 @@ const BottomSheet = ({closeButtonId, componentId, initialSnapIndex = 0, renderCo
return () => listener.remove();
}, [close]);
const handleAnimationStart = useCallback(() => {
interaction.current = InteractionManager.createInteractionHandle();
}, []);
const handleClose = useCallback(() => {
if (sheetRef.current) {
sheetRef.current.snapTo(1);
sheetRef.current.close();
} else {
close();
}
}, []);
const handleCloseEnd = useCallback(() => {
if (firstRun.current) {
backdropOpacity.value = 0;
setTimeout(close, 250);
const handleChange = useCallback((index: number) => {
setTimeout(() => {
if (interaction.current) {
InteractionManager.clearInteractionHandle(interaction.current);
interaction.current = undefined;
}
});
if (index <= 0) {
close();
}
}, []);
const handleOpenStart = useCallback(() => {
backdropOpacity.value = 1;
}, []);
useAndroidHardwareBackHandler(componentId, handleClose);
useNavButtonPressed(closeButtonId || '', componentId, close, [close]);
useEffect(() => {
hapticFeedback();
Keyboard.dismiss();
sheetRef.current?.snapTo(initialSnapIndex);
backdropOpacity.value = 1;
const t = setTimeout(() => {
firstRun.current = true;
}, 100);
return () => clearTimeout(t);
}, []);
useEffect(() => {
const navigationEvents = RNN.events().registerNavigationButtonPressedListener(({buttonId}) => {
if (closeButtonId && buttonId === closeButtonId) {
close();
}
});
return () => navigationEvents.remove();
}, [close]);
const backdropStyle = useAnimatedStyle(() => ({
opacity: withTiming(backdropOpacity.value, {duration: 250, easing: Easing.inOut(Easing.linear)}),
backgroundColor: 'rgba(0, 0, 0, 0.6)',
}));
const renderBackdrop = () => {
const renderBackdrop = useCallback((props: BottomSheetBackdropProps) => {
return (
<TapGestureHandler
shouldCancelWhenOutside={true}
maxDist={10}
onHandlerStateChange={(event) => {
if (event.nativeEvent.state === State.END && event.nativeEvent.oldState === State.ACTIVE) {
sheetRef.current?.snapTo(lastSnap);
}
}}
testID={`${testID}.backdrop`}
>
<Animated.View
style={[StyleSheet.absoluteFill, backdropStyle]}
/>
</TapGestureHandler>
<BottomSheetBackdrop
{...props}
disappearsOnIndex={0}
appearsOnIndex={1}
opacity={0.6}
/>
);
};
}, []);
const renderContainerContent = () => (
<View
style={{
backgroundColor: theme.centerChannelBg,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
borderWidth: isTablet ? 0 : 1,
opacity: 1,
paddingHorizontal: 20,
paddingTop: isTablet ? PADDING_TOP_TABLET : PADDING_TOP_MOBILE,
height: '100%',
width: '100%',
alignSelf: 'center',
}}
style={[styles.content, isTablet && styles.contentTablet, contentStyle]}
testID={`${testID}.screen`}
>
{renderContent()}
@ -143,7 +169,6 @@ const BottomSheet = ({closeButtonId, componentId, initialSnapIndex = 0, renderCo
);
if (isTablet) {
const styles = getStyleSheet(theme);
return (
<>
<View style={styles.separator}/>
@ -153,32 +178,25 @@ const BottomSheet = ({closeButtonId, componentId, initialSnapIndex = 0, renderCo
}
return (
<>
<RNBottomSheet
ref={sheetRef}
snapPoints={snapPoints}
borderRadius={12}
initialSnap={snapPoints.length - 1}
renderContent={renderContainerContent}
onCloseEnd={handleCloseEnd}
onOpenStart={handleOpenStart}
enabledBottomInitialAnimation={false}
renderHeader={Indicator}
enabledContentTapInteraction={false}
/>
{renderBackdrop()}
</>
<BottomSheetM
ref={sheetRef}
index={initialSnapIndex}
snapPoints={snapPoints}
animateOnMount={true}
backdropComponent={renderBackdrop}
onAnimate={handleAnimationStart}
onChange={handleChange}
animationConfigs={animatedConfig}
handleComponent={Indicator}
style={styles.bottomSheet}
backgroundStyle={bottomSheetBackgroundStyle}
footerComponent={footerComponent}
keyboardBehavior='extend'
keyboardBlurBehavior='restore'
>
{renderContainerContent()}
</BottomSheetM>
);
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
separator: {
height: 1,
borderTopWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.08),
},
};
});
export default BottomSheet;

View file

@ -6,9 +6,9 @@ import {Animated, StyleSheet, View} from 'react-native';
const styles = StyleSheet.create({
dragIndicatorContainer: {
marginVertical: 10,
alignItems: 'center',
justifyContent: 'center',
top: -15,
},
dragIndicator: {
backgroundColor: 'white',

View file

@ -48,7 +48,7 @@ export default function ChannelDropdown({
sharedChannelsEnabled,
}: Props) {
const intl = useIntl();
const insets = useSafeAreaInsets();
const {bottom} = useSafeAreaInsets();
const theme = useTheme();
const style = getStyleFromTheme(theme);
@ -73,11 +73,11 @@ export default function ChannelDropdown({
items += 1;
}
const itemsSnap = bottomSheetSnapPoint(items, ITEM_HEIGHT, insets.bottom) + TITLE_HEIGHT;
const itemsSnap = bottomSheetSnapPoint(items, ITEM_HEIGHT, bottom) + TITLE_HEIGHT;
bottomSheet({
title: intl.formatMessage({id: 'browse_channels.dropdownTitle', defaultMessage: 'Show'}),
renderContent,
snapPoints: [itemsSnap, 10],
snapPoints: [1, itemsSnap],
closeButtonId: 'close',
theme,
});

View file

@ -11,6 +11,7 @@ import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {getUserIdFromChannelName} from '@utils/user';
import IntroOptions from '../options';
@ -98,23 +99,28 @@ const DirectChannel = ({channel, currentUserId, isBot, members, theme}: Props) =
}, [channel.displayName, theme]);
const profiles = useMemo(() => {
const channelMembers = members?.filter((m) => m.userId !== currentUserId);
if (!channelMembers?.length) {
return null;
}
if (channel.type === General.DM_CHANNEL) {
const teammateId = getUserIdFromChannelName(currentUserId, channel.name);
const teammate = members?.find((m) => m.userId === teammateId);
if (!teammate) {
return null;
}
return (
<Member
channelId={channel.id}
containerStyle={{height: 96}}
member={channelMembers[0]}
member={teammate}
size={96}
theme={theme}
/>
);
}
const channelMembers = members?.filter((m) => m.userId !== currentUserId);
if (!channelMembers?.length) {
return null;
}
return (
<Group
theme={theme}

View file

@ -4,14 +4,16 @@
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Platform, Text, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {bottomSheetSnapPoint} from '@app/utils/helpers';
import {CHANNEL_ACTIONS_OPTIONS_HEIGHT} from '@components/channel_actions/channel_actions';
import CompassIcon from '@components/compass_icon';
import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
import NavigationHeader from '@components/navigation_header';
import {ITEM_HEIGHT} from '@components/option_item';
import RoundedHeaderContext from '@components/rounded_header_context';
import {General, Screens} from '@constants';
import {QUICK_OPTIONS_HEIGHT} from '@constants/view';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header';
@ -22,7 +24,7 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import OtherMentionsBadge from './other_mentions_badge';
import QuickActions from './quick_actions';
import QuickActions, {MARGIN, SEPARATOR_HEIGHT} from './quick_actions';
import type {HeaderRightButton} from '@components/navigation_header/header';
@ -70,6 +72,7 @@ const ChannelHeader = ({
}: ChannelProps) => {
const intl = useIntl();
const isTablet = useIsTablet();
const {bottom} = useSafeAreaInsets();
const theme = useTheme();
const styles = getStyleSheet(theme);
const defaultHeight = useDefaultHeaderHeight();
@ -132,7 +135,8 @@ const ChannelHeader = ({
}
// When calls is enabled, we need space to move the "Copy Link" from a button to an option
const height = QUICK_OPTIONS_HEIGHT + (callsAvailable && !isDMorGM ? ITEM_HEIGHT : 0);
const items = callsAvailable && !isDMorGM ? 3 : 2;
const height = CHANNEL_ACTIONS_OPTIONS_HEIGHT + SEPARATOR_HEIGHT + MARGIN + (items * ITEM_HEIGHT);
const renderContent = () => {
return (
@ -147,11 +151,11 @@ const ChannelHeader = ({
bottomSheet({
title: '',
renderContent,
snapPoints: [height, 10],
snapPoints: [1, bottomSheetSnapPoint(1, height, bottom)],
theme,
closeButtonId: 'close-channel-quick-actions',
});
}, [channelId, isDMorGM, isTablet, onTitlePress, theme, callsAvailable]);
}, [bottom, channelId, isDMorGM, isTablet, onTitlePress, theme, callsAvailable]);
const rightButtons: HeaderRightButton[] = useMemo(() => ([

View file

@ -8,7 +8,6 @@ import ChannelActions from '@components/channel_actions';
import CopyChannelLinkOption from '@components/channel_actions/copy_channel_link_option';
import InfoBox from '@components/channel_actions/info_box';
import LeaveChannelLabel from '@components/channel_actions/leave_channel_label';
import {QUICK_OPTIONS_HEIGHT} from '@constants/view';
import {useTheme} from '@context/theme';
import {dismissBottomSheet} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -19,20 +18,23 @@ type Props = {
isDMorGM: boolean;
}
export const SEPARATOR_HEIGHT = 17;
export const MARGIN = 8;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
minHeight: QUICK_OPTIONS_HEIGHT,
flex: 1,
},
line: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
height: 1,
marginVertical: 8,
marginVertical: MARGIN,
},
wrapper: {
marginBottom: 8,
marginBottom: MARGIN,
},
separator: {
width: 8,
width: MARGIN,
},
}));

View file

@ -6,8 +6,8 @@ import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {General} from '@constants';
import {observeProfileLongPresTutorial} from '@queries/app/global';
import {General, Tutorial} from '@constants';
import {observeTutorialWatched} from '@queries/app/global';
import {observeConfigValue, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
import {observeTeammateNameDisplay} from '@queries/servers/user';
@ -24,7 +24,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
teammateNameDisplay: observeTeammateNameDisplay(database),
currentUserId: observeCurrentUserId(database),
currentTeamId: observeCurrentTeamId(database),
tutorialWatched: observeProfileLongPresTutorial(),
tutorialWatched: observeTutorialWatched(Tutorial.PROFILE_LONG_PRESS),
restrictDirectMessage,
};
});

View file

@ -9,7 +9,6 @@ import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import {updateLocalCustomStatus} from '@actions/local/user';
import {removeRecentCustomStatus, updateCustomStatus, unsetCustomStatus} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import TabletTitle from '@components/tablet_title';
import {Events, Screens} from '@constants';
import {CustomStatusDurationEnum, SET_CUSTOM_STATUS_FAILURE} from '@constants/custom_status';
@ -18,7 +17,7 @@ import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {dismissModal, goToScreen, showModal} from '@screens/navigation';
import {dismissModal, goToScreen, openAsBottomSheet, showModal} from '@screens/navigation';
import {getCurrentMomentForTimezone, getRoundedTime} from '@utils/helpers';
import {logDebug} from '@utils/log';
import {mergeNavigationOptions} from '@utils/navigation';
@ -285,12 +284,12 @@ const CustomStatus = ({
}, [newStatus, isStatusSet, storedStatus, currentUser]);
const openEmojiPicker = useCallback(preventDoubleTap(() => {
CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor).then((source) => {
const screen = Screens.EMOJI_PICKER;
const title = intl.formatMessage({id: 'mobile.custom_status.choose_emoji', defaultMessage: 'Choose an emoji'});
const passProps = {closeButton: source, onEmojiPress: handleEmojiClick};
showModal(screen, title, passProps);
openAsBottomSheet({
closeButtonId: 'close-emoji-picker',
screen: Screens.EMOJI_PICKER,
theme,
title: intl.formatMessage({id: 'mobile.custom_status.choose_emoji', defaultMessage: 'Choose an emoji'}),
props: {onEmojiPress: handleEmojiClick},
});
}), [theme, intl, handleEmojiClick]);

View file

@ -14,6 +14,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import NetworkManager from '@managers/network_manager';
import {TITLE_HEIGHT} from '@screens/bottom_sheet/content';
import PanelItem from '@screens/edit_profile/components/panel_item';
import {bottomSheet} from '@screens/navigation';
import PickerUtil from '@utils/file/file_picker';
@ -121,13 +122,12 @@ const ProfileImagePicker = ({
);
};
const snapPointsCount = canRemovePicture ? 5 : 4;
const snapPoint = bottomSheetSnapPoint(snapPointsCount, ITEM_HEIGHT, bottom);
const snapPoint = bottomSheetSnapPoint(4, ITEM_HEIGHT, bottom) + TITLE_HEIGHT;
return bottomSheet({
closeButtonId: 'close-edit-profile',
renderContent,
snapPoints: [snapPoint, 10],
snapPoints: [1, snapPoint],
title: 'Change profile photo',
theme,
});

View file

@ -1,51 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect} from 'react';
import {Keyboard} from 'react-native';
import React, {useCallback} from 'react';
import {DeviceEventEmitter, StyleSheet} from 'react-native';
import EmojiPicker from '@components/emoji_picker';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {dismissModal, setButtons} from '@screens/navigation';
import {Events} from '@constants';
import BottomSheet from '@screens/bottom_sheet';
import Picker from './picker';
import PickerFooter from './picker/footer';
type Props = {
componentId: string;
onEmojiPress: (emoji: string) => void;
closeButton: never;
closeButtonId: string;
};
const EMOJI_PICKER_BUTTON = 'close-add-reaction';
const EmojiPickerScreen = ({closeButton, componentId, onEmojiPress}: Props) => {
useEffect(() => {
setButtons(componentId, {
leftButtons: [
{
icon: closeButton,
id: EMOJI_PICKER_BUTTON,
testID: 'close.emoji_picker.button',
},
],
rightButtons: [],
});
}, []);
const close = () => {
Keyboard.dismiss();
dismissModal({componentId});
};
useNavButtonPressed(EMOJI_PICKER_BUTTON, componentId, close, []);
const style = StyleSheet.create({
contentStyle: {
paddingTop: 14,
},
});
const EmojiPickerScreen = ({closeButtonId, componentId, onEmojiPress}: Props) => {
const handleEmojiPress = useCallback((emoji: string) => {
onEmojiPress(emoji);
close();
DeviceEventEmitter.emit(Events.CLOSE_BOTTOM_SHEET);
}, []);
const renderContent = useCallback(() => {
return (
<Picker
onEmojiPress={handleEmojiPress}
testID='emoji_picker'
/>
);
}, []);
return (
<EmojiPicker
onEmojiPress={handleEmojiPress}
testID='emoji_picker'
<BottomSheet
renderContent={renderContent}
closeButtonId={closeButtonId}
componentId={componentId}
contentStyle={style.contentStyle}
initialSnapIndex={1}
footerComponent={PickerFooter}
testID='post_options'
/>
);
};

View file

@ -18,28 +18,31 @@ type Props = {
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
width: 32,
height: 32,
alignItems: 'center',
flex: 1,
height: 35,
justifyContent: 'center',
zIndex: 10,
},
icon: {
color: changeOpacity(theme.centerChannelColor, 0.4),
color: changeOpacity(theme.centerChannelColor, 0.56),
},
selectedContainer: {
backgroundColor: changeOpacity(theme.buttonBg, 0.08),
borderRadius: 4,
},
selected: {
color: theme.centerChannelColor,
color: theme.buttonBg,
},
}));
const SectionIcon = ({currentIndex, icon, index, scrollToIndex, theme}: Props) => {
const EmojiCategoryBarIcon = ({currentIndex, icon, index, scrollToIndex, theme}: Props) => {
const style = getStyleSheet(theme);
const onPress = useCallback(preventDoubleTap(() => scrollToIndex(index)), []);
return (
<TouchableOpacity
onPress={onPress}
style={style.container}
style={[style.container, currentIndex === index ? style.selectedContainer : undefined]}
>
<CompassIcon
name={icon}
@ -50,4 +53,4 @@ const SectionIcon = ({currentIndex, icon, index, scrollToIndex, theme}: Props) =
);
};
export default SectionIcon;
export default EmojiCategoryBarIcon;

View file

@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {View} from 'react-native';
import {useTheme} from '@context/theme';
import {selectEmojiCategoryBarSection, useEmojiCategoryBar} from '@hooks/emoji_category_bar';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import EmojiCategoryBarIcon from './icon';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
justifyContent: 'space-between',
backgroundColor: theme.centerChannelBg,
height: 55,
paddingHorizontal: 12,
paddingTop: 11,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.08),
borderTopWidth: 1,
flexDirection: 'row',
},
}));
type Props = {
onSelect?: (index: number | undefined) => void;
}
const EmojiCategoryBar = ({onSelect}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const {currentIndex, icons} = useEmojiCategoryBar();
const scrollToIndex = useCallback((index: number) => {
if (onSelect) {
onSelect(index);
return;
}
selectEmojiCategoryBarSection(index);
}, []);
if (!icons) {
return null;
}
return (
<View
style={styles.container}
testID='emoji_picker.category_bar'
>
{icons.map((icon, index) => (
<EmojiCategoryBarIcon
currentIndex={currentIndex}
key={icon.key}
icon={icon.icon}
index={index}
scrollToIndex={scrollToIndex}
theme={theme}
/>
))}
</View>
);
};
export default EmojiCategoryBar;

View file

@ -1,11 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BottomSheetFlatList} from '@gorhom/bottom-sheet';
import Fuse from 'fuse.js';
import React, {useCallback, useMemo} from 'react';
import {FlatList, ListRenderItemInfo, StyleSheet, View} from 'react-native';
import NoResultsWithTerm from '@components/no_results_with_term';
import {useIsTablet} from '@hooks/device';
import {getEmojis, searchEmojis} from '@utils/emoji/helpers';
import EmojiItem from './emoji_item';
@ -14,7 +16,6 @@ import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji
type Props = {
customEmojis: CustomEmojiModel[];
keyboardHeight: number;
skinTone: string;
searchTerm: string;
onEmojiPress: (emojiName: string) => void;
@ -28,9 +29,9 @@ const style = StyleSheet.create({
},
});
const EmojiFiltered = ({customEmojis, keyboardHeight, skinTone, searchTerm, onEmojiPress}: Props) => {
const EmojiFiltered = ({customEmojis, skinTone, searchTerm, onEmojiPress}: Props) => {
const isTablet = useIsTablet();
const emojis = useMemo(() => getEmojis(skinTone, customEmojis), [skinTone, customEmojis]);
const flatListStyle = useMemo(() => ({flexGrow: 1, paddingBottom: keyboardHeight}), [keyboardHeight]);
const fuse = useMemo(() => {
const options = {findAllMatches: true, ignoreLocation: true, includeMatches: true, shouldSort: false, includeScore: true};
@ -45,6 +46,8 @@ const EmojiFiltered = ({customEmojis, keyboardHeight, skinTone, searchTerm, onEm
return searchEmojis(fuse, searchTerm);
}, [fuse, searchTerm]);
const List = useMemo(() => (isTablet ? FlatList : BottomSheetFlatList), [isTablet]);
const keyExtractor = useCallback((item: string) => item, []);
const renderEmpty = useCallback(() => {
@ -65,8 +68,7 @@ const EmojiFiltered = ({customEmojis, keyboardHeight, skinTone, searchTerm, onEm
}, []);
return (
<FlatList
contentContainerStyle={flatListStyle}
<List
data={data}
initialNumToRender={30}
keyboardDismissMode='interactive'

View file

@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {Preferences} from '@constants';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import EmojiFiltered from './filtered';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
skinTone: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE).
observeWithColumns(['value']).pipe(
switchMap((prefs) => of$(prefs?.[0]?.value ?? 'default')),
),
}));
export default withDatabase(enhanced(EmojiFiltered));

View file

@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BottomSheetFooter, BottomSheetFooterProps, SHEET_STATE, useBottomSheet, useBottomSheetInternal} from '@gorhom/bottom-sheet';
import React, {useCallback} from 'react';
import {Platform} from 'react-native';
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import {useTheme} from '@context/theme';
import {useKeyboardHeight} from '@hooks/device';
import {selectEmojiCategoryBarSection} from '@hooks/emoji_category_bar';
import EmojiCategoryBar from '../emoji_category_bar';
const PickerFooter = (props: BottomSheetFooterProps) => {
const theme = useTheme();
const keyboardHeight = useKeyboardHeight();
const {animatedSheetState} = useBottomSheetInternal();
const {expand} = useBottomSheet();
const scrollToIndex = useCallback((index: number) => {
if (animatedSheetState.value === SHEET_STATE.EXTENDED) {
selectEmojiCategoryBarSection(index);
return;
}
expand();
// @ts-expect-error wait until the bottom sheet is epanded
while (animatedSheetState.value !== SHEET_STATE.EXTENDED) {
// do nothing
}
selectEmojiCategoryBarSection(index);
}, []);
const animatedStyle = useAnimatedStyle(() => {
const paddingBottom = withTiming(
Platform.OS === 'ios' ? 20 : 0,
{duration: 250},
);
return {backgroundColor: theme.centerChannelBg, paddingBottom};
}, [theme]);
const heightAnimatedStyle = useAnimatedStyle(() => {
let height = 55;
if (keyboardHeight === 0 && Platform.OS === 'ios') {
height += 20;
} else if (keyboardHeight) {
height = 0;
}
return {
height,
};
}, [keyboardHeight]);
return (
<BottomSheetFooter
style={heightAnimatedStyle}
{...props}
>
<Animated.View style={[animatedStyle]}>
<EmojiCategoryBar onSelect={scrollToIndex}/>
</Animated.View>
</BottomSheetFooter>
);
};
export default PickerFooter;

View file

@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useBottomSheet} from '@gorhom/bottom-sheet';
import React, {useCallback} from 'react';
import {NativeSyntheticEvent, TextInputFocusEventData} from 'react-native';
import SearchBar, {SearchProps} from '@components/search';
const BottomSheetSearch = ({onFocus, ...props}: SearchProps) => {
const {expand} = useBottomSheet();
const handleOnFocus = useCallback((event: NativeSyntheticEvent<TextInputFocusEventData>) => {
expand();
onFocus?.(event);
}, [onFocus, expand]);
return (
<SearchBar
onFocus={handleOnFocus}
{...props}
/>
);
};
export default BottomSheetSearch;

View file

@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect} from 'react';
import {LayoutChangeEvent, StyleSheet, View} from 'react-native';
import {useSharedValue} from 'react-native-reanimated';
import SearchBar, {SearchProps} from '@components/search';
import {useIsTablet} from '@hooks/device';
import {setEmojiSkinTone} from '@hooks/emoji_category_bar';
import BottomSheetSearch from './bottom_sheet_search';
import SkinToneSelector from './skintone_selector';
type Props = SearchProps & {
skinTone: string;
}
const styles = StyleSheet.create({
flex: {flex: 1},
row: {flexDirection: 'row'},
});
const PickerHeader = ({skinTone, ...props}: Props) => {
const isTablet = useIsTablet();
const containerWidth = useSharedValue(0);
const isSearching = useSharedValue(false);
useEffect(() => {
const req = requestAnimationFrame(() => {
setEmojiSkinTone(skinTone);
});
return () => cancelAnimationFrame(req);
}, [skinTone]);
const onBlur = useCallback(() => {
isSearching.value = false;
}, []);
const onFocus = useCallback(() => {
isSearching.value = true;
}, []);
const onLayout = useCallback((e: LayoutChangeEvent) => {
containerWidth.value = e.nativeEvent.layout.width;
}, []);
let search;
if (isTablet) {
search = (
<SearchBar
{...props}
onBlur={onBlur}
onFocus={onFocus}
/>
);
} else {
search = (
<BottomSheetSearch
{...props}
onBlur={onBlur}
onFocus={onFocus}
/>
);
}
return (
<View
onLayout={onLayout}
style={styles.row}
>
<View style={styles.flex}>
{search}
</View>
<SkinToneSelector
skinTone={skinTone}
containerWidth={containerWidth}
isSearching={isSearching}
/>
</View>
);
};
export default PickerHeader;

View file

@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {Preferences} from '@constants';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import PickerHeader from './header';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
skinTone: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE).
observeWithColumns(['value']).pipe(
switchMap((prefs) => of$(prefs?.[0]?.value ?? 'default')),
),
}));
export default withDatabase(enhanced(PickerHeader));

View file

@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {TouchableOpacity} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {changeOpacity} from '@utils/theme';
type Props = {
collapse: () => void;
};
const hitSlop = {top: 10, bottom: 10, left: 10, right: 10};
const CloseButton = ({collapse}: Props) => {
const theme = useTheme();
return (
<TouchableOpacity
hitSlop={hitSlop}
onPress={collapse}
>
<CompassIcon
name='close'
size={24}
color={changeOpacity(theme.centerChannelColor, 0.56)}
/>
</TouchableOpacity>
);
};
export default CloseButton;

View file

@ -0,0 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import withObservables from '@nozbe/with-observables';
import {Tutorial} from '@constants';
import {observeTutorialWatched} from '@queries/app/global';
import SkinToneSelector from './skintone_selector';
const enhance = withObservables([], () => ({
tutorialWatched: observeTutorialWatched(Tutorial.EMOJI_SKIN_SELECTOR),
}));
export default enhance(SkinToneSelector);

View file

@ -0,0 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {View} from 'react-native';
import {savePreferredSkinTone} from '@actions/remote/preference';
import FormattedText from '@components/formatted_text';
import TouchableEmoji from '@components/touchable_emoji';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {skinCodes} from '@utils/emoji';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
onSelectSkin: () => void;
selected: string;
skins: Record<string, string>;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
width: 42,
alignItems: 'center',
justifyContent: 'center',
},
selected: {
backgroundColor: changeOpacity(theme.buttonBg, 0.08),
borderRadius: 4,
},
skins: {
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between',
},
textContainer: {
marginHorizontal: 16,
maxWidth: 57,
},
text: {
color: theme.centerChannelColor,
...typography('Body', 75, 'SemiBold'),
},
}));
const SkinSelector = ({onSelectSkin, selected, skins}: Props) => {
const isTablet = useIsTablet();
const theme = useTheme();
const serverUrl = useServerUrl();
const styles = getStyleSheet(theme);
const handleSelectSkin = useCallback(async (emoji: string) => {
const skin = emoji.split('hand_')[1] || 'default';
const code = Object.keys(skinCodes).find((key) => skinCodes[key] === skin) || 'default';
await savePreferredSkinTone(serverUrl, code);
onSelectSkin();
}, [serverUrl]);
return (
<>
<View style={[styles.textContainer, isTablet && {marginLeft: 0}]}>
<FormattedText
id='default_skin_tone'
defaultMessage='Default Skin Tone'
style={styles.text}
/>
</View>
<View style={[styles.skins, isTablet && {marginRight: 10}]}>
{Object.keys(skins).map((key) => {
const name = skins[key];
return (
<View
key={name}
style={[styles.container, selected === key && styles.selected]}
>
<TouchableEmoji
name={name}
size={28}
onEmojiPress={handleSelectSkin}
/>
</View>
);
})}
</View>
</>
);
};
export default SkinSelector;

View file

@ -0,0 +1,182 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Platform, StyleSheet} from 'react-native';
import Animated, {
EntryAnimationsValues, ExitAnimationsValues, FadeIn, FadeOut,
SharedValue, useAnimatedStyle, withDelay, withTiming,
} from 'react-native-reanimated';
import Tooltip from 'react-native-walkthrough-tooltip';
import {storeSkinEmojiSelectorTutorial} from '@actions/app/global';
import TouchableEmoji from '@components/touchable_emoji';
import {useIsTablet} from '@hooks/device';
import {skinCodes} from '@utils/emoji';
import CloseButton from './close_button';
import SkinSelector from './skin_selector';
import SkinSelectorTooltip from './tooltip';
type Props = {
containerWidth: SharedValue<number>;
isSearching: SharedValue<boolean>;
skinTone?: string;
tutorialWatched: boolean;
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
},
expanded: {
alignItems: 'center',
flexDirection: 'row',
width: '100%',
zIndex: 2,
},
tooltipStyle: {
shadowColor: '#000',
shadowOffset: {width: 0, height: 2},
shadowRadius: 2,
shadowOpacity: 0.16,
},
});
const skins = Object.keys(skinCodes).reduce<Record<string, string>>((result, value) => {
const skin = skinCodes[value];
if (value === 'default') {
result[value] = 'hand';
} else {
result[value] = `hand_${skin}`;
}
return result;
}, {});
const SkinToneSelector = ({skinTone = 'default', containerWidth, isSearching, tutorialWatched}: Props) => {
const [expanded, setExpanded] = useState(false);
const [tooltipVisible, setTooltipVisible] = useState(false);
const isTablet = useIsTablet();
const tooltipContentStyle = useMemo(() => ({
borderRadius: 8,
maxWidth: isTablet ? 352 : undefined,
padding: 0,
}), [isTablet]);
const exiting = useCallback((values: ExitAnimationsValues) => {
'worklet';
const animations = {
originX: withTiming(containerWidth.value, {duration: 250}),
opacity: withTiming(0, {duration: 250}),
};
const initialValues = {
originX: values.currentOriginX,
opacity: 1,
};
return {
initialValues,
animations,
};
}, [containerWidth.value]);
const entering = useCallback((values: EntryAnimationsValues) => {
'worklet';
const animations = {
originX: withTiming(values.targetOriginX, {duration: 250}),
opacity: withTiming(1, {duration: 300}),
};
const initialValues = {
originX: containerWidth.value - 122,
opacity: 0,
};
return {
initialValues,
animations,
};
}, [containerWidth.value]);
const collapse = useCallback(() => {
setExpanded(false);
}, []);
const expand = useCallback(() => {
setExpanded(true);
}, []);
const close = useCallback(() => {
setTooltipVisible(false);
storeSkinEmojiSelectorTutorial();
}, []);
const widthAnimatedStyle = useAnimatedStyle(() => {
return {
width: withDelay(isSearching.value ? 0 : 700, withTiming(isSearching.value ? 0 : 32, {duration: isSearching.value ? 50 : 300})),
marginLeft: Platform.OS === 'android' ? 10 : undefined,
};
}, []);
const opacityStyle = useAnimatedStyle(() => {
return {
opacity: withDelay(isSearching.value ? 0 : 700, withTiming(isSearching.value ? 0 : 1, {duration: isSearching.value ? 50 : 350})),
};
}, []);
useEffect(() => {
const t = setTimeout(() => {
if (!tutorialWatched) {
setTooltipVisible(true);
}
}, 750);
return () => clearTimeout(t);
}, []);
return (
<>
{!expanded &&
<Tooltip
isVisible={tooltipVisible}
useInteractionManager={true}
contentStyle={tooltipContentStyle}
content={<SkinSelectorTooltip onClose={close}/>}
placement={isTablet ? 'left' : 'top'}
onClose={close}
tooltipStyle={styles.tooltipStyle}
>
<Animated.View
style={widthAnimatedStyle}
exiting={FadeOut}
entering={FadeIn}
>
<Animated.View style={[styles.container, opacityStyle]}>
<TouchableEmoji
name={skins[skinTone]}
onEmojiPress={expand}
size={28}
/>
</Animated.View>
</Animated.View>
</Tooltip>
}
{expanded &&
<Animated.View
style={styles.expanded}
entering={entering}
exiting={exiting}
>
{!isTablet && <CloseButton collapse={collapse}/>}
<SkinSelector
selected={skinTone}
skins={skins}
onSelectSkin={collapse}
/>
{isTablet && <CloseButton collapse={collapse}/>}
</Animated.View>
}
</>
);
};
export default SkinToneSelector;

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