Gekidou Android share extension (#6803)

* Refactor app database queries to not require the app database as argument

* Android Share Extension and fix notifications prompt

* feedback review
This commit is contained in:
Elias Nahum
2022-11-30 23:18:56 +02:00
committed by GitHub
parent c1f480de31
commit 6eadc527bb
86 changed files with 4116 additions and 383 deletions

View File

@@ -83,5 +83,23 @@
android:resizeableActivity="true" android:resizeableActivity="true"
android:exported="true" android:exported="true"
/> />
<activity
android:name="com.mattermost.share.ShareActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:windowSoftInputMode="adjustResize"
android:label="@string/app_name"
android:screenOrientation="portrait"
android:theme="@style/AppTheme"
android:taskAffinity="com.mattermost.share"
android:exported="true"
>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<!-- for sharing-->
<data android:mimeType="*/*" />
</intent-filter>
</activity>
</application> </application>
</manifest> </manifest>

View File

@@ -6,6 +6,7 @@ import android.net.Uri;
import android.provider.DocumentsContract; import android.provider.DocumentsContract;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.provider.OpenableColumns; import android.provider.OpenableColumns;
import android.content.ContentResolver;
import android.os.Environment; import android.os.Environment;
import android.webkit.MimeTypeMap; import android.webkit.MimeTypeMap;
import android.util.Log; import android.util.Log;
@@ -182,8 +183,14 @@ public class RealPathUtil {
} }
public static String getExtension(String uri) { public static String getExtension(String uri) {
String extension = "";
if (uri == null) { if (uri == null) {
return null; return extension;
}
extension = MimeTypeMap.getFileExtensionFromUrl(uri);
if (!extension.equals("")) {
return extension;
} }
int dot = uri.lastIndexOf("."); int dot = uri.lastIndexOf(".");
@@ -210,6 +217,15 @@ public class RealPathUtil {
return getMimeType(file); 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) { public static void deleteTempFiles(final File dir) {
try { try {
if (dir.isDirectory()) { if (dir.isDirectory()) {
@@ -241,4 +257,21 @@ public class RealPathUtil {
return f.getName(); 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;
}
} }

View File

@@ -18,6 +18,7 @@ import com.mattermost.helpers.Network;
import com.mattermost.helpers.NotificationHelper; import com.mattermost.helpers.NotificationHelper;
import com.mattermost.helpers.PushNotificationDataHelper; import com.mattermost.helpers.PushNotificationDataHelper;
import com.mattermost.helpers.ResolvePromise; import com.mattermost.helpers.ResolvePromise;
import com.mattermost.share.ShareModule;
import com.wix.reactnativenotifications.core.NotificationIntentAdapter; import com.wix.reactnativenotifications.core.NotificationIntentAdapter;
import com.wix.reactnativenotifications.core.notification.PushNotification; import com.wix.reactnativenotifications.core.notification.PushNotification;
import com.wix.reactnativenotifications.core.AppLaunchHelper; import com.wix.reactnativenotifications.core.AppLaunchHelper;
@@ -80,7 +81,9 @@ public class CustomPushNotification extends PushNotification {
switch (type) { switch (type) {
case CustomPushNotificationHelper.PUSH_TYPE_MESSAGE: case CustomPushNotificationHelper.PUSH_TYPE_MESSAGE:
case CustomPushNotificationHelper.PUSH_TYPE_SESSION: 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); boolean createSummary = type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE);
if (type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE)) { if (type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE)) {
if (channelId != null) { if (channelId != null) {

View File

@@ -12,13 +12,12 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import com.mattermost.helpers.RealPathUtil; import com.mattermost.helpers.RealPathUtil;
import com.mattermost.share.ShareModule;
import com.wix.reactnativenotifications.RNNotificationsPackage; import com.wix.reactnativenotifications.RNNotificationsPackage;
import com.reactnativenavigation.NavigationApplication; import com.reactnativenavigation.NavigationApplication;
import com.wix.reactnativenotifications.core.notification.INotificationsApplication; import com.wix.reactnativenotifications.core.notification.INotificationsApplication;
import com.wix.reactnativenotifications.core.notification.IPushNotification; 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.AppLaunchHelper;
import com.wix.reactnativenotifications.core.AppLifecycleFacade; import com.wix.reactnativenotifications.core.AppLifecycleFacade;
import com.wix.reactnativenotifications.core.JsIOHelper; import com.wix.reactnativenotifications.core.JsIOHelper;
@@ -68,10 +67,12 @@ public class MainApplication extends NavigationApplication implements INotificat
switch (name) { switch (name) {
case "MattermostManaged": case "MattermostManaged":
return MattermostManagedModule.getInstance(reactContext); return MattermostManagedModule.getInstance(reactContext);
case "Notifications": case "MattermostShare":
return NotificationsModule.getInstance(instance, reactContext); return ShareModule.getInstance(reactContext);
default: case "Notifications":
throw new IllegalArgumentException("Could not find module " + name); 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 () -> { return () -> {
Map<String, ReactModuleInfo> map = new HashMap<>(); Map<String, ReactModuleInfo> map = new HashMap<>();
map.put("MattermostManaged", new ReactModuleInfo("MattermostManaged", "com.mattermost.rnbeta.MattermostManagedModule", false, false, false, false, false)); map.put("MattermostManaged", new ReactModuleInfo("MattermostManaged", "com.mattermost.rnbeta.MattermostManagedModule", false, false, false, false, false));
map.put("MattermostShare", new ReactModuleInfo("MattermostShare", "com.mattermost.share.ShareModule", false, false, true, false, false));
map.put("Notifications", new ReactModuleInfo("Notifications", "com.mattermost.rnbeta.NotificationsModule", false, false, false, false, false)); map.put("Notifications", new ReactModuleInfo("Notifications", "com.mattermost.rnbeta.NotificationsModule", false, false, false, false, false));
return map; return map;
}; };

View File

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

View File

@@ -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<String, Object> getConstants() {
HashMap<String, Object> 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<Uri> 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();
}
}
}

View File

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

View File

@@ -22,7 +22,7 @@ import {selectDefaultTeam} from '@helpers/api/team';
import {DEFAULT_LOCALE} from '@i18n'; import {DEFAULT_LOCALE} from '@i18n';
import NetworkManager from '@managers/network_manager'; import NetworkManager from '@managers/network_manager';
import {getDeviceToken} from '@queries/app/global'; 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 {prepareMyChannelsForTeam, queryAllChannelsForTeam, queryChannelsById} from '@queries/servers/channel';
import {prepareModels, truncateCrtRelatedTables} from '@queries/servers/entry'; import {prepareModels, truncateCrtRelatedTables} from '@queries/servers/entry';
import {getHasCRTChanged} from '@queries/servers/preference'; import {getHasCRTChanged} from '@queries/servers/preference';
@@ -362,27 +362,21 @@ export const registerDeviceToken = async (serverUrl: string) => {
return {error}; return {error};
} }
const appDatabase = DatabaseManager.appDatabase?.database; const deviceToken = await getDeviceToken();
if (appDatabase) { if (deviceToken) {
const deviceToken = await getDeviceToken(appDatabase); client.attachDevice(deviceToken);
if (deviceToken) {
client.attachDevice(deviceToken);
}
} }
return {error: undefined}; return {error: undefined};
}; };
export const syncOtherServers = async (serverUrl: string) => { export const syncOtherServers = async (serverUrl: string) => {
const database = DatabaseManager.appDatabase?.database; const servers = await getAllServers();
if (database) { for (const server of servers) {
const servers = await queryAllServers(database); if (server.url !== serverUrl && server.lastActiveAt > 0) {
for (const server of servers) { registerDeviceToken(server.url);
if (server.url !== serverUrl && server.lastActiveAt > 0) { syncAllChannelMembersAndThreads(server.url);
registerDeviceToken(server.url); autoUpdateTimezone(server.url);
syncAllChannelMembersAndThreads(server.url);
autoUpdateTimezone(server.url);
}
} }
} }
}; };
@@ -479,12 +473,7 @@ export async function verifyPushProxy(serverUrl: string) {
return; return;
} }
const appDatabase = DatabaseManager.appDatabase?.database; const deviceId = await getDeviceToken();
if (!appDatabase) {
return;
}
const deviceId = await getDeviceToken(appDatabase);
if (!deviceId) { if (!deviceId) {
return; return;
} }

View File

@@ -27,12 +27,7 @@ async function getDeviceIdForPing(serverUrl: string, checkDeviceId: boolean) {
} }
} }
const appDatabase = DatabaseManager.appDatabase?.database; return getDeviceToken();
if (!appDatabase) {
return '';
}
return getDeviceToken(appDatabase);
} }
// Default timeout interval for ping is 5 seconds // Default timeout interval for ping is 5 seconds

View File

@@ -12,7 +12,7 @@ import PushNotifications from '@init/push_notifications';
import NetworkManager from '@managers/network_manager'; import NetworkManager from '@managers/network_manager';
import WebsocketManager from '@managers/websocket_manager'; import WebsocketManager from '@managers/websocket_manager';
import {getDeviceToken} from '@queries/app/global'; 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 {getCurrentUserId, getExpiredSession, getConfig, getLicense} from '@queries/servers/system';
import {getCurrentUser} from '@queries/servers/user'; import {getCurrentUser} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store'; import EphemeralStore from '@store/ephemeral_store';
@@ -124,7 +124,7 @@ export const login = async (serverUrl: string, {ldapOnly = false, loginId, mfaTo
} }
try { try {
deviceToken = await getDeviceToken(appDatabase); deviceToken = await getDeviceToken();
user = await client.login( user = await client.login(
loginId, loginId,
password, password,
@@ -204,11 +204,10 @@ export const cancelSessionNotification = async (serverUrl: string) => {
export const scheduleSessionNotification = async (serverUrl: string) => { export const scheduleSessionNotification = async (serverUrl: string) => {
try { try {
const {database: appDatabase} = DatabaseManager.getAppDatabaseAndOperator();
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const sessions = await fetchSessions(serverUrl, 'me'); const sessions = await fetchSessions(serverUrl, 'me');
const user = await getCurrentUser(database); const user = await getCurrentUser(database);
const serverName = await queryServerName(appDatabase, serverUrl); const serverName = await getServerDisplayName(serverUrl);
await cancelSessionNotification(serverUrl); await cancelSessionNotification(serverUrl);
@@ -286,7 +285,7 @@ export const ssoLogin = async (serverUrl: string, serverDisplayName: string, ser
displayName: serverDisplayName, displayName: serverDisplayName,
}, },
}); });
deviceToken = await getDeviceToken(database); deviceToken = await getDeviceToken();
user = await client.getMe(); user = await client.getMe();
await server?.operator.handleUsers({users: [user], prepareRecordsOnly: false}); await server?.operator.handleUsers({users: [user], prepareRecordsOnly: false});
await server?.operator.handleSystem({ await server?.operator.handleSystem({
@@ -312,9 +311,8 @@ export const ssoLogin = async (serverUrl: string, serverDisplayName: string, ser
async function findSession(serverUrl: string, sessions: Session[]) { async function findSession(serverUrl: string, sessions: Session[]) {
try { try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const {database: appDatabase} = DatabaseManager.getAppDatabaseAndOperator();
const expiredSession = await getExpiredSession(database); 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 // First try and find the session by the given identifier hyqddef7jjdktqiyy36gxa8sqy
let session = sessions.find((s) => s.id === expiredSession?.id); let session = sessions.find((s) => s.id === expiredSession?.id);

View File

@@ -18,7 +18,7 @@ import {fetchUsersByIds, updateUsersNoLongerVisible} from '@actions/remote/user'
import {loadCallForChannel} from '@calls/actions/calls'; import {loadCallForChannel} from '@calls/actions/calls';
import {Events, Screens} from '@constants'; import {Events, Screens} from '@constants';
import DatabaseManager from '@database/manager'; 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 {deleteChannelMembership, getChannelById, prepareMyChannelsForTeam, getCurrentChannel} from '@queries/servers/channel';
import {prepareCommonSystemValues, getConfig, setCurrentChannelId, getCurrentChannelId, getCurrentTeamId} from '@queries/servers/system'; import {prepareCommonSystemValues, getConfig, setCurrentChannelId, getCurrentChannelId, getCurrentTeamId} from '@queries/servers/system';
import {getNthLastChannelFromTeam} from '@queries/servers/team'; import {getNthLastChannelFromTeam} from '@queries/servers/team';
@@ -356,7 +356,7 @@ export async function handleUserRemovedFromChannelEvent(serverUrl: string, msg:
if (user.id === userId) { if (user.id === userId) {
await removeCurrentUserFromChannel(serverUrl, channelId); await removeCurrentUserFromChannel(serverUrl, channelId);
if (channel && channel.id === channelId) { if (channel && channel.id === channelId) {
const currentServer = await queryActiveServer(DatabaseManager.appDatabase!.database); const currentServer = await getActiveServer();
if (currentServer?.url === serverUrl) { if (currentServer?.url === serverUrl) {
DeviceEventEmitter.emit(Events.LEAVE_CHANNEL, channel.displayName); DeviceEventEmitter.emit(Events.LEAVE_CHANNEL, channel.displayName);
@@ -431,7 +431,7 @@ export async function handleChannelDeletedEvent(serverUrl: string, msg: WebSocke
await removeCurrentUserFromChannel(serverUrl, channelId); await removeCurrentUserFromChannel(serverUrl, channelId);
if (currentChannel && currentChannel.id === channelId) { if (currentChannel && currentChannel.id === channelId) {
const currentServer = await queryActiveServer(DatabaseManager.appDatabase!.database); const currentServer = await getActiveServer();
if (currentServer?.url === serverUrl) { if (currentServer?.url === serverUrl) {
DeviceEventEmitter.emit(Events.CHANNEL_ARCHIVED, currentChannel.displayName); DeviceEventEmitter.emit(Events.CHANNEL_ARCHIVED, currentChannel.displayName);

View File

@@ -31,7 +31,7 @@ import {Events, Screens, WebsocketEvents} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database'; import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager'; import DatabaseManager from '@database/manager';
import AppsManager from '@managers/apps_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 {getCurrentChannel} from '@queries/servers/channel';
import { import {
getConfig, getConfig,
@@ -135,7 +135,7 @@ async function doReconnect(serverUrl: string) {
const currentTeam = await getCurrentTeam(database); const currentTeam = await getCurrentTeam(database);
const currentChannel = await getCurrentChannel(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); const entryData = await entry(serverUrl, currentTeam?.id, currentChannel?.id, lastDisconnectedAt);
if ('error' in entryData) { 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 no longer a member of the current team or the current channel
if (initialTeamId !== currentTeam?.id || initialChannelId !== currentChannel?.id) { if (initialTeamId !== currentTeam?.id || initialChannelId !== currentChannel?.id) {
const currentServer = await queryActiveServer(appDatabase); const currentServer = await getActiveServer();
const isChannelScreenMounted = NavigationStore.getNavigationComponents().includes(Screens.CHANNEL); const isChannelScreenMounted = NavigationStore.getNavigationComponents().includes(Screens.CHANNEL);
if (serverUrl === currentServer?.url) { if (serverUrl === currentServer?.url) {
if (currentTeam && initialTeamId !== currentTeam.id) { if (currentTeam && initialTeamId !== currentTeam.id) {

View File

@@ -1,6 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import IntegrationsManager from '@managers/integrations_manager'; import IntegrationsManager from '@managers/integrations_manager';
import {getActiveServerUrl} from '@queries/app/servers'; import {getActiveServerUrl} from '@queries/app/servers';
@@ -9,14 +9,10 @@ export async function handleOpenDialogEvent(serverUrl: string, msg: WebSocketMes
if (!data) { if (!data) {
return; return;
} }
const appDatabase = DatabaseManager.appDatabase?.database;
if (!appDatabase) {
return;
}
try { try {
const dialog: InteractiveDialogConfig = JSON.parse(data); const dialog: InteractiveDialogConfig = JSON.parse(data);
const currentServer = await getActiveServerUrl(appDatabase); const currentServer = await getActiveServerUrl();
if (currentServer === serverUrl) { if (currentServer === serverUrl) {
IntegrationsManager.getManager(serverUrl).setDialog(dialog); IntegrationsManager.getManager(serverUrl).setDialog(dialog);
} }

View File

@@ -41,11 +41,7 @@ export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMess
} }
if (currentTeam?.id === teamId) { if (currentTeam?.id === teamId) {
const appDatabase = DatabaseManager.appDatabase?.database; const currentServer = await getActiveServerUrl();
let currentServer = '';
if (appDatabase) {
currentServer = await getActiveServerUrl(appDatabase);
}
if (currentServer === serverUrl) { if (currentServer === serverUrl) {
DeviceEventEmitter.emit(Events.LEAVE_TEAM, currentTeam?.displayName); DeviceEventEmitter.emit(Events.LEAVE_TEAM, currentTeam?.displayName);

View File

@@ -187,7 +187,8 @@ const ChannelIcon = ({
<DmAvatar <DmAvatar
channelName={name} channelName={name}
isInfo={isInfo} isInfo={isInfo}
/>); />
);
} }
return ( return (

View File

@@ -10,9 +10,10 @@ import {switchMap, distinctUntilChanged} from 'rxjs/operators';
import {observeChannelsWithCalls} from '@calls/state'; import {observeChannelsWithCalls} from '@calls/state';
import {General} from '@constants'; import {General} from '@constants';
import {withServerUrl} from '@context/server'; 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 {queryDraft} from '@queries/servers/drafts';
import {observeCurrentChannelId, observeCurrentUserId} from '@queries/servers/system'; import {observeCurrentChannelId, observeCurrentUserId} from '@queries/servers/system';
import {observeTeam} from '@queries/servers/team';
import ChannelItem from './channel_item'; import ChannelItem from './channel_item';
@@ -26,7 +27,7 @@ type EnhanceProps = WithDatabaseArgs & {
serverUrl?: string; 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'], ({ const enhance = withObservables(['channel', 'showTeamName'], ({
channel, channel,
@@ -58,7 +59,7 @@ const enhance = withObservables(['channel', 'showTeamName'], ({
let teamDisplayName = of$(''); let teamDisplayName = of$('');
if (channel.teamId && showTeamName) { if (channel.teamId && showTeamName) {
teamDisplayName = channel.team.observe().pipe( teamDisplayName = observeTeam(database, channel.teamId).pipe(
switchMap((team) => of$(team?.displayName || '')), switchMap((team) => of$(team?.displayName || '')),
distinctUntilChanged(), distinctUntilChanged(),
); );

View File

@@ -112,7 +112,7 @@ type FloatingTextInputProps = TextInputProps & {
testID?: string; testID?: string;
textInputStyle?: TextStyle; textInputStyle?: TextStyle;
theme: Theme; theme: Theme;
value: string; value?: string;
} }
const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProps>(({ const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProps>(({
@@ -133,7 +133,7 @@ const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProp
testID, testID,
textInputStyle, textInputStyle,
theme, theme,
value = '', value,
...props ...props
}: FloatingTextInputProps, ref) => { }: FloatingTextInputProps, ref) => {
const [focused, setIsFocused] = useState(false); const [focused, setIsFocused] = useState(false);
@@ -153,11 +153,11 @@ const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProp
useEffect( useEffect(
() => { () => {
if (!focusedLabel && value) { if (!focusedLabel && (value || props.defaultValue)) {
debouncedOnFocusTextInput(true); debouncedOnFocusTextInput(true);
} }
}, },
[value], [value, props.defaultValue],
); );
const onTextInputBlur = useCallback((e: NativeSyntheticEvent<TextInputFocusEventData>) => onExecution(e, const onTextInputBlur = useCallback((e: NativeSyntheticEvent<TextInputFocusEventData>) => onExecution(e,
@@ -218,7 +218,7 @@ const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProp
}, [styles, theme, shouldShowError, focused, textInputStyle, focusedLabel, multiline, editable]); }, [styles, theme, shouldShowError, focused, textInputStyle, focusedLabel, multiline, editable]);
const textAnimatedTextStyle = useAnimatedStyle(() => { const textAnimatedTextStyle = useAnimatedStyle(() => {
const inputText = placeholder || value; const inputText = placeholder || value || props.defaultValue;
const index = inputText || focusedLabel ? 1 : 0; const index = inputText || focusedLabel ? 1 : 0;
const toValue = positions[index]; const toValue = positions[index];
const toSize = size[index]; const toSize = size[index];

View File

@@ -9,8 +9,7 @@ import {distinctUntilChanged, map} from 'rxjs/operators';
import {setLastServerVersionCheck} from '@actions/local/systems'; import {setLastServerVersionCheck} from '@actions/local/systems';
import {useServerUrl} from '@context/server'; import {useServerUrl} from '@context/server';
import DatabaseManager from '@database/manager'; import {getServer} from '@queries/app/servers';
import {queryServer} from '@queries/app/servers';
import {observeConfigValue, observeLastServerVersionCheck} from '@queries/servers/system'; import {observeConfigValue, observeLastServerVersionCheck} from '@queries/servers/system';
import {observeCurrentUser} from '@queries/servers/user'; import {observeCurrentUser} from '@queries/servers/user';
import {isSupportedServer, unsupportedServer} from '@utils/server'; import {isSupportedServer, unsupportedServer} from '@utils/server';
@@ -27,12 +26,9 @@ type ServerVersionProps = {
const VALIDATE_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours const VALIDATE_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
const handleUnsupportedServer = async (serverUrl: string, isAdmin: boolean, intl: IntlShape) => { const handleUnsupportedServer = async (serverUrl: string, isAdmin: boolean, intl: IntlShape) => {
const appDatabase = DatabaseManager.appDatabase?.database; const serverModel = await getServer(serverUrl);
if (appDatabase) { unsupportedServer(serverModel?.displayName || '', isAdmin, intl);
const serverModel = await queryServer(appDatabase, serverUrl); setLastServerVersionCheck(serverUrl);
unsupportedServer(serverModel?.displayName || '', isAdmin, intl);
setLastServerVersionCheck(serverUrl);
}
}; };
const ServerVersion = ({isAdmin, lastChecked, version}: ServerVersionProps) => { const ServerVersion = ({isAdmin, lastChecked, version}: ServerVersionProps) => {

View File

@@ -6,11 +6,13 @@ export const IMAGE_MIN_DIMENSION = 50;
export const MAX_GIF_SIZE = 100 * 1024 * 1024; export const MAX_GIF_SIZE = 100 * 1024 * 1024;
export const VIEWPORT_IMAGE_OFFSET = 70; export const VIEWPORT_IMAGE_OFFSET = 70;
export const VIEWPORT_IMAGE_REPLY_OFFSET = 11; export const VIEWPORT_IMAGE_REPLY_OFFSET = 11;
export const MAX_RESOLUTION = 7680 * 4320; // 8K, ~33MPX
export default { export default {
IMAGE_MAX_HEIGHT, IMAGE_MAX_HEIGHT,
IMAGE_MIN_DIMENSION, IMAGE_MIN_DIMENSION,
MAX_GIF_SIZE, MAX_GIF_SIZE,
MAX_RESOLUTION,
VIEWPORT_IMAGE_OFFSET, VIEWPORT_IMAGE_OFFSET,
VIEWPORT_IMAGE_REPLY_OFFSET, VIEWPORT_IMAGE_REPLY_OFFSET,
}; };

View File

@@ -21,12 +21,12 @@ import AppDataOperator from '@database/operator/app_data_operator';
import ServerDataOperator from '@database/operator/server_data_operator'; import ServerDataOperator from '@database/operator/server_data_operator';
import {schema as appSchema} from '@database/schema/app'; import {schema as appSchema} from '@database/schema/app';
import {serverSchema} from '@database/schema/server'; import {serverSchema} from '@database/schema/server';
import {queryActiveServer, queryServer, queryServerByIdentifier} from '@queries/app/servers';
import {deleteIOSDatabase} from '@utils/mattermost_managed'; import {deleteIOSDatabase} from '@utils/mattermost_managed';
import {urlSafeBase64Encode} from '@utils/security'; import {urlSafeBase64Encode} from '@utils/security';
import {removeProtocol} from '@utils/url'; import {removeProtocol} from '@utils/url';
import type {AppDatabase, CreateServerDatabaseArgs, Models, RegisterServerDatabaseArgs, ServerDatabase, ServerDatabases} from '@typings/database/database'; 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 {SERVERS} = MM_TABLES.APP;
const APP_DATABASE = 'app'; const APP_DATABASE = 'app';
@@ -85,7 +85,6 @@ class DatabaseManager {
database, database,
operator, operator,
}; };
return this.appDatabase; return this.appDatabase;
} catch (e) { } catch (e) {
// do nothing // do nothing
@@ -168,9 +167,9 @@ class DatabaseManager {
public updateServerIdentifier = async (serverUrl: string, identifier: string) => { public updateServerIdentifier = async (serverUrl: string, identifier: string) => {
const appDatabase = this.appDatabase?.database; const appDatabase = this.appDatabase?.database;
if (appDatabase) { if (appDatabase) {
const server = await queryServer(appDatabase, serverUrl); const server = await this.getServer(serverUrl);
await appDatabase.write(async () => { await appDatabase.write(async () => {
await server.update((record) => { await server?.update((record) => {
record.identifier = identifier; record.identifier = identifier;
}); });
}); });
@@ -180,9 +179,9 @@ class DatabaseManager {
public updateServerDisplayName = async (serverUrl: string, displayName: string) => { public updateServerDisplayName = async (serverUrl: string, displayName: string) => {
const appDatabase = this.appDatabase?.database; const appDatabase = this.appDatabase?.database;
if (appDatabase) { if (appDatabase) {
const server = await queryServer(appDatabase, serverUrl); const server = await this.getServer(serverUrl);
await appDatabase.write(async () => { await appDatabase.write(async () => {
await server.update((record) => { await server?.update((record) => {
record.displayName = displayName; record.displayName = displayName;
}); });
}); });
@@ -190,42 +189,23 @@ class DatabaseManager {
}; };
private isServerPresent = async (serverUrl: string): Promise<boolean> => { private isServerPresent = async (serverUrl: string): Promise<boolean> => {
if (this.appDatabase?.database) { const server = await this.getServer(serverUrl);
const server = await queryServer(this.appDatabase.database, serverUrl); return Boolean(server);
return Boolean(server);
}
return false;
}; };
public getActiveServerUrl = async (): Promise<string|null|undefined> => { public getActiveServerUrl = async (): Promise<string|undefined> => {
const database = this.appDatabase?.database; const server = await this.getActiveServer();
if (database) { return server?.url;
const server = await queryActiveServer(database);
return server?.url;
}
return null;
}; };
public getActiveServerDisplayName = async (): Promise<string|null|undefined> => { public getActiveServerDisplayName = async (): Promise<string|undefined> => {
const database = this.appDatabase?.database; const server = await this.getActiveServer();
if (database) { return server?.displayName;
const server = await queryActiveServer(database);
return server?.displayName;
}
return null;
}; };
public getServerUrlFromIdentifier = async (identifier: string): Promise<string|undefined> => { public getServerUrlFromIdentifier = async (identifier: string): Promise<string|undefined> => {
const database = this.appDatabase?.database; const server = await this.getServerByIdentifier(identifier);
if (database) { return server?.url;
const server = await queryServerByIdentifier(database, identifier);
return server?.url;
}
return undefined;
}; };
public getAppDatabaseAndOperator = () => { public getAppDatabaseAndOperator = () => {
@@ -247,12 +227,9 @@ class DatabaseManager {
}; };
public getActiveServerDatabase = async (): Promise<Database|undefined> => { public getActiveServerDatabase = async (): Promise<Database|undefined> => {
const database = this.appDatabase?.database; const server = await this.getActiveServer();
if (database) { if (server?.url) {
const server = await queryActiveServer(database); return this.serverDatabases[server.url]!.database;
if (server?.url) {
return this.serverDatabases[server.url]!.database;
}
} }
return undefined; return undefined;
@@ -273,9 +250,9 @@ class DatabaseManager {
}; };
public deleteServerDatabase = async (serverUrl: string): Promise<void> => { public deleteServerDatabase = async (serverUrl: string): Promise<void> => {
if (this.appDatabase?.database) { const database = this.appDatabase?.database;
const database = this.appDatabase?.database; if (database) {
const server = await queryServer(database, serverUrl); const server = await this.getServer(serverUrl);
if (server) { if (server) {
database.write(async () => { database.write(async () => {
await server.update((record) => { await server.update((record) => {
@@ -291,9 +268,9 @@ class DatabaseManager {
}; };
public destroyServerDatabase = async (serverUrl: string): Promise<void> => { public destroyServerDatabase = async (serverUrl: string): Promise<void> => {
if (this.appDatabase?.database) { const database = this.appDatabase?.database;
const database = this.appDatabase?.database; if (database) {
const server = await queryServer(database, serverUrl); const server = await this.getServer(serverUrl);
if (server) { if (server) {
database.write(async () => { database.write(async () => {
await server.destroyPermanently(); await server.destroyPermanently();
@@ -380,6 +357,46 @@ class DatabaseManager {
const toFindWithoutProtocol = removeProtocol(toFind); const toFindWithoutProtocol = removeProtocol(toFind);
return Object.keys(this.serverDatabases).find((k) => removeProtocol(k) === toFindWithoutProtocol); 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<ServerModel>(SERVERS).query(Q.where('url', serverUrl)).fetch());
return servers?.[0];
} catch {
return undefined;
}
};
private getAllServers = async () => {
try {
const {database} = this.getAppDatabaseAndOperator();
return database.get<ServerModel>(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<ServerModel>(SERVERS).query(Q.where('identifier', identifier)).fetch());
return servers?.[0];
} catch {
return undefined;
}
};
} }
export default new DatabaseManager(); export default new DatabaseManager();

View File

@@ -22,7 +22,7 @@ import AppDataOperator from '@database/operator/app_data_operator';
import ServerDataOperator from '@database/operator/server_data_operator'; import ServerDataOperator from '@database/operator/server_data_operator';
import {schema as appSchema} from '@database/schema/app'; import {schema as appSchema} from '@database/schema/app';
import {serverSchema} from '@database/schema/server'; 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 {querySystemValue} from '@queries/servers/system';
import {deleteLegacyFileCache} from '@utils/file'; import {deleteLegacyFileCache} from '@utils/file';
import {emptyFunction} from '@utils/general'; import {emptyFunction} from '@utils/general';
@@ -223,7 +223,7 @@ class DatabaseManager {
try { try {
const appDatabase = this.appDatabase?.database; const appDatabase = this.appDatabase?.database;
if (appDatabase) { if (appDatabase) {
const serverModel = await queryServer(appDatabase, serverUrl); const serverModel = await getServer(serverUrl);
if (!serverModel) { if (!serverModel) {
await appDatabase.write(async () => { await appDatabase.write(async () => {
@@ -254,9 +254,9 @@ class DatabaseManager {
public updateServerIdentifier = async (serverUrl: string, identifier: string, displayName?: string) => { public updateServerIdentifier = async (serverUrl: string, identifier: string, displayName?: string) => {
const appDatabase = this.appDatabase?.database; const appDatabase = this.appDatabase?.database;
if (appDatabase) { if (appDatabase) {
const server = await queryServer(appDatabase, serverUrl); const server = await getServer(serverUrl);
await appDatabase.write(async () => { await appDatabase.write(async () => {
await server.update((record) => { await server?.update((record) => {
record.identifier = identifier; record.identifier = identifier;
if (displayName) { if (displayName) {
record.displayName = displayName; record.displayName = displayName;
@@ -269,9 +269,9 @@ class DatabaseManager {
public updateServerDisplayName = async (serverUrl: string, displayName: string) => { public updateServerDisplayName = async (serverUrl: string, displayName: string) => {
const appDatabase = this.appDatabase?.database; const appDatabase = this.appDatabase?.database;
if (appDatabase) { if (appDatabase) {
const server = await queryServer(appDatabase, serverUrl); const server = await getServer(serverUrl);
await appDatabase.write(async () => { await appDatabase.write(async () => {
await server.update((record) => { await server?.update((record) => {
record.displayName = displayName; record.displayName = displayName;
}); });
}); });
@@ -284,50 +284,31 @@ class DatabaseManager {
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
private isServerPresent = async (serverUrl: string): Promise<boolean> => { private isServerPresent = async (serverUrl: string): Promise<boolean> => {
if (this.appDatabase?.database) { const server = await getServer(serverUrl);
const server = await queryServer(this.appDatabase.database, serverUrl); return Boolean(server);
return Boolean(server);
}
return false;
}; };
/** /**
* getActiveServerUrl: Get the server url for active server database. * getActiveServerUrl: Get the server url for active server database.
* @returns {Promise<string|null|undefined>} * @returns {Promise<string|undefined>}
*/ */
public getActiveServerUrl = async (): Promise<string|null|undefined> => { public getActiveServerUrl = async (): Promise<string|undefined> => {
const database = this.appDatabase?.database; const server = await getActiveServer();
if (database) { return server?.url;
const server = await queryActiveServer(database);
return server?.url;
}
return null;
}; };
/** /**
* getActiveServerDisplayName: Get the server display name for active server database. * getActiveServerDisplayName: Get the server display name for active server database.
* @returns {Promise<string|null|undefined>} * @returns {Promise<string|undefined>}
*/ */
public getActiveServerDisplayName = async (): Promise<string|null|undefined> => { public getActiveServerDisplayName = async (): Promise<string|undefined> => {
const database = this.appDatabase?.database; const server = await getActiveServer();
if (database) { return server?.displayName;
const server = await queryActiveServer(database);
return server?.displayName;
}
return null;
}; };
public getServerUrlFromIdentifier = async (identifier: string): Promise<string|undefined> => { public getServerUrlFromIdentifier = async (identifier: string): Promise<string|undefined> => {
const database = this.appDatabase?.database; const server = await getServerByIdentifier(identifier);
if (database) { return server?.url;
const server = await queryServerByIdentifier(database, identifier);
return server?.url;
}
return undefined;
}; };
/** /**
@@ -335,12 +316,9 @@ class DatabaseManager {
* @returns {Promise<Database|undefined>} * @returns {Promise<Database|undefined>}
*/ */
public getActiveServerDatabase = async (): Promise<Database|undefined> => { public getActiveServerDatabase = async (): Promise<Database|undefined> => {
const database = this.appDatabase?.database; const server = await getActiveServer();
if (database) { if (server?.url) {
const server = await queryActiveServer(database); return this.serverDatabases[server.url]?.database;
if (server?.url) {
return this.serverDatabases[server.url]?.database;
}
} }
return undefined; return undefined;
@@ -405,9 +383,9 @@ class DatabaseManager {
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
public deleteServerDatabase = async (serverUrl: string): Promise<void> => { public deleteServerDatabase = async (serverUrl: string): Promise<void> => {
if (this.appDatabase?.database) { const database = this.appDatabase?.database;
const database = this.appDatabase?.database; if (database) {
const server = await queryServer(database, serverUrl); const server = await getServer(serverUrl);
if (server) { if (server) {
database.write(async () => { database.write(async () => {
await server.update((record) => { await server.update((record) => {
@@ -429,9 +407,9 @@ class DatabaseManager {
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
public destroyServerDatabase = async (serverUrl: string): Promise<void> => { public destroyServerDatabase = async (serverUrl: string): Promise<void> => {
if (this.appDatabase?.database) { const database = this.appDatabase?.database;
const database = this.appDatabase?.database; if (database) {
const server = await queryServer(database, serverUrl); const server = await getServer(serverUrl);
if (server) { if (server) {
database.write(async () => { database.write(async () => {
await server.destroyPermanently(); await server.destroyPermanently();

99
app/init/app.ts Normal file
View File

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

View File

@@ -8,6 +8,7 @@ import {Alert, AlertButton, AppState, AppStateStatus, Platform} from 'react-nati
import {DEFAULT_LOCALE, getTranslations, t} from '@i18n'; import {DEFAULT_LOCALE, getTranslations, t} from '@i18n';
import {toMilliseconds} from '@utils/datetime'; import {toMilliseconds} from '@utils/datetime';
import {isMainActivity} from '@utils/helpers';
import {getIOSAppGroupDetails} from '@utils/mattermost_managed'; import {getIOSAppGroupDetails} from '@utils/mattermost_managed';
const PROMPT_IN_APP_PIN_CODE_AFTER = toMilliseconds({minutes: 5}); const PROMPT_IN_APP_PIN_CODE_AFTER = toMilliseconds({minutes: 5});
@@ -146,7 +147,7 @@ class ManagedApp {
const isBackground = appState === 'background'; const isBackground = appState === 'background';
if (isActive && this.previousAppState === 'background' && !this.performingAuthentication) { 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; const authExpired = this.backgroundSince > 0 && (Date.now() - this.backgroundSince) >= PROMPT_IN_APP_PIN_CODE_AFTER;
await this.handleDeviceAuthentication(authExpired); await this.handleDeviceAuthentication(authExpired);
} }

View File

@@ -13,6 +13,7 @@ import {
NotificationTextInput, NotificationTextInput,
Registered, Registered,
} from 'react-native-notifications'; } from 'react-native-notifications';
import {requestNotifications} from 'react-native-permissions';
import {storeDeviceToken} from '@actions/app/global'; import {storeDeviceToken} from '@actions/app/global';
import {markChannelAsViewed} from '@actions/local/channel'; import {markChannelAsViewed} from '@actions/local/channel';
@@ -22,27 +23,41 @@ import {Device, Events, Navigation, PushNotification, Screens} from '@constants'
import DatabaseManager from '@database/manager'; import DatabaseManager from '@database/manager';
import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n'; import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n';
import NativeNotifications from '@notifications'; import NativeNotifications from '@notifications';
import {queryServerName} from '@queries/app/servers'; import {getServerDisplayName} from '@queries/app/servers';
import {getCurrentChannelId} from '@queries/servers/system'; import {getCurrentChannelId} from '@queries/servers/system';
import {getIsCRTEnabled, getThreadById} from '@queries/servers/thread'; import {getIsCRTEnabled, getThreadById} from '@queries/servers/thread';
import {dismissOverlay, showOverlay} from '@screens/navigation'; import {dismissOverlay, showOverlay} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store'; import EphemeralStore from '@store/ephemeral_store';
import NavigationStore from '@store/navigation_store'; import NavigationStore from '@store/navigation_store';
import {isTablet} from '@utils/helpers'; import {isMainActivity, isTablet} from '@utils/helpers';
import {logInfo} from '@utils/log'; import {logInfo} from '@utils/log';
import {convertToNotificationData} from '@utils/notification'; import {convertToNotificationData} from '@utils/notification';
class PushNotifications { class PushNotifications {
configured = false; configured = false;
init() { init(register: boolean) {
Notifications.registerRemoteNotifications(); if (register) {
Notifications.registerRemoteNotifications();
}
Notifications.events().registerNotificationOpened(this.onNotificationOpened); Notifications.events().registerNotificationOpened(this.onNotificationOpened);
Notifications.events().registerRemoteNotificationsRegistered(this.onRemoteNotificationsRegistered); Notifications.events().registerRemoteNotificationsRegistered(this.onRemoteNotificationsRegistered);
Notifications.events().registerNotificationReceivedBackground(this.onNotificationReceivedBackground); Notifications.events().registerNotificationReceivedBackground(this.onNotificationReceivedBackground);
Notifications.events().registerNotificationReceivedForeground(this.onNotificationReceivedForeground); 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 = () => { createReplyCategory = () => {
const replyTitle = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.title')); const replyTitle = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.title'));
const replyButton = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.button')); const replyButton = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.button'));
@@ -93,7 +108,7 @@ class PushNotifications {
const database = DatabaseManager.serverDatabases[serverUrl]?.database; const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (database) { if (database) {
const isTabletDevice = await isTablet(); const isTabletDevice = await isTablet();
const displayName = await queryServerName(DatabaseManager.appDatabase!.database, serverUrl); const displayName = await getServerDisplayName(serverUrl);
const channelId = await getCurrentChannelId(database); const channelId = await getCurrentChannelId(database);
const isCRTEnabled = await getIsCRTEnabled(database); const isCRTEnabled = await getIsCRTEnabled(database);
let serverName; let serverName;
@@ -218,7 +233,7 @@ class PushNotifications {
onNotificationReceivedForeground = (incoming: Notification, completion: (response: NotificationCompletion) => void) => { onNotificationReceivedForeground = (incoming: Notification, completion: (response: NotificationCompletion) => void) => {
const notification = convertToNotificationData(incoming, false); const notification = convertToNotificationData(incoming, false);
if (AppState.currentState !== 'inactive') { if (AppState.currentState !== 'inactive') {
notification.foreground = AppState.currentState === 'active'; notification.foreground = AppState.currentState === 'active' && isMainActivity();
this.processNotification(notification); this.processNotification(notification);
} }

View File

@@ -9,12 +9,11 @@ import {autoUpdateTimezone} from '@actions/remote/user';
import LocalConfig from '@assets/config.json'; import LocalConfig from '@assets/config.json';
import {Events, Sso} from '@constants'; import {Events, Sso} from '@constants';
import {MIN_REQUIRED_VERSION} from '@constants/supported_server'; import {MIN_REQUIRED_VERSION} from '@constants/supported_server';
import DatabaseManager from '@database/manager';
import {DEFAULT_LOCALE, getTranslations, t} from '@i18n'; import {DEFAULT_LOCALE, getTranslations, t} from '@i18n';
import {getServerCredentials} from '@init/credentials'; import {getServerCredentials} from '@init/credentials';
import {getLaunchPropsFromDeepLink, relaunchApp} from '@init/launch'; import {getLaunchPropsFromDeepLink, relaunchApp} from '@init/launch';
import * as analytics from '@managers/analytics'; import * as analytics from '@managers/analytics';
import {queryAllServers} from '@queries/app/servers'; import {getAllServers} from '@queries/app/servers';
import {logError} from '@utils/log'; import {logError} from '@utils/log';
import type {jsAndNativeErrorHandler} from '@typings/global/error_handling'; import type {jsAndNativeErrorHandler} from '@typings/global/error_handling';
@@ -29,8 +28,7 @@ class GlobalEventHandler {
DeviceEventEmitter.addListener(Events.CONFIG_CHANGED, this.onServerConfigChanged); DeviceEventEmitter.addListener(Events.CONFIG_CHANGED, this.onServerConfigChanged);
RNLocalize.addEventListener('change', async () => { RNLocalize.addEventListener('change', async () => {
try { try {
const {database} = DatabaseManager.getAppDatabaseAndOperator(); const servers = await getAllServers();
const servers = await queryAllServers(database);
for (const server of servers) { for (const server of servers) {
if (server.url && server.lastActiveAt > 0) { if (server.url && server.lastActiveAt > 0) {
autoUpdateTimezone(server.url); autoUpdateTimezone(server.url);

View File

@@ -16,11 +16,12 @@ import PushNotifications from '@init/push_notifications';
import * as analytics from '@managers/analytics'; import * as analytics from '@managers/analytics';
import NetworkManager from '@managers/network_manager'; import NetworkManager from '@managers/network_manager';
import WebsocketManager from '@managers/websocket_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 {getCurrentUser} from '@queries/servers/user';
import {getThemeFromState} from '@screens/navigation'; import {getThemeFromState} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store'; import EphemeralStore from '@store/ephemeral_store';
import {deleteFileCache, deleteFileCacheByDir} from '@utils/file'; import {deleteFileCache, deleteFileCacheByDir} from '@utils/file';
import {isMainActivity} from '@utils/helpers';
import {addNewServer} from '@utils/server'; import {addNewServer} from '@utils/server';
import type {LaunchType} from '@typings/launch'; import type {LaunchType} from '@typings/launch';
@@ -54,10 +55,10 @@ class SessionManager {
} }
init() { init() {
this.cancelAll(); this.cancelAllSessionNotifications();
} }
private cancelAll = async () => { private cancelAllSessionNotifications = async () => {
const serverCredentials = await getAllServerCredentials(); const serverCredentials = await getAllServerCredentials();
for (const {serverUrl} of serverCredentials) { for (const {serverUrl} of serverCredentials) {
cancelSessionNotification(serverUrl); cancelSessionNotification(serverUrl);
@@ -86,7 +87,7 @@ class SessionManager {
} }
}; };
private scheduleAll = async () => { private scheduleAllSessionNotifications = async () => {
if (!this.scheduling) { if (!this.scheduling) {
this.scheduling = true; this.scheduling = true;
const serverCredentials = await getAllServerCredentials(); const serverCredentials = await getAllServerCredentials();
@@ -142,17 +143,17 @@ class SessionManager {
}; };
private onAppStateChange = async (appState: AppStateStatus) => { private onAppStateChange = async (appState: AppStateStatus) => {
if (appState === this.previousAppState) { if (appState === this.previousAppState || !isMainActivity()) {
return; return;
} }
this.previousAppState = appState; this.previousAppState = appState;
switch (appState) { switch (appState) {
case 'active': case 'active':
setTimeout(this.cancelAll, 750); setTimeout(this.cancelAllSessionNotifications, 750);
break; break;
case 'inactive': case 'inactive':
this.scheduleAll(); this.scheduleAllSessionNotifications();
break; 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 // 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 getAllServers();
const servers = await queryAllServers(DatabaseManager.appDatabase.database); if (!servers.length) {
if (!servers.length) { await storeOnboardingViewedValue(false);
await storeOnboardingViewedValue(false);
}
} }
relaunchApp({launchType, serverUrl, displayName}, true); relaunchApp({launchType, serverUrl, displayName}, true);
@@ -196,8 +195,7 @@ class SessionManager {
await this.terminateSession(serverUrl, false); await this.terminateSession(serverUrl, false);
const activeServerUrl = await DatabaseManager.getActiveServerUrl(); const activeServerUrl = await DatabaseManager.getActiveServerUrl();
const appDatabase = DatabaseManager.appDatabase?.database; const serverDisplayName = await getServerDisplayName(serverUrl);
const serverDisplayName = appDatabase ? await queryServerName(appDatabase, serverUrl) : undefined;
await relaunchApp({launchType: Launch.Normal, serverUrl, displayName: serverDisplayName}, true); await relaunchApp({launchType: Launch.Normal, serverUrl, displayName: serverDisplayName}, true);
if (activeServerUrl) { if (activeServerUrl) {

View File

@@ -15,6 +15,7 @@ import DatabaseManager from '@database/manager';
import {getCurrentUserId} from '@queries/servers/system'; import {getCurrentUserId} from '@queries/servers/system';
import {queryAllUsers} from '@queries/servers/user'; import {queryAllUsers} from '@queries/servers/user';
import {toMilliseconds} from '@utils/datetime'; import {toMilliseconds} from '@utils/datetime';
import {isMainActivity} from '@utils/helpers';
import {logError} from '@utils/log'; import {logError} from '@utils/log';
const WAIT_TO_CLOSE = toMilliseconds({seconds: 15}); const WAIT_TO_CLOSE = toMilliseconds({seconds: 15});
@@ -181,6 +182,8 @@ class WebsocketManager {
return; return;
} }
const isMain = isMainActivity();
this.cancelAllConnections(); this.cancelAllConnections();
if (appState !== 'active' && !this.isBackgroundTimerRunning) { if (appState !== 'active' && !this.isBackgroundTimerRunning) {
this.isBackgroundTimerRunning = true; this.isBackgroundTimerRunning = true;
@@ -195,7 +198,7 @@ class WebsocketManager {
return; 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) { if (this.backgroundIntervalId) {
BackgroundTimer.clearInterval(this.backgroundIntervalId); BackgroundTimer.clearInterval(this.backgroundIntervalId);
} }
@@ -205,7 +208,9 @@ class WebsocketManager {
return; return;
} }
this.previousAppState = appState; if (isMain) {
this.previousAppState = appState;
}
}; };
private onNetStateChange = async (netState: NetInfoState) => { private onNetStateChange = async (netState: NetInfoState) => {

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {Database, Q} from '@nozbe/watermelondb'; import {Q} from '@nozbe/watermelondb';
import {of as of$} from 'rxjs'; import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators'; import {switchMap} from 'rxjs/operators';
@@ -12,9 +12,10 @@ import type GlobalModel from '@typings/database/models/app/global';
const {APP: {GLOBAL}} = MM_TABLES; const {APP: {GLOBAL}} = MM_TABLES;
export const getDeviceToken = async (appDatabase: Database): Promise<string> => { export const getDeviceToken = async (): Promise<string> => {
try { try {
const tokens = await appDatabase.get<GlobalModel>(GLOBAL).find(GLOBAL_IDENTIFIERS.DEVICE_TOKEN); const {database} = DatabaseManager.getAppDatabaseAndOperator();
const tokens = await database.get<GlobalModel>(GLOBAL).find(GLOBAL_IDENTIFIERS.DEVICE_TOKEN);
return tokens?.value || ''; return tokens?.value || '';
} catch { } catch {
return ''; return '';

View File

@@ -1,7 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // 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 {SupportedServer} from '@constants';
import {MM_TABLES} from '@constants/database'; import {MM_TABLES} from '@constants/database';
@@ -13,18 +14,51 @@ import type ServerModel from '@typings/database/models/app/servers';
const {APP: {SERVERS}} = MM_TABLES; const {APP: {SERVERS}} = MM_TABLES;
export const queryServer = async (appDatabase: Database, serverUrl: string) => { export const queryServerDisplayName = (serverUrl: string) => {
const servers = (await appDatabase.get<ServerModel>(SERVERS).query(Q.where('url', serverUrl)).fetch());
return servers?.[0];
};
export const queryAllServers = async (appDatabase: Database) => {
return appDatabase.get<ServerModel>(MM_TABLES.APP.SERVERS).query().fetch();
};
export const queryActiveServer = async (appDatabase: Database) => {
try { try {
const servers = await queryAllServers(appDatabase); const {database} = DatabaseManager.getAppDatabaseAndOperator();
return database.get<ServerModel>(SERVERS).query(Q.where('url', serverUrl));
} catch {
return undefined;
}
};
export const queryAllActiveServers = () => {
try {
const {database} = DatabaseManager.getAppDatabaseAndOperator();
return database.get<ServerModel>(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<ServerModel>(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<ServerModel>(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)); const server = servers?.filter((s) => s.identifier)?.reduce((a, b) => (b.lastActiveAt > a.lastActiveAt ? b : a));
return server; return server;
} catch { } catch {
@@ -32,45 +66,45 @@ export const queryActiveServer = async (appDatabase: Database) => {
} }
}; };
export const getActiveServerUrl = async (appDatabase: Database) => { export const getActiveServerUrl = async () => {
const server = await queryActiveServer(appDatabase); const server = await getActiveServer();
return server?.url || ''; return server?.url || '';
}; };
export const queryServerByIdentifier = async (appDatabase: Database, identifier: string) => { export const getServerByIdentifier = async (identifier: string) => {
try { try {
const servers = (await appDatabase.get<ServerModel>(SERVERS).query(Q.where('identifier', identifier)).fetch()); const {database} = DatabaseManager.getAppDatabaseAndOperator();
const servers = (await database.get<ServerModel>(SERVERS).query(Q.where('identifier', identifier)).fetch());
return servers?.[0]; return servers?.[0];
} catch { } catch {
return undefined; return undefined;
} }
}; };
export const queryServerByDisplayName = async (appDatabase: Database, displayName: string) => { export const getServerByDisplayName = async (displayName: string) => {
const servers = await queryAllServers(appDatabase); const servers = await getAllServers();
const server = servers.find((s) => s.displayName.toLowerCase() === displayName.toLowerCase()); const server = servers.find((s) => s.displayName.toLowerCase() === displayName.toLowerCase());
return server; return server;
}; };
export const queryServerName = async (appDatabase: Database, serverUrl: string) => { export const getServerDisplayName = async (serverUrl: string) => {
try { const servers = await queryServerDisplayName(serverUrl)?.fetch();
const servers = (await appDatabase.get<ServerModel>(SERVERS).query(Q.where('url', serverUrl)).fetch()); return servers?.[0].displayName || serverUrl;
return servers?.[0].displayName; };
} catch {
return 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 () => { export const areAllServersSupported = async () => {
let appDatabase; const servers = await getAllServers();
try {
const databaseAndOperator = DatabaseManager.getAppDatabaseAndOperator();
appDatabase = databaseAndOperator.database;
} catch {
return false;
}
const servers = await queryAllServers(appDatabase);
for await (const s of servers) { for await (const s of servers) {
if (s.lastActiveAt) { if (s.lastActiveAt) {
try { try {

View File

@@ -9,7 +9,7 @@ import {Navigation} from 'react-native-navigation';
import {SafeAreaView} from 'react-native-safe-area-context'; import {SafeAreaView} from 'react-native-safe-area-context';
import DatabaseManager from '@database/manager'; import DatabaseManager from '@database/manager';
import {queryServerByDisplayName} from '@queries/app/servers'; import {getServerByDisplayName} from '@queries/app/servers';
import Background from '@screens/background'; import Background from '@screens/background';
import {dismissModal} from '@screens/navigation'; import {dismissModal} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@@ -73,7 +73,7 @@ const EditServer = ({closeButtonId, componentId, server, theme}: ServerProps) =>
} }
setSaving(true); setSaving(true);
const knownServer = await queryServerByDisplayName(DatabaseManager.appDatabase!.database, displayName); const knownServer = await getServerByDisplayName(displayName);
if (knownServer && knownServer.lastActiveAt > 0 && knownServer.url !== server.url) { if (knownServer && knownServer.lastActiveAt > 0 && knownServer.url !== server.url) {
setButtonDisabled(true); setButtonDisabled(true);
setDisplayNameError(formatMessage({ setDisplayNameError(formatMessage({

View File

@@ -18,6 +18,7 @@ import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device'; import {useIsTablet} from '@hooks/device';
import {resetToTeams, openToS} from '@screens/navigation'; import {resetToTeams, openToS} from '@screens/navigation';
import NavigationStore from '@store/navigation_store'; import NavigationStore from '@store/navigation_store';
import {isMainActivity} from '@utils/helpers';
import {tryRunAppReview} from '@utils/reviews'; import {tryRunAppReview} from '@utils/reviews';
import {addSentryContext} from '@utils/sentry'; import {addSentryContext} from '@utils/sentry';
@@ -79,24 +80,27 @@ const ChannelListScreen = (props: ChannelProps) => {
const isHomeScreen = NavigationStore.getNavigationTopComponentId() === Screens.HOME; const isHomeScreen = NavigationStore.getNavigationTopComponentId() === Screens.HOME;
const homeTab = NavigationStore.getVisibleTab() === Screens.HOME; const homeTab = NavigationStore.getVisibleTab() === Screens.HOME;
const focused = navigation.isFocused() && isHomeScreen && homeTab; 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) { if (isMainActivity()) {
clearTimeout(backPressTimeout); 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; return false;
}, [intl]); }, [intl]);

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; 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 {StyleSheet} from 'react-native';
import ServerIcon from '@components/server_icon'; import ServerIcon from '@components/server_icon';
@@ -12,6 +12,7 @@ import {subscribeAllServers} from '@database/subscription/servers';
import {subscribeUnreadAndMentionsByServer, UnreadObserverArgs} from '@database/subscription/unreads'; import {subscribeUnreadAndMentionsByServer, UnreadObserverArgs} from '@database/subscription/unreads';
import {useIsTablet} from '@hooks/device'; import {useIsTablet} from '@hooks/device';
import {bottomSheet} from '@screens/navigation'; import {bottomSheet} from '@screens/navigation';
import {sortServersByDisplayName} from '@utils/server';
import ServerList from './servers_list'; 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 = { export type ServersRef = {
openServers: () => void; openServers: () => void;
} }
@@ -88,7 +75,7 @@ const Servers = React.forwardRef<ServersRef>((props, ref) => {
}; };
const serversObserver = async (servers: ServersModel[]) => { const serversObserver = async (servers: ServersModel[]) => {
registeredServers.current = sortServers(servers, intl); registeredServers.current = sortServersByDisplayName(servers, intl);
// unsubscribe mentions from servers that were removed // unsubscribe mentions from servers that were removed
const allUrls = new Set(servers.map((s) => s.url)); const allUrls = new Set(servers.map((s) => s.url));

View File

@@ -23,7 +23,7 @@ import {useTheme} from '@context/theme';
import DatabaseManager from '@database/manager'; import DatabaseManager from '@database/manager';
import {subscribeServerUnreadAndMentions, UnreadObserverArgs} from '@database/subscription/unreads'; import {subscribeServerUnreadAndMentions, UnreadObserverArgs} from '@database/subscription/unreads';
import {useIsTablet} from '@hooks/device'; import {useIsTablet} from '@hooks/device';
import {queryServerByIdentifier} from '@queries/app/servers'; import {getServerByIdentifier} from '@queries/app/servers';
import {dismissBottomSheet} from '@screens/navigation'; import {dismissBottomSheet} from '@screens/navigation';
import {canReceiveNotifications} from '@utils/push_proxy'; import {canReceiveNotifications} from '@utils/push_proxy';
import {alertServerAlreadyConnected, alertServerError, alertServerLogout, alertServerRemove, editServer, loginToServer} from '@utils/server'; import {alertServerAlreadyConnected, alertServerError, alertServerLogout, alertServerRemove, editServer, loginToServer} from '@utils/server';
@@ -243,7 +243,7 @@ const ServerItem = ({
setSwitching(false); setSwitching(false);
return; return;
} }
const existingServer = await queryServerByIdentifier(DatabaseManager.appDatabase!.database, data.config!.DiagnosticId); const existingServer = await getServerByIdentifier(data.config!.DiagnosticId);
if (existingServer && existingServer.lastActiveAt > 0) { if (existingServer && existingServer.lastActiveAt > 0) {
alertServerAlreadyConnected(intl); alertServerAlreadyConnected(intl);
setSwitching(false); setSwitching(false);

View File

@@ -16,10 +16,10 @@ import LocalConfig from '@assets/config.json';
import ClientError from '@client/rest/error'; import ClientError from '@client/rest/error';
import AppVersion from '@components/app_version'; import AppVersion from '@components/app_version';
import {Screens, Launch} from '@constants'; import {Screens, Launch} from '@constants';
import DatabaseManager from '@database/manager';
import {t} from '@i18n'; import {t} from '@i18n';
import PushNotifications from '@init/push_notifications';
import NetworkManager from '@managers/network_manager'; 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 Background from '@screens/background';
import {dismissModal, goToScreen, loginAnimationOptions} from '@screens/navigation'; import {dismissModal, goToScreen, loginAnimationOptions} from '@screens/navigation';
import {getErrorMessage} from '@utils/client_error'; import {getErrorMessage} from '@utils/client_error';
@@ -56,7 +56,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
}, },
scrollContainer: { scrollContainer: {
alignItems: 'center', alignItems: 'center',
height: '100%', height: '90%',
justifyContent: 'center', justifyContent: 'center',
}, },
})); }));
@@ -161,6 +161,8 @@ const Server = ({
} }
}); });
PushNotifications.registerIfNeeded();
return () => navigationEvents.remove(); return () => navigationEvents.remove();
}, []); }, []);
@@ -220,7 +222,7 @@ const Server = ({
setUrlError(undefined); setUrlError(undefined);
} }
const server = await queryServerByDisplayName(DatabaseManager.appDatabase!.database, displayName); const server = await getServerByDisplayName(displayName);
if (server && server.lastActiveAt > 0) { if (server && server.lastActiveAt > 0) {
setButtonDisabled(true); setButtonDisabled(true);
setDisplayNameError(formatMessage({ setDisplayNameError(formatMessage({
@@ -293,7 +295,7 @@ const Server = ({
return; return;
} }
const server = await queryServerByIdentifier(DatabaseManager.appDatabase!.database, data.config!.DiagnosticId); const server = await getServerByIdentifier(data.config!.DiagnosticId);
setConnecting(false); setConnecting(false);
if (server && server.lastActiveAt > 0) { if (server && server.lastActiveAt > 0) {

View File

@@ -10,6 +10,7 @@ import {IOS_STATUS_BAR_HEIGHT} from '@constants/view';
const {MattermostManaged} = NativeModules; const {MattermostManaged} = NativeModules;
const isRunningInSplitView = MattermostManaged.isRunningInSplitView; 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 // 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 // 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) { export function hasTrailingSpaces(term: string) {
return term.length !== term.trimEnd().length; 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',
});
}

View File

@@ -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) { function unsupportedServerAdminAlert(serverDisplayName: string, intl: IntlShape, onPress?: () => void) {
const title = intl.formatMessage({id: 'mobile.server_upgrade.title', defaultMessage: 'Server upgrade required'}); const title = intl.formatMessage({id: 'mobile.server_upgrade.title', defaultMessage: 'Server upgrade required'});

View File

@@ -78,6 +78,24 @@ export function extractFirstLink(text: string) {
return ''; 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) { export function isYoutubeLink(link: string) {
return link.trim().match(ytRegex); return link.trim().match(ytRegex);
} }

View File

@@ -262,6 +262,10 @@
"emoji_skin.medium_dark_skin_tone": "medium dark skin tone", "emoji_skin.medium_dark_skin_tone": "medium dark skin tone",
"emoji_skin.medium_light_skin_tone": "medium light skin tone", "emoji_skin.medium_light_skin_tone": "medium light skin tone",
"emoji_skin.medium_skin_tone": "medium 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}", "file_upload.fileAbove": "Files must be less than {max}",
"find_channels.directory": "Directory", "find_channels.directory": "Directory",
"find_channels.new_channel": "New Channel", "find_channels.new_channel": "New Channel",
@@ -625,7 +629,7 @@
"notification_settings.auto_responder": "Automatic Replies", "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.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.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.auto_responder.to.enable": "Enable automatic replies",
"notification_settings.email": "Email Notifications", "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", "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..keywordsDescription": "Other words that trigger a mention",
"notification_settings.mentions.channelWide": "Channel-wide mentions", "notification_settings.mentions.channelWide": "Channel-wide mentions",
"notification_settings.mentions.keywords": "Keywords", "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.keywordsLabel": "Keywords are not case-sensitive. Separate keywords with commas.",
"notification_settings.mentions.sensitiveName": "Your case sensitive first name", "notification_settings.mentions.sensitiveName": "Your case sensitive first name",
"notification_settings.mentions.sensitiveUsername": "Your non-case sensitive username", "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.notice_text": "Mattermost is made possible by the open source software used in our {platform} and {mobile}.",
"settings.notifications": "Notifications", "settings.notifications": "Notifications",
"settings.save": "Save", "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.no": "No, thanks",
"share_feedback.button.yes": "Yes", "share_feedback.button.yes": "Yes",
"share_feedback.subtitle": "We'd love to hear how we can make your experience better.", "share_feedback.subtitle": "We'd love to hear how we can make your experience better.",

View File

@@ -688,6 +688,14 @@ platform :android do
) )
end 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| Dir.glob('../android/app/src/main/java/com/mattermost/helpers/*.java') do |item|
find_replace_string( find_replace_string(
path_to_file: item[1..-1], path_to_file: item[1..-1],

View File

@@ -2,25 +2,13 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import TurboLogger from '@mattermost/react-native-turbo-log'; 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 {RUNNING_E2E} from 'react-native-dotenv';
import 'react-native-gesture-handler'; 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 ViewReactNativeStyleAttributes from 'react-native/Libraries/Components/View/ReactNativeStyleAttributes';
import {Events, Screens} from './app/constants'; import {initialize, start} from './app/init/app';
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 setFontFamily from './app/utils/font_family'; import setFontFamily from './app/utils/font_family';
import {logInfo} from './app/utils/log'; import {logInfo} from './app/utils/log';
@@ -64,76 +52,13 @@ if (global.HermesInternal) {
require('@formatjs/intl-datetimeformat/add-golden-tz'); 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 () => { Navigation.events().registerAppLaunchedListener(async () => {
// See caution in the library doc https://wix.github.io/react-native-navigation/docs/app-launch#android await initialize();
if (!alreadyInitialized) { await start();
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();
}); });
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);
}
}

View File

@@ -29,7 +29,7 @@ extension Database {
let db = try getDatabaseForServer(serverUrl) let db = try getDatabaseForServer(serverUrl)
let stmtString = """ let stmtString = """
SELECT COUNT(DISTINCT my.id) FROM Channel c \ 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 INNER JOIN Team t ON c.team_id=t.id
""" """
let stmt = try db.prepare(stmtString) let stmt = try db.prepare(stmtString)

View File

@@ -22,7 +22,7 @@ struct AttachmentsView: View {
if sizeError && attachments.count == 1 { if sizeError && attachments.count == 1 {
return "File must be less than \(server.maxFileSize.formattedFileSize)" return "File must be less than \(server.maxFileSize.formattedFileSize)"
} else if sizeError { } 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 { } else if resolutionError {
return "Image exceeds maximum dimensions of 7680 x 4320 px" return "Image exceeds maximum dimensions of 7680 x 4320 px"
} }

View File

@@ -31,6 +31,7 @@ target 'Mattermost' do
permissions_path = '../node_modules/react-native-permissions/ios' permissions_path = '../node_modules/react-native-permissions/ios'
pod 'Permission-Camera', :path => "#{permissions_path}/Camera" pod 'Permission-Camera', :path => "#{permissions_path}/Camera"
pod 'Permission-Microphone', :path => "#{permissions_path}/Microphone" pod 'Permission-Microphone', :path => "#{permissions_path}/Microphone"
pod 'Permission-Notifications', :path => "#{permissions_path}/Notifications"
pod 'Permission-PhotoLibrary', :path => "#{permissions_path}/PhotoLibrary" pod 'Permission-PhotoLibrary', :path => "#{permissions_path}/PhotoLibrary"
pod 'React-jsi', :path => '../node_modules/react-native/ReactCommon/jsi', :modular_headers => true pod 'React-jsi', :path => '../node_modules/react-native/ReactCommon/jsi', :modular_headers => true
pod 'simdjson', path: '../node_modules/@nozbe/simdjson' pod 'simdjson', path: '../node_modules/@nozbe/simdjson'

View File

@@ -101,6 +101,8 @@ PODS:
- RNPermissions - RNPermissions
- Permission-Microphone (3.6.1): - Permission-Microphone (3.6.1):
- RNPermissions - RNPermissions
- Permission-Notifications (3.6.1):
- RNPermissions
- Permission-PhotoLibrary (3.6.1): - Permission-PhotoLibrary (3.6.1):
- RNPermissions - RNPermissions
- RCT-Folly (2021.07.22.00): - RCT-Folly (2021.07.22.00):
@@ -584,6 +586,7 @@ DEPENDENCIES:
- OpenSSL-Universal (= 1.1.1100) - OpenSSL-Universal (= 1.1.1100)
- Permission-Camera (from `../node_modules/react-native-permissions/ios/Camera`) - Permission-Camera (from `../node_modules/react-native-permissions/ios/Camera`)
- Permission-Microphone (from `../node_modules/react-native-permissions/ios/Microphone`) - 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`) - Permission-PhotoLibrary (from `../node_modules/react-native-permissions/ios/PhotoLibrary`)
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
- RCTRequired (from `../node_modules/react-native/Libraries/RCTRequired`) - RCTRequired (from `../node_modules/react-native/Libraries/RCTRequired`)
@@ -711,6 +714,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-permissions/ios/Camera" :path: "../node_modules/react-native-permissions/ios/Camera"
Permission-Microphone: Permission-Microphone:
:path: "../node_modules/react-native-permissions/ios/Microphone" :path: "../node_modules/react-native-permissions/ios/Microphone"
Permission-Notifications:
:path: "../node_modules/react-native-permissions/ios/Notifications"
Permission-PhotoLibrary: Permission-PhotoLibrary:
:path: "../node_modules/react-native-permissions/ios/PhotoLibrary" :path: "../node_modules/react-native-permissions/ios/PhotoLibrary"
RCT-Folly: RCT-Folly:
@@ -893,6 +898,7 @@ SPEC CHECKSUMS:
OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c
Permission-Camera: bf6791b17c7f614b6826019fcfdcc286d3a107f6 Permission-Camera: bf6791b17c7f614b6826019fcfdcc286d3a107f6
Permission-Microphone: 48212dd4d28025d9930d583e3c7a56da7268665c Permission-Microphone: 48212dd4d28025d9930d583e3c7a56da7268665c
Permission-Notifications: 150484ae586eb9be4e32217582a78350a9bb31c3
Permission-PhotoLibrary: 5b34ca67279f7201ae109cef36f9806a6596002d Permission-PhotoLibrary: 5b34ca67279f7201ae109cef36f9806a6596002d
RCT-Folly: 0080d0a6ebf2577475bda044aa59e2ca1f909cda RCT-Folly: 0080d0a6ebf2577475bda044aa59e2ca1f909cda
RCTRequired: 21229f84411088e5d8538f21212de49e46cc83e2 RCTRequired: 21229f84411088e5d8538f21212de49e46cc83e2
@@ -975,6 +981,6 @@ SPEC CHECKSUMS:
Yoga: eca980a5771bf114c41a754098cd85e6e0d90ed7 Yoga: eca980a5771bf114c41a754098cd85e6e0d90ed7
YogaKit: f782866e155069a2cca2517aafea43200b01fd5a YogaKit: f782866e155069a2cca2517aafea43200b01fd5a
PODFILE CHECKSUM: 62c761c9aec9bb4ef459a75ab630937696026291 PODFILE CHECKSUM: 96049f4170c9cafd0ff121db4444b3f46f51bc97
COCOAPODS: 1.11.3 COCOAPODS: 1.11.3

118
package-lock.json generated
View File

@@ -32,6 +32,7 @@
"@react-native-cookies/cookies": "6.2.1", "@react-native-cookies/cookies": "6.2.1",
"@react-navigation/bottom-tabs": "6.4.0", "@react-navigation/bottom-tabs": "6.4.0",
"@react-navigation/native": "6.0.13", "@react-navigation/native": "6.0.13",
"@react-navigation/stack": "6.3.7",
"@rudderstack/rudder-sdk-react-native": "1.5.1", "@rudderstack/rudder-sdk-react-native": "1.5.1",
"@sentry/react-native": "4.8.0", "@sentry/react-native": "4.8.0",
"@stream-io/flat-list-mvcp": "0.10.2", "@stream-io/flat-list-mvcp": "0.10.2",
@@ -42,6 +43,7 @@
"deepmerge": "4.2.2", "deepmerge": "4.2.2",
"emoji-regex": "10.2.1", "emoji-regex": "10.2.1",
"fuse.js": "6.6.2", "fuse.js": "6.6.2",
"html-entities": "2.3.3",
"jail-monkey": "2.7.0", "jail-monkey": "2.7.0",
"mime-db": "1.52.0", "mime-db": "1.52.0",
"moment-timezone": "0.5.38", "moment-timezone": "0.5.38",
@@ -141,7 +143,7 @@
"@types/uuid": "8.3.4", "@types/uuid": "8.3.4",
"@typescript-eslint/eslint-plugin": "5.42.1", "@typescript-eslint/eslint-plugin": "5.42.1",
"@typescript-eslint/parser": "5.42.1", "@typescript-eslint/parser": "5.42.1",
"axios": "1.1.3", "axios": "1.2.0",
"axios-cookiejar-support": "4.0.3", "axios-cookiejar-support": "4.0.3",
"babel-jest": "29.3.0", "babel-jest": "29.3.0",
"babel-loader": "9.1.0", "babel-loader": "9.1.0",
@@ -4886,9 +4888,9 @@
} }
}, },
"node_modules/@react-navigation/elements": { "node_modules/@react-navigation/elements": {
"version": "1.3.6", "version": "1.3.9",
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.6.tgz", "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.9.tgz",
"integrity": "sha512-pNJ8R9JMga6SXOw6wGVN0tjmE6vegwPmJBL45SEMX2fqTfAk2ykDnlJHodRpHpAgsv0DaI8qX76z3A+aqKSU0w==", "integrity": "sha512-V9aIZN19ufaKWlXT4UcM545tDiEt9DIQS+74pDgbnzoQcDypn0CvSqWopFhPACMdJatgmlZUuOrrMfTeNrBWgA==",
"peerDependencies": { "peerDependencies": {
"@react-navigation/native": "^6.0.0", "@react-navigation/native": "^6.0.0",
"react": "*", "react": "*",
@@ -4919,6 +4921,52 @@
"nanoid": "^3.1.23" "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": { "node_modules/@rudderstack/rudder-sdk-react-native": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@rudderstack/rudder-sdk-react-native/-/rudder-sdk-react-native-1.5.1.tgz", "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": { "node_modules/axios": {
"version": "1.1.3", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.1.3.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.2.0.tgz",
"integrity": "sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA==", "integrity": "sha512-zT7wZyNYu3N5Bu0wuZ6QccIf93Qk1eV8LOewxgjOZFd2DenOs98cJ7+Y6703d0wkaXGY6/nZd4EweJaHz9uzQw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.0", "follow-redirects": "^1.15.0",
@@ -11355,6 +11403,11 @@
"react-is": "^16.7.0" "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": { "node_modules/html-escaper": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -25304,9 +25357,9 @@
} }
}, },
"@react-navigation/elements": { "@react-navigation/elements": {
"version": "1.3.6", "version": "1.3.9",
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.6.tgz", "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.9.tgz",
"integrity": "sha512-pNJ8R9JMga6SXOw6wGVN0tjmE6vegwPmJBL45SEMX2fqTfAk2ykDnlJHodRpHpAgsv0DaI8qX76z3A+aqKSU0w==", "integrity": "sha512-V9aIZN19ufaKWlXT4UcM545tDiEt9DIQS+74pDgbnzoQcDypn0CvSqWopFhPACMdJatgmlZUuOrrMfTeNrBWgA==",
"requires": {} "requires": {}
}, },
"@react-navigation/native": { "@react-navigation/native": {
@@ -25328,6 +25381,40 @@
"nanoid": "^3.1.23" "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": { "@rudderstack/rudder-sdk-react-native": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@rudderstack/rudder-sdk-react-native/-/rudder-sdk-react-native-1.5.1.tgz", "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==" "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw=="
}, },
"axios": { "axios": {
"version": "1.1.3", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.1.3.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.2.0.tgz",
"integrity": "sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA==", "integrity": "sha512-zT7wZyNYu3N5Bu0wuZ6QccIf93Qk1eV8LOewxgjOZFd2DenOs98cJ7+Y6703d0wkaXGY6/nZd4EweJaHz9uzQw==",
"dev": true, "dev": true,
"requires": { "requires": {
"follow-redirects": "^1.15.0", "follow-redirects": "^1.15.0",
@@ -30193,6 +30280,11 @@
"react-is": "^16.7.0" "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": { "html-escaper": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",

View File

@@ -29,6 +29,7 @@
"@react-native-cookies/cookies": "6.2.1", "@react-native-cookies/cookies": "6.2.1",
"@react-navigation/bottom-tabs": "6.4.0", "@react-navigation/bottom-tabs": "6.4.0",
"@react-navigation/native": "6.0.13", "@react-navigation/native": "6.0.13",
"@react-navigation/stack": "6.3.7",
"@rudderstack/rudder-sdk-react-native": "1.5.1", "@rudderstack/rudder-sdk-react-native": "1.5.1",
"@sentry/react-native": "4.8.0", "@sentry/react-native": "4.8.0",
"@stream-io/flat-list-mvcp": "0.10.2", "@stream-io/flat-list-mvcp": "0.10.2",
@@ -39,6 +40,7 @@
"deepmerge": "4.2.2", "deepmerge": "4.2.2",
"emoji-regex": "10.2.1", "emoji-regex": "10.2.1",
"fuse.js": "6.6.2", "fuse.js": "6.6.2",
"html-entities": "2.3.3",
"jail-monkey": "2.7.0", "jail-monkey": "2.7.0",
"mime-db": "1.52.0", "mime-db": "1.52.0",
"moment-timezone": "0.5.38", "moment-timezone": "0.5.38",
@@ -138,7 +140,7 @@
"@types/uuid": "8.3.4", "@types/uuid": "8.3.4",
"@typescript-eslint/eslint-plugin": "5.42.1", "@typescript-eslint/eslint-plugin": "5.42.1",
"@typescript-eslint/parser": "5.42.1", "@typescript-eslint/parser": "5.42.1",
"axios": "1.1.3", "axios": "1.2.0",
"axios-cookiejar-support": "4.0.3", "axios-cookiejar-support": "4.0.3",
"babel-jest": "29.3.0", "babel-jest": "29.3.0",
"babel-loader": "9.1.0", "babel-loader": "9.1.0",

View File

@@ -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 (
<CompassIcon
name='archive-outline'
style={style.icon}
size={24}
/>
);
}
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 = (
<FastImage
key={pictureUrl}
style={style.image}
source={imgSource}
/>
);
} else {
icon = (
<CompassIcon
color={changeOpacity(theme.centerChannelColor, 0.72)}
name={ACCOUNT_OUTLINE_IMAGE}
size={24}
/>
);
}
return (
<View style={style.container}>
{icon}
</View>
);
};
export default Avatar;

View File

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

View File

@@ -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 (
<TouchableOpacity onPress={handleOnPress}>
<>
<View style={containerStyle}>
<View style={styles.wrapper}>
<Icon
database={channel.database}
membersCount={membersCount}
name={channel.name}
shared={channel.shared}
size={24}
theme={theme}
type={channel.type}
/>
<View>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={textStyles}
>
{displayName}
</Text>
{Boolean(teamDisplayName) &&
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={styles.teamName}
>
{teamDisplayName}
</Text>
}
</View>
</View>
</View>
</>
</TouchableOpacity>
);
};
export default ChannelListItem;

View File

@@ -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<ViewStyle>;
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 = (
<CompassIcon
name={iconName}
style={[styles.icon, styles.iconInfo, {fontSize: size, left: 0.5}]}
/>
);
} else if (type === General.OPEN_CHANNEL) {
icon = (
<CompassIcon
name='globe'
style={[styles.icon, styles.iconInfo, {fontSize: size, left: 1}]}
/>
);
} else if (type === General.PRIVATE_CHANNEL) {
icon = (
<CompassIcon
name='lock-outline'
style={[styles.icon, styles.iconInfo, {fontSize: size, left: 0.5}]}
/>
);
} else if (type === General.GM_CHANNEL) {
const fontSize = size - 12;
icon = (
<View
style={[styles.groupBox, styles.groupBoxInfo, {width: size, height: size}]}
>
<Text
style={[styles.group, styles.groupInfo, {fontSize}]}
>
{membersCount - 1}
</Text>
</View>
);
} else if (type === General.DM_CHANNEL) {
icon = (
<Avatar
channelName={name}
database={database}
theme={theme}
/>
);
}
return (
<View style={[styles.container, {width: size, height: size}, style]}>
{icon}
</View>
);
};
export default React.memo(ChannelIcon);

View File

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

View File

@@ -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 = (
<Single
file={files[0]}
maxFileSize={maxFileSize}
theme={theme}
/>
);
} else {
attachments = (
<Multiple
files={files}
maxFileSize={maxFileSize}
theme={theme}
/>
);
}
return (
<>
<View style={attachmentsContainerStyle}>
{attachments}
</View>
{Boolean(error) &&
<ErrorLabel
text={error!}
theme={theme}
/>
}
</>
);
};
export default Attachments;

View File

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

View File

@@ -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 (
<View style={containerStyle}>
<FileIcon file={file}/>
<View style={textContainerStyle}>
<Text
numberOfLines={1}
style={nameStyle}
>
{file.name}
</Text>
<Text style={styles.info}>
{size}
</Text>
</View>
</View>
);
};
export default Info;

View File

@@ -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<SharedItem>) => {
const containerStyle: StyleProp<ViewStyle> = [styles.item];
if (index === 0) {
containerStyle.push(styles.first);
} else if (index === files.length - 1) {
containerStyle.push(styles.last);
}
return (
<View style={containerStyle}>
<Single
file={item}
isSmall={true}
maxFileSize={maxFileSize}
theme={theme}
/>
</View>
);
}, [maxFileSize, theme, files]);
return (
<>
<FlatList
data={files}
horizontal={true}
keyExtractor={getKey}
renderItem={renderItem}
style={styles.list}
contentContainerStyle={styles.container}
overScrollMode='always'
/>
<View style={styles.labelContainer}>
<FormattedText
id='share_extension.multiple_label'
defaultMessage='{count, number} attachments'
values={count}
style={styles.label}
/>
</View>
</>
);
};
export default Multiple;

View File

@@ -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 = (
<Thumbnail
contentMode={contentMode}
file={file}
hasError={hasError}
theme={theme}
type={type}
/>
);
} else {
attachment = (
<Info
contentMode={contentMode}
file={fileInfo}
hasError={hasError}
theme={theme}
/>
);
}
if (isSmall) {
return (
<View>
{attachment}
<View style={styles.remove}>
<TouchableOpacity
hitSlop={hitSlop}
onPress={onPress}
>
<CompassIcon
name='close-circle'
size={24}
color={changeOpacity(theme.centerChannelColor, 0.56)}
/>
</TouchableOpacity>
</View>
</View>
);
}
return attachment;
};
export default Single;

View File

@@ -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 (
<View style={containerStyle}>
<View style={styles.center}>
<FastImage
source={source}
style={[imgStyle, styles.radius]}
resizeMode='cover'
/>
{type === 'video' &&
<>
<LinearGradient
start={GRADIENT_START}
end={GRADIENT_END}
locations={GRADIENT_LOCATIONS}
colors={GRADIENT_COLORS}
style={StyleSheet.absoluteFill}
/>
<View style={styles.play}>
<CompassIcon
name='play'
size={20}
color='white'
/>
</View>
</>
}
</View>
</View>
);
};
export default Thumbnail;

View File

@@ -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 (
<View style={styles.container}>
<KeyboardAwareScrollView
bounces={false}
enableAutomaticScroll={true}
enableOnAndroid={false}
enableResetScrollToCoords={true}
keyboardDismissMode='on-drag'
keyboardShouldPersistTaps='handled'
scrollToOverflowEnabled={true}
contentContainerStyle={styles.content}
>
{Boolean(linkPreviewUrl) &&
<LinkPreview
theme={theme}
url={linkPreviewUrl!}
/>
}
{files.length > 0 &&
<Attachments
database={database}
theme={theme}
/>
}
{(files.length > 0 || Boolean(linkPreviewUrl)) &&
<View style={styles.divider}/>
}
<Options
channelId={channelId}
database={database}
serverUrl={serverUrl}
theme={theme}
/>
<View style={styles.divider}/>
<Message theme={theme}/>
</KeyboardAwareScrollView>
</View>
);
};
export default ContentView;

View File

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

View File

@@ -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<ViewStyle>;
};
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 (
<View style={style}>
{error && !loading &&
<CompassIcon
color={changeOpacity(theme.centerChannelColor, 0.16)}
name='file-image-broken-outline-large'
size={24}
/>
}
{loading &&
<ActivityIndicator
color={changeOpacity(theme.centerChannelColor, 0.16)}
size='small'
/>
}
{!error &&
<FastImage
resizeMode='cover'
source={{uri: url}}
style={{borderRadius: 4, height: 72, width: 72}}
onLoad={onLoad}
onError={onError}
/>
}
</View>
);
};
const LinkPreview = ({theme, url}: Props) => {
const styles = getStyles(theme);
const [data, setData] = useState<OpenGraph|undefined>();
useEffect(() => {
if (url) {
fetchOpenGraph(url).then(setData);
}
}, [url]);
if (!data || data.error) {
return null;
}
return (
<View style={styles.container}>
<View style={styles.flex}>
<Text
numberOfLines={2}
style={styles.title}
>
{url}
</Text>
<Text
numberOfLines={1}
style={styles.link}
>
{data!.link}
</Text>
</View>
{Boolean(data.imageURL) &&
<OpenGraphImage
style={styles.image}
theme={theme}
url={data.imageURL}
/>
}
</View>
);
};
export default LinkPreview;

View File

@@ -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 (
<View style={styles.container}>
<FloatingTextInput
allowFontScaling={false}
autoCapitalize='none'
autoCorrect={false}
autoFocus={false}
containerStyle={styles.textInputContainer}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
label={label}
multiline={true}
onChangeText={onChangeText}
returnKeyType='default'
textAlignVertical='top'
textInputStyle={styles.input}
theme={theme}
underlineColorAndroid='transparent'
defaultValue={message || ''}
/>
</View>
);
};
export default Message;

View File

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

View File

@@ -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 (
<TouchableOpacity
onPress={onPress}
style={styles.container}
>
<Text style={styles.label}>{label}</Text>
<View style={styles.row}>
<Text
numberOfLines={1}
style={styles.value}
>
{value}
</Text>
<CompassIcon
color={changeOpacity(theme.centerChannelColor, 0.32)}
name='chevron-right'
size={24}
/>
</View>
</TouchableOpacity>
);
};
export default Option;

View File

@@ -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<any>();
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 = (
<Option
label={channelLabel}
value={channelDisplayName || ''}
onPress={onChannelPress}
theme={theme}
/>
);
} else {
channel = (
<ErrorLabel
style={{marginHorizontal: 0}}
text={errorLabel}
theme={theme}
/>
);
}
return (
<View style={styles.container}>
<Option
label={serverLabel}
value={serverDisplayName}
onPress={onServerPress}
theme={theme}
/>
{channel}
</View>
);
};
export default Options;

View File

@@ -0,0 +1,51 @@
// 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 {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
style?: StyleProp<ViewStyle>;
text: string;
theme: Theme;
}
const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
icon: {
color: theme.errorTextColor,
},
message: {
color: theme.errorTextColor,
marginLeft: 7,
top: -2,
...typography('Body', 75),
},
row: {
flexDirection: 'row',
alignItems: 'flex-start',
marginHorizontal: 20,
marginTop: 12,
},
}));
const ErrorLabel = ({style, text, theme}: Props) => {
const styles = getStyles(theme);
return (
<View style={[styles.row, style]}>
<CompassIcon
name='alert-outline'
size={12}
style={styles.icon}
/>
<Text style={styles.message}>
{text}
</Text>
</View>
);
};
export default ErrorLabel;

View File

@@ -0,0 +1,53 @@
// 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 FormattedText from '@components/formatted_text';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
theme: Theme;
}
const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
marginHorizontal: 32,
},
title: {
color: theme.centerChannelColor,
marginBottom: 8,
...typography('Heading', 400),
},
description: {
color: changeOpacity(theme.centerChannelColor, 0.72),
textAlign: 'center',
...typography('Body', 200),
},
}));
const NoMemberships = ({theme}: Props) => {
const styles = getStyles(theme);
return (
<View style={styles.container}>
<FormattedText
id='extension.no_memberships.title'
defaultMessage='Not a member of any team yet'
style={styles.title}
/>
<FormattedText
id='extension.no_memberships.description'
defaultMessage="To share content, you'll need to be a member of a team on a Mattermost server."
style={styles.description}
/>
</View>
);
};
export default NoMemberships;

View File

@@ -0,0 +1,53 @@
// 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 FormattedText from '@components/formatted_text';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
theme: Theme;
}
const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
marginHorizontal: 32,
},
title: {
color: theme.centerChannelColor,
marginBottom: 8,
...typography('Heading', 400),
},
description: {
color: changeOpacity(theme.centerChannelColor, 0.72),
textAlign: 'center',
...typography('Body', 200),
},
}));
const NoServers = ({theme}: Props) => {
const styles = getStyles(theme);
return (
<View style={styles.container}>
<FormattedText
id='extension.no_servers.title'
defaultMessage='Not connected to any servers'
style={styles.title}
/>
<FormattedText
id='extension.no_servers.description'
defaultMessage="To share content, you'll need to be logged in to a Mattermost server."
style={styles.description}
/>
</View>
);
};
export default NoServers;

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {StyleSheet, TouchableOpacity, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {useShareExtensionState} from '@share/state';
type Props = {
theme: Theme;
}
const hitSlop = {top: 10, left: 10, right: 10, bottom: 10};
const styles = StyleSheet.create({
left: {
marginLeft: 10,
},
});
const CloseHeaderButton = ({theme}: Props) => {
const {closeExtension} = useShareExtensionState();
const onPress = useCallback(() => {
closeExtension(null);
}, [closeExtension]);
return (
<TouchableOpacity
onPress={onPress}
hitSlop={hitSlop}
>
<View style={styles.left}>
<CompassIcon
name='close'
color={theme.sidebarHeaderTextColor}
size={24}
/>
</View>
</TouchableOpacity>
);
};
export default CloseHeaderButton;

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {StyleSheet, TouchableOpacity, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {useShareExtensionState} from '@share/state';
import {changeOpacity} from '@utils/theme';
type Props = {
theme: Theme;
}
const hitSlop = {top: 10, left: 10, right: 10, bottom: 10};
const styles = StyleSheet.create({
right: {
marginRight: 10,
},
});
const PostButton = ({theme}: Props) => {
const {
closeExtension, channelId, files, globalError,
linkPreviewUrl, message, serverUrl, userId,
} = useShareExtensionState();
const disabled = !serverUrl || !channelId || (!message && !files.length && !linkPreviewUrl) || globalError;
const onPress = useCallback(() => {
if (!serverUrl || !channelId || !userId) {
return;
}
let text = message || '';
if (linkPreviewUrl) {
if (text) {
text = `${text}\n\n${linkPreviewUrl}`;
} else {
text = linkPreviewUrl;
}
}
closeExtension({
serverUrl,
channelId,
files,
message: text,
userId,
});
}, [serverUrl, channelId, message, files, linkPreviewUrl, userId]);
return (
<TouchableOpacity
disabled={disabled}
onPress={onPress}
hitSlop={hitSlop}
>
<View style={[styles.right]}>
<CompassIcon
name='send'
color={changeOpacity(theme.sidebarHeaderTextColor, disabled ? 0.16 : 1)}
size={24}
/>
</View>
</TouchableOpacity>
);
};
export default PostButton;

View File

@@ -0,0 +1,45 @@
// 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 FormattedText from '@components/formatted_text';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
paddingVertical: 8,
paddingTop: 12,
paddingLeft: 2,
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: theme.centerChannelBg,
},
heading: {
color: changeOpacity(theme.centerChannelColor, 0.64),
textTransform: 'uppercase',
...typography('Heading', 75, 'SemiBold'),
},
}));
type Props = {
theme: Theme;
}
const RecentHeader = ({theme}: Props) => {
const styles = getStyles(theme);
return (
<View style={styles.container}>
<FormattedText
id='mobile.channel_list.recent'
defaultMessage='Recent'
style={styles.heading}
/>
</View>
);
};
export default RecentHeader;

View File

@@ -0,0 +1,32 @@
// 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 {queryMyRecentChannels} from '@queries/servers/channel';
import {queryJoinedTeams} from '@queries/servers/team';
import {retrieveChannels} from '@screens/find_channels/utils';
import Recent from './recent';
import type {WithDatabaseArgs} from '@typings/database/database';
const MAX_CHANNELS = 20;
const enhanced = withObservables(['database'], ({database}: WithDatabaseArgs) => {
const teamsCount = queryJoinedTeams(database).observeCount();
const recentChannels = queryMyRecentChannels(database, MAX_CHANNELS).
observeWithColumns(['last_viewed_at']).pipe(
switchMap((myChannels) => retrieveChannels(database, myChannels, true)),
);
return {
recentChannels,
showTeamName: teamsCount.pipe(switchMap((count) => of$(count > 1))),
};
});
export default enhanced(Recent);

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useNavigation} from '@react-navigation/native';
import React, {useCallback, useEffect, useState} from 'react';
import {SectionList, SectionListRenderItemInfo} from 'react-native';
import ChannelItem from '@share/components/channel_item';
import {setShareExtensionChannelId} from '@share/state';
import RecentHeader from './header';
import type ChannelModel from '@typings/database/models/servers/channel';
type Props = {
recentChannels: ChannelModel[];
showTeamName: boolean;
theme: Theme;
}
const buildSections = (recentChannels: ChannelModel[]) => {
const sections = [{
data: recentChannels,
}];
return sections;
};
const RecentList = ({recentChannels, showTeamName, theme}: Props) => {
const navigation = useNavigation();
const [sections, setSections] = useState(buildSections(recentChannels));
const onPress = useCallback((channelId: string) => {
setShareExtensionChannelId(channelId);
navigation.goBack();
}, []);
const renderSectionHeader = useCallback(() => (
<RecentHeader theme={theme}/>
), [theme]);
const renderSectionItem = useCallback(({item}: SectionListRenderItemInfo<ChannelModel>) => {
return (
<ChannelItem
channel={item}
onPress={onPress}
showTeamName={showTeamName}
theme={theme}
/>
);
}, [onPress, showTeamName]);
useEffect(() => {
setSections(buildSections(recentChannels));
}, [recentChannels]);
return (
<SectionList
keyboardDismissMode='interactive'
keyboardShouldPersistTaps='handled'
renderItem={renderSectionItem}
renderSectionHeader={renderSectionHeader}
sections={sections}
showsVerticalScrollIndicator={false}
stickySectionHeadersEnabled={true}
/>
);
};
export default RecentList;

View File

@@ -0,0 +1,51 @@
// 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 {combineLatestWith, switchMap} from 'rxjs/operators';
import {observeArchiveChannelsByTerm, observeDirectChannelsByTerm, observeJoinedChannelsByTerm} from '@queries/servers/channel';
import {queryJoinedTeams} from '@queries/servers/team';
import {retrieveChannels} from '@screens/find_channels/utils';
import SearchChannels, {MAX_RESULTS} from './search_channels';
import type {WithDatabaseArgs} from '@typings/database/database';
type EnhanceProps = WithDatabaseArgs & {
term: string;
}
const enhanced = withObservables(['term', 'database'], ({database, term}: EnhanceProps) => {
const teamsCount = queryJoinedTeams(database).observeCount();
const joinedChannelsMatchStart = observeJoinedChannelsByTerm(database, term, MAX_RESULTS, true);
const joinedChannelsMatch = observeJoinedChannelsByTerm(database, term, MAX_RESULTS);
const directChannelsMatchStart = observeDirectChannelsByTerm(database, term, MAX_RESULTS, true);
const directChannelsMatch = observeDirectChannelsByTerm(database, term, MAX_RESULTS);
const channelsMatchStart = joinedChannelsMatchStart.pipe(
combineLatestWith(directChannelsMatchStart),
switchMap((matchStart) => {
return retrieveChannels(database, matchStart.flat(), true);
}),
);
const channelsMatch = joinedChannelsMatch.pipe(
combineLatestWith(directChannelsMatch),
switchMap((matched) => retrieveChannels(database, matched.flat(), true)),
);
const archivedChannels = observeArchiveChannelsByTerm(database, term, MAX_RESULTS).pipe(
switchMap((archived) => retrieveChannels(database, archived)),
);
return {
archivedChannels,
channelsMatch,
channelsMatchStart,
showTeamName: teamsCount.pipe(switchMap((count) => of$(count > 1))),
};
});
export default enhanced(SearchChannels);

View File

@@ -0,0 +1,113 @@
// 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 {FlatList, ListRenderItemInfo, StyleSheet, View} from 'react-native';
import Animated, {FadeInDown, FadeOutUp} from 'react-native-reanimated';
import NoResultsWithTerm from '@components/no_results_with_term';
import ChannelItem from '@share/components/channel_item';
import {setShareExtensionChannelId} from '@share/state';
import {sortChannelsByDisplayName} from '@utils/channel';
import type ChannelModel from '@typings/database/models/servers/channel';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
archivedChannels: ChannelModel[];
channelsMatch: ChannelModel[];
channelsMatchStart: ChannelModel[];
showTeamName: boolean;
term: string;
theme: Theme;
}
const style = StyleSheet.create({
flex: {flex: 1},
noResultContainer: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
},
list: {
flexGrow: 1,
},
});
export const MAX_RESULTS = 20;
const SearchChannels = ({
archivedChannels, channelsMatch, channelsMatchStart,
showTeamName, term, theme,
}: Props) => {
const navigation = useNavigation();
const {locale} = useIntl();
const onPress = useCallback((channelId: string) => {
setShareExtensionChannelId(channelId);
navigation.goBack();
}, []);
const renderEmpty = useCallback(() => {
if (term) {
return (
<View style={style.noResultContainer}>
<NoResultsWithTerm term={term}/>
</View>
);
}
return null;
}, [term, theme]);
const renderItem = useCallback(({item}: ListRenderItemInfo<ChannelModel>) => {
return (
<ChannelItem
channel={item}
onPress={onPress}
showTeamName={showTeamName}
theme={theme}
/>
);
}, [showTeamName]);
const data = useMemo(() => {
const items: Array<ChannelModel|Channel|UserModel> = [...channelsMatchStart];
// Channels that matches
if (items.length < MAX_RESULTS) {
items.push(...channelsMatch);
}
// Archived channels local
if (items.length < MAX_RESULTS) {
const archivedAlpha = archivedChannels.
sort(sortChannelsByDisplayName.bind(null, locale));
items.push(...archivedAlpha.slice(0, MAX_RESULTS + 1));
}
return [...new Set(items)].slice(0, MAX_RESULTS + 1);
}, [archivedChannels, channelsMatchStart, channelsMatch, locale]);
return (
<Animated.View
entering={FadeInDown.duration(100)}
exiting={FadeOutUp.duration(100)}
style={style.flex}
>
<FlatList
contentContainerStyle={style.list}
keyboardDismissMode='interactive'
keyboardShouldPersistTaps='handled'
ListEmptyComponent={renderEmpty}
renderItem={renderItem}
data={data}
showsVerticalScrollIndicator={false}
/>
</Animated.View>
);
};
export default SearchChannels;

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import withObservables from '@nozbe/with-observables';
import {observeAllActiveServers} from '@app/queries/app/servers';
import ServersList from './servers_list';
const enhanced = withObservables([], () => ({
servers: observeAllActiveServers(),
}));
export default enhanced(ServersList);

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useNavigation} from '@react-navigation/native';
import React, {useCallback} from 'react';
import {Text, TouchableOpacity, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {setShareExtensionServerUrl} from '@share/state';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {removeProtocol, stripTrailingSlashes} from '@utils/url';
import type ServersModel from '@typings/database/models/app/servers';
type Props = {
server: ServersModel;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
alignItems: 'center',
backgroundColor: changeOpacity(theme.centerChannelColor, 0.04),
borderRadius: 8,
flexDirection: 'row',
height: 72,
marginBottom: 12,
},
details: {
marginLeft: 14,
flex: 1,
},
name: {
color: theme.centerChannelColor,
flexShrink: 1,
...typography('Body', 200, 'SemiBold'),
},
nameView: {
flexDirection: 'row',
marginRight: 7,
},
row: {
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: 18,
},
url: {
color: changeOpacity(theme.centerChannelColor, 0.72),
marginRight: 7,
...typography('Body', 75, 'Regular'),
},
}));
const ServerItem = ({server, theme}: Props) => {
const navigation = useNavigation();
const styles = getStyleSheet(theme);
const onServerPressed = useCallback(() => {
setShareExtensionServerUrl(server.url);
navigation.goBack();
}, [server]);
return (
<TouchableOpacity
onPress={onServerPressed}
style={styles.container}
>
<View style={styles.row}>
<CompassIcon
size={36}
name='server-variant'
color={changeOpacity(theme.centerChannelColor, 0.56)}
/>
<View style={styles.details}>
<View style={styles.nameView}>
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={styles.name}
>
{server.displayName}
</Text>
</View>
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={styles.url}
>
{removeProtocol(stripTrailingSlashes(server.url))}
</Text>
</View>
</View>
</TouchableOpacity>
);
};
export default ServerItem;

View File

@@ -0,0 +1,57 @@
// 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 {FlatList, ListRenderItemInfo, StyleSheet, View} from 'react-native';
import {sortServersByDisplayName} from '@utils/server';
import ServerItem from './server_item';
import type ServersModel from '@typings/database/models/app/servers';
type Props = {
servers: ServersModel[];
theme: Theme;
}
const styles = StyleSheet.create({
container: {
flex: 1,
marginTop: 16,
marginHorizontal: 20,
},
contentContainer: {
marginVertical: 4,
},
});
const keyExtractor = (item: ServersModel) => item.url;
const ServersList = ({servers, theme}: Props) => {
const intl = useIntl();
const data = useMemo(() => sortServersByDisplayName(servers, intl), [intl.locale, servers]);
const renderServer = useCallback(({item}: ListRenderItemInfo<ServersModel>) => {
return (
<ServerItem
server={item}
theme={theme}
/>
);
}, [theme]);
return (
<View style={styles.container}>
<FlatList
data={data}
renderItem={renderServer}
keyExtractor={keyExtractor}
contentContainerStyle={styles.contentContainer}
/>
</View>
);
};
export default ServersList;

132
share_extension/index.tsx Normal file
View File

@@ -0,0 +1,132 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {NavigationContainer} from '@react-navigation/native';
import {createStackNavigator} from '@react-navigation/stack';
import React, {useEffect, useMemo, useState} from 'react';
import {IntlProvider} from 'react-intl';
import {Appearance, BackHandler, NativeModules} from 'react-native';
import {getDefaultThemeByAppearance} from '@context/theme';
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
import {initialize} from '@init/app';
import {extractStartLink, isValidUrl} from '@utils/url';
import ChannelsScreen from './screens/channels';
import ServersScreen from './screens/servers';
import ShareScreen from './screens/share';
const ShareModule: NativeShareExtension = NativeModules.MattermostShare;
const Stack = createStackNavigator();
const closeExtension = (data: ShareExtensionDataToSend | null = null) => {
ShareModule.close(data);
};
const ShareExtension = () => {
const [data, setData] = useState<SharedItem[]>();
const [theme, setTheme] = useState<Theme>(getDefaultThemeByAppearance());
const defaultNavigationOptions = useMemo(() => ({
headerStyle: {
backgroundColor: theme.sidebarHeaderBg,
},
headerTitleStyle: {
marginHorizontal: 0,
left: 0,
color: theme.sidebarHeaderTextColor,
},
headerBackTitleStyle: {
color: theme.sidebarHeaderTextColor,
margin: 0,
},
headerTintColor: theme.sidebarHeaderTextColor,
headerTopInsetEnabled: false,
cardStyle: {backgroundColor: theme.centerChannelBg},
}), [theme]);
const {text: message, link: linkPreviewUrl} = useMemo(() => {
let text = data?.filter((i) => i.isString)[0]?.value;
let link;
if (text) {
const first = extractStartLink(text);
if (first && isValidUrl(first)) {
link = first;
text = text.replace(first, '');
}
}
return {text, link};
}, [data]);
const files = useMemo(() => {
return data?.filter((i) => !i.isString) || [];
}, [data]);
useEffect(() => {
initialize().finally(async () => {
const items = await ShareModule.getSharedData();
setData(items);
});
const backListener = BackHandler.addEventListener('hardwareBackPress', () => {
const scene = ShareModule.getCurrentActivityName();
if (scene === 'ShareActivity') {
closeExtension();
return true;
}
return false;
});
const appearanceListener = Appearance.addChangeListener(() => {
setTheme(getDefaultThemeByAppearance());
});
return () => {
backListener.remove();
appearanceListener.remove();
};
}, []);
if (!data) {
return null;
}
return (
<IntlProvider
locale={DEFAULT_LOCALE}
messages={getTranslations(DEFAULT_LOCALE)}
>
<NavigationContainer>
<Stack.Navigator
initialRouteName='Share'
screenOptions={defaultNavigationOptions}
>
<Stack.Screen name='Share'>
{() => (
<ShareScreen
files={files}
linkPreviewUrl={linkPreviewUrl}
message={message}
theme={theme}
/>
)}
</Stack.Screen>
<Stack.Screen name='Servers'>
{() => (
<ServersScreen theme={theme}/>
)}
</Stack.Screen>
<Stack.Screen name='Channels'>
{() => (
<ChannelsScreen theme={theme}/>
)}
</Stack.Screen>
</Stack.Navigator>
</NavigationContainer>
</IntlProvider>
);
};
export default ShareExtension;

View File

@@ -0,0 +1,123 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import GenericClient from '@mattermost/react-native-network-client';
import {decode} from 'html-entities';
export type OpenGraph = {
link: string;
title?: string;
imageURL?: string;
error?: any;
}
const metaTags: Record<string, string> = {
title: 'title',
description: 'description',
ogUrl: 'og:url',
ogType: 'og:type',
ogTitle: 'og:title',
ogDescription: 'og:description',
ogImage: 'og:image',
ogVideo: 'og:video',
ogVideoType: 'og:video:type',
ogVideoWidth: 'og:video:width',
ogVideoHeight: 'og:video:height',
ogVideoUrl: 'og:video:url',
twitterPlayer: 'twitter:player',
twitterPlayerWidth: 'twitter:player:width',
twitterPlayerHeight: 'twitter:player:height',
twitterPlayerStream: 'twitter:player:stream',
twitterCard: 'twitter:card',
twitterDomain: 'twitter:domain',
twitterUrl: 'twitter:url',
twitterTitle: 'twitter:title',
twitterDescription: 'twitter:description',
twitterImage: 'twitter:image',
};
const fetchRaw = async (url: string) => {
try {
const res = await GenericClient.get(url, {
headers: {
'User-Agent': 'OpenGraph',
'Cache-Control': 'no-cache',
Accept: '*/*',
Connection: 'keep-alive',
},
});
if (!res.ok) {
return res;
}
return res.data as any;
} catch (error: any) {
return {message: error.message};
}
};
const fetchOpenGraph = async (url: string): Promise<OpenGraph> => {
const {
ogTitle,
ogImage,
} = metaTags;
try {
const html = await fetchRaw(url);
let siteTitle = '';
const tagTitle = html.match(
/<title[^>]*>[\r\n\t\s]*([^<]+)[\r\n\t\s]*<\/title>/gim,
);
siteTitle = tagTitle[0].replace(
/<title[^>]*>[\r\n\t\s]*([^<]+)[\r\n\t\s]*<\/title>/gim,
'$1',
);
const og = [];
const metas: any = html.match(/<meta[^>]+>/gim);
// There is no else statement
/* istanbul ignore else */
if (metas) {
for (let meta of metas) {
meta = meta.replace(/\s*\/?>$/, ' />');
const zname = meta.replace(/[\s\S]*(property|name)\s*=\s*([\s\S]+)/, '$2');
const name = (/^["']/).test(zname) ? zname.substr(1, zname.slice(1).indexOf(zname[0])) : zname.substr(0, zname.search(/[\s\t]/g));
const valid = Boolean(Object.keys(metaTags).filter((m: any) => metaTags[m].toLowerCase() === name.toLowerCase()).length);
// There is no else statement
/* istanbul ignore else */
if (valid) {
const zcontent = meta.replace(/[\s\S]*(content)\s*=\s*([\s\S]+)/, '$2');
const content = (/^["']/).test(zcontent) ? zcontent.substr(1, zcontent.slice(1).indexOf(zcontent[0])) : zcontent.substr(0, zcontent.search(/[\s\t]/g));
og.push({name, value: content === 'undefined' ? null : content});
}
}
}
const result: OpenGraph = {link: url};
const data = og.reduce(
(chain: any, meta: any) => ({...chain, [meta.name]: decode(meta.value)}),
{url},
);
// Image
result.imageURL = data[ogImage] ? data[ogImage] : null;
// Title
data[ogTitle] = data[ogTitle] ? data[ogTitle] : siteTitle;
result.title = data[ogTitle];
return result;
} catch (error: any) {
return {
link: url,
error,
};
}
};
export default fetchOpenGraph;

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {of as of$, switchMap} from 'rxjs';
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getAllServers} from '@queries/app/servers';
import type ChannelModel from '@typings/database/models/servers/channel';
const {SERVER: {CHANNEL}} = MM_TABLES;
export const queryHasChannels = (serverUrl: string) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
return database.get<ChannelModel>(CHANNEL).query(
Q.unsafeSqlQuery('SELECT DISTINCT c.* FROM Channel c \
INNER JOIN MyChannel my ON c.id=my.id AND c.delete_at = 0 \
INNER JOIN Team t ON c.team_id=t.id',
),
);
} catch (e) {
return undefined;
}
};
export const getServerHasChannels = async (serverUrl: string) => {
const channelsCount = await queryHasChannels(serverUrl)?.fetch();
return (channelsCount?.length ?? 0) > 0;
};
export const observeServerHasChannels = (serverUrl: string) => {
return queryHasChannels(serverUrl)?.observe().pipe(
switchMap((channels) => of$(channels?.length > 0 ?? false)),
) || of$(false);
};
export const hasChannels = async () => {
try {
const servers = await getAllServers();
const activeSrvers = servers.filter((s) => s.identifier && s.lastActiveAt);
const promises: Array<Promise<boolean>> = [];
for (const active of activeSrvers) {
promises.push(getServerHasChannels(active.url));
}
const result = await Promise.all(promises);
return result.some((r) => r);
} catch {
return false;
}
};

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo, useState} from 'react';
import {View} from 'react-native';
import SearchBar from '@components/search';
import DatabaseManager from '@database/manager';
import RecentChannels from '@share/components/recent_channels';
import SearchChannels from '@share/components/search_channels';
import {useShareExtensionServerUrl} from '@share/state';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
theme: Theme;
}
const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flex: 1,
marginHorizontal: 20,
marginTop: 20,
},
inputContainerStyle: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.12),
},
inputStyle: {
color: theme.centerChannelColor,
},
listContainer: {
flex: 1,
marginTop: 8,
},
}));
const Channels = ({theme}: Props) => {
const serverUrl = useShareExtensionServerUrl();
const styles = getStyles(theme);
const [term, setTerm] = useState('');
const color = useMemo(() => changeOpacity(theme.centerChannelColor, 0.72), [theme]);
const cancelButtonProps = useMemo(() => ({
color,
buttonTextStyle: {
...typography('Body', 100),
},
}), [color]);
const database = useMemo(() => {
try {
const server = DatabaseManager.getServerDatabaseAndOperator(serverUrl || '');
return server.database;
} catch {
return undefined;
}
}, [serverUrl]);
if (!database) {
return null;
}
return (
<View style={styles.container}>
<SearchBar
autoCapitalize='none'
autoFocus={true}
cancelButtonProps={cancelButtonProps}
clearIconColor={color}
inputContainerStyle={styles.inputContainerStyle}
inputStyle={styles.inputStyle}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
onChangeText={setTerm}
placeholderTextColor={color}
searchIconColor={color}
selectionColor={color}
value={term}
/>
{term === '' &&
<RecentChannels
database={database}
theme={theme}
/>
}
{Boolean(term) &&
<SearchChannels
database={database}
term={term}
theme={theme}
/>
}
</View>
);
};
export default Channels;

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useNavigation} from '@react-navigation/native';
import React, {useEffect} from 'react';
import {useIntl} from 'react-intl';
import {StyleSheet, View} from 'react-native';
import ServersList from '@share/components/servers_list';
type Props = {
theme: Theme;
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
const Servers = ({theme}: Props) => {
const navigator = useNavigation();
const intl = useIntl();
useEffect(() => {
navigator.setOptions({
title: intl.formatMessage({id: 'share_extension.servers_screen.title', defaultMessage: 'Select server'}),
});
}, [intl.locale]);
return (
<View style={styles.container}>
<ServersList theme={theme}/>
</View>
);
};
export default Servers;

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import withObservables from '@nozbe/with-observables';
import {useNavigation} from '@react-navigation/native';
import React, {useEffect, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {StyleSheet, View} from 'react-native';
import {from as from$} from 'rxjs';
import DatabaseManager from '@database/manager';
import {getActiveServerUrl} from '@queries/app/servers';
import ContentView from '@share/components/content_view';
import NoMemberships from '@share/components/error/no_memberships';
import NoServers from '@share/components/error/no_servers';
import CloseHeaderButton from '@share/components/header/close_header_button';
import PostButton from '@share/components/header/post_button';
import {hasChannels} from '@share/queries';
import {setShareExtensionState, useShareExtensionServerUrl} from '@share/state';
type Props = {
hasChannelMemberships: boolean;
initialServerUrl: string;
files: SharedItem[];
linkPreviewUrl?: string;
message?: string;
theme: Theme;
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
const ShareScreen = ({hasChannelMemberships, initialServerUrl, files, linkPreviewUrl, message, theme}: Props) => {
const navigator = useNavigation();
const intl = useIntl();
const serverUrl = useShareExtensionServerUrl();
const hasServers = useMemo(() => Object.keys(DatabaseManager.serverDatabases).length > 0, []);
const serverDb = useMemo(() => {
try {
if (!serverUrl) {
return undefined;
}
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
return database;
} catch {
return undefined;
}
}, [serverUrl]);
useEffect(() => {
navigator.setOptions({
title: intl.formatMessage({id: 'share_extension.share_screen.title', defaultMessage: 'Share to Mattermost'}),
});
}, [intl.locale]);
useEffect(() => {
setShareExtensionState({
files,
linkPreviewUrl,
message,
serverUrl: initialServerUrl,
});
navigator.setOptions({
headerLeft: () => (<CloseHeaderButton theme={theme}/>),
headerRight: () => (<PostButton theme={theme}/>),
});
}, []);
return (
<View style={styles.container}>
{!hasServers &&
<NoServers theme={theme}/>
}
{hasServers && !hasChannelMemberships &&
<NoMemberships theme={theme}/>
}
{hasServers && hasChannelMemberships && Boolean(serverDb) &&
<ContentView
database={serverDb!}
theme={theme}
/>
}
</View>
);
};
const enhanced = withObservables([], () => ({
initialServerUrl: from$(getActiveServerUrl()),
hasChannelMemberships: from$(hasChannels()),
}));
export default enhanced(ShareScreen);

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
import {NativeModules} from 'react-native';
import {BehaviorSubject} from 'rxjs';
const ShareModule: NativeShareExtension = NativeModules.MattermostShare;
const defaultState: ShareExtensionState = {
closeExtension: ShareModule.close,
channelId: undefined,
files: [],
globalError: false,
linkPreviewUrl: undefined,
message: undefined,
serverUrl: undefined,
userId: undefined,
};
const subject: BehaviorSubject<ShareExtensionState> = new BehaviorSubject(defaultState);
export const getShareExtensionState = () => {
return subject.value;
};
export const setShareExtensionState = (state: Omit<ShareExtensionState, 'closeExtension' | 'globalError'>) => {
const prevState = getShareExtensionState();
subject.next({...prevState, ...state});
};
export const setShareExtensionGlobalError = (globalError: boolean) => {
const state = getShareExtensionState();
const newState = {
...state,
globalError,
};
setShareExtensionState(newState);
};
export const setShareExtensionMessage = (message?: string) => {
const state = getShareExtensionState();
const newState = {
...state,
message,
};
setShareExtensionState(newState);
};
export const setShareExtensionServerUrl = (serverUrl: string) => {
const state = getShareExtensionState();
const newState = {
...state,
serverUrl,
};
setShareExtensionState(newState);
};
export const setShareExtensionUserAndChannelIds = (userId: string, channelId: string) => {
const state = getShareExtensionState();
const newState = {
...state,
channelId,
userId,
};
setShareExtensionState(newState);
};
export const setShareExtensionUserId = (userId: string) => {
const state = getShareExtensionState();
const newState = {
...state,
userId,
};
setShareExtensionState(newState);
};
export const setShareExtensionChannelId = (channelId: string) => {
const state = getShareExtensionState();
const newState = {
...state,
channelId,
};
setShareExtensionState(newState);
};
export const removeShareExtensionFile = (file: SharedItem) => {
const state = getShareExtensionState();
const files = [...state.files];
const index = files.findIndex((f) => f === file);
if (index > -1) {
files.splice(index, 1);
const newState = {
...state,
files,
};
setShareExtensionState(newState);
}
};
export const useShareExtensionState = () => {
const [state, setState] = useState(defaultState);
useEffect(() => {
const sub = subject.subscribe(setState);
return () => sub.unsubscribe();
}, []);
return state;
};
export const useShareExtensionServerUrl = () => {
const state = useShareExtensionState();
const [serverUrl, setServerUrl] = useState(state.serverUrl);
useEffect(() => {
setServerUrl(state.serverUrl);
}, [state.serverUrl]);
return serverUrl;
};
export const useShareExtensionMessage = () => {
const state = useShareExtensionState();
const [message, setMessage] = useState(state.message);
useEffect(() => {
setMessage(state.message);
}, [state.message]);
return message;
};
export const useShareExtensionFiles = () => {
const state = useShareExtensionState();
const [files, setFiles] = useState(state.files);
useEffect(() => {
setFiles(state.files);
}, [state.files]);
return files;
};

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export function toFileInfo(f: SharedItem): FileInfo {
return {
post_id: '',
user_id: '',
extension: f.extension,
mime_type: f.type,
has_preview_image: (f.width || 0) > 0,
height: f.height || 0,
width: f.width || 0,
name: f.filename || '',
size: f.size || 0,
uri: f.value,
};
}
export function imageDimensions(imgHeight: number, imgWidth: number, maxHeight: number, viewportWidth: number) {
if (!imgHeight || !imgWidth) {
return {
height: 0,
width: 0,
};
}
const widthRatio = imgWidth / imgHeight;
const heightRatio = imgWidth / imgHeight;
let height = imgHeight;
let width = imgWidth;
if (imgWidth >= viewportWidth) {
width = viewportWidth;
height = width * widthRatio;
}
if (height > maxHeight) {
height = maxHeight;
width = height * heightRatio;
}
return {height, width};
}

39
types/global/share_extension.d.ts vendored Normal file
View File

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
interface NativeShareExtension {
close: (data: ShareExtensionDataToSend|null) => void;
getCurrentActivityName: () => string;
getSharedData: () => Promise<SharedItem[]>;
}
interface ShareExtensionDataToSend {
channelId: string;
files: SharedItem[];
message: string;
serverUrl: string;
userId: string;
}
interface SharedItem {
extension: string;
filename?: string;
isString: boolean;
size?: number;
type: string;
value: string;
height?: number;
width?: number;
videoThumb?: string;
}
interface ShareExtensionState {
channelId?: string;
closeExtension: (data: ShareExtensionDataToSend | null) => void;
files: SharedItem[];
globalError: boolean;
linkPreviewUrl?: string;
message?: string;
serverUrl?: string;
userId?: string;
}