Merge pull request #5237 from vector-im/feature/mna/5123-room-tag-suggestion

#5123: @room tag suggestion
This commit is contained in:
Benoit Marty 2022-02-17 15:40:54 +01:00 committed by GitHub
commit d1d26a98af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 504 additions and 87 deletions

1
changelog.d/5123.feature Normal file
View File

@ -0,0 +1 @@
Add completion for @room to notify everyone in a room

View File

@ -59,6 +59,10 @@
<item name="android:fontFamily">sans-serif-medium</item> <item name="android:fontFamily">sans-serif-medium</item>
</style> </style>
<style name="TextAppearance.Vector.Body.OnError">
<item name="android:textColor">?colorOnError</item>
</style>
<style name="TextAppearance.Vector.Caption" parent="TextAppearance.MaterialComponents.Caption"> <style name="TextAppearance.Vector.Caption" parent="TextAppearance.MaterialComponents.Caption">
<item name="fontFamily">sans-serif</item> <item name="fontFamily">sans-serif</item>
<item name="android:fontFamily">sans-serif</item> <item name="android:fontFamily">sans-serif</item>

View File

@ -50,6 +50,9 @@ interface PushRuleService {
// fun fulfilledBingRule(event: Event, rules: List<PushRule>): PushRule? // fun fulfilledBingRule(event: Event, rules: List<PushRule>): PushRule?
fun resolveSenderNotificationPermissionCondition(event: Event,
condition: SenderNotificationPermissionCondition): Boolean
interface PushRuleListener { interface PushRuleListener {
fun onEvents(pushEvents: PushEvents) fun onEvents(pushEvents: PushEvents)
} }

View File

@ -35,7 +35,19 @@ sealed class MatrixItem(
data class UserItem(override val id: String, data class UserItem(override val id: String,
override val displayName: String? = null, override val displayName: String? = null,
override val avatarUrl: 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 { init {
if (BuildConfig.DEBUG) checkId() 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) * Return the prefix as defined in the matrix spec (and not extracted from the id)
*/ */
fun getIdPrefix() = when (this) { private fun getIdPrefix() = when (this) {
is UserItem -> '@' is UserItem -> '@'
is EventItem -> '$' is EventItem -> '$'
is SpaceItem, is SpaceItem,
is RoomItem -> '!' is RoomItem,
is EveryoneInRoomItem -> '!'
is RoomAliasItem -> '#' is RoomAliasItem -> '#'
is GroupItem -> '+' is GroupItem -> '+'
} }
fun firstLetterOfDisplayName(): String { 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) return (displayName?.takeIf { it.isNotBlank() } ?: id)
.let { dn -> .let { dn ->
var startIndex = 0 var startIndex = 0
@ -151,7 +169,8 @@ sealed class MatrixItem(
} }
companion object { 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.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 // If no name is available, use room alias as Riot-Web does
fun PublicRoom.toMatrixItem() = MatrixItem.RoomItem(roomId, name ?: getPrimaryAlias() ?: "", avatarUrl) fun PublicRoom.toMatrixItem() = MatrixItem.RoomItem(roomId, name ?: getPrimaryAlias() ?: "", avatarUrl)

View File

@ -19,11 +19,13 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations import androidx.lifecycle.Transformations
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import org.matrix.android.sdk.api.pushrules.Action 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.PushEvents
import org.matrix.android.sdk.api.pushrules.PushRuleService import org.matrix.android.sdk.api.pushrules.PushRuleService
import org.matrix.android.sdk.api.pushrules.RuleKind import org.matrix.android.sdk.api.pushrules.RuleKind
import org.matrix.android.sdk.api.pushrules.RuleScope import org.matrix.android.sdk.api.pushrules.RuleScope
import org.matrix.android.sdk.api.pushrules.RuleSetKey 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.getActions
import org.matrix.android.sdk.api.pushrules.rest.PushRule import org.matrix.android.sdk.api.pushrules.rest.PushRule
import org.matrix.android.sdk.api.pushrules.rest.RuleSet import org.matrix.android.sdk.api.pushrules.rest.RuleSet
@ -53,6 +55,7 @@ internal class DefaultPushRuleService @Inject constructor(
private val removePushRuleTask: RemovePushRuleTask, private val removePushRuleTask: RemovePushRuleTask,
private val pushRuleFinder: PushRuleFinder, private val pushRuleFinder: PushRuleFinder,
private val taskExecutor: TaskExecutor, private val taskExecutor: TaskExecutor,
private val conditionResolver: ConditionResolver,
@SessionDatabase private val monarchy: Monarchy @SessionDatabase private val monarchy: Monarchy
) : PushRuleService { ) : PushRuleService {
@ -143,6 +146,10 @@ internal class DefaultPushRuleService @Inject constructor(
return pushRuleFinder.fulfilledBingRule(event, rules)?.getActions().orEmpty() return pushRuleFinder.fulfilledBingRule(event, rules)?.getActions().orEmpty()
} }
override fun resolveSenderNotificationPermissionCondition(event: Event, condition: SenderNotificationPermissionCondition): Boolean {
return conditionResolver.resolveSenderNotificationPermissionCondition(event, condition)
}
override fun getKeywords(): LiveData<Set<String>> { override fun getKeywords(): LiveData<Set<String>> {
// Keywords are all content rules that don't start with '.' // Keywords are all content rules that don't start with '.'
val liveData = monarchy.findAllMappedWithChanges( val liveData = monarchy.findAllMappedWithChanges(

View File

@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.session.room.send.pills
import android.text.SpannableString import android.text.SpannableString
import org.matrix.android.sdk.api.session.room.send.MatrixItemSpan 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 org.matrix.android.sdk.internal.session.displayname.DisplayNameResolver
import java.util.Collections import java.util.Collections
import javax.inject.Inject import javax.inject.Inject
@ -51,6 +52,8 @@ internal class TextPillsUtils @Inject constructor(
val pills = spannableString val pills = spannableString
?.getSpans(0, text.length, MatrixItemSpan::class.java) ?.getSpans(0, text.length, MatrixItemSpan::class.java)
?.map { MentionLinkSpec(it, spannableString.getSpanStart(it), spannableString.getSpanEnd(it)) } ?.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() ?.toMutableList()
?.takeIf { it.isNotEmpty() } ?.takeIf { it.isNotEmpty() }
?: return null ?: return null

View File

@ -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<AutocompleteHeaderItem.Holder>() {
@EpoxyAttribute var title: String? = null
override fun bind(holder: Holder) {
super.bind(holder)
holder.titleView.text = title
}
class Holder : VectorEpoxyHolder() {
val titleView by bind<TextView>(R.id.headerItemAutocompleteTitle)
}
}

View File

@ -16,31 +16,81 @@
package im.vector.app.features.autocomplete.member package im.vector.app.features.autocomplete.member
import android.content.Context
import com.airbnb.epoxy.TypedEpoxyController import com.airbnb.epoxy.TypedEpoxyController
import im.vector.app.R
import im.vector.app.features.autocomplete.AutocompleteClickListener 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.autocomplete.autocompleteMatrixItem
import im.vector.app.features.home.AvatarRenderer 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 org.matrix.android.sdk.api.util.toMatrixItem
import javax.inject.Inject import javax.inject.Inject
class AutocompleteMemberController @Inject constructor() : TypedEpoxyController<List<RoomMemberSummary>>() { class AutocompleteMemberController @Inject constructor(private val context: Context) :
TypedEpoxyController<List<AutocompleteMemberItem>>() {
var listener: AutocompleteClickListener<RoomMemberSummary>? = null /* ==========================================================================================
* Fields
* ========================================================================================== */
var listener: AutocompleteClickListener<AutocompleteMemberItem>? = null
/* ==========================================================================================
* Dependencies
* ========================================================================================== */
@Inject lateinit var avatarRenderer: AvatarRenderer @Inject lateinit var avatarRenderer: AvatarRenderer
override fun buildModels(data: List<RoomMemberSummary>?) { /* ==========================================================================================
* Specialization
* ========================================================================================== */
override fun buildModels(data: List<AutocompleteMemberItem>?) {
if (data.isNullOrEmpty()) { if (data.isNullOrEmpty()) {
return 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 val host = this
data.forEach { user ->
autocompleteMatrixItem { autocompleteMatrixItem {
roomMember.roomMemberSummary.let { user ->
id(user.userId) id(user.userId)
matrixItem(user.toMatrixItem()) matrixItem(user.toMatrixItem())
avatarRenderer(host.avatarRenderer) 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) }
} }
} }
} }

View File

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

View File

@ -21,26 +21,44 @@ import androidx.recyclerview.widget.RecyclerView
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.R
import im.vector.app.features.autocomplete.AutocompleteClickListener import im.vector.app.features.autocomplete.AutocompleteClickListener
import im.vector.app.features.autocomplete.RecyclerViewPresenter 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.query.QueryStringValue
import org.matrix.android.sdk.api.session.Session 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.members.roomMemberQueryParams
import org.matrix.android.sdk.api.session.room.model.Membership 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.session.room.model.RoomMemberSummary
import org.matrix.android.sdk.api.util.MatrixItem
class AutocompleteMemberPresenter @AssistedInject constructor(context: Context, class AutocompleteMemberPresenter @AssistedInject constructor(context: Context,
@Assisted val roomId: String, @Assisted val roomId: String,
session: Session, private val session: Session,
private val controller: AutocompleteMemberController private val controller: AutocompleteMemberController
) : RecyclerViewPresenter<RoomMemberSummary>(context), AutocompleteClickListener<RoomMemberSummary> { ) : RecyclerViewPresenter<AutocompleteMemberItem>(context), AutocompleteClickListener<AutocompleteMemberItem> {
/* ==========================================================================================
* Fields
* ========================================================================================== */
private val room by lazy { session.getRoom(roomId)!! } private val room by lazy { session.getRoom(roomId)!! }
/* ==========================================================================================
* Init
* ========================================================================================== */
init { init {
controller.listener = this controller.listener = this
} }
/* ==========================================================================================
* Public api
* ========================================================================================== */
fun clear() { fun clear() {
controller.listener = null controller.listener = null
} }
@ -50,16 +68,48 @@ class AutocompleteMemberPresenter @AssistedInject constructor(context: Context,
fun create(roomId: String): AutocompleteMemberPresenter fun create(roomId: String): AutocompleteMemberPresenter
} }
/* ==========================================================================================
* Specialization
* ========================================================================================== */
override fun instantiateAdapter(): RecyclerView.Adapter<*> { override fun instantiateAdapter(): RecyclerView.Adapter<*> {
return controller.adapter return controller.adapter
} }
override fun onItemClick(t: RoomMemberSummary) { override fun onItemClick(t: AutocompleteMemberItem) {
dispatchClick(t) dispatchClick(t)
} }
override fun onQuery(query: CharSequence?) { override fun onQuery(query: CharSequence?) {
val queryParams = roomMemberQueryParams { 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<AutocompleteMemberItem>().apply {
if (members.isNotEmpty()) {
if (canAddHeaders) {
add(membersHeader)
}
addAll(members)
}
everyone?.let {
val everyoneHeader = createEveryoneHeader()
add(everyoneHeader)
add(it)
}
}
controller.setData(items)
}
/* ==========================================================================================
* Helper methods
* ========================================================================================== */
private fun createQueryParams(query: CharSequence?) = roomMemberQueryParams {
displayName = if (query.isNullOrBlank()) { displayName = if (query.isNullOrBlank()) {
QueryStringValue.IsNotEmpty QueryStringValue.IsNotEmpty
} else { } else {
@ -68,11 +118,50 @@ class AutocompleteMemberPresenter @AssistedInject constructor(context: Context,
memberships = listOf(Membership.JOIN) memberships = listOf(Membership.JOIN)
excludeSelf = true excludeSelf = true
} }
val members = room.getRoomMembers(queryParams)
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() .asSequence()
.sortedBy { it.displayName } .sortedBy { it.displayName }
.disambiguate() .disambiguate()
controller.setData(members.toList()) .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"
} }
} }

View File

@ -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.command.CommandAutocompletePolicy
import im.vector.app.features.autocomplete.emoji.AutocompleteEmojiPresenter import im.vector.app.features.autocomplete.emoji.AutocompleteEmojiPresenter
import im.vector.app.features.autocomplete.group.AutocompleteGroupPresenter 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.member.AutocompleteMemberPresenter
import im.vector.app.features.autocomplete.room.AutocompleteRoomPresenter import im.vector.app.features.autocomplete.room.AutocompleteRoomPresenter
import im.vector.app.features.command.Command 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.html.PillImageSpan
import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.themes.ThemeUtils
import org.matrix.android.sdk.api.session.group.model.GroupSummary 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.session.room.model.RoomSummary
import org.matrix.android.sdk.api.util.MatrixItem 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.toMatrixItem
import org.matrix.android.sdk.api.util.toRoomAliasMatrixItem import org.matrix.android.sdk.api.util.toRoomAliasMatrixItem
@ -106,7 +107,7 @@ class AutoCompleter @AssistedInject constructor(
Autocomplete.on<Command>(editText) Autocomplete.on<Command>(editText)
.with(commandAutocompletePolicy) .with(commandAutocompletePolicy)
.with(autocompleteCommandPresenter) .with(autocompleteCommandPresenter)
.with(ELEVATION) .with(ELEVATION_DP)
.with(backgroundDrawable) .with(backgroundDrawable)
.with(object : AutocompleteCallback<Command> { .with(object : AutocompleteCallback<Command> {
override fun onPopupItemClicked(editable: Editable, item: Command): Boolean { override fun onPopupItemClicked(editable: Editable, item: Command): Boolean {
@ -125,15 +126,24 @@ class AutoCompleter @AssistedInject constructor(
private fun setupMembers(backgroundDrawable: ColorDrawable, editText: EditText) { private fun setupMembers(backgroundDrawable: ColorDrawable, editText: EditText) {
autocompleteMemberPresenter = autocompleteMemberPresenterFactory.create(roomId) autocompleteMemberPresenter = autocompleteMemberPresenterFactory.create(roomId)
Autocomplete.on<RoomMemberSummary>(editText) Autocomplete.on<AutocompleteMemberItem>(editText)
.with(CharPolicy('@', true)) .with(CharPolicy(TRIGGER_AUTO_COMPLETE_MEMBERS, true))
.with(autocompleteMemberPresenter) .with(autocompleteMemberPresenter)
.with(ELEVATION) .with(ELEVATION_DP)
.with(backgroundDrawable) .with(backgroundDrawable)
.with(object : AutocompleteCallback<RoomMemberSummary> { .with(object : AutocompleteCallback<AutocompleteMemberItem> {
override fun onPopupItemClicked(editable: Editable, item: RoomMemberSummary): Boolean { override fun onPopupItemClicked(editable: Editable, item: AutocompleteMemberItem): Boolean {
insertMatrixItem(editText, editable, "@", item.toMatrixItem()) return when (item) {
return true 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) { override fun onPopupVisibilityChanged(shown: Boolean) {
@ -144,13 +154,13 @@ class AutoCompleter @AssistedInject constructor(
private fun setupRooms(backgroundDrawable: ColorDrawable, editText: EditText) { private fun setupRooms(backgroundDrawable: ColorDrawable, editText: EditText) {
Autocomplete.on<RoomSummary>(editText) Autocomplete.on<RoomSummary>(editText)
.with(CharPolicy('#', true)) .with(CharPolicy(TRIGGER_AUTO_COMPLETE_ROOMS, true))
.with(autocompleteRoomPresenter) .with(autocompleteRoomPresenter)
.with(ELEVATION) .with(ELEVATION_DP)
.with(backgroundDrawable) .with(backgroundDrawable)
.with(object : AutocompleteCallback<RoomSummary> { .with(object : AutocompleteCallback<RoomSummary> {
override fun onPopupItemClicked(editable: Editable, item: RoomSummary): Boolean { override fun onPopupItemClicked(editable: Editable, item: RoomSummary): Boolean {
insertMatrixItem(editText, editable, "#", item.toRoomAliasMatrixItem()) insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_ROOMS, item.toRoomAliasMatrixItem())
return true return true
} }
@ -162,13 +172,13 @@ class AutoCompleter @AssistedInject constructor(
private fun setupGroups(backgroundDrawable: ColorDrawable, editText: EditText) { private fun setupGroups(backgroundDrawable: ColorDrawable, editText: EditText) {
Autocomplete.on<GroupSummary>(editText) Autocomplete.on<GroupSummary>(editText)
.with(CharPolicy('+', true)) .with(CharPolicy(TRIGGER_AUTO_COMPLETE_GROUPS, true))
.with(autocompleteGroupPresenter) .with(autocompleteGroupPresenter)
.with(ELEVATION) .with(ELEVATION_DP)
.with(backgroundDrawable) .with(backgroundDrawable)
.with(object : AutocompleteCallback<GroupSummary> { .with(object : AutocompleteCallback<GroupSummary> {
override fun onPopupItemClicked(editable: Editable, item: GroupSummary): Boolean { override fun onPopupItemClicked(editable: Editable, item: GroupSummary): Boolean {
insertMatrixItem(editText, editable, "+", item.toMatrixItem()) insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_GROUPS, item.toMatrixItem())
return true return true
} }
@ -180,9 +190,9 @@ class AutoCompleter @AssistedInject constructor(
private fun setupEmojis(backgroundDrawable: Drawable, editText: EditText) { private fun setupEmojis(backgroundDrawable: Drawable, editText: EditText) {
Autocomplete.on<String>(editText) Autocomplete.on<String>(editText)
.with(CharPolicy(':', false)) .with(CharPolicy(TRIGGER_AUTO_COMPLETE_EMOJIS, false))
.with(autocompleteEmojiPresenter) .with(autocompleteEmojiPresenter)
.with(ELEVATION) .with(ELEVATION_DP)
.with(backgroundDrawable) .with(backgroundDrawable)
.with(object : AutocompleteCallback<String> { .with(object : AutocompleteCallback<String> {
override fun onPopupItemClicked(editable: Editable, item: String): Boolean { override fun onPopupItemClicked(editable: Editable, item: String): Boolean {
@ -210,7 +220,7 @@ class AutoCompleter @AssistedInject constructor(
.build() .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 // Detect last firstChar and remove it
var startIndex = editable.lastIndexOf(firstChar) var startIndex = editable.lastIndexOf(firstChar)
if (startIndex == -1) { if (startIndex == -1) {
@ -228,7 +238,7 @@ class AutoCompleter @AssistedInject constructor(
// Adding trailing space " " or ": " if the user started mention someone // Adding trailing space " " or ": " if the user started mention someone
val displayNameSuffix = val displayNameSuffix =
if (firstChar == "@" && startIndex == 0) { if (matrixItem is MatrixItem.UserItem) {
": " ": "
} else { } else {
" " " "
@ -249,6 +259,10 @@ class AutoCompleter @AssistedInject constructor(
} }
companion object { 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 = ':'
} }
} }

View File

@ -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.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.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.createLinkMovementMethod
import im.vector.app.features.home.room.detail.timeline.tools.linkify import im.vector.app.features.home.room.detail.timeline.tools.linkify
import im.vector.app.features.html.EventHtmlRenderer import im.vector.app.features.html.EventHtmlRenderer
@ -112,6 +113,7 @@ class MessageItemFactory @Inject constructor(
private val timelineMediaSizeProvider: TimelineMediaSizeProvider, private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
private val htmlRenderer: Lazy<EventHtmlRenderer>, private val htmlRenderer: Lazy<EventHtmlRenderer>,
private val htmlCompressor: VectorHtmlCompressor, private val htmlCompressor: VectorHtmlCompressor,
private val textRendererFactory: EventTextRenderer.Factory,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val imageContentRenderer: ImageContentRenderer, private val imageContentRenderer: ImageContentRenderer,
private val messageInformationDataFactory: MessageInformationDataFactory, private val messageInformationDataFactory: MessageInformationDataFactory,
@ -138,6 +140,10 @@ class MessageItemFactory @Inject constructor(
pillsPostProcessorFactory.create(roomId) pillsPostProcessorFactory.create(roomId)
} }
private val textRenderer by lazy {
textRendererFactory.create(roomId)
}
fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*>? { fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*>? {
val event = params.event val event = params.event
val highlight = params.isHighlighted val highlight = params.isHighlighted
@ -549,8 +555,9 @@ class MessageItemFactory @Inject constructor(
highlight: Boolean, highlight: Boolean,
callback: TimelineEventController.Callback?, callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): MessageTextItem? { attributes: AbsMessageItem.Attributes): MessageTextItem? {
val bindingOptions = spanUtils.getBindingOptions(body) val renderedBody = textRenderer.render(body)
val linkifiedBody = body.linkify(callback) val bindingOptions = spanUtils.getBindingOptions(renderedBody)
val linkifiedBody = renderedBody.linkify(callback)
return MessageTextItem_() return MessageTextItem_()
.message( .message(

View File

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

View File

@ -19,6 +19,7 @@
package im.vector.app.features.html package im.vector.app.features.html
import android.content.Context import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Paint import android.graphics.Paint
import android.graphics.drawable.Drawable 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.core.glide.GlideRequests
import im.vector.app.features.displayname.getBestName import im.vector.app.features.displayname.getBestName
import im.vector.app.features.home.AvatarRenderer 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.session.room.send.MatrixItemSpan
import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.MatrixItem
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
@ -117,6 +119,11 @@ class PillImageSpan(private val glideRequests: GlideRequests,
setChipMinHeightResource(R.dimen.pill_min_height) setChipMinHeightResource(R.dimen.pill_min_height)
setChipIconSizeResource(R.dimen.pill_avatar_size) setChipIconSizeResource(R.dimen.pill_avatar_size)
chipIcon = icon 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) setBounds(0, 0, intrinsicWidth, intrinsicHeight)
} }
} }

View File

@ -38,55 +38,85 @@ class PillsPostProcessor @AssistedInject constructor(@Assisted private val roomI
private val sessionHolder: ActiveSessionHolder) : private val sessionHolder: ActiveSessionHolder) :
EventHtmlRenderer.PostProcessor { EventHtmlRenderer.PostProcessor {
/* ==========================================================================================
* Public api
* ========================================================================================== */
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(roomId: String?): PillsPostProcessor fun create(roomId: String?): PillsPostProcessor
} }
/* ==========================================================================================
* Specialization
* ========================================================================================== */
override fun afterRender(renderedText: Spannable) { override fun afterRender(renderedText: Spannable) {
addPillSpans(renderedText, roomId) addPillSpans(renderedText, roomId)
} }
/* ==========================================================================================
* Helper methods
* ========================================================================================== */
private fun addPillSpans(renderedText: Spannable, roomId: String?) { 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. // We let markdown handle links and then we add PillImageSpan if needed.
val linkSpans = renderedText.getSpans(0, renderedText.length, LinkSpan::class.java) val linkSpans = renderedText.getSpans(0, renderedText.length, LinkSpan::class.java)
linkSpans.forEach { linkSpan -> linkSpans.forEach { linkSpan ->
val pillSpan = linkSpan.createPillSpan(roomId) ?: return@forEach val pillSpan = linkSpan.createPillSpan(roomId) ?: return@forEach
val startSpan = renderedText.getSpanStart(linkSpan) val startSpan = renderedText.getSpanStart(linkSpan)
val endSpan = renderedText.getSpanEnd(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? { private fun LinkSpan.createPillSpan(roomId: String?): PillImageSpan? {
val permalinkData = PermalinkParser.parse(url) val matrixItem = when (val permalinkData = PermalinkParser.parse(url)) {
val matrixItem = when (permalinkData) { is PermalinkData.UserLink -> permalinkData.toMatrixItem(roomId)
is PermalinkData.UserLink -> { is PermalinkData.RoomLink -> permalinkData.toMatrixItem()
is PermalinkData.GroupLink -> permalinkData.toMatrixItem()
else -> null
} ?: return null
return createPillImageSpan(matrixItem)
}
private fun PermalinkData.UserLink.toMatrixItem(roomId: String?): MatrixItem? =
if (roomId == null) { if (roomId == null) {
sessionHolder.getSafeActiveSession()?.getUser(permalinkData.userId)?.toMatrixItem() sessionHolder.getSafeActiveSession()?.getUser(userId)?.toMatrixItem()
} else { } else {
sessionHolder.getSafeActiveSession()?.getRoomMember(permalinkData.userId, roomId)?.toMatrixItem() sessionHolder.getSafeActiveSession()?.getRoomMember(userId, roomId)?.toMatrixItem()
} }
}
is PermalinkData.RoomLink -> { private fun PermalinkData.RoomLink.toMatrixItem(): MatrixItem? =
if (permalinkData.eventId == null) { if (eventId == null) {
val room: RoomSummary? = sessionHolder.getSafeActiveSession()?.getRoomSummary(permalinkData.roomIdOrAlias) val room: RoomSummary? = sessionHolder.getSafeActiveSession()?.getRoomSummary(roomIdOrAlias)
if (permalinkData.isRoomAlias) { when {
MatrixItem.RoomAliasItem(permalinkData.roomIdOrAlias, room?.displayName, room?.avatarUrl) isRoomAlias -> MatrixItem.RoomAliasItem(roomIdOrAlias, room?.displayName, room?.avatarUrl)
} else { else -> MatrixItem.RoomItem(roomIdOrAlias, room?.displayName, room?.avatarUrl)
MatrixItem.RoomItem(permalinkData.roomIdOrAlias, room?.displayName, room?.avatarUrl)
} }
} else { } else {
// Exclude event link (used in reply events, we do not want to pill the "in reply to") // Exclude event link (used in reply events, we do not want to pill the "in reply to")
null null
} }
}
is PermalinkData.GroupLink -> { private fun PermalinkData.GroupLink.toMatrixItem(): MatrixItem? {
val group = sessionHolder.getSafeActiveSession()?.getGroupSummary(permalinkData.groupId) val group = sessionHolder.getSafeActiveSession()?.getGroupSummary(groupId)
MatrixItem.GroupItem(permalinkData.groupId, group?.displayName, group?.avatarUrl) return MatrixItem.GroupItem(groupId, group?.displayName, group?.avatarUrl)
}
else -> null
} ?: return null
return PillImageSpan(GlideApp.with(context), avatarRenderer, context, matrixItem)
} }
} }

View File

@ -0,0 +1,21 @@
<?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:background="?android:colorBackground"
android:orientation="horizontal"
android:padding="8dp"
tools:viewBindingIgnore="true">
<TextView
android:id="@+id/headerItemAutocompleteTitle"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:maxLines="1"
android:textColor="?vctr_content_secondary"
tools:text="Users" />
</LinearLayout>

View File

@ -3782,4 +3782,7 @@
<string name="message_reaction_show_less">Show less</string> <string name="message_reaction_show_less">Show less</string>
<string name="message_reaction_show_more">"%1$d more"</string> <string name="message_reaction_show_more">"%1$d more"</string>
<string name="room_message_notify_everyone">Notify the whole room</string>
<string name="room_message_autocomplete_users">Users</string>
<string name="room_message_autocomplete_notification">Room notification</string>
</resources> </resources>