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>
</style>
<style name="TextAppearance.Vector.Body.OnError">
<item name="android:textColor">?colorOnError</item>
</style>
<style name="TextAppearance.Vector.Caption" parent="TextAppearance.MaterialComponents.Caption">
<item name="fontFamily">sans-serif</item>
<item name="android:fontFamily">sans-serif</item>
@ -81,4 +85,4 @@
<item name="android:letterSpacing">0.02</item>
</style>
</resources>
</resources>

View File

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

View File

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

View File

@ -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<Set<String>> {
// Keywords are all content rules that don't start with '.'
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 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

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
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<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
override fun buildModels(data: List<RoomMemberSummary>?) {
/* ==========================================================================================
* Specialization
* ========================================================================================== */
override fun buildModels(data: List<AutocompleteMemberItem>?) {
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) }
}
}
}

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.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<RoomMemberSummary>(context), AutocompleteClickListener<RoomMemberSummary> {
) : RecyclerViewPresenter<AutocompleteMemberItem>(context), AutocompleteClickListener<AutocompleteMemberItem> {
/* ==========================================================================================
* 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<AutocompleteMemberItem>().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"
}
}

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.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<Command>(editText)
.with(commandAutocompletePolicy)
.with(autocompleteCommandPresenter)
.with(ELEVATION)
.with(ELEVATION_DP)
.with(backgroundDrawable)
.with(object : AutocompleteCallback<Command> {
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<RoomMemberSummary>(editText)
.with(CharPolicy('@', true))
Autocomplete.on<AutocompleteMemberItem>(editText)
.with(CharPolicy(TRIGGER_AUTO_COMPLETE_MEMBERS, true))
.with(autocompleteMemberPresenter)
.with(ELEVATION)
.with(ELEVATION_DP)
.with(backgroundDrawable)
.with(object : AutocompleteCallback<RoomMemberSummary> {
override fun onPopupItemClicked(editable: Editable, item: RoomMemberSummary): Boolean {
insertMatrixItem(editText, editable, "@", item.toMatrixItem())
return true
.with(object : AutocompleteCallback<AutocompleteMemberItem> {
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<RoomSummary>(editText)
.with(CharPolicy('#', true))
.with(CharPolicy(TRIGGER_AUTO_COMPLETE_ROOMS, true))
.with(autocompleteRoomPresenter)
.with(ELEVATION)
.with(ELEVATION_DP)
.with(backgroundDrawable)
.with(object : AutocompleteCallback<RoomSummary> {
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<GroupSummary>(editText)
.with(CharPolicy('+', true))
.with(CharPolicy(TRIGGER_AUTO_COMPLETE_GROUPS, true))
.with(autocompleteGroupPresenter)
.with(ELEVATION)
.with(ELEVATION_DP)
.with(backgroundDrawable)
.with(object : AutocompleteCallback<GroupSummary> {
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<String>(editText)
.with(CharPolicy(':', false))
.with(CharPolicy(TRIGGER_AUTO_COMPLETE_EMOJIS, false))
.with(autocompleteEmojiPresenter)
.with(ELEVATION)
.with(ELEVATION_DP)
.with(backgroundDrawable)
.with(object : AutocompleteCallback<String> {
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 = ':'
}
}

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.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<EventHtmlRenderer>,
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(

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

View File

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

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_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>