mirror of
https://github.com/SchildiChat/SchildiChat-android.git
synced 2025-01-28 18:09:30 +01:00
Merge pull request #5237 from vector-im/feature/mna/5123-room-tag-suggestion
#5123: @room tag suggestion
This commit is contained in:
commit
d1d26a98af
1
changelog.d/5123.feature
Normal file
1
changelog.d/5123.feature
Normal file
@ -0,0 +1 @@
|
||||
Add completion for @room to notify everyone in a room
|
@ -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>
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 = ':'
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
21
vector/src/main/res/layout/item_autocomplete_header_item.xml
Normal file
21
vector/src/main/res/layout/item_autocomplete_header_item.xml
Normal 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>
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user