diff --git a/changelog.d/5123.feature b/changelog.d/5123.feature new file mode 100644 index 0000000000..cb1a7adf08 --- /dev/null +++ b/changelog.d/5123.feature @@ -0,0 +1 @@ +Add completion for @room to notify everyone in a room diff --git a/library/ui-styles/src/main/res/values/text_appearances.xml b/library/ui-styles/src/main/res/values/text_appearances.xml index 4ad3fd493e..8e30dd00d6 100644 --- a/library/ui-styles/src/main/res/values/text_appearances.xml +++ b/library/ui-styles/src/main/res/values/text_appearances.xml @@ -59,6 +59,10 @@ sans-serif-medium + + - \ No newline at end of file + diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/pushrules/PushRuleService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/pushrules/PushRuleService.kt index 88268f0f86..76885d8545 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/pushrules/PushRuleService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/pushrules/PushRuleService.kt @@ -50,6 +50,9 @@ interface PushRuleService { // fun fulfilledBingRule(event: Event, rules: List): PushRule? + fun resolveSenderNotificationPermissionCondition(event: Event, + condition: SenderNotificationPermissionCondition): Boolean + interface PushRuleListener { fun onEvents(pushEvents: PushEvents) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt index 3396c4a6c9..302f7387fa 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt @@ -35,7 +35,19 @@ sealed class MatrixItem( data class UserItem(override val id: String, override val displayName: String? = null, override val avatarUrl: String? = null) : - MatrixItem(id, displayName?.removeSuffix(ircPattern), avatarUrl) { + MatrixItem(id, displayName?.removeSuffix(IRC_PATTERN), avatarUrl) { + init { + if (BuildConfig.DEBUG) checkId() + } + + override fun updateAvatar(newAvatar: String?) = copy(avatarUrl = newAvatar) + } + + data class EveryoneInRoomItem(override val id: String, + override val displayName: String = NOTIFY_EVERYONE, + override val avatarUrl: String? = null, + val roomDisplayName: String? = null) : + MatrixItem(id, displayName, avatarUrl) { init { if (BuildConfig.DEBUG) checkId() } @@ -46,7 +58,7 @@ sealed class MatrixItem( data class EventItem(override val id: String, override val displayName: String? = null, override val avatarUrl: String? = null) : - MatrixItem(id, displayName, avatarUrl) { + MatrixItem(id, displayName, avatarUrl) { init { if (BuildConfig.DEBUG) checkId() } @@ -57,7 +69,7 @@ sealed class MatrixItem( data class RoomItem(override val id: String, override val displayName: String? = null, override val avatarUrl: String? = null) : - MatrixItem(id, displayName, avatarUrl) { + MatrixItem(id, displayName, avatarUrl) { init { if (BuildConfig.DEBUG) checkId() } @@ -68,7 +80,7 @@ sealed class MatrixItem( data class SpaceItem(override val id: String, override val displayName: String? = null, override val avatarUrl: String? = null) : - MatrixItem(id, displayName, avatarUrl) { + MatrixItem(id, displayName, avatarUrl) { init { if (BuildConfig.DEBUG) checkId() } @@ -79,7 +91,7 @@ sealed class MatrixItem( data class RoomAliasItem(override val id: String, override val displayName: String? = null, override val avatarUrl: String? = null) : - MatrixItem(id, displayName, avatarUrl) { + MatrixItem(id, displayName, avatarUrl) { init { if (BuildConfig.DEBUG) checkId() } @@ -90,7 +102,7 @@ sealed class MatrixItem( data class GroupItem(override val id: String, override val displayName: String? = null, override val avatarUrl: String? = null) : - MatrixItem(id, displayName, avatarUrl) { + MatrixItem(id, displayName, avatarUrl) { init { if (BuildConfig.DEBUG) checkId() } @@ -109,16 +121,22 @@ sealed class MatrixItem( /** * Return the prefix as defined in the matrix spec (and not extracted from the id) */ - fun getIdPrefix() = when (this) { - is UserItem -> '@' - is EventItem -> '$' + private fun getIdPrefix() = when (this) { + is UserItem -> '@' + is EventItem -> '$' is SpaceItem, - is RoomItem -> '!' - is RoomAliasItem -> '#' - is GroupItem -> '+' + is RoomItem, + is EveryoneInRoomItem -> '!' + is RoomAliasItem -> '#' + is GroupItem -> '+' } fun firstLetterOfDisplayName(): String { + val displayName = when (this) { + // use the room display name for the notify everyone item + is EveryoneInRoomItem -> roomDisplayName + else -> displayName + } return (displayName?.takeIf { it.isNotBlank() } ?: id) .let { dn -> var startIndex = 0 @@ -151,7 +169,8 @@ sealed class MatrixItem( } companion object { - private const val ircPattern = " (IRC)" + private const val IRC_PATTERN = " (IRC)" + const val NOTIFY_EVERYONE = "@room" } } @@ -171,6 +190,8 @@ fun RoomSummary.toMatrixItem() = if (roomType == RoomType.SPACE) { fun RoomSummary.toRoomAliasMatrixItem() = MatrixItem.RoomAliasItem(canonicalAlias ?: roomId, displayName, avatarUrl) +fun RoomSummary.toEveryoneInRoomMatrixItem() = MatrixItem.EveryoneInRoomItem(id = roomId, avatarUrl = avatarUrl, roomDisplayName = displayName) + // If no name is available, use room alias as Riot-Web does fun PublicRoom.toMatrixItem() = MatrixItem.RoomItem(roomId, name ?: getPrimaryAlias() ?: "", avatarUrl) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/DefaultPushRuleService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/DefaultPushRuleService.kt index 3e821b8956..cdc7350f8b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/DefaultPushRuleService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/DefaultPushRuleService.kt @@ -19,11 +19,13 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations import com.zhuinden.monarchy.Monarchy import org.matrix.android.sdk.api.pushrules.Action +import org.matrix.android.sdk.api.pushrules.ConditionResolver import org.matrix.android.sdk.api.pushrules.PushEvents import org.matrix.android.sdk.api.pushrules.PushRuleService import org.matrix.android.sdk.api.pushrules.RuleKind import org.matrix.android.sdk.api.pushrules.RuleScope import org.matrix.android.sdk.api.pushrules.RuleSetKey +import org.matrix.android.sdk.api.pushrules.SenderNotificationPermissionCondition import org.matrix.android.sdk.api.pushrules.getActions import org.matrix.android.sdk.api.pushrules.rest.PushRule import org.matrix.android.sdk.api.pushrules.rest.RuleSet @@ -53,6 +55,7 @@ internal class DefaultPushRuleService @Inject constructor( private val removePushRuleTask: RemovePushRuleTask, private val pushRuleFinder: PushRuleFinder, private val taskExecutor: TaskExecutor, + private val conditionResolver: ConditionResolver, @SessionDatabase private val monarchy: Monarchy ) : PushRuleService { @@ -143,6 +146,10 @@ internal class DefaultPushRuleService @Inject constructor( return pushRuleFinder.fulfilledBingRule(event, rules)?.getActions().orEmpty() } + override fun resolveSenderNotificationPermissionCondition(event: Event, condition: SenderNotificationPermissionCondition): Boolean { + return conditionResolver.resolveSenderNotificationPermissionCondition(event, condition) + } + override fun getKeywords(): LiveData> { // Keywords are all content rules that don't start with '.' val liveData = monarchy.findAllMappedWithChanges( diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt index 33cb0db243..ccbfbfcded 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.session.room.send.pills import android.text.SpannableString import org.matrix.android.sdk.api.session.room.send.MatrixItemSpan +import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.internal.session.displayname.DisplayNameResolver import java.util.Collections import javax.inject.Inject @@ -51,6 +52,8 @@ internal class TextPillsUtils @Inject constructor( val pills = spannableString ?.getSpans(0, text.length, MatrixItemSpan::class.java) ?.map { MentionLinkSpec(it, spannableString.getSpanStart(it), spannableString.getSpanEnd(it)) } + // we use the raw text for @room notification instead of a link + ?.filterNot { it.span.matrixItem is MatrixItem.EveryoneInRoomItem } ?.toMutableList() ?.takeIf { it.isNotEmpty() } ?: return null diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/AutocompleteHeaderItem.kt b/vector/src/main/java/im/vector/app/features/autocomplete/AutocompleteHeaderItem.kt new file mode 100644 index 0000000000..f287104415 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/autocomplete/AutocompleteHeaderItem.kt @@ -0,0 +1,39 @@ +/* + * 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.app.features.autocomplete + +import android.widget.TextView +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel + +@EpoxyModelClass(layout = R.layout.item_autocomplete_header_item) +abstract class AutocompleteHeaderItem : VectorEpoxyModel() { + + @EpoxyAttribute var title: String? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.titleView.text = title + } + + class Holder : VectorEpoxyHolder() { + val titleView by bind(R.id.headerItemAutocompleteTitle) + } +} diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberController.kt b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberController.kt index 9b4bd78504..2034cee90a 100644 --- a/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberController.kt +++ b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberController.kt @@ -16,31 +16,81 @@ package im.vector.app.features.autocomplete.member +import android.content.Context import com.airbnb.epoxy.TypedEpoxyController +import im.vector.app.R import im.vector.app.features.autocomplete.AutocompleteClickListener +import im.vector.app.features.autocomplete.autocompleteHeaderItem import im.vector.app.features.autocomplete.autocompleteMatrixItem import im.vector.app.features.home.AvatarRenderer -import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary +import org.matrix.android.sdk.api.util.toEveryoneInRoomMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject -class AutocompleteMemberController @Inject constructor() : TypedEpoxyController>() { +class AutocompleteMemberController @Inject constructor(private val context: Context) : + TypedEpoxyController>() { - var listener: AutocompleteClickListener? = null + /* ========================================================================================== + * Fields + * ========================================================================================== */ + + var listener: AutocompleteClickListener? = null + + /* ========================================================================================== + * Dependencies + * ========================================================================================== */ @Inject lateinit var avatarRenderer: AvatarRenderer - override fun buildModels(data: List?) { + /* ========================================================================================== + * Specialization + * ========================================================================================== */ + + override fun buildModels(data: List?) { if (data.isNullOrEmpty()) { return } + data.forEach { item -> + when (item) { + is AutocompleteMemberItem.Header -> buildHeaderItem(item) + is AutocompleteMemberItem.RoomMember -> buildRoomMemberItem(item) + is AutocompleteMemberItem.Everyone -> buildEveryoneItem(item) + } + } + } + + /* ========================================================================================== + * Helper methods + * ========================================================================================== */ + + private fun buildHeaderItem(header: AutocompleteMemberItem.Header) { + autocompleteHeaderItem { + id(header.id) + title(header.title) + } + } + + private fun buildRoomMemberItem(roomMember: AutocompleteMemberItem.RoomMember) { val host = this - data.forEach { user -> - autocompleteMatrixItem { + autocompleteMatrixItem { + roomMember.roomMemberSummary.let { user -> id(user.userId) matrixItem(user.toMatrixItem()) avatarRenderer(host.avatarRenderer) - clickListener { host.listener?.onItemClick(user) } + clickListener { host.listener?.onItemClick(roomMember) } + } + } + } + + private fun buildEveryoneItem(everyone: AutocompleteMemberItem.Everyone) { + val host = this + autocompleteMatrixItem { + everyone.roomSummary.let { room -> + id(room.roomId) + matrixItem(room.toEveryoneInRoomMatrixItem()) + subName(host.context.getString(R.string.room_message_notify_everyone)) + avatarRenderer(host.avatarRenderer) + clickListener { host.listener?.onItemClick(everyone) } } } } diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberItem.kt b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberItem.kt new file mode 100644 index 0000000000..77c5069938 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberItem.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 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.app.features.autocomplete.member + +import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary +import org.matrix.android.sdk.api.session.room.model.RoomSummary + +sealed class AutocompleteMemberItem { + data class Header(val id: String, val title: String) : AutocompleteMemberItem() + data class RoomMember(val roomMemberSummary: RoomMemberSummary) : AutocompleteMemberItem() + data class Everyone(val roomSummary: RoomSummary) : AutocompleteMemberItem() +} diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt index 4976cb39b9..ce3b9c6a7e 100644 --- a/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt +++ b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt @@ -21,26 +21,44 @@ import androidx.recyclerview.widget.RecyclerView import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import im.vector.app.R import im.vector.app.features.autocomplete.AutocompleteClickListener import im.vector.app.features.autocomplete.RecyclerViewPresenter +import org.matrix.android.sdk.api.pushrules.SenderNotificationPermissionCondition import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.room.members.RoomMemberQueryParams import org.matrix.android.sdk.api.session.room.members.roomMemberQueryParams import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary +import org.matrix.android.sdk.api.util.MatrixItem class AutocompleteMemberPresenter @AssistedInject constructor(context: Context, @Assisted val roomId: String, - session: Session, + private val session: Session, private val controller: AutocompleteMemberController -) : RecyclerViewPresenter(context), AutocompleteClickListener { +) : RecyclerViewPresenter(context), AutocompleteClickListener { + + /* ========================================================================================== + * Fields + * ========================================================================================== */ private val room by lazy { session.getRoom(roomId)!! } + /* ========================================================================================== + * Init + * ========================================================================================== */ + init { controller.listener = this } + /* ========================================================================================== + * Public api + * ========================================================================================== */ + fun clear() { controller.listener = null } @@ -50,29 +68,100 @@ class AutocompleteMemberPresenter @AssistedInject constructor(context: Context, fun create(roomId: String): AutocompleteMemberPresenter } + /* ========================================================================================== + * Specialization + * ========================================================================================== */ + override fun instantiateAdapter(): RecyclerView.Adapter<*> { return controller.adapter } - override fun onItemClick(t: RoomMemberSummary) { + override fun onItemClick(t: AutocompleteMemberItem) { dispatchClick(t) } override fun onQuery(query: CharSequence?) { - val queryParams = roomMemberQueryParams { - displayName = if (query.isNullOrBlank()) { - QueryStringValue.IsNotEmpty - } else { - QueryStringValue.Contains(query.toString(), QueryStringValue.Case.INSENSITIVE) + val queryParams = createQueryParams(query) + val membersHeader = createMembersHeader() + val members = createMemberItems(queryParams) + val everyone = createEveryoneItem(query) + // add headers only when user can notify everyone + val canAddHeaders = canNotifyEveryone() + + val items = mutableListOf().apply { + if (members.isNotEmpty()) { + if (canAddHeaders) { + add(membersHeader) + } + addAll(members) + } + everyone?.let { + val everyoneHeader = createEveryoneHeader() + add(everyoneHeader) + add(it) } - memberships = listOf(Membership.JOIN) - excludeSelf = true } - val members = room.getRoomMembers(queryParams) - .asSequence() - .sortedBy { it.displayName } - .disambiguate() - controller.setData(members.toList()) + + controller.setData(items) + } + + /* ========================================================================================== + * Helper methods + * ========================================================================================== */ + + private fun createQueryParams(query: CharSequence?) = roomMemberQueryParams { + displayName = if (query.isNullOrBlank()) { + QueryStringValue.IsNotEmpty + } else { + QueryStringValue.Contains(query.toString(), QueryStringValue.Case.INSENSITIVE) + } + memberships = listOf(Membership.JOIN) + excludeSelf = true + } + + private fun createMembersHeader() = + AutocompleteMemberItem.Header( + ID_HEADER_MEMBERS, + context.getString(R.string.room_message_autocomplete_users) + ) + + private fun createMemberItems(queryParams: RoomMemberQueryParams) = + room.getRoomMembers(queryParams) + .asSequence() + .sortedBy { it.displayName } + .disambiguate() + .map { AutocompleteMemberItem.RoomMember(it) } + .toList() + + private fun createEveryoneHeader() = + AutocompleteMemberItem.Header( + ID_HEADER_EVERYONE, + context.getString(R.string.room_message_autocomplete_notification) + ) + + private fun createEveryoneItem(query: CharSequence?) = + room.roomSummary() + ?.takeIf { canNotifyEveryone() } + ?.takeIf { query.isNullOrBlank() || MatrixItem.NOTIFY_EVERYONE.startsWith("@$query") } + ?.let { + AutocompleteMemberItem.Everyone(it) + } + + private fun canNotifyEveryone() = session.resolveSenderNotificationPermissionCondition( + Event( + senderId = session.myUserId, + roomId = roomId + ), + SenderNotificationPermissionCondition(PowerLevelsContent.NOTIFICATIONS_ROOM_KEY) + ) + + /* ========================================================================================== + * Const + * ========================================================================================== */ + + companion object { + private const val ID_HEADER_MEMBERS = "ID_HEADER_MEMBERS" + private const val ID_HEADER_EVERYONE = "ID_HEADER_EVERYONE" } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt index 9f85d4015b..be5f9c0bb4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt @@ -33,6 +33,7 @@ import im.vector.app.features.autocomplete.command.AutocompleteCommandPresenter import im.vector.app.features.autocomplete.command.CommandAutocompletePolicy import im.vector.app.features.autocomplete.emoji.AutocompleteEmojiPresenter import im.vector.app.features.autocomplete.group.AutocompleteGroupPresenter +import im.vector.app.features.autocomplete.member.AutocompleteMemberItem import im.vector.app.features.autocomplete.member.AutocompleteMemberPresenter import im.vector.app.features.autocomplete.room.AutocompleteRoomPresenter import im.vector.app.features.command.Command @@ -41,9 +42,9 @@ import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.html.PillImageSpan import im.vector.app.features.themes.ThemeUtils import org.matrix.android.sdk.api.session.group.model.GroupSummary -import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.util.MatrixItem +import org.matrix.android.sdk.api.util.toEveryoneInRoomMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toRoomAliasMatrixItem @@ -106,7 +107,7 @@ class AutoCompleter @AssistedInject constructor( Autocomplete.on(editText) .with(commandAutocompletePolicy) .with(autocompleteCommandPresenter) - .with(ELEVATION) + .with(ELEVATION_DP) .with(backgroundDrawable) .with(object : AutocompleteCallback { override fun onPopupItemClicked(editable: Editable, item: Command): Boolean { @@ -125,15 +126,24 @@ class AutoCompleter @AssistedInject constructor( private fun setupMembers(backgroundDrawable: ColorDrawable, editText: EditText) { autocompleteMemberPresenter = autocompleteMemberPresenterFactory.create(roomId) - Autocomplete.on(editText) - .with(CharPolicy('@', true)) + Autocomplete.on(editText) + .with(CharPolicy(TRIGGER_AUTO_COMPLETE_MEMBERS, true)) .with(autocompleteMemberPresenter) - .with(ELEVATION) + .with(ELEVATION_DP) .with(backgroundDrawable) - .with(object : AutocompleteCallback { - override fun onPopupItemClicked(editable: Editable, item: RoomMemberSummary): Boolean { - insertMatrixItem(editText, editable, "@", item.toMatrixItem()) - return true + .with(object : AutocompleteCallback { + override fun onPopupItemClicked(editable: Editable, item: AutocompleteMemberItem): Boolean { + return when (item) { + is AutocompleteMemberItem.Header -> false // do nothing header is not clickable + is AutocompleteMemberItem.RoomMember -> { + insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_MEMBERS, item.roomMemberSummary.toMatrixItem()) + true + } + is AutocompleteMemberItem.Everyone -> { + insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_MEMBERS, item.roomSummary.toEveryoneInRoomMatrixItem()) + true + } + } } override fun onPopupVisibilityChanged(shown: Boolean) { @@ -144,13 +154,13 @@ class AutoCompleter @AssistedInject constructor( private fun setupRooms(backgroundDrawable: ColorDrawable, editText: EditText) { Autocomplete.on(editText) - .with(CharPolicy('#', true)) + .with(CharPolicy(TRIGGER_AUTO_COMPLETE_ROOMS, true)) .with(autocompleteRoomPresenter) - .with(ELEVATION) + .with(ELEVATION_DP) .with(backgroundDrawable) .with(object : AutocompleteCallback { override fun onPopupItemClicked(editable: Editable, item: RoomSummary): Boolean { - insertMatrixItem(editText, editable, "#", item.toRoomAliasMatrixItem()) + insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_ROOMS, item.toRoomAliasMatrixItem()) return true } @@ -162,13 +172,13 @@ class AutoCompleter @AssistedInject constructor( private fun setupGroups(backgroundDrawable: ColorDrawable, editText: EditText) { Autocomplete.on(editText) - .with(CharPolicy('+', true)) + .with(CharPolicy(TRIGGER_AUTO_COMPLETE_GROUPS, true)) .with(autocompleteGroupPresenter) - .with(ELEVATION) + .with(ELEVATION_DP) .with(backgroundDrawable) .with(object : AutocompleteCallback { override fun onPopupItemClicked(editable: Editable, item: GroupSummary): Boolean { - insertMatrixItem(editText, editable, "+", item.toMatrixItem()) + insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_GROUPS, item.toMatrixItem()) return true } @@ -180,9 +190,9 @@ class AutoCompleter @AssistedInject constructor( private fun setupEmojis(backgroundDrawable: Drawable, editText: EditText) { Autocomplete.on(editText) - .with(CharPolicy(':', false)) + .with(CharPolicy(TRIGGER_AUTO_COMPLETE_EMOJIS, false)) .with(autocompleteEmojiPresenter) - .with(ELEVATION) + .with(ELEVATION_DP) .with(backgroundDrawable) .with(object : AutocompleteCallback { override fun onPopupItemClicked(editable: Editable, item: String): Boolean { @@ -210,7 +220,7 @@ class AutoCompleter @AssistedInject constructor( .build() } - private fun insertMatrixItem(editText: EditText, editable: Editable, firstChar: String, matrixItem: MatrixItem) { + private fun insertMatrixItem(editText: EditText, editable: Editable, firstChar: Char, matrixItem: MatrixItem) { // Detect last firstChar and remove it var startIndex = editable.lastIndexOf(firstChar) if (startIndex == -1) { @@ -228,7 +238,7 @@ class AutoCompleter @AssistedInject constructor( // Adding trailing space " " or ": " if the user started mention someone val displayNameSuffix = - if (firstChar == "@" && startIndex == 0) { + if (matrixItem is MatrixItem.UserItem) { ": " } else { " " @@ -249,6 +259,10 @@ class AutoCompleter @AssistedInject constructor( } companion object { - private const val ELEVATION = 6f + private const val ELEVATION_DP = 6f + private const val TRIGGER_AUTO_COMPLETE_MEMBERS = '@' + private const val TRIGGER_AUTO_COMPLETE_ROOMS = '#' + private const val TRIGGER_AUTO_COMPLETE_GROUPS = '+' + private const val TRIGGER_AUTO_COMPLETE_EMOJIS = ':' } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index 0c836748c8..aa1758dd6c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -61,6 +61,7 @@ import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem_ import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem_ +import im.vector.app.features.home.room.detail.timeline.render.EventTextRenderer import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod import im.vector.app.features.home.room.detail.timeline.tools.linkify import im.vector.app.features.html.EventHtmlRenderer @@ -112,6 +113,7 @@ class MessageItemFactory @Inject constructor( private val timelineMediaSizeProvider: TimelineMediaSizeProvider, private val htmlRenderer: Lazy, private val htmlCompressor: VectorHtmlCompressor, + private val textRendererFactory: EventTextRenderer.Factory, private val stringProvider: StringProvider, private val imageContentRenderer: ImageContentRenderer, private val messageInformationDataFactory: MessageInformationDataFactory, @@ -138,6 +140,10 @@ class MessageItemFactory @Inject constructor( pillsPostProcessorFactory.create(roomId) } + private val textRenderer by lazy { + textRendererFactory.create(roomId) + } + fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*>? { val event = params.event val highlight = params.isHighlighted @@ -549,8 +555,9 @@ class MessageItemFactory @Inject constructor( highlight: Boolean, callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes): MessageTextItem? { - val bindingOptions = spanUtils.getBindingOptions(body) - val linkifiedBody = body.linkify(callback) + val renderedBody = textRenderer.render(body) + val bindingOptions = spanUtils.getBindingOptions(renderedBody) + val linkifiedBody = renderedBody.linkify(callback) return MessageTextItem_() .message( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/EventTextRenderer.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/EventTextRenderer.kt new file mode 100644 index 0000000000..d50a6fb297 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/EventTextRenderer.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2022 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.app.features.home.room.detail.timeline.render + +import android.content.Context +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.Spanned +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.glide.GlideApp +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.html.PillImageSpan +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.util.MatrixItem + +class EventTextRenderer @AssistedInject constructor(@Assisted private val roomId: String?, + private val context: Context, + private val avatarRenderer: AvatarRenderer, + private val sessionHolder: ActiveSessionHolder) { + + /* ========================================================================================== + * Public api + * ========================================================================================== */ + + @AssistedFactory + interface Factory { + fun create(roomId: String?): EventTextRenderer + } + + /** + * @param text the text you want to render + */ + fun render(text: CharSequence): CharSequence { + return if (roomId != null && text.contains(MatrixItem.NOTIFY_EVERYONE)) { + SpannableStringBuilder(text).apply { + addNotifyEveryoneSpans(this, roomId) + } + } else { + text + } + } + + /* ========================================================================================== + * Helper methods + * ========================================================================================== */ + + private fun addNotifyEveryoneSpans(text: Spannable, roomId: String) { + val room: RoomSummary? = sessionHolder.getSafeActiveSession()?.getRoomSummary(roomId) + val matrixItem = MatrixItem.EveryoneInRoomItem( + id = roomId, + avatarUrl = room?.avatarUrl, + roomDisplayName = room?.displayName + ) + + // search for notify everyone text + var foundIndex = text.indexOf(MatrixItem.NOTIFY_EVERYONE, 0) + while (foundIndex >= 0) { + val endSpan = foundIndex + MatrixItem.NOTIFY_EVERYONE.length + addPillSpan(text, createPillImageSpan(matrixItem), foundIndex, endSpan) + foundIndex = text.indexOf(MatrixItem.NOTIFY_EVERYONE, endSpan) + } + } + + private fun createPillImageSpan(matrixItem: MatrixItem) = + PillImageSpan(GlideApp.with(context), avatarRenderer, context, matrixItem) + + private fun addPillSpan( + renderedText: Spannable, + pillSpan: PillImageSpan, + startSpan: Int, + endSpan: Int + ) { + renderedText.setSpan(pillSpan, startSpan, endSpan, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } +} diff --git a/vector/src/main/java/im/vector/app/features/html/PillImageSpan.kt b/vector/src/main/java/im/vector/app/features/html/PillImageSpan.kt index ff2e2a9cdb..ae285b074c 100644 --- a/vector/src/main/java/im/vector/app/features/html/PillImageSpan.kt +++ b/vector/src/main/java/im/vector/app/features/html/PillImageSpan.kt @@ -19,6 +19,7 @@ package im.vector.app.features.html import android.content.Context +import android.content.res.ColorStateList import android.graphics.Canvas import android.graphics.Paint import android.graphics.drawable.Drawable @@ -32,6 +33,7 @@ import im.vector.app.R import im.vector.app.core.glide.GlideRequests import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.themes.ThemeUtils import org.matrix.android.sdk.api.session.room.send.MatrixItemSpan import org.matrix.android.sdk.api.util.MatrixItem import java.lang.ref.WeakReference @@ -117,6 +119,11 @@ class PillImageSpan(private val glideRequests: GlideRequests, setChipMinHeightResource(R.dimen.pill_min_height) setChipIconSizeResource(R.dimen.pill_avatar_size) chipIcon = icon + if (matrixItem is MatrixItem.EveryoneInRoomItem) { + chipBackgroundColor = ColorStateList.valueOf(ThemeUtils.getColor(context, R.attr.colorError)) + // setTextColor API does not exist right now for ChipDrawable, use textAppearance + setTextAppearanceResource(R.style.TextAppearance_Vector_Body_OnError) + } setBounds(0, 0, intrinsicWidth, intrinsicHeight) } } diff --git a/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt b/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt index f8a2ee5137..506f5e773c 100644 --- a/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt +++ b/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt @@ -36,57 +36,87 @@ class PillsPostProcessor @AssistedInject constructor(@Assisted private val roomI private val context: Context, private val avatarRenderer: AvatarRenderer, private val sessionHolder: ActiveSessionHolder) : - EventHtmlRenderer.PostProcessor { + EventHtmlRenderer.PostProcessor { + + /* ========================================================================================== + * Public api + * ========================================================================================== */ @AssistedFactory interface Factory { fun create(roomId: String?): PillsPostProcessor } + /* ========================================================================================== + * Specialization + * ========================================================================================== */ + override fun afterRender(renderedText: Spannable) { addPillSpans(renderedText, roomId) } + /* ========================================================================================== + * Helper methods + * ========================================================================================== */ + private fun addPillSpans(renderedText: Spannable, roomId: String?) { + addLinkSpans(renderedText, roomId) + } + + private fun addPillSpan( + renderedText: Spannable, + pillSpan: PillImageSpan, + startSpan: Int, + endSpan: Int + ) { + renderedText.setSpan(pillSpan, startSpan, endSpan, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + private fun addLinkSpans(renderedText: Spannable, roomId: String?) { // We let markdown handle links and then we add PillImageSpan if needed. val linkSpans = renderedText.getSpans(0, renderedText.length, LinkSpan::class.java) linkSpans.forEach { linkSpan -> val pillSpan = linkSpan.createPillSpan(roomId) ?: return@forEach val startSpan = renderedText.getSpanStart(linkSpan) val endSpan = renderedText.getSpanEnd(linkSpan) - renderedText.setSpan(pillSpan, startSpan, endSpan, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + addPillSpan(renderedText, pillSpan, startSpan, endSpan) } } + private fun createPillImageSpan(matrixItem: MatrixItem) = + PillImageSpan(GlideApp.with(context), avatarRenderer, context, matrixItem) + private fun LinkSpan.createPillSpan(roomId: String?): PillImageSpan? { - val permalinkData = PermalinkParser.parse(url) - val matrixItem = when (permalinkData) { - is PermalinkData.UserLink -> { - if (roomId == null) { - sessionHolder.getSafeActiveSession()?.getUser(permalinkData.userId)?.toMatrixItem() - } else { - sessionHolder.getSafeActiveSession()?.getRoomMember(permalinkData.userId, roomId)?.toMatrixItem() - } - } - is PermalinkData.RoomLink -> { - if (permalinkData.eventId == null) { - val room: RoomSummary? = sessionHolder.getSafeActiveSession()?.getRoomSummary(permalinkData.roomIdOrAlias) - if (permalinkData.isRoomAlias) { - MatrixItem.RoomAliasItem(permalinkData.roomIdOrAlias, room?.displayName, room?.avatarUrl) - } else { - MatrixItem.RoomItem(permalinkData.roomIdOrAlias, room?.displayName, room?.avatarUrl) - } - } else { - // Exclude event link (used in reply events, we do not want to pill the "in reply to") - null - } - } - is PermalinkData.GroupLink -> { - val group = sessionHolder.getSafeActiveSession()?.getGroupSummary(permalinkData.groupId) - MatrixItem.GroupItem(permalinkData.groupId, group?.displayName, group?.avatarUrl) - } + val matrixItem = when (val permalinkData = PermalinkParser.parse(url)) { + is PermalinkData.UserLink -> permalinkData.toMatrixItem(roomId) + is PermalinkData.RoomLink -> permalinkData.toMatrixItem() + is PermalinkData.GroupLink -> permalinkData.toMatrixItem() else -> null } ?: return null - return PillImageSpan(GlideApp.with(context), avatarRenderer, context, matrixItem) + return createPillImageSpan(matrixItem) + } + + private fun PermalinkData.UserLink.toMatrixItem(roomId: String?): MatrixItem? = + if (roomId == null) { + sessionHolder.getSafeActiveSession()?.getUser(userId)?.toMatrixItem() + } else { + sessionHolder.getSafeActiveSession()?.getRoomMember(userId, roomId)?.toMatrixItem() + } + + private fun PermalinkData.RoomLink.toMatrixItem(): MatrixItem? = + if (eventId == null) { + val room: RoomSummary? = sessionHolder.getSafeActiveSession()?.getRoomSummary(roomIdOrAlias) + when { + isRoomAlias -> MatrixItem.RoomAliasItem(roomIdOrAlias, room?.displayName, room?.avatarUrl) + else -> MatrixItem.RoomItem(roomIdOrAlias, room?.displayName, room?.avatarUrl) + } + } else { + // Exclude event link (used in reply events, we do not want to pill the "in reply to") + null + } + + private fun PermalinkData.GroupLink.toMatrixItem(): MatrixItem? { + val group = sessionHolder.getSafeActiveSession()?.getGroupSummary(groupId) + return MatrixItem.GroupItem(groupId, group?.displayName, group?.avatarUrl) } } diff --git a/vector/src/main/res/layout/item_autocomplete_header_item.xml b/vector/src/main/res/layout/item_autocomplete_header_item.xml new file mode 100644 index 0000000000..f842129e0c --- /dev/null +++ b/vector/src/main/res/layout/item_autocomplete_header_item.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index c155b6bb75..69a1c53d1c 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -3782,4 +3782,7 @@ Show less "%1$d more" + Notify the whole room + Users + Room notification