diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index aadfdf3d2d..9fb7308cc5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -83,5 +83,23 @@ android:resizeableActivity="true" android:exported="true" /> + + + + + + + + + diff --git a/android/app/src/main/java/com/mattermost/helpers/RealPathUtil.java b/android/app/src/main/java/com/mattermost/helpers/RealPathUtil.java index a17025f486..6c51801721 100644 --- a/android/app/src/main/java/com/mattermost/helpers/RealPathUtil.java +++ b/android/app/src/main/java/com/mattermost/helpers/RealPathUtil.java @@ -6,6 +6,7 @@ import android.net.Uri; import android.provider.DocumentsContract; import android.provider.MediaStore; import android.provider.OpenableColumns; +import android.content.ContentResolver; import android.os.Environment; import android.webkit.MimeTypeMap; import android.util.Log; @@ -182,8 +183,14 @@ public class RealPathUtil { } public static String getExtension(String uri) { + String extension = ""; if (uri == null) { - return null; + return extension; + } + + extension = MimeTypeMap.getFileExtensionFromUrl(uri); + if (!extension.equals("")) { + return extension; } int dot = uri.lastIndexOf("."); @@ -210,6 +217,15 @@ public class RealPathUtil { return getMimeType(file); } + public static String getMimeTypeFromUri(final Context context, final Uri uri) { + try { + ContentResolver cR = context.getContentResolver(); + return cR.getType(uri); + } catch (Exception e) { + return "application/octet-stream"; + } + } + public static void deleteTempFiles(final File dir) { try { if (dir.isDirectory()) { @@ -241,4 +257,21 @@ public class RealPathUtil { return f.getName(); } + public static File createDirIfNotExists(String path) { + File dir = new File(path); + if (dir.exists()) { + return dir; + } + + try { + dir.mkdirs(); + // Add .nomedia to hide the thumbnail directory from gallery + File noMedia = new File(path, ".nomedia"); + noMedia.createNewFile(); + } catch (IOException e) { + e.printStackTrace(); + } + + return dir; + } } diff --git a/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java b/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java index dd63c4cfcd..48a664f7e4 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java @@ -18,6 +18,7 @@ import com.mattermost.helpers.Network; import com.mattermost.helpers.NotificationHelper; import com.mattermost.helpers.PushNotificationDataHelper; import com.mattermost.helpers.ResolvePromise; +import com.mattermost.share.ShareModule; import com.wix.reactnativenotifications.core.NotificationIntentAdapter; import com.wix.reactnativenotifications.core.notification.PushNotification; import com.wix.reactnativenotifications.core.AppLaunchHelper; @@ -80,7 +81,9 @@ public class CustomPushNotification extends PushNotification { switch (type) { case CustomPushNotificationHelper.PUSH_TYPE_MESSAGE: case CustomPushNotificationHelper.PUSH_TYPE_SESSION: - if (!mAppLifecycleFacade.isAppVisible()) { + String currentActivityName = ShareModule.getInstance().getCurrentActivityName(); + Log.i("ReactNative", currentActivityName); + if (!mAppLifecycleFacade.isAppVisible() || !currentActivityName.equals("MainActivity")) { boolean createSummary = type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE); if (type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE)) { if (channelId != null) { diff --git a/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java b/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java index 596225b5be..29d030d776 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java @@ -12,13 +12,12 @@ import java.util.List; import java.util.Map; import com.mattermost.helpers.RealPathUtil; +import com.mattermost.share.ShareModule; import com.wix.reactnativenotifications.RNNotificationsPackage; import com.reactnativenavigation.NavigationApplication; import com.wix.reactnativenotifications.core.notification.INotificationsApplication; import com.wix.reactnativenotifications.core.notification.IPushNotification; -import com.wix.reactnativenotifications.core.notificationdrawer.IPushNotificationsDrawer; -import com.wix.reactnativenotifications.core.notificationdrawer.INotificationsDrawerApplication; import com.wix.reactnativenotifications.core.AppLaunchHelper; import com.wix.reactnativenotifications.core.AppLifecycleFacade; import com.wix.reactnativenotifications.core.JsIOHelper; @@ -68,10 +67,12 @@ public class MainApplication extends NavigationApplication implements INotificat switch (name) { case "MattermostManaged": return MattermostManagedModule.getInstance(reactContext); - case "Notifications": - return NotificationsModule.getInstance(instance, reactContext); - default: - throw new IllegalArgumentException("Could not find module " + name); + case "MattermostShare": + return ShareModule.getInstance(reactContext); + case "Notifications": + return NotificationsModule.getInstance(instance, reactContext); + default: + throw new IllegalArgumentException("Could not find module " + name); } } @@ -80,6 +81,7 @@ public class MainApplication extends NavigationApplication implements INotificat return () -> { Map map = new HashMap<>(); 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)); return map; }; diff --git a/android/app/src/main/java/com/mattermost/share/ShareActivity.java b/android/app/src/main/java/com/mattermost/share/ShareActivity.java new file mode 100644 index 0000000000..573f9ac839 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/share/ShareActivity.java @@ -0,0 +1,20 @@ +package com.mattermost.share; + +import android.os.Bundle; + +import com.facebook.react.ReactActivity; +import com.mattermost.rnbeta.MainApplication; + +public class ShareActivity extends ReactActivity { + @Override + protected String getMainComponentName() { + return "MattermostShare"; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + MainApplication app = (MainApplication) this.getApplication(); + app.sharedExtensionIsOpened = true; + } +} diff --git a/android/app/src/main/java/com/mattermost/share/ShareModule.java b/android/app/src/main/java/com/mattermost/share/ShareModule.java new file mode 100644 index 0000000000..c6060cb8df --- /dev/null +++ b/android/app/src/main/java/com/mattermost/share/ShareModule.java @@ -0,0 +1,258 @@ +package com.mattermost.share; + +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.Arguments; +import com.mattermost.helpers.Credentials; +import com.mattermost.rnbeta.MainApplication; +import com.mattermost.helpers.RealPathUtil; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.File; +import java.util.ArrayList; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.json.JSONException; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.MultipartBody; +import okhttp3.RequestBody; +import okhttp3.Response; + +public class ShareModule extends ReactContextBaseJavaModule { + private final OkHttpClient client = new OkHttpClient(); + public static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + private static ShareModule instance; + private final MainApplication mApplication; + private ReactApplicationContext mReactContext; + private File tempFolder; + + private ShareModule(ReactApplicationContext reactContext) { + super(reactContext); + mReactContext = reactContext; + mApplication = (MainApplication)reactContext.getApplicationContext(); + } + + public static ShareModule getInstance(ReactApplicationContext reactContext) { + if (instance == null) { + instance = new ShareModule(reactContext); + } else { + instance.mReactContext = reactContext; + } + + return instance; + } + + public static ShareModule getInstance() { + return instance; + } + + @NonNull + @Override + public String getName() { + return "MattermostShare"; + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public String getCurrentActivityName() { + Activity currentActivity = getCurrentActivity(); + if (currentActivity != null) { + String actvName = currentActivity.getComponentName().getClassName(); + String[] components = actvName.split("\\."); + return components[components.length - 1]; + } + + return ""; + } + + @ReactMethod + public void clear() { + Activity currentActivity = getCurrentActivity(); + if (currentActivity != null && this.getCurrentActivityName().equals("ShareActivity")) { + Intent intent = currentActivity.getIntent(); + intent.setAction(""); + intent.removeExtra(Intent.EXTRA_TEXT); + intent.removeExtra(Intent.EXTRA_STREAM); + } + } + + @Nullable + @Override + public Map getConstants() { + HashMap constants = new HashMap<>(1); + constants.put("cacheDirName", RealPathUtil.CACHE_DIR_NAME); + constants.put("isOpened", mApplication.sharedExtensionIsOpened); + return constants; + } + + @ReactMethod + public void close(ReadableMap data) { + this.clear(); + Activity currentActivity = getCurrentActivity(); + if (currentActivity == null || !this.getCurrentActivityName().equals("ShareActivity")) { + return; + } + + currentActivity.finishAndRemoveTask(); + if (data != null && data.hasKey("serverUrl")) { + ReadableArray files = data.getArray("files"); + String serverUrl = data.getString("serverUrl"); + final String token = Credentials.getCredentialsForServerSync(this.getReactApplicationContext(), serverUrl); + JSONObject postData = buildPostObject(data); + + if (files != null && files.size() > 0) { + uploadFiles(serverUrl, token, files, postData); + } else { + try { + post(serverUrl, token, postData); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + mApplication.sharedExtensionIsOpened = false; + RealPathUtil.deleteTempFiles(this.tempFolder); + } + + @ReactMethod + public void getSharedData(Promise promise) { + promise.resolve(processIntent()); + } + + public WritableArray processIntent() { + String type, action, extra; + WritableArray items = Arguments.createArray(); + Activity currentActivity = getCurrentActivity(); + + if (currentActivity != null) { + this.tempFolder = new File(currentActivity.getCacheDir(), RealPathUtil.CACHE_DIR_NAME); + Intent intent = currentActivity.getIntent(); + action = intent.getAction(); + type = intent.getType(); + extra = intent.getStringExtra(Intent.EXTRA_TEXT); + + if (Intent.ACTION_SEND.equals(action) && "text/plain".equals(type) && extra != null) { + items.pushMap(ShareUtils.getTextItem(extra)); + } else if (Intent.ACTION_SEND.equals(action)) { + if (extra != null) { + items.pushMap(ShareUtils.getTextItem(extra)); + } + Uri uri = intent.getParcelableExtra(Intent.EXTRA_STREAM); + if (uri != null) { + ReadableMap fileInfo = ShareUtils.getFileItem(currentActivity, uri); + if (fileInfo != null) { + items.pushMap(fileInfo); + } + } + } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { + if (extra != null) { + items.pushMap(ShareUtils.getTextItem(extra)); + } + + ArrayList uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + for (Uri uri : uris) { + ReadableMap fileInfo = ShareUtils.getFileItem(currentActivity, uri); + if (fileInfo != null) { + items.pushMap(fileInfo); + } + } + } + } + + return items; + } + + private JSONObject buildPostObject(ReadableMap data) { + JSONObject json = new JSONObject(); + try { + json.put("user_id", data.getString("userId")); + if (data.hasKey("channelId")) { + json.put("channel_id", data.getString("channelId")); + } + if (data.hasKey("message")) { + json.put("message", data.getString("message")); + } + } catch (JSONException e) { + e.printStackTrace(); + } + return json; + } + + private void post(String serverUrl, String token, JSONObject postData) throws IOException { + RequestBody body = RequestBody.create(postData.toString(), JSON); + Request request = new Request.Builder() + .header("Authorization", "BEARER " + token) + .url(serverUrl + "/api/v4/posts") + .post(body) + .build(); + client.newCall(request).execute(); + } + + private void uploadFiles(String serverUrl, String token, ReadableArray files, JSONObject postData) { + try { + MultipartBody.Builder builder = new MultipartBody.Builder() + .setType(MultipartBody.FORM); + + for(int i = 0 ; i < files.size() ; i++) { + ReadableMap file = files.getMap(i); + String mime = file.getString("type"); + String fullPath = file.getString("value"); + if (fullPath != null) { + String filePath = fullPath.replaceFirst("file://", ""); + File fileInfo = new File(filePath); + if (fileInfo.exists() && mime != null) { + final MediaType MEDIA_TYPE = MediaType.parse(mime); + builder.addFormDataPart("files", file.getString("filename"), RequestBody.create(fileInfo, MEDIA_TYPE)); + } + } + } + + builder.addFormDataPart("channel_id", postData.getString("channel_id")); + RequestBody body = builder.build(); + Request request = new Request.Builder() + .header("Authorization", "BEARER " + token) + .url(serverUrl + "/api/v4/files") + .post(body) + .build(); + + try (Response response = client.newCall(request).execute()) { + if (response.isSuccessful()) { + String responseData = response.body().string(); + JSONObject responseJson = new JSONObject(responseData); + JSONArray fileInfoArray = responseJson.getJSONArray("file_infos"); + JSONArray file_ids = new JSONArray(); + for(int i = 0 ; i < fileInfoArray.length() ; i++) { + JSONObject fileInfo = fileInfoArray.getJSONObject(i); + file_ids.put(fileInfo.getString("id")); + } + postData.put("file_ids", file_ids); + post(serverUrl, token, postData); + } + } catch (IOException e) { + e.printStackTrace(); + } + + } catch (JSONException e) { + e.printStackTrace(); + } + } +} diff --git a/android/app/src/main/java/com/mattermost/share/ShareUtils.java b/android/app/src/main/java/com/mattermost/share/ShareUtils.java new file mode 100644 index 0000000000..b8ca97e7ba --- /dev/null +++ b/android/app/src/main/java/com/mattermost/share/ShareUtils.java @@ -0,0 +1,111 @@ +package com.mattermost.share; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.MediaMetadataRetriever; +import android.net.Uri; +import android.webkit.URLUtil; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.WritableMap; +import com.mattermost.helpers.RealPathUtil; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.util.UUID; + +public class ShareUtils { + public static ReadableMap getTextItem(String text) { + WritableMap map = Arguments.createMap(); + map.putString("value", text); + map.putString("type", ""); + map.putBoolean("isString", true); + return map; + } + + public static ReadableMap getFileItem(Activity activity, Uri uri) { + WritableMap map = Arguments.createMap(); + String filePath = RealPathUtil.getRealPathFromURI(activity, uri); + if (filePath == null) { + return null; + } + + File file = new File(filePath); + String type = RealPathUtil.getMimeTypeFromUri(activity, uri); + if (type != null) { + if (type.startsWith("image/")) { + BitmapFactory.Options bitMapOption = ShareUtils.getImageDimensions(filePath); + map.putInt("height", bitMapOption.outHeight); + map.putInt("width", bitMapOption.outWidth); + + } else if (type.startsWith("video/")) { + File cacheDir = new File(activity.getCacheDir(), RealPathUtil.CACHE_DIR_NAME); + addVideoThumbnailToMap(cacheDir, activity.getApplicationContext(), map, "file://" + filePath); + } + } else { + type = "application/octet-stream"; + } + + map.putString("value", "file://" + filePath); + map.putDouble("size", (double) file.length()); + map.putString("filename", file.getName()); + map.putString("type", type); + map.putString("extension", RealPathUtil.getExtension(filePath).replaceFirst(".", "")); + map.putBoolean("isString", false); + return map; + } + + public static BitmapFactory.Options getImageDimensions(String filePath) { + BitmapFactory.Options bitMapOption = new BitmapFactory.Options(); + bitMapOption.inJustDecodeBounds=true; + BitmapFactory.decodeFile(filePath, bitMapOption); + return bitMapOption; + } + + private static void addVideoThumbnailToMap(File cacheDir, Context context, WritableMap map, String filePath) { + String fileName = ("thumb-" + UUID.randomUUID().toString()) + ".png"; + OutputStream fOut = null; + + try { + File file = new File(cacheDir, fileName); + Bitmap image = getBitmapAtTime(context, filePath, 1); + if (file.createNewFile()) { + fOut = new FileOutputStream(file); + image.compress(Bitmap.CompressFormat.PNG, 100, fOut); + fOut.flush(); + fOut.close(); + + map.putString("videoThumb", "file://" + file.getAbsolutePath()); + map.putInt("width", image.getWidth()); + map.putInt("height", image.getHeight()); + } + } catch (Exception ignored) { + } + } + + private static Bitmap getBitmapAtTime(Context context, String filePath, int time) { + MediaMetadataRetriever retriever = new MediaMetadataRetriever(); + if (URLUtil.isFileUrl(filePath)) { + String decodedPath; + try { + decodedPath = URLDecoder.decode(filePath, "UTF-8"); + } catch (UnsupportedEncodingException e) { + decodedPath = filePath; + } + + retriever.setDataSource(decodedPath.replace("file://", "")); + } else if (filePath.contains("content://")) { + retriever.setDataSource(context, Uri.parse(filePath)); + } + + Bitmap image = retriever.getFrameAtTime(time * 1000, MediaMetadataRetriever.OPTION_CLOSEST_SYNC); + retriever.release(); + return image; + } +} diff --git a/app/actions/remote/entry/common.ts b/app/actions/remote/entry/common.ts index dcf0de25c5..72cd4a3b11 100644 --- a/app/actions/remote/entry/common.ts +++ b/app/actions/remote/entry/common.ts @@ -22,7 +22,7 @@ import {selectDefaultTeam} from '@helpers/api/team'; import {DEFAULT_LOCALE} from '@i18n'; import NetworkManager from '@managers/network_manager'; import {getDeviceToken} from '@queries/app/global'; -import {queryAllServers} from '@queries/app/servers'; +import {getAllServers} from '@queries/app/servers'; import {prepareMyChannelsForTeam, queryAllChannelsForTeam, queryChannelsById} from '@queries/servers/channel'; import {prepareModels, truncateCrtRelatedTables} from '@queries/servers/entry'; import {getHasCRTChanged} from '@queries/servers/preference'; @@ -362,27 +362,21 @@ export const registerDeviceToken = async (serverUrl: string) => { return {error}; } - const appDatabase = DatabaseManager.appDatabase?.database; - if (appDatabase) { - const deviceToken = await getDeviceToken(appDatabase); - if (deviceToken) { - client.attachDevice(deviceToken); - } + const deviceToken = await getDeviceToken(); + if (deviceToken) { + client.attachDevice(deviceToken); } return {error: undefined}; }; export const syncOtherServers = async (serverUrl: string) => { - const database = DatabaseManager.appDatabase?.database; - if (database) { - const servers = await queryAllServers(database); - for (const server of servers) { - if (server.url !== serverUrl && server.lastActiveAt > 0) { - registerDeviceToken(server.url); - syncAllChannelMembersAndThreads(server.url); - autoUpdateTimezone(server.url); - } + const servers = await getAllServers(); + for (const server of servers) { + if (server.url !== serverUrl && server.lastActiveAt > 0) { + registerDeviceToken(server.url); + syncAllChannelMembersAndThreads(server.url); + autoUpdateTimezone(server.url); } } }; @@ -479,12 +473,7 @@ export async function verifyPushProxy(serverUrl: string) { return; } - const appDatabase = DatabaseManager.appDatabase?.database; - if (!appDatabase) { - return; - } - - const deviceId = await getDeviceToken(appDatabase); + const deviceId = await getDeviceToken(); if (!deviceId) { return; } diff --git a/app/actions/remote/general.ts b/app/actions/remote/general.ts index e624ccee51..21f0b72106 100644 --- a/app/actions/remote/general.ts +++ b/app/actions/remote/general.ts @@ -27,12 +27,7 @@ async function getDeviceIdForPing(serverUrl: string, checkDeviceId: boolean) { } } - const appDatabase = DatabaseManager.appDatabase?.database; - if (!appDatabase) { - return ''; - } - - return getDeviceToken(appDatabase); + return getDeviceToken(); } // Default timeout interval for ping is 5 seconds diff --git a/app/actions/remote/session.ts b/app/actions/remote/session.ts index d88728e79e..df3460b914 100644 --- a/app/actions/remote/session.ts +++ b/app/actions/remote/session.ts @@ -12,7 +12,7 @@ import PushNotifications from '@init/push_notifications'; import NetworkManager from '@managers/network_manager'; import WebsocketManager from '@managers/websocket_manager'; import {getDeviceToken} from '@queries/app/global'; -import {queryServerName} from '@queries/app/servers'; +import {getServerDisplayName} from '@queries/app/servers'; import {getCurrentUserId, getExpiredSession, getConfig, getLicense} from '@queries/servers/system'; import {getCurrentUser} from '@queries/servers/user'; import EphemeralStore from '@store/ephemeral_store'; @@ -124,7 +124,7 @@ export const login = async (serverUrl: string, {ldapOnly = false, loginId, mfaTo } try { - deviceToken = await getDeviceToken(appDatabase); + deviceToken = await getDeviceToken(); user = await client.login( loginId, password, @@ -204,11 +204,10 @@ export const cancelSessionNotification = async (serverUrl: string) => { export const scheduleSessionNotification = async (serverUrl: string) => { try { - const {database: appDatabase} = DatabaseManager.getAppDatabaseAndOperator(); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const sessions = await fetchSessions(serverUrl, 'me'); const user = await getCurrentUser(database); - const serverName = await queryServerName(appDatabase, serverUrl); + const serverName = await getServerDisplayName(serverUrl); await cancelSessionNotification(serverUrl); @@ -286,7 +285,7 @@ export const ssoLogin = async (serverUrl: string, serverDisplayName: string, ser displayName: serverDisplayName, }, }); - deviceToken = await getDeviceToken(database); + deviceToken = await getDeviceToken(); user = await client.getMe(); await server?.operator.handleUsers({users: [user], prepareRecordsOnly: false}); await server?.operator.handleSystem({ @@ -312,9 +311,8 @@ export const ssoLogin = async (serverUrl: string, serverDisplayName: string, ser async function findSession(serverUrl: string, sessions: Session[]) { try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); - const {database: appDatabase} = DatabaseManager.getAppDatabaseAndOperator(); const expiredSession = await getExpiredSession(database); - const deviceToken = await getDeviceToken(appDatabase); + const deviceToken = await getDeviceToken(); // First try and find the session by the given identifier hyqddef7jjdktqiyy36gxa8sqy let session = sessions.find((s) => s.id === expiredSession?.id); diff --git a/app/actions/websocket/channel.ts b/app/actions/websocket/channel.ts index 569a97cf42..12040ee3f1 100644 --- a/app/actions/websocket/channel.ts +++ b/app/actions/websocket/channel.ts @@ -18,7 +18,7 @@ import {fetchUsersByIds, updateUsersNoLongerVisible} from '@actions/remote/user' import {loadCallForChannel} from '@calls/actions/calls'; import {Events, Screens} from '@constants'; import DatabaseManager from '@database/manager'; -import {queryActiveServer} from '@queries/app/servers'; +import {getActiveServer} from '@queries/app/servers'; import {deleteChannelMembership, getChannelById, prepareMyChannelsForTeam, getCurrentChannel} from '@queries/servers/channel'; import {prepareCommonSystemValues, getConfig, setCurrentChannelId, getCurrentChannelId, getCurrentTeamId} from '@queries/servers/system'; import {getNthLastChannelFromTeam} from '@queries/servers/team'; @@ -356,7 +356,7 @@ export async function handleUserRemovedFromChannelEvent(serverUrl: string, msg: if (user.id === userId) { await removeCurrentUserFromChannel(serverUrl, channelId); if (channel && channel.id === channelId) { - const currentServer = await queryActiveServer(DatabaseManager.appDatabase!.database); + const currentServer = await getActiveServer(); if (currentServer?.url === serverUrl) { DeviceEventEmitter.emit(Events.LEAVE_CHANNEL, channel.displayName); @@ -431,7 +431,7 @@ export async function handleChannelDeletedEvent(serverUrl: string, msg: WebSocke await removeCurrentUserFromChannel(serverUrl, channelId); if (currentChannel && currentChannel.id === channelId) { - const currentServer = await queryActiveServer(DatabaseManager.appDatabase!.database); + const currentServer = await getActiveServer(); if (currentServer?.url === serverUrl) { DeviceEventEmitter.emit(Events.CHANNEL_ARCHIVED, currentChannel.displayName); diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 5ba00ca071..1af41822f9 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -31,7 +31,7 @@ import {Events, Screens, WebsocketEvents} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import AppsManager from '@managers/apps_manager'; -import {getActiveServerUrl, queryActiveServer} from '@queries/app/servers'; +import {getActiveServerUrl, getActiveServer} from '@queries/app/servers'; import {getCurrentChannel} from '@queries/servers/channel'; import { getConfig, @@ -135,7 +135,7 @@ async function doReconnect(serverUrl: string) { const currentTeam = await getCurrentTeam(database); const currentChannel = await getCurrentChannel(database); - const currentActiveServerUrl = await getActiveServerUrl(DatabaseManager.appDatabase!.database); + const currentActiveServerUrl = await getActiveServerUrl(); const entryData = await entry(serverUrl, currentTeam?.id, currentChannel?.id, lastDisconnectedAt); if ('error' in entryData) { @@ -150,7 +150,7 @@ async function doReconnect(serverUrl: string) { // if no longer a member of the current team or the current channel if (initialTeamId !== currentTeam?.id || initialChannelId !== currentChannel?.id) { - const currentServer = await queryActiveServer(appDatabase); + const currentServer = await getActiveServer(); const isChannelScreenMounted = NavigationStore.getNavigationComponents().includes(Screens.CHANNEL); if (serverUrl === currentServer?.url) { if (currentTeam && initialTeamId !== currentTeam.id) { diff --git a/app/actions/websocket/integrations.ts b/app/actions/websocket/integrations.ts index 50fe62dee9..b895337c53 100644 --- a/app/actions/websocket/integrations.ts +++ b/app/actions/websocket/integrations.ts @@ -1,6 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import DatabaseManager from '@database/manager'; + import IntegrationsManager from '@managers/integrations_manager'; import {getActiveServerUrl} from '@queries/app/servers'; @@ -9,14 +9,10 @@ export async function handleOpenDialogEvent(serverUrl: string, msg: WebSocketMes if (!data) { return; } - const appDatabase = DatabaseManager.appDatabase?.database; - if (!appDatabase) { - return; - } try { const dialog: InteractiveDialogConfig = JSON.parse(data); - const currentServer = await getActiveServerUrl(appDatabase); + const currentServer = await getActiveServerUrl(); if (currentServer === serverUrl) { IntegrationsManager.getManager(serverUrl).setDialog(dialog); } diff --git a/app/actions/websocket/teams.ts b/app/actions/websocket/teams.ts index 8993b0733c..b8cace9ceb 100644 --- a/app/actions/websocket/teams.ts +++ b/app/actions/websocket/teams.ts @@ -41,11 +41,7 @@ export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMess } if (currentTeam?.id === teamId) { - const appDatabase = DatabaseManager.appDatabase?.database; - let currentServer = ''; - if (appDatabase) { - currentServer = await getActiveServerUrl(appDatabase); - } + const currentServer = await getActiveServerUrl(); if (currentServer === serverUrl) { DeviceEventEmitter.emit(Events.LEAVE_TEAM, currentTeam?.displayName); diff --git a/app/components/channel_icon/index.tsx b/app/components/channel_icon/index.tsx index 33e1172cc9..96386c5f1d 100644 --- a/app/components/channel_icon/index.tsx +++ b/app/components/channel_icon/index.tsx @@ -187,7 +187,8 @@ const ChannelIcon = ({ ); + /> + ); } return ( diff --git a/app/components/channel_item/index.ts b/app/components/channel_item/index.ts index 91dfc00fc0..1568ce8147 100644 --- a/app/components/channel_item/index.ts +++ b/app/components/channel_item/index.ts @@ -10,9 +10,10 @@ import {switchMap, distinctUntilChanged} from 'rxjs/operators'; import {observeChannelsWithCalls} from '@calls/state'; import {General} from '@constants'; import {withServerUrl} from '@context/server'; -import {observeMyChannel} from '@queries/servers/channel'; +import {observeChannelSettings, observeMyChannel} from '@queries/servers/channel'; import {queryDraft} from '@queries/servers/drafts'; import {observeCurrentChannelId, observeCurrentUserId} from '@queries/servers/system'; +import {observeTeam} from '@queries/servers/team'; import ChannelItem from './channel_item'; @@ -26,7 +27,7 @@ type EnhanceProps = WithDatabaseArgs & { serverUrl?: string; } -const observeIsMutedSetting = (mc: MyChannelModel) => mc.settings.observe().pipe(switchMap((s) => of$(s?.notifyProps?.mark_unread === General.MENTION))); +const observeIsMutedSetting = (mc: MyChannelModel) => observeChannelSettings(mc.database, mc.id).pipe(switchMap((s) => of$(s?.notifyProps?.mark_unread === General.MENTION))); const enhance = withObservables(['channel', 'showTeamName'], ({ channel, @@ -58,7 +59,7 @@ const enhance = withObservables(['channel', 'showTeamName'], ({ let teamDisplayName = of$(''); if (channel.teamId && showTeamName) { - teamDisplayName = channel.team.observe().pipe( + teamDisplayName = observeTeam(database, channel.teamId).pipe( switchMap((team) => of$(team?.displayName || '')), distinctUntilChanged(), ); diff --git a/app/components/floating_text_input_label/index.tsx b/app/components/floating_text_input_label/index.tsx index 2028078963..0855f6b4c2 100644 --- a/app/components/floating_text_input_label/index.tsx +++ b/app/components/floating_text_input_label/index.tsx @@ -112,7 +112,7 @@ type FloatingTextInputProps = TextInputProps & { testID?: string; textInputStyle?: TextStyle; theme: Theme; - value: string; + value?: string; } const FloatingTextInput = forwardRef(({ @@ -133,7 +133,7 @@ const FloatingTextInput = forwardRef { const [focused, setIsFocused] = useState(false); @@ -153,11 +153,11 @@ const FloatingTextInput = forwardRef { - if (!focusedLabel && value) { + if (!focusedLabel && (value || props.defaultValue)) { debouncedOnFocusTextInput(true); } }, - [value], + [value, props.defaultValue], ); const onTextInputBlur = useCallback((e: NativeSyntheticEvent) => onExecution(e, @@ -218,7 +218,7 @@ const FloatingTextInput = forwardRef { - const inputText = placeholder || value; + const inputText = placeholder || value || props.defaultValue; const index = inputText || focusedLabel ? 1 : 0; const toValue = positions[index]; const toSize = size[index]; diff --git a/app/components/server_version/index.tsx b/app/components/server_version/index.tsx index 55ac7d7b9b..d23ab3e7de 100644 --- a/app/components/server_version/index.tsx +++ b/app/components/server_version/index.tsx @@ -9,8 +9,7 @@ import {distinctUntilChanged, map} from 'rxjs/operators'; import {setLastServerVersionCheck} from '@actions/local/systems'; import {useServerUrl} from '@context/server'; -import DatabaseManager from '@database/manager'; -import {queryServer} from '@queries/app/servers'; +import {getServer} from '@queries/app/servers'; import {observeConfigValue, observeLastServerVersionCheck} from '@queries/servers/system'; import {observeCurrentUser} from '@queries/servers/user'; import {isSupportedServer, unsupportedServer} from '@utils/server'; @@ -27,12 +26,9 @@ type ServerVersionProps = { const VALIDATE_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours const handleUnsupportedServer = async (serverUrl: string, isAdmin: boolean, intl: IntlShape) => { - const appDatabase = DatabaseManager.appDatabase?.database; - if (appDatabase) { - const serverModel = await queryServer(appDatabase, serverUrl); - unsupportedServer(serverModel?.displayName || '', isAdmin, intl); - setLastServerVersionCheck(serverUrl); - } + const serverModel = await getServer(serverUrl); + unsupportedServer(serverModel?.displayName || '', isAdmin, intl); + setLastServerVersionCheck(serverUrl); }; const ServerVersion = ({isAdmin, lastChecked, version}: ServerVersionProps) => { diff --git a/app/constants/image.ts b/app/constants/image.ts index 287c2d2f6a..bd7d073709 100644 --- a/app/constants/image.ts +++ b/app/constants/image.ts @@ -6,11 +6,13 @@ export const IMAGE_MIN_DIMENSION = 50; export const MAX_GIF_SIZE = 100 * 1024 * 1024; export const VIEWPORT_IMAGE_OFFSET = 70; export const VIEWPORT_IMAGE_REPLY_OFFSET = 11; +export const MAX_RESOLUTION = 7680 * 4320; // 8K, ~33MPX export default { IMAGE_MAX_HEIGHT, IMAGE_MIN_DIMENSION, MAX_GIF_SIZE, + MAX_RESOLUTION, VIEWPORT_IMAGE_OFFSET, VIEWPORT_IMAGE_REPLY_OFFSET, }; diff --git a/app/database/manager/__mocks__/index.ts b/app/database/manager/__mocks__/index.ts index 3d1baf3ece..9f84c0c17d 100644 --- a/app/database/manager/__mocks__/index.ts +++ b/app/database/manager/__mocks__/index.ts @@ -21,12 +21,12 @@ 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 {queryActiveServer, queryServer, queryServerByIdentifier} from '@queries/app/servers'; import {deleteIOSDatabase} from '@utils/mattermost_managed'; import {urlSafeBase64Encode} from '@utils/security'; import {removeProtocol} from '@utils/url'; import type {AppDatabase, CreateServerDatabaseArgs, Models, RegisterServerDatabaseArgs, ServerDatabase, ServerDatabases} from '@typings/database/database'; +import type ServerModel from '@typings/database/models/app/servers'; const {SERVERS} = MM_TABLES.APP; const APP_DATABASE = 'app'; @@ -85,7 +85,6 @@ class DatabaseManager { database, operator, }; - return this.appDatabase; } catch (e) { // do nothing @@ -168,9 +167,9 @@ class DatabaseManager { public updateServerIdentifier = async (serverUrl: string, identifier: string) => { const appDatabase = this.appDatabase?.database; if (appDatabase) { - const server = await queryServer(appDatabase, serverUrl); + const server = await this.getServer(serverUrl); await appDatabase.write(async () => { - await server.update((record) => { + await server?.update((record) => { record.identifier = identifier; }); }); @@ -180,9 +179,9 @@ class DatabaseManager { public updateServerDisplayName = async (serverUrl: string, displayName: string) => { const appDatabase = this.appDatabase?.database; if (appDatabase) { - const server = await queryServer(appDatabase, serverUrl); + const server = await this.getServer(serverUrl); await appDatabase.write(async () => { - await server.update((record) => { + await server?.update((record) => { record.displayName = displayName; }); }); @@ -190,42 +189,23 @@ class DatabaseManager { }; private isServerPresent = async (serverUrl: string): Promise => { - if (this.appDatabase?.database) { - const server = await queryServer(this.appDatabase.database, serverUrl); - return Boolean(server); - } - - return false; + const server = await this.getServer(serverUrl); + return Boolean(server); }; - public getActiveServerUrl = async (): Promise => { - const database = this.appDatabase?.database; - if (database) { - const server = await queryActiveServer(database); - return server?.url; - } - - return null; + public getActiveServerUrl = async (): Promise => { + const server = await this.getActiveServer(); + return server?.url; }; - public getActiveServerDisplayName = async (): Promise => { - const database = this.appDatabase?.database; - if (database) { - const server = await queryActiveServer(database); - return server?.displayName; - } - - return null; + public getActiveServerDisplayName = async (): Promise => { + const server = await this.getActiveServer(); + return server?.displayName; }; public getServerUrlFromIdentifier = async (identifier: string): Promise => { - const database = this.appDatabase?.database; - if (database) { - const server = await queryServerByIdentifier(database, identifier); - return server?.url; - } - - return undefined; + const server = await this.getServerByIdentifier(identifier); + return server?.url; }; public getAppDatabaseAndOperator = () => { @@ -247,12 +227,9 @@ class DatabaseManager { }; public getActiveServerDatabase = async (): Promise => { - const database = this.appDatabase?.database; - if (database) { - const server = await queryActiveServer(database); - if (server?.url) { - return this.serverDatabases[server.url]!.database; - } + const server = await this.getActiveServer(); + if (server?.url) { + return this.serverDatabases[server.url]!.database; } return undefined; @@ -273,9 +250,9 @@ class DatabaseManager { }; public deleteServerDatabase = async (serverUrl: string): Promise => { - if (this.appDatabase?.database) { - const database = this.appDatabase?.database; - const server = await queryServer(database, serverUrl); + const database = this.appDatabase?.database; + if (database) { + const server = await this.getServer(serverUrl); if (server) { database.write(async () => { await server.update((record) => { @@ -291,9 +268,9 @@ class DatabaseManager { }; public destroyServerDatabase = async (serverUrl: string): Promise => { - if (this.appDatabase?.database) { - const database = this.appDatabase?.database; - const server = await queryServer(database, serverUrl); + const database = this.appDatabase?.database; + if (database) { + const server = await this.getServer(serverUrl); if (server) { database.write(async () => { await server.destroyPermanently(); @@ -380,6 +357,46 @@ class DatabaseManager { const toFindWithoutProtocol = removeProtocol(toFind); return Object.keys(this.serverDatabases).find((k) => removeProtocol(k) === toFindWithoutProtocol); }; + + // This actions already exists but the mock fails when using them cause of cyclic requires + private getServer = async (serverUrl: string) => { + try { + const {database} = this.getAppDatabaseAndOperator(); + const servers = (await database.get(SERVERS).query(Q.where('url', serverUrl)).fetch()); + return servers?.[0]; + } catch { + return undefined; + } + }; + + private getAllServers = async () => { + try { + const {database} = this.getAppDatabaseAndOperator(); + return database.get(MM_TABLES.APP.SERVERS).query().fetch(); + } catch { + return []; + } + }; + + private getActiveServer = async () => { + try { + const servers = await this.getAllServers(); + const server = servers?.filter((s) => s.identifier)?.reduce((a, b) => (b.lastActiveAt > a.lastActiveAt ? b : a)); + return server; + } catch { + return undefined; + } + }; + + private getServerByIdentifier = async (identifier: string) => { + try { + const {database} = this.getAppDatabaseAndOperator(); + const servers = (await database.get(SERVERS).query(Q.where('identifier', identifier)).fetch()); + return servers?.[0]; + } catch { + return undefined; + } + }; } export default new DatabaseManager(); diff --git a/app/database/manager/index.ts b/app/database/manager/index.ts index c1d2819c17..6a586ea749 100644 --- a/app/database/manager/index.ts +++ b/app/database/manager/index.ts @@ -22,7 +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 {queryActiveServer, queryServer, queryServerByIdentifier} from '@queries/app/servers'; +import {getActiveServer, getServer, getServerByIdentifier} from '@queries/app/servers'; import {querySystemValue} from '@queries/servers/system'; import {deleteLegacyFileCache} from '@utils/file'; import {emptyFunction} from '@utils/general'; @@ -223,7 +223,7 @@ class DatabaseManager { try { const appDatabase = this.appDatabase?.database; if (appDatabase) { - const serverModel = await queryServer(appDatabase, serverUrl); + const serverModel = await getServer(serverUrl); if (!serverModel) { await appDatabase.write(async () => { @@ -254,9 +254,9 @@ class DatabaseManager { public updateServerIdentifier = async (serverUrl: string, identifier: string, displayName?: string) => { const appDatabase = this.appDatabase?.database; if (appDatabase) { - const server = await queryServer(appDatabase, serverUrl); + const server = await getServer(serverUrl); await appDatabase.write(async () => { - await server.update((record) => { + await server?.update((record) => { record.identifier = identifier; if (displayName) { record.displayName = displayName; @@ -269,9 +269,9 @@ class DatabaseManager { public updateServerDisplayName = async (serverUrl: string, displayName: string) => { const appDatabase = this.appDatabase?.database; if (appDatabase) { - const server = await queryServer(appDatabase, serverUrl); + const server = await getServer(serverUrl); await appDatabase.write(async () => { - await server.update((record) => { + await server?.update((record) => { record.displayName = displayName; }); }); @@ -284,50 +284,31 @@ class DatabaseManager { * @returns {Promise} */ private isServerPresent = async (serverUrl: string): Promise => { - if (this.appDatabase?.database) { - const server = await queryServer(this.appDatabase.database, serverUrl); - return Boolean(server); - } - - return false; + const server = await getServer(serverUrl); + return Boolean(server); }; /** * getActiveServerUrl: Get the server url for active server database. - * @returns {Promise} + * @returns {Promise} */ - public getActiveServerUrl = async (): Promise => { - const database = this.appDatabase?.database; - if (database) { - const server = await queryActiveServer(database); - return server?.url; - } - - return null; + public getActiveServerUrl = async (): Promise => { + const server = await getActiveServer(); + return server?.url; }; /** * getActiveServerDisplayName: Get the server display name for active server database. - * @returns {Promise} + * @returns {Promise} */ - public getActiveServerDisplayName = async (): Promise => { - const database = this.appDatabase?.database; - if (database) { - const server = await queryActiveServer(database); - return server?.displayName; - } - - return null; + public getActiveServerDisplayName = async (): Promise => { + const server = await getActiveServer(); + return server?.displayName; }; public getServerUrlFromIdentifier = async (identifier: string): Promise => { - const database = this.appDatabase?.database; - if (database) { - const server = await queryServerByIdentifier(database, identifier); - return server?.url; - } - - return undefined; + const server = await getServerByIdentifier(identifier); + return server?.url; }; /** @@ -335,12 +316,9 @@ class DatabaseManager { * @returns {Promise} */ public getActiveServerDatabase = async (): Promise => { - const database = this.appDatabase?.database; - if (database) { - const server = await queryActiveServer(database); - if (server?.url) { - return this.serverDatabases[server.url]?.database; - } + const server = await getActiveServer(); + if (server?.url) { + return this.serverDatabases[server.url]?.database; } return undefined; @@ -405,9 +383,9 @@ class DatabaseManager { * @returns {Promise} */ public deleteServerDatabase = async (serverUrl: string): Promise => { - if (this.appDatabase?.database) { - const database = this.appDatabase?.database; - const server = await queryServer(database, serverUrl); + const database = this.appDatabase?.database; + if (database) { + const server = await getServer(serverUrl); if (server) { database.write(async () => { await server.update((record) => { @@ -429,9 +407,9 @@ class DatabaseManager { * @returns {Promise} */ public destroyServerDatabase = async (serverUrl: string): Promise => { - if (this.appDatabase?.database) { - const database = this.appDatabase?.database; - const server = await queryServer(database, serverUrl); + const database = this.appDatabase?.database; + if (database) { + const server = await getServer(serverUrl); if (server) { database.write(async () => { await server.destroyPermanently(); diff --git a/app/init/app.ts b/app/init/app.ts new file mode 100644 index 0000000000..665d30c7b4 --- /dev/null +++ b/app/init/app.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {DeviceEventEmitter} from 'react-native'; +import {ComponentDidAppearEvent, ComponentDidDisappearEvent, ModalDismissedEvent, Navigation, ScreenPoppedEvent} from 'react-native-navigation'; + +import {Events, Screens} from '@constants'; +import {OVERLAY_SCREENS} from '@constants/screens'; +import DatabaseManager from '@database/manager'; +import {getAllServerCredentials} from '@init/credentials'; +import {initialLaunch} from '@init/launch'; +import ManagedApp from '@init/managed_app'; +import PushNotifications from '@init/push_notifications'; +import GlobalEventHandler from '@managers/global_event_handler'; +import NetworkManager from '@managers/network_manager'; +import SessionManager from '@managers/session_manager'; +import WebsocketManager from '@managers/websocket_manager'; +import {registerScreens} from '@screens/index'; +import NavigationStore from '@store/navigation_store'; + +let alreadyInitialized = false; +let serverCredentials: ServerCredential[]; + +export async function initialize() { + if (!alreadyInitialized) { + alreadyInitialized = true; + serverCredentials = await getAllServerCredentials(); + const serverUrls = serverCredentials.map((credential) => credential.serverUrl); + + await DatabaseManager.init(serverUrls); + await NetworkManager.init(serverCredentials); + + GlobalEventHandler.init(); + ManagedApp.init(); + SessionManager.init(); + } +} + +export async function start() { + await initialize(); + await WebsocketManager.init(serverCredentials); + + PushNotifications.init(serverCredentials.length > 0); + + registerNavigationListeners(); + registerScreens(); + initialLaunch(); +} + +function registerNavigationListeners() { + Navigation.events().registerComponentDidAppearListener(screenDidAppearListener); + Navigation.events().registerComponentDidDisappearListener(screenDidDisappearListener); + Navigation.events().registerComponentWillAppearListener(screenWillAppear); + Navigation.events().registerScreenPoppedListener(screenPoppedListener); + Navigation.events().registerModalDismissedListener(modalDismissedListener); +} + +function screenWillAppear({componentId}: ComponentDidAppearEvent) { + if (componentId === Screens.HOME) { + DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, true); + } else if ([Screens.EDIT_POST, Screens.THREAD].includes(componentId)) { + DeviceEventEmitter.emit(Events.PAUSE_KEYBOARD_TRACKING_VIEW, true); + } +} + +function screenDidAppearListener({componentId, componentType}: ComponentDidAppearEvent) { + if (!OVERLAY_SCREENS.has(componentId) && componentType === 'Component') { + NavigationStore.addNavigationComponentId(componentId); + } +} + +function screenDidDisappearListener({componentId}: ComponentDidDisappearEvent) { + if (componentId !== Screens.HOME) { + if ([Screens.EDIT_POST, Screens.THREAD].includes(componentId)) { + DeviceEventEmitter.emit(Events.PAUSE_KEYBOARD_TRACKING_VIEW, false); + } + + if (NavigationStore.getNavigationTopComponentId() === Screens.HOME) { + DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, true); + } + } +} + +function screenPoppedListener({componentId}: ScreenPoppedEvent) { + NavigationStore.removeNavigationComponentId(componentId); + if (NavigationStore.getNavigationTopComponentId() === Screens.HOME) { + DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, true); + } +} + +function modalDismissedListener({componentId}: ModalDismissedEvent) { + const topScreen = NavigationStore.getNavigationTopComponentId(); + const topModal = NavigationStore.getNavigationTopModalId(); + const toRemove = topScreen === topModal ? topModal : componentId; + NavigationStore.removeNavigationModal(toRemove); + if (NavigationStore.getNavigationTopComponentId() === Screens.HOME) { + DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, true); + } +} diff --git a/app/init/managed_app.ts b/app/init/managed_app.ts index 86c15f29b0..234a5f783d 100644 --- a/app/init/managed_app.ts +++ b/app/init/managed_app.ts @@ -8,6 +8,7 @@ import {Alert, AlertButton, AppState, AppStateStatus, Platform} from 'react-nati import {DEFAULT_LOCALE, getTranslations, t} from '@i18n'; import {toMilliseconds} from '@utils/datetime'; +import {isMainActivity} from '@utils/helpers'; import {getIOSAppGroupDetails} from '@utils/mattermost_managed'; const PROMPT_IN_APP_PIN_CODE_AFTER = toMilliseconds({minutes: 5}); @@ -146,7 +147,7 @@ class ManagedApp { const isBackground = appState === 'background'; if (isActive && this.previousAppState === 'background' && !this.performingAuthentication) { - if (this.enabled && this.inAppPinCode) { + if (this.enabled && this.inAppPinCode && isMainActivity()) { const authExpired = this.backgroundSince > 0 && (Date.now() - this.backgroundSince) >= PROMPT_IN_APP_PIN_CODE_AFTER; await this.handleDeviceAuthentication(authExpired); } diff --git a/app/init/push_notifications.ts b/app/init/push_notifications.ts index e60fc2b695..e0c976a206 100644 --- a/app/init/push_notifications.ts +++ b/app/init/push_notifications.ts @@ -13,6 +13,7 @@ import { NotificationTextInput, Registered, } from 'react-native-notifications'; +import {requestNotifications} from 'react-native-permissions'; import {storeDeviceToken} from '@actions/app/global'; import {markChannelAsViewed} from '@actions/local/channel'; @@ -22,27 +23,41 @@ import {Device, Events, Navigation, PushNotification, Screens} from '@constants' import DatabaseManager from '@database/manager'; import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n'; import NativeNotifications from '@notifications'; -import {queryServerName} from '@queries/app/servers'; +import {getServerDisplayName} from '@queries/app/servers'; import {getCurrentChannelId} from '@queries/servers/system'; import {getIsCRTEnabled, getThreadById} from '@queries/servers/thread'; import {dismissOverlay, showOverlay} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; import NavigationStore from '@store/navigation_store'; -import {isTablet} from '@utils/helpers'; +import {isMainActivity, isTablet} from '@utils/helpers'; import {logInfo} from '@utils/log'; import {convertToNotificationData} from '@utils/notification'; class PushNotifications { configured = false; - init() { - Notifications.registerRemoteNotifications(); + init(register: boolean) { + if (register) { + Notifications.registerRemoteNotifications(); + } + Notifications.events().registerNotificationOpened(this.onNotificationOpened); Notifications.events().registerRemoteNotificationsRegistered(this.onRemoteNotificationsRegistered); Notifications.events().registerNotificationReceivedBackground(this.onNotificationReceivedBackground); Notifications.events().registerNotificationReceivedForeground(this.onNotificationReceivedForeground); } + async registerIfNeeded() { + const isRegistered = await Notifications.isRegisteredForRemoteNotifications(); + if (!isRegistered) { + if (Platform.OS === 'android') { + Notifications.registerRemoteNotifications(); + } else { + await requestNotifications(['alert', 'sound', 'badge']); + } + } + } + createReplyCategory = () => { const replyTitle = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.title')); const replyButton = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.button')); @@ -93,7 +108,7 @@ class PushNotifications { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (database) { const isTabletDevice = await isTablet(); - const displayName = await queryServerName(DatabaseManager.appDatabase!.database, serverUrl); + const displayName = await getServerDisplayName(serverUrl); const channelId = await getCurrentChannelId(database); const isCRTEnabled = await getIsCRTEnabled(database); let serverName; @@ -218,7 +233,7 @@ class PushNotifications { onNotificationReceivedForeground = (incoming: Notification, completion: (response: NotificationCompletion) => void) => { const notification = convertToNotificationData(incoming, false); if (AppState.currentState !== 'inactive') { - notification.foreground = AppState.currentState === 'active'; + notification.foreground = AppState.currentState === 'active' && isMainActivity(); this.processNotification(notification); } diff --git a/app/managers/global_event_handler.ts b/app/managers/global_event_handler.ts index 28ab25db64..7c0f75af4f 100644 --- a/app/managers/global_event_handler.ts +++ b/app/managers/global_event_handler.ts @@ -9,12 +9,11 @@ import {autoUpdateTimezone} from '@actions/remote/user'; import LocalConfig from '@assets/config.json'; import {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 {getLaunchPropsFromDeepLink, relaunchApp} from '@init/launch'; import * as analytics from '@managers/analytics'; -import {queryAllServers} from '@queries/app/servers'; +import {getAllServers} from '@queries/app/servers'; import {logError} from '@utils/log'; import type {jsAndNativeErrorHandler} from '@typings/global/error_handling'; @@ -29,8 +28,7 @@ class GlobalEventHandler { DeviceEventEmitter.addListener(Events.CONFIG_CHANGED, this.onServerConfigChanged); RNLocalize.addEventListener('change', async () => { try { - const {database} = DatabaseManager.getAppDatabaseAndOperator(); - const servers = await queryAllServers(database); + const servers = await getAllServers(); for (const server of servers) { if (server.url && server.lastActiveAt > 0) { autoUpdateTimezone(server.url); diff --git a/app/managers/session_manager.ts b/app/managers/session_manager.ts index d8d44495cc..542be18001 100644 --- a/app/managers/session_manager.ts +++ b/app/managers/session_manager.ts @@ -16,11 +16,12 @@ import PushNotifications from '@init/push_notifications'; import * as analytics from '@managers/analytics'; import NetworkManager from '@managers/network_manager'; import WebsocketManager from '@managers/websocket_manager'; -import {queryAllServers, queryServerName} from '@queries/app/servers'; +import {getAllServers, getServerDisplayName} from '@queries/app/servers'; import {getCurrentUser} from '@queries/servers/user'; import {getThemeFromState} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; import {deleteFileCache, deleteFileCacheByDir} from '@utils/file'; +import {isMainActivity} from '@utils/helpers'; import {addNewServer} from '@utils/server'; import type {LaunchType} from '@typings/launch'; @@ -54,10 +55,10 @@ class SessionManager { } init() { - this.cancelAll(); + this.cancelAllSessionNotifications(); } - private cancelAll = async () => { + private cancelAllSessionNotifications = async () => { const serverCredentials = await getAllServerCredentials(); for (const {serverUrl} of serverCredentials) { cancelSessionNotification(serverUrl); @@ -86,7 +87,7 @@ class SessionManager { } }; - private scheduleAll = async () => { + private scheduleAllSessionNotifications = async () => { if (!this.scheduling) { this.scheduling = true; const serverCredentials = await getAllServerCredentials(); @@ -142,17 +143,17 @@ class SessionManager { }; private onAppStateChange = async (appState: AppStateStatus) => { - if (appState === this.previousAppState) { + if (appState === this.previousAppState || !isMainActivity()) { return; } this.previousAppState = appState; switch (appState) { case 'active': - setTimeout(this.cancelAll, 750); + setTimeout(this.cancelAllSessionNotifications, 750); break; case 'inactive': - this.scheduleAll(); + this.scheduleAllSessionNotifications(); break; } }; @@ -179,11 +180,9 @@ class SessionManager { } // set the onboardingViewed value to false so the launch will show the onboarding screen after all servers were removed - if (DatabaseManager.appDatabase) { - const servers = await queryAllServers(DatabaseManager.appDatabase.database); - if (!servers.length) { - await storeOnboardingViewedValue(false); - } + const servers = await getAllServers(); + if (!servers.length) { + await storeOnboardingViewedValue(false); } relaunchApp({launchType, serverUrl, displayName}, true); @@ -196,8 +195,7 @@ class SessionManager { await this.terminateSession(serverUrl, false); const activeServerUrl = await DatabaseManager.getActiveServerUrl(); - const appDatabase = DatabaseManager.appDatabase?.database; - const serverDisplayName = appDatabase ? await queryServerName(appDatabase, serverUrl) : undefined; + const serverDisplayName = await getServerDisplayName(serverUrl); await relaunchApp({launchType: Launch.Normal, serverUrl, displayName: serverDisplayName}, true); if (activeServerUrl) { diff --git a/app/managers/websocket_manager.ts b/app/managers/websocket_manager.ts index 3ac1ea269e..9dc12a0889 100644 --- a/app/managers/websocket_manager.ts +++ b/app/managers/websocket_manager.ts @@ -15,6 +15,7 @@ import DatabaseManager from '@database/manager'; import {getCurrentUserId} from '@queries/servers/system'; import {queryAllUsers} from '@queries/servers/user'; import {toMilliseconds} from '@utils/datetime'; +import {isMainActivity} from '@utils/helpers'; import {logError} from '@utils/log'; const WAIT_TO_CLOSE = toMilliseconds({seconds: 15}); @@ -181,6 +182,8 @@ class WebsocketManager { return; } + const isMain = isMainActivity(); + this.cancelAllConnections(); if (appState !== 'active' && !this.isBackgroundTimerRunning) { this.isBackgroundTimerRunning = true; @@ -195,7 +198,7 @@ class WebsocketManager { return; } - if (appState === 'active' && this.netConnected) { // Reopen the websockets only if there is connection + if (appState === 'active' && this.netConnected && isMain) { // Reopen the websockets only if there is connection if (this.backgroundIntervalId) { BackgroundTimer.clearInterval(this.backgroundIntervalId); } @@ -205,7 +208,9 @@ class WebsocketManager { return; } - this.previousAppState = appState; + if (isMain) { + this.previousAppState = appState; + } }; private onNetStateChange = async (netState: NetInfoState) => { diff --git a/app/queries/app/global.ts b/app/queries/app/global.ts index acecf327da..f362d09993 100644 --- a/app/queries/app/global.ts +++ b/app/queries/app/global.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Database, Q} from '@nozbe/watermelondb'; +import {Q} from '@nozbe/watermelondb'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; @@ -12,9 +12,10 @@ import type GlobalModel from '@typings/database/models/app/global'; const {APP: {GLOBAL}} = MM_TABLES; -export const getDeviceToken = async (appDatabase: Database): Promise => { +export const getDeviceToken = async (): Promise => { try { - const tokens = await appDatabase.get(GLOBAL).find(GLOBAL_IDENTIFIERS.DEVICE_TOKEN); + const {database} = DatabaseManager.getAppDatabaseAndOperator(); + const tokens = await database.get(GLOBAL).find(GLOBAL_IDENTIFIERS.DEVICE_TOKEN); return tokens?.value || ''; } catch { return ''; diff --git a/app/queries/app/servers.ts b/app/queries/app/servers.ts index a1d28a6ddb..a735b0fcf5 100644 --- a/app/queries/app/servers.ts +++ b/app/queries/app/servers.ts @@ -1,7 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Database, Q} from '@nozbe/watermelondb'; +import {Q} from '@nozbe/watermelondb'; +import {of as of$, switchMap, distinctUntilChanged} from 'rxjs'; import {SupportedServer} from '@constants'; import {MM_TABLES} from '@constants/database'; @@ -13,18 +14,51 @@ import type ServerModel from '@typings/database/models/app/servers'; const {APP: {SERVERS}} = MM_TABLES; -export const queryServer = async (appDatabase: Database, serverUrl: string) => { - const servers = (await appDatabase.get(SERVERS).query(Q.where('url', serverUrl)).fetch()); - return servers?.[0]; -}; - -export const queryAllServers = async (appDatabase: Database) => { - return appDatabase.get(MM_TABLES.APP.SERVERS).query().fetch(); -}; - -export const queryActiveServer = async (appDatabase: Database) => { +export const queryServerDisplayName = (serverUrl: string) => { try { - const servers = await queryAllServers(appDatabase); + const {database} = DatabaseManager.getAppDatabaseAndOperator(); + return database.get(SERVERS).query(Q.where('url', serverUrl)); + } catch { + return undefined; + } +}; + +export const queryAllActiveServers = () => { + try { + const {database} = DatabaseManager.getAppDatabaseAndOperator(); + return database.get(MM_TABLES.APP.SERVERS).query( + Q.and( + Q.where('identifier', Q.notEq('')), + Q.where('last_active_at', Q.gt(0)), + ), + ); + } catch { + return undefined; + } +}; + +export const getServer = async (serverUrl: string) => { + try { + const {database} = DatabaseManager.getAppDatabaseAndOperator(); + const servers = (await database.get(SERVERS).query(Q.where('url', serverUrl)).fetch()); + return servers?.[0]; + } catch { + return undefined; + } +}; + +export const getAllServers = async () => { + try { + const {database} = DatabaseManager.getAppDatabaseAndOperator(); + return database.get(MM_TABLES.APP.SERVERS).query().fetch(); + } catch { + return []; + } +}; + +export const getActiveServer = async () => { + try { + const servers = await getAllServers(); const server = servers?.filter((s) => s.identifier)?.reduce((a, b) => (b.lastActiveAt > a.lastActiveAt ? b : a)); return server; } catch { @@ -32,45 +66,45 @@ export const queryActiveServer = async (appDatabase: Database) => { } }; -export const getActiveServerUrl = async (appDatabase: Database) => { - const server = await queryActiveServer(appDatabase); +export const getActiveServerUrl = async () => { + const server = await getActiveServer(); return server?.url || ''; }; -export const queryServerByIdentifier = async (appDatabase: Database, identifier: string) => { +export const getServerByIdentifier = async (identifier: string) => { try { - const servers = (await appDatabase.get(SERVERS).query(Q.where('identifier', identifier)).fetch()); + const {database} = DatabaseManager.getAppDatabaseAndOperator(); + const servers = (await database.get(SERVERS).query(Q.where('identifier', identifier)).fetch()); return servers?.[0]; } catch { return undefined; } }; -export const queryServerByDisplayName = async (appDatabase: Database, displayName: string) => { - const servers = await queryAllServers(appDatabase); +export const getServerByDisplayName = async (displayName: string) => { + const servers = await getAllServers(); const server = servers.find((s) => s.displayName.toLowerCase() === displayName.toLowerCase()); return server; }; -export const queryServerName = async (appDatabase: Database, serverUrl: string) => { - try { - const servers = (await appDatabase.get(SERVERS).query(Q.where('url', serverUrl)).fetch()); - return servers?.[0].displayName; - } catch { - return serverUrl; - } +export const getServerDisplayName = async (serverUrl: string) => { + const servers = await queryServerDisplayName(serverUrl)?.fetch(); + return servers?.[0].displayName || serverUrl; +}; + +export const observeServerDisplayName = (serverUrl: string) => { + return queryServerDisplayName(serverUrl)?.observeWithColumns(['display_name']).pipe( + switchMap((s) => of$(s.length ? s[0].displayName : serverUrl)), + distinctUntilChanged(), + ); +}; + +export const observeAllActiveServers = () => { + return queryAllActiveServers()?.observe() || of$([]); }; export const areAllServersSupported = async () => { - let appDatabase; - try { - const databaseAndOperator = DatabaseManager.getAppDatabaseAndOperator(); - appDatabase = databaseAndOperator.database; - } catch { - return false; - } - - const servers = await queryAllServers(appDatabase); + const servers = await getAllServers(); for await (const s of servers) { if (s.lastActiveAt) { try { diff --git a/app/screens/edit_server/index.tsx b/app/screens/edit_server/index.tsx index 8d61a9c562..aec4167a4f 100644 --- a/app/screens/edit_server/index.tsx +++ b/app/screens/edit_server/index.tsx @@ -9,7 +9,7 @@ import {Navigation} from 'react-native-navigation'; import {SafeAreaView} from 'react-native-safe-area-context'; import DatabaseManager from '@database/manager'; -import {queryServerByDisplayName} from '@queries/app/servers'; +import {getServerByDisplayName} from '@queries/app/servers'; import Background from '@screens/background'; import {dismissModal} from '@screens/navigation'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -73,7 +73,7 @@ const EditServer = ({closeButtonId, componentId, server, theme}: ServerProps) => } setSaving(true); - const knownServer = await queryServerByDisplayName(DatabaseManager.appDatabase!.database, displayName); + const knownServer = await getServerByDisplayName(displayName); if (knownServer && knownServer.lastActiveAt > 0 && knownServer.url !== server.url) { setButtonDisabled(true); setDisplayNameError(formatMessage({ diff --git a/app/screens/home/channel_list/channel_list.tsx b/app/screens/home/channel_list/channel_list.tsx index 733d8f1be1..f1cbf5d63d 100644 --- a/app/screens/home/channel_list/channel_list.tsx +++ b/app/screens/home/channel_list/channel_list.tsx @@ -18,6 +18,7 @@ import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; import {resetToTeams, openToS} from '@screens/navigation'; import NavigationStore from '@store/navigation_store'; +import {isMainActivity} from '@utils/helpers'; import {tryRunAppReview} from '@utils/reviews'; import {addSentryContext} from '@utils/sentry'; @@ -79,24 +80,27 @@ const ChannelListScreen = (props: ChannelProps) => { const isHomeScreen = NavigationStore.getNavigationTopComponentId() === Screens.HOME; const homeTab = NavigationStore.getVisibleTab() === Screens.HOME; const focused = navigation.isFocused() && isHomeScreen && homeTab; - if (!backPressedCount && focused) { - backPressedCount++; - ToastAndroid.show(intl.formatMessage({ - id: 'mobile.android.back_handler_exit', - defaultMessage: 'Press back again to exit', - }), ToastAndroid.SHORT); - if (backPressTimeout) { - clearTimeout(backPressTimeout); + if (isMainActivity()) { + if (!backPressedCount && focused) { + backPressedCount++; + ToastAndroid.show(intl.formatMessage({ + id: 'mobile.android.back_handler_exit', + defaultMessage: 'Press back again to exit', + }), ToastAndroid.SHORT); + + if (backPressTimeout) { + clearTimeout(backPressTimeout); + } + backPressTimeout = setTimeout(() => { + clearTimeout(backPressTimeout!); + backPressedCount = 0; + }, 2000); + return true; + } else if (isHomeScreen && !homeTab) { + DeviceEventEmitter.emit(NavigationConstants.NAVIGATION_HOME); + return true; } - backPressTimeout = setTimeout(() => { - clearTimeout(backPressTimeout!); - backPressedCount = 0; - }, 2000); - return true; - } else if (isHomeScreen && !homeTab) { - DeviceEventEmitter.emit(NavigationConstants.NAVIGATION_HOME); - return true; } return false; }, [intl]); diff --git a/app/screens/home/channel_list/servers/index.tsx b/app/screens/home/channel_list/servers/index.tsx index 51d053f464..6712fddd46 100644 --- a/app/screens/home/channel_list/servers/index.tsx +++ b/app/screens/home/channel_list/servers/index.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; -import {IntlShape, useIntl} from 'react-intl'; +import {useIntl} from 'react-intl'; import {StyleSheet} from 'react-native'; import ServerIcon from '@components/server_icon'; @@ -12,6 +12,7 @@ import {subscribeAllServers} from '@database/subscription/servers'; import {subscribeUnreadAndMentionsByServer, UnreadObserverArgs} from '@database/subscription/unreads'; import {useIsTablet} from '@hooks/device'; import {bottomSheet} from '@screens/navigation'; +import {sortServersByDisplayName} from '@utils/server'; import ServerList from './servers_list'; @@ -33,20 +34,6 @@ const styles = StyleSheet.create({ }, }); -const sortServers = (servers: ServersModel[], intl: IntlShape) => { - function serverName(s: ServersModel) { - if (s.displayName === s.url) { - return intl.formatMessage({id: 'servers.default', defaultMessage: 'Default Server'}); - } - - return s.displayName; - } - - return servers.sort((a, b) => { - return serverName(a).localeCompare(serverName(b)); - }); -}; - export type ServersRef = { openServers: () => void; } @@ -88,7 +75,7 @@ const Servers = React.forwardRef((props, ref) => { }; const serversObserver = async (servers: ServersModel[]) => { - registeredServers.current = sortServers(servers, intl); + registeredServers.current = sortServersByDisplayName(servers, intl); // unsubscribe mentions from servers that were removed const allUrls = new Set(servers.map((s) => s.url)); diff --git a/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx b/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx index 98da057633..f8301f0b00 100644 --- a/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx +++ b/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx @@ -23,7 +23,7 @@ import {useTheme} from '@context/theme'; import DatabaseManager from '@database/manager'; import {subscribeServerUnreadAndMentions, UnreadObserverArgs} from '@database/subscription/unreads'; import {useIsTablet} from '@hooks/device'; -import {queryServerByIdentifier} from '@queries/app/servers'; +import {getServerByIdentifier} from '@queries/app/servers'; import {dismissBottomSheet} from '@screens/navigation'; import {canReceiveNotifications} from '@utils/push_proxy'; import {alertServerAlreadyConnected, alertServerError, alertServerLogout, alertServerRemove, editServer, loginToServer} from '@utils/server'; @@ -243,7 +243,7 @@ const ServerItem = ({ setSwitching(false); return; } - const existingServer = await queryServerByIdentifier(DatabaseManager.appDatabase!.database, data.config!.DiagnosticId); + const existingServer = await getServerByIdentifier(data.config!.DiagnosticId); if (existingServer && existingServer.lastActiveAt > 0) { alertServerAlreadyConnected(intl); setSwitching(false); diff --git a/app/screens/server/index.tsx b/app/screens/server/index.tsx index 181a81f308..0e7e0cb559 100644 --- a/app/screens/server/index.tsx +++ b/app/screens/server/index.tsx @@ -16,10 +16,10 @@ import LocalConfig from '@assets/config.json'; import ClientError from '@client/rest/error'; import AppVersion from '@components/app_version'; import {Screens, Launch} from '@constants'; -import DatabaseManager from '@database/manager'; import {t} from '@i18n'; +import PushNotifications from '@init/push_notifications'; import NetworkManager from '@managers/network_manager'; -import {queryServerByDisplayName, queryServerByIdentifier} from '@queries/app/servers'; +import {getServerByDisplayName, getServerByIdentifier} from '@queries/app/servers'; import Background from '@screens/background'; import {dismissModal, goToScreen, loginAnimationOptions} from '@screens/navigation'; import {getErrorMessage} from '@utils/client_error'; @@ -56,7 +56,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, scrollContainer: { alignItems: 'center', - height: '100%', + height: '90%', justifyContent: 'center', }, })); @@ -161,6 +161,8 @@ const Server = ({ } }); + PushNotifications.registerIfNeeded(); + return () => navigationEvents.remove(); }, []); @@ -220,7 +222,7 @@ const Server = ({ setUrlError(undefined); } - const server = await queryServerByDisplayName(DatabaseManager.appDatabase!.database, displayName); + const server = await getServerByDisplayName(displayName); if (server && server.lastActiveAt > 0) { setButtonDisabled(true); setDisplayNameError(formatMessage({ @@ -293,7 +295,7 @@ const Server = ({ return; } - const server = await queryServerByIdentifier(DatabaseManager.appDatabase!.database, data.config!.DiagnosticId); + const server = await getServerByIdentifier(data.config!.DiagnosticId); setConnecting(false); if (server && server.lastActiveAt > 0) { diff --git a/app/utils/helpers.ts b/app/utils/helpers.ts index a3019c1997..e3115ca660 100644 --- a/app/utils/helpers.ts +++ b/app/utils/helpers.ts @@ -10,6 +10,7 @@ import {IOS_STATUS_BAR_HEIGHT} from '@constants/view'; const {MattermostManaged} = NativeModules; const isRunningInSplitView = MattermostManaged.isRunningInSplitView; +const ShareModule: NativeShareExtension|undefined = Platform.select({android: NativeModules.MattermostShare}); // isMinimumServerVersion will return true if currentVersion is equal to higher or than // the provided minimum version. A non-equal major version will ignore minor and dot @@ -151,3 +152,16 @@ export function bottomSheetSnapPoint(itemsCount: number, itemHeight: number, bot export function hasTrailingSpaces(term: string) { return term.length !== term.trimEnd().length; } + +/** + * isMainActivity returns true if the current activity on Android is the MainActivity otherwise it returns false, + * on iOS the result is always true + * + * @returns boolean + */ +export function isMainActivity() { + return Platform.select({ + default: true, + android: ShareModule?.getCurrentActivityName() === 'MainActivity', + }); +} diff --git a/app/utils/server/index.ts b/app/utils/server/index.ts index ba2d53ca82..f3130385a0 100644 --- a/app/utils/server/index.ts +++ b/app/utils/server/index.ts @@ -194,6 +194,20 @@ export function alertServerAlreadyConnected(intl: IntlShape) { ); } +export const sortServersByDisplayName = (servers: ServersModel[], intl: IntlShape) => { + function serverName(s: ServersModel) { + if (s.displayName === s.url) { + return intl.formatMessage({id: 'servers.default', defaultMessage: 'Default Server'}); + } + + return s.displayName; + } + + return servers.sort((a, b) => { + return serverName(a).localeCompare(serverName(b)); + }); +}; + function unsupportedServerAdminAlert(serverDisplayName: string, intl: IntlShape, onPress?: () => void) { const title = intl.formatMessage({id: 'mobile.server_upgrade.title', defaultMessage: 'Server upgrade required'}); diff --git a/app/utils/url/index.ts b/app/utils/url/index.ts index 232e16bf1b..71d06da3ed 100644 --- a/app/utils/url/index.ts +++ b/app/utils/url/index.ts @@ -78,6 +78,24 @@ export function extractFirstLink(text: string) { return ''; } +export function extractStartLink(text: string) { + const pattern = /^((?:https?|ftp):\/\/[-A-Z0-9+\u0026\u2019@#/%?=()~_|!:,.;]*[-A-Z0-9+\u0026@#/%=~()_|])/i; + let inText = text; + + // strip out code blocks + inText = inText.replace(/`[^`]*`/g, ''); + + // strip out inline markdown images + inText = inText.replace(/!\[[^\]]*]\([^)]*\)/g, ''); + + const match = pattern.exec(inText); + if (match) { + return match[0].trim(); + } + + return ''; +} + export function isYoutubeLink(link: string) { return link.trim().match(ytRegex); } diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 377b0ce791..1323443cae 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -262,6 +262,10 @@ "emoji_skin.medium_dark_skin_tone": "medium dark skin tone", "emoji_skin.medium_light_skin_tone": "medium light skin tone", "emoji_skin.medium_skin_tone": "medium skin tone", + "extension.no_memberships.description": "To share content, you'll need to be a member of a team on a Mattermost server.", + "extension.no_memberships.title": "Not a member of any team yet", + "extension.no_servers.description": "To share content, you'll need to be logged in to a Mattermost server.", + "extension.no_servers.title": "Not connected to any servers", "file_upload.fileAbove": "Files must be less than {max}", "find_channels.directory": "Directory", "find_channels.new_channel": "New Channel", @@ -625,7 +629,7 @@ "notification_settings.auto_responder": "Automatic Replies", "notification_settings.auto_responder.default_message": "Hello, I am out of office and unable to respond to messages.", "notification_settings.auto_responder.footer.message": "Set a custom message that is automatically sent in response to direct messages, such as an out of office or vacation reply. Enabling this setting changes your status to Out of Office and disables notifications.", - "notification_settings.auto_responder.message": "", + "notification_settings.auto_responder.message": "Message", "notification_settings.auto_responder.to.enable": "Enable automatic replies", "notification_settings.email": "Email Notifications", "notification_settings.email.crt.emailInfo": "When enabled, any reply to a thread you're following will send an email notification", @@ -643,7 +647,7 @@ "notification_settings.mentions..keywordsDescription": "Other words that trigger a mention", "notification_settings.mentions.channelWide": "Channel-wide mentions", "notification_settings.mentions.keywords": "Keywords", - "notification_settings.mentions.keywords_mention": "", + "notification_settings.mentions.keywords_mention": "Keywords that trigger mentions", "notification_settings.mentions.keywordsLabel": "Keywords are not case-sensitive. Separate keywords with commas.", "notification_settings.mentions.sensitiveName": "Your case sensitive first name", "notification_settings.mentions.sensitiveUsername": "Your non-case sensitive username", @@ -826,6 +830,18 @@ "settings.notice_text": "Mattermost is made possible by the open source software used in our {platform} and {mobile}.", "settings.notifications": "Notifications", "settings.save": "Save", + "share_extension.channel_error": "You are not a member of a team on the selected server. Select another server or open Mattermost to join a team.", + "share_extension.channel_label": "Channel", + "share_extension.count_limit": "You can only share {count, number} {count, plural, one {file} other {files}} on this server", + "share_extension.file_limit.multiple": "Each file must be less than {size}", + "share_extension.file_limit.single": "File must be less than {size}", + "share_extension.max_resolution": "Image exceeds maximum dimensions of 7680 x 4320 px", + "share_extension.message": "Enter a message (optional)", + "share_extension.multiple_label": "{count, number} attachments", + "share_extension.server_label": "Server", + "share_extension.servers_screen.title": "Select server", + "share_extension.share_screen.title": "Share to Mattermost", + "share_extension.upload_disabled": "File uploads are disabled for the selected server", "share_feedback.button.no": "No, thanks", "share_feedback.button.yes": "Yes", "share_feedback.subtitle": "We'd love to hear how we can make your experience better.", diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 2d14828f88..ac5295d189 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -688,6 +688,14 @@ platform :android do ) end + Dir.glob('../android/app/src/main/java/com/mattermost/share/*.java') do |item| + find_replace_string( + path_to_file: item[1..-1], + old_string: 'import com.mattermost.rnbeta.MainApplication;', + new_string: "import #{package_id}.MainApplication;" + ) + end + Dir.glob('../android/app/src/main/java/com/mattermost/helpers/*.java') do |item| find_replace_string( path_to_file: item[1..-1], diff --git a/index.ts b/index.ts index ccceb3ec9e..1b0b597d17 100644 --- a/index.ts +++ b/index.ts @@ -2,25 +2,13 @@ // See LICENSE.txt for license information. import TurboLogger from '@mattermost/react-native-turbo-log'; -import {DeviceEventEmitter, LogBox} from 'react-native'; +import {LogBox, Platform} from 'react-native'; import {RUNNING_E2E} from 'react-native-dotenv'; import 'react-native-gesture-handler'; -import {ComponentDidAppearEvent, ComponentDidDisappearEvent, ModalDismissedEvent, Navigation, ScreenPoppedEvent} from 'react-native-navigation'; +import {Navigation} from 'react-native-navigation'; import ViewReactNativeStyleAttributes from 'react-native/Libraries/Components/View/ReactNativeStyleAttributes'; -import {Events, Screens} from './app/constants'; -import {OVERLAY_SCREENS} from './app/constants/screens'; -import DatabaseManager from './app/database/manager'; -import {getAllServerCredentials} from './app/init/credentials'; -import {initialLaunch} from './app/init/launch'; -import ManagedApp from './app/init/managed_app'; -import PushNotifications from './app/init/push_notifications'; -import GlobalEventHandler from './app/managers/global_event_handler'; -import NetworkManager from './app/managers/network_manager'; -import SessionManager from './app/managers/session_manager'; -import WebsocketManager from './app/managers/websocket_manager'; -import {registerScreens} from './app/screens'; -import NavigationStore from './app/store/navigation_store'; +import {initialize, start} from './app/init/app'; import setFontFamily from './app/utils/font_family'; import {logInfo} from './app/utils/log'; @@ -64,76 +52,13 @@ if (global.HermesInternal) { require('@formatjs/intl-datetimeformat/add-golden-tz'); } -let alreadyInitialized = false; +if (Platform.OS === 'android') { + const ShareExtension = require('share_extension/index.tsx').default; + const AppRegistry = require('react-native/Libraries/ReactNative/AppRegistry'); + AppRegistry.registerComponent('MattermostShare', () => ShareExtension); +} + Navigation.events().registerAppLaunchedListener(async () => { - // See caution in the library doc https://wix.github.io/react-native-navigation/docs/app-launch#android - if (!alreadyInitialized) { - alreadyInitialized = true; - GlobalEventHandler.init(); - ManagedApp.init(); - registerNavigationListeners(); - registerScreens(); - - const serverCredentials = await getAllServerCredentials(); - const serverUrls = serverCredentials.map((credential) => credential.serverUrl); - - await DatabaseManager.init(serverUrls); - await NetworkManager.init(serverCredentials); - await WebsocketManager.init(serverCredentials); - PushNotifications.init(); - SessionManager.init(); - } - - initialLaunch(); + await initialize(); + await start(); }); - -const registerNavigationListeners = () => { - Navigation.events().registerComponentDidAppearListener(screenDidAppearListener); - Navigation.events().registerComponentDidDisappearListener(screenDidDisappearListener); - Navigation.events().registerComponentWillAppearListener(screenWillAppear); - Navigation.events().registerScreenPoppedListener(screenPoppedListener); - Navigation.events().registerModalDismissedListener(modalDismissedListener); -}; - -function screenWillAppear({componentId}: ComponentDidAppearEvent) { - if (componentId === Screens.HOME) { - DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, true); - } else if ([Screens.EDIT_POST, Screens.THREAD].includes(componentId)) { - DeviceEventEmitter.emit(Events.PAUSE_KEYBOARD_TRACKING_VIEW, true); - } -} - -function screenDidAppearListener({componentId, componentType}: ComponentDidAppearEvent) { - if (!OVERLAY_SCREENS.has(componentId) && componentType === 'Component') { - NavigationStore.addNavigationComponentId(componentId); - } -} - -function screenDidDisappearListener({componentId}: ComponentDidDisappearEvent) { - if (componentId !== Screens.HOME) { - if ([Screens.EDIT_POST, Screens.THREAD].includes(componentId)) { - DeviceEventEmitter.emit(Events.PAUSE_KEYBOARD_TRACKING_VIEW, false); - } - - if (NavigationStore.getNavigationTopComponentId() === Screens.HOME) { - DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, true); - } - } -} - -function screenPoppedListener({componentId}: ScreenPoppedEvent) { - NavigationStore.removeNavigationComponentId(componentId); - if (NavigationStore.getNavigationTopComponentId() === Screens.HOME) { - DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, true); - } -} - -function modalDismissedListener({componentId}: ModalDismissedEvent) { - const topScreen = NavigationStore.getNavigationTopComponentId(); - const topModal = NavigationStore.getNavigationTopModalId(); - const toRemove = topScreen === topModal ? topModal : componentId; - NavigationStore.removeNavigationModal(toRemove); - if (NavigationStore.getNavigationTopComponentId() === Screens.HOME) { - DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, true); - } -} diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift index 6e5c16f277..751bb35df6 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift @@ -29,7 +29,7 @@ extension Database { let db = try getDatabaseForServer(serverUrl) let stmtString = """ SELECT COUNT(DISTINCT my.id) FROM Channel c \ - INNER JOIN MyChannel my ON c.id=my.id \ + INNER JOIN MyChannel my ON c.id=my.id AND c.delete_at = 0 \ INNER JOIN Team t ON c.team_id=t.id """ let stmt = try db.prepare(stmtString) diff --git a/ios/MattermostShare/Views/ContentViews/AttachmentsViews/AttachmentsView.swift b/ios/MattermostShare/Views/ContentViews/AttachmentsViews/AttachmentsView.swift index 1282e7bbc0..747a998807 100644 --- a/ios/MattermostShare/Views/ContentViews/AttachmentsViews/AttachmentsView.swift +++ b/ios/MattermostShare/Views/ContentViews/AttachmentsViews/AttachmentsView.swift @@ -22,7 +22,7 @@ struct AttachmentsView: View { if sizeError && attachments.count == 1 { return "File must be less than \(server.maxFileSize.formattedFileSize)" } else if sizeError { - return "Each file must be less then \(server.maxFileSize.formattedFileSize)" + return "Each file must be less than \(server.maxFileSize.formattedFileSize)" } else if resolutionError { return "Image exceeds maximum dimensions of 7680 x 4320 px" } diff --git a/ios/Podfile b/ios/Podfile index 3faa7970dc..a740aa8fab 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -31,6 +31,7 @@ target 'Mattermost' do permissions_path = '../node_modules/react-native-permissions/ios' pod 'Permission-Camera', :path => "#{permissions_path}/Camera" pod 'Permission-Microphone', :path => "#{permissions_path}/Microphone" + pod 'Permission-Notifications', :path => "#{permissions_path}/Notifications" pod 'Permission-PhotoLibrary', :path => "#{permissions_path}/PhotoLibrary" pod 'React-jsi', :path => '../node_modules/react-native/ReactCommon/jsi', :modular_headers => true pod 'simdjson', path: '../node_modules/@nozbe/simdjson' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 592370082c..e36d213c7d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -101,6 +101,8 @@ PODS: - RNPermissions - Permission-Microphone (3.6.1): - RNPermissions + - Permission-Notifications (3.6.1): + - RNPermissions - Permission-PhotoLibrary (3.6.1): - RNPermissions - RCT-Folly (2021.07.22.00): @@ -584,6 +586,7 @@ DEPENDENCIES: - OpenSSL-Universal (= 1.1.1100) - Permission-Camera (from `../node_modules/react-native-permissions/ios/Camera`) - Permission-Microphone (from `../node_modules/react-native-permissions/ios/Microphone`) + - Permission-Notifications (from `../node_modules/react-native-permissions/ios/Notifications`) - Permission-PhotoLibrary (from `../node_modules/react-native-permissions/ios/PhotoLibrary`) - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCTRequired (from `../node_modules/react-native/Libraries/RCTRequired`) @@ -711,6 +714,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-permissions/ios/Camera" Permission-Microphone: :path: "../node_modules/react-native-permissions/ios/Microphone" + Permission-Notifications: + :path: "../node_modules/react-native-permissions/ios/Notifications" Permission-PhotoLibrary: :path: "../node_modules/react-native-permissions/ios/PhotoLibrary" RCT-Folly: @@ -893,6 +898,7 @@ SPEC CHECKSUMS: OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c Permission-Camera: bf6791b17c7f614b6826019fcfdcc286d3a107f6 Permission-Microphone: 48212dd4d28025d9930d583e3c7a56da7268665c + Permission-Notifications: 150484ae586eb9be4e32217582a78350a9bb31c3 Permission-PhotoLibrary: 5b34ca67279f7201ae109cef36f9806a6596002d RCT-Folly: 0080d0a6ebf2577475bda044aa59e2ca1f909cda RCTRequired: 21229f84411088e5d8538f21212de49e46cc83e2 @@ -975,6 +981,6 @@ SPEC CHECKSUMS: Yoga: eca980a5771bf114c41a754098cd85e6e0d90ed7 YogaKit: f782866e155069a2cca2517aafea43200b01fd5a -PODFILE CHECKSUM: 62c761c9aec9bb4ef459a75ab630937696026291 +PODFILE CHECKSUM: 96049f4170c9cafd0ff121db4444b3f46f51bc97 COCOAPODS: 1.11.3 diff --git a/package-lock.json b/package-lock.json index 052011467f..a729f5ea8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@react-native-cookies/cookies": "6.2.1", "@react-navigation/bottom-tabs": "6.4.0", "@react-navigation/native": "6.0.13", + "@react-navigation/stack": "6.3.7", "@rudderstack/rudder-sdk-react-native": "1.5.1", "@sentry/react-native": "4.8.0", "@stream-io/flat-list-mvcp": "0.10.2", @@ -42,6 +43,7 @@ "deepmerge": "4.2.2", "emoji-regex": "10.2.1", "fuse.js": "6.6.2", + "html-entities": "2.3.3", "jail-monkey": "2.7.0", "mime-db": "1.52.0", "moment-timezone": "0.5.38", @@ -141,7 +143,7 @@ "@types/uuid": "8.3.4", "@typescript-eslint/eslint-plugin": "5.42.1", "@typescript-eslint/parser": "5.42.1", - "axios": "1.1.3", + "axios": "1.2.0", "axios-cookiejar-support": "4.0.3", "babel-jest": "29.3.0", "babel-loader": "9.1.0", @@ -4886,9 +4888,9 @@ } }, "node_modules/@react-navigation/elements": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.6.tgz", - "integrity": "sha512-pNJ8R9JMga6SXOw6wGVN0tjmE6vegwPmJBL45SEMX2fqTfAk2ykDnlJHodRpHpAgsv0DaI8qX76z3A+aqKSU0w==", + "version": "1.3.9", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.9.tgz", + "integrity": "sha512-V9aIZN19ufaKWlXT4UcM545tDiEt9DIQS+74pDgbnzoQcDypn0CvSqWopFhPACMdJatgmlZUuOrrMfTeNrBWgA==", "peerDependencies": { "@react-navigation/native": "^6.0.0", "react": "*", @@ -4919,6 +4921,52 @@ "nanoid": "^3.1.23" } }, + "node_modules/@react-navigation/stack": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-6.3.7.tgz", + "integrity": "sha512-M0gGeIpXmY08ZxZlHO9o/NLj9lO4zGdTll+a9e40BwfSxR5v6R34msKHUJ57nxrzvr2/MSSllZRkW3wc8woKFg==", + "dependencies": { + "@react-navigation/elements": "^1.3.9", + "color": "^4.2.3", + "warn-once": "^0.1.0" + }, + "peerDependencies": { + "@react-navigation/native": "^6.0.0", + "react": "*", + "react-native": "*", + "react-native-gesture-handler": ">= 1.0.0", + "react-native-safe-area-context": ">= 3.0.0", + "react-native-screens": ">= 3.0.0" + } + }, + "node_modules/@react-navigation/stack/node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/@react-navigation/stack/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@react-navigation/stack/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, "node_modules/@rudderstack/rudder-sdk-react-native": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@rudderstack/rudder-sdk-react-native/-/rudder-sdk-react-native-1.5.1.tgz", @@ -7039,9 +7087,9 @@ } }, "node_modules/axios": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.1.3.tgz", - "integrity": "sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.2.0.tgz", + "integrity": "sha512-zT7wZyNYu3N5Bu0wuZ6QccIf93Qk1eV8LOewxgjOZFd2DenOs98cJ7+Y6703d0wkaXGY6/nZd4EweJaHz9uzQw==", "dev": true, "dependencies": { "follow-redirects": "^1.15.0", @@ -11355,6 +11403,11 @@ "react-is": "^16.7.0" } }, + "node_modules/html-entities": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -25304,9 +25357,9 @@ } }, "@react-navigation/elements": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.6.tgz", - "integrity": "sha512-pNJ8R9JMga6SXOw6wGVN0tjmE6vegwPmJBL45SEMX2fqTfAk2ykDnlJHodRpHpAgsv0DaI8qX76z3A+aqKSU0w==", + "version": "1.3.9", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.9.tgz", + "integrity": "sha512-V9aIZN19ufaKWlXT4UcM545tDiEt9DIQS+74pDgbnzoQcDypn0CvSqWopFhPACMdJatgmlZUuOrrMfTeNrBWgA==", "requires": {} }, "@react-navigation/native": { @@ -25328,6 +25381,40 @@ "nanoid": "^3.1.23" } }, + "@react-navigation/stack": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-6.3.7.tgz", + "integrity": "sha512-M0gGeIpXmY08ZxZlHO9o/NLj9lO4zGdTll+a9e40BwfSxR5v6R34msKHUJ57nxrzvr2/MSSllZRkW3wc8woKFg==", + "requires": { + "@react-navigation/elements": "^1.3.9", + "color": "^4.2.3", + "warn-once": "^0.1.0" + }, + "dependencies": { + "color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "requires": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + } + } + }, "@rudderstack/rudder-sdk-react-native": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@rudderstack/rudder-sdk-react-native/-/rudder-sdk-react-native-1.5.1.tgz", @@ -26938,9 +27025,9 @@ "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==" }, "axios": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.1.3.tgz", - "integrity": "sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.2.0.tgz", + "integrity": "sha512-zT7wZyNYu3N5Bu0wuZ6QccIf93Qk1eV8LOewxgjOZFd2DenOs98cJ7+Y6703d0wkaXGY6/nZd4EweJaHz9uzQw==", "dev": true, "requires": { "follow-redirects": "^1.15.0", @@ -30193,6 +30280,11 @@ "react-is": "^16.7.0" } }, + "html-entities": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==" + }, "html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", diff --git a/package.json b/package.json index c441273c32..c25ab40953 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@react-native-cookies/cookies": "6.2.1", "@react-navigation/bottom-tabs": "6.4.0", "@react-navigation/native": "6.0.13", + "@react-navigation/stack": "6.3.7", "@rudderstack/rudder-sdk-react-native": "1.5.1", "@sentry/react-native": "4.8.0", "@stream-io/flat-list-mvcp": "0.10.2", @@ -39,6 +40,7 @@ "deepmerge": "4.2.2", "emoji-regex": "10.2.1", "fuse.js": "6.6.2", + "html-entities": "2.3.3", "jail-monkey": "2.7.0", "mime-db": "1.52.0", "moment-timezone": "0.5.38", @@ -138,7 +140,7 @@ "@types/uuid": "8.3.4", "@typescript-eslint/eslint-plugin": "5.42.1", "@typescript-eslint/parser": "5.42.1", - "axios": "1.1.3", + "axios": "1.2.0", "axios-cookiejar-support": "4.0.3", "babel-jest": "29.3.0", "babel-loader": "9.1.0", diff --git a/share_extension/components/channel_item/avatar/avatar.tsx b/share_extension/components/channel_item/avatar/avatar.tsx new file mode 100644 index 0000000000..5daeb5434f --- /dev/null +++ b/share_extension/components/channel_item/avatar/avatar.tsx @@ -0,0 +1,93 @@ +// 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 FastImage from 'react-native-fast-image'; + +import {ACCOUNT_OUTLINE_IMAGE} from '@app/constants/profile'; +import CompassIcon from '@components/compass_icon'; +import NetworkManager from '@managers/network_manager'; +import {useShareExtensionServerUrl} from '@share/state'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import type UserModel from '@typings/database/models/servers/user'; + +type Props = { + author?: UserModel; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: {marginLeft: 4}, + icon: { + color: changeOpacity(theme.centerChannelColor, 0.72), + left: 1, + }, + image: { + borderRadius: 12, + height: 24, + width: 24, + }, +})); + +const Avatar = ({author, theme}: Props) => { + const serverUrl = useShareExtensionServerUrl(); + const style = getStyleSheet(theme); + const isBot = author?.isBot || false; + let pictureUrl = ''; + + if (author?.deleteAt) { + return ( + + ); + } + + if (author && serverUrl) { + try { + const client = NetworkManager.getClient(serverUrl); + let lastPictureUpdate = 0; + if (isBot) { + lastPictureUpdate = author?.props?.bot_last_icon_update || 0; + } else { + lastPictureUpdate = author?.lastPictureUpdate || 0; + } + + pictureUrl = client.getProfilePictureUrl(author.id, lastPictureUpdate); + } catch { + // handle below that the client is not set + } + } + + let icon; + if (pictureUrl) { + const imgSource = {uri: `${serverUrl}${pictureUrl}`}; + icon = ( + + ); + } else { + icon = ( + + ); + } + + return ( + + {icon} + + ); +}; + +export default Avatar; diff --git a/share_extension/components/channel_item/avatar/index.ts b/share_extension/components/channel_item/avatar/index.ts new file mode 100644 index 0000000000..09a9b982ee --- /dev/null +++ b/share_extension/components/channel_item/avatar/index.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {observeCurrentUserId} from '@queries/servers/system'; +import {observeUser} from '@queries/servers/user'; +import {getUserIdFromChannelName} from '@utils/user'; + +import Avatar from './avatar'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhance = withObservables(['channelName', 'database'], ({channelName, database}: {channelName: string} & WithDatabaseArgs) => { + const currentUserId = observeCurrentUserId(database); + + const authorId = currentUserId.pipe( + switchMap((userId) => of$(getUserIdFromChannelName(userId, channelName))), + ); + + const author = authorId.pipe( + switchMap((id) => { + return observeUser(database, id); + }), + ); + + return { + author, + }; +}); + +export default enhance(Avatar); diff --git a/share_extension/components/channel_item/channel_item.tsx b/share_extension/components/channel_item/channel_item.tsx new file mode 100644 index 0000000000..6c163914cb --- /dev/null +++ b/share_extension/components/channel_item/channel_item.tsx @@ -0,0 +1,143 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'; + +import {General} from '@constants'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; +import {getUserIdFromChannelName} from '@utils/user'; + +import Icon from './icon'; + +import type ChannelModel from '@typings/database/models/servers/channel'; + +type Props = { + channel: ChannelModel; + currentUserId: string; + membersCount: number; + onPress: (channelId: string) => void; + hasMember: boolean; + teamDisplayName?: string; + theme: Theme; +} + +export const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + flexDirection: 'row', + paddingHorizontal: 20, + minHeight: 40, + alignItems: 'center', + }, + infoItem: { + paddingHorizontal: 0, + }, + wrapper: { + flex: 1, + flexDirection: 'row', + }, + text: { + marginTop: -1, + color: changeOpacity(theme.sidebarText, 0.72), + paddingLeft: 12, + paddingRight: 20, + }, + textInfo: { + color: theme.centerChannelColor, + paddingRight: 20, + }, + teamName: { + color: changeOpacity(theme.centerChannelColor, 0.64), + paddingLeft: 12, + marginTop: 4, + ...typography('Body', 75), + }, +})); + +export const textStyle = StyleSheet.create({ + bold: typography('Body', 200, 'SemiBold'), + regular: typography('Body', 200, 'Regular'), +}); + +const ChannelListItem = ({ + channel, currentUserId, membersCount, hasMember, + onPress, teamDisplayName, theme, +}: Props) => { + const {formatMessage} = useIntl(); + const styles = getStyleSheet(theme); + + const height = useMemo(() => { + return teamDisplayName ? 58 : 44; + }, [teamDisplayName]); + + const handleOnPress = useCallback(() => { + onPress(channel.id); + }, [channel.id]); + + const textStyles = useMemo(() => [ + textStyle.regular, + styles.text, + styles.textInfo, + ], [styles]); + + const containerStyle = useMemo(() => [ + styles.container, + styles.infoItem, + {minHeight: height}, + ], + [height, styles]); + + if (!hasMember) { + return null; + } + + const teammateId = (channel.type === General.DM_CHANNEL) ? getUserIdFromChannelName(currentUserId, channel.name) : undefined; + const isOwnDirectMessage = (channel.type === General.DM_CHANNEL) && currentUserId === teammateId; + + let displayName = channel.displayName; + if (isOwnDirectMessage) { + displayName = formatMessage({id: 'channel_header.directchannel.you', defaultMessage: '{displayName} (you)'}, {displayName}); + } + + return ( + + <> + + + + + + {displayName} + + {Boolean(teamDisplayName) && + + {teamDisplayName} + + } + + + + + + ); +}; + +export default ChannelListItem; diff --git a/share_extension/components/channel_item/icon.tsx b/share_extension/components/channel_item/icon.tsx new file mode 100644 index 0000000000..71c418e893 --- /dev/null +++ b/share_extension/components/channel_item/icon.tsx @@ -0,0 +1,117 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleProp, Text, View, ViewStyle} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import General from '@constants/general'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import Avatar from './avatar'; + +import type {Database} from '@nozbe/watermelondb'; + +type ChannelIconProps = { + database: Database; + membersCount?: number; + name: string; + shared: boolean; + size?: number; + style?: StyleProp; + theme: Theme; + type: string; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + alignItems: 'center', + justifyContent: 'center', + }, + icon: { + color: changeOpacity(theme.sidebarText, 0.4), + }, + iconInfo: { + color: changeOpacity(theme.centerChannelColor, 0.72), + }, + groupBox: { + alignItems: 'center', + backgroundColor: changeOpacity(theme.sidebarText, 0.16), + borderRadius: 4, + justifyContent: 'center', + }, + groupBoxInfo: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.3), + }, + group: { + color: theme.sidebarText, + ...typography('Body', 75, 'SemiBold'), + }, + groupInfo: { + color: changeOpacity(theme.centerChannelColor, 0.72), + }, + }; +}); + +const ChannelIcon = ({ + database, membersCount = 0, name, + shared, size = 12, style, theme, type, +}: ChannelIconProps) => { + const styles = getStyleSheet(theme); + + let icon; + if (shared) { + const iconName = type === General.PRIVATE_CHANNEL ? 'circle-multiple-outline-lock' : 'circle-multiple-outline'; + icon = ( + + ); + } else if (type === General.OPEN_CHANNEL) { + icon = ( + + ); + } else if (type === General.PRIVATE_CHANNEL) { + icon = ( + + ); + } else if (type === General.GM_CHANNEL) { + const fontSize = size - 12; + icon = ( + + + {membersCount - 1} + + + ); + } else if (type === General.DM_CHANNEL) { + icon = ( + + ); + } + + return ( + + {icon} + + ); +}; + +export default React.memo(ChannelIcon); diff --git a/share_extension/components/channel_item/index.ts b/share_extension/components/channel_item/index.ts new file mode 100644 index 0000000000..0f5fe45693 --- /dev/null +++ b/share_extension/components/channel_item/index.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import withObservables from '@nozbe/with-observables'; +import React from 'react'; +import {of as of$} from 'rxjs'; +import {switchMap, distinctUntilChanged} from 'rxjs/operators'; + +import {General} from '@constants'; +import {withServerUrl} from '@context/server'; +import {observeMyChannel} from '@queries/servers/channel'; +import {observeCurrentUserId} from '@queries/servers/system'; +import {observeTeam} from '@queries/servers/team'; + +import ChannelItem from './channel_item'; + +import type ChannelModel from '@typings/database/models/servers/channel'; + +type Props = { + channel: ChannelModel; + showTeamName?: boolean; + serverUrl?: string; +} + +const enhance = withObservables(['channel', 'showTeamName'], ({channel, showTeamName}: Props) => { + const database = channel.database; + const currentUserId = observeCurrentUserId(database); + const myChannel = observeMyChannel(database, channel.id); + + let teamDisplayName = of$(''); + if (channel.teamId && showTeamName) { + teamDisplayName = observeTeam(database, channel.teamId).pipe( + switchMap((team) => of$(team?.displayName || '')), + distinctUntilChanged(), + ); + } + + let membersCount = of$(0); + if (channel.type === General.GM_CHANNEL) { + membersCount = channel.members.observeCount(false); + } + + const hasMember = myChannel.pipe( + switchMap((mc) => of$(Boolean(mc))), + distinctUntilChanged(), + ); + + return { + channel: channel.observe(), + currentUserId, + membersCount, + teamDisplayName, + hasMember, + }; +}); + +export default React.memo(withServerUrl(enhance(ChannelItem))); diff --git a/share_extension/components/content_view/attachments/attachments.tsx b/share_extension/components/content_view/attachments/attachments.tsx new file mode 100644 index 0000000000..de0c96b48a --- /dev/null +++ b/share_extension/components/content_view/attachments/attachments.tsx @@ -0,0 +1,126 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {StyleSheet, View} from 'react-native'; + +import {MAX_RESOLUTION} from '@constants/image'; +import ErrorLabel from '@share/components/error/label'; +import {setShareExtensionGlobalError, useShareExtensionFiles} from '@share/state'; +import {getFormattedFileSize} from '@utils/file'; + +import Multiple from './multiple'; +import Single from './single'; + +type Props = { + canUploadFiles: boolean; + maxFileCount: number; + maxFileSize: number; + theme: Theme; +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + }, + margin: { + marginHorizontal: 20, + }, +}); + +const Attachments = ({canUploadFiles, maxFileCount, maxFileSize, theme}: Props) => { + const intl = useIntl(); + const files = useShareExtensionFiles(); + + const error = useMemo(() => { + if (!canUploadFiles) { + return intl.formatMessage({ + id: 'share_extension.upload_disabled', + defaultMessage: 'File uploads are disabled for the selected server', + }); + } + + if (files.length > maxFileCount) { + return intl.formatMessage({ + id: 'share_extension.count_limit', + defaultMessage: 'You can only share {count, number} {count, plural, one {file} other {files}} on this server', + }, {count: maxFileCount}); + } + + let maxResolutionError = false; + const totalSize = files.reduce((total, file) => { + if (file.width && file.height && !maxResolutionError) { + maxResolutionError = (file.width * file.height) > MAX_RESOLUTION; + } + return total + (file.size || 0); + }, 0); + + if (totalSize > maxFileSize) { + if (files.length > 1) { + return intl.formatMessage({ + id: 'share_extension.file_limit.multiple', + defaultMessage: 'Each file must be less than {size}', + }, {size: getFormattedFileSize(maxFileSize)}); + } + + return intl.formatMessage({ + id: 'share_extension.file_limit.single', + defaultMessage: 'File must be less than {size}', + }, {size: getFormattedFileSize(maxFileSize)}); + } + + if (maxResolutionError) { + return intl.formatMessage({ + id: 'share_extension.max_resolution', + defaultMessage: 'Image exceeds maximum dimensions of 7680 x 4320 px', + }); + } + + return undefined; + }, [canUploadFiles, maxFileCount, maxFileSize, files, intl.locale]); + + const attachmentsContainerStyle = useMemo(() => [ + styles.container, + files.length === 1 && styles.margin, + ], [files]); + + useEffect(() => { + setShareExtensionGlobalError(Boolean(error)); + }, [error]); + + let attachments; + if (files.length === 1) { + attachments = ( + + ); + } else { + attachments = ( + + ); + } + + return ( + <> + + {attachments} + + {Boolean(error) && + + } + + ); +}; + +export default Attachments; diff --git a/share_extension/components/content_view/attachments/index.ts b/share_extension/components/content_view/attachments/index.ts new file mode 100644 index 0000000000..66e09f19c5 --- /dev/null +++ b/share_extension/components/content_view/attachments/index.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import withObservables from '@nozbe/with-observables'; + +import {observeCanUploadFiles, observeConfigIntValue, observeMaxFileCount} from '@queries/servers/system'; + +import Attachments from './attachments'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables(['database'], ({database}: WithDatabaseArgs) => ({ + canUploadFiles: observeCanUploadFiles(database), + maxFileCount: observeMaxFileCount(database), + maxFileSize: observeConfigIntValue(database, 'MaxFileSize'), +})); + +export default enhanced(Attachments); diff --git a/share_extension/components/content_view/attachments/info.tsx b/share_extension/components/content_view/attachments/info.tsx new file mode 100644 index 0000000000..c0ba23d5da --- /dev/null +++ b/share_extension/components/content_view/attachments/info.tsx @@ -0,0 +1,101 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {Text, View} from 'react-native'; + +import FileIcon from '@components/files/file_icon'; +import {getFormattedFileSize} from '@utils/file'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + contentMode: 'small' | 'large'; + file: FileInfo; + hasError: boolean; + theme: Theme; +}; + +const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + alignItems: 'center', + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + borderWidth: 1, + flexDirection: 'row', + height: 64, + justifyContent: 'flex-start', + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowRadius: 3, + shadowOpacity: 0.8, + width: '100%', + }, + error: { + borderColor: theme.errorTextColor, + borderWidth: 2, + }, + info: { + textTransform: 'uppercase', + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 75), + }, + name: { + color: theme.centerChannelColor, + ...typography('Body', 200, 'SemiBold'), + }, + small: { + flexDirection: 'column', + height: 104, + justifyContent: 'center', + paddingHorizontal: 4, + width: 104, + }, + smallName: { + ...typography('Body', 75, 'SemiBold'), + }, + smallWrapper: { + alignItems: 'center', + }, +})); + +const Info = ({contentMode, file, hasError, theme}: Props) => { + const styles = getStyles(theme); + const containerStyle = useMemo(() => [ + styles.container, + contentMode === 'small' && styles.small, + hasError && styles.error, + ], [contentMode, hasError]); + + const textContainerStyle = useMemo(() => (contentMode === 'small' && styles.smallWrapper), [contentMode]); + + const nameStyle = useMemo(() => [ + styles.name, + contentMode === 'small' && styles.smallName, + ], [contentMode]); + + const size = useMemo(() => { + return `${file.extension} ${getFormattedFileSize(file.size)}`; + }, [file.size, file.extension]); + + return ( + + + + + {file.name} + + + {size} + + + + ); +}; + +export default Info; diff --git a/share_extension/components/content_view/attachments/multiple.tsx b/share_extension/components/content_view/attachments/multiple.tsx new file mode 100644 index 0000000000..4098de3877 --- /dev/null +++ b/share_extension/components/content_view/attachments/multiple.tsx @@ -0,0 +1,97 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo} from 'react'; +import {FlatList, ListRenderItemInfo, StyleProp, View, ViewStyle} from 'react-native'; + +import FormattedText from '@app/components/formatted_text'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import Single from './single'; + +type Props = { + files: SharedItem[]; + maxFileSize: number; + theme: Theme; +}; + +const getKey = (item: SharedItem) => item.value; + +const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({ + first: { + marginLeft: 20, + }, + item: { + marginRight: 12, + }, + last: { + marginRight: 20, + }, + list: { + height: 114, + top: -8, + width: '100%', + }, + container: { + alignItems: 'flex-end', + }, + labelContainer: { + alignItems: 'flex-start', + marginTop: 8, + paddingHorizontal: 20, + width: '100%', + }, + label: { + color: changeOpacity(theme.centerChannelColor, 0.72), + ...typography('Body', 75), + }, +})); + +const Multiple = ({files, maxFileSize, theme}: Props) => { + const styles = getStyles(theme); + const count = useMemo(() => ({count: files.length}), [files.length]); + + const renderItem = useCallback(({item, index}: ListRenderItemInfo) => { + const containerStyle: StyleProp = [styles.item]; + if (index === 0) { + containerStyle.push(styles.first); + } else if (index === files.length - 1) { + containerStyle.push(styles.last); + } + return ( + + + + ); + }, [maxFileSize, theme, files]); + + return ( + <> + + + + + + ); +}; + +export default Multiple; diff --git a/share_extension/components/content_view/attachments/single.tsx b/share_extension/components/content_view/attachments/single.tsx new file mode 100644 index 0000000000..3c908d9703 --- /dev/null +++ b/share_extension/components/content_view/attachments/single.tsx @@ -0,0 +1,130 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo} from 'react'; +import {LayoutAnimation, TouchableOpacity, View} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import {MAX_RESOLUTION} from '@constants/image'; +import {removeShareExtensionFile} from '@share/state'; +import {toFileInfo} from '@share/utils'; +import {isImage, isVideo} from '@utils/file'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import Info from './info'; +import Thumbnail from './thumbnail'; + +type Props = { + file: SharedItem; + maxFileSize: number; + isSmall?: boolean; + theme: Theme; +}; + +const hitSlop = {top: 10, left: 10, right: 10, bottom: 10}; + +const layoutAnimConfig = { + duration: 300, + update: { + type: LayoutAnimation.Types.easeInEaseOut, + }, + delete: { + duration: 100, + type: LayoutAnimation.Types.easeInEaseOut, + property: LayoutAnimation.Properties.opacity, + }, +}; + +const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({ + remove: { + backgroundColor: theme.centerChannelBg, + borderRadius: 12, + height: 24, + position: 'absolute', + right: -5, + top: -7, + width: 24, + }, +})); + +const Single = ({file, isSmall, maxFileSize, theme}: Props) => { + const styles = getStyles(theme); + const fileInfo = useMemo(() => toFileInfo(file), [file]); + const contentMode = isSmall ? 'small' : 'large'; + const type = useMemo(() => { + if (isImage(fileInfo)) { + return 'image'; + } + + if (isVideo(fileInfo)) { + return 'video'; + } + + return undefined; + }, [fileInfo]); + + const hasError = useMemo(() => { + const size = file.size || 0; + if (size > maxFileSize) { + return true; + } + + if (type === 'image' && file.height && file.width) { + return (file.width * file.height) > MAX_RESOLUTION; + } + + return false; + }, [file, maxFileSize, type]); + + const onPress = useCallback(() => { + LayoutAnimation.configureNext(layoutAnimConfig); + removeShareExtensionFile(file); + }, [file]); + + let attachment; + + if (type) { + attachment = ( + + ); + } else { + attachment = ( + + ); + } + + if (isSmall) { + return ( + + {attachment} + + + + + + + ); + } + + return attachment; +}; + +export default Single; diff --git a/share_extension/components/content_view/attachments/thumbnail.tsx b/share_extension/components/content_view/attachments/thumbnail.tsx new file mode 100644 index 0000000000..6f58dc2692 --- /dev/null +++ b/share_extension/components/content_view/attachments/thumbnail.tsx @@ -0,0 +1,117 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {StyleSheet, useWindowDimensions, View} from 'react-native'; +import FastImage from 'react-native-fast-image'; +import LinearGradient from 'react-native-linear-gradient'; + +import CompassIcon from '@components/compass_icon'; +import {imageDimensions} from '@share/utils'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + contentMode: 'small' | 'large'; + file: SharedItem; + hasError: boolean; + theme: Theme; + type?: 'image' | 'video'; +} + +const GRADIENT_COLORS = ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, .16)']; +const GRADIENT_END = {x: 1, y: 1}; +const GRADIENT_LOCATIONS = [0.5, 1]; +const GRADIENT_START = {x: 0.3, y: 0.3}; + +const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + borderRadius: 4, + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + borderWidth: 1, + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowRadius: 3, + shadowOpacity: 0.8, + elevation: 1, + }, + center: { + alignItems: 'center', + justifyContent: 'center', + }, + error: { + borderColor: theme.errorTextColor, + borderWidth: 2, + }, + play: { + alignItems: 'flex-end', + justifyContent: 'flex-end', + padding: 2, + ...StyleSheet.absoluteFillObject, + }, + radius: { + borderRadius: 4, + }, +})); + +const Thumbnail = ({contentMode, file, hasError, theme, type}: Props) => { + const dimensions = useWindowDimensions(); + const styles = getStyles(theme); + const isSmall = contentMode === 'small'; + const imgStyle = useMemo(() => { + if (isSmall) { + return { + height: 104, + width: 104, + }; + } + + if (!file.width || !file.height) { + return { + height: 0, + width: 0, + }; + } + + return imageDimensions(file.height!, file.width!, 156, dimensions.width - 20); + }, [isSmall, file, dimensions.width]); + + const containerStyle = useMemo(() => ([ + styles.container, + hasError && styles.error, + styles.radius, + ]), [styles, imgStyle, hasError]); + + const source = useMemo(() => ({uri: type === 'video' ? file.videoThumb : file.value}), [type, file]); + + return ( + + + + {type === 'video' && + <> + + + + + + } + + + ); +}; + +export default Thumbnail; diff --git a/share_extension/components/content_view/content_view.tsx b/share_extension/components/content_view/content_view.tsx new file mode 100644 index 0000000000..6ae346866a --- /dev/null +++ b/share_extension/components/content_view/content_view.tsx @@ -0,0 +1,89 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect} from 'react'; +import {View} from 'react-native'; +import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view'; + +import {setShareExtensionUserAndChannelIds, useShareExtensionState} from '@share/state'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import Attachments from './attachments'; +import LinkPreview from './link_preview'; +import Message from './message'; +import Options from './options'; + +import type {Database} from '@nozbe/watermelondb'; + +type Props = { + currentChannelId: string; + currentUserId: string; + database: Database; + theme: Theme; +} + +const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + flex: 1, + }, + content: { + paddingTop: 20, + }, + divider: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + height: 1, + marginBottom: 8, + marginHorizontal: 20, + marginTop: 20, + }, +})); + +const ContentView = ({database, currentChannelId, currentUserId, theme}: Props) => { + const {linkPreviewUrl, files, serverUrl, channelId} = useShareExtensionState(); + const styles = getStyles(theme); + + useEffect(() => { + setShareExtensionUserAndChannelIds(currentUserId, currentChannelId); + }, [currentUserId, currentChannelId]); + + return ( + + + {Boolean(linkPreviewUrl) && + + } + {files.length > 0 && + + } + {(files.length > 0 || Boolean(linkPreviewUrl)) && + + } + + + + + + ); +}; + +export default ContentView; diff --git a/share_extension/components/content_view/index.tsx b/share_extension/components/content_view/index.tsx new file mode 100644 index 0000000000..126d242cdf --- /dev/null +++ b/share_extension/components/content_view/index.tsx @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import withObservables from '@nozbe/with-observables'; + +import {observeCurrentChannelId, observeCurrentUserId} from '@queries/servers/system'; + +import ContentView from './content_view'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables(['database'], ({database}: WithDatabaseArgs) => ({ + currentUserId: observeCurrentUserId(database), + currentChannelId: observeCurrentChannelId(database), +})); + +export default enhanced(ContentView); + diff --git a/share_extension/components/content_view/link_preview/index.tsx b/share_extension/components/content_view/link_preview/index.tsx new file mode 100644 index 0000000000..9098a1ef1f --- /dev/null +++ b/share_extension/components/content_view/link_preview/index.tsx @@ -0,0 +1,137 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useState} from 'react'; +import {ActivityIndicator, StyleProp, Text, View, ViewStyle} from 'react-native'; +import FastImage from 'react-native-fast-image'; + +import CompassIcon from '@components/compass_icon'; +import fetchOpenGraph, {OpenGraph} from '@share/open_graph'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + url?: string; + theme: Theme; +} + +type OpenGraphImageProps = Props & { + style: StyleProp; +}; + +const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + borderWidth: 1, + borderRadius: 4, + flexDirection: 'row', + maxHeight: 96, + height: 96, + marginHorizontal: 20, + padding: 12, + }, + flex: {flex: 1}, + image: { + alignItems: 'center', + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + borderRadius: 4, + borderWidth: 1, + height: 72, + justifyContent: 'center', + marginLeft: 10, + width: 72, + }, + link: { + color: changeOpacity(theme.centerChannelColor, 0.64), + marginTop: 4, + ...typography('Body', 75, 'Regular'), + }, + title: { + color: theme.linkColor, + ...typography('Body', 200, 'SemiBold'), + }, +})); + +const OpenGraphImage = ({style, theme, url}: OpenGraphImageProps) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + + const onLoad = useCallback(() => { + setLoading(false); + }, []); + + const onError = useCallback(() => { + setError(true); + setLoading(false); + }, []); + + return ( + + {error && !loading && + + } + {loading && + + } + {!error && + + } + + ); +}; + +const LinkPreview = ({theme, url}: Props) => { + const styles = getStyles(theme); + const [data, setData] = useState(); + + useEffect(() => { + if (url) { + fetchOpenGraph(url).then(setData); + } + }, [url]); + + if (!data || data.error) { + return null; + } + + return ( + + + + {url} + + + {data!.link} + + + {Boolean(data.imageURL) && + + } + + ); +}; + +export default LinkPreview; diff --git a/share_extension/components/content_view/message/index.tsx b/share_extension/components/content_view/message/index.tsx new file mode 100644 index 0000000000..e03303e08e --- /dev/null +++ b/share_extension/components/content_view/message/index.tsx @@ -0,0 +1,74 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {debounce} from 'lodash'; +import React, {useCallback, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {View} from 'react-native'; + +import FloatingTextInput from '@components/floating_text_input_label'; +import {setShareExtensionMessage, useShareExtensionMessage} from '@share/state'; +import {getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + theme: Theme; +} + +const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + marginHorizontal: 20, + }, + input: { + color: theme.centerChannelColor, + minHeight: 154, + ...typography('Body', 200, 'Regular'), + }, + textInputContainer: { + marginTop: 20, + alignSelf: 'center', + minHeight: 154, + height: undefined, + }, +})); + +const Message = ({theme}: Props) => { + const intl = useIntl(); + const styles = getStyles(theme); + const message = useShareExtensionMessage(); + + const label = useMemo(() => { + return intl.formatMessage({ + id: 'share_extension.message', + defaultMessage: 'Enter a message (optional)', + }); + }, [intl.locale]); + + const onChangeText = useCallback(debounce((text: string) => { + setShareExtensionMessage(text); + }, 250), []); + + return ( + + + + ); +}; + +export default Message; diff --git a/share_extension/components/content_view/options/index.ts b/share_extension/components/content_view/options/index.ts new file mode 100644 index 0000000000..4da08bb259 --- /dev/null +++ b/share_extension/components/content_view/options/index.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import withObservables from '@nozbe/with-observables'; +import {of as of$, switchMap, combineLatestWith} from 'rxjs'; + +import {observeServerDisplayName} from '@queries/app/servers'; +import {observeChannel} from '@queries/servers/channel'; +import {observeTeam, queryJoinedTeams} from '@queries/servers/team'; +import {observeServerHasChannels} from '@share/queries'; + +import Options from './options'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type Props = WithDatabaseArgs & { + serverUrl?: string; + channelId?: string; +} + +const enhanced = withObservables(['database', 'channelId', 'serverUrl'], ({database, channelId, serverUrl}: Props) => { + const channel = observeChannel(database, channelId || ''); + const teamsCount = queryJoinedTeams(database).observeCount(); + const team = channel.pipe( + switchMap((c) => (c?.teamId ? observeTeam(database, c.teamId) : of$(undefined))), + ); + + const channelDisplayName = channel.pipe( + combineLatestWith(team, teamsCount), + switchMap(([c, t, count]) => { + if (!c) { + return of$(undefined); + } + + if (t) { + return of$(count > 1 ? `${c.displayName} (${t.displayName})` : c.displayName); + } + + return of$(c.displayName); + }), + ); + + return { + channelDisplayName, + serverDisplayName: observeServerDisplayName(serverUrl || ''), + hasChannels: observeServerHasChannels(serverUrl || ''), + }; +}); + +export default enhanced(Options); diff --git a/share_extension/components/content_view/options/option.tsx b/share_extension/components/content_view/options/option.tsx new file mode 100644 index 0000000000..1cdb9dedc4 --- /dev/null +++ b/share_extension/components/content_view/options/option.tsx @@ -0,0 +1,70 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Text, TouchableOpacity, View} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + label: string; + onPress: () => void; + theme: Theme; + value: string; +} + +const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + flexDirection: 'row', + height: 48, + alignItems: 'center', + }, + label: { + color: theme.centerChannelColor, + ...typography('Body', 200), + }, + row: { + flexDirection: 'row', + flex: 1, + justifyContent: 'flex-end', + + }, + value: { + alignItems: 'flex-end', + color: changeOpacity(theme.centerChannelColor, 0.56), + top: 2, + flexShrink: 1, + marginLeft: 10, + ...typography('Body', 100), + }, +})); + +const Option = ({label, onPress, theme, value}: Props) => { + const styles = getStyles(theme); + + return ( + + {label} + + + {value} + + + + + ); +}; + +export default Option; diff --git a/share_extension/components/content_view/options/options.tsx b/share_extension/components/content_view/options/options.tsx new file mode 100644 index 0000000000..61fda59aee --- /dev/null +++ b/share_extension/components/content_view/options/options.tsx @@ -0,0 +1,92 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useNavigation} from '@react-navigation/native'; +import React, {useCallback, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {StyleSheet, View} from 'react-native'; + +import ErrorLabel from '@share/components/error/label'; + +import Option from './option'; + +type Props = { + channelDisplayName?: string; + hasChannels: boolean; + serverDisplayName: string; + theme: Theme; +} + +const styles = StyleSheet.create({ + container: { + marginHorizontal: 20, + }, +}); + +const Options = ({channelDisplayName, hasChannels, serverDisplayName, theme}: Props) => { + const navigator = useNavigation(); + const intl = useIntl(); + + const serverLabel = useMemo(() => { + return intl.formatMessage({ + id: 'share_extension.server_label', + defaultMessage: 'Server', + }); + }, [intl.locale]); + + const channelLabel = useMemo(() => { + return intl.formatMessage({ + id: 'share_extension.channel_label', + defaultMessage: 'Channel', + }); + }, [intl.locale]); + + const errorLabel = useMemo(() => { + return intl.formatMessage({ + id: 'share_extension.channel_error', + defaultMessage: 'You are not a member of a team on the selected server. Select another server or open Mattermost to join a team.', + }); + }, [intl.locale]); + + const onServerPress = useCallback(() => { + navigator.navigate('Servers'); + }, []); + + const onChannelPress = useCallback(() => { + navigator.navigate('Channels'); + }, []); + + let channel; + if (hasChannels) { + channel = ( +