Compare commits

...

10 commits

Author SHA1 Message Date
unified-ci-app[bot]
1a2faccb7f
Bump app build and version number (#8031)
Some checks failed
github-release / test (push) Has been cancelled
github-release / build-ios-unsigned (push) Has been cancelled
github-release / build-android-unsigned (push) Has been cancelled
github-release / release (push) Has been cancelled
* Bump app build number to 529

* Bump app version number to 2.17.1

---------

Co-authored-by: runner <runner@Mac-1718801613616.local>
2024-06-19 15:18:45 +02:00
Mattermost Build
5e3c6eec8f
fix android push notifications when app is closed (#7989) (#8030)
(cherry picked from commit e5ce796770)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2024-06-19 14:48:47 +02:00
unified-ci-app[bot]
0c5ca33a10
Bump app build number to 525 (#7986)
Co-authored-by: runner <runner@Mac-1717190520036.local>
2024-06-03 12:45:17 +02:00
Mattermost Build
765ee984ac
Fix user autocomplete not showing users not in channel (#7980) (#7985) 2024-06-01 05:29:11 +08:00
Mattermost Build
91910693af
fix update image on account screen when updating profile image (#7978) (#7983)
(cherry picked from commit 83e4aba522)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2024-05-31 17:09:36 +02:00
Mattermost Build
c9314b2315
Fix user autocomplete not always showing on search screen (#7977) (#7979)
(cherry picked from commit 663f9ba01a)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2024-05-31 09:14:26 +02:00
Mario Vitale
d0aa643e94
Manual cherrypick of PR7959 (#7960)
Co-authored-by: unified-ci-app[bot] <121569378+unified-ci-app[bot]@users.noreply.github.com>
2024-05-17 14:50:07 +02:00
Mattermost Build
230efe1eb9
Update README.md (#7946) (#7955)
* Update README.md

* Update full_description.txt

* Update changelog

(cherry picked from commit 08c38259cb)

Co-authored-by: Amy Blais <29708087+amyblais@users.noreply.github.com>
2024-05-16 14:43:43 +02:00
Mattermost Build
444ca1c7b5
[MM-57460] - Calls: Host controls, add mute all to participant list (#7945) (#7948)
* add mute all to participant list

* i18n

* hostControlsAvailable

* mute-all -> mute-others

(cherry picked from commit 59db1839ec)

Co-authored-by: Christopher Poile <cpoile@gmail.com>
2024-05-10 16:59:21 -04:00
Mattermost Build
4d9b298187
[MM-58129] Calls: Host controls remove participant, view profile (#7938) (#7947)
* host remove command, host removed feedback

* add view profile control

* i18n; dismissBottomSheet later

* PR comments

* remove confusing setPreviousCall(null)

* our own separator, bit more space for Android

* no need for previousCall due to changes on server

* move host removed alert to an error

* channel_id is not send via the msg.broadcast

* channel_id is still sent via the msg.broadcast

(cherry picked from commit 7fbd2e52e2)

Co-authored-by: Christopher Poile <cpoile@gmail.com>
2024-05-10 16:50:56 -04:00
30 changed files with 479 additions and 249 deletions

View file

@ -1,6 +1,6 @@
# Mattermost Mobile v2
- **Minimum Server versions:** Current ESR version (8.1.0+)
- **Minimum Server versions:** Current ESR version (9.5.0+)
- **Supported iOS versions:** 12.4+
- **Supported Android versions:** 7.0+

View file

@ -111,8 +111,8 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 520
versionName "2.16.0"
versionCode 529
versionName "2.17.1"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}

View file

@ -4,29 +4,27 @@ import android.content.Context
import android.os.Bundle
import android.util.Log
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.database_extension.*
import com.mattermost.helpers.push_notification.*
import kotlinx.coroutines.*
import com.mattermost.helpers.database_extension.getDatabaseForServer
import com.mattermost.helpers.database_extension.saveToDatabase
import com.mattermost.helpers.push_notification.addToDefaultCategoryIfNeeded
import com.mattermost.helpers.push_notification.fetchMyChannel
import com.mattermost.helpers.push_notification.fetchMyTeamCategories
import com.mattermost.helpers.push_notification.fetchNeededUsers
import com.mattermost.helpers.push_notification.fetchPosts
import com.mattermost.helpers.push_notification.fetchTeamIfNeeded
import com.mattermost.helpers.push_notification.fetchThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class PushNotificationDataHelper(private val context: Context) {
private var coroutineScope = CoroutineScope(Dispatchers.Default)
fun fetchAndStoreDataForPushNotification(initialData: Bundle, isReactInit: Boolean): Bundle? {
var result: Bundle? = null
val job = coroutineScope.launch(Dispatchers.Default) {
result = PushNotificationDataRunnable.start(context, initialData, isReactInit)
suspend fun fetchAndStoreDataForPushNotification(initialData: Bundle, isReactInit: Boolean): Bundle? {
return withContext(Dispatchers.Default) {
PushNotificationDataRunnable.start(context, initialData, isReactInit)
}
runBlocking {
job.join()
}
return result
}
}
@ -37,8 +35,8 @@ class PushNotificationDataRunnable {
private val mutex = Mutex()
suspend fun start(context: Context, initialData: Bundle, isReactInit: Boolean): Bundle? {
// for more info see: https://blog.danlew.net/2020/01/28/coroutines-and-java-synchronization-dont-mix/
mutex.withLock {
// for more info see: https://blog.danlew.net/2020/01/28/coroutines-and-java-synchronization-dont-mix/
val serverUrl: String = initialData.getString("server_url") ?: return null
val db = dbHelper.getDatabaseForServer(context, serverUrl)
var result: Bundle? = null
@ -50,8 +48,9 @@ class PushNotificationDataRunnable {
val postId = initialData.getString("post_id")
val rootId = initialData.getString("root_id")
val isCRTEnabled = initialData.getString("is_crt_enabled") == "true"
val ackId = initialData.getString("ack_id")
Log.i("ReactNative", "Start fetching notification data in server=$serverUrl for channel=$channelId")
Log.i("ReactNative", "Start fetching notification data in server=$serverUrl for channel=$channelId and ack=$ackId")
val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty()
val notificationData = Arguments.createMap()
@ -89,7 +88,7 @@ class PushNotificationDataRunnable {
getThreadList(notificationThread, postData?.getArray("threads"))?.let {
val threadsArray = Arguments.createArray()
for(item in it) {
for (item in it) {
threadsArray.pushMap(item)
}
notificationData.putArray("threads", threadsArray)
@ -105,7 +104,7 @@ class PushNotificationDataRunnable {
dbHelper.saveToDatabase(db, notificationData, teamId, channelId, receivingThreads)
}
Log.i("ReactNative", "Done processing push notification=$serverUrl for channel=$channelId")
Log.i("ReactNative", "Done processing push notification=$serverUrl for channel=$channelId and ack=$ackId")
}
} catch (e: Exception) {
e.printStackTrace()

View file

@ -1,6 +1,7 @@
package com.mattermost.helpers.database_extension
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.text.TextUtils
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableMap
@ -8,8 +9,7 @@ import com.mattermost.helpers.DatabaseHelper
import com.mattermost.helpers.QueryArgs
import com.mattermost.helpers.mapCursor
import com.nozbe.watermelondb.WMDatabase
import java.util.*
import kotlin.Exception
import java.util.Arrays
internal fun DatabaseHelper.saveToDatabase(db: WMDatabase, data: ReadableMap, teamId: String?, channelId: String?, receivingThreads: Boolean) {
db.transaction {
@ -57,7 +57,7 @@ fun DatabaseHelper.getDatabaseForServer(context: Context?, serverUrl: String): W
if (cursor.count == 1) {
cursor.moveToFirst()
val databasePath = String.format("file://%s", cursor.getString(0))
return WMDatabase.getInstance(databasePath, context!!)
return WMDatabase.buildDatabase(databasePath, context!!, SQLiteDatabase.CREATE_IF_NECESSARY)
}
}
} catch (e: Exception) {
@ -73,7 +73,7 @@ fun DatabaseHelper.getDeviceToken(): String? {
defaultDatabase!!.rawQuery(query, arrayOf("deviceToken")).use { cursor ->
if (cursor.count == 1) {
cursor.moveToFirst()
return cursor.getString(0);
return cursor.getString(0)
}
}
} catch (e: Exception) {

View file

@ -1,183 +0,0 @@
package com.mattermost.rnbeta;
import android.app.Notification;
import android.app.PendingIntent;
import android.content.Context;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import java.util.Objects;
import com.facebook.react.bridge.ReadableMap;
import com.mattermost.helpers.CustomPushNotificationHelper;
import com.mattermost.helpers.DatabaseHelper;
import com.mattermost.helpers.Network;
import com.mattermost.helpers.NotificationHelper;
import com.mattermost.helpers.PushNotificationDataHelper;
import com.mattermost.helpers.ReadableMapUtils;
import com.mattermost.share.ShareModule;
import com.wix.reactnativenotifications.core.NotificationIntentAdapter;
import com.wix.reactnativenotifications.core.notification.PushNotification;
import com.wix.reactnativenotifications.core.AppLaunchHelper;
import com.wix.reactnativenotifications.core.AppLifecycleFacade;
import com.wix.reactnativenotifications.core.JsIOHelper;
import static com.mattermost.helpers.database_extension.GeneralKt.*;
import static com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME;
public class CustomPushNotification extends PushNotification {
private final PushNotificationDataHelper dataHelper;
public CustomPushNotification(Context context, Bundle bundle, AppLifecycleFacade appLifecycleFacade, AppLaunchHelper appLaunchHelper, JsIOHelper jsIoHelper) {
super(context, bundle, appLifecycleFacade, appLaunchHelper, jsIoHelper);
dataHelper = new PushNotificationDataHelper(context);
try {
Objects.requireNonNull(DatabaseHelper.Companion.getInstance()).init(context);
Network.init(context);
NotificationHelper.cleanNotificationPreferencesIfNeeded(context);
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void onReceived() {
final Bundle initialData = mNotificationProps.asBundle();
final String type = initialData.getString("type");
final String ackId = initialData.getString("ack_id");
final String postId = initialData.getString("post_id");
final String channelId = initialData.getString("channel_id");
final String signature = initialData.getString("signature");
final boolean isIdLoaded = initialData.getString("id_loaded") != null && initialData.getString("id_loaded").equals("true");
int notificationId = NotificationHelper.getNotificationId(initialData);
String serverUrl = addServerUrlToBundle(initialData);
if (ackId != null && serverUrl != null) {
Bundle response = ReceiptDelivery.send(ackId, serverUrl, postId, type, isIdLoaded);
if (isIdLoaded && response != null) {
Bundle current = mNotificationProps.asBundle();
if (!current.containsKey("server_url")) {
response.putString("server_url", serverUrl);
}
current.putAll(response);
mNotificationProps = createProps(current);
}
}
if (!CustomPushNotificationHelper.verifySignature(mContext, signature, serverUrl, ackId)) {
Log.i("Mattermost Notifications Signature verification", "Notification skipped because we could not verify it.");
return;
}
finishProcessingNotification(serverUrl, type, channelId, notificationId);
}
@Override
public void onOpened() {
if (mNotificationProps != null) {
digestNotification();
Bundle data = mNotificationProps.asBundle();
NotificationHelper.clearChannelOrThreadNotifications(mContext, data);
}
}
private void finishProcessingNotification(final String serverUrl, @NonNull final String type, final String channelId, final int notificationId) {
final boolean isReactInit = mAppLifecycleFacade.isReactInitialized();
switch (type) {
case CustomPushNotificationHelper.PUSH_TYPE_MESSAGE:
case CustomPushNotificationHelper.PUSH_TYPE_SESSION:
ShareModule shareModule = ShareModule.getInstance();
String currentActivityName = shareModule != null ? shareModule.getCurrentActivityName() : "";
Log.i("ReactNative", currentActivityName);
if (!mAppLifecycleFacade.isAppVisible() || !currentActivityName.equals("MainActivity")) {
boolean createSummary = type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE);
if (type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE)) {
if (channelId != null) {
Bundle notificationBundle = mNotificationProps.asBundle();
if (serverUrl != null) {
// We will only fetch the data related to the notification on the native side
// as updating the data directly to the db removes the wal & shm files needed
// by watermelonDB, if the DB is updated while WDB is running it causes WDB to
// detect the database as malformed, thus the app stop working and a restart is required.
// Data will be fetch from within the JS context instead.
Bundle notificationResult = dataHelper.fetchAndStoreDataForPushNotification(notificationBundle, isReactInit);
if (notificationResult != null) {
notificationBundle.putBundle("data", notificationResult);
mNotificationProps = createProps(notificationBundle);
}
}
createSummary = NotificationHelper.addNotificationToPreferences(
mContext,
notificationId,
notificationBundle
);
}
}
buildNotification(notificationId, createSummary);
}
break;
case CustomPushNotificationHelper.PUSH_TYPE_CLEAR:
NotificationHelper.clearChannelOrThreadNotifications(mContext, mNotificationProps.asBundle());
break;
}
if (isReactInit) {
notifyReceivedToJS();
}
}
private void buildNotification(Integer notificationId, boolean createSummary) {
final PendingIntent pendingIntent = NotificationIntentAdapter.createPendingNotificationIntent(mContext, mNotificationProps);
final Notification notification = buildNotification(pendingIntent);
if (createSummary) {
final Notification summary = getNotificationSummaryBuilder(pendingIntent).build();
super.postNotification(summary, notificationId + 1);
}
super.postNotification(notification, notificationId);
}
@Override
protected NotificationCompat.Builder getNotificationBuilder(PendingIntent intent) {
Bundle bundle = mNotificationProps.asBundle();
return CustomPushNotificationHelper.createNotificationBuilder(mContext, intent, bundle, false);
}
protected NotificationCompat.Builder getNotificationSummaryBuilder(PendingIntent intent) {
Bundle bundle = mNotificationProps.asBundle();
return CustomPushNotificationHelper.createNotificationBuilder(mContext, intent, bundle, true);
}
private void notifyReceivedToJS() {
mJsIOHelper.sendEventToJS(NOTIFICATION_RECEIVED_EVENT_NAME, mNotificationProps.asBundle(), mAppLifecycleFacade.getRunningReactContext());
}
private String addServerUrlToBundle(Bundle bundle) {
DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance();
String serverId = bundle.getString("server_id");
String serverUrl = null;
if (dbHelper != null) {
if (serverId == null) {
serverUrl = dbHelper.getOnlyServerUrl();
} else {
serverUrl = getServerUrlForIdentifier(dbHelper, serverId);
}
if (!TextUtils.isEmpty(serverUrl)) {
bundle.putString("server_url", serverUrl);
mNotificationProps = createProps(bundle);
}
}
return serverUrl;
}
}

View file

@ -0,0 +1,179 @@
package com.mattermost.rnbeta
import android.app.PendingIntent
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.core.app.NotificationCompat
import com.mattermost.helpers.CustomPushNotificationHelper
import com.mattermost.helpers.DatabaseHelper
import com.mattermost.helpers.Network
import com.mattermost.helpers.NotificationHelper
import com.mattermost.helpers.PushNotificationDataHelper
import com.mattermost.helpers.database_extension.getServerUrlForIdentifier
import com.mattermost.share.ShareModule
import com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME
import com.wix.reactnativenotifications.core.AppLaunchHelper
import com.wix.reactnativenotifications.core.AppLifecycleFacade
import com.wix.reactnativenotifications.core.JsIOHelper
import com.wix.reactnativenotifications.core.NotificationIntentAdapter
import com.wix.reactnativenotifications.core.notification.PushNotification
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class CustomPushNotification(
context: Context,
bundle: Bundle,
appLifecycleFacade: AppLifecycleFacade,
appLaunchHelper: AppLaunchHelper,
jsIoHelper: JsIOHelper
) : PushNotification(context, bundle, appLifecycleFacade, appLaunchHelper, jsIoHelper) {
private val dataHelper = PushNotificationDataHelper(context)
init {
try {
DatabaseHelper.instance?.init(context)
Network.init(context)
NotificationHelper.cleanNotificationPreferencesIfNeeded(context)
} catch (e: Exception) {
e.printStackTrace()
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onReceived() {
val initialData = mNotificationProps.asBundle()
val type = initialData.getString("type")
val ackId = initialData.getString("ack_id")
val postId = initialData.getString("post_id")
val channelId = initialData.getString("channel_id")
val signature = initialData.getString("signature")
val isIdLoaded = initialData.getString("id_loaded") == "true"
val notificationId = NotificationHelper.getNotificationId(initialData)
val serverUrl = addServerUrlToBundle(initialData)
GlobalScope.launch {
try {
handlePushNotificationInCoroutine(serverUrl, type, channelId, ackId, isIdLoaded, notificationId, postId, signature)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private suspend fun handlePushNotificationInCoroutine(
serverUrl: String?,
type: String?,
channelId: String?,
ackId: String?,
isIdLoaded: Boolean,
notificationId: Int,
postId: String?,
signature: String?
) {
if (ackId != null && serverUrl != null) {
val response = ReceiptDelivery.send(ackId, serverUrl, postId, type, isIdLoaded)
if (isIdLoaded && response != null) {
val current = mNotificationProps.asBundle()
if (!current.containsKey("server_url")) {
response.putString("server_url", serverUrl)
}
current.putAll(response)
mNotificationProps = createProps(current)
}
}
if (!CustomPushNotificationHelper.verifySignature(mContext, signature, serverUrl, ackId)) {
Log.i("Mattermost Notifications Signature verification", "Notification skipped because we could not verify it.")
return
}
finishProcessingNotification(serverUrl, type, channelId, notificationId)
}
override fun onOpened() {
mNotificationProps?.let {
digestNotification()
NotificationHelper.clearChannelOrThreadNotifications(mContext, it.asBundle())
}
}
private suspend fun finishProcessingNotification(serverUrl: String?, type: String?, channelId: String?, notificationId: Int) {
val isReactInit = mAppLifecycleFacade.isReactInitialized()
when (type) {
CustomPushNotificationHelper.PUSH_TYPE_MESSAGE, CustomPushNotificationHelper.PUSH_TYPE_SESSION -> {
val shareModule = ShareModule.getInstance()
val currentActivityName = shareModule?.currentActivityName ?: ""
Log.i("ReactNative", currentActivityName)
if (!mAppLifecycleFacade.isAppVisible() || currentActivityName != "MainActivity") {
var createSummary = type == CustomPushNotificationHelper.PUSH_TYPE_MESSAGE
if (type == CustomPushNotificationHelper.PUSH_TYPE_MESSAGE) {
channelId?.let {
val notificationBundle = mNotificationProps.asBundle()
serverUrl?.let {
val notificationResult = dataHelper.fetchAndStoreDataForPushNotification(notificationBundle, isReactInit)
notificationResult?.let { result ->
notificationBundle.putBundle("data", result)
mNotificationProps = createProps(notificationBundle)
}
}
createSummary = NotificationHelper.addNotificationToPreferences(mContext, notificationId, notificationBundle)
}
}
buildNotification(notificationId, createSummary)
}
}
CustomPushNotificationHelper.PUSH_TYPE_CLEAR -> NotificationHelper.clearChannelOrThreadNotifications(mContext, mNotificationProps.asBundle())
}
if (isReactInit) {
notifyReceivedToJS()
}
}
private fun buildNotification(notificationId: Int, createSummary: Boolean) {
val pendingIntent = NotificationIntentAdapter.createPendingNotificationIntent(mContext, mNotificationProps)
val notification = buildNotification(pendingIntent)
if (createSummary) {
val summary = getNotificationSummaryBuilder(pendingIntent).build()
super.postNotification(summary, notificationId + 1)
}
super.postNotification(notification, notificationId)
}
override fun getNotificationBuilder(intent: PendingIntent): NotificationCompat.Builder {
val bundle = mNotificationProps.asBundle()
return CustomPushNotificationHelper.createNotificationBuilder(mContext, intent, bundle, false)
}
private fun getNotificationSummaryBuilder(intent: PendingIntent): NotificationCompat.Builder {
val bundle = mNotificationProps.asBundle()
return CustomPushNotificationHelper.createNotificationBuilder(mContext, intent, bundle, true)
}
private fun notifyReceivedToJS() {
mJsIOHelper.sendEventToJS(NOTIFICATION_RECEIVED_EVENT_NAME, mNotificationProps.asBundle(), mAppLifecycleFacade.runningReactContext)
}
private fun addServerUrlToBundle(bundle: Bundle): String? {
val dbHelper = DatabaseHelper.instance
val serverId = bundle.getString("server_id")
var serverUrl: String? = null
dbHelper?.let {
serverUrl = if (serverId == null) {
it.onlyServerUrl
} else {
it.getServerUrlForIdentifier(serverId)
}
if (!serverUrl.isNullOrEmpty()) {
bundle.putString("server_url", serverUrl)
mNotificationProps = createProps(bundle)
}
}
return serverUrl
}
}

View file

@ -173,10 +173,10 @@ class MainApplication : NavigationApplication(), INotificationsApplication {
defaultAppLaunchHelper: AppLaunchHelper?
): IPushNotification {
return CustomPushNotification(
context,
bundle,
defaultFacade,
defaultAppLaunchHelper,
context!!,
bundle!!,
defaultFacade!!,
defaultAppLaunchHelper!!,
JsIOHelper()
)
}

View file

@ -36,6 +36,7 @@ import {
handleCallUserVoiceOn,
handleHostLowerHand,
handleHostMute,
handleHostRemoved,
handleUserDismissedNotification,
} from '@calls/connection/websocket_event_handlers';
import {isSupportedServerCalls} from '@calls/utils';
@ -461,6 +462,9 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) {
case WebsocketEvents.CALLS_HOST_LOWER_HAND:
handleHostLowerHand(serverUrl, msg);
break;
case WebsocketEvents.CALLS_HOST_REMOVED:
handleHostRemoved(serverUrl, msg);
break;
case WebsocketEvents.GROUP_RECEIVED:
handleGroupReceivedEvent(serverUrl, msg);

View file

@ -278,7 +278,7 @@ const AtMention = ({
const filteredUsers = filterResults(fallbackUsers, term);
setFilteredLocalUsers(filteredUsers.length ? filteredUsers : emptyUserlList);
} else if (receivedUsers) {
sortRecievedUsers(sUrl, term, receivedUsers?.users, receivedUsers?.out_of_channel);
await sortRecievedUsers(sUrl, term, receivedUsers?.users, receivedUsers?.out_of_channel);
}
setLoading(false);
@ -298,8 +298,14 @@ const AtMention = ({
if (outOfChannel?.length) {
const outChannelMemberIds = outOfChannel.map((e) => e.id);
// This only get us the users we have on the database.
// We need to append those users from which we don't have
// information at the end of the list.
const outSortedMembers = await getUsersFromDMSorted(database, outChannelMemberIds);
sortedMembers.push(...outSortedMembers);
const idSet = new Set(outSortedMembers.map((v) => v.id));
const outRest = outOfChannel.filter((v) => !idSet.has(v.id));
sortedMembers.push(...outSortedMembers, ...outRest);
}
if (hasTrailingSpaces(term)) {

View file

@ -13,6 +13,7 @@ import {ACCOUNT_OUTLINE_IMAGE} from '@constants/profile';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {getLastPictureUpdate} from '@utils/user';
import type UserModel from '@typings/database/models/servers/user';
@ -42,6 +43,7 @@ const Image = ({author, forwardRef, iconSize, size, source, url}: Props) => {
serverUrl = url || serverUrl;
const style = getStyleSheet(theme);
const lastPictureUpdateAt = author ? getLastPictureUpdate(author) : 0;
const fIStyle = useMemo(() => ({
borderRadius: size / 2,
backgroundColor: theme.centerChannelBg,
@ -56,7 +58,7 @@ const Image = ({author, forwardRef, iconSize, size, source, url}: Props) => {
const pictureUrl = buildProfileImageUrlFromUser(serverUrl, author);
return source ?? {uri: buildAbsoluteUrl(serverUrl, pictureUrl)};
}, [author, serverUrl, source]);
}, [author, serverUrl, source, lastPictureUpdateAt]);
if (typeof source === 'string') {
return (

View file

@ -90,6 +90,7 @@ const WebsocketEvents = {
CALLS_CAPTION: `custom_${Calls.PluginId}_caption`,
CALLS_HOST_MUTE: `custom_${Calls.PluginId}_host_mute`,
CALLS_HOST_LOWER_HAND: `custom_${Calls.PluginId}_host_lower_hand`,
CALLS_HOST_REMOVED: `custom_${Calls.PluginId}_host_removed`,
GROUP_RECEIVED: 'received_group',
GROUP_MEMBER_ADD: 'group_member_add',

View file

@ -642,6 +642,17 @@ export const hostMuteSession = async (serverUrl: string, callId: string, session
}
};
export const hostMuteOthers = async (serverUrl: string, callId: string) => {
try {
const client = NetworkManager.getClient(serverUrl);
return client.hostMuteOthers(callId);
} catch (error) {
logDebug('error on hostMuteOthers', getFullErrorMessage(error));
await forceLogoutIfNecessary(serverUrl, error);
return error;
}
};
export const hostStopScreenshare = async (serverUrl: string, callId: string, sessionId: string) => {
try {
const client = NetworkManager.getClient(serverUrl);
@ -664,3 +675,13 @@ export const hostLowerHand = async (serverUrl: string, callId: string, sessionId
}
};
export const hostRemove = async (serverUrl: string, callId: string, sessionId: string) => {
try {
const client = NetworkManager.getClient(serverUrl);
return client.hostRemove(callId, sessionId);
} catch (error) {
logDebug('error on hostRemove', getFullErrorMessage(error));
await forceLogoutIfNecessary(serverUrl, error);
return error;
}
};

View file

@ -4,9 +4,9 @@
import {Alert} from 'react-native';
import {hasMicrophonePermission, joinCall, leaveCall, unmuteMyself} from '@calls/actions';
import {dismissIncomingCall} from '@calls/actions/calls';
import {dismissIncomingCall, hostRemove} from '@calls/actions/calls';
import {hasBluetoothPermission} from '@calls/actions/permissions';
import {userLeftChannelErr, userRemovedFromChannelErr} from '@calls/errors';
import {hostRemovedErr, userLeftChannelErr, userRemovedFromChannelErr} from '@calls/errors';
import {
getCallsConfig,
getCallsState,
@ -19,6 +19,7 @@ import {errorAlert} from '@calls/utils';
import DatabaseManager from '@database/manager';
import {getChannelById} from '@queries/servers/channel';
import {getCurrentUser} from '@queries/servers/user';
import {dismissBottomSheet} from '@screens/navigation';
import {isDMorGM} from '@utils/channel';
import {getFullErrorMessage} from '@utils/errors';
import {logError} from '@utils/log';
@ -413,8 +414,55 @@ export const showErrorAlertOnClose = (err: Error, intl: IntlShape) => {
}),
);
break;
case hostRemovedErr:
Alert.alert(
intl.formatMessage({
id: 'mobile.calls_removed_alert_title',
defaultMessage: 'You were removed from the call',
}),
intl.formatMessage({
id: 'mobile.calls_removed_alert_body',
defaultMessage: 'The host removed you from the call.',
}),
[{
text: intl.formatMessage({
id: 'mobile.calls_dismiss',
defaultMessage: 'Dismiss',
}),
}]);
break;
default:
// Fallback with generic error
errorAlert(getFullErrorMessage(err, intl), intl);
}
};
export const removeFromCall = (serverUrl: string, displayName: string, callId: string, sessionId: string, intl: IntlShape) => {
const {formatMessage} = intl;
const title = formatMessage({
id: 'mobile.calls_remove_alert_title',
defaultMessage: 'Remove participant',
});
const body = formatMessage({
id: 'mobile.calls_remove_alert_body',
defaultMessage: 'Are you sure you want to remove {displayName} from the call? ',
}, {displayName});
Alert.alert(title, body, [{
text: formatMessage({
id: 'mobile.post.cancel',
defaultMessage: 'Cancel',
}),
}, {
text: formatMessage({
id: 'mobile.calls_remove',
defaultMessage: 'Remove',
}),
onPress: () => {
hostRemove(serverUrl, callId, sessionId);
dismissBottomSheet();
},
style: 'destructive',
}]);
};

View file

@ -19,8 +19,10 @@ export interface ClientCallsMix {
dismissCall: (channelId: string) => Promise<ApiResp>;
hostMake: (callId: string, newHostId: string) => Promise<ApiResp>;
hostMute: (callId: string, sessionId: string) => Promise<ApiResp>;
hostMuteOthers: (callId: string) => Promise<ApiResp>;
hostScreenOff: (callId: string, sessionId: string) => Promise<ApiResp>;
hostLowerHand: (callId: string, sessionId: string) => Promise<ApiResp>;
hostRemove: (callId: string, sessionId: string) => Promise<ApiResp>;
}
const ClientCalls = (superclass: any) => class extends superclass {
@ -130,6 +132,13 @@ const ClientCalls = (superclass: any) => class extends superclass {
);
};
hostMuteOthers = async (callId: string) => {
return this.doFetch(
`${this.getCallsRoute()}/calls/${callId}/host/mute-others`,
{method: 'post'},
);
};
hostScreenOff = async (callId: string, sessionId: string) => {
return this.doFetch(
`${this.getCallsRoute()}/calls/${callId}/host/screen-off`,
@ -149,6 +158,16 @@ const ClientCalls = (superclass: any) => class extends superclass {
},
);
};
hostRemove = async (callId: string, sessionId: string) => {
return this.doFetch(
`${this.getCallsRoute()}/calls/${callId}/host/remove`,
{
method: 'post',
body: {session_id: sessionId},
},
);
};
};
export default ClientCalls;

View file

@ -4,7 +4,8 @@
import {DeviceEventEmitter} from 'react-native';
import {fetchUsersByIds} from '@actions/remote/user';
import {muteMyself, unraiseHand} from '@calls/actions';
import {leaveCall, muteMyself, unraiseHand} from '@calls/actions';
import {hostRemovedErr} from '@calls/errors';
import {
callEnded,
callStarted,
@ -228,3 +229,14 @@ export const handleHostLowerHand = async (serverUrl: string, msg: WebSocketMessa
unraiseHand();
};
export const handleHostRemoved = async (serverUrl: string, msg: WebSocketMessage<HostControlsMsgData>) => {
const currentCall = getCurrentCall();
if (currentCall?.serverUrl !== serverUrl ||
currentCall?.channelId !== msg.data.channel_id ||
currentCall?.mySessionId !== msg.data.session_id) {
return;
}
leaveCall(hostRemovedErr);
};

View file

@ -3,3 +3,4 @@
export const userRemovedFromChannelErr = new Error('user was removed from channel');
export const userLeftChannelErr = new Error('user has left channel');
export const hostRemovedErr = new Error('user was removed by host');

View file

@ -3,21 +3,28 @@
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {StyleSheet} from 'react-native';
import {Platform, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {hostLowerHand, hostMake, hostMuteSession, hostStopScreenshare} from '@calls/actions/calls';
import {removeFromCall} from '@calls/alerts';
import {useHostMenus} from '@calls/hooks';
import SlideUpPanelItem from '@components/slide_up_panel_item';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import BottomSheet from '@screens/bottom_sheet';
import {dismissBottomSheet} from '@screens/navigation';
import UserProfileTitle from '@screens/user_profile/title';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {displayUsername} from '@utils/user';
import type {CallSession, CurrentCall} from '@calls/types/calls';
const TITLE_HEIGHT = 118;
const ITEM_HEIGHT = 48;
const SEPARATOR_HEIGHT = 17;
const ANDROID_BUMP_HEIGHT = 20;
type Props = {
currentCall: CurrentCall;
@ -27,11 +34,18 @@ type Props = {
hideGuestTags: boolean;
}
const styles = StyleSheet.create({
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => ({
iconStyle: {
marginRight: 6,
},
});
separator: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
height: 1,
width: '100%',
alignSelf: 'center',
marginVertical: 8,
},
}));
export const HostControls = ({
currentCall,
@ -41,7 +55,10 @@ export const HostControls = ({
hideGuestTags,
}: Props) => {
const intl = useIntl();
const theme = useTheme();
const {bottom} = useSafeAreaInsets();
const {openUserProfile} = useHostMenus();
const styles = getStyleFromTheme(theme);
const sharingScreen = currentCall.screenOn === session.sessionId;
@ -63,20 +80,35 @@ export const HostControls = ({
const stopScreensharePress = useCallback(async () => {
hostStopScreenshare(currentCall.serverUrl, currentCall.channelId, session.sessionId);
await dismissBottomSheet();
}, [currentCall.serverUrl, currentCall.id, session.sessionId]);
}, [currentCall.serverUrl, currentCall.channelId, session.sessionId]);
const profilePress = useCallback(async () => {
await dismissBottomSheet();
openUserProfile(session);
}, [session]);
const removePress = useCallback(async () => {
const displayName = displayUsername(session.userModel, intl.locale, teammateNameDisplay, true);
removeFromCall(currentCall.serverUrl, displayName, currentCall.channelId, session.sessionId, intl);
}, [session.userModel, intl, teammateNameDisplay, currentCall.serverUrl, currentCall.channelId, session.sessionId]);
const snapPoints = useMemo(() => {
const items = 1 + (session.muted ? 0 : 1) + (sharingScreen ? 1 : 0) + (session.raisedHand ? 1 : 0);
const items = 3 + (session.muted ? 0 : 1) + (sharingScreen ? 1 : 0) + (session.raisedHand ? 1 : 0);
return [
1,
bottomSheetSnapPoint(items, ITEM_HEIGHT, bottom) + TITLE_HEIGHT,
bottomSheetSnapPoint(items, ITEM_HEIGHT, bottom) + TITLE_HEIGHT + SEPARATOR_HEIGHT + (Platform.OS === 'android' ? ANDROID_BUMP_HEIGHT : 0),
];
}, [bottom, session.muted, sharingScreen, session.raisedHand]);
const makeHostText = intl.formatMessage({id: 'mobile.calls_make_host', defaultMessage: 'Make host'});
const muteText = intl.formatMessage({id: 'mobile.calls_mute_participant', defaultMessage: 'Mute participant'});
const lowerHandText = intl.formatMessage({id: 'mobile.calls_lower_hand', defaultMessage: 'Lower hand'});
const stopScreenshareText = intl.formatMessage({id: 'mobile.calls_stop_screenshare', defaultMessage: 'Stop screen share'});
const stopScreenshareText = intl.formatMessage({
id: 'mobile.calls_stop_screenshare',
defaultMessage: 'Stop screen share',
});
const profileText = intl.formatMessage({id: 'mobile.calls_view_profile', defaultMessage: 'View profile'});
const removeText = intl.formatMessage({id: 'mobile.calls_remove_participant', defaultMessage: 'Remove from call'});
const renderContent = () => {
if (!session?.userModel) {
@ -125,6 +157,20 @@ export const HostControls = ({
onPress={makeHostPress}
text={makeHostText}
/>
<SlideUpPanelItem
leftIcon={'account-outline'}
leftIconStyles={styles.iconStyle}
onPress={profilePress}
text={profileText}
/>
<View style={styles.separator}/>
<SlideUpPanelItem
leftIcon={'minus-circle-outline'}
leftIconStyles={styles.iconStyle}
onPress={removePress}
text={removeText}
destructive={true}
/>
</>
);
};

View file

@ -7,6 +7,7 @@ import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {observeCallDatabase, observeCurrentSessionsDict} from '@calls/observers';
import {ParticipantsList} from '@calls/screens/participants_list/participants_list';
import {observeCurrentCall} from '@calls/state';
import {observeTeammateNameDisplay} from '@queries/servers/user';
const enhanced = withObservables([], () => {
@ -14,10 +15,22 @@ const enhanced = withObservables([], () => {
switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))),
distinctUntilChanged(),
);
const callServerUrl = observeCurrentCall().pipe(
switchMap((call) => of$(call?.serverUrl)),
);
const callChannelId = observeCurrentCall().pipe(
switchMap((call) => of$(call?.channelId)),
);
const callUserId = observeCurrentCall().pipe(
switchMap((call) => of$(call?.myUserId)),
);
return {
sessionsDict: observeCurrentSessionsDict(),
teammateNameDisplay,
callServerUrl,
callChannelId,
callUserId,
};
});

View file

@ -4,14 +4,16 @@
import {BottomSheetFlatList} from '@gorhom/bottom-sheet';
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {type ListRenderItemInfo, useWindowDimensions, View} from 'react-native';
import {type ListRenderItemInfo, Text, TouchableOpacity, useWindowDimensions, View} from 'react-native';
import {FlatList} from 'react-native-gesture-handler';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {useHostMenus} from '@calls/hooks';
import {hostMuteOthers} from '@calls/actions/calls';
import {useHostControlsAvailable, useHostMenus} from '@calls/hooks';
import {Participant} from '@calls/screens/participants_list/participant';
import Pill from '@calls/screens/participants_list/pill';
import {sortSessions} from '@calls/utils';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
@ -31,11 +33,15 @@ type Props = {
closeButtonId: string;
sessionsDict: Dictionary<CallSession>;
teammateNameDisplay: string;
callServerUrl?: string;
callChannelId?: string;
callUserId?: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
header: {
paddingBottom: 12,
paddingRight: 2,
flexDirection: 'row',
gap: 8,
alignItems: 'center',
@ -44,12 +50,35 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
color: theme.centerChannelColor,
...typography('Heading', 600, 'SemiBold'),
},
muteButton: {
flexDirection: 'row',
padding: 6,
gap: 6,
alignItems: 'center',
marginLeft: 'auto',
},
muteIcon: {
color: theme.buttonBg,
},
muteText: {
color: theme.buttonBg,
...typography('Body', 100, 'SemiBold'),
marginTop: -2,
},
}));
export const ParticipantsList = ({closeButtonId, sessionsDict, teammateNameDisplay}: Props) => {
export const ParticipantsList = ({
closeButtonId,
sessionsDict,
teammateNameDisplay,
callServerUrl,
callChannelId,
callUserId,
}: Props) => {
const intl = useIntl();
const theme = useTheme();
const {onPress} = useHostMenus();
const hostControlsAvailable = useHostControlsAvailable();
const {bottom} = useSafeAreaInsets();
const {height} = useWindowDimensions();
const isTablet = useIsTablet();
@ -63,6 +92,14 @@ export const ParticipantsList = ({closeButtonId, sessionsDict, teammateNameDispl
if (sessions.length > MIN_ROWS && snapPoint1 < snapPoint2) {
snapPoints.push(snapPoint2);
}
const otherUnMuted = useMemo(() => sessions.some((s) => !s.muted && s.userId !== callUserId), [sessions, callUserId]);
const muteOthersPress = useCallback(async () => {
if (!callServerUrl || !callChannelId) {
return;
}
hostMuteOthers(callServerUrl, callChannelId);
}, [callServerUrl, callChannelId]);
const renderItem = useCallback(({item}: ListRenderItemInfo<CallSession>) => (
<Participant
@ -83,6 +120,21 @@ export const ParticipantsList = ({closeButtonId, sessionsDict, teammateNameDispl
defaultMessage={'Participants'}
/>
<Pill text={sessions.length}/>
{hostControlsAvailable && otherUnMuted &&
<TouchableOpacity
style={styles.muteButton}
onPress={muteOthersPress}
>
<CompassIcon
size={16}
name={'microphone-off'}
style={styles.muteIcon}
/>
<Text style={styles.muteText}>
{intl.formatMessage({id: 'mobile.calls_mute_others', defaultMessage: 'Mute others'})}
</Text>
</TouchableOpacity>
}
</View>
<List
data={sessions}

View file

@ -82,7 +82,7 @@ const EditProfilePicture = ({user, onUpdateProfilePicture}: ChangeProfilePicture
let prefix = '';
if (pictureUrl.includes('/api/')) {
prefix = serverUrl;
} else if (Platform.OS === 'android' && !pictureUrl.startsWith('content://') && !pictureUrl.startsWith('http://') && !pictureUrl.startsWith('https://')) {
} else if (Platform.OS === 'android' && !pictureUrl.startsWith('content://') && !pictureUrl.startsWith('http://') && !pictureUrl.startsWith('https://') && !pictureUrl.startsWith('file://')) {
prefix = 'file://';
}

View file

@ -486,6 +486,7 @@
"mobile.calls_mic_error": "To participate, open Settings to grant Mattermost access to your microphone.",
"mobile.calls_more": "More",
"mobile.calls_mute": "Mute",
"mobile.calls_mute_others": "Mute others",
"mobile.calls_mute_participant": "Mute participant",
"mobile.calls_name_is_talking_postfix": "is talking...",
"mobile.calls_name_started_call": "{name} started a call",
@ -515,6 +516,12 @@
"mobile.calls_recording_start_no_permissions": "You don't have permissions to start a recording. Please ask the call host to start a recording.",
"mobile.calls_recording_stop_no_permissions": "You don't have permissions to stop the recording. Please ask the call host to stop the recording.",
"mobile.calls_recording_stop_none_in_progress": "No recording is in progress.",
"mobile.calls_remove": "Remove",
"mobile.calls_remove_alert_body": "Are you sure you want to remove {displayName} from the call? ",
"mobile.calls_remove_alert_title": "Remove participant",
"mobile.calls_remove_participant": "Remove from call",
"mobile.calls_removed_alert_body": "The host removed you from the call.",
"mobile.calls_removed_alert_title": "You were removed from the call",
"mobile.calls_request_message": "Calls are currently running in test mode and only system admins can start them. Reach out directly to your system admin for assistance",
"mobile.calls_request_title": "Calls is not currently enabled",
"mobile.calls_see_logs": "See server logs",
@ -531,6 +538,7 @@
"mobile.calls_user_left_channel_error_title": "You left the channel",
"mobile.calls_user_removed_from_channel_error_message": "You have been removed from the channel, and have been disconnected from the call.",
"mobile.calls_user_removed_from_channel_error_title": "You were removed from channel",
"mobile.calls_view_profile": "View profile",
"mobile.calls_viewing_screen": "You are viewing {name}'s screen",
"mobile.calls_you": "(you)",
"mobile.calls_you_2": "You",

View file

@ -1,4 +1,4 @@
Requires Mattermost Server v8.1.0+. Older servers may not be able to connect or have unexpected behavior.
Requires Mattermost Server v9.5.0+. Older servers may not be able to connect or have unexpected behavior.
-------

View file

@ -1,4 +1,4 @@
This version is compatible with Mattermost servers v8.1.0+.
This version is compatible with Mattermost servers v9.5.0+.
Please see [changelog](https://docs.mattermost.com/administration/mobile-changelog.html) for full release notes. If you're interested in helping beta test upcoming versions before they are released, please see our [documentation](https://github.com/mattermost/mattermost-mobile#testing).

View file

@ -1945,7 +1945,7 @@
CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 520;
CURRENT_PROJECT_VERSION = 529;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
ENABLE_BITCODE = NO;
HEADER_SEARCH_PATHS = (
@ -1989,7 +1989,7 @@
CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 520;
CURRENT_PROJECT_VERSION = 529;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
ENABLE_BITCODE = NO;
HEADER_SEARCH_PATHS = (
@ -2132,7 +2132,7 @@
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 520;
CURRENT_PROJECT_VERSION = 529;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
GCC_C_LANGUAGE_STANDARD = gnu11;
@ -2181,7 +2181,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 520;
CURRENT_PROJECT_VERSION = 529;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
GCC_C_LANGUAGE_STANDARD = gnu11;

View file

@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.16.0</string>
<string>2.17.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@ -37,7 +37,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>520</string>
<string>529</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>

View file

@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2.16.0</string>
<string>2.17.1</string>
<key>CFBundleVersion</key>
<string>520</string>
<string>529</string>
<key>UIAppFonts</key>
<array>
<string>OpenSans-Bold.ttf</string>

View file

@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2.16.0</string>
<string>2.17.1</string>
<key>CFBundleVersion</key>
<string>520</string>
<string>529</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "mattermost-mobile",
"version": "2.16.0",
"version": "2.17.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mattermost-mobile",
"version": "2.16.0",
"version": "2.17.0",
"hasInstallScript": true,
"license": "Apache 2.0",
"dependencies": {

View file

@ -1,6 +1,6 @@
{
"name": "mattermost-mobile",
"version": "2.16.0",
"version": "2.17.1",
"description": "Mattermost Mobile with React Native",
"repository": "git@github.com:mattermost/mattermost-mobile.git",
"author": "Mattermost, Inc.",

View file

@ -370,7 +370,7 @@ index 027c366..5807e79 100644
unsafeExecute(work: UnsafeExecuteOperations, callback: ResultCallback<void>): void
diff --git a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java b/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java
index 2f170e0..bac0352 100644
index 2f170e0..bd87f92 100644
--- a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java
+++ b/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java
@@ -11,6 +11,8 @@ import java.util.Arrays;
@ -414,17 +414,19 @@ index 2f170e0..bac0352 100644
} else {
// On some systems there is some kind of lock on `/databases` folder ¯\_(ツ)_/¯
path = context.getDatabasePath("" + name + ".db").getPath().replace("/databases", "");
@@ -172,6 +190,10 @@ public class WMDatabase {
@@ -172,7 +190,11 @@ public class WMDatabase {
});
}
- interface TransactionFunction {
+ public void unsafeVacuum() {
+ execute("vacuum");
+ }
+
interface TransactionFunction {
+ public interface TransactionFunction {
void applyTransactionFunction();
}
diff --git a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseBridge.java b/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseBridge.java
index 117b2bc..57e4abb 100644
--- a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseBridge.java