Support for Android Tablets & Foldable (#7025)

* Add Support for Android tablets & foldables

* add tablet and book posture

* Regenerate disposed observable on WindowInfoTracker
This commit is contained in:
Elias Nahum
2023-01-26 20:31:18 +02:00
committed by GitHub
parent 8be99d2c70
commit c1fbaffd3e
32 changed files with 1180 additions and 910 deletions

View File

@@ -206,6 +206,10 @@ dependencies {
}
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
implementation 'io.reactivex.rxjava3:rxjava:3.1.6'
implementation 'io.reactivex.rxjava3:rxandroid:3.0.2'
implementation 'androidx.window:window-rxjava3:1.0.0'
implementation 'androidx.window:window:1.0.0'
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'com.google.android.material:material:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'

View File

@@ -0,0 +1,58 @@
package com.mattermost.rnbeta
import android.app.Activity
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import androidx.window.layout.WindowLayoutInfo
import androidx.window.rxjava3.layout.windowLayoutInfoObservable
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
class FoldableObserver(private val activity: Activity) {
private var disposable: Disposable? = null
private lateinit var observable: Observable<WindowLayoutInfo>
public fun onCreate() {
observable = WindowInfoTracker.getOrCreate(activity)
.windowLayoutInfoObservable(activity)
}
public fun onStart() {
if (disposable?.isDisposed == true) {
onCreate()
}
disposable = observable.observeOn(AndroidSchedulers.mainThread())
.subscribe { layoutInfo ->
val splitViewModule = SplitViewModule.getInstance()
val foldingFeature = layoutInfo.displayFeatures
.filterIsInstance<FoldingFeature>()
.firstOrNull()
when {
foldingFeature?.state === FoldingFeature.State.FLAT ->
splitViewModule?.setDeviceFolded(false)
isTableTopPosture(foldingFeature) ->
splitViewModule?.setDeviceFolded(false)
isBookPosture(foldingFeature) ->
splitViewModule?.setDeviceFolded(false)
else -> {
splitViewModule?.setDeviceFolded(true)
}
}
}
}
public fun onStop() {
disposable?.dispose()
}
private fun isTableTopPosture(foldFeature : FoldingFeature?) : Boolean {
return foldFeature?.state == FoldingFeature.State.HALF_OPENED &&
foldFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
}
private fun isBookPosture(foldFeature : FoldingFeature?) : Boolean {
return foldFeature?.state == FoldingFeature.State.HALF_OPENED &&
foldFeature.orientation == FoldingFeature.Orientation.VERTICAL
}
}

View File

@@ -12,9 +12,9 @@ import com.github.emilioicai.hwkeyboardevent.HWKeyboardEventModule;
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
import com.facebook.react.defaults.DefaultReactActivityDelegate;
public class MainActivity extends NavigationActivity {
private boolean HWKeyboardConnected = false;
private FoldableObserver foldableObserver = new FoldableObserver(this);
@Override
protected String getMainComponentName() {
@@ -43,6 +43,19 @@ public class MainActivity extends NavigationActivity {
super.onCreate(null);
setContentView(R.layout.launch_screen);
setHWKeyboardConnected();
foldableObserver.onCreate();
}
@Override
protected void onStart() {
super.onStart();
foldableObserver.onStart();
}
@Override
protected void onStop() {
super.onStop();
foldableObserver.onStop();
}
@Override

View File

@@ -1,7 +1,9 @@
package com.mattermost.rnbeta;
import android.content.Context;
import android.os.Bundle;
import android.util.Log;
import java.io.File;
import java.util.Collections;
import java.util.HashMap;
@@ -42,7 +44,6 @@ public class MainApplication extends NavigationApplication implements INotificat
public static MainApplication instance;
public Boolean sharedExtensionIsOpened = false;
private final ReactNativeHost mReactNativeHost =
new DefaultReactNativeHost(this) {
@Override
@@ -69,6 +70,8 @@ public class MainApplication extends NavigationApplication implements INotificat
return ShareModule.getInstance(reactContext);
case "Notifications":
return NotificationsModule.getInstance(instance, reactContext);
case "SplitView":
return SplitViewModule.Companion.getInstance(reactContext);
default:
throw new IllegalArgumentException("Could not find module " + name);
}
@@ -81,6 +84,7 @@ public class MainApplication extends NavigationApplication implements INotificat
map.put("MattermostManaged", new ReactModuleInfo("MattermostManaged", "com.mattermost.rnbeta.MattermostManagedModule", false, false, false, false, false));
map.put("MattermostShare", new ReactModuleInfo("MattermostShare", "com.mattermost.share.ShareModule", false, false, true, false, false));
map.put("Notifications", new ReactModuleInfo("Notifications", "com.mattermost.rnbeta.NotificationsModule", false, false, false, false, false));
map.put("SplitView", new ReactModuleInfo("SplitView", "com.mattermost.rnbeta.SplitViewModule", false, false, false, false, false));
return map;
};
}

View File

@@ -133,19 +133,6 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule {
promise.resolve(map);
}
@ReactMethod
public void isRunningInSplitView(final Promise promise) {
WritableMap result = Arguments.createMap();
Activity current = getCurrentActivity();
if (current != null) {
result.putBoolean("isSplitView", current.isInMultiWindowMode());
} else {
result.putBoolean("isSplitView", false);
}
promise.resolve(result);
}
@ReactMethod
public void saveFile(String path, final Promise promise) {
Uri contentUri;

View File

@@ -0,0 +1,73 @@
package com.mattermost.rnbeta
import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
import com.learnium.RNDeviceInfo.resolver.DeviceTypeResolver
class SplitViewModule(private var reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
private var isDeviceFolded: Boolean = false
private var listenerCount = 0
companion object {
private var instance: SplitViewModule? = null
fun getInstance(reactContext: ReactApplicationContext): SplitViewModule {
if (instance == null) {
instance = SplitViewModule(reactContext)
} else {
instance!!.reactContext = reactContext
}
return instance!!
}
fun getInstance(): SplitViewModule? {
return instance
}
}
override fun getName() = "SplitView"
fun sendEvent(eventName: String,
params: WritableMap?) {
reactContext
.getJSModule(RCTDeviceEventEmitter::class.java)
.emit(eventName, params)
}
private fun getSplitViewResults(folded: Boolean) : WritableMap? {
if (currentActivity != null) {
val deviceResolver = DeviceTypeResolver(this.reactContext)
val map = Arguments.createMap()
map.putBoolean("isSplitView", currentActivity!!.isInMultiWindowMode || folded)
map.putBoolean("isTablet", deviceResolver.isTablet)
return map
}
return null
}
fun setDeviceFolded(folded: Boolean) {
val map = getSplitViewResults(folded)
if (listenerCount > 0 && isDeviceFolded != folded) {
sendEvent("SplitViewChanged", map)
}
isDeviceFolded = folded
}
@ReactMethod
fun isRunningInSplitView(promise: Promise) {
promise.resolve(getSplitViewResults(isDeviceFolded))
}
@ReactMethod
fun addListener(eventName: String) {
listenerCount += 1
}
@ReactMethod
fun removeListeners(count: Int) {
listenerCount -= count
}
}

View File

@@ -1,13 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Platform} from 'react-native';
import DeviceInfo from 'react-native-device-info';
import FileSystem from 'react-native-fs';
export default {
DOCUMENTS_PATH: `${FileSystem.CachesDirectoryPath}/Documents`,
IS_TABLET: Platform.select({android: false, default: DeviceInfo.isTablet()}),
IS_TABLET: DeviceInfo.isTablet(),
PUSH_NOTIFY_ANDROID_REACT_NATIVE: 'android_rn',
PUSH_NOTIFY_APPLE_REACT_NATIVE: 'apple_rn',
};

View File

@@ -2,27 +2,36 @@
// See LICENSE.txt for license information.
import React, {RefObject, useEffect, useRef, useState} from 'react';
import {AppState, Keyboard, NativeModules, Platform, useWindowDimensions, View} from 'react-native';
import {AppState, Keyboard, NativeEventEmitter, NativeModules, Platform, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {Device} from '@constants';
import type {KeyboardTrackingViewRef} from 'react-native-keyboard-tracking-view';
const {MattermostManaged} = NativeModules;
const isRunningInSplitView = MattermostManaged.isRunningInSplitView;
const {SplitView} = NativeModules;
const {isRunningInSplitView} = SplitView;
const emitter = new NativeEventEmitter(SplitView);
export function useSplitView() {
const [isSplitView, setIsSplitView] = useState(false);
const dimensions = useWindowDimensions();
useEffect(() => {
if (Device.IS_TABLET) {
isRunningInSplitView().then((result: {isSplitView: boolean}) => {
setIsSplitView(result.isSplitView);
isRunningInSplitView().then((result: SplitViewResult) => {
if (result.isSplitView != null) {
setIsSplitView(result.isSplitView);
}
});
}
}, [dimensions]);
const listener = emitter.addListener('SplitViewChanged', (result: SplitViewResult) => {
if (result.isSplitView != null) {
setIsSplitView(result.isSplitView);
}
});
return () => listener.remove();
}, []);
return isSplitView;
}

View File

@@ -1,18 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Alert, DeviceEventEmitter, Linking} from 'react-native';
import {Alert, DeviceEventEmitter, Linking, NativeEventEmitter, NativeModules} from 'react-native';
import RNLocalize from 'react-native-localize';
import semver from 'semver';
import {switchToChannelById} from '@actions/remote/channel';
import {autoUpdateTimezone} from '@actions/remote/user';
import LocalConfig from '@assets/config.json';
import {Events, Sso} from '@constants';
import {Device, Events, Sso} from '@constants';
import {MIN_REQUIRED_VERSION} from '@constants/supported_server';
import DatabaseManager from '@database/manager';
import {DEFAULT_LOCALE, getTranslations, t} from '@i18n';
import {getServerCredentials} from '@init/credentials';
import * as analytics from '@managers/analytics';
import {getAllServers} from '@queries/app/servers';
import {getAllServers, getActiveServerUrl} from '@queries/app/servers';
import {queryTeamDefaultChannel} from '@queries/servers/channel';
import {getCommonSystemValues} from '@queries/servers/system';
import {getTeamChannelHistory} from '@queries/servers/team';
import {setScreensOrientation} from '@screens/navigation';
import {handleDeepLink} from '@utils/deep_link';
import {logError} from '@utils/log';
@@ -20,6 +26,9 @@ import type {jsAndNativeErrorHandler} from '@typings/global/error_handling';
type LinkingCallbackArg = {url: string};
const {SplitView} = NativeModules;
const splitViewEmitter = new NativeEventEmitter(SplitView);
class GlobalEventHandler {
JavascriptAndNativeErrorHandler: jsAndNativeErrorHandler | undefined;
@@ -39,6 +48,7 @@ class GlobalEventHandler {
}
});
splitViewEmitter.addListener('SplitViewChanged', this.onSplitViewChanged);
Linking.addEventListener('url', this.onDeepLink);
}
@@ -93,6 +103,38 @@ class GlobalEventHandler {
}
};
onSplitViewChanged = async (result: SplitViewResult) => {
if (result.isTablet != null && Device.IS_TABLET !== result.isTablet) {
Device.IS_TABLET = result.isTablet;
const serverUrl = await getActiveServerUrl();
if (serverUrl && result.isTablet) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const {currentChannelId, currentTeamId} = await getCommonSystemValues(database);
if (currentTeamId && !currentChannelId) {
let channelId = '';
const teamChannelHistory = await getTeamChannelHistory(database, currentTeamId);
if (teamChannelHistory.length) {
channelId = teamChannelHistory[0];
} else {
const defaultChannel = await queryTeamDefaultChannel(database, currentTeamId).fetch();
if (defaultChannel.length) {
channelId = defaultChannel[0].id;
}
}
if (channelId) {
switchToChannelById(serverUrl, channelId);
}
}
} catch {
// do nothing, the UI will not show a channel but that is fixed when the user picks one.
}
}
setScreensOrientation(result.isTablet);
}
};
serverUpgradeNeeded = async (serverUrl: string) => {
const credentials = await getServerCredentials(serverUrl);

View File

@@ -15,7 +15,7 @@ import CompassIcon from '@components/compass_icon';
import {Screens} from '@constants';
import {CURRENT_CALL_BAR_HEIGHT} from '@constants/view';
import {useTheme} from '@context/theme';
import {dismissAllModalsAndPopToScreen} from '@screens/navigation';
import {allOrientations, dismissAllModalsAndPopToScreen} from '@screens/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {displayUsername} from '@utils/user';
@@ -105,7 +105,7 @@ const CurrentCallBar = ({
layout: {
backgroundColor: '#000',
componentBackgroundColor: '#000',
orientation: ['portrait', 'landscape'],
orientation: allOrientations,
},
topBar: {
background: {

View File

@@ -40,12 +40,14 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import DatabaseManager from '@database/manager';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device';
import {
bottomSheet,
dismissAllModalsAndPopToScreen,
dismissBottomSheet,
goToScreen,
popTopScreen,
setScreensOrientation,
} from '@screens/navigation';
import NavigationStore from '@store/navigation_store';
import {bottomSheetSnapPoint} from '@utils/helpers';
@@ -261,6 +263,7 @@ const CallScreen = ({
const theme = useTheme();
const {bottom} = useSafeAreaInsets();
const {width, height} = useWindowDimensions();
const isTablet = useIsTablet();
const serverUrl = useServerUrl();
const {EnableRecordings} = useCallsConfig(serverUrl);
usePermissionsChecker(micPermissionsGranted);
@@ -432,6 +435,16 @@ const CallScreen = ({
return () => listener.remove();
}, []);
useEffect(() => {
const didDismissListener = Navigation.events().registerComponentDidDisappearListener(async ({componentId: screen}) => {
if (componentId === screen) {
setScreensOrientation(isTablet);
}
});
return () => didDismissListener.remove();
}, [isTablet]);
if (!currentCall || !myParticipant) {
// Note: this happens because the screen is "rendered", even after the screen has been popped, and the
// currentCall will have already been set to null when those extra renders run. We probably don't ever need

View File

@@ -187,7 +187,7 @@ export const queryAllMyChannel = (database: Database) => {
};
export const queryAllMyChannelsForTeam = (database: Database, teamId: string) => {
return database.get<ChannelModel>(MY_CHANNEL).query(
return database.get<MyChannelModel>(MY_CHANNEL).query(
Q.on(CHANNEL, Q.where('team_id', Q.oneOf([teamId, '']))),
);
};

View File

@@ -3,12 +3,11 @@
import React, {useCallback, useMemo, useRef, useState} from 'react';
import {NativeModules, useWindowDimensions, Platform} from 'react-native';
import {Navigation} from 'react-native-navigation';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device';
import {useGalleryControls} from '@hooks/gallery';
import {dismissOverlay} from '@screens/navigation';
import {dismissOverlay, setScreensOrientation} from '@screens/navigation';
import {freezeOtherScreens} from '@utils/gallery';
import Footer from './footer';
@@ -46,14 +45,10 @@ const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialInde
}, []);
const close = useCallback(() => {
setScreensOrientation(isTablet);
if (Platform.OS === 'ios' && !isTablet) {
// We need both the navigation & the module
Navigation.setDefaultOptions({
layout: {
orientation: ['portrait'],
},
});
NativeModules.MattermostManaged.lockPortrait();
NativeModules.SplitView.lockPortrait();
}
freezeOtherScreens(false);
requestAnimationFrame(async () => {

View File

@@ -5,7 +5,7 @@ exports[`components/categories_list should render channels error 1`] = `
animatedStyle={
{
"value": {
"maxWidth": "100%",
"maxWidth": 750,
},
}
}
@@ -14,7 +14,7 @@ exports[`components/categories_list should render channels error 1`] = `
{
"backgroundColor": "#1e325c",
"flex": 1,
"maxWidth": "100%",
"maxWidth": 750,
"paddingLeft": 18,
"paddingRight": 20,
"paddingTop": 10,
@@ -340,7 +340,7 @@ exports[`components/categories_list should render team error 1`] = `
animatedStyle={
{
"value": {
"maxWidth": "100%",
"maxWidth": 750,
},
}
}
@@ -349,7 +349,7 @@ exports[`components/categories_list should render team error 1`] = `
{
"backgroundColor": "#1e325c",
"flex": 1,
"maxWidth": "100%",
"maxWidth": 750,
"paddingLeft": 18,
"paddingRight": 20,
"paddingTop": 10,

View File

@@ -2,7 +2,6 @@
// See LICENSE.txt for license information.
import React from 'react';
import {act} from 'react-test-renderer';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
@@ -19,15 +18,11 @@ describe('components/channel_list/categories', () => {
});
it('render without error', () => {
jest.useFakeTimers();
const wrapper = renderWithEverything(
<Categories/>,
{database},
);
act(() => jest.runAllTimers());
expect(wrapper.toJSON()).toBeTruthy();
jest.useRealTimers();
});
});

View File

@@ -46,7 +46,19 @@ const Categories = ({categories, onlyUnreads, unreadsOnTop}: Props) => {
const isTablet = useIsTablet();
const switchingTeam = useTeamSwitch();
const teamId = categories[0]?.teamId;
const [initiaLoad, setInitialLoad] = useState(true);
const categoriesToShow = useMemo(() => {
if (onlyUnreads && !unreadsOnTop) {
return ['UNREADS' as const];
}
const orderedCategories = [...categories];
orderedCategories.sort((a, b) => a.sortOrder - b.sortOrder);
if (unreadsOnTop) {
return ['UNREADS' as const, ...orderedCategories];
}
return orderedCategories;
}, [categories, onlyUnreads, unreadsOnTop]);
const [initiaLoad, setInitialLoad] = useState(!categoriesToShow.length);
const onChannelSwitch = useCallback(async (channelId: string) => {
switchToChannelById(serverUrl, channelId);
@@ -76,18 +88,6 @@ const Categories = ({categories, onlyUnreads, unreadsOnTop}: Props) => {
);
}, [teamId, intl.locale, isTablet, onChannelSwitch, onlyUnreads]);
const categoriesToShow = useMemo(() => {
if (onlyUnreads && !unreadsOnTop) {
return ['UNREADS' as const];
}
const orderedCategories = [...categories];
orderedCategories.sort((a, b) => a.sortOrder - b.sortOrder);
if (unreadsOnTop) {
return ['UNREADS' as const, ...orderedCategories];
}
return orderedCategories;
}, [categories, onlyUnreads, unreadsOnTop]);
useEffect(() => {
const t = setTimeout(() => {
setInitialLoad(false);

View File

@@ -33,7 +33,6 @@ describe('components/categories_list', () => {
it('should render', () => {
const wrapper = renderWithEverything(
<CategoriesList
isTablet={false}
teamsCount={1}
channelsCount={1}
/>,
@@ -47,7 +46,6 @@ describe('components/categories_list', () => {
const wrapper = renderWithEverything(
<CategoriesList
isCRTEnabled={true}
isTablet={false}
teamsCount={1}
channelsCount={1}
/>,
@@ -69,7 +67,6 @@ describe('components/categories_list', () => {
jest.useFakeTimers();
const wrapper = renderWithEverything(
<CategoriesList
isTablet={false}
teamsCount={0}
channelsCount={1}
/>,
@@ -92,7 +89,6 @@ describe('components/categories_list', () => {
jest.useFakeTimers();
const wrapper = renderWithEverything(
<CategoriesList
isTablet={false}
teamsCount={1}
channelsCount={0}
/>,

View File

@@ -2,11 +2,13 @@
// See LICENSE.txt for license information.
import React, {useEffect} from 'react';
import {useWindowDimensions} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import ThreadsButton from '@components/threads_button';
import {TABLET_SIDEBAR_WIDTH, TEAM_SIDEBAR_WIDTH} from '@constants/view';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {makeStyleSheetFromTheme} from '@utils/theme';
import Categories from './categories';
@@ -28,7 +30,6 @@ type ChannelListProps = {
channelsCount: number;
iconPad?: boolean;
isCRTEnabled?: boolean;
isTablet: boolean;
teamsCount: number;
};
@@ -36,9 +37,11 @@ const getTabletWidth = (teamsCount: number) => {
return TABLET_SIDEBAR_WIDTH - (teamsCount > 1 ? TEAM_SIDEBAR_WIDTH : 0);
};
const CategoriesList = ({channelsCount, iconPad, isCRTEnabled, isTablet, teamsCount}: ChannelListProps) => {
const CategoriesList = ({channelsCount, iconPad, isCRTEnabled, teamsCount}: ChannelListProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const {width} = useWindowDimensions();
const isTablet = useIsTablet();
const tabletWidth = useSharedValue(isTablet ? getTabletWidth(teamsCount) : 0);
useEffect(() => {
@@ -50,12 +53,12 @@ const CategoriesList = ({channelsCount, iconPad, isCRTEnabled, isTablet, teamsCo
const tabletStyle = useAnimatedStyle(() => {
if (!isTablet) {
return {
maxWidth: '100%',
maxWidth: width,
};
}
return {maxWidth: withTiming(tabletWidth.value, {duration: 350})};
}, [isTablet]);
}, [isTablet, width]);
let content;

View File

@@ -183,7 +183,6 @@ const ChannelListScreen = (props: ChannelProps) => {
<CategoriesList
iconPad={canAddOtherServers && props.teamsCount <= 1}
isCRTEnabled={props.isCRTEnabled}
isTablet={isTablet}
teamsCount={props.teamsCount}
channelsCount={props.channelsCount}
/>

View File

@@ -5,7 +5,7 @@
import merge from 'deepmerge';
import {Appearance, DeviceEventEmitter, NativeModules, StatusBar, Platform, Alert} from 'react-native';
import {ComponentWillAppearEvent, ImageResource, Navigation, Options, OptionsModalPresentationStyle, OptionsTopBarButton, ScreenPoppedEvent} from 'react-native-navigation';
import {ComponentWillAppearEvent, ImageResource, LayoutOrientation, Navigation, Options, OptionsModalPresentationStyle, OptionsTopBarButton, ScreenPoppedEvent} from 'react-native-navigation';
import tinyColor from 'tinycolor2';
import CompassIcon from '@components/compass_icon';
@@ -21,8 +21,8 @@ import type {BottomSheetFooterProps} from '@gorhom/bottom-sheet';
import type {LaunchProps} from '@typings/launch';
import type {AvailableScreens, NavButtons} from '@typings/screens/navigation';
const {MattermostManaged} = NativeModules;
const isRunningInSplitView = MattermostManaged.isRunningInSplitView;
const {SplitView} = NativeModules;
const {isRunningInSplitView} = SplitView;
const alpha = {
from: 0,
@@ -30,6 +30,9 @@ const alpha = {
duration: 150,
};
export const allOrientations: LayoutOrientation[] = ['sensor', 'sensorLandscape', 'sensorPortrait', 'landscape', 'portrait'];
export const portraitOrientation: LayoutOrientation[] = ['portrait'];
export function registerNavigationListeners() {
Navigation.events().registerScreenPoppedListener(onPoppedListener);
Navigation.events().registerCommandListener(onCommandListener);
@@ -185,7 +188,7 @@ Navigation.setDefaultOptions({
},
},
layout: {
orientation: Device.IS_TABLET ? undefined : ['portrait'],
orientation: Device.IS_TABLET ? allOrientations : portraitOrientation,
},
topBar: {
title: {
@@ -218,6 +221,19 @@ Appearance.addChangeListener(() => {
}
});
export function setScreensOrientation(allowRotation: boolean) {
const options: Options = {
layout: {
orientation: allowRotation ? allOrientations : portraitOrientation,
},
};
Navigation.setDefaultOptions(options);
const screens = NavigationStore.getScreensInStack();
for (const s of screens) {
Navigation.mergeOptions(s, options);
}
}
export function getThemeFromState(): Theme {
return EphemeralStore.theme || getDefaultThemeByAppearance();
}

View File

@@ -7,7 +7,7 @@ import {Navigation, Options, OptionsLayout} from 'react-native-navigation';
import {measure} from 'react-native-reanimated';
import {Events, Screens} from '@constants';
import {showOverlay} from '@screens/navigation';
import {allOrientations, showOverlay} from '@screens/navigation';
import {isImage, isVideo} from '@utils/file';
import {generateId} from '@utils/general';
@@ -127,7 +127,7 @@ export function openGalleryAtIndex(galleryIdentifier: string, initialIndex: numb
items,
};
const layout: OptionsLayout = {
orientation: ['portrait', 'landscape'],
orientation: allOrientations,
};
const options: Options = {
layout,
@@ -155,7 +155,7 @@ export function openGalleryAtIndex(galleryIdentifier: string, initialIndex: numb
if (Platform.OS === 'ios') {
// on iOS we need both the navigation & the module
Navigation.setDefaultOptions({layout});
NativeModules.MattermostManaged.unlockOrientation();
NativeModules.SplitView.unlockOrientation();
}
showOverlay(Screens.GALLERY, props, options);

View File

@@ -8,8 +8,8 @@ import {Device} from '@constants';
import {CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES} from '@constants/custom_status';
import {STATUS_BAR_HEIGHT} from '@constants/view';
const {MattermostManaged} = NativeModules;
const isRunningInSplitView = MattermostManaged.isRunningInSplitView;
const {SplitView} = NativeModules;
const {isRunningInSplitView} = SplitView;
const ShareModule: NativeShareExtension|undefined = Platform.select({android: NativeModules.MattermostShare});
// isMinimumServerVersion will return true if currentVersion is equal to higher or than

View File

@@ -31,6 +31,8 @@
7F537B2928A517580086D6B3 /* NotificationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F537B2828A517580086D6B3 /* NotificationHelper.swift */; };
7F581D35221ED5C60099E66B /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F581D34221ED5C60099E66B /* NotificationService.swift */; };
7F581D39221ED5C60099E66B /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 7F581D32221ED5C60099E66B /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
7F59882B29827F2200C8C108 /* SplitViewModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F59882A29827F2200C8C108 /* SplitViewModule.swift */; };
7F59882E2982850000C8C108 /* SplitViewModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 7F59882D2982850000C8C108 /* SplitViewModule.m */; };
7F7E9F462864E6C60064BFAF /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7E9F442864E6C60064BFAF /* Color.swift */; };
7F7E9F472864E6C60064BFAF /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7E9F452864E6C60064BFAF /* View.swift */; };
7F7E9F4C2864E6EC0064BFAF /* AttachmentModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7E9F492864E6EB0064BFAF /* AttachmentModel.swift */; };
@@ -196,6 +198,8 @@
7F581D34221ED5C60099E66B /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
7F581D36221ED5C60099E66B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
7F581F77221EEA5A0099E66B /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = "<group>"; };
7F59882A29827F2200C8C108 /* SplitViewModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SplitViewModule.swift; path = Mattermost/Modules/SplitViewModule.swift; sourceTree = "<group>"; };
7F59882D2982850000C8C108 /* SplitViewModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = SplitViewModule.m; path = Mattermost/Modules/SplitViewModule.m; sourceTree = "<group>"; };
7F63D2C21E6DD98A001FAE12 /* Mattermost.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = Mattermost.entitlements; path = Mattermost/Mattermost.entitlements; sourceTree = "<group>"; };
7F7E9F442864E6C60064BFAF /* Color.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = "<group>"; };
7F7E9F452864E6C60064BFAF /* View.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = "<group>"; };
@@ -465,6 +469,8 @@
7FEB109A1F61019C0039A015 /* MattermostManaged.m */,
7FEC870428A44A7B00DE96CB /* NotificationsModule.h */,
7FEC870028A4325D00DE96CB /* NotificationsModule.m */,
7F59882A29827F2200C8C108 /* SplitViewModule.swift */,
7F59882D2982850000C8C108 /* SplitViewModule.m */,
);
name = Modules;
sourceTree = "<group>";
@@ -800,8 +806,8 @@
);
mainGroup = 83CBB9F61A601CBA00E9B192;
packageReferences = (
7FD4822A2864D73300A5B18B /* XCRemoteSwiftPackageReference "OpenGraph.git" */,
27C667A129523ECA00E590D5 /* XCRemoteSwiftPackageReference "sentry-cocoa.git" */,
7FD4822A2864D73300A5B18B /* XCRemoteSwiftPackageReference "OpenGraph" */,
27C667A129523ECA00E590D5 /* XCRemoteSwiftPackageReference "sentry-cocoa" */,
);
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
projectDirPath = "";
@@ -1008,12 +1014,14 @@
buildActionMask = 2147483647;
files = (
7FEC870128A4325D00DE96CB /* NotificationsModule.m in Sources */,
7F59882E2982850000C8C108 /* SplitViewModule.m in Sources */,
7F1EB88527FDE361002E7EEC /* GekidouWrapper.swift in Sources */,
7FCEFB9326B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.m in Sources */,
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
7F151D3E221B062700FAD8F3 /* RuntimeUtils.swift in Sources */,
7F537B2928A517580086D6B3 /* NotificationHelper.swift in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */,
7F59882B29827F2200C8C108 /* SplitViewModule.swift in Sources */,
7FEB109D1F61019C0039A015 /* MattermostManaged.m in Sources */,
536CC6C323E79287002C478C /* RNNotificationEventHandler+HandleReplyAction.m in Sources */,
);
@@ -1523,7 +1531,7 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
27C667A129523ECA00E590D5 /* XCRemoteSwiftPackageReference "sentry-cocoa.git" */ = {
27C667A129523ECA00E590D5 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/getsentry/sentry-cocoa.git";
requirement = {
@@ -1531,7 +1539,7 @@
kind = branch;
};
};
7FD4822A2864D73300A5B18B /* XCRemoteSwiftPackageReference "OpenGraph.git" */ = {
7FD4822A2864D73300A5B18B /* XCRemoteSwiftPackageReference "OpenGraph" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/satoshi-takano/OpenGraph.git";
requirement = {
@@ -1544,12 +1552,12 @@
/* Begin XCSwiftPackageProductDependency section */
27C667A229523ECA00E590D5 /* Sentry */ = {
isa = XCSwiftPackageProductDependency;
package = 27C667A129523ECA00E590D5 /* XCRemoteSwiftPackageReference "sentry-cocoa.git" */;
package = 27C667A129523ECA00E590D5 /* XCRemoteSwiftPackageReference "sentry-cocoa" */;
productName = Sentry;
};
27C667A429523F0A00E590D5 /* Sentry */ = {
isa = XCSwiftPackageProductDependency;
package = 27C667A129523ECA00E590D5 /* XCRemoteSwiftPackageReference "sentry-cocoa.git" */;
package = 27C667A129523ECA00E590D5 /* XCRemoteSwiftPackageReference "sentry-cocoa" */;
productName = Sentry;
};
49AE370026D4455D00EF4E52 /* Gekidou */ = {
@@ -1566,7 +1574,7 @@
};
7FD4822B2864D73300A5B18B /* OpenGraph */ = {
isa = XCSwiftPackageProductDependency;
package = 7FD4822A2864D73300A5B18B /* XCRemoteSwiftPackageReference "OpenGraph.git" */;
package = 7FD4822A2864D73300A5B18B /* XCRemoteSwiftPackageReference "OpenGraph" */;
productName = OpenGraph;
};
/* End XCSwiftPackageProductDependency section */

View File

@@ -1,3 +1,7 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
#import <React/RCTConstants.h>
#import "AppDelegate.h"

View File

@@ -6,7 +6,6 @@
// See License.txt for license information.
//
#import "AppDelegate.h"
#import "MattermostManaged.h"
#import "CreateThumbnail.h"
#import "Mattermost-Swift.h"
@@ -52,21 +51,6 @@ RCT_EXPORT_MODULE();
};
}
RCT_EXPORT_METHOD(isRunningInSplitView:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
dispatch_block_t splitViewCheck = ^{
BOOL isRunningInFullScreen = CGRectEqualToRect(
[UIApplication sharedApplication].delegate.window.frame,
[UIApplication sharedApplication].delegate.window.screen.bounds);
resolve(@{
@"isSplitView": @(!isRunningInFullScreen)
});
};
if (dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL) == dispatch_queue_get_label(dispatch_get_main_queue())) {
splitViewCheck();
} else {
dispatch_async(dispatch_get_main_queue(), splitViewCheck);
}
}
RCT_EXPORT_METHOD(deleteDatabaseDirectory: (NSString *)databaseName shouldRemoveDirectory: (BOOL) shouldRemoveDirectory callback: (RCTResponseSenderBlock)callback){
@try {
@@ -169,37 +153,6 @@ RCT_EXPORT_METHOD(removeListeners:(double)count) {
// Keep: Required for RN built in Event Emitter Calls.
}
RCT_EXPORT_METHOD(unlockOrientation)
{
dispatch_async(dispatch_get_main_queue(), ^{
AppDelegate * appDelegate = (AppDelegate *)[UIApplication sharedApplication].delegate;
appDelegate.allowRotation = YES;
NSNumber *resetOrientationTarget = [NSNumber numberWithInt:UIInterfaceOrientationUnknown];
[[UIDevice currentDevice] setValue:resetOrientationTarget forKey:@"orientation"];
});
}
RCT_EXPORT_METHOD(lockPortrait)
{
dispatch_async(dispatch_get_main_queue(), ^{
AppDelegate * appDelegate = (AppDelegate *)[UIApplication sharedApplication].delegate;
appDelegate.allowRotation = NO;
NSNumber *resetOrientationTarget = [NSNumber numberWithInt:UIInterfaceOrientationUnknown];
[[UIDevice currentDevice] setValue:resetOrientationTarget forKey:@"orientation"];
NSNumber *orientationTarget = [NSNumber numberWithInt:UIInterfaceOrientationPortrait];
[[UIDevice currentDevice] setValue:orientationTarget forKey:@"orientation"];
});
}
RCT_EXPORT_METHOD(createThumbnail:(NSDictionary *)config findEventsWithResolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
{

View File

@@ -0,0 +1,13 @@
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
@interface RCT_EXTERN_REMAP_MODULE(SplitView, SplitViewModule, RCTEventEmitter)
RCT_EXTERN_METHOD(supportedEvents)
RCT_EXTERN_METHOD(startObserving)
RCT_EXTERN_METHOD(stopObserving)
RCT_EXTERN_METHOD(isRunningInSplitView:
(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(unlockOrientation)
RCT_EXTERN_METHOD(lockPortrait)
@end

View File

@@ -0,0 +1,75 @@
import Foundation
@objc(SplitViewModule)
class SplitViewModule: RCTEventEmitter {
var hasListeners = false
@objc
override static func requiresMainQueueSetup() -> Bool {
return true
}
@objc
override func supportedEvents() -> [String]! {
return ["SplitViewChanged"]
}
@objc
override func startObserving() {
hasListeners = true
NotificationCenter.default.addObserver(self,
selector: #selector(isSplitView), name: NSNotification.Name.RCTUserInterfaceStyleDidChange,
object: nil)
}
@objc
override func stopObserving() {
hasListeners = false
NotificationCenter.default.removeObserver(self,
name: NSNotification.Name.RCTUserInterfaceStyleDidChange,
object: nil)
}
@objc func isRunningInFullScreen() -> Bool {
guard let w = UIApplication.shared.delegate?.window, let window = w else { return false }
return window.frame.equalTo(window.screen.bounds)
}
@objc func isSplitView() {
if hasListeners && UIDevice.current.userInterfaceIdiom == .pad {
sendEvent(withName: "SplitViewChanged", body: [
"isSplitView": !isRunningInFullScreen(),
"isTablet": UIDevice.current.userInterfaceIdiom == .pad,
])
}
}
@objc(isRunningInSplitView:withRejecter:)
func isRunningInSplitView(resolve:@escaping RCTPromiseResolveBlock, reject:RCTPromiseRejectBlock) -> Void {
DispatchQueue.main.async { [weak self] in
resolve([
"isSplitView": !(self?.isRunningInFullScreen() ?? false),
"isTablet": UIDevice.current.userInterfaceIdiom == .pad,
])
}
}
@objc(unlockOrientation)
func unlockOrientation() {
DispatchQueue.main.async {
let appDelegate = UIApplication.shared.delegate as! AppDelegate
appDelegate.allowRotation = true
UIDevice.current.setValue(UIInterfaceOrientation.unknown, forKey: "orientation")
}
}
@objc(lockPortrait)
func lockPortrait() {
DispatchQueue.main.async {
let appDelegate = UIApplication.shared.delegate as! AppDelegate
appDelegate.allowRotation = false
UIDevice.current.setValue(UIInterfaceOrientation.unknown, forKey: "orientation")
UIDevice.current.setValue(UIInterfaceOrientation.portrait, forKey: "orientation")
}
}
}

View File

@@ -334,7 +334,7 @@ PODS:
- glog
- react-native-background-timer (2.4.1):
- React-Core
- react-native-cameraroll (5.2.2):
- react-native-cameraroll (5.2.3):
- React-Core
- react-native-cookies (6.2.1):
- React-Core
@@ -535,7 +535,7 @@ PODS:
- RNScreens (3.19.0):
- React-Core
- React-RCTImage
- RNSentry (4.13.0):
- RNSentry (4.14.0):
- React-Core
- Sentry/HybridSDK (= 7.31.5)
- RNShare (8.1.0):
@@ -929,7 +929,7 @@ SPEC CHECKSUMS:
React-jsinspector: ff56004b0c974b688a6548c156d5830ad751ae07
React-logger: 60a0b5f8bed667ecf9e24fecca1f30d125de6d75
react-native-background-timer: 17ea5e06803401a379ebf1f20505b793ac44d0fe
react-native-cameraroll: 71d68167beb6fc7216aa564abb6d86f1d666a2c6
react-native-cameraroll: 5b25d0be40185d02e522bf2abf8a1ba4e8faa107
react-native-cookies: f54fcded06bb0cda05c11d86788020b43528a26c
react-native-create-thumbnail: e022bcdcba8a0b4529a50d3fa1a832ec921be39d
react-native-document-picker: 958e2bc82e128be69055be261aeac8d872c8d34c
@@ -977,7 +977,7 @@ SPEC CHECKSUMS:
RNReanimated: cc5e3aa479cb9170bcccf8204291a6950a3be128
RNRudderSdk: 07f732edfe473ef67b8cc6ad1800ddb81b78286f
RNScreens: ea4cd3a853063cda19a4e3c28d2e52180c80f4eb
RNSentry: acebe4104a6f5915ae871eb59dc73f13dcc92ef7
RNSentry: 7e90aec2633d2fdad8aeb839c9915e4376fd27d1
RNShare: 48b3113cd089a2be8ff0515c3ae7a46a4db8a76b
RNSVG: d787d64ca06b9158e763ad2638a8c4edce00782a
RNVectorIcons: fcc2f6cb32f5735b586e66d14103a74ce6ad61f8

1533
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@
"@msgpack/msgpack": "2.8.0",
"@nozbe/watermelondb": "0.25.1",
"@nozbe/with-observables": "1.4.1",
"@react-native-camera-roll/camera-roll": "5.2.2",
"@react-native-camera-roll/camera-roll": "5.2.3",
"@react-native-clipboard/clipboard": "1.11.1",
"@react-native-community/datetimepicker": "6.7.3",
"@react-native-community/netinfo": "9.3.7",
@@ -32,7 +32,7 @@
"@react-navigation/native": "6.1.2",
"@react-navigation/stack": "6.3.11",
"@rudderstack/rudder-sdk-react-native": "1.5.2",
"@sentry/react-native": "4.13.0",
"@sentry/react-native": "4.14.0",
"@stream-io/flat-list-mvcp": "0.10.2",
"base-64": "1.0.0",
"commonmark": "npm:@mattermost/commonmark@0.30.1-0",
@@ -118,7 +118,7 @@
"@types/commonmark": "0.27.5",
"@types/commonmark-react-renderer": "4.3.1",
"@types/deep-equal": "1.0.1",
"@types/jest": "29.2.6",
"@types/jest": "29.4.0",
"@types/lodash": "4.14.191",
"@types/mime-db": "1.43.1",
"@types/querystringify": "2.0.0",
@@ -139,9 +139,9 @@
"@types/uuid": "9.0.0",
"@typescript-eslint/eslint-plugin": "5.49.0",
"@typescript-eslint/parser": "5.49.0",
"axios": "1.2.3",
"axios": "1.2.4",
"axios-cookiejar-support": "4.0.6",
"babel-jest": "29.3.1",
"babel-jest": "29.4.0",
"babel-loader": "9.1.2",
"babel-plugin-module-resolver": "5.0.0",
"deep-freeze": "0.0.1",
@@ -154,8 +154,8 @@
"eslint-plugin-react-hooks": "4.6.0",
"husky": "8.0.3",
"isomorphic-fetch": "3.0.0",
"jest": "29.3.1",
"jest-cli": "29.3.1",
"jest": "29.4.0",
"jest-cli": "29.4.0",
"jetifier": "2.0.0",
"metro-react-native-babel-preset": "0.74.1",
"mmjstool": "github:mattermost/mattermost-utilities#010f456ea8be5beebafdb8776177cba515c1969e",

View File

@@ -92,6 +92,11 @@ jest.doMock('react-native', () => {
},
}),
},
SplitView: {
addListener: jest.fn(),
removeListeners: jest.fn(),
isRunningInSplitView: jest.fn().mockResolvedValue(() => ({isSplitView: false, isTablet: false})),
},
Notifications: {
getDeliveredNotifications: jest.fn().mockResolvedValue([]),
removeChannelNotifications: jest.fn().mockImplementation(),

7
types/global/device.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
type SplitViewResult = {
isSplitView?: boolean;
isTablet?: boolean;
}