Merge pull request #1464 from vector-im/feature/room_settings

Room Settings: Name, Topic, Photo, Aliases, History Visibility
This commit is contained in:
Benoit Marty 2020-06-30 16:33:07 +02:00 committed by GitHub
commit 87a087c0b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1385 additions and 118 deletions

View File

@ -9,6 +9,8 @@ Improvements 🙌:
- "Add Matrix app" menu is now always visible (#1495)
- Handle `/op`, `/deop`, and `/nick` commands (#12)
- Prioritising Recovery key over Recovery passphrase (#1463)
- Room Settings: Name, Topic, Photo, Aliases, History Visibility (#1455)
- Update user avatar (#1054)
Bugfix 🐛:
- Fix dark theme issue on login screen (#1097)

View File

@ -16,12 +16,14 @@
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.room.Room
import im.vector.matrix.android.api.session.room.members.RoomMemberQueryParams
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
import im.vector.matrix.android.api.session.room.model.ReadReceipt
import im.vector.matrix.android.api.session.room.model.RoomHistoryVisibility
import im.vector.matrix.android.api.session.room.model.RoomMemberSummary
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.notification.RoomNotificationState
@ -101,6 +103,30 @@ class RxRoom(private val room: Room) {
fun invite(userId: String, reason: String? = null): Completable = completableBuilder<Unit> {
room.invite(userId, reason, it)
}
fun updateTopic(topic: String): Completable = completableBuilder<Unit> {
room.updateTopic(topic, it)
}
fun updateName(name: String): Completable = completableBuilder<Unit> {
room.updateName(name, it)
}
fun addRoomAlias(alias: String): Completable = completableBuilder<Unit> {
room.addRoomAlias(alias, it)
}
fun updateCanonicalAlias(alias: String): Completable = completableBuilder<Unit> {
room.updateCanonicalAlias(alias, it)
}
fun updateHistoryReadability(readability: RoomHistoryVisibility): Completable = completableBuilder<Unit> {
room.updateHistoryReadability(readability, it)
}
fun updateAvatar(avatarUri: Uri, fileName: String): Completable = completableBuilder<Unit> {
room.updateAvatar(avatarUri, fileName, it)
}
}
fun Room.rx(): RxRoom {

View File

@ -17,6 +17,7 @@
package im.vector.matrix.android.api.session.profile
import android.net.Uri
import androidx.lifecycle.LiveData
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.identity.ThreePid
@ -48,6 +49,14 @@ interface ProfileService {
*/
fun setDisplayName(userId: String, newDisplayName: String, matrixCallback: MatrixCallback<Unit>): Cancelable
/**
* Update the avatar for this user
* @param userId the userId to update the avatar of
* @param newAvatarUri the new avatar uri of the user
* @param fileName the fileName of selected image
*/
fun updateAvatar(userId: String, newAvatarUri: Uri, fileName: String, matrixCallback: MatrixCallback<Unit>): Cancelable
/**
* Return the current avatarUrl for this user.
* @param userId the userId param to look for

View File

@ -27,7 +27,9 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
*/
data class RoomSummary constructor(
val roomId: String,
// Computed display name
val displayName: String = "",
val name: String = "",
val topic: String = "",
val avatarUrl: String = "",
val canonicalAlias: String? = null,

View File

@ -17,6 +17,7 @@
package im.vector.matrix.android.api.session.room.powerlevels
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.room.model.PowerLevelsContent
/**
@ -123,4 +124,59 @@ class PowerLevelsHelper(private val powerLevelsContent: PowerLevelsContent) {
else -> Role.Moderator.value
}
}
/**
* Check if user have the necessary power level to change room name
* @param userId the id of the user to check for.
* @return true if able to change room name
*/
fun isUserAbleToChangeRoomName(userId: String): Boolean {
val powerLevel = getUserPowerLevelValue(userId)
val minPowerLevel = powerLevelsContent.events[EventType.STATE_ROOM_NAME] ?: powerLevelsContent.stateDefault
return powerLevel >= minPowerLevel
}
/**
* Check if user have the necessary power level to change room topic
* @param userId the id of the user to check for.
* @return true if able to change room topic
*/
fun isUserAbleToChangeRoomTopic(userId: String): Boolean {
val powerLevel = getUserPowerLevelValue(userId)
val minPowerLevel = powerLevelsContent.events[EventType.STATE_ROOM_TOPIC] ?: powerLevelsContent.stateDefault
return powerLevel >= minPowerLevel
}
/**
* Check if user have the necessary power level to change room canonical alias
* @param userId the id of the user to check for.
* @return true if able to change room canonical alias
*/
fun isUserAbleToChangeRoomCanonicalAlias(userId: String): Boolean {
val powerLevel = getUserPowerLevelValue(userId)
val minPowerLevel = powerLevelsContent.events[EventType.STATE_ROOM_CANONICAL_ALIAS] ?: powerLevelsContent.stateDefault
return powerLevel >= minPowerLevel
}
/**
* Check if user have the necessary power level to change room history readability
* @param userId the id of the user to check for.
* @return true if able to change room history readability
*/
fun isUserAbleToChangeRoomHistoryReadability(userId: String): Boolean {
val powerLevel = getUserPowerLevelValue(userId)
val minPowerLevel = powerLevelsContent.events[EventType.STATE_ROOM_HISTORY_VISIBILITY] ?: powerLevelsContent.stateDefault
return powerLevel >= minPowerLevel
}
/**
* Check if user have the necessary power level to change room avatar
* @param userId the id of the user to check for.
* @return true if able to change room avatar
*/
fun isUserAbleToChangeRoomAvatar(userId: String): Boolean {
val powerLevel = getUserPowerLevelValue(userId)
val minPowerLevel = powerLevelsContent.events[EventType.STATE_ROOM_AVATAR] ?: powerLevelsContent.stateDefault
return powerLevel >= minPowerLevel
}
}

View File

@ -16,10 +16,12 @@
package im.vector.matrix.android.api.session.room.state
import android.net.Uri
import androidx.lifecycle.LiveData
import im.vector.matrix.android.api.MatrixCallback
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.room.model.RoomHistoryVisibility
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.api.util.JsonDict
import im.vector.matrix.android.api.util.Optional
@ -31,6 +33,31 @@ interface StateService {
*/
fun updateTopic(topic: String, callback: MatrixCallback<Unit>): Cancelable
/**
* Update the name of the room
*/
fun updateName(name: String, callback: MatrixCallback<Unit>): Cancelable
/**
* Add new alias to the room.
*/
fun addRoomAlias(roomAlias: String, callback: MatrixCallback<Unit>): Cancelable
/**
* Update the canonical alias of the room
*/
fun updateCanonicalAlias(alias: String, callback: MatrixCallback<Unit>): Cancelable
/**
* Update the history readability of the room
*/
fun updateHistoryReadability(readability: RoomHistoryVisibility, callback: MatrixCallback<Unit>): Cancelable
/**
* Update the avatar of the room
*/
fun updateAvatar(avatarUri: Uri, fileName: String, callback: MatrixCallback<Unit>): Cancelable
fun sendStateEvent(eventType: String, stateKey: String?, body: JsonDict, callback: MatrixCallback<Unit>): Cancelable
fun getStateEvent(eventType: String, stateKey: QueryStringValue = QueryStringValue.NoCondition): Event?

View File

@ -35,6 +35,7 @@ internal class RoomSummaryMapper @Inject constructor(private val timelineEventMa
return RoomSummary(
roomId = roomSummaryEntity.roomId,
displayName = roomSummaryEntity.displayName ?: "",
name = roomSummaryEntity.name ?: "",
topic = roomSummaryEntity.topic ?: "",
avatarUrl = roomSummaryEntity.avatarUrl ?: "",
isDirect = roomSummaryEntity.isDirect,

View File

@ -28,6 +28,7 @@ internal open class RoomSummaryEntity(
@PrimaryKey var roomId: String = "",
var displayName: String? = "",
var avatarUrl: String? = "",
var name: String? = "",
var topic: String? = "",
var latestPreviewableEvent: TimelineEventEntity? = null,
var heroes: RealmList<String> = RealmList(),

View File

@ -16,12 +16,16 @@
package im.vector.matrix.android.internal.session.content
import android.content.Context
import android.net.Uri
import com.squareup.moshi.Moshi
import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.matrix.android.internal.di.Authenticated
import im.vector.matrix.android.internal.network.ProgressRequestBody
import im.vector.matrix.android.internal.network.awaitResponse
import im.vector.matrix.android.internal.network.toFailure
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
@ -31,12 +35,14 @@ import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.greenrobot.eventbus.EventBus
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import javax.inject.Inject
internal class FileUploader @Inject constructor(@Authenticated
private val okHttpClient: OkHttpClient,
private val eventBus: EventBus,
private val context: Context,
contentUrlResolver: ContentUrlResolver,
moshi: Moshi) {
@ -59,6 +65,19 @@ internal class FileUploader @Inject constructor(@Authenticated
return upload(uploadBody, filename, progressListener)
}
suspend fun uploadFromUri(uri: Uri,
filename: String?,
mimeType: String?,
progressListener: ProgressRequestBody.Listener? = null): ContentUploadResponse {
val inputStream = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)
} ?: throw FileNotFoundException()
inputStream.use {
return uploadByteArray(it.readBytes(), filename, mimeType, progressListener)
}
}
private suspend fun upload(uploadBody: RequestBody, filename: String?, progressListener: ProgressRequestBody.Listener?): ContentUploadResponse {
val urlBuilder = uploadUrl.toHttpUrlOrNull()?.newBuilder() ?: throw RuntimeException()

View File

@ -17,6 +17,7 @@
package im.vector.matrix.android.internal.session.profile
import android.net.Uri
import androidx.lifecycle.LiveData
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.MatrixCallback
@ -27,16 +28,22 @@ import im.vector.matrix.android.api.util.JsonDict
import im.vector.matrix.android.api.util.Optional
import im.vector.matrix.android.internal.database.model.UserThreePidEntity
import im.vector.matrix.android.internal.di.SessionDatabase
import im.vector.matrix.android.internal.session.content.FileUploader
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.task.launchToCallback
import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers
import io.realm.kotlin.where
import javax.inject.Inject
internal class DefaultProfileService @Inject constructor(private val taskExecutor: TaskExecutor,
@SessionDatabase private val monarchy: Monarchy,
private val coroutineDispatchers: MatrixCoroutineDispatchers,
private val refreshUserThreePidsTask: RefreshUserThreePidsTask,
private val getProfileInfoTask: GetProfileInfoTask,
private val setDisplayNameTask: SetDisplayNameTask) : ProfileService {
private val setDisplayNameTask: SetDisplayNameTask,
private val setAvatarUrlTask: SetAvatarUrlTask,
private val fileUploader: FileUploader) : ProfileService {
override fun getDisplayName(userId: String, matrixCallback: MatrixCallback<Optional<String>>): Cancelable {
val params = GetProfileInfoTask.Params(userId)
@ -64,6 +71,17 @@ internal class DefaultProfileService @Inject constructor(private val taskExecuto
.executeBy(taskExecutor)
}
override fun updateAvatar(userId: String, newAvatarUri: Uri, fileName: String, matrixCallback: MatrixCallback<Unit>): Cancelable {
return taskExecutor.executorScope.launchToCallback(coroutineDispatchers.main, matrixCallback) {
val response = fileUploader.uploadFromUri(newAvatarUri, fileName, "image/jpeg")
setAvatarUrlTask
.configureWith(SetAvatarUrlTask.Params(userId = userId, newAvatarUrl = response.contentUri)) {
callback = matrixCallback
}
.executeBy(taskExecutor)
}
}
override fun getAvatarUrl(userId: String, matrixCallback: MatrixCallback<Optional<String>>): Cancelable {
val params = GetProfileInfoTask.Params(userId)
return getProfileInfoTask

View File

@ -49,6 +49,12 @@ internal interface ProfileAPI {
@PUT(NetworkConstants.URI_API_PREFIX_PATH_R0 + "profile/{userId}/displayname")
fun setDisplayName(@Path("userId") userId: String, @Body body: SetDisplayNameBody): Call<Unit>
/**
* Change user avatar url.
*/
@PUT(NetworkConstants.URI_API_PREFIX_PATH_R0 + "profile/{userId}/avatar_url")
fun setAvatarUrl(@Path("userId") userId: String, @Body body: SetAvatarUrlBody): Call<Unit>
/**
* Bind a threePid
* Ref: https://matrix.org/docs/spec/client_server/latest#post-matrix-client-r0-account-3pid-bind

View File

@ -54,4 +54,7 @@ internal abstract class ProfileModule {
@Binds
abstract fun bindSetDisplayNameTask(task: DefaultSetDisplayNameTask): SetDisplayNameTask
@Binds
abstract fun bindSetAvatarUrlTask(task: DefaultSetAvatarUrlTask): SetAvatarUrlTask
}

View File

@ -0,0 +1,28 @@
/*
* 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.profile
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class SetAvatarUrlBody(
/**
* The new avatar url for this user.
*/
@Json(name = "avatar_url")
val avatarUrl: String
)

View File

@ -0,0 +1,43 @@
/*
* 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.profile
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.task.Task
import org.greenrobot.eventbus.EventBus
import javax.inject.Inject
internal abstract class SetAvatarUrlTask : Task<SetAvatarUrlTask.Params, Unit> {
data class Params(
val userId: String,
val newAvatarUrl: String
)
}
internal class DefaultSetAvatarUrlTask @Inject constructor(
private val profileAPI: ProfileAPI,
private val eventBus: EventBus) : SetAvatarUrlTask() {
override suspend fun execute(params: Params) {
return executeRequest(eventBus) {
val body = SetAvatarUrlBody(
avatarUrl = params.newAvatarUrl
)
apiCall = profileAPI.setAvatarUrl(params.userId, body)
}
}
}

View File

@ -26,6 +26,7 @@ import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRooms
import im.vector.matrix.android.api.session.room.model.thirdparty.ThirdPartyProtocol
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.membership.RoomMembersResponse
import im.vector.matrix.android.internal.session.room.membership.admin.UserIdAndReason
@ -311,6 +312,14 @@ internal interface RoomAPI {
@GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "directory/room/{roomAlias}")
fun getRoomIdByAlias(@Path("roomAlias") roomAlias: String): Call<RoomAliasDescription>
/**
* Add alias to the room.
* @param roomAlias the room alias.
*/
@PUT(NetworkConstants.URI_API_PREFIX_PATH_R0 + "directory/room/{roomAlias}")
fun addRoomAlias(@Path("roomAlias") roomAlias: String,
@Body body: AddRoomAliasBody): Call<Unit>
/**
* Inform that the user is starting to type or has stopped typing
*/

View File

@ -24,6 +24,8 @@ import im.vector.matrix.android.api.session.room.RoomDirectoryService
import im.vector.matrix.android.api.session.room.RoomService
import im.vector.matrix.android.internal.session.DefaultFileService
import im.vector.matrix.android.internal.session.SessionScope
import im.vector.matrix.android.internal.session.room.alias.AddRoomAliasTask
import im.vector.matrix.android.internal.session.room.alias.DefaultAddRoomAliasTask
import im.vector.matrix.android.internal.session.room.alias.DefaultGetRoomIdByAliasTask
import im.vector.matrix.android.internal.session.room.alias.GetRoomIdByAliasTask
import im.vector.matrix.android.internal.session.room.create.CreateRoomTask
@ -190,6 +192,9 @@ internal abstract class RoomModule {
@Binds
abstract fun bindGetRoomIdByAliasTask(task: DefaultGetRoomIdByAliasTask): GetRoomIdByAliasTask
@Binds
abstract fun bindAddRoomAliasTask(task: DefaultAddRoomAliasTask): AddRoomAliasTask
@Binds
abstract fun bindSendTypingTask(task: DefaultSendTypingTask): SendTypingTask

View File

@ -0,0 +1,28 @@
/*
* 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.alias
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class AddRoomAliasBody(
/**
* Required. The room id which the alias will be added to.
*/
@Json(name = "room_id") val roomId: String
)

View File

@ -0,0 +1,47 @@
/*
* 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.alias
import im.vector.matrix.android.internal.network.executeRequest
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 AddRoomAliasTask : Task<AddRoomAliasTask.Params, Unit> {
data class Params(
val roomId: String,
val roomAlias: String
)
}
internal class DefaultAddRoomAliasTask @Inject constructor(
private val roomAPI: RoomAPI,
private val eventBus: EventBus
) : AddRoomAliasTask {
override suspend fun execute(params: AddRoomAliasTask.Params) {
executeRequest<Unit>(eventBus) {
apiCall = roomAPI.addRoomAlias(
roomAlias = params.roomAlias,
body = AddRoomAliasBody(
roomId = params.roomId
)
)
}
}
}

View File

@ -16,6 +16,7 @@
package im.vector.matrix.android.internal.session.room.state
import android.net.Uri
import androidx.lifecycle.LiveData
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
@ -23,17 +24,25 @@ import im.vector.matrix.android.api.MatrixCallback
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.events.model.EventType
import im.vector.matrix.android.api.session.room.model.RoomHistoryVisibility
import im.vector.matrix.android.api.session.room.state.StateService
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.api.util.JsonDict
import im.vector.matrix.android.api.util.Optional
import im.vector.matrix.android.internal.session.content.FileUploader
import im.vector.matrix.android.internal.session.room.alias.AddRoomAliasTask
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.task.launchToCallback
import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers
internal class DefaultStateService @AssistedInject constructor(@Assisted private val roomId: String,
private val stateEventDataSource: StateEventDataSource,
private val taskExecutor: TaskExecutor,
private val sendStateTask: SendStateTask
private val sendStateTask: SendStateTask,
private val coroutineDispatchers: MatrixCoroutineDispatchers,
private val fileUploader: FileUploader,
private val addRoomAliasTask: AddRoomAliasTask
) : StateService {
@AssistedInject.Factory
@ -84,4 +93,51 @@ internal class DefaultStateService @AssistedInject constructor(@Assisted private
stateKey = null
)
}
override fun updateName(name: String, callback: MatrixCallback<Unit>): Cancelable {
return sendStateEvent(
eventType = EventType.STATE_ROOM_NAME,
body = mapOf("name" to name),
callback = callback,
stateKey = null
)
}
override fun addRoomAlias(roomAlias: String, callback: MatrixCallback<Unit>): Cancelable {
return addRoomAliasTask
.configureWith(AddRoomAliasTask.Params(roomId, roomAlias)) {
this.callback = callback
}
.executeBy(taskExecutor)
}
override fun updateCanonicalAlias(alias: String, callback: MatrixCallback<Unit>): Cancelable {
return sendStateEvent(
eventType = EventType.STATE_ROOM_CANONICAL_ALIAS,
body = mapOf("alias" to alias),
callback = callback,
stateKey = null
)
}
override fun updateHistoryReadability(readability: RoomHistoryVisibility, callback: MatrixCallback<Unit>): Cancelable {
return sendStateEvent(
eventType = EventType.STATE_ROOM_HISTORY_VISIBILITY,
body = mapOf("history_visibility" to readability),
callback = callback,
stateKey = null
)
}
override fun updateAvatar(avatarUri: Uri, fileName: String, callback: MatrixCallback<Unit>): Cancelable {
return taskExecutor.executorScope.launchToCallback(coroutineDispatchers.main, callback) {
val response = fileUploader.uploadFromUri(avatarUri, fileName, "image/jpeg")
sendStateEvent(
eventType = EventType.STATE_ROOM_AVATAR,
body = mapOf("url" to response.contentUri),
callback = callback,
stateKey = null
)
}
}
}

View File

@ -23,6 +23,7 @@ import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.RoomAliasesContent
import im.vector.matrix.android.api.session.room.model.RoomCanonicalAliasContent
import im.vector.matrix.android.api.session.room.model.RoomNameContent
import im.vector.matrix.android.api.session.room.model.RoomTopicContent
import im.vector.matrix.android.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM
import im.vector.matrix.android.internal.crypto.crosssigning.SessionToCryptoRoomMembersUpdate
@ -107,6 +108,7 @@ internal class RoomSummaryUpdater @Inject constructor(
val latestPreviewableEvent = TimelineEventEntity.latestEvent(realm, roomId, includesSending = true,
filterTypes = PREVIEWABLE_TYPES, filterContentRelation = true)
val lastNameEvent = CurrentStateEventEntity.getOrNull(realm, roomId, type = EventType.STATE_ROOM_NAME, stateKey = "")?.root
val lastTopicEvent = CurrentStateEventEntity.getOrNull(realm, roomId, type = EventType.STATE_ROOM_TOPIC, stateKey = "")?.root
val lastCanonicalAliasEvent = CurrentStateEventEntity.getOrNull(realm, roomId, type = EventType.STATE_ROOM_CANONICAL_ALIAS, stateKey = "")?.root
val lastAliasesEvent = CurrentStateEventEntity.getOrNull(realm, roomId, type = EventType.STATE_ROOM_ALIASES, stateKey = "")?.root
@ -122,6 +124,7 @@ internal class RoomSummaryUpdater @Inject constructor(
roomSummaryEntity.displayName = roomDisplayNameResolver.resolve(realm, roomId).toString()
roomSummaryEntity.avatarUrl = roomAvatarResolver.resolve(realm, roomId)
roomSummaryEntity.name = ContentMapper.map(lastNameEvent?.content).toModel<RoomNameContent>()?.name
roomSummaryEntity.topic = ContentMapper.map(lastTopicEvent?.content).toModel<RoomTopicContent>()?.topic
roomSummaryEntity.latestPreviewableEvent = latestPreviewableEvent
roomSummaryEntity.canonicalAlias = ContentMapper.map(lastCanonicalAliasEvent?.content).toModel<RoomCanonicalAliasContent>()

View File

@ -25,10 +25,12 @@ import androidx.core.view.isVisible
import androidx.core.widget.ImageViewCompat
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.matrix.android.api.util.MatrixItem
import im.vector.riotx.R
import im.vector.riotx.core.epoxy.VectorEpoxyHolder
import im.vector.riotx.core.epoxy.VectorEpoxyModel
import im.vector.riotx.core.extensions.setTextOrHide
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.themes.ThemeUtils
@EpoxyModelClass(layout = R.layout.item_profile_action)
@ -51,6 +53,12 @@ abstract class ProfileActionItem : VectorEpoxyModel<ProfileActionItem.Holder>()
@EpoxyAttribute
var accessoryRes: Int = 0
@EpoxyAttribute
var accessoryMatrixItem: MatrixItem? = null
@EpoxyAttribute
var avatarRenderer: AvatarRenderer? = null
@EpoxyAttribute
var editable: Boolean = true
@ -93,6 +101,13 @@ abstract class ProfileActionItem : VectorEpoxyModel<ProfileActionItem.Holder>()
holder.secondaryAccessory.isVisible = false
}
if (accessoryMatrixItem != null) {
avatarRenderer?.render(accessoryMatrixItem!!, holder.secondaryAccessory)
holder.secondaryAccessory.isVisible = true
} else {
holder.secondaryAccessory.isVisible = false
}
if (editableRes != 0 && editable) {
val tintColorSecondary = if (destructive) {
tintColor

View File

@ -19,8 +19,10 @@ package im.vector.riotx.core.epoxy.profiles
import androidx.annotation.DrawableRes
import com.airbnb.epoxy.EpoxyController
import im.vector.matrix.android.api.util.MatrixItem
import im.vector.riotx.core.epoxy.ClickListener
import im.vector.riotx.core.epoxy.dividerItem
import im.vector.riotx.features.home.AvatarRenderer
fun EpoxyController.buildProfileSection(title: String) {
profileSectionItem {
@ -41,7 +43,9 @@ fun EpoxyController.buildProfileAction(
destructive: Boolean = false,
divider: Boolean = true,
action: ClickListener? = null,
@DrawableRes accessory: Int = 0
@DrawableRes accessory: Int = 0,
accessoryMatrixItem: MatrixItem? = null,
avatarRenderer: AvatarRenderer? = null
) {
profileActionItem {
iconRes(icon)
@ -53,6 +57,8 @@ fun EpoxyController.buildProfileAction(
destructive(destructive)
title(title)
accessoryRes(accessory)
accessoryMatrixItem(accessoryMatrixItem)
avatarRenderer(avatarRenderer)
listener { _ ->
action?.invoke()
}

View File

@ -20,7 +20,6 @@ package im.vector.riotx.features.attachments.preview
import android.app.Activity.RESULT_CANCELED
import android.app.Activity.RESULT_OK
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.os.Parcelable
import android.view.Menu
@ -38,7 +37,6 @@ import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import com.yalantis.ucrop.UCrop
import com.yalantis.ucrop.UCropActivity
import im.vector.matrix.android.api.extensions.orFalse
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.riotx.R
@ -52,6 +50,7 @@ import im.vector.riotx.core.utils.SnapOnScrollListener
import im.vector.riotx.core.utils.allGranted
import im.vector.riotx.core.utils.attachSnapHelperWithListener
import im.vector.riotx.core.utils.checkPermissions
import im.vector.riotx.features.media.createUCropWithDefaultSettings
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_attachments_preview.*
import timber.log.Timber
@ -203,32 +202,7 @@ class AttachmentsPreviewFragment @Inject constructor(
val currentAttachment = it.attachments.getOrNull(it.currentAttachmentIndex) ?: return@withState
val destinationFile = File(requireContext().cacheDir, "${currentAttachment.name}_edited_image_${System.currentTimeMillis()}")
val uri = currentAttachment.queryUri
UCrop.of(uri, destinationFile.toUri())
.withOptions(
UCrop.Options()
.apply {
setAllowedGestures(
/* tabScale = */ UCropActivity.SCALE,
/* tabRotate = */ UCropActivity.ALL,
/* tabAspectRatio = */ UCropActivity.SCALE
)
setToolbarTitle(currentAttachment.name)
// Disable freestyle crop, usability was not easy
// setFreeStyleCropEnabled(true)
// Color used for toolbar icon and text
setToolbarColor(colorProvider.getColorFromAttribute(R.attr.riotx_background))
setToolbarWidgetColor(colorProvider.getColorFromAttribute(R.attr.vctr_toolbar_primary_text_color))
// Background
setRootViewBackgroundColor(colorProvider.getColorFromAttribute(R.attr.riotx_background))
// Status bar color (pb in dark mode, icon of the status bar are dark)
setStatusBarColor(colorProvider.getColorFromAttribute(R.attr.riotx_header_panel_background))
// Known issue: there is still orange color used by the lib
// https://github.com/Yalantis/uCrop/issues/602
setActiveControlsWidgetColor(colorProvider.getColor(R.color.riotx_accent))
// Hide the logo (does not work)
setLogoColor(Color.TRANSPARENT)
}
)
createUCropWithDefaultSettings(requireContext(), uri, destinationFile.toUri(), currentAttachment.name)
.start(requireContext(), this)
}

View File

@ -0,0 +1,89 @@
/*
* 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.form
import android.text.Editable
import android.view.View
import androidx.appcompat.widget.AppCompatButton
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import im.vector.riotx.R
import im.vector.riotx.core.epoxy.VectorEpoxyHolder
import im.vector.riotx.core.epoxy.VectorEpoxyModel
import im.vector.riotx.core.platform.SimpleTextWatcher
@EpoxyModelClass(layout = R.layout.item_form_text_input_with_button)
abstract class FormEditTextWithButtonItem : VectorEpoxyModel<FormEditTextWithButtonItem.Holder>() {
@EpoxyAttribute
var hint: String? = null
@EpoxyAttribute
var value: String? = null
@EpoxyAttribute
var enabled: Boolean = true
@EpoxyAttribute
var buttonText: String? = null
@EpoxyAttribute
var onTextChange: ((String) -> Unit)? = null
@EpoxyAttribute
var onButtonClicked: ((View) -> Unit)? = null
private val onTextChangeListener = object : SimpleTextWatcher() {
override fun afterTextChanged(s: Editable) {
onTextChange?.invoke(s.toString())
}
}
override fun bind(holder: Holder) {
holder.textInputLayout.isEnabled = enabled
holder.textInputLayout.hint = hint
// Update only if text is different
if (holder.textInputEditText.text.toString() != value) {
holder.textInputEditText.setText(value)
}
holder.textInputEditText.isEnabled = enabled
holder.textInputEditText.addTextChangedListener(onTextChangeListener)
holder.textInputButton.text = buttonText
holder.textInputButton.setOnClickListener(onButtonClicked)
}
override fun shouldSaveViewState(): Boolean {
return false
}
override fun unbind(holder: Holder) {
super.unbind(holder)
holder.textInputEditText.removeTextChangedListener(onTextChangeListener)
}
class Holder : VectorEpoxyHolder() {
val textInputLayout by bind<TextInputLayout>(R.id.formTextInputTextInputLayout)
val textInputEditText by bind<TextInputEditText>(R.id.formTextInputTextInputEditText)
val textInputButton by bind<AppCompatButton>(R.id.formTextInputButton)
}
}

View File

@ -26,7 +26,6 @@ import im.vector.matrix.android.api.session.room.model.PowerLevelsContent
import im.vector.matrix.android.api.session.room.model.RoomAliasesContent
import im.vector.matrix.android.api.session.room.model.RoomCanonicalAliasContent
import im.vector.matrix.android.api.session.room.model.RoomGuestAccessContent
import im.vector.matrix.android.api.session.room.model.RoomHistoryVisibility
import im.vector.matrix.android.api.session.room.model.RoomHistoryVisibilityContent
import im.vector.matrix.android.api.session.room.model.RoomJoinRules
import im.vector.matrix.android.api.session.room.model.RoomJoinRulesContent
@ -47,6 +46,7 @@ import timber.log.Timber
import javax.inject.Inject
class NoticeEventFormatter @Inject constructor(private val sessionHolder: ActiveSessionHolder,
private val roomHistoryVisibilityFormatter: RoomHistoryVisibilityFormatter,
private val sp: StringProvider) {
private fun Event.isSentByCurrentUser() = senderId != null && senderId == sessionHolder.getSafeActiveSession()?.myUserId
@ -223,12 +223,7 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active
private fun formatRoomHistoryVisibilityEvent(event: Event, senderName: String?): CharSequence? {
val historyVisibility = event.getClearContent().toModel<RoomHistoryVisibilityContent>()?.historyVisibility ?: return null
val formattedVisibility = when (historyVisibility) {
RoomHistoryVisibility.SHARED -> sp.getString(R.string.notice_room_visibility_shared)
RoomHistoryVisibility.INVITED -> sp.getString(R.string.notice_room_visibility_invited)
RoomHistoryVisibility.JOINED -> sp.getString(R.string.notice_room_visibility_joined)
RoomHistoryVisibility.WORLD_READABLE -> sp.getString(R.string.notice_room_visibility_world_readable)
}
val formattedVisibility = roomHistoryVisibilityFormatter.format(historyVisibility)
return if (event.isSentByCurrentUser()) {
sp.getString(R.string.notice_made_future_room_visibility_by_you, formattedVisibility)
} else {

View File

@ -0,0 +1,36 @@
/*
* 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.home.room.detail.timeline.format
import im.vector.matrix.android.api.session.room.model.RoomHistoryVisibility
import im.vector.riotx.R
import im.vector.riotx.core.resources.StringProvider
import javax.inject.Inject
class RoomHistoryVisibilityFormatter @Inject constructor(
private val stringProvider: StringProvider
) {
fun format(roomHistoryVisibility: RoomHistoryVisibility): String {
return when (roomHistoryVisibility) {
RoomHistoryVisibility.SHARED -> stringProvider.getString(R.string.notice_room_visibility_shared)
RoomHistoryVisibility.INVITED -> stringProvider.getString(R.string.notice_room_visibility_invited)
RoomHistoryVisibility.JOINED -> stringProvider.getString(R.string.notice_room_visibility_joined)
RoomHistoryVisibility.WORLD_READABLE -> stringProvider.getString(R.string.notice_room_visibility_world_readable)
}
}
}

View File

@ -16,19 +16,41 @@
package im.vector.riotx.features.media
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri
import com.yalantis.ucrop.UCrop
import im.vector.riotx.R
import im.vector.riotx.core.di.ActiveSessionHolder
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.core.utils.PERMISSIONS_FOR_TAKING_PHOTO
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_CAMERA
import im.vector.riotx.core.utils.allGranted
import im.vector.riotx.core.utils.checkPermissions
import im.vector.riotx.multipicker.MultiPicker
import im.vector.riotx.multipicker.entity.MultiPickerImageType
import kotlinx.android.synthetic.main.activity_big_image_viewer.*
import java.io.File
import javax.inject.Inject
class BigImageViewerActivity : VectorBaseActivity() {
@Inject lateinit var sessionHolder: ActiveSessionHolder
@Inject lateinit var colorProvider: ColorProvider
@Inject lateinit var stringProvider: StringProvider
private var uri: Uri? = null
override fun getMenuRes() = R.menu.vector_big_avatar_viewer
override fun injectWith(injector: ScreenComponent) {
injector.inject(this)
@ -45,7 +67,7 @@ class BigImageViewerActivity : VectorBaseActivity() {
setDisplayHomeAsUpEnabled(true)
}
val uri = sessionHolder.getSafeActiveSession()
uri = sessionHolder.getSafeActiveSession()
?.contentUrlResolver()
?.resolveFullSize(intent.getStringExtra(EXTRA_IMAGE_URL))
?.toUri()
@ -57,14 +79,110 @@ class BigImageViewerActivity : VectorBaseActivity() {
}
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
menu.findItem(R.id.bigAvatarEditAction).isVisible = shouldShowEditAction()
return super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.bigAvatarEditAction) {
showAvatarSelector()
return true
}
return super.onOptionsItemSelected(item)
}
private fun shouldShowEditAction(): Boolean {
return uri != null && intent.getBooleanExtra(EXTRA_CAN_EDIT_IMAGE, false)
}
private fun showAvatarSelector() {
AlertDialog.Builder(this)
.setItems(arrayOf(
stringProvider.getString(R.string.attachment_type_camera),
stringProvider.getString(R.string.attachment_type_gallery)
)) { dialog, which ->
dialog.cancel()
onAvatarTypeSelected(isCamera = (which == 0))
}
.show()
}
private var avatarCameraUri: Uri? = null
private fun onAvatarTypeSelected(isCamera: Boolean) {
if (isCamera) {
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, this, PERMISSION_REQUEST_CODE_LAUNCH_CAMERA)) {
avatarCameraUri = MultiPicker.get(MultiPicker.CAMERA).startWithExpectingFile(this)
}
} else {
MultiPicker.get(MultiPicker.IMAGE).single().startWith(this)
}
}
private fun onRoomAvatarSelected(image: MultiPickerImageType) {
val destinationFile = File(cacheDir, "${image.displayName}_edited_image_${System.currentTimeMillis()}")
val uri = image.contentUri
createUCropWithDefaultSettings(this, uri, destinationFile.toUri(), image.displayName)
.apply { withAspectRatio(1f, 1f) }
.start(this)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (resultCode == Activity.RESULT_OK) {
when (requestCode) {
MultiPicker.REQUEST_CODE_TAKE_PHOTO -> {
avatarCameraUri?.let { uri ->
MultiPicker.get(MultiPicker.CAMERA)
.getTakenPhoto(this, requestCode, resultCode, uri)
?.let {
onRoomAvatarSelected(it)
}
}
}
MultiPicker.REQUEST_CODE_PICK_IMAGE -> {
MultiPicker
.get(MultiPicker.IMAGE)
.getSelectedFiles(this, requestCode, resultCode, data)
.firstOrNull()?.let {
// TODO. UCrop library cannot read from Gallery. For now, we will set avatar as it is.
// onRoomAvatarSelected(it)
onAvatarCropped(it.contentUri)
}
}
UCrop.REQUEST_CROP -> data?.let { onAvatarCropped(UCrop.getOutput(it)) }
}
}
super.onActivityResult(requestCode, resultCode, data)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
if (allGranted(grantResults)) {
when (requestCode) {
PERMISSION_REQUEST_CODE_LAUNCH_CAMERA -> onAvatarTypeSelected(true)
}
}
}
private fun onAvatarCropped(uri: Uri?) {
if (uri != null) {
setResult(Activity.RESULT_OK, Intent().setData(uri))
this@BigImageViewerActivity.finish()
} else {
Toast.makeText(this, "Cannot retrieve cropped value", Toast.LENGTH_SHORT).show()
}
}
companion object {
private const val EXTRA_TITLE = "EXTRA_TITLE"
private const val EXTRA_IMAGE_URL = "EXTRA_IMAGE_URL"
private const val EXTRA_CAN_EDIT_IMAGE = "EXTRA_CAN_EDIT_IMAGE"
const val REQUEST_CODE = 1000
fun newIntent(context: Context, title: String?, imageUrl: String): Intent {
fun newIntent(context: Context, title: String?, imageUrl: String, canEditImage: Boolean = false): Intent {
return Intent(context, BigImageViewerActivity::class.java).apply {
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_IMAGE_URL, imageUrl)
putExtra(EXTRA_CAN_EDIT_IMAGE, canEditImage)
}
}
}

View File

@ -0,0 +1,55 @@
/*
* 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.media
import android.content.Context
import android.graphics.Color
import android.net.Uri
import androidx.core.content.ContextCompat
import com.yalantis.ucrop.UCrop
import com.yalantis.ucrop.UCropActivity
import im.vector.riotx.R
import im.vector.riotx.features.themes.ThemeUtils
fun createUCropWithDefaultSettings(context: Context, source: Uri, destination: Uri, toolbarTitle: String?): UCrop {
return UCrop.of(source, destination)
.withOptions(
UCrop.Options()
.apply {
setAllowedGestures(
/* tabScale = */ UCropActivity.SCALE,
/* tabRotate = */ UCropActivity.ALL,
/* tabAspectRatio = */ UCropActivity.SCALE
)
setToolbarTitle(toolbarTitle)
// Disable freestyle crop, usability was not easy
// setFreeStyleCropEnabled(true)
// Color used for toolbar icon and text
setToolbarColor(ThemeUtils.getColor(context, R.attr.riotx_background))
setToolbarWidgetColor(ThemeUtils.getColor(context, R.attr.vctr_toolbar_primary_text_color))
// Background
setRootViewBackgroundColor(ThemeUtils.getColor(context, R.attr.riotx_background))
// Status bar color (pb in dark mode, icon of the status bar are dark)
setStatusBarColor(ThemeUtils.getColor(context, R.attr.riotx_header_panel_background))
// Known issue: there is still orange color used by the lib
// https://github.com/Yalantis/uCrop/issues/602
setActiveControlsWidgetColor(ContextCompat.getColor(context, R.color.riotx_accent))
// Hide the logo (does not work)
setLogoColor(Color.TRANSPARENT)
}
)
}

View File

@ -17,11 +17,13 @@
package im.vector.riotx.features.roomprofile
import android.net.Uri
import im.vector.matrix.android.api.session.room.notification.RoomNotificationState
import im.vector.riotx.core.platform.VectorViewModelAction
sealed class RoomProfileAction: VectorViewModelAction {
object LeaveRoom: RoomProfileAction()
data class ChangeRoomNotificationState(val notificationState: RoomNotificationState) : RoomProfileAction()
data class ChangeRoomAvatar(val uri: Uri, val fileName: String?) : RoomProfileAction()
object ShareRoomProfile : RoomProfileAction()
}

View File

@ -17,15 +17,23 @@
package im.vector.riotx.features.roomprofile
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import android.view.MenuItem
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityOptionsCompat
import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import androidx.core.view.isVisible
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import com.yalantis.ucrop.UCrop
import im.vector.matrix.android.api.session.room.notification.RoomNotificationState
import im.vector.matrix.android.api.util.MatrixItem
import im.vector.matrix.android.api.util.toMatrixItem
@ -36,7 +44,12 @@ import im.vector.riotx.core.extensions.cleanup
import im.vector.riotx.core.extensions.configureWith
import im.vector.riotx.core.extensions.exhaustive
import im.vector.riotx.core.extensions.setTextOrHide
import im.vector.riotx.core.intent.getFilenameFromUri
import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.core.utils.PERMISSIONS_FOR_TAKING_PHOTO
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_CAMERA
import im.vector.riotx.core.utils.allGranted
import im.vector.riotx.core.utils.checkPermissions
import im.vector.riotx.core.utils.copyToClipboard
import im.vector.riotx.core.utils.startSharePlainTextIntent
import im.vector.riotx.features.crypto.util.toImageRes
@ -45,10 +58,15 @@ import im.vector.riotx.features.home.room.list.actions.RoomListActionsArgs
import im.vector.riotx.features.home.room.list.actions.RoomListQuickActionsBottomSheet
import im.vector.riotx.features.home.room.list.actions.RoomListQuickActionsSharedAction
import im.vector.riotx.features.home.room.list.actions.RoomListQuickActionsSharedActionViewModel
import im.vector.riotx.features.media.BigImageViewerActivity
import im.vector.riotx.features.media.createUCropWithDefaultSettings
import im.vector.riotx.multipicker.MultiPicker
import im.vector.riotx.multipicker.entity.MultiPickerImageType
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_matrix_profile.*
import kotlinx.android.synthetic.main.view_stub_room_profile_header.*
import timber.log.Timber
import java.io.File
import javax.inject.Inject
@Parcelize
@ -96,6 +114,7 @@ class RoomProfileFragment @Inject constructor(
is RoomProfileViewEvents.Failure -> showFailure(it.throwable)
is RoomProfileViewEvents.OnLeaveRoomSuccess -> onLeaveRoom()
is RoomProfileViewEvents.ShareRoomProfile -> onShareRoomProfile(it.permalink)
RoomProfileViewEvents.OnChangeAvatarSuccess -> dismissLoadingDialog()
}.exhaustive
}
roomListQuickActionsSharedActionViewModel
@ -221,7 +240,89 @@ class RoomProfileFragment @Inject constructor(
startSharePlainTextIntent(fragment = this, chooserTitle = null, text = permalink)
}
private fun onAvatarClicked(view: View, matrixItem: MatrixItem.RoomItem) {
navigator.openBigImageViewer(requireActivity(), view, matrixItem)
private fun onAvatarClicked(view: View, matrixItem: MatrixItem.RoomItem) = withState(roomProfileViewModel) {
if (matrixItem.avatarUrl?.isNotEmpty() == true) {
val intent = BigImageViewerActivity.newIntent(requireContext(), matrixItem.getBestName(), matrixItem.avatarUrl!!, it.canChangeAvatar)
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(activity!!, view, ViewCompat.getTransitionName(view) ?: "")
startActivityForResult(intent, BigImageViewerActivity.REQUEST_CODE, options.toBundle())
} else if (it.canChangeAvatar) {
showAvatarSelector()
}
}
private fun showAvatarSelector() {
AlertDialog.Builder(requireContext())
.setItems(arrayOf(
getString(R.string.attachment_type_camera),
getString(R.string.attachment_type_gallery)
)) { dialog, which ->
dialog.cancel()
onAvatarTypeSelected(isCamera = (which == 0))
}
.show()
}
private var avatarCameraUri: Uri? = null
private fun onAvatarTypeSelected(isCamera: Boolean) {
if (isCamera) {
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, this, PERMISSION_REQUEST_CODE_LAUNCH_CAMERA)) {
avatarCameraUri = MultiPicker.get(MultiPicker.CAMERA).startWithExpectingFile(this)
}
} else {
MultiPicker.get(MultiPicker.IMAGE).single().startWith(this)
}
}
private fun onRoomAvatarSelected(image: MultiPickerImageType) {
val destinationFile = File(requireContext().cacheDir, "${image.displayName}_edited_image_${System.currentTimeMillis()}")
val uri = image.contentUri
createUCropWithDefaultSettings(requireContext(), uri, destinationFile.toUri(), image.displayName)
.apply { withAspectRatio(1f, 1f) }
.start(requireContext(), this)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (resultCode == Activity.RESULT_OK) {
when (requestCode) {
MultiPicker.REQUEST_CODE_TAKE_PHOTO -> {
avatarCameraUri?.let { uri ->
MultiPicker.get(MultiPicker.CAMERA)
.getTakenPhoto(requireContext(), requestCode, resultCode, uri)
?.let {
onRoomAvatarSelected(it)
}
}
}
MultiPicker.REQUEST_CODE_PICK_IMAGE -> {
MultiPicker
.get(MultiPicker.IMAGE)
.getSelectedFiles(requireContext(), requestCode, resultCode, data)
.firstOrNull()?.let {
// TODO. UCrop library cannot read from Gallery. For now, we will set avatar as it is.
// onRoomAvatarSelected(it)
onAvatarCropped(it.contentUri)
}
}
UCrop.REQUEST_CROP -> data?.let { onAvatarCropped(UCrop.getOutput(it)) }
BigImageViewerActivity.REQUEST_CODE -> data?.let { onAvatarCropped(it.data) }
}
}
super.onActivityResult(requestCode, resultCode, data)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
if (allGranted(grantResults)) {
when (requestCode) {
PERMISSION_REQUEST_CODE_LAUNCH_CAMERA -> onAvatarTypeSelected(true)
}
}
}
private fun onAvatarCropped(uri: Uri?) {
if (uri != null) {
roomProfileViewModel.handle(RoomProfileAction.ChangeRoomAvatar(uri, getFilenameFromUri(context, uri)))
} else {
Toast.makeText(requireContext(), "Cannot retrieve cropped value", Toast.LENGTH_SHORT).show()
}
}
}

View File

@ -26,5 +26,6 @@ sealed class RoomProfileViewEvents : VectorViewEvents {
data class Failure(val throwable: Throwable) : RoomProfileViewEvents()
object OnLeaveRoomSuccess : RoomProfileViewEvents()
object OnChangeAvatarSuccess : RoomProfileViewEvents()
data class ShareRoomProfile(val permalink: String) : RoomProfileViewEvents()
}

View File

@ -25,11 +25,14 @@ import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.permalinks.PermalinkFactory
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.room.powerlevels.PowerLevelsHelper
import im.vector.matrix.rx.rx
import im.vector.matrix.rx.unwrap
import im.vector.riotx.R
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.powerlevel.PowerLevelsObservableFactory
import java.util.UUID
class RoomProfileViewModel @AssistedInject constructor(@Assisted private val initialState: RoomProfileViewState,
private val stringProvider: StringProvider,
@ -62,12 +65,22 @@ class RoomProfileViewModel @AssistedInject constructor(@Assisted private val ini
.execute {
copy(roomSummary = it)
}
val powerLevelsContentLive = PowerLevelsObservableFactory(room).createObservable()
powerLevelsContentLive
.subscribe {
val powerLevelsHelper = PowerLevelsHelper(it)
setState { copy(canChangeAvatar = powerLevelsHelper.isUserAbleToChangeRoomAvatar(session.myUserId)) }
}
.disposeOnClear()
}
override fun handle(action: RoomProfileAction) = when (action) {
RoomProfileAction.LeaveRoom -> handleLeaveRoom()
is RoomProfileAction.ChangeRoomNotificationState -> handleChangeNotificationMode(action)
is RoomProfileAction.ShareRoomProfile -> handleShareRoomProfile()
is RoomProfileAction.ChangeRoomAvatar -> handleChangeAvatar(action)
}
private fun handleChangeNotificationMode(action: RoomProfileAction.ChangeRoomNotificationState) {
@ -96,4 +109,18 @@ class RoomProfileViewModel @AssistedInject constructor(@Assisted private val ini
_viewEvents.post(RoomProfileViewEvents.ShareRoomProfile(permalink))
}
}
private fun handleChangeAvatar(action: RoomProfileAction.ChangeRoomAvatar) {
_viewEvents.post(RoomProfileViewEvents.Loading())
room.rx().updateAvatar(action.uri, action.fileName ?: UUID.randomUUID().toString())
.subscribe(
{
_viewEvents.post(RoomProfileViewEvents.OnChangeAvatarSuccess)
},
{
_viewEvents.post(RoomProfileViewEvents.Failure(it))
}
)
.disposeOnClear()
}
}

View File

@ -24,7 +24,8 @@ import im.vector.matrix.android.api.session.room.model.RoomSummary
data class RoomProfileViewState(
val roomId: String,
val roomSummary: Async<RoomSummary> = Uninitialized
val roomSummary: Async<RoomSummary> = Uninitialized,
val canChangeAvatar: Boolean = false
) : MvRxState {
constructor(args: RoomProfileArgs) : this(roomId = args.roomId)

View File

@ -16,11 +16,14 @@
package im.vector.riotx.features.roomprofile.settings
import im.vector.matrix.android.api.session.room.model.RoomHistoryVisibility
import im.vector.riotx.core.platform.VectorViewModelAction
sealed class RoomSettingsAction : VectorViewModelAction {
data class SetRoomName(val newName: String) : RoomSettingsAction()
data class SetRoomTopic(val newTopic: String) : RoomSettingsAction()
data class SetRoomAvatar(val newAvatarUrl: String) : RoomSettingsAction()
data class SetRoomHistoryVisibility(val visibility: RoomHistoryVisibility) : RoomSettingsAction()
data class SetRoomCanonicalAlias(val newCanonicalAlias: String) : RoomSettingsAction()
object EnableEncryption : RoomSettingsAction()
object Save : RoomSettingsAction()
}

View File

@ -17,21 +17,30 @@
package im.vector.riotx.features.roomprofile.settings
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.RoomHistoryVisibilityContent
import im.vector.riotx.R
import im.vector.riotx.core.epoxy.profiles.buildProfileAction
import im.vector.riotx.core.epoxy.profiles.buildProfileSection
import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.form.formEditTextItem
import im.vector.riotx.features.home.room.detail.timeline.format.RoomHistoryVisibilityFormatter
import javax.inject.Inject
// TODO Add other feature here (waiting for design)
class RoomSettingsController @Inject constructor(
private val stringProvider: StringProvider,
private val roomHistoryVisibilityFormatter: RoomHistoryVisibilityFormatter,
colorProvider: ColorProvider
) : TypedEpoxyController<RoomSettingsViewState>() {
interface Callback {
fun onEnableEncryptionClicked()
fun onNameChanged(name: String)
fun onTopicChanged(topic: String)
fun onHistoryVisibilityClicked()
fun onAliasChanged(alias: String)
}
private val dividerColor = colorProvider.getColorFromAttribute(R.attr.vctr_list_divider_color)
@ -45,10 +54,56 @@ class RoomSettingsController @Inject constructor(
override fun buildModels(data: RoomSettingsViewState?) {
val roomSummary = data?.roomSummary?.invoke() ?: return
val historyVisibility = data.historyVisibilityEvent?.let { formatRoomHistoryVisibilityEvent(it) } ?: ""
val newHistoryVisibility = data.newHistoryVisibility?.let { roomHistoryVisibilityFormatter.format(it) }
buildProfileSection(
stringProvider.getString(R.string.settings)
)
formEditTextItem {
id("name")
enabled(data.actionPermissions.canChangeName)
value(data.newName ?: roomSummary.displayName)
hint(stringProvider.getString(R.string.room_settings_name_hint))
onTextChange { text ->
callback?.onNameChanged(text)
}
}
formEditTextItem {
id("topic")
enabled(data.actionPermissions.canChangeTopic)
value(data.newTopic ?: roomSummary.topic)
hint(stringProvider.getString(R.string.room_settings_topic_hint))
onTextChange { text ->
callback?.onTopicChanged(text)
}
}
formEditTextItem {
id("alias")
enabled(data.actionPermissions.canChangeCanonicalAlias)
value(data.newCanonicalAlias ?: roomSummary.canonicalAlias)
hint(stringProvider.getString(R.string.room_settings_addresses_add_new_address))
onTextChange { text ->
callback?.onAliasChanged(text)
}
}
buildProfileAction(
id = "historyReadability",
title = stringProvider.getString(R.string.room_settings_room_read_history_rules_pref_title),
subtitle = newHistoryVisibility ?: historyVisibility,
dividerColor = dividerColor,
divider = false,
editable = data.actionPermissions.canChangeHistoryReadability,
action = { if (data.actionPermissions.canChangeHistoryReadability) callback?.onHistoryVisibilityClicked() }
)
if (roomSummary.isEncrypted) {
buildProfileAction(
id = "encryption",
@ -69,4 +124,9 @@ class RoomSettingsController @Inject constructor(
)
}
}
private fun formatRoomHistoryVisibilityEvent(event: Event): String? {
val historyVisibility = event.getClearContent().toModel<RoomHistoryVisibilityContent>()?.historyVisibility ?: return null
return roomHistoryVisibilityFormatter.format(historyVisibility)
}
}

View File

@ -17,19 +17,26 @@
package im.vector.riotx.features.roomprofile.settings
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomHistoryVisibility
import im.vector.matrix.android.api.session.room.model.RoomHistoryVisibilityContent
import im.vector.matrix.android.api.util.toMatrixItem
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.exhaustive
import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.core.utils.toast
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.format.RoomHistoryVisibilityFormatter
import im.vector.riotx.features.roomprofile.RoomProfileArgs
import kotlinx.android.synthetic.main.fragment_room_setting_generic.*
import kotlinx.android.synthetic.main.merge_overlay_waiting_view.*
@ -38,6 +45,7 @@ import javax.inject.Inject
class RoomSettingsFragment @Inject constructor(
val viewModelFactory: RoomSettingsViewModel.Factory,
private val controller: RoomSettingsController,
private val roomHistoryVisibilityFormatter: RoomHistoryVisibilityFormatter,
private val avatarRenderer: AvatarRenderer
) : VectorBaseFragment(), RoomSettingsController.Callback {
@ -46,6 +54,8 @@ class RoomSettingsFragment @Inject constructor(
override fun getLayoutResId() = R.layout.fragment_room_setting_generic
override fun getMenuRes() = R.menu.vector_room_settings
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
controller.callback = this
@ -57,20 +67,50 @@ class RoomSettingsFragment @Inject constructor(
viewModel.observeViewEvents {
when (it) {
is RoomSettingsViewEvents.Failure -> showFailure(it.throwable)
is RoomSettingsViewEvents.Success -> showSuccess()
}.exhaustive
}
}
private fun showSuccess() {
activity?.toast(R.string.room_settings_save_success)
}
override fun onDestroyView() {
recyclerView.cleanup()
super.onDestroyView()
}
override fun onPrepareOptionsMenu(menu: Menu) {
withState(viewModel) { state ->
menu.findItem(R.id.roomSettingsSaveAction).isVisible = state.showSaveAction
}
super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.roomSettingsSaveAction) {
viewModel.handle(RoomSettingsAction.Save)
}
return super.onOptionsItemSelected(item)
}
override fun invalidate() = withState(viewModel) { viewState ->
controller.setData(viewState)
renderRoomSummary(viewState)
}
private fun renderRoomSummary(state: RoomSettingsViewState) {
waiting_view.isVisible = state.isLoading
state.roomSummary()?.let {
roomSettingsToolbarTitleView.text = it.displayName
avatarRenderer.render(it.toMatrixItem(), roomSettingsToolbarAvatarImageView)
}
invalidateOptionsMenu()
}
override fun onEnableEncryptionClicked() {
AlertDialog.Builder(requireActivity())
.setTitle(R.string.room_settings_enable_encryption_dialog_title)
@ -82,12 +122,43 @@ class RoomSettingsFragment @Inject constructor(
.show()
}
private fun renderRoomSummary(state: RoomSettingsViewState) {
waiting_view.isVisible = state.isLoading
override fun onNameChanged(name: String) {
viewModel.handle(RoomSettingsAction.SetRoomName(name))
}
state.roomSummary()?.let {
roomSettingsToolbarTitleView.text = it.displayName
avatarRenderer.render(it.toMatrixItem(), roomSettingsToolbarAvatarImageView)
override fun onTopicChanged(topic: String) {
viewModel.handle(RoomSettingsAction.SetRoomTopic(topic))
}
override fun onHistoryVisibilityClicked() = withState(viewModel) { state ->
val historyVisibilities = arrayOf(
RoomHistoryVisibility.SHARED,
RoomHistoryVisibility.INVITED,
RoomHistoryVisibility.JOINED,
RoomHistoryVisibility.WORLD_READABLE
)
val currentHistoryVisibility =
state.newHistoryVisibility ?: state.historyVisibilityEvent?.getClearContent().toModel<RoomHistoryVisibilityContent>()?.historyVisibility
val currentHistoryVisibilityIndex = historyVisibilities.indexOf(currentHistoryVisibility)
AlertDialog.Builder(requireContext()).apply {
setTitle(R.string.room_settings_room_read_history_rules_pref_title)
setSingleChoiceItems(
historyVisibilities
.map { roomHistoryVisibilityFormatter.format(it) }
.toTypedArray(),
currentHistoryVisibilityIndex) { dialog, which ->
if (which != currentHistoryVisibilityIndex) {
viewModel.handle(RoomSettingsAction.SetRoomHistoryVisibility(historyVisibilities[which]))
}
dialog.cancel()
}
show()
}
return@withState
}
override fun onAliasChanged(alias: String) {
viewModel.handle(RoomSettingsAction.SetRoomCanonicalAlias(alias))
}
}

View File

@ -24,4 +24,5 @@ import im.vector.riotx.core.platform.VectorViewEvents
*/
sealed class RoomSettingsViewEvents : VectorViewEvents {
data class Failure(val throwable: Throwable) : RoomSettingsViewEvents()
object Success : RoomSettingsViewEvents()
}

View File

@ -23,9 +23,15 @@ 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.events.model.EventType
import im.vector.matrix.android.api.session.room.powerlevels.PowerLevelsHelper
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.VectorViewModel
import im.vector.riotx.features.powerlevel.PowerLevelsObservableFactory
import io.reactivex.Completable
import io.reactivex.Observable
class RoomSettingsViewModel @AssistedInject constructor(@Assisted initialState: RoomSettingsViewState,
private val session: Session)
@ -49,41 +55,130 @@ class RoomSettingsViewModel @AssistedInject constructor(@Assisted initialState:
init {
observeRoomSummary()
observeState()
}
private fun observeState() {
selectSubscribe(
RoomSettingsViewState::newName,
RoomSettingsViewState::newCanonicalAlias,
RoomSettingsViewState::newTopic,
RoomSettingsViewState::newHistoryVisibility,
RoomSettingsViewState::roomSummary) { newName,
newCanonicalAlias,
newTopic,
newHistoryVisibility,
asyncSummary ->
val summary = asyncSummary()
setState {
copy(
showSaveAction = summary?.name != newName
|| summary?.topic != newTopic
|| summary?.canonicalAlias != newCanonicalAlias?.takeIf { it.isNotEmpty() }
|| newHistoryVisibility != null
)
}
}
}
private fun observeRoomSummary() {
room.rx().liveRoomSummary()
.unwrap()
.execute { async ->
copy(roomSummary = async)
val roomSummary = async.invoke()
copy(
historyVisibilityEvent = room.getStateEvent(EventType.STATE_ROOM_HISTORY_VISIBILITY),
roomSummary = async,
newName = roomSummary?.name,
newTopic = roomSummary?.topic,
newCanonicalAlias = roomSummary?.canonicalAlias
)
}
val powerLevelsContentLive = PowerLevelsObservableFactory(room).createObservable()
powerLevelsContentLive
.subscribe {
val powerLevelsHelper = PowerLevelsHelper(it)
val permissions = RoomSettingsViewState.ActionPermissions(
canChangeName = powerLevelsHelper.isUserAbleToChangeRoomName(session.myUserId),
canChangeTopic = powerLevelsHelper.isUserAbleToChangeRoomTopic(session.myUserId),
canChangeCanonicalAlias = powerLevelsHelper.isUserAbleToChangeRoomCanonicalAlias(session.myUserId),
canChangeHistoryReadability = powerLevelsHelper.isUserAbleToChangeRoomHistoryReadability(session.myUserId)
)
setState { copy(actionPermissions = permissions) }
}
.disposeOnClear()
}
override fun handle(action: RoomSettingsAction) {
when (action) {
is RoomSettingsAction.EnableEncryption -> handleEnableEncryption()
is RoomSettingsAction.EnableEncryption -> handleEnableEncryption()
is RoomSettingsAction.SetRoomName -> setState { copy(newName = action.newName) }
is RoomSettingsAction.SetRoomTopic -> setState { copy(newTopic = action.newTopic) }
is RoomSettingsAction.SetRoomHistoryVisibility -> setState { copy(newHistoryVisibility = action.visibility) }
is RoomSettingsAction.SetRoomCanonicalAlias -> setState { copy(newCanonicalAlias = action.newCanonicalAlias) }
is RoomSettingsAction.Save -> saveSettings()
}.exhaustive
}
private fun saveSettings() = withState { state ->
postLoading(true)
val operationList = mutableListOf<Completable>()
val summary = state.roomSummary.invoke()
if (summary?.name != state.newName) {
operationList.add(room.rx().updateName(state.newName ?: ""))
}
if (summary?.topic != state.newTopic) {
operationList.add(room.rx().updateTopic(state.newTopic ?: ""))
}
if (state.newCanonicalAlias != null && summary?.canonicalAlias != state.newCanonicalAlias.takeIf { it.isNotEmpty() }) {
operationList.add(room.rx().addRoomAlias(state.newCanonicalAlias))
operationList.add(room.rx().updateCanonicalAlias(state.newCanonicalAlias))
}
if (state.newHistoryVisibility != null) {
operationList.add(room.rx().updateHistoryReadability(state.newHistoryVisibility))
}
Observable
.fromIterable(operationList)
.concatMapCompletable { it }
.subscribe(
{
postLoading(false)
setState { copy(newHistoryVisibility = null) }
_viewEvents.post(RoomSettingsViewEvents.Success)
},
{
postLoading(false)
_viewEvents.post(RoomSettingsViewEvents.Failure(it))
}
)
}
private fun handleEnableEncryption() {
setState {
copy(isLoading = true)
}
postLoading(true)
room.enableEncryption(callback = object : MatrixCallback<Unit> {
override fun onFailure(failure: Throwable) {
setState {
copy(isLoading = false)
}
postLoading(false)
_viewEvents.post(RoomSettingsViewEvents.Failure(failure))
}
override fun onSuccess(data: Unit) {
setState {
copy(isLoading = false)
}
postLoading(false)
}
})
}
private fun postLoading(isLoading: Boolean) {
setState {
copy(isLoading = isLoading)
}
}
}

View File

@ -19,14 +19,30 @@ package im.vector.riotx.features.roomprofile.settings
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.RoomHistoryVisibility
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotx.features.roomprofile.RoomProfileArgs
data class RoomSettingsViewState(
val roomId: String,
val historyVisibilityEvent: Event? = null,
val roomSummary: Async<RoomSummary> = Uninitialized,
val isLoading: Boolean = false
val isLoading: Boolean = false,
val newName: String? = null,
val newTopic: String? = null,
val newHistoryVisibility: RoomHistoryVisibility? = null,
val newCanonicalAlias: String? = null,
val showSaveAction: Boolean = false,
val actionPermissions: ActionPermissions = ActionPermissions()
) : MvRxState {
constructor(args: RoomProfileArgs) : this(roomId = args.roomId)
data class ActionPermissions(
val canChangeName: Boolean = false,
val canChangeTopic: Boolean = false,
val canChangeCanonicalAlias: Boolean = false,
val canChangeHistoryReadability: Boolean = false
)
}

View File

@ -20,13 +20,16 @@ package im.vector.riotx.features.settings
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.text.Editable
import android.util.Patterns
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.view.isVisible
import androidx.preference.EditTextPreference
import androidx.preference.Preference
@ -36,6 +39,7 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.cache.DiskCache
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import com.yalantis.ucrop.UCrop
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.NoOpMatrixCallback
import im.vector.matrix.android.api.failure.isInvalidPassword
@ -44,25 +48,32 @@ import im.vector.matrix.android.api.session.integrationmanager.IntegrationManage
import im.vector.riotx.R
import im.vector.riotx.core.extensions.hideKeyboard
import im.vector.riotx.core.extensions.showPassword
import im.vector.riotx.core.intent.getFilenameFromUri
import im.vector.riotx.core.platform.SimpleTextWatcher
import im.vector.riotx.core.preference.UserAvatarPreference
import im.vector.riotx.core.preference.VectorPreference
import im.vector.riotx.core.preference.VectorSwitchPreference
import im.vector.riotx.core.utils.PERMISSIONS_FOR_TAKING_PHOTO
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_CAMERA
import im.vector.riotx.core.utils.TextUtils
import im.vector.riotx.core.utils.allGranted
import im.vector.riotx.core.utils.checkPermissions
import im.vector.riotx.core.utils.copyToClipboard
import im.vector.riotx.core.utils.getSizeOfFiles
import im.vector.riotx.core.utils.toast
import im.vector.riotx.features.MainActivity
import im.vector.riotx.features.MainActivityArgs
import im.vector.riotx.features.media.createUCropWithDefaultSettings
import im.vector.riotx.features.themes.ThemeUtils
import im.vector.riotx.features.workers.signout.SignOutUiWorker
import im.vector.riotx.multipicker.MultiPicker
import im.vector.riotx.multipicker.entity.MultiPickerImageType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.UUID
class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
@ -72,6 +83,8 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
private var mDisplayedEmails = ArrayList<String>()
private var mDisplayedPhoneNumber = ArrayList<String>()
private var avatarCameraUri: Uri? = null
private val mUserSettingsCategory by lazy {
findPreference<PreferenceCategory>(VectorPreferences.SETTINGS_USER_SETTINGS_PREFERENCE_KEY)!!
}
@ -281,7 +294,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
if (allGranted(grantResults)) {
if (requestCode == PERMISSION_REQUEST_CODE_LAUNCH_CAMERA) {
changeAvatar()
onAvatarTypeSelected(true)
}
}
}
@ -291,8 +304,27 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
if (resultCode == Activity.RESULT_OK) {
when (requestCode) {
REQUEST_NEW_PHONE_NUMBER -> refreshPhoneNumbersList()
REQUEST_PHONEBOOK_COUNTRY -> onPhonebookCountryUpdate(data)
REQUEST_NEW_PHONE_NUMBER -> refreshPhoneNumbersList()
REQUEST_PHONEBOOK_COUNTRY -> onPhonebookCountryUpdate(data)
MultiPicker.REQUEST_CODE_TAKE_PHOTO -> {
avatarCameraUri?.let { uri ->
MultiPicker.get(MultiPicker.CAMERA)
.getTakenPhoto(requireContext(), requestCode, resultCode, uri)
?.let {
onAvatarSelected(it)
}
}
}
MultiPicker.REQUEST_CODE_PICK_IMAGE -> {
MultiPicker
.get(MultiPicker.IMAGE)
.getSelectedFiles(requireContext(), requestCode, resultCode, data)
.firstOrNull()?.let {
// TODO. UCrop library cannot read from Gallery. For now, we will set avatar as it is.
onAvatarCropped(it.contentUri)
}
}
UCrop.REQUEST_CROP -> data?.let { onAvatarCropped(UCrop.getOutput(it)) }
/* TODO
VectorUtils.TAKE_IMAGE -> {
val thumbnailUri = VectorUtils.getThumbnailUriFromIntent(activity, data, session.mediaCache)
@ -370,21 +402,59 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
* Update the avatar.
*/
private fun onUpdateAvatarClick() {
notImplemented()
/* TODO
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, this, PERMISSION_REQUEST_CODE_LAUNCH_CAMERA)) {
changeAvatar()
}
*/
AlertDialog
.Builder(requireContext())
.setItems(arrayOf(
getString(R.string.attachment_type_camera),
getString(R.string.attachment_type_gallery)
)) { dialog, which ->
dialog.cancel()
onAvatarTypeSelected(isCamera = (which == 0))
}.show()
}
private fun changeAvatar() {
/* TODO
val intent = Intent(activity, VectorMediaPickerActivity::class.java)
intent.putExtra(VectorMediaPickerActivity.EXTRA_AVATAR_MODE, true)
startActivityForResult(intent, VectorUtils.TAKE_IMAGE)
*/
private fun onAvatarTypeSelected(isCamera: Boolean) {
if (isCamera) {
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, this, PERMISSION_REQUEST_CODE_LAUNCH_CAMERA)) {
avatarCameraUri = MultiPicker.get(MultiPicker.CAMERA).startWithExpectingFile(this)
}
} else {
MultiPicker.get(MultiPicker.IMAGE).single().startWith(this)
}
}
private fun onAvatarSelected(image: MultiPickerImageType) {
val destinationFile = File(requireContext().cacheDir, "${image.displayName}_edited_image_${System.currentTimeMillis()}")
val uri = image.contentUri
createUCropWithDefaultSettings(requireContext(), uri, destinationFile.toUri(), image.displayName)
.apply { withAspectRatio(1f, 1f) }
.start(requireContext(), this)
}
private fun onAvatarCropped(uri: Uri?) {
if (uri != null) {
uploadAvatar(uri)
} else {
Toast.makeText(requireContext(), "Cannot retrieve cropped value", Toast.LENGTH_SHORT).show()
}
}
private fun uploadAvatar(uri: Uri) {
displayLoadingView()
session.updateAvatar(session.myUserId, uri, getFilenameFromUri(context, uri) ?: UUID.randomUUID().toString(), object : MatrixCallback<Unit> {
override fun onSuccess(data: Unit) {
if (!isAdded) return
mUserAvatarPreference.refreshAvatar()
onCommonDone(null)
}
override fun onFailure(failure: Throwable) {
if (!isAdded) return
onCommonDone(failure.localizedMessage)
}
})
}
// ==============================================================================================================
@ -505,9 +575,9 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
} */
}
// ==============================================================================================================
// Email management
// ==============================================================================================================
// ==============================================================================================================
// Email management
// ==============================================================================================================
/**
* Refresh the emails list
@ -632,47 +702,47 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
*
* @param pid the used pid.
*/
/* TODO
private fun showEmailValidationDialog(pid: ThreePid) {
activity?.let {
AlertDialog.Builder(it)
.setTitle(R.string.account_email_validation_title)
.setMessage(R.string.account_email_validation_message)
.setPositiveButton(R.string._continue) { _, _ ->
session.myUser.add3Pid(pid, true, object : MatrixCallback<Unit> {
override fun onSuccess(info: Void?) {
/* TODO
private fun showEmailValidationDialog(pid: ThreePid) {
activity?.let {
AlertDialog.Builder(it)
.setTitle(R.string.account_email_validation_title)
.setMessage(R.string.account_email_validation_message)
.setPositiveButton(R.string._continue) { _, _ ->
session.myUser.add3Pid(pid, true, object : MatrixCallback<Unit> {
override fun onSuccess(info: Void?) {
it.runOnUiThread {
hideLoadingView()
refreshEmailsList()
}
}
override fun onNetworkError(e: Exception) {
onCommonDone(e.localizedMessage)
}
override fun onMatrixError(e: MatrixError) {
if (TextUtils.equals(e.errcode, MatrixError.THREEPID_AUTH_FAILED)) {
it.runOnUiThread {
hideLoadingView()
refreshEmailsList()
it.toast(R.string.account_email_validation_error)
}
}
override fun onNetworkError(e: Exception) {
} else {
onCommonDone(e.localizedMessage)
}
}
override fun onMatrixError(e: MatrixError) {
if (TextUtils.equals(e.errcode, MatrixError.THREEPID_AUTH_FAILED)) {
it.runOnUiThread {
hideLoadingView()
it.toast(R.string.account_email_validation_error)
}
} else {
onCommonDone(e.localizedMessage)
}
}
override fun onUnexpectedError(e: Exception) {
onCommonDone(e.localizedMessage)
}
})
}
.setNegativeButton(R.string.cancel) { _, _ ->
hideLoadingView()
}
.show()
}
} */
override fun onUnexpectedError(e: Exception) {
onCommonDone(e.localizedMessage)
}
})
}
.setNegativeButton(R.string.cancel) { _, _ ->
hideLoadingView()
}
.show()
}
} */
/**
* Display a dialog which asks confirmation for the deletion of a 3pid

View File

@ -0,0 +1,50 @@
<?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:minHeight="@dimen/item_form_min_height">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/formTextInputTextInputLayout"
style="@style/VectorTextInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
app:layout_constraintBottom_toTopOf="@+id/formTextInputDivider"
app:layout_constraintEnd_toStartOf="@id/formTextInputButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/formTextInputTextInputEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:hint="@string/create_room_name_hint" />
</com.google.android.material.textfield.TextInputLayout>
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/formTextInputButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:background="?attr/colorAccent"
app:layout_constraintBottom_toBottomOf="@id/formTextInputTextInputLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/formTextInputTextInputLayout"
tools:text="Add" />
<View
android:id="@+id/formTextInputDivider"
android:layout_width="0dp"
android:layout_height="1dp"
android:background="?riotx_header_panel_border_mobile"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:background="@drawable/bg_attachment_type_selector"
android:orientation="vertical"
android:paddingTop="16dp"
android:paddingBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="16dp"
android:baselineAligned="false"
android:orientation="horizontal"
android:weightSum="2">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<ImageButton
android:id="@+id/avatarCameraButton"
style="@style/AttachmentTypeSelectorButton"
android:contentDescription="@string/attachment_type_camera"
android:src="@drawable/ic_attachment_camera_white_24dp"
tools:background="@color/riotx_accent" />
<TextView
style="@style/AttachmentTypeSelectorLabel"
android:importantForAccessibility="no"
android:text="@string/attachment_type_camera" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<ImageButton
android:id="@+id/avatarGalleryButton"
style="@style/AttachmentTypeSelectorButton"
android:contentDescription="@string/attachment_type_gallery"
android:src="@drawable/ic_attachment_gallery_white_24dp"
tools:background="@color/riotx_accent" />
<TextView
style="@style/AttachmentTypeSelectorLabel"
android:importantForAccessibility="no"
android:text="@string/attachment_type_gallery" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/bigAvatarEditAction"
android:title="@string/edit"
android:icon="@drawable/ic_edit"
app:iconTint="?attr/colorAccent"
app:showAsAction="ifRoom" />
</menu>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/roomSettingsSaveAction"
android:title="@string/save"
app:iconTint="?attr/colorAccent"
app:showAsAction="ifRoom" />
</menu>

View File

@ -2496,4 +2496,9 @@ Not all features in Riot are implemented in RiotX yet. Main missing (and coming
<string name="save_your_security_key_title">Save your Security Key</string>
<string name="save_your_security_key_notice">Store your Security Key somewhere safe, like a password manager or a safe.</string>
</resources>
<!-- Room Settings -->
<string name="room_settings_name_hint">Room Name</string>
<string name="room_settings_topic_hint">Topic</string>
<string name="room_settings_save_success">You changed room settings successfully</string>
</resources>