mirror of
https://github.com/vector-im/element-android.git
synced 2024-12-02 16:06:40 +08:00
Merge pull request #1658 from vector-im/feature/3pid_invite
3pid invite
This commit is contained in:
commit
eedf545409
@ -12,6 +12,8 @@ Improvements 🙌:
|
||||
- Setup server recovery banner (#1648)
|
||||
- Set up SSSS from security settings (#1567)
|
||||
- New lab setting to add 'unread notifications' tab to main screen
|
||||
- Render third party invite event (#548)
|
||||
- Display three pid invites in the room members list (#548)
|
||||
|
||||
Bugfix 🐛:
|
||||
- Integration Manager: Wrong URL to review terms if URL in config contains path (#1606)
|
||||
@ -27,7 +29,7 @@ Translations 🗣:
|
||||
-
|
||||
|
||||
SDK API changes ⚠️:
|
||||
-
|
||||
- CreateRoomParams has been updated
|
||||
|
||||
Build 🧱:
|
||||
- Upgrade some dependencies
|
||||
|
@ -19,6 +19,7 @@ package im.vector.matrix.rx
|
||||
import android.net.Uri
|
||||
import im.vector.matrix.android.api.query.QueryStringValue
|
||||
import im.vector.matrix.android.api.session.events.model.Event
|
||||
import im.vector.matrix.android.api.session.identity.ThreePid
|
||||
import im.vector.matrix.android.api.session.room.Room
|
||||
import im.vector.matrix.android.api.session.room.members.RoomMemberQueryParams
|
||||
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
|
||||
@ -71,6 +72,13 @@ class RxRoom(private val room: Room) {
|
||||
}
|
||||
}
|
||||
|
||||
fun liveStateEvents(eventTypes: Set<String>): Observable<List<Event>> {
|
||||
return room.getStateEventsLive(eventTypes).asObservable()
|
||||
.startWithCallable {
|
||||
room.getStateEvents(eventTypes)
|
||||
}
|
||||
}
|
||||
|
||||
fun liveReadMarker(): Observable<Optional<String>> {
|
||||
return room.getReadMarkerLive().asObservable()
|
||||
}
|
||||
@ -104,6 +112,10 @@ class RxRoom(private val room: Room) {
|
||||
room.invite(userId, reason, it)
|
||||
}
|
||||
|
||||
fun invite3pid(threePid: ThreePid): Completable = completableBuilder<Unit> {
|
||||
room.invite3pid(threePid, it)
|
||||
}
|
||||
|
||||
fun updateTopic(topic: String): Completable = completableBuilder<Unit> {
|
||||
room.updateTopic(topic, it)
|
||||
}
|
||||
|
@ -65,7 +65,7 @@ class CryptoTestHelper(private val mTestHelper: CommonTestHelper) {
|
||||
val aliceSession = mTestHelper.createAccount(TestConstants.USER_ALICE, defaultSessionParams)
|
||||
|
||||
val roomId = mTestHelper.doSync<String> {
|
||||
aliceSession.createRoom(CreateRoomParams(name = "MyRoom"), it)
|
||||
aliceSession.createRoom(CreateRoomParams().apply { name = "MyRoom" }, it)
|
||||
}
|
||||
|
||||
if (encryptedRoom) {
|
||||
@ -175,7 +175,7 @@ class CryptoTestHelper(private val mTestHelper: CommonTestHelper) {
|
||||
}
|
||||
|
||||
mTestHelper.doSync<Unit> {
|
||||
samSession.joinRoom(room.roomId, null, it)
|
||||
samSession.joinRoom(room.roomId, null, emptyList(), it)
|
||||
}
|
||||
|
||||
return samSession
|
||||
@ -286,9 +286,11 @@ class CryptoTestHelper(private val mTestHelper: CommonTestHelper) {
|
||||
fun createDM(alice: Session, bob: Session): String {
|
||||
val roomId = mTestHelper.doSync<String> {
|
||||
alice.createRoom(
|
||||
CreateRoomParams(invitedUserIds = listOf(bob.myUserId))
|
||||
.setDirectMessage()
|
||||
.enableEncryptionIfInvitedUsersSupportIt(),
|
||||
CreateRoomParams().apply {
|
||||
invitedUserIds.add(bob.myUserId)
|
||||
setDirectMessage()
|
||||
enableEncryptionIfInvitedUsersSupportIt = true
|
||||
},
|
||||
it
|
||||
)
|
||||
}
|
||||
|
@ -66,7 +66,10 @@ class KeyShareTests : InstrumentedTest {
|
||||
// Create an encrypted room and add a message
|
||||
val roomId = mTestHelper.doSync<String> {
|
||||
aliceSession.createRoom(
|
||||
CreateRoomParams(RoomDirectoryVisibility.PRIVATE).enableEncryptionWithAlgorithm(true),
|
||||
CreateRoomParams().apply {
|
||||
visibility = RoomDirectoryVisibility.PRIVATE
|
||||
enableEncryption()
|
||||
},
|
||||
it
|
||||
)
|
||||
}
|
||||
|
@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.matrix.android.api.extensions
|
||||
|
||||
fun CharSequence.ensurePrefix(prefix: CharSequence): CharSequence {
|
||||
return when {
|
||||
startsWith(prefix) -> this
|
||||
else -> "$prefix$this"
|
||||
}
|
||||
}
|
@ -18,6 +18,7 @@ package im.vector.matrix.android.api.session.room.members
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import im.vector.matrix.android.api.MatrixCallback
|
||||
import im.vector.matrix.android.api.session.identity.ThreePid
|
||||
import im.vector.matrix.android.api.session.room.model.RoomMemberSummary
|
||||
import im.vector.matrix.android.api.util.Cancelable
|
||||
|
||||
@ -63,6 +64,12 @@ interface MembershipService {
|
||||
reason: String? = null,
|
||||
callback: MatrixCallback<Unit>): Cancelable
|
||||
|
||||
/**
|
||||
* Invite a user with email or phone number in the room
|
||||
*/
|
||||
fun invite3pid(threePid: ThreePid,
|
||||
callback: MatrixCallback<Unit>): Cancelable
|
||||
|
||||
/**
|
||||
* Ban a user from the room
|
||||
*/
|
||||
|
@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2019 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.matrix.android.api.session.room.model
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
/**
|
||||
* Class representing the EventType.STATE_ROOM_THIRD_PARTY_INVITE state event content
|
||||
* Ref: https://matrix.org/docs/spec/client_server/r0.6.1#m-room-third-party-invite
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class RoomThirdPartyInviteContent(
|
||||
/**
|
||||
* Required. A user-readable string which represents the user who has been invited.
|
||||
* This should not contain the user's third party ID, as otherwise when the invite
|
||||
* is accepted it would leak the association between the matrix ID and the third party ID.
|
||||
*/
|
||||
@Json(name = "display_name") val displayName: String,
|
||||
|
||||
/**
|
||||
* Required. A URL which can be fetched, with querystring public_key=public_key, to validate
|
||||
* whether the key has been revoked. The URL must return a JSON object containing a boolean property named 'valid'.
|
||||
*/
|
||||
@Json(name = "key_validity_url") val keyValidityUrl: String,
|
||||
|
||||
/**
|
||||
* Required. A base64-encoded ed25519 key with which token must be signed (though a signature from any entry in
|
||||
* public_keys is also sufficient). This exists for backwards compatibility.
|
||||
*/
|
||||
@Json(name = "public_key") val publicKey: String,
|
||||
|
||||
/**
|
||||
* Keys with which the token may be signed.
|
||||
*/
|
||||
@Json(name = "public_keys") val publicKeys: List<PublicKeys> = emptyList()
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PublicKeys(
|
||||
/**
|
||||
* An optional URL which can be fetched, with querystring public_key=public_key, to validate whether the key
|
||||
* has been revoked. The URL must return a JSON object containing a boolean property named 'valid'. If this URL
|
||||
* is absent, the key must be considered valid indefinitely.
|
||||
*/
|
||||
@Json(name = "key_validity_url") val keyValidityUrl: String? = null,
|
||||
|
||||
/**
|
||||
* Required. A base-64 encoded ed25519 key with which token may be signed.
|
||||
*/
|
||||
@Json(name = "public_key") val publicKey: String
|
||||
)
|
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2019 New Vector Ltd
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@ -16,87 +16,57 @@
|
||||
|
||||
package im.vector.matrix.android.api.session.room.model.create
|
||||
|
||||
import android.util.Patterns
|
||||
import androidx.annotation.CheckResult
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import im.vector.matrix.android.api.MatrixPatterns.isUserId
|
||||
import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig
|
||||
import im.vector.matrix.android.api.session.events.model.Event
|
||||
import im.vector.matrix.android.api.session.events.model.EventType
|
||||
import im.vector.matrix.android.api.session.events.model.toContent
|
||||
import im.vector.matrix.android.api.session.identity.ThreePid
|
||||
import im.vector.matrix.android.api.session.room.model.PowerLevelsContent
|
||||
import im.vector.matrix.android.api.session.room.model.RoomDirectoryVisibility
|
||||
import im.vector.matrix.android.api.session.room.model.RoomHistoryVisibility
|
||||
import im.vector.matrix.android.internal.auth.data.ThreePidMedium
|
||||
import im.vector.matrix.android.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Parameter to create a room, with facilities functions to configure it
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CreateRoomParams(
|
||||
// TODO Give a way to include other initial states
|
||||
class CreateRoomParams {
|
||||
/**
|
||||
* A public visibility indicates that the room will be shown in the published room list.
|
||||
* A private visibility will hide the room from the published room list.
|
||||
* Rooms default to private visibility if this key is not included.
|
||||
* NB: This should not be confused with join_rules which also uses the word public. One of: ["public", "private"]
|
||||
*/
|
||||
@Json(name = "visibility")
|
||||
val visibility: RoomDirectoryVisibility? = null,
|
||||
var visibility: RoomDirectoryVisibility? = null
|
||||
|
||||
/**
|
||||
* The desired room alias local part. If this is included, a room alias will be created and mapped to the newly created room.
|
||||
* The alias will belong on the same homeserver which created the room.
|
||||
* For example, if this was set to "foo" and sent to the homeserver "example.com" the complete room alias would be #foo:example.com.
|
||||
*/
|
||||
@Json(name = "room_alias_name")
|
||||
val roomAliasName: String? = null,
|
||||
var roomAliasName: String? = null
|
||||
|
||||
/**
|
||||
* If this is included, an m.room.name event will be sent into the room to indicate the name of the room.
|
||||
* If this is not null, an m.room.name event will be sent into the room to indicate the name of the room.
|
||||
* See Room Events for more information on m.room.name.
|
||||
*/
|
||||
@Json(name = "name")
|
||||
val name: String? = null,
|
||||
var name: String? = null
|
||||
|
||||
/**
|
||||
* If this is included, an m.room.topic event will be sent into the room to indicate the topic for the room.
|
||||
* If this is not null, an m.room.topic event will be sent into the room to indicate the topic for the room.
|
||||
* See Room Events for more information on m.room.topic.
|
||||
*/
|
||||
@Json(name = "topic")
|
||||
val topic: String? = null,
|
||||
var topic: String? = null
|
||||
|
||||
/**
|
||||
* A list of user IDs to invite to the room.
|
||||
* This will tell the server to invite everyone in the list to the newly created room.
|
||||
*/
|
||||
@Json(name = "invite")
|
||||
val invitedUserIds: List<String>? = null,
|
||||
val invitedUserIds = mutableListOf<String>()
|
||||
|
||||
/**
|
||||
* A list of objects representing third party IDs to invite into the room.
|
||||
*/
|
||||
@Json(name = "invite_3pid")
|
||||
val invite3pids: List<Invite3Pid>? = null,
|
||||
val invite3pids = mutableListOf<ThreePid>()
|
||||
|
||||
/**
|
||||
* Extra keys to be added to the content of the m.room.create.
|
||||
* The server will clobber the following keys: creator.
|
||||
* Future versions of the specification may allow the server to clobber other keys.
|
||||
* If set to true, when the room will be created, if cross-signing is enabled and we can get keys for every invited users,
|
||||
* the encryption will be enabled on the created room
|
||||
*/
|
||||
@Json(name = "creation_content")
|
||||
val creationContent: Any? = null,
|
||||
|
||||
/**
|
||||
* A list of state events to set in the new room.
|
||||
* This allows the user to override the default state events set in the new room.
|
||||
* The expected format of the state events are an object with type, state_key and content keys set.
|
||||
* Takes precedence over events set by presets, but gets overridden by name and topic keys.
|
||||
*/
|
||||
@Json(name = "initial_state")
|
||||
val initialStates: List<Event>? = null,
|
||||
var enableEncryptionIfInvitedUsersSupportIt: Boolean = false
|
||||
|
||||
/**
|
||||
* Convenience parameter for setting various default state events based on a preset. Must be either:
|
||||
@ -105,164 +75,43 @@ data class CreateRoomParams(
|
||||
* room creator.
|
||||
* public_chat: => join_rules is set to public. history_visibility is set to shared.
|
||||
*/
|
||||
@Json(name = "preset")
|
||||
val preset: CreateRoomPreset? = null,
|
||||
var preset: CreateRoomPreset? = null
|
||||
|
||||
/**
|
||||
* This flag makes the server set the is_direct flag on the m.room.member events sent to the users in invite and invite_3pid.
|
||||
* See Direct Messaging for more information.
|
||||
*/
|
||||
@Json(name = "is_direct")
|
||||
val isDirect: Boolean? = null,
|
||||
var isDirect: Boolean? = null
|
||||
|
||||
/**
|
||||
* Extra keys to be added to the content of the m.room.create.
|
||||
* The server will clobber the following keys: creator.
|
||||
* Future versions of the specification may allow the server to clobber other keys.
|
||||
*/
|
||||
var creationContent: Any? = null
|
||||
|
||||
/**
|
||||
* The power level content to override in the default power level event
|
||||
*/
|
||||
@Json(name = "power_level_content_override")
|
||||
val powerLevelContentOverride: PowerLevelsContent? = null
|
||||
) {
|
||||
@Transient
|
||||
internal var enableEncryptionIfInvitedUsersSupportIt: Boolean = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* After calling this method, when the room will be created, if cross-signing is enabled and we can get keys for every invited users,
|
||||
* the encryption will be enabled on the created room
|
||||
* @param value true to activate this behavior.
|
||||
* @return this, to allow chaining methods
|
||||
*/
|
||||
fun enableEncryptionIfInvitedUsersSupportIt(value: Boolean = true): CreateRoomParams {
|
||||
enableEncryptionIfInvitedUsersSupportIt = value
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the crypto algorithm to the room creation parameters.
|
||||
*
|
||||
* @param enable true to enable encryption.
|
||||
* @param algorithm the algorithm, default to [MXCRYPTO_ALGORITHM_MEGOLM], which is actually the only supported algorithm for the moment
|
||||
* @return a modified copy of the CreateRoomParams object, or this if there is no modification
|
||||
*/
|
||||
@CheckResult
|
||||
fun enableEncryptionWithAlgorithm(enable: Boolean = true,
|
||||
algorithm: String = MXCRYPTO_ALGORITHM_MEGOLM): CreateRoomParams {
|
||||
// Remove the existing value if any.
|
||||
val newInitialStates = initialStates
|
||||
?.filter { it.type != EventType.STATE_ROOM_ENCRYPTION }
|
||||
|
||||
return if (algorithm == MXCRYPTO_ALGORITHM_MEGOLM) {
|
||||
if (enable) {
|
||||
val contentMap = mapOf("algorithm" to algorithm)
|
||||
|
||||
val algoEvent = Event(
|
||||
type = EventType.STATE_ROOM_ENCRYPTION,
|
||||
stateKey = "",
|
||||
content = contentMap.toContent()
|
||||
)
|
||||
|
||||
copy(
|
||||
initialStates = newInitialStates.orEmpty() + algoEvent
|
||||
)
|
||||
} else {
|
||||
return copy(
|
||||
initialStates = newInitialStates
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Timber.e("Unsupported algorithm: $algorithm")
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the history visibility in the room creation parameters.
|
||||
*
|
||||
* @param historyVisibility the expected history visibility, set null to remove any existing value.
|
||||
* @return a modified copy of the CreateRoomParams object
|
||||
*/
|
||||
@CheckResult
|
||||
fun setHistoryVisibility(historyVisibility: RoomHistoryVisibility?): CreateRoomParams {
|
||||
// Remove the existing value if any.
|
||||
val newInitialStates = initialStates
|
||||
?.filter { it.type != EventType.STATE_ROOM_HISTORY_VISIBILITY }
|
||||
|
||||
if (historyVisibility != null) {
|
||||
val contentMap = mapOf("history_visibility" to historyVisibility)
|
||||
|
||||
val historyVisibilityEvent = Event(
|
||||
type = EventType.STATE_ROOM_HISTORY_VISIBILITY,
|
||||
stateKey = "",
|
||||
content = contentMap.toContent())
|
||||
|
||||
return copy(
|
||||
initialStates = newInitialStates.orEmpty() + historyVisibilityEvent
|
||||
)
|
||||
} else {
|
||||
return copy(
|
||||
initialStates = newInitialStates
|
||||
)
|
||||
}
|
||||
}
|
||||
var powerLevelContentOverride: PowerLevelsContent? = null
|
||||
|
||||
/**
|
||||
* Mark as a direct message room.
|
||||
* @return a modified copy of the CreateRoomParams object
|
||||
*/
|
||||
@CheckResult
|
||||
fun setDirectMessage(): CreateRoomParams {
|
||||
return copy(
|
||||
preset = CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT,
|
||||
fun setDirectMessage() {
|
||||
preset = CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT
|
||||
isDirect = true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells if the created room can be a direct chat one.
|
||||
*
|
||||
* @return true if it is a direct chat
|
||||
* Supported value: MXCRYPTO_ALGORITHM_MEGOLM
|
||||
*/
|
||||
fun isDirect(): Boolean {
|
||||
return preset == CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT
|
||||
&& isDirect == true
|
||||
}
|
||||
var algorithm: String? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* @return the first invited user id
|
||||
*/
|
||||
fun getFirstInvitedUserId(): String? {
|
||||
return invitedUserIds?.firstOrNull() ?: invite3pids?.firstOrNull()?.address
|
||||
}
|
||||
var historyVisibility: RoomHistoryVisibility? = null
|
||||
|
||||
/**
|
||||
* Add some ids to the room creation
|
||||
* ids might be a matrix id or an email address.
|
||||
*
|
||||
* @param ids the participant ids to add.
|
||||
* @return a modified copy of the CreateRoomParams object
|
||||
*/
|
||||
@CheckResult
|
||||
fun addParticipantIds(hsConfig: HomeServerConnectionConfig,
|
||||
userId: String,
|
||||
ids: List<String>): CreateRoomParams {
|
||||
return copy(
|
||||
invite3pids = (invite3pids.orEmpty() + ids
|
||||
.takeIf { hsConfig.identityServerUri != null }
|
||||
?.filter { id -> Patterns.EMAIL_ADDRESS.matcher(id).matches() }
|
||||
?.map { id ->
|
||||
Invite3Pid(
|
||||
idServer = hsConfig.identityServerUri!!.host!!,
|
||||
medium = ThreePidMedium.EMAIL,
|
||||
address = id
|
||||
)
|
||||
}
|
||||
.orEmpty())
|
||||
.distinct(),
|
||||
invitedUserIds = (invitedUserIds.orEmpty() + ids
|
||||
.filter { id -> isUserId(id) }
|
||||
// do not invite oneself
|
||||
.filter { id -> id != userId })
|
||||
.distinct()
|
||||
)
|
||||
// TODO add phonenumbers when it will be available
|
||||
fun enableEncryption() {
|
||||
algorithm = MXCRYPTO_ALGORITHM_MEGOLM
|
||||
}
|
||||
}
|
||||
|
@ -1,42 +0,0 @@
|
||||
/*
|
||||
* Copyright 2019 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.matrix.android.api.session.room.model.create
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Invite3Pid(
|
||||
/**
|
||||
* Required.
|
||||
* The hostname+port of the identity server which should be used for third party identifier lookups.
|
||||
*/
|
||||
@Json(name = "id_server")
|
||||
val idServer: String,
|
||||
|
||||
/**
|
||||
* Required.
|
||||
* The kind of address being passed in the address field, for example email.
|
||||
*/
|
||||
val medium: String,
|
||||
|
||||
/**
|
||||
* Required.
|
||||
* The invitee's third party identifier.
|
||||
*/
|
||||
val address: String
|
||||
)
|
@ -62,6 +62,7 @@ import javax.net.ssl.HttpsURLConnection
|
||||
@SessionScope
|
||||
internal class DefaultIdentityService @Inject constructor(
|
||||
private val identityStore: IdentityStore,
|
||||
private val ensureIdentityTokenTask: EnsureIdentityTokenTask,
|
||||
private val getOpenIdTokenTask: GetOpenIdTokenTask,
|
||||
private val identityBulkLookupTask: IdentityBulkLookupTask,
|
||||
private val identityRegisterTask: IdentityRegisterTask,
|
||||
@ -278,7 +279,7 @@ internal class DefaultIdentityService @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun lookUpInternal(canRetry: Boolean, threePids: List<ThreePid>): List<FoundThreePid> {
|
||||
ensureToken()
|
||||
ensureIdentityTokenTask.execute(Unit)
|
||||
|
||||
return try {
|
||||
identityBulkLookupTask.execute(IdentityBulkLookupTask.Params(threePids))
|
||||
@ -295,17 +296,6 @@ internal class DefaultIdentityService @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureToken() {
|
||||
val identityData = identityStore.getIdentityData() ?: throw IdentityServiceError.NoIdentityServerConfigured
|
||||
val url = identityData.identityServerUrl ?: throw IdentityServiceError.NoIdentityServerConfigured
|
||||
|
||||
if (identityData.token == null) {
|
||||
// Try to get a token
|
||||
val token = getNewIdentityServerToken(url)
|
||||
identityStore.setToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getNewIdentityServerToken(url: String): String {
|
||||
val api = retrofitFactory.create(unauthenticatedOkHttpClient, url).create(IdentityAuthAPI::class.java)
|
||||
|
||||
|
@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.matrix.android.internal.session.identity
|
||||
|
||||
import dagger.Lazy
|
||||
import im.vector.matrix.android.api.session.identity.IdentityServiceError
|
||||
import im.vector.matrix.android.internal.di.UnauthenticatedWithCertificate
|
||||
import im.vector.matrix.android.internal.network.RetrofitFactory
|
||||
import im.vector.matrix.android.internal.session.identity.data.IdentityStore
|
||||
import im.vector.matrix.android.internal.session.openid.GetOpenIdTokenTask
|
||||
import im.vector.matrix.android.internal.task.Task
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Inject
|
||||
|
||||
internal interface EnsureIdentityTokenTask : Task<Unit, Unit>
|
||||
|
||||
internal class DefaultEnsureIdentityTokenTask @Inject constructor(
|
||||
private val identityStore: IdentityStore,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
@UnauthenticatedWithCertificate
|
||||
private val unauthenticatedOkHttpClient: Lazy<OkHttpClient>,
|
||||
private val getOpenIdTokenTask: GetOpenIdTokenTask,
|
||||
private val identityRegisterTask: IdentityRegisterTask
|
||||
) : EnsureIdentityTokenTask {
|
||||
|
||||
override suspend fun execute(params: Unit) {
|
||||
val identityData = identityStore.getIdentityData() ?: throw IdentityServiceError.NoIdentityServerConfigured
|
||||
val url = identityData.identityServerUrl ?: throw IdentityServiceError.NoIdentityServerConfigured
|
||||
|
||||
if (identityData.token == null) {
|
||||
// Try to get a token
|
||||
val token = getNewIdentityServerToken(url)
|
||||
identityStore.setToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getNewIdentityServerToken(url: String): String {
|
||||
val api = retrofitFactory.create(unauthenticatedOkHttpClient, url).create(IdentityAuthAPI::class.java)
|
||||
|
||||
val openIdToken = getOpenIdTokenTask.execute(Unit)
|
||||
val token = identityRegisterTask.execute(IdentityRegisterTask.Params(api, openIdToken))
|
||||
|
||||
return token.token
|
||||
}
|
||||
}
|
@ -78,6 +78,9 @@ internal abstract class IdentityModule {
|
||||
@Binds
|
||||
abstract fun bindIdentityStore(store: RealmIdentityStore): IdentityStore
|
||||
|
||||
@Binds
|
||||
abstract fun bindEnsureIdentityTokenTask(task: DefaultEnsureIdentityTokenTask): EnsureIdentityTokenTask
|
||||
|
||||
@Binds
|
||||
abstract fun bindIdentityPingTask(task: DefaultIdentityPingTask): IdentityPingTask
|
||||
|
||||
|
@ -18,9 +18,6 @@ package im.vector.matrix.android.internal.session.room
|
||||
|
||||
import im.vector.matrix.android.api.session.events.model.Content
|
||||
import im.vector.matrix.android.api.session.events.model.Event
|
||||
import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams
|
||||
import im.vector.matrix.android.api.session.room.model.create.CreateRoomResponse
|
||||
import im.vector.matrix.android.api.session.room.model.create.JoinRoomResponse
|
||||
import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoomsParams
|
||||
import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoomsResponse
|
||||
import im.vector.matrix.android.api.session.room.model.thirdparty.ThirdPartyProtocol
|
||||
@ -28,9 +25,13 @@ import im.vector.matrix.android.api.util.JsonDict
|
||||
import im.vector.matrix.android.internal.network.NetworkConstants
|
||||
import im.vector.matrix.android.internal.session.room.alias.AddRoomAliasBody
|
||||
import im.vector.matrix.android.internal.session.room.alias.RoomAliasDescription
|
||||
import im.vector.matrix.android.internal.session.room.create.CreateRoomBody
|
||||
import im.vector.matrix.android.internal.session.room.create.CreateRoomResponse
|
||||
import im.vector.matrix.android.internal.session.room.create.JoinRoomResponse
|
||||
import im.vector.matrix.android.internal.session.room.membership.RoomMembersResponse
|
||||
import im.vector.matrix.android.internal.session.room.membership.admin.UserIdAndReason
|
||||
import im.vector.matrix.android.internal.session.room.membership.joining.InviteBody
|
||||
import im.vector.matrix.android.internal.session.room.membership.threepid.ThreePidInviteBody
|
||||
import im.vector.matrix.android.internal.session.room.relation.RelationsResponse
|
||||
import im.vector.matrix.android.internal.session.room.reporting.ReportContentBody
|
||||
import im.vector.matrix.android.internal.session.room.send.SendResponse
|
||||
@ -79,7 +80,7 @@ internal interface RoomAPI {
|
||||
*/
|
||||
@Headers("CONNECT_TIMEOUT:60000", "READ_TIMEOUT:60000", "WRITE_TIMEOUT:60000")
|
||||
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "createRoom")
|
||||
fun createRoom(@Body param: CreateRoomParams): Call<CreateRoomResponse>
|
||||
fun createRoom(@Body param: CreateRoomBody): Call<CreateRoomResponse>
|
||||
|
||||
/**
|
||||
* Get a list of messages starting from a reference.
|
||||
@ -170,6 +171,14 @@ internal interface RoomAPI {
|
||||
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/invite")
|
||||
fun invite(@Path("roomId") roomId: String, @Body body: InviteBody): Call<Unit>
|
||||
|
||||
/**
|
||||
* Invite a user to a room, using a ThreePid
|
||||
* Ref: https://matrix.org/docs/spec/client_server/r0.6.1#id101
|
||||
* @param roomId Required. The room identifier (not alias) to which to invite the user.
|
||||
*/
|
||||
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/invite")
|
||||
fun invite3pid(@Path("roomId") roomId: String, @Body body: ThreePidInviteBody): Call<Unit>
|
||||
|
||||
/**
|
||||
* Send a generic state events
|
||||
*
|
||||
|
@ -44,6 +44,8 @@ import im.vector.matrix.android.internal.session.room.membership.joining.InviteT
|
||||
import im.vector.matrix.android.internal.session.room.membership.joining.JoinRoomTask
|
||||
import im.vector.matrix.android.internal.session.room.membership.leaving.DefaultLeaveRoomTask
|
||||
import im.vector.matrix.android.internal.session.room.membership.leaving.LeaveRoomTask
|
||||
import im.vector.matrix.android.internal.session.room.membership.threepid.DefaultInviteThreePidTask
|
||||
import im.vector.matrix.android.internal.session.room.membership.threepid.InviteThreePidTask
|
||||
import im.vector.matrix.android.internal.session.room.read.DefaultMarkAllRoomsReadTask
|
||||
import im.vector.matrix.android.internal.session.room.read.DefaultSetReadMarkersTask
|
||||
import im.vector.matrix.android.internal.session.room.read.MarkAllRoomsReadTask
|
||||
@ -139,6 +141,9 @@ internal abstract class RoomModule {
|
||||
@Binds
|
||||
abstract fun bindInviteTask(task: DefaultInviteTask): InviteTask
|
||||
|
||||
@Binds
|
||||
abstract fun bindInviteThreePidTask(task: DefaultInviteThreePidTask): InviteThreePidTask
|
||||
|
||||
@Binds
|
||||
abstract fun bindJoinRoomTask(task: DefaultJoinRoomTask): JoinRoomTask
|
||||
|
||||
|
@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.matrix.android.internal.session.room.create
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import im.vector.matrix.android.api.session.events.model.Event
|
||||
import im.vector.matrix.android.api.session.room.model.PowerLevelsContent
|
||||
import im.vector.matrix.android.api.session.room.model.RoomDirectoryVisibility
|
||||
import im.vector.matrix.android.api.session.room.model.create.CreateRoomPreset
|
||||
import im.vector.matrix.android.internal.session.room.membership.threepid.ThreePidInviteBody
|
||||
|
||||
/**
|
||||
* Parameter to create a room
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
internal data class CreateRoomBody(
|
||||
/**
|
||||
* A public visibility indicates that the room will be shown in the published room list.
|
||||
* A private visibility will hide the room from the published room list.
|
||||
* Rooms default to private visibility if this key is not included.
|
||||
* NB: This should not be confused with join_rules which also uses the word public. One of: ["public", "private"]
|
||||
*/
|
||||
@Json(name = "visibility")
|
||||
val visibility: RoomDirectoryVisibility?,
|
||||
|
||||
/**
|
||||
* The desired room alias local part. If this is included, a room alias will be created and mapped to the newly created room.
|
||||
* The alias will belong on the same homeserver which created the room.
|
||||
* For example, if this was set to "foo" and sent to the homeserver "example.com" the complete room alias would be #foo:example.com.
|
||||
*/
|
||||
@Json(name = "room_alias_name")
|
||||
val roomAliasName: String?,
|
||||
|
||||
/**
|
||||
* If this is included, an m.room.name event will be sent into the room to indicate the name of the room.
|
||||
* See Room Events for more information on m.room.name.
|
||||
*/
|
||||
@Json(name = "name")
|
||||
val name: String?,
|
||||
|
||||
/**
|
||||
* If this is included, an m.room.topic event will be sent into the room to indicate the topic for the room.
|
||||
* See Room Events for more information on m.room.topic.
|
||||
*/
|
||||
@Json(name = "topic")
|
||||
val topic: String?,
|
||||
|
||||
/**
|
||||
* A list of user IDs to invite to the room.
|
||||
* This will tell the server to invite everyone in the list to the newly created room.
|
||||
*/
|
||||
@Json(name = "invite")
|
||||
val invitedUserIds: List<String>?,
|
||||
|
||||
/**
|
||||
* A list of objects representing third party IDs to invite into the room.
|
||||
*/
|
||||
@Json(name = "invite_3pid")
|
||||
val invite3pids: List<ThreePidInviteBody>?,
|
||||
|
||||
/**
|
||||
* Extra keys to be added to the content of the m.room.create.
|
||||
* The server will clobber the following keys: creator.
|
||||
* Future versions of the specification may allow the server to clobber other keys.
|
||||
*/
|
||||
@Json(name = "creation_content")
|
||||
val creationContent: Any?,
|
||||
|
||||
/**
|
||||
* A list of state events to set in the new room.
|
||||
* This allows the user to override the default state events set in the new room.
|
||||
* The expected format of the state events are an object with type, state_key and content keys set.
|
||||
* Takes precedence over events set by presets, but gets overridden by name and topic keys.
|
||||
*/
|
||||
@Json(name = "initial_state")
|
||||
val initialStates: List<Event>?,
|
||||
|
||||
/**
|
||||
* Convenience parameter for setting various default state events based on a preset. Must be either:
|
||||
* private_chat => join_rules is set to invite. history_visibility is set to shared.
|
||||
* trusted_private_chat => join_rules is set to invite. history_visibility is set to shared. All invitees are given the same power level as the
|
||||
* room creator.
|
||||
* public_chat: => join_rules is set to public. history_visibility is set to shared.
|
||||
*/
|
||||
@Json(name = "preset")
|
||||
val preset: CreateRoomPreset?,
|
||||
|
||||
/**
|
||||
* This flag makes the server set the is_direct flag on the m.room.member events sent to the users in invite and invite_3pid.
|
||||
* See Direct Messaging for more information.
|
||||
*/
|
||||
@Json(name = "is_direct")
|
||||
val isDirect: Boolean?,
|
||||
|
||||
/**
|
||||
* The power level content to override in the default power level event
|
||||
*/
|
||||
@Json(name = "power_level_content_override")
|
||||
val powerLevelContentOverride: PowerLevelsContent?
|
||||
)
|
@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.matrix.android.internal.session.room.create
|
||||
|
||||
import im.vector.matrix.android.api.session.crypto.crosssigning.CrossSigningService
|
||||
import im.vector.matrix.android.api.session.events.model.Event
|
||||
import im.vector.matrix.android.api.session.events.model.EventType
|
||||
import im.vector.matrix.android.api.session.events.model.toContent
|
||||
import im.vector.matrix.android.api.session.identity.IdentityServiceError
|
||||
import im.vector.matrix.android.api.session.identity.toMedium
|
||||
import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams
|
||||
import im.vector.matrix.android.internal.crypto.DeviceListManager
|
||||
import im.vector.matrix.android.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM
|
||||
import im.vector.matrix.android.internal.di.AuthenticatedIdentity
|
||||
import im.vector.matrix.android.internal.network.token.AccessTokenProvider
|
||||
import im.vector.matrix.android.internal.session.identity.EnsureIdentityTokenTask
|
||||
import im.vector.matrix.android.internal.session.identity.data.IdentityStore
|
||||
import im.vector.matrix.android.internal.session.identity.data.getIdentityServerUrlWithoutProtocol
|
||||
import im.vector.matrix.android.internal.session.room.membership.threepid.ThreePidInviteBody
|
||||
import java.security.InvalidParameterException
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class CreateRoomBodyBuilder @Inject constructor(
|
||||
private val ensureIdentityTokenTask: EnsureIdentityTokenTask,
|
||||
private val crossSigningService: CrossSigningService,
|
||||
private val deviceListManager: DeviceListManager,
|
||||
private val identityStore: IdentityStore,
|
||||
@AuthenticatedIdentity
|
||||
private val accessTokenProvider: AccessTokenProvider
|
||||
) {
|
||||
|
||||
suspend fun build(params: CreateRoomParams): CreateRoomBody {
|
||||
val invite3pids = params.invite3pids
|
||||
.takeIf { it.isNotEmpty() }
|
||||
.let {
|
||||
// This can throw Exception if Identity server is not configured
|
||||
ensureIdentityTokenTask.execute(Unit)
|
||||
|
||||
val identityServerUrlWithoutProtocol = identityStore.getIdentityServerUrlWithoutProtocol()
|
||||
?: throw IdentityServiceError.NoIdentityServerConfigured
|
||||
val identityServerAccessToken = accessTokenProvider.getToken() ?: throw IdentityServiceError.NoIdentityServerConfigured
|
||||
|
||||
params.invite3pids.map {
|
||||
ThreePidInviteBody(
|
||||
id_server = identityServerUrlWithoutProtocol,
|
||||
id_access_token = identityServerAccessToken,
|
||||
medium = it.toMedium(),
|
||||
address = it.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val initialStates = listOfNotNull(
|
||||
buildEncryptionWithAlgorithmEvent(params),
|
||||
buildHistoryVisibilityEvent(params)
|
||||
)
|
||||
.takeIf { it.isNotEmpty() }
|
||||
|
||||
return CreateRoomBody(
|
||||
visibility = params.visibility,
|
||||
roomAliasName = params.roomAliasName,
|
||||
name = params.name,
|
||||
topic = params.topic,
|
||||
invitedUserIds = params.invitedUserIds,
|
||||
invite3pids = invite3pids,
|
||||
creationContent = params.creationContent,
|
||||
initialStates = initialStates,
|
||||
preset = params.preset,
|
||||
isDirect = params.isDirect,
|
||||
powerLevelContentOverride = params.powerLevelContentOverride
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildHistoryVisibilityEvent(params: CreateRoomParams): Event? {
|
||||
return params.historyVisibility
|
||||
?.let {
|
||||
val contentMap = mapOf("history_visibility" to it)
|
||||
|
||||
Event(
|
||||
type = EventType.STATE_ROOM_HISTORY_VISIBILITY,
|
||||
stateKey = "",
|
||||
content = contentMap.toContent())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the crypto algorithm to the room creation parameters.
|
||||
*/
|
||||
private suspend fun buildEncryptionWithAlgorithmEvent(params: CreateRoomParams): Event? {
|
||||
if (params.algorithm == null
|
||||
&& canEnableEncryption(params)) {
|
||||
// Enable the encryption
|
||||
params.enableEncryption()
|
||||
}
|
||||
return params.algorithm
|
||||
?.let {
|
||||
if (it != MXCRYPTO_ALGORITHM_MEGOLM) {
|
||||
throw InvalidParameterException("Unsupported algorithm: $it")
|
||||
}
|
||||
val contentMap = mapOf("algorithm" to it)
|
||||
|
||||
Event(
|
||||
type = EventType.STATE_ROOM_ENCRYPTION,
|
||||
stateKey = "",
|
||||
content = contentMap.toContent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun canEnableEncryption(params: CreateRoomParams): Boolean {
|
||||
return (params.enableEncryptionIfInvitedUsersSupportIt
|
||||
&& crossSigningService.isCrossSigningVerified()
|
||||
&& params.invite3pids.isEmpty())
|
||||
&& params.invitedUserIds.isNotEmpty()
|
||||
&& params.invitedUserIds.let { userIds ->
|
||||
val keys = deviceListManager.downloadKeys(userIds, forceDownload = false)
|
||||
|
||||
userIds.all { userId ->
|
||||
keys.map[userId].let { deviceMap ->
|
||||
if (deviceMap.isNullOrEmpty()) {
|
||||
// A user has no device, so do not enable encryption
|
||||
false
|
||||
} else {
|
||||
// Check that every user's device have at least one key
|
||||
deviceMap.values.all { !it.keys.isNullOrEmpty() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2019 New Vector Ltd
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.matrix.android.api.session.room.model.create
|
||||
package im.vector.matrix.android.internal.session.room.create
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
@ -17,11 +17,9 @@
|
||||
package im.vector.matrix.android.internal.session.room.create
|
||||
|
||||
import com.zhuinden.monarchy.Monarchy
|
||||
import im.vector.matrix.android.api.session.crypto.crosssigning.CrossSigningService
|
||||
import im.vector.matrix.android.api.session.room.failure.CreateRoomFailure
|
||||
import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams
|
||||
import im.vector.matrix.android.api.session.room.model.create.CreateRoomResponse
|
||||
import im.vector.matrix.android.internal.crypto.DeviceListManager
|
||||
import im.vector.matrix.android.api.session.room.model.create.CreateRoomPreset
|
||||
import im.vector.matrix.android.internal.database.awaitNotEmptyResult
|
||||
import im.vector.matrix.android.internal.database.model.RoomEntity
|
||||
import im.vector.matrix.android.internal.database.model.RoomEntityFields
|
||||
@ -51,20 +49,15 @@ internal class DefaultCreateRoomTask @Inject constructor(
|
||||
private val readMarkersTask: SetReadMarkersTask,
|
||||
@SessionDatabase
|
||||
private val realmConfiguration: RealmConfiguration,
|
||||
private val crossSigningService: CrossSigningService,
|
||||
private val deviceListManager: DeviceListManager,
|
||||
private val createRoomBodyBuilder: CreateRoomBodyBuilder,
|
||||
private val eventBus: EventBus
|
||||
) : CreateRoomTask {
|
||||
|
||||
override suspend fun execute(params: CreateRoomParams): String {
|
||||
val createRoomParams = if (canEnableEncryption(params)) {
|
||||
params.enableEncryptionWithAlgorithm()
|
||||
} else {
|
||||
params
|
||||
}
|
||||
val createRoomBody = createRoomBodyBuilder.build(params)
|
||||
|
||||
val createRoomResponse = executeRequest<CreateRoomResponse>(eventBus) {
|
||||
apiCall = roomAPI.createRoom(createRoomParams)
|
||||
apiCall = roomAPI.createRoom(createRoomBody)
|
||||
}
|
||||
val roomId = createRoomResponse.roomId
|
||||
// Wait for room to come back from the sync (but it can maybe be in the DB if the sync response is received before)
|
||||
@ -76,35 +69,13 @@ internal class DefaultCreateRoomTask @Inject constructor(
|
||||
} catch (exception: TimeoutCancellationException) {
|
||||
throw CreateRoomFailure.CreatedWithTimeout
|
||||
}
|
||||
if (createRoomParams.isDirect()) {
|
||||
handleDirectChatCreation(createRoomParams, roomId)
|
||||
if (params.isDirect()) {
|
||||
handleDirectChatCreation(params, roomId)
|
||||
}
|
||||
setReadMarkers(roomId)
|
||||
return roomId
|
||||
}
|
||||
|
||||
private suspend fun canEnableEncryption(params: CreateRoomParams): Boolean {
|
||||
return params.enableEncryptionIfInvitedUsersSupportIt
|
||||
&& crossSigningService.isCrossSigningVerified()
|
||||
&& params.invite3pids.isNullOrEmpty()
|
||||
&& params.invitedUserIds?.isNotEmpty() == true
|
||||
&& params.invitedUserIds.let { userIds ->
|
||||
val keys = deviceListManager.downloadKeys(userIds, forceDownload = false)
|
||||
|
||||
userIds.all { userId ->
|
||||
keys.map[userId].let { deviceMap ->
|
||||
if (deviceMap.isNullOrEmpty()) {
|
||||
// A user has no device, so do not enable encryption
|
||||
false
|
||||
} else {
|
||||
// Check that every user's device have at least one key
|
||||
deviceMap.values.all { !it.keys.isNullOrEmpty() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleDirectChatCreation(params: CreateRoomParams, roomId: String) {
|
||||
val otherUserId = params.getFirstInvitedUserId()
|
||||
?: throw IllegalStateException("You can't create a direct room without an invitedUser")
|
||||
@ -123,4 +94,21 @@ internal class DefaultCreateRoomTask @Inject constructor(
|
||||
val setReadMarkerParams = SetReadMarkersTask.Params(roomId, forceReadReceipt = true, forceReadMarker = true)
|
||||
return readMarkersTask.execute(setReadMarkerParams)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells if the created room can be a direct chat one.
|
||||
*
|
||||
* @return true if it is a direct chat
|
||||
*/
|
||||
private fun CreateRoomParams.isDirect(): Boolean {
|
||||
return preset == CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT
|
||||
&& isDirect == true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the first invited user id
|
||||
*/
|
||||
private fun CreateRoomParams.getFirstInvitedUserId(): String? {
|
||||
return invitedUserIds.firstOrNull() ?: invite3pids.firstOrNull()?.value
|
||||
}
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ import com.squareup.inject.assisted.Assisted
|
||||
import com.squareup.inject.assisted.AssistedInject
|
||||
import com.zhuinden.monarchy.Monarchy
|
||||
import im.vector.matrix.android.api.MatrixCallback
|
||||
import im.vector.matrix.android.api.session.identity.ThreePid
|
||||
import im.vector.matrix.android.api.session.room.members.MembershipService
|
||||
import im.vector.matrix.android.api.session.room.members.RoomMemberQueryParams
|
||||
import im.vector.matrix.android.api.session.room.model.Membership
|
||||
@ -36,6 +37,7 @@ import im.vector.matrix.android.internal.session.room.membership.admin.Membershi
|
||||
import im.vector.matrix.android.internal.session.room.membership.joining.InviteTask
|
||||
import im.vector.matrix.android.internal.session.room.membership.joining.JoinRoomTask
|
||||
import im.vector.matrix.android.internal.session.room.membership.leaving.LeaveRoomTask
|
||||
import im.vector.matrix.android.internal.session.room.membership.threepid.InviteThreePidTask
|
||||
import im.vector.matrix.android.internal.task.TaskExecutor
|
||||
import im.vector.matrix.android.internal.task.configureWith
|
||||
import im.vector.matrix.android.internal.util.fetchCopied
|
||||
@ -48,6 +50,7 @@ internal class DefaultMembershipService @AssistedInject constructor(
|
||||
private val taskExecutor: TaskExecutor,
|
||||
private val loadRoomMembersTask: LoadRoomMembersTask,
|
||||
private val inviteTask: InviteTask,
|
||||
private val inviteThreePidTask: InviteThreePidTask,
|
||||
private val joinTask: JoinRoomTask,
|
||||
private val leaveRoomTask: LeaveRoomTask,
|
||||
private val membershipAdminTask: MembershipAdminTask,
|
||||
@ -152,6 +155,15 @@ internal class DefaultMembershipService @AssistedInject constructor(
|
||||
.executeBy(taskExecutor)
|
||||
}
|
||||
|
||||
override fun invite3pid(threePid: ThreePid, callback: MatrixCallback<Unit>): Cancelable {
|
||||
val params = InviteThreePidTask.Params(roomId, threePid)
|
||||
return inviteThreePidTask
|
||||
.configureWith(params) {
|
||||
this.callback = callback
|
||||
}
|
||||
.executeBy(taskExecutor)
|
||||
}
|
||||
|
||||
override fun join(reason: String?, viaServers: List<String>, callback: MatrixCallback<Unit>): Cancelable {
|
||||
val params = JoinRoomTask.Params(roomId, reason, viaServers)
|
||||
return joinTask
|
||||
|
@ -18,13 +18,13 @@ package im.vector.matrix.android.internal.session.room.membership.joining
|
||||
|
||||
import im.vector.matrix.android.api.session.room.failure.JoinRoomFailure
|
||||
import im.vector.matrix.android.api.session.room.members.ChangeMembershipState
|
||||
import im.vector.matrix.android.api.session.room.model.create.JoinRoomResponse
|
||||
import im.vector.matrix.android.internal.database.awaitNotEmptyResult
|
||||
import im.vector.matrix.android.internal.database.model.RoomEntity
|
||||
import im.vector.matrix.android.internal.database.model.RoomEntityFields
|
||||
import im.vector.matrix.android.internal.di.SessionDatabase
|
||||
import im.vector.matrix.android.internal.network.executeRequest
|
||||
import im.vector.matrix.android.internal.session.room.RoomAPI
|
||||
import im.vector.matrix.android.internal.session.room.create.JoinRoomResponse
|
||||
import im.vector.matrix.android.internal.session.room.membership.RoomChangeMembershipStateDataSource
|
||||
import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask
|
||||
import im.vector.matrix.android.internal.task.Task
|
||||
|
@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright 2019 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.matrix.android.internal.session.room.membership.threepid
|
||||
|
||||
import im.vector.matrix.android.api.session.identity.IdentityServiceError
|
||||
import im.vector.matrix.android.api.session.identity.ThreePid
|
||||
import im.vector.matrix.android.api.session.identity.toMedium
|
||||
import im.vector.matrix.android.internal.di.AuthenticatedIdentity
|
||||
import im.vector.matrix.android.internal.network.executeRequest
|
||||
import im.vector.matrix.android.internal.network.token.AccessTokenProvider
|
||||
import im.vector.matrix.android.internal.session.identity.EnsureIdentityTokenTask
|
||||
import im.vector.matrix.android.internal.session.identity.data.IdentityStore
|
||||
import im.vector.matrix.android.internal.session.identity.data.getIdentityServerUrlWithoutProtocol
|
||||
import im.vector.matrix.android.internal.session.room.RoomAPI
|
||||
import im.vector.matrix.android.internal.task.Task
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import javax.inject.Inject
|
||||
|
||||
internal interface InviteThreePidTask : Task<InviteThreePidTask.Params, Unit> {
|
||||
data class Params(
|
||||
val roomId: String,
|
||||
val threePid: ThreePid
|
||||
)
|
||||
}
|
||||
|
||||
internal class DefaultInviteThreePidTask @Inject constructor(
|
||||
private val roomAPI: RoomAPI,
|
||||
private val eventBus: EventBus,
|
||||
private val identityStore: IdentityStore,
|
||||
private val ensureIdentityTokenTask: EnsureIdentityTokenTask,
|
||||
@AuthenticatedIdentity
|
||||
private val accessTokenProvider: AccessTokenProvider
|
||||
) : InviteThreePidTask {
|
||||
|
||||
override suspend fun execute(params: InviteThreePidTask.Params) {
|
||||
ensureIdentityTokenTask.execute(Unit)
|
||||
|
||||
val identityServerUrlWithoutProtocol = identityStore.getIdentityServerUrlWithoutProtocol() ?: throw IdentityServiceError.NoIdentityServerConfigured
|
||||
val identityServerAccessToken = accessTokenProvider.getToken() ?: throw IdentityServiceError.NoIdentityServerConfigured
|
||||
|
||||
return executeRequest(eventBus) {
|
||||
val body = ThreePidInviteBody(
|
||||
id_server = identityServerUrlWithoutProtocol,
|
||||
id_access_token = identityServerAccessToken,
|
||||
medium = params.threePid.toMedium(),
|
||||
address = params.threePid.value
|
||||
)
|
||||
apiCall = roomAPI.invite3pid(params.roomId, body)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2019 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.matrix.android.internal.session.room.membership.threepid
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
internal data class ThreePidInviteBody(
|
||||
/**
|
||||
* Required. The hostname+port of the identity server which should be used for third party identifier lookups.
|
||||
*/
|
||||
@Json(name = "id_server") val id_server: String,
|
||||
/**
|
||||
* Required. An access token previously registered with the identity server. Servers can treat this as optional
|
||||
* to distinguish between r0.5-compatible clients and this specification version.
|
||||
*/
|
||||
@Json(name = "id_access_token") val id_access_token: String,
|
||||
/**
|
||||
* Required. The kind of address being passed in the address field, for example email.
|
||||
*/
|
||||
@Json(name = "medium") val medium: String,
|
||||
/**
|
||||
* Required. The invitee's third party identifier.
|
||||
*/
|
||||
@Json(name = "address") val address: String
|
||||
)
|
@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.riotx.core.contacts
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import androidx.annotation.WorkerThread
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
class ContactsDataSource @Inject constructor(
|
||||
private val context: Context
|
||||
) {
|
||||
|
||||
/**
|
||||
* Will return a list of contact from the contacts book of the device, with at least one email or phone.
|
||||
* If both param are false, you will get en empty list.
|
||||
* Note: The return list does not contain any matrixId.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun getContacts(
|
||||
withEmails: Boolean,
|
||||
withMsisdn: Boolean
|
||||
): List<MappedContact> {
|
||||
val map = mutableMapOf<Long, MappedContactBuilder>()
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
measureTimeMillis {
|
||||
contentResolver.query(
|
||||
ContactsContract.Contacts.CONTENT_URI,
|
||||
arrayOf(
|
||||
ContactsContract.Contacts._ID,
|
||||
ContactsContract.Data.DISPLAY_NAME,
|
||||
ContactsContract.Data.PHOTO_URI
|
||||
),
|
||||
null,
|
||||
null,
|
||||
// Sort by Display name
|
||||
ContactsContract.Data.DISPLAY_NAME
|
||||
)
|
||||
?.use { cursor ->
|
||||
if (cursor.count > 0) {
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(ContactsContract.Contacts._ID) ?: continue
|
||||
val displayName = cursor.getString(ContactsContract.Contacts.DISPLAY_NAME) ?: continue
|
||||
|
||||
val mappedContactBuilder = MappedContactBuilder(
|
||||
id = id,
|
||||
displayName = displayName
|
||||
)
|
||||
|
||||
cursor.getString(ContactsContract.Data.PHOTO_URI)
|
||||
?.let { Uri.parse(it) }
|
||||
?.let { mappedContactBuilder.photoURI = it }
|
||||
|
||||
map[id] = mappedContactBuilder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the phone numbers
|
||||
if (withMsisdn) {
|
||||
contentResolver.query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||
arrayOf(
|
||||
ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
|
||||
ContactsContract.CommonDataKinds.Phone.NUMBER
|
||||
),
|
||||
null,
|
||||
null,
|
||||
null)
|
||||
?.use { innerCursor ->
|
||||
while (innerCursor.moveToNext()) {
|
||||
val mappedContactBuilder = innerCursor.getLong(ContactsContract.CommonDataKinds.Phone.CONTACT_ID)
|
||||
?.let { map[it] }
|
||||
?: continue
|
||||
innerCursor.getString(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||
?.let {
|
||||
mappedContactBuilder.msisdns.add(
|
||||
MappedMsisdn(
|
||||
phoneNumber = it,
|
||||
matrixId = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get Emails
|
||||
if (withEmails) {
|
||||
contentResolver.query(
|
||||
ContactsContract.CommonDataKinds.Email.CONTENT_URI,
|
||||
arrayOf(
|
||||
ContactsContract.CommonDataKinds.Email.CONTACT_ID,
|
||||
ContactsContract.CommonDataKinds.Email.DATA
|
||||
),
|
||||
null,
|
||||
null,
|
||||
null)
|
||||
?.use { innerCursor ->
|
||||
while (innerCursor.moveToNext()) {
|
||||
// This would allow you get several email addresses
|
||||
// if the email addresses were stored in an array
|
||||
val mappedContactBuilder = innerCursor.getLong(ContactsContract.CommonDataKinds.Email.CONTACT_ID)
|
||||
?.let { map[it] }
|
||||
?: continue
|
||||
innerCursor.getString(ContactsContract.CommonDataKinds.Email.DATA)
|
||||
?.let {
|
||||
mappedContactBuilder.emails.add(
|
||||
MappedEmail(
|
||||
email = it,
|
||||
matrixId = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.also { Timber.d("Took ${it}ms to fetch ${map.size} contact(s)") }
|
||||
|
||||
return map
|
||||
.values
|
||||
.filter { it.emails.isNotEmpty() || it.msisdns.isNotEmpty() }
|
||||
.map { it.build() }
|
||||
}
|
||||
|
||||
private fun Cursor.getString(column: String): String? {
|
||||
return getColumnIndex(column)
|
||||
.takeIf { it != -1 }
|
||||
?.let { getString(it) }
|
||||
}
|
||||
|
||||
private fun Cursor.getLong(column: String): Long? {
|
||||
return getColumnIndex(column)
|
||||
.takeIf { it != -1 }
|
||||
?.let { getLong(it) }
|
||||
}
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.riotx.core.contacts
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
class MappedContactBuilder(
|
||||
val id: Long,
|
||||
val displayName: String
|
||||
) {
|
||||
var photoURI: Uri? = null
|
||||
val msisdns = mutableListOf<MappedMsisdn>()
|
||||
val emails = mutableListOf<MappedEmail>()
|
||||
|
||||
fun build(): MappedContact {
|
||||
return MappedContact(
|
||||
id = id,
|
||||
displayName = displayName,
|
||||
photoURI = photoURI,
|
||||
msisdns = msisdns,
|
||||
emails = emails
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class MappedContact(
|
||||
val id: Long,
|
||||
val displayName: String,
|
||||
val photoURI: Uri? = null,
|
||||
val msisdns: List<MappedMsisdn> = emptyList(),
|
||||
val emails: List<MappedEmail> = emptyList()
|
||||
)
|
||||
|
||||
data class MappedEmail(
|
||||
val email: String,
|
||||
val matrixId: String?
|
||||
)
|
||||
|
||||
data class MappedMsisdn(
|
||||
val phoneNumber: String,
|
||||
val matrixId: String?
|
||||
)
|
@ -23,6 +23,7 @@ import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.multibindings.IntoMap
|
||||
import im.vector.riotx.features.attachments.preview.AttachmentsPreviewFragment
|
||||
import im.vector.riotx.features.contactsbook.ContactsBookFragment
|
||||
import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupSettingsFragment
|
||||
import im.vector.riotx.features.crypto.quads.SharedSecuredStorageKeyFragment
|
||||
import im.vector.riotx.features.crypto.quads.SharedSecuredStoragePassphraseFragment
|
||||
@ -528,4 +529,9 @@ interface FragmentModule {
|
||||
@IntoMap
|
||||
@FragmentKey(WidgetFragment::class)
|
||||
fun bindWidgetFragment(fragment: WidgetFragment): Fragment
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@FragmentKey(ContactsBookFragment::class)
|
||||
fun bindPhoneBookFragment(fragment: ContactsBookFragment): Fragment
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ package im.vector.riotx.core.epoxy.profiles
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import com.airbnb.epoxy.EpoxyAttribute
|
||||
import com.airbnb.epoxy.EpoxyModelClass
|
||||
import im.vector.matrix.android.api.crypto.RoomEncryptionTrustLevel
|
||||
@ -36,16 +37,21 @@ abstract class ProfileMatrixItem : VectorEpoxyModel<ProfileMatrixItem.Holder>()
|
||||
|
||||
@EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
|
||||
@EpoxyAttribute lateinit var matrixItem: MatrixItem
|
||||
@EpoxyAttribute var editable: Boolean = true
|
||||
@EpoxyAttribute var userEncryptionTrustLevel: RoomEncryptionTrustLevel? = null
|
||||
@EpoxyAttribute var clickListener: View.OnClickListener? = null
|
||||
|
||||
override fun bind(holder: Holder) {
|
||||
super.bind(holder)
|
||||
val bestName = matrixItem.getBestName()
|
||||
val matrixId = matrixItem.id.takeIf { it != bestName }
|
||||
holder.view.setOnClickListener(clickListener)
|
||||
val matrixId = matrixItem.id
|
||||
.takeIf { it != bestName }
|
||||
// Special case for ThreePid fake matrix item
|
||||
.takeIf { it != "@" }
|
||||
holder.view.setOnClickListener(clickListener?.takeIf { editable })
|
||||
holder.titleView.text = bestName
|
||||
holder.subtitleView.setTextOrHide(matrixId)
|
||||
holder.editableView.isVisible = editable
|
||||
avatarRenderer.render(matrixItem, holder.avatarImageView)
|
||||
holder.avatarDecorationImageView.setImageResource(userEncryptionTrustLevel.toImageRes())
|
||||
}
|
||||
@ -55,5 +61,6 @@ abstract class ProfileMatrixItem : VectorEpoxyModel<ProfileMatrixItem.Holder>()
|
||||
val subtitleView by bind<TextView>(R.id.matrixItemSubtitle)
|
||||
val avatarImageView by bind<ImageView>(R.id.matrixItemAvatar)
|
||||
val avatarDecorationImageView by bind<ImageView>(R.id.matrixItemAvatarDecoration)
|
||||
val editableView by bind<View>(R.id.matrixItemEditable)
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,9 @@ package im.vector.riotx.core.extensions
|
||||
import android.os.Bundle
|
||||
import android.util.Patterns
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.i18n.phonenumbers.NumberParseException
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import im.vector.matrix.android.api.extensions.ensurePrefix
|
||||
|
||||
fun Boolean.toOnOff() = if (this) "ON" else "OFF"
|
||||
|
||||
@ -33,3 +36,15 @@ fun <T : Fragment> T.withArgs(block: Bundle.() -> Unit) = apply { arguments = Bu
|
||||
* Check if a CharSequence is an email
|
||||
*/
|
||||
fun CharSequence.isEmail() = Patterns.EMAIL_ADDRESS.matcher(this).matches()
|
||||
|
||||
/**
|
||||
* Check if a CharSequence is a phone number
|
||||
*/
|
||||
fun CharSequence.isMsisdn(): Boolean {
|
||||
return try {
|
||||
PhoneNumberUtil.getInstance().parse(ensurePrefix("+"), null)
|
||||
true
|
||||
} catch (e: NumberParseException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
@ -38,13 +38,13 @@ inline fun <T, R : Comparable<R>> Iterable<T>.lastMinBy(selector: (T) -> R): T?
|
||||
/**
|
||||
* Call each for each item, and between between each items
|
||||
*/
|
||||
inline fun <T> Collection<T>.join(each: (T) -> Unit, between: (T) -> Unit) {
|
||||
inline fun <T> Collection<T>.join(each: (Int, T) -> Unit, between: (Int, T) -> Unit) {
|
||||
val lastIndex = size - 1
|
||||
forEachIndexed { idx, t ->
|
||||
each(t)
|
||||
each(idx, t)
|
||||
|
||||
if (idx != lastIndex) {
|
||||
between(t)
|
||||
between(idx, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -68,6 +68,7 @@ const val PERMISSION_REQUEST_CODE_DOWNLOAD_FILE = 575
|
||||
const val PERMISSION_REQUEST_CODE_PICK_ATTACHMENT = 576
|
||||
const val PERMISSION_REQUEST_CODE_INCOMING_URI = 577
|
||||
const val PERMISSION_REQUEST_CODE_PREVIEW_FRAGMENT = 578
|
||||
const val PERMISSION_REQUEST_CODE_READ_CONTACTS = 579
|
||||
|
||||
/**
|
||||
* Log the used permissions statuses.
|
||||
|
@ -17,6 +17,9 @@
|
||||
package im.vector.riotx.features.command
|
||||
|
||||
import im.vector.matrix.android.api.MatrixPatterns
|
||||
import im.vector.matrix.android.api.session.identity.ThreePid
|
||||
import im.vector.riotx.core.extensions.isEmail
|
||||
import im.vector.riotx.core.extensions.isMsisdn
|
||||
import timber.log.Timber
|
||||
|
||||
object CommandParser {
|
||||
@ -139,16 +142,25 @@ object CommandParser {
|
||||
if (messageParts.size >= 2) {
|
||||
val userId = messageParts[1]
|
||||
|
||||
if (MatrixPatterns.isUserId(userId)) {
|
||||
when {
|
||||
MatrixPatterns.isUserId(userId) -> {
|
||||
ParsedCommand.Invite(
|
||||
userId,
|
||||
textMessage.substring(Command.INVITE.length + userId.length)
|
||||
.trim()
|
||||
.takeIf { it.isNotBlank() }
|
||||
)
|
||||
} else {
|
||||
}
|
||||
userId.isEmail() -> {
|
||||
ParsedCommand.Invite3Pid(ThreePid.Email(userId))
|
||||
}
|
||||
userId.isMsisdn() -> {
|
||||
ParsedCommand.Invite3Pid(ThreePid.Msisdn(userId))
|
||||
}
|
||||
else -> {
|
||||
ParsedCommand.ErrorSyntax(Command.INVITE)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ParsedCommand.ErrorSyntax(Command.INVITE)
|
||||
}
|
||||
|
@ -16,6 +16,8 @@
|
||||
|
||||
package im.vector.riotx.features.command
|
||||
|
||||
import im.vector.matrix.android.api.session.identity.ThreePid
|
||||
|
||||
/**
|
||||
* Represent a parsed command
|
||||
*/
|
||||
@ -41,6 +43,7 @@ sealed class ParsedCommand {
|
||||
class UnbanUser(val userId: String, val reason: String?) : ParsedCommand()
|
||||
class SetUserPowerLevel(val userId: String, val powerLevel: Int?) : ParsedCommand()
|
||||
class Invite(val userId: String, val reason: String?) : ParsedCommand()
|
||||
class Invite3Pid(val threePid: ThreePid) : ParsedCommand()
|
||||
class JoinRoom(val roomAlias: String, val reason: String?) : ParsedCommand()
|
||||
class PartRoom(val roomAlias: String, val reason: String?) : ParsedCommand()
|
||||
class ChangeTopic(val topic: String) : ParsedCommand()
|
||||
|
@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.riotx.features.contactsbook
|
||||
|
||||
import android.widget.TextView
|
||||
import com.airbnb.epoxy.EpoxyAttribute
|
||||
import com.airbnb.epoxy.EpoxyModelClass
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.epoxy.ClickListener
|
||||
import im.vector.riotx.core.epoxy.VectorEpoxyHolder
|
||||
import im.vector.riotx.core.epoxy.VectorEpoxyModel
|
||||
import im.vector.riotx.core.epoxy.onClick
|
||||
import im.vector.riotx.core.extensions.setTextOrHide
|
||||
|
||||
@EpoxyModelClass(layout = R.layout.item_contact_detail)
|
||||
abstract class ContactDetailItem : VectorEpoxyModel<ContactDetailItem.Holder>() {
|
||||
|
||||
@EpoxyAttribute lateinit var threePid: String
|
||||
@EpoxyAttribute var matrixId: String? = null
|
||||
@EpoxyAttribute var clickListener: ClickListener? = null
|
||||
|
||||
override fun bind(holder: Holder) {
|
||||
super.bind(holder)
|
||||
holder.view.onClick(clickListener)
|
||||
holder.nameView.text = threePid
|
||||
holder.matrixIdView.setTextOrHide(matrixId)
|
||||
}
|
||||
|
||||
class Holder : VectorEpoxyHolder() {
|
||||
val nameView by bind<TextView>(R.id.contactDetailName)
|
||||
val matrixIdView by bind<TextView>(R.id.contactDetailMatrixId)
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.riotx.features.contactsbook
|
||||
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import com.airbnb.epoxy.EpoxyAttribute
|
||||
import com.airbnb.epoxy.EpoxyModelClass
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.contacts.MappedContact
|
||||
import im.vector.riotx.core.epoxy.VectorEpoxyHolder
|
||||
import im.vector.riotx.core.epoxy.VectorEpoxyModel
|
||||
import im.vector.riotx.features.home.AvatarRenderer
|
||||
|
||||
@EpoxyModelClass(layout = R.layout.item_contact_main)
|
||||
abstract class ContactItem : VectorEpoxyModel<ContactItem.Holder>() {
|
||||
|
||||
@EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
|
||||
@EpoxyAttribute lateinit var mappedContact: MappedContact
|
||||
|
||||
override fun bind(holder: Holder) {
|
||||
super.bind(holder)
|
||||
// If name is empty, use userId as name and force it being centered
|
||||
holder.nameView.text = mappedContact.displayName
|
||||
avatarRenderer.render(mappedContact, holder.avatarImageView)
|
||||
}
|
||||
|
||||
class Holder : VectorEpoxyHolder() {
|
||||
val nameView by bind<TextView>(R.id.contactDisplayName)
|
||||
val avatarImageView by bind<ImageView>(R.id.contactAvatar)
|
||||
}
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.riotx.features.contactsbook
|
||||
|
||||
import im.vector.riotx.core.platform.VectorViewModelAction
|
||||
|
||||
sealed class ContactsBookAction : VectorViewModelAction {
|
||||
data class FilterWith(val filter: String) : ContactsBookAction()
|
||||
data class OnlyBoundContacts(val onlyBoundContacts: Boolean) : ContactsBookAction()
|
||||
}
|
@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.riotx.features.contactsbook
|
||||
|
||||
import com.airbnb.epoxy.EpoxyController
|
||||
import com.airbnb.mvrx.Fail
|
||||
import com.airbnb.mvrx.Loading
|
||||
import com.airbnb.mvrx.Success
|
||||
import com.airbnb.mvrx.Uninitialized
|
||||
import im.vector.matrix.android.api.session.identity.ThreePid
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.contacts.MappedContact
|
||||
import im.vector.riotx.core.epoxy.errorWithRetryItem
|
||||
import im.vector.riotx.core.epoxy.loadingItem
|
||||
import im.vector.riotx.core.epoxy.noResultItem
|
||||
import im.vector.riotx.core.error.ErrorFormatter
|
||||
import im.vector.riotx.core.resources.StringProvider
|
||||
import im.vector.riotx.features.home.AvatarRenderer
|
||||
import javax.inject.Inject
|
||||
|
||||
class ContactsBookController @Inject constructor(
|
||||
private val stringProvider: StringProvider,
|
||||
private val avatarRenderer: AvatarRenderer,
|
||||
private val errorFormatter: ErrorFormatter) : EpoxyController() {
|
||||
|
||||
private var state: ContactsBookViewState? = null
|
||||
|
||||
var callback: Callback? = null
|
||||
|
||||
init {
|
||||
requestModelBuild()
|
||||
}
|
||||
|
||||
fun setData(state: ContactsBookViewState) {
|
||||
this.state = state
|
||||
requestModelBuild()
|
||||
}
|
||||
|
||||
override fun buildModels() {
|
||||
val currentState = state ?: return
|
||||
val hasSearch = currentState.searchTerm.isNotEmpty()
|
||||
when (val asyncMappedContacts = currentState.mappedContacts) {
|
||||
is Uninitialized -> renderEmptyState(false)
|
||||
is Loading -> renderLoading()
|
||||
is Success -> renderSuccess(currentState.filteredMappedContacts, hasSearch, currentState.onlyBoundContacts)
|
||||
is Fail -> renderFailure(asyncMappedContacts.error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderLoading() {
|
||||
loadingItem {
|
||||
id("loading")
|
||||
loadingText(stringProvider.getString(R.string.loading_contact_book))
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderFailure(failure: Throwable) {
|
||||
errorWithRetryItem {
|
||||
id("error")
|
||||
text(errorFormatter.toHumanReadable(failure))
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderSuccess(mappedContacts: List<MappedContact>,
|
||||
hasSearch: Boolean,
|
||||
onlyBoundContacts: Boolean) {
|
||||
if (mappedContacts.isEmpty()) {
|
||||
renderEmptyState(hasSearch)
|
||||
} else {
|
||||
renderContacts(mappedContacts, onlyBoundContacts)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderContacts(mappedContacts: List<MappedContact>, onlyBoundContacts: Boolean) {
|
||||
for (mappedContact in mappedContacts) {
|
||||
contactItem {
|
||||
id(mappedContact.id)
|
||||
mappedContact(mappedContact)
|
||||
avatarRenderer(avatarRenderer)
|
||||
}
|
||||
mappedContact.emails
|
||||
.forEachIndexed { index, it ->
|
||||
if (onlyBoundContacts && it.matrixId == null) return@forEachIndexed
|
||||
|
||||
contactDetailItem {
|
||||
id("${mappedContact.id}-e-$index-${it.email}")
|
||||
threePid(it.email)
|
||||
matrixId(it.matrixId)
|
||||
clickListener {
|
||||
if (it.matrixId != null) {
|
||||
callback?.onMatrixIdClick(it.matrixId)
|
||||
} else {
|
||||
callback?.onThreePidClick(ThreePid.Email(it.email))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mappedContact.msisdns
|
||||
.forEachIndexed { index, it ->
|
||||
if (onlyBoundContacts && it.matrixId == null) return@forEachIndexed
|
||||
|
||||
contactDetailItem {
|
||||
id("${mappedContact.id}-m-$index-${it.phoneNumber}")
|
||||
threePid(it.phoneNumber)
|
||||
matrixId(it.matrixId)
|
||||
clickListener {
|
||||
if (it.matrixId != null) {
|
||||
callback?.onMatrixIdClick(it.matrixId)
|
||||
} else {
|
||||
callback?.onThreePidClick(ThreePid.Msisdn(it.phoneNumber))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderEmptyState(hasSearch: Boolean) {
|
||||
val noResultRes = if (hasSearch) {
|
||||
R.string.no_result_placeholder
|
||||
} else {
|
||||
R.string.empty_contact_book
|
||||
}
|
||||
noResultItem {
|
||||
id("noResult")
|
||||
text(stringProvider.getString(noResultRes))
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onMatrixIdClick(matrixId: String)
|
||||
fun onThreePidClick(threePid: ThreePid)
|
||||
}
|
||||
}
|
@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.riotx.features.contactsbook
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
import com.airbnb.mvrx.activityViewModel
|
||||
import com.airbnb.mvrx.withState
|
||||
import com.jakewharton.rxbinding3.widget.checkedChanges
|
||||
import com.jakewharton.rxbinding3.widget.textChanges
|
||||
import im.vector.matrix.android.api.session.identity.ThreePid
|
||||
import im.vector.matrix.android.api.session.user.model.User
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.extensions.cleanup
|
||||
import im.vector.riotx.core.extensions.configureWith
|
||||
import im.vector.riotx.core.extensions.hideKeyboard
|
||||
import im.vector.riotx.core.platform.VectorBaseFragment
|
||||
import im.vector.riotx.features.userdirectory.PendingInvitee
|
||||
import im.vector.riotx.features.userdirectory.UserDirectoryAction
|
||||
import im.vector.riotx.features.userdirectory.UserDirectorySharedAction
|
||||
import im.vector.riotx.features.userdirectory.UserDirectorySharedActionViewModel
|
||||
import im.vector.riotx.features.userdirectory.UserDirectoryViewModel
|
||||
import kotlinx.android.synthetic.main.fragment_contacts_book.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
class ContactsBookFragment @Inject constructor(
|
||||
val contactsBookViewModelFactory: ContactsBookViewModel.Factory,
|
||||
private val contactsBookController: ContactsBookController
|
||||
) : VectorBaseFragment(), ContactsBookController.Callback {
|
||||
|
||||
override fun getLayoutResId() = R.layout.fragment_contacts_book
|
||||
private val viewModel: UserDirectoryViewModel by activityViewModel()
|
||||
|
||||
// Use activityViewModel to avoid loading several times the data
|
||||
private val contactsBookViewModel: ContactsBookViewModel by activityViewModel()
|
||||
|
||||
private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
sharedActionViewModel = activityViewModelProvider.get(UserDirectorySharedActionViewModel::class.java)
|
||||
setupRecyclerView()
|
||||
setupFilterView()
|
||||
setupOnlyBoundContactsView()
|
||||
setupCloseView()
|
||||
}
|
||||
|
||||
private fun setupOnlyBoundContactsView() {
|
||||
phoneBookOnlyBoundContacts.checkedChanges()
|
||||
.subscribe {
|
||||
contactsBookViewModel.handle(ContactsBookAction.OnlyBoundContacts(it))
|
||||
}
|
||||
.disposeOnDestroyView()
|
||||
}
|
||||
|
||||
private fun setupFilterView() {
|
||||
phoneBookFilter
|
||||
.textChanges()
|
||||
.skipInitialValue()
|
||||
.debounce(300, TimeUnit.MILLISECONDS)
|
||||
.subscribe {
|
||||
contactsBookViewModel.handle(ContactsBookAction.FilterWith(it.toString()))
|
||||
}
|
||||
.disposeOnDestroyView()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
phoneBookRecyclerView.cleanup()
|
||||
contactsBookController.callback = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
contactsBookController.callback = this
|
||||
phoneBookRecyclerView.configureWith(contactsBookController)
|
||||
}
|
||||
|
||||
private fun setupCloseView() {
|
||||
phoneBookClose.debouncedClicks {
|
||||
sharedActionViewModel.post(UserDirectorySharedAction.GoBack)
|
||||
}
|
||||
}
|
||||
|
||||
override fun invalidate() = withState(contactsBookViewModel) { state ->
|
||||
phoneBookOnlyBoundContacts.isVisible = state.isBoundRetrieved
|
||||
contactsBookController.setData(state)
|
||||
}
|
||||
|
||||
override fun onMatrixIdClick(matrixId: String) {
|
||||
view?.hideKeyboard()
|
||||
viewModel.handle(UserDirectoryAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(User(matrixId))))
|
||||
sharedActionViewModel.post(UserDirectorySharedAction.GoBack)
|
||||
}
|
||||
|
||||
override fun onThreePidClick(threePid: ThreePid) {
|
||||
view?.hideKeyboard()
|
||||
viewModel.handle(UserDirectoryAction.SelectPendingInvitee(PendingInvitee.ThreePidPendingInvitee(threePid)))
|
||||
sharedActionViewModel.post(UserDirectorySharedAction.GoBack)
|
||||
}
|
||||
}
|
@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.riotx.features.contactsbook
|
||||
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.airbnb.mvrx.ActivityViewModelContext
|
||||
import com.airbnb.mvrx.FragmentViewModelContext
|
||||
import com.airbnb.mvrx.Loading
|
||||
import com.airbnb.mvrx.MvRxViewModelFactory
|
||||
import com.airbnb.mvrx.Success
|
||||
import com.airbnb.mvrx.ViewModelContext
|
||||
import com.squareup.inject.assisted.Assisted
|
||||
import com.squareup.inject.assisted.AssistedInject
|
||||
import im.vector.matrix.android.api.MatrixCallback
|
||||
import im.vector.matrix.android.api.session.Session
|
||||
import im.vector.matrix.android.api.session.identity.FoundThreePid
|
||||
import im.vector.matrix.android.api.session.identity.ThreePid
|
||||
import im.vector.riotx.core.contacts.ContactsDataSource
|
||||
import im.vector.riotx.core.contacts.MappedContact
|
||||
import im.vector.riotx.core.extensions.exhaustive
|
||||
import im.vector.riotx.core.platform.EmptyViewEvents
|
||||
import im.vector.riotx.core.platform.VectorViewModel
|
||||
import im.vector.riotx.features.createdirect.CreateDirectRoomActivity
|
||||
import im.vector.riotx.features.invite.InviteUsersToRoomActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
private typealias PhoneBookSearch = String
|
||||
|
||||
class ContactsBookViewModel @AssistedInject constructor(@Assisted
|
||||
initialState: ContactsBookViewState,
|
||||
private val contactsDataSource: ContactsDataSource,
|
||||
private val session: Session)
|
||||
: VectorViewModel<ContactsBookViewState, ContactsBookAction, EmptyViewEvents>(initialState) {
|
||||
|
||||
@AssistedInject.Factory
|
||||
interface Factory {
|
||||
fun create(initialState: ContactsBookViewState): ContactsBookViewModel
|
||||
}
|
||||
|
||||
companion object : MvRxViewModelFactory<ContactsBookViewModel, ContactsBookViewState> {
|
||||
|
||||
override fun create(viewModelContext: ViewModelContext, state: ContactsBookViewState): ContactsBookViewModel? {
|
||||
return when (viewModelContext) {
|
||||
is FragmentViewModelContext -> (viewModelContext.fragment() as ContactsBookFragment).contactsBookViewModelFactory.create(state)
|
||||
is ActivityViewModelContext -> {
|
||||
when (viewModelContext.activity<FragmentActivity>()) {
|
||||
is CreateDirectRoomActivity -> viewModelContext.activity<CreateDirectRoomActivity>().contactsBookViewModelFactory.create(state)
|
||||
is InviteUsersToRoomActivity -> viewModelContext.activity<InviteUsersToRoomActivity>().contactsBookViewModelFactory.create(state)
|
||||
else -> error("Wrong activity or fragment")
|
||||
}
|
||||
}
|
||||
else -> error("Wrong activity or fragment")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var allContacts: List<MappedContact> = emptyList()
|
||||
private var mappedContacts: List<MappedContact> = emptyList()
|
||||
|
||||
init {
|
||||
loadContacts()
|
||||
|
||||
selectSubscribe(ContactsBookViewState::searchTerm, ContactsBookViewState::onlyBoundContacts) { _, _ ->
|
||||
updateFilteredMappedContacts()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadContacts() {
|
||||
setState {
|
||||
copy(
|
||||
mappedContacts = Loading()
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
allContacts = contactsDataSource.getContacts(
|
||||
withEmails = true,
|
||||
// Do not handle phone numbers for the moment
|
||||
withMsisdn = false
|
||||
)
|
||||
mappedContacts = allContacts
|
||||
|
||||
setState {
|
||||
copy(
|
||||
mappedContacts = Success(allContacts)
|
||||
)
|
||||
}
|
||||
|
||||
performLookup(allContacts)
|
||||
updateFilteredMappedContacts()
|
||||
}
|
||||
}
|
||||
|
||||
private fun performLookup(data: List<MappedContact>) {
|
||||
viewModelScope.launch {
|
||||
val threePids = data.flatMap { contact ->
|
||||
contact.emails.map { ThreePid.Email(it.email) } +
|
||||
contact.msisdns.map { ThreePid.Msisdn(it.phoneNumber) }
|
||||
}
|
||||
session.identityService().lookUp(threePids, object : MatrixCallback<List<FoundThreePid>> {
|
||||
override fun onFailure(failure: Throwable) {
|
||||
// Ignore
|
||||
Timber.w(failure, "Unable to perform the lookup")
|
||||
}
|
||||
|
||||
override fun onSuccess(data: List<FoundThreePid>) {
|
||||
mappedContacts = allContacts.map { contactModel ->
|
||||
contactModel.copy(
|
||||
emails = contactModel.emails.map { email ->
|
||||
email.copy(
|
||||
matrixId = data
|
||||
.firstOrNull { foundThreePid -> foundThreePid.threePid.value == email.email }
|
||||
?.matrixId
|
||||
)
|
||||
},
|
||||
msisdns = contactModel.msisdns.map { msisdn ->
|
||||
msisdn.copy(
|
||||
matrixId = data
|
||||
.firstOrNull { foundThreePid -> foundThreePid.threePid.value == msisdn.phoneNumber }
|
||||
?.matrixId
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
setState {
|
||||
copy(
|
||||
isBoundRetrieved = true
|
||||
)
|
||||
}
|
||||
|
||||
updateFilteredMappedContacts()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFilteredMappedContacts() = withState { state ->
|
||||
val filteredMappedContacts = mappedContacts
|
||||
.filter { it.displayName.contains(state.searchTerm, true) }
|
||||
.filter { contactModel ->
|
||||
!state.onlyBoundContacts
|
||||
|| contactModel.emails.any { it.matrixId != null } || contactModel.msisdns.any { it.matrixId != null }
|
||||
}
|
||||
|
||||
setState {
|
||||
copy(
|
||||
filteredMappedContacts = filteredMappedContacts
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun handle(action: ContactsBookAction) {
|
||||
when (action) {
|
||||
is ContactsBookAction.FilterWith -> handleFilterWith(action)
|
||||
is ContactsBookAction.OnlyBoundContacts -> handleOnlyBoundContacts(action)
|
||||
}.exhaustive
|
||||
}
|
||||
|
||||
private fun handleOnlyBoundContacts(action: ContactsBookAction.OnlyBoundContacts) {
|
||||
setState {
|
||||
copy(
|
||||
onlyBoundContacts = action.onlyBoundContacts
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFilterWith(action: ContactsBookAction.FilterWith) {
|
||||
setState {
|
||||
copy(
|
||||
searchTerm = action.filter
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.riotx.features.contactsbook
|
||||
|
||||
import com.airbnb.mvrx.Async
|
||||
import com.airbnb.mvrx.Loading
|
||||
import com.airbnb.mvrx.MvRxState
|
||||
import im.vector.riotx.core.contacts.MappedContact
|
||||
|
||||
data class ContactsBookViewState(
|
||||
// All the contacts on the phone
|
||||
val mappedContacts: Async<List<MappedContact>> = Loading(),
|
||||
// Use to filter contacts by display name
|
||||
val searchTerm: String = "",
|
||||
// Tru to display only bound contacts with their bound 2pid
|
||||
val onlyBoundContacts: Boolean = false,
|
||||
// All contacts, filtered by searchTerm and onlyBoundContacts
|
||||
val filteredMappedContacts: List<MappedContact> = emptyList(),
|
||||
// True when the identity service has return some data
|
||||
val isBoundRetrieved: Boolean = false
|
||||
) : MvRxState
|
@ -16,9 +16,9 @@
|
||||
|
||||
package im.vector.riotx.features.createdirect
|
||||
|
||||
import im.vector.matrix.android.api.session.user.model.User
|
||||
import im.vector.riotx.core.platform.VectorViewModelAction
|
||||
import im.vector.riotx.features.userdirectory.PendingInvitee
|
||||
|
||||
sealed class CreateDirectRoomAction : VectorViewModelAction {
|
||||
data class CreateRoomAndInviteSelectedUsers(val selectedUsers: Set<User>) : CreateDirectRoomAction()
|
||||
data class CreateRoomAndInviteSelectedUsers(val invitees: Set<PendingInvitee>) : CreateDirectRoomAction()
|
||||
}
|
||||
|
@ -35,8 +35,15 @@ import im.vector.riotx.core.di.ScreenComponent
|
||||
import im.vector.riotx.core.error.ErrorFormatter
|
||||
import im.vector.riotx.core.extensions.addFragment
|
||||
import im.vector.riotx.core.extensions.addFragmentToBackstack
|
||||
import im.vector.riotx.core.extensions.exhaustive
|
||||
import im.vector.riotx.core.platform.SimpleFragmentActivity
|
||||
import im.vector.riotx.core.platform.WaitingViewData
|
||||
import im.vector.riotx.core.utils.PERMISSIONS_FOR_MEMBERS_SEARCH
|
||||
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_READ_CONTACTS
|
||||
import im.vector.riotx.core.utils.allGranted
|
||||
import im.vector.riotx.core.utils.checkPermissions
|
||||
import im.vector.riotx.features.contactsbook.ContactsBookFragment
|
||||
import im.vector.riotx.features.contactsbook.ContactsBookViewModel
|
||||
import im.vector.riotx.features.userdirectory.KnownUsersFragment
|
||||
import im.vector.riotx.features.userdirectory.KnownUsersFragmentArgs
|
||||
import im.vector.riotx.features.userdirectory.UserDirectoryFragment
|
||||
@ -53,6 +60,7 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
|
||||
private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel
|
||||
@Inject lateinit var userDirectoryViewModelFactory: UserDirectoryViewModel.Factory
|
||||
@Inject lateinit var createDirectRoomViewModelFactory: CreateDirectRoomViewModel.Factory
|
||||
@Inject lateinit var contactsBookViewModelFactory: ContactsBookViewModel.Factory
|
||||
@Inject lateinit var errorFormatter: ErrorFormatter
|
||||
|
||||
override fun injectWith(injector: ScreenComponent) {
|
||||
@ -73,7 +81,8 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
|
||||
UserDirectorySharedAction.Close -> finish()
|
||||
UserDirectorySharedAction.GoBack -> onBackPressed()
|
||||
is UserDirectorySharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction)
|
||||
}
|
||||
UserDirectorySharedAction.OpenPhoneBook -> openPhoneBook()
|
||||
}.exhaustive
|
||||
}
|
||||
.disposeOnDestroy()
|
||||
if (isFirstCreation()) {
|
||||
@ -91,9 +100,27 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPhoneBook() {
|
||||
// Check permission first
|
||||
if (checkPermissions(PERMISSIONS_FOR_MEMBERS_SEARCH,
|
||||
this,
|
||||
PERMISSION_REQUEST_CODE_READ_CONTACTS,
|
||||
0)) {
|
||||
addFragmentToBackstack(R.id.container, ContactsBookFragment::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
|
||||
if (allGranted(grantResults)) {
|
||||
if (requestCode == PERMISSION_REQUEST_CODE_READ_CONTACTS) {
|
||||
addFragmentToBackstack(R.id.container, ContactsBookFragment::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMenuItemSelected(action: UserDirectorySharedAction.OnMenuItemSelected) {
|
||||
if (action.itemId == R.id.action_create_direct_room) {
|
||||
viewModel.handle(CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(action.selectedUsers))
|
||||
viewModel.handle(CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(action.invitees))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,9 +23,10 @@ import com.squareup.inject.assisted.Assisted
|
||||
import com.squareup.inject.assisted.AssistedInject
|
||||
import im.vector.matrix.android.api.session.Session
|
||||
import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams
|
||||
import im.vector.matrix.android.api.session.user.model.User
|
||||
import im.vector.matrix.rx.rx
|
||||
import im.vector.riotx.core.extensions.exhaustive
|
||||
import im.vector.riotx.core.platform.VectorViewModel
|
||||
import im.vector.riotx.features.userdirectory.PendingInvitee
|
||||
|
||||
class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
|
||||
initialState: CreateDirectRoomViewState,
|
||||
@ -48,16 +49,22 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
|
||||
|
||||
override fun handle(action: CreateDirectRoomAction) {
|
||||
when (action) {
|
||||
is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> createRoomAndInviteSelectedUsers(action.selectedUsers)
|
||||
is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> createRoomAndInviteSelectedUsers(action.invitees)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRoomAndInviteSelectedUsers(selectedUsers: Set<User>) {
|
||||
val roomParams = CreateRoomParams(
|
||||
invitedUserIds = selectedUsers.map { it.userId }
|
||||
)
|
||||
.setDirectMessage()
|
||||
.enableEncryptionIfInvitedUsersSupportIt()
|
||||
private fun createRoomAndInviteSelectedUsers(invitees: Set<PendingInvitee>) {
|
||||
val roomParams = CreateRoomParams()
|
||||
.apply {
|
||||
invitees.forEach {
|
||||
when (it) {
|
||||
is PendingInvitee.UserPendingInvitee -> invitedUserIds.add(it.user.userId)
|
||||
is PendingInvitee.ThreePidPendingInvitee -> invite3pids.add(it.threePid)
|
||||
}.exhaustive
|
||||
}
|
||||
setDirectMessage()
|
||||
enableEncryptionIfInvitedUsersSupportIt = true
|
||||
}
|
||||
|
||||
session.rx()
|
||||
.createRoom(roomParams)
|
||||
|
@ -235,11 +235,12 @@ class VerificationBottomSheetViewModel @AssistedInject constructor(
|
||||
pendingRequest = Loading()
|
||||
)
|
||||
}
|
||||
val roomParams = CreateRoomParams(
|
||||
invitedUserIds = listOf(otherUserId)
|
||||
)
|
||||
.setDirectMessage()
|
||||
.enableEncryptionIfInvitedUsersSupportIt()
|
||||
val roomParams = CreateRoomParams()
|
||||
.apply {
|
||||
invitedUserIds.add(otherUserId)
|
||||
setDirectMessage()
|
||||
enableEncryptionIfInvitedUsersSupportIt = true
|
||||
}
|
||||
|
||||
session.createRoom(roomParams, object : MatrixCallback<String> {
|
||||
override fun onSuccess(data: String) {
|
||||
|
@ -30,6 +30,7 @@ import com.bumptech.glide.request.target.DrawableImageViewTarget
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import im.vector.matrix.android.api.session.content.ContentUrlResolver
|
||||
import im.vector.matrix.android.api.util.MatrixItem
|
||||
import im.vector.riotx.core.contacts.MappedContact
|
||||
import im.vector.riotx.core.di.ActiveSessionHolder
|
||||
import im.vector.riotx.core.glide.GlideApp
|
||||
import im.vector.riotx.core.glide.GlideRequest
|
||||
@ -63,6 +64,23 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active
|
||||
DrawableImageViewTarget(imageView))
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun render(mappedContact: MappedContact, imageView: ImageView) {
|
||||
// Create a Fake MatrixItem, for the placeholder
|
||||
val matrixItem = MatrixItem.UserItem(
|
||||
// Need an id starting with @
|
||||
id = "@${mappedContact.displayName}",
|
||||
displayName = mappedContact.displayName
|
||||
)
|
||||
|
||||
val placeholder = getPlaceholderDrawable(imageView.context, matrixItem)
|
||||
GlideApp.with(imageView)
|
||||
.load(mappedContact.photoURI)
|
||||
.apply(RequestOptions.circleCropTransform())
|
||||
.placeholder(placeholder)
|
||||
.into(imageView)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun render(context: Context,
|
||||
glideRequests: GlideRequests,
|
||||
|
@ -960,7 +960,7 @@ class RoomDetailFragment @Inject constructor(
|
||||
updateComposerText("")
|
||||
}
|
||||
is RoomDetailViewEvents.SlashCommandResultError -> {
|
||||
displayCommandError(sendMessageResult.throwable.localizedMessage ?: getString(R.string.unexpected_error))
|
||||
displayCommandError(errorFormatter.toHumanReadable(sendMessageResult.throwable))
|
||||
}
|
||||
is RoomDetailViewEvents.SlashCommandNotImplemented -> {
|
||||
displayCommandError(getString(R.string.not_implemented))
|
||||
|
@ -457,6 +457,10 @@ class RoomDetailViewModel @AssistedInject constructor(
|
||||
handleInviteSlashCommand(slashCommandResult)
|
||||
popDraft()
|
||||
}
|
||||
is ParsedCommand.Invite3Pid -> {
|
||||
handleInvite3pidSlashCommand(slashCommandResult)
|
||||
popDraft()
|
||||
}
|
||||
is ParsedCommand.SetUserPowerLevel -> {
|
||||
handleSetUserPowerLevel(slashCommandResult)
|
||||
popDraft()
|
||||
@ -678,6 +682,12 @@ class RoomDetailViewModel @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInvite3pidSlashCommand(invite: ParsedCommand.Invite3Pid) {
|
||||
launchSlashCommandFlow {
|
||||
room.invite3pid(invite.threePid, it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSetUserPowerLevel(setUserPowerLevel: ParsedCommand.SetUserPowerLevel) {
|
||||
val currentPowerLevelsContent = room.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS)
|
||||
?.content
|
||||
|
@ -50,6 +50,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
|
||||
EventType.STATE_ROOM_TOPIC,
|
||||
EventType.STATE_ROOM_AVATAR,
|
||||
EventType.STATE_ROOM_MEMBER,
|
||||
EventType.STATE_ROOM_THIRD_PARTY_INVITE,
|
||||
EventType.STATE_ROOM_ALIASES,
|
||||
EventType.STATE_ROOM_CANONICAL_ALIAS,
|
||||
EventType.STATE_ROOM_JOIN_RULES,
|
||||
@ -96,8 +97,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
|
||||
verificationConclusionItemFactory.create(event, highlight, callback)
|
||||
}
|
||||
|
||||
// Unhandled event types (yet)
|
||||
EventType.STATE_ROOM_THIRD_PARTY_INVITE -> defaultItemFactory.create(event, highlight, callback)
|
||||
// Unhandled event types
|
||||
else -> {
|
||||
// Should only happen when shouldShowHiddenEvents() settings is ON
|
||||
Timber.v("Type ${event.root.getClearType()} not handled")
|
||||
|
@ -32,6 +32,7 @@ import im.vector.matrix.android.api.session.room.model.RoomJoinRules
|
||||
import im.vector.matrix.android.api.session.room.model.RoomJoinRulesContent
|
||||
import im.vector.matrix.android.api.session.room.model.RoomMemberContent
|
||||
import im.vector.matrix.android.api.session.room.model.RoomNameContent
|
||||
import im.vector.matrix.android.api.session.room.model.RoomThirdPartyInviteContent
|
||||
import im.vector.matrix.android.api.session.room.model.RoomTopicContent
|
||||
import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
|
||||
import im.vector.matrix.android.api.session.room.model.create.RoomCreateContent
|
||||
@ -63,6 +64,7 @@ class NoticeEventFormatter @Inject constructor(private val activeSessionDataSour
|
||||
EventType.STATE_ROOM_TOPIC -> formatRoomTopicEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
|
||||
EventType.STATE_ROOM_AVATAR -> formatRoomAvatarEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
|
||||
EventType.STATE_ROOM_MEMBER -> formatRoomMemberEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
|
||||
EventType.STATE_ROOM_THIRD_PARTY_INVITE -> formatRoomThirdPartyInvite(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
|
||||
EventType.STATE_ROOM_ALIASES -> formatRoomAliasesEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
|
||||
EventType.STATE_ROOM_CANONICAL_ALIAS -> formatRoomCanonicalAliasEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
|
||||
EventType.STATE_ROOM_HISTORY_VISIBILITY -> formatRoomHistoryVisibilityEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
|
||||
@ -156,6 +158,7 @@ class NoticeEventFormatter @Inject constructor(private val activeSessionDataSour
|
||||
EventType.STATE_ROOM_TOPIC -> formatRoomTopicEvent(event, senderName)
|
||||
EventType.STATE_ROOM_AVATAR -> formatRoomAvatarEvent(event, senderName)
|
||||
EventType.STATE_ROOM_MEMBER -> formatRoomMemberEvent(event, senderName)
|
||||
EventType.STATE_ROOM_THIRD_PARTY_INVITE -> formatRoomThirdPartyInvite(event, senderName)
|
||||
EventType.STATE_ROOM_HISTORY_VISIBILITY -> formatRoomHistoryVisibilityEvent(event, senderName)
|
||||
EventType.CALL_INVITE,
|
||||
EventType.CALL_HANGUP,
|
||||
@ -254,6 +257,31 @@ class NoticeEventFormatter @Inject constructor(private val activeSessionDataSour
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatRoomThirdPartyInvite(event: Event, senderName: String?): CharSequence? {
|
||||
val content = event.getClearContent().toModel<RoomThirdPartyInviteContent>()
|
||||
val prevContent = event.resolvedPrevContent()?.toModel<RoomThirdPartyInviteContent>()
|
||||
|
||||
return when {
|
||||
prevContent != null -> {
|
||||
// Revoke case
|
||||
if (event.isSentByCurrentUser()) {
|
||||
sp.getString(R.string.notice_room_third_party_revoked_invite_by_you, prevContent.displayName)
|
||||
} else {
|
||||
sp.getString(R.string.notice_room_third_party_revoked_invite, senderName, prevContent.displayName)
|
||||
}
|
||||
}
|
||||
content != null -> {
|
||||
// Invitation case
|
||||
if (event.isSentByCurrentUser()) {
|
||||
sp.getString(R.string.notice_room_third_party_invite_by_you, content.displayName)
|
||||
} else {
|
||||
sp.getString(R.string.notice_room_third_party_invite, senderName, content.displayName)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatCallEvent(type: String, event: Event, senderName: String?): CharSequence? {
|
||||
return when (type) {
|
||||
EventType.CALL_INVITE -> {
|
||||
|
@ -16,9 +16,9 @@
|
||||
|
||||
package im.vector.riotx.features.invite
|
||||
|
||||
import im.vector.matrix.android.api.session.user.model.User
|
||||
import im.vector.riotx.core.platform.VectorViewModelAction
|
||||
import im.vector.riotx.features.userdirectory.PendingInvitee
|
||||
|
||||
sealed class InviteUsersToRoomAction : VectorViewModelAction {
|
||||
data class InviteSelectedUsers(val selectedUsers: Set<User>) : InviteUsersToRoomAction()
|
||||
data class InviteSelectedUsers(val invitees: Set<PendingInvitee>) : InviteUsersToRoomAction()
|
||||
}
|
||||
|
@ -30,9 +30,16 @@ import im.vector.riotx.core.di.ScreenComponent
|
||||
import im.vector.riotx.core.error.ErrorFormatter
|
||||
import im.vector.riotx.core.extensions.addFragment
|
||||
import im.vector.riotx.core.extensions.addFragmentToBackstack
|
||||
import im.vector.riotx.core.extensions.exhaustive
|
||||
import im.vector.riotx.core.platform.SimpleFragmentActivity
|
||||
import im.vector.riotx.core.platform.WaitingViewData
|
||||
import im.vector.riotx.core.utils.PERMISSIONS_FOR_MEMBERS_SEARCH
|
||||
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_READ_CONTACTS
|
||||
import im.vector.riotx.core.utils.allGranted
|
||||
import im.vector.riotx.core.utils.checkPermissions
|
||||
import im.vector.riotx.core.utils.toast
|
||||
import im.vector.riotx.features.contactsbook.ContactsBookFragment
|
||||
import im.vector.riotx.features.contactsbook.ContactsBookViewModel
|
||||
import im.vector.riotx.features.userdirectory.KnownUsersFragment
|
||||
import im.vector.riotx.features.userdirectory.KnownUsersFragmentArgs
|
||||
import im.vector.riotx.features.userdirectory.UserDirectoryFragment
|
||||
@ -53,6 +60,7 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() {
|
||||
private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel
|
||||
@Inject lateinit var userDirectoryViewModelFactory: UserDirectoryViewModel.Factory
|
||||
@Inject lateinit var inviteUsersToRoomViewModelFactory: InviteUsersToRoomViewModel.Factory
|
||||
@Inject lateinit var contactsBookViewModelFactory: ContactsBookViewModel.Factory
|
||||
@Inject lateinit var errorFormatter: ErrorFormatter
|
||||
|
||||
override fun injectWith(injector: ScreenComponent) {
|
||||
@ -74,7 +82,8 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() {
|
||||
UserDirectorySharedAction.Close -> finish()
|
||||
UserDirectorySharedAction.GoBack -> onBackPressed()
|
||||
is UserDirectorySharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction)
|
||||
}
|
||||
UserDirectorySharedAction.OpenPhoneBook -> openPhoneBook()
|
||||
}.exhaustive
|
||||
}
|
||||
.disposeOnDestroy()
|
||||
if (isFirstCreation()) {
|
||||
@ -92,9 +101,27 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() {
|
||||
viewModel.observeViewEvents { renderInviteEvents(it) }
|
||||
}
|
||||
|
||||
private fun openPhoneBook() {
|
||||
// Check permission first
|
||||
if (checkPermissions(PERMISSIONS_FOR_MEMBERS_SEARCH,
|
||||
this,
|
||||
PERMISSION_REQUEST_CODE_READ_CONTACTS,
|
||||
0)) {
|
||||
addFragmentToBackstack(R.id.container, ContactsBookFragment::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
|
||||
if (allGranted(grantResults)) {
|
||||
if (requestCode == PERMISSION_REQUEST_CODE_READ_CONTACTS) {
|
||||
addFragmentToBackstack(R.id.container, ContactsBookFragment::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMenuItemSelected(action: UserDirectorySharedAction.OnMenuItemSelected) {
|
||||
if (action.itemId == R.id.action_invite_users_to_room_invite) {
|
||||
viewModel.handle(InviteUsersToRoomAction.InviteSelectedUsers(action.selectedUsers))
|
||||
viewModel.handle(InviteUsersToRoomAction.InviteSelectedUsers(action.invitees))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -22,11 +22,11 @@ import com.airbnb.mvrx.ViewModelContext
|
||||
import com.squareup.inject.assisted.Assisted
|
||||
import com.squareup.inject.assisted.AssistedInject
|
||||
import im.vector.matrix.android.api.session.Session
|
||||
import im.vector.matrix.android.api.session.user.model.User
|
||||
import im.vector.matrix.rx.rx
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.platform.VectorViewModel
|
||||
import im.vector.riotx.core.resources.StringProvider
|
||||
import im.vector.riotx.features.userdirectory.PendingInvitee
|
||||
import io.reactivex.Observable
|
||||
|
||||
class InviteUsersToRoomViewModel @AssistedInject constructor(@Assisted
|
||||
@ -53,27 +53,30 @@ class InviteUsersToRoomViewModel @AssistedInject constructor(@Assisted
|
||||
|
||||
override fun handle(action: InviteUsersToRoomAction) {
|
||||
when (action) {
|
||||
is InviteUsersToRoomAction.InviteSelectedUsers -> inviteUsersToRoom(action.selectedUsers)
|
||||
is InviteUsersToRoomAction.InviteSelectedUsers -> inviteUsersToRoom(action.invitees)
|
||||
}
|
||||
}
|
||||
|
||||
private fun inviteUsersToRoom(selectedUsers: Set<User>) {
|
||||
private fun inviteUsersToRoom(invitees: Set<PendingInvitee>) {
|
||||
_viewEvents.post(InviteUsersToRoomViewEvents.Loading)
|
||||
|
||||
Observable.fromIterable(selectedUsers).flatMapCompletable { user ->
|
||||
room.rx().invite(user.userId, null)
|
||||
Observable.fromIterable(invitees).flatMapCompletable { user ->
|
||||
when (user) {
|
||||
is PendingInvitee.UserPendingInvitee -> room.rx().invite(user.user.userId, null)
|
||||
is PendingInvitee.ThreePidPendingInvitee -> room.rx().invite3pid(user.threePid)
|
||||
}
|
||||
}.subscribe(
|
||||
{
|
||||
val successMessage = when (selectedUsers.size) {
|
||||
val successMessage = when (invitees.size) {
|
||||
1 -> stringProvider.getString(R.string.invitation_sent_to_one_user,
|
||||
selectedUsers.first().getBestName())
|
||||
invitees.first().getBestName())
|
||||
2 -> stringProvider.getString(R.string.invitations_sent_to_two_users,
|
||||
selectedUsers.first().getBestName(),
|
||||
selectedUsers.last().getBestName())
|
||||
invitees.first().getBestName(),
|
||||
invitees.last().getBestName())
|
||||
else -> stringProvider.getQuantityString(R.plurals.invitations_sent_to_one_and_more_users,
|
||||
selectedUsers.size - 1,
|
||||
selectedUsers.first().getBestName(),
|
||||
selectedUsers.size - 1)
|
||||
invitees.size - 1,
|
||||
invitees.first().getBestName(),
|
||||
invitees.size - 1)
|
||||
}
|
||||
_viewEvents.post(InviteUsersToRoomViewEvents.Success(successMessage))
|
||||
},
|
||||
|
@ -84,15 +84,19 @@ class CreateRoomViewModel @AssistedInject constructor(@Assisted initialState: Cr
|
||||
copy(asyncCreateRoomRequest = Loading())
|
||||
}
|
||||
|
||||
val createRoomParams = CreateRoomParams(
|
||||
name = state.roomName.takeIf { it.isNotBlank() },
|
||||
val createRoomParams = CreateRoomParams()
|
||||
.apply {
|
||||
name = state.roomName.takeIf { it.isNotBlank() }
|
||||
// Directory visibility
|
||||
visibility = if (state.isInRoomDirectory) RoomDirectoryVisibility.PUBLIC else RoomDirectoryVisibility.PRIVATE,
|
||||
visibility = if (state.isInRoomDirectory) RoomDirectoryVisibility.PUBLIC else RoomDirectoryVisibility.PRIVATE
|
||||
// Public room
|
||||
preset = if (state.isPublic) CreateRoomPreset.PRESET_PUBLIC_CHAT else CreateRoomPreset.PRESET_PRIVATE_CHAT
|
||||
)
|
||||
|
||||
// Encryption
|
||||
.enableEncryptionWithAlgorithm(state.isEncrypted)
|
||||
if (state.isEncrypted) {
|
||||
enableEncryption()
|
||||
}
|
||||
}
|
||||
|
||||
session.createRoom(createRoomParams, object : MatrixCallback<String> {
|
||||
override fun onSuccess(data: String) {
|
||||
|
@ -18,4 +18,6 @@ package im.vector.riotx.features.roomprofile.members
|
||||
|
||||
import im.vector.riotx.core.platform.VectorViewModelAction
|
||||
|
||||
sealed class RoomMemberListAction : VectorViewModelAction
|
||||
sealed class RoomMemberListAction : VectorViewModelAction {
|
||||
data class RevokeThreePidInvite(val stateKey: String) : RoomMemberListAction()
|
||||
}
|
||||
|
@ -17,7 +17,11 @@
|
||||
package im.vector.riotx.features.roomprofile.members
|
||||
|
||||
import com.airbnb.epoxy.TypedEpoxyController
|
||||
import im.vector.matrix.android.api.session.events.model.Event
|
||||
import im.vector.matrix.android.api.session.events.model.toModel
|
||||
import im.vector.matrix.android.api.session.room.model.RoomMemberSummary
|
||||
import im.vector.matrix.android.api.session.room.model.RoomThirdPartyInviteContent
|
||||
import im.vector.matrix.android.api.util.MatrixItem
|
||||
import im.vector.matrix.android.api.util.toMatrixItem
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.epoxy.dividerItem
|
||||
@ -37,6 +41,7 @@ class RoomMemberListController @Inject constructor(
|
||||
|
||||
interface Callback {
|
||||
fun onRoomMemberClicked(roomMember: RoomMemberSummary)
|
||||
fun onThreePidInvites(event: Event)
|
||||
}
|
||||
|
||||
private val dividerColor = colorProvider.getColorFromAttribute(R.attr.vctr_list_divider_color)
|
||||
@ -49,15 +54,29 @@ class RoomMemberListController @Inject constructor(
|
||||
|
||||
override fun buildModels(data: RoomMemberListViewState?) {
|
||||
val roomMembersByPowerLevel = data?.roomMemberSummaries?.invoke() ?: return
|
||||
val threePidInvites = data.threePidInvites().orEmpty()
|
||||
var threePidInvitesDone = threePidInvites.isEmpty()
|
||||
|
||||
for ((powerLevelCategory, roomMemberList) in roomMembersByPowerLevel) {
|
||||
if (roomMemberList.isEmpty()) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (powerLevelCategory == RoomMemberListCategories.USER && !threePidInvitesDone) {
|
||||
// If there is not regular invite, display threepid invite before the regular user
|
||||
buildProfileSection(
|
||||
stringProvider.getString(RoomMemberListCategories.INVITE.titleRes)
|
||||
)
|
||||
|
||||
buildThreePidInvites(data)
|
||||
threePidInvitesDone = true
|
||||
}
|
||||
|
||||
buildProfileSection(
|
||||
stringProvider.getString(powerLevelCategory.titleRes)
|
||||
)
|
||||
roomMemberList.join(
|
||||
each = { roomMember ->
|
||||
each = { _, roomMember ->
|
||||
profileMatrixItem {
|
||||
id(roomMember.userId)
|
||||
matrixItem(roomMember.toMatrixItem())
|
||||
@ -68,13 +87,62 @@ class RoomMemberListController @Inject constructor(
|
||||
}
|
||||
}
|
||||
},
|
||||
between = { roomMemberBefore ->
|
||||
between = { _, roomMemberBefore ->
|
||||
dividerItem {
|
||||
id("divider_${roomMemberBefore.userId}")
|
||||
color(dividerColor)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (powerLevelCategory == RoomMemberListCategories.INVITE) {
|
||||
// Display the threepid invite after the regular invite
|
||||
dividerItem {
|
||||
id("divider_threepidinvites")
|
||||
color(dividerColor)
|
||||
}
|
||||
buildThreePidInvites(data)
|
||||
threePidInvitesDone = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!threePidInvitesDone) {
|
||||
// If there is not regular invite and no regular user, finally display threepid invite here
|
||||
buildProfileSection(
|
||||
stringProvider.getString(RoomMemberListCategories.INVITE.titleRes)
|
||||
)
|
||||
|
||||
buildThreePidInvites(data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildThreePidInvites(data: RoomMemberListViewState) {
|
||||
data.threePidInvites()
|
||||
?.filter { it.content.toModel<RoomThirdPartyInviteContent>() != null }
|
||||
?.join(
|
||||
each = { idx, event ->
|
||||
event.content.toModel<RoomThirdPartyInviteContent>()
|
||||
?.let { content ->
|
||||
profileMatrixItem {
|
||||
id("3pid_$idx")
|
||||
matrixItem(content.toMatrixItem())
|
||||
avatarRenderer(avatarRenderer)
|
||||
editable(data.actionsPermissions.canRevokeThreePidInvite)
|
||||
clickListener { _ ->
|
||||
callback?.onThreePidInvites(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
between = { idx, _ ->
|
||||
dividerItem {
|
||||
id("divider3_$idx")
|
||||
color(dividerColor)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun RoomThirdPartyInviteContent.toMatrixItem(): MatrixItem {
|
||||
return MatrixItem.UserItem("@", displayName = displayName)
|
||||
}
|
||||
}
|
||||
|
@ -20,10 +20,14 @@ import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.airbnb.mvrx.args
|
||||
import com.airbnb.mvrx.fragmentViewModel
|
||||
import com.airbnb.mvrx.withState
|
||||
import im.vector.matrix.android.api.session.events.model.Event
|
||||
import im.vector.matrix.android.api.session.events.model.toModel
|
||||
import im.vector.matrix.android.api.session.room.model.RoomMemberSummary
|
||||
import im.vector.matrix.android.api.session.room.model.RoomThirdPartyInviteContent
|
||||
import im.vector.matrix.android.api.util.toMatrixItem
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.extensions.cleanup
|
||||
@ -88,6 +92,22 @@ class RoomMemberListFragment @Inject constructor(
|
||||
navigator.openRoomMemberProfile(roomMember.userId, roomId = roomProfileArgs.roomId, context = requireActivity())
|
||||
}
|
||||
|
||||
override fun onThreePidInvites(event: Event) {
|
||||
// Display a dialog to revoke invite if power level is high enough
|
||||
val content = event.content.toModel<RoomThirdPartyInviteContent>() ?: return
|
||||
val stateKey = event.stateKey ?: return
|
||||
if (withState(viewModel) { it.actionsPermissions.canRevokeThreePidInvite }) {
|
||||
AlertDialog.Builder(requireActivity())
|
||||
.setTitle(R.string.three_pid_revoke_invite_dialog_title)
|
||||
.setMessage(getString(R.string.three_pid_revoke_invite_dialog_content, content.displayName))
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setPositiveButton(R.string.revoke) { _, _ ->
|
||||
viewModel.handle(RoomMemberListAction.RevokeThreePidInvite(stateKey))
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderRoomSummary(state: RoomMemberListViewState) {
|
||||
state.roomSummary()?.let {
|
||||
roomSettingsToolbarTitleView.text = it.displayName
|
||||
|
@ -16,11 +16,13 @@
|
||||
|
||||
package im.vector.riotx.features.roomprofile.members
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.airbnb.mvrx.FragmentViewModelContext
|
||||
import com.airbnb.mvrx.MvRxViewModelFactory
|
||||
import com.airbnb.mvrx.ViewModelContext
|
||||
import com.squareup.inject.assisted.Assisted
|
||||
import com.squareup.inject.assisted.AssistedInject
|
||||
import im.vector.matrix.android.api.NoOpMatrixCallback
|
||||
import im.vector.matrix.android.api.crypto.RoomEncryptionTrustLevel
|
||||
import im.vector.matrix.android.api.extensions.orFalse
|
||||
import im.vector.matrix.android.api.query.QueryStringValue
|
||||
@ -37,12 +39,14 @@ import im.vector.matrix.rx.asObservable
|
||||
import im.vector.matrix.rx.mapOptional
|
||||
import im.vector.matrix.rx.rx
|
||||
import im.vector.matrix.rx.unwrap
|
||||
import im.vector.riotx.core.extensions.exhaustive
|
||||
import im.vector.riotx.core.platform.EmptyViewEvents
|
||||
import im.vector.riotx.core.platform.VectorViewModel
|
||||
import im.vector.riotx.features.powerlevel.PowerLevelsObservableFactory
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.functions.BiFunction
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState: RoomMemberListViewState,
|
||||
@ -68,6 +72,7 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState
|
||||
|
||||
init {
|
||||
observeRoomMemberSummaries()
|
||||
observeThirdPartyInvites()
|
||||
observeRoomSummary()
|
||||
observePowerLevel()
|
||||
}
|
||||
@ -124,7 +129,12 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState
|
||||
PowerLevelsObservableFactory(room).createObservable()
|
||||
.subscribe {
|
||||
val permissions = ActionPermissions(
|
||||
canInvite = PowerLevelsHelper(it).isUserAbleToInvite(session.myUserId)
|
||||
canInvite = PowerLevelsHelper(it).isUserAbleToInvite(session.myUserId),
|
||||
canRevokeThreePidInvite = PowerLevelsHelper(it).isUserAllowedToSend(
|
||||
userId = session.myUserId,
|
||||
isState = true,
|
||||
eventType = EventType.STATE_ROOM_THIRD_PARTY_INVITE
|
||||
)
|
||||
)
|
||||
setState {
|
||||
copy(actionsPermissions = permissions)
|
||||
@ -140,6 +150,13 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeThirdPartyInvites() {
|
||||
room.rx().liveStateEvents(setOf(EventType.STATE_ROOM_THIRD_PARTY_INVITE))
|
||||
.execute { async ->
|
||||
copy(threePidInvites = async)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildRoomMemberSummaries(powerLevelsContent: PowerLevelsContent, roomMembers: List<RoomMemberSummary>): RoomMemberSummaries {
|
||||
val admins = ArrayList<RoomMemberSummary>()
|
||||
val moderators = ArrayList<RoomMemberSummary>()
|
||||
@ -169,5 +186,19 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState
|
||||
}
|
||||
|
||||
override fun handle(action: RoomMemberListAction) {
|
||||
when (action) {
|
||||
is RoomMemberListAction.RevokeThreePidInvite -> handleRevokeThreePidInvite(action)
|
||||
}.exhaustive
|
||||
}
|
||||
|
||||
private fun handleRevokeThreePidInvite(action: RoomMemberListAction.RevokeThreePidInvite) {
|
||||
viewModelScope.launch {
|
||||
room.sendStateEvent(
|
||||
eventType = EventType.STATE_ROOM_THIRD_PARTY_INVITE,
|
||||
stateKey = action.stateKey,
|
||||
body = emptyMap(),
|
||||
callback = NoOpMatrixCallback()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ import com.airbnb.mvrx.Async
|
||||
import com.airbnb.mvrx.MvRxState
|
||||
import com.airbnb.mvrx.Uninitialized
|
||||
import im.vector.matrix.android.api.crypto.RoomEncryptionTrustLevel
|
||||
import im.vector.matrix.android.api.session.events.model.Event
|
||||
import im.vector.matrix.android.api.session.room.model.RoomMemberSummary
|
||||
import im.vector.matrix.android.api.session.room.model.RoomSummary
|
||||
import im.vector.riotx.R
|
||||
@ -30,6 +31,7 @@ data class RoomMemberListViewState(
|
||||
val roomId: String,
|
||||
val roomSummary: Async<RoomSummary> = Uninitialized,
|
||||
val roomMemberSummaries: Async<RoomMemberSummaries> = Uninitialized,
|
||||
val threePidInvites: Async<List<Event>> = Uninitialized,
|
||||
val trustLevelMap: Async<Map<String, RoomEncryptionTrustLevel?>> = Uninitialized,
|
||||
val actionsPermissions: ActionPermissions = ActionPermissions()
|
||||
) : MvRxState {
|
||||
@ -38,7 +40,8 @@ data class RoomMemberListViewState(
|
||||
}
|
||||
|
||||
data class ActionPermissions(
|
||||
val canInvite: Boolean = false
|
||||
val canInvite: Boolean = false,
|
||||
val canRevokeThreePidInvite: Boolean = false
|
||||
)
|
||||
|
||||
typealias RoomMemberSummaries = List<Pair<RoomMemberListCategories, List<RoomMemberSummary>>>
|
||||
|
@ -60,7 +60,7 @@ class DirectoryUsersController @Inject constructor(private val session: Session,
|
||||
is Loading -> renderLoading()
|
||||
is Success -> renderSuccess(
|
||||
computeUsersList(asyncUsers(), currentState.directorySearchTerm),
|
||||
currentState.selectedUsers.map { it.userId },
|
||||
currentState.getSelectedMatrixId(),
|
||||
hasSearch
|
||||
)
|
||||
is Fail -> renderFailure(asyncUsers.error)
|
||||
|
@ -51,7 +51,7 @@ class KnownUsersController @Inject constructor(private val session: Session,
|
||||
|
||||
fun setData(state: UserDirectoryViewState) {
|
||||
this.isFiltering = !state.filterKnownUsersValue.isEmpty()
|
||||
val newSelection = state.selectedUsers.map { it.userId }
|
||||
val newSelection = state.getSelectedMatrixId()
|
||||
this.users = state.knownUsers
|
||||
if (newSelection != selectedUsers) {
|
||||
this.selectedUsers = newSelection
|
||||
|
@ -63,8 +63,9 @@ class KnownUsersFragment @Inject constructor(
|
||||
setupRecyclerView()
|
||||
setupFilterView()
|
||||
setupAddByMatrixIdView()
|
||||
setupAddFromPhoneBookView()
|
||||
setupCloseView()
|
||||
viewModel.selectSubscribe(this, UserDirectoryViewState::selectedUsers) {
|
||||
viewModel.selectSubscribe(this, UserDirectoryViewState::pendingInvitees) {
|
||||
renderSelectedUsers(it)
|
||||
}
|
||||
}
|
||||
@ -77,7 +78,7 @@ class KnownUsersFragment @Inject constructor(
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||
withState(viewModel) {
|
||||
val showMenuItem = it.selectedUsers.isNotEmpty()
|
||||
val showMenuItem = it.pendingInvitees.isNotEmpty()
|
||||
menu.forEach { menuItem ->
|
||||
menuItem.isVisible = showMenuItem
|
||||
}
|
||||
@ -86,7 +87,7 @@ class KnownUsersFragment @Inject constructor(
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = withState(viewModel) {
|
||||
sharedActionViewModel.post(UserDirectorySharedAction.OnMenuItemSelected(item.itemId, it.selectedUsers))
|
||||
sharedActionViewModel.post(UserDirectorySharedAction.OnMenuItemSelected(item.itemId, it.pendingInvitees))
|
||||
return@withState true
|
||||
}
|
||||
|
||||
@ -96,6 +97,13 @@ class KnownUsersFragment @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupAddFromPhoneBookView() {
|
||||
addFromPhoneBook.debouncedClicks {
|
||||
// TODO handle Permission first
|
||||
sharedActionViewModel.post(UserDirectorySharedAction.OpenPhoneBook)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
knownUsersController.callback = this
|
||||
// Don't activate animation as we might have way to much item animation when filtering
|
||||
@ -131,14 +139,14 @@ class KnownUsersFragment @Inject constructor(
|
||||
knownUsersController.setData(it)
|
||||
}
|
||||
|
||||
private fun renderSelectedUsers(selectedUsers: Set<User>) {
|
||||
private fun renderSelectedUsers(invitees: Set<PendingInvitee>) {
|
||||
invalidateOptionsMenu()
|
||||
|
||||
val currentNumberOfChips = chipGroup.childCount
|
||||
val newNumberOfChips = selectedUsers.size
|
||||
val newNumberOfChips = invitees.size
|
||||
|
||||
chipGroup.removeAllViews()
|
||||
selectedUsers.forEach { addChipToGroup(it) }
|
||||
invitees.forEach { addChipToGroup(it) }
|
||||
|
||||
// Scroll to the bottom when adding chips. When removing chips, do not scroll
|
||||
if (newNumberOfChips >= currentNumberOfChips) {
|
||||
@ -148,22 +156,22 @@ class KnownUsersFragment @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun addChipToGroup(user: User) {
|
||||
private fun addChipToGroup(pendingInvitee: PendingInvitee) {
|
||||
val chip = Chip(requireContext())
|
||||
chip.setChipBackgroundColorResource(android.R.color.transparent)
|
||||
chip.chipStrokeWidth = dimensionConverter.dpToPx(1).toFloat()
|
||||
chip.text = user.getBestName()
|
||||
chip.text = pendingInvitee.getBestName()
|
||||
chip.isClickable = true
|
||||
chip.isCheckable = false
|
||||
chip.isCloseIconVisible = true
|
||||
chipGroup.addView(chip)
|
||||
chip.setOnCloseIconClickListener {
|
||||
viewModel.handle(UserDirectoryAction.RemoveSelectedUser(user))
|
||||
viewModel.handle(UserDirectoryAction.RemovePendingInvitee(pendingInvitee))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(user: User) {
|
||||
view?.hideKeyboard()
|
||||
viewModel.handle(UserDirectoryAction.SelectUser(user))
|
||||
viewModel.handle(UserDirectoryAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(user)))
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.riotx.features.userdirectory
|
||||
|
||||
import im.vector.matrix.android.api.session.identity.ThreePid
|
||||
import im.vector.matrix.android.api.session.user.model.User
|
||||
|
||||
sealed class PendingInvitee {
|
||||
data class UserPendingInvitee(val user: User) : PendingInvitee()
|
||||
data class ThreePidPendingInvitee(val threePid: ThreePid) : PendingInvitee()
|
||||
|
||||
fun getBestName(): String {
|
||||
return when (this) {
|
||||
is UserPendingInvitee -> user.getBestName()
|
||||
is ThreePidPendingInvitee -> threePid.value
|
||||
}
|
||||
}
|
||||
}
|
@ -16,13 +16,12 @@
|
||||
|
||||
package im.vector.riotx.features.userdirectory
|
||||
|
||||
import im.vector.matrix.android.api.session.user.model.User
|
||||
import im.vector.riotx.core.platform.VectorViewModelAction
|
||||
|
||||
sealed class UserDirectoryAction : VectorViewModelAction {
|
||||
data class FilterKnownUsers(val value: String) : UserDirectoryAction()
|
||||
data class SearchDirectoryUsers(val value: String) : UserDirectoryAction()
|
||||
object ClearFilterKnownUsers : UserDirectoryAction()
|
||||
data class SelectUser(val user: User) : UserDirectoryAction()
|
||||
data class RemoveSelectedUser(val user: User) : UserDirectoryAction()
|
||||
data class SelectPendingInvitee(val pendingInvitee: PendingInvitee) : UserDirectoryAction()
|
||||
data class RemovePendingInvitee(val pendingInvitee: PendingInvitee) : UserDirectoryAction()
|
||||
}
|
||||
|
@ -84,7 +84,7 @@ class UserDirectoryFragment @Inject constructor(
|
||||
|
||||
override fun onItemClick(user: User) {
|
||||
view?.hideKeyboard()
|
||||
viewModel.handle(UserDirectoryAction.SelectUser(user))
|
||||
viewModel.handle(UserDirectoryAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(user)))
|
||||
sharedActionViewModel.post(UserDirectorySharedAction.GoBack)
|
||||
}
|
||||
|
||||
|
@ -16,12 +16,12 @@
|
||||
|
||||
package im.vector.riotx.features.userdirectory
|
||||
|
||||
import im.vector.matrix.android.api.session.user.model.User
|
||||
import im.vector.riotx.core.platform.VectorSharedAction
|
||||
|
||||
sealed class UserDirectorySharedAction : VectorSharedAction {
|
||||
object OpenUsersDirectory : UserDirectorySharedAction()
|
||||
object OpenPhoneBook : UserDirectorySharedAction()
|
||||
object Close : UserDirectorySharedAction()
|
||||
object GoBack : UserDirectorySharedAction()
|
||||
data class OnMenuItemSelected(val itemId: Int, val selectedUsers: Set<User>) : UserDirectorySharedAction()
|
||||
data class OnMenuItemSelected(val itemId: Int, val invitees: Set<PendingInvitee>) : UserDirectorySharedAction()
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ import com.squareup.inject.assisted.AssistedInject
|
||||
import im.vector.matrix.android.api.session.Session
|
||||
import im.vector.matrix.android.api.util.toMatrixItem
|
||||
import im.vector.matrix.rx.rx
|
||||
import im.vector.riotx.core.extensions.exhaustive
|
||||
import im.vector.riotx.core.extensions.toggle
|
||||
import im.vector.riotx.core.platform.VectorViewModel
|
||||
import im.vector.riotx.features.createdirect.CreateDirectRoomActivity
|
||||
@ -79,21 +80,21 @@ class UserDirectoryViewModel @AssistedInject constructor(@Assisted
|
||||
is UserDirectoryAction.FilterKnownUsers -> knownUsersFilter.accept(Option.just(action.value))
|
||||
is UserDirectoryAction.ClearFilterKnownUsers -> knownUsersFilter.accept(Option.empty())
|
||||
is UserDirectoryAction.SearchDirectoryUsers -> directoryUsersSearch.accept(action.value)
|
||||
is UserDirectoryAction.SelectUser -> handleSelectUser(action)
|
||||
is UserDirectoryAction.RemoveSelectedUser -> handleRemoveSelectedUser(action)
|
||||
}
|
||||
is UserDirectoryAction.SelectPendingInvitee -> handleSelectUser(action)
|
||||
is UserDirectoryAction.RemovePendingInvitee -> handleRemoveSelectedUser(action)
|
||||
}.exhaustive
|
||||
}
|
||||
|
||||
private fun handleRemoveSelectedUser(action: UserDirectoryAction.RemoveSelectedUser) = withState { state ->
|
||||
val selectedUsers = state.selectedUsers.minus(action.user)
|
||||
setState { copy(selectedUsers = selectedUsers) }
|
||||
private fun handleRemoveSelectedUser(action: UserDirectoryAction.RemovePendingInvitee) = withState { state ->
|
||||
val selectedUsers = state.pendingInvitees.minus(action.pendingInvitee)
|
||||
setState { copy(pendingInvitees = selectedUsers) }
|
||||
}
|
||||
|
||||
private fun handleSelectUser(action: UserDirectoryAction.SelectUser) = withState { state ->
|
||||
private fun handleSelectUser(action: UserDirectoryAction.SelectPendingInvitee) = withState { state ->
|
||||
// Reset the filter asap
|
||||
directoryUsersSearch.accept("")
|
||||
val selectedUsers = state.selectedUsers.toggle(action.user)
|
||||
setState { copy(selectedUsers = selectedUsers) }
|
||||
val selectedUsers = state.pendingInvitees.toggle(action.pendingInvitee)
|
||||
setState { copy(pendingInvitees = selectedUsers) }
|
||||
}
|
||||
|
||||
private fun observeDirectoryUsers() = withState { state ->
|
||||
|
@ -27,11 +27,21 @@ data class UserDirectoryViewState(
|
||||
val excludedUserIds: Set<String>? = null,
|
||||
val knownUsers: Async<PagedList<User>> = Uninitialized,
|
||||
val directoryUsers: Async<List<User>> = Uninitialized,
|
||||
val selectedUsers: Set<User> = emptySet(),
|
||||
val pendingInvitees: Set<PendingInvitee> = emptySet(),
|
||||
val createAndInviteState: Async<String> = Uninitialized,
|
||||
val directorySearchTerm: String = "",
|
||||
val filterKnownUsersValue: Option<String> = Option.empty()
|
||||
) : MvRxState {
|
||||
|
||||
constructor(args: KnownUsersFragmentArgs) : this(excludedUserIds = args.excludedUserIds)
|
||||
|
||||
fun getSelectedMatrixId(): List<String> {
|
||||
return pendingInvitees
|
||||
.mapNotNull {
|
||||
when (it) {
|
||||
is PendingInvitee.UserPendingInvitee -> it.user.userId
|
||||
is PendingInvitee.ThreePidPendingInvitee -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
122
vector/src/main/res/layout/fragment_contacts_book.xml
Normal file
122
vector/src/main/res/layout/fragment_contacts_book.xml
Normal file
@ -0,0 +1,122 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/phoneBookToolbar"
|
||||
style="@style/VectorToolbarStyle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="?actionBarSize"
|
||||
android:elevation="4dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/phoneBookClose"
|
||||
android:layout_width="@dimen/layout_touch_size"
|
||||
android:layout_height="@dimen/layout_touch_size"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
android:scaleType="center"
|
||||
android:src="@drawable/ic_x_18dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/phoneBookTitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="@string/contacts_book_title"
|
||||
android:textColor="?riotx_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toEndOf="@+id/phoneBookClose"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</androidx.appcompat.widget.Toolbar>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/phoneBookFilterContainer"
|
||||
style="@style/VectorTextInputLayout"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/layout_horizontal_margin"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="@dimen/layout_horizontal_margin"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/phoneBookToolbar">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/phoneBookFilter"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/search" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.checkbox.MaterialCheckBox
|
||||
android:id="@+id/phoneBookOnlyBoundContacts"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/layout_horizontal_margin"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginEnd="@dimen/layout_horizontal_margin"
|
||||
android:text="@string/matrix_only_filter"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/phoneBookFilterContainer"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<View
|
||||
android:id="@+id/phoneBookFilterDivider"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:background="?attr/vctr_list_divider_color"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/phoneBookOnlyBoundContacts" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/phoneBookRecyclerView"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:fastScrollEnabled="true"
|
||||
android:overScrollMode="always"
|
||||
android:scrollbars="vertical"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/phoneBookFilterDivider"
|
||||
tools:listitem="@layout/item_contact_main" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
@ -123,6 +123,23 @@
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/knownUsersFilterDivider" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/addFromPhoneBook"
|
||||
style="@style/VectorButtonStyleText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:minHeight="@dimen/layout_touch_size"
|
||||
android:text="@string/search_in_my_contacts"
|
||||
android:visibility="visible"
|
||||
app:icon="@drawable/ic_plus_circle"
|
||||
app:iconPadding="13dp"
|
||||
app:iconTint="@color/riotx_accent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/addByMatrixId" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="0dp"
|
||||
@ -134,7 +151,7 @@
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/addByMatrixId"
|
||||
app:layout_constraintTop_toBottomOf="@+id/addFromPhoneBook"
|
||||
tools:listitem="@layout/item_known_user" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
46
vector/src/main/res/layout/item_contact_detail.xml
Normal file
46
vector/src/main/res/layout/item_contact_detail.xml
Normal file
@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?riotx_background"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
android:minHeight="60dp"
|
||||
android:padding="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/contactDetailName"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginStart="60dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="?riotx_text_primary"
|
||||
android:textSize="15sp"
|
||||
app:layout_constraintBottom_toTopOf="@id/contactDetailMatrixId"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_chainStyle="packed"
|
||||
tools:text="@tools:sample/full_names" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/contactDetailMatrixId"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="?riotx_text_secondary"
|
||||
android:textSize="15sp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="@+id/contactDetailName"
|
||||
app:layout_constraintTop_toBottomOf="@+id/contactDetailName"
|
||||
tools:text="@sample/matrix.json/data/mxid"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
39
vector/src/main/res/layout/item_contact_main.xml
Normal file
39
vector/src/main/res/layout/item_contact_main.xml
Normal file
@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?riotx_background"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingEnd="8dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/contactAvatar"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/contactDisplayName"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginStart="12dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="?riotx_text_primary"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/contactAvatar"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="@tools:sample/full_names" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
@ -2541,4 +2541,15 @@ Not all features in Riot are implemented in RiotX yet. Main missing (and coming
|
||||
<string name="notice_crypto_unable_to_decrypt_merged">Waiting for encryption history</string>
|
||||
|
||||
<string name="save_recovery_key_chooser_hint">Save recovery key in</string>
|
||||
|
||||
<string name="add_from_phone_book">Add from my phone book</string>
|
||||
<string name="empty_phone_book">Your phone book is empty</string>
|
||||
<string name="phone_book_title">Phone book</string>
|
||||
<string name="search_in_my_contacts">Search in my contacts</string>
|
||||
<string name="loading_contact_book">Retrieving your contacts…</string>
|
||||
<string name="empty_contact_book">Your contact book is empty</string>
|
||||
<string name="contacts_book_title">Contacts book</string>
|
||||
|
||||
<string name="three_pid_revoke_invite_dialog_title">Revoke invite</string>
|
||||
<string name="three_pid_revoke_invite_dialog_content">Revoke invite to %1$s?</string>
|
||||
</resources>
|
||||
|
Loading…
Reference in New Issue
Block a user