Skip to content

Commit fc6e205

Browse files
Merge pull request #6154 from nextcloud/feat/6072/mentionsClick
Mention chip click brings you to the 1on1 conversation
2 parents ab23609 + cad17b0 commit fc6e205

7 files changed

Lines changed: 302 additions & 86 deletions

File tree

app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3781,29 +3781,12 @@ class ChatActivity :
37813781
}*/
37823782
}
37833783

3784-
@Subscribe(threadMode = ThreadMode.BACKGROUND)
3784+
@Subscribe(threadMode = ThreadMode.MAIN)
37853785
fun onMessageEvent(userMentionClickEvent: UserMentionClickEvent) {
37863786
if (currentConversation?.type != ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL ||
37873787
currentConversation?.name != userMentionClickEvent.userId
37883788
) {
3789-
var apiVersion = 1
3790-
// FIXME Fix API checking with guests?
3791-
if (conversationUser != null) {
3792-
apiVersion = ApiUtils.getConversationApiVersion(conversationUser!!, intArrayOf(ApiUtils.API_V4, 1))
3793-
}
3794-
3795-
val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom(
3796-
version = apiVersion,
3797-
baseUrl = conversationUser?.baseUrl!!,
3798-
roomType = "1",
3799-
invite = userMentionClickEvent.userId
3800-
)
3801-
3802-
chatViewModel.createRoom(
3803-
credentials!!,
3804-
retrofitBucket.url!!,
3805-
retrofitBucket.queryMap!!
3806-
)
3789+
joinOneToOneConversation(userMentionClickEvent.userId)
38073790
}
38083791
}
38093792

app/src/main/java/com/nextcloud/talk/ui/chat/MentionChip.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ import androidx.compose.ui.platform.LocalContext
3030
import androidx.compose.ui.platform.LocalDensity
3131
import androidx.compose.ui.res.dimensionResource
3232
import androidx.compose.ui.res.painterResource
33+
import androidx.compose.ui.res.stringResource
34+
import androidx.compose.ui.semantics.Role
35+
import androidx.compose.ui.semantics.contentDescription
36+
import androidx.compose.ui.semantics.role
37+
import androidx.compose.ui.semantics.semantics
3338
import androidx.compose.ui.text.AnnotatedString
3439
import androidx.compose.ui.text.Placeholder
3540
import androidx.compose.ui.text.PlaceholderVerticalAlign
@@ -232,9 +237,18 @@ fun MentionChip(mention: MentionChipModel, textStyle: TextStyle, isMultilineLayo
232237
}
233238
val fallbackIcon = resolveMentionFallbackIcon(mention)
234239
val verticalPadding = if (isMultilineLayout) multilineChipVerticalPadding else chipVerticalPadding
240+
val chipContentDescription = if (mention.isClickableUserMention) {
241+
stringResource(R.string.mention_open_chat_with, mention.name)
242+
} else {
243+
mention.name
244+
}
235245

236246
Row(
237247
modifier = Modifier
248+
.semantics {
249+
contentDescription = chipContentDescription
250+
if (mention.isClickableUserMention) role = Role.Button
251+
}
238252
.background(backgroundColor, RoundedCornerShape(chipCornerRadius))
239253
.clickable(enabled = mention.isClickableUserMention) {
240254
EventBus.getDefault().post(UserMentionClickEvent(mention.id))

app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.kt

Lines changed: 0 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -58,19 +58,15 @@ import com.nextcloud.talk.PhoneUtils.isPhoneNumber
5858
import com.nextcloud.talk.R
5959
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
6060
import com.nextcloud.talk.data.user.model.User
61-
import com.nextcloud.talk.events.UserMentionClickEvent
6261
import com.nextcloud.talk.extensions.loadUserAvatar
6362
import com.nextcloud.talk.ui.theme.ViewThemeUtils
6463
import com.nextcloud.talk.utils.ApiUtils.getUrlForAvatar
6564
import com.nextcloud.talk.utils.ApiUtils.getUrlForFederatedAvatar
6665
import com.nextcloud.talk.utils.ApiUtils.getUrlForGuestAvatar
6766
import com.nextcloud.talk.utils.preferences.AppPreferencesImpl
68-
import com.nextcloud.talk.utils.text.Spans.MentionChipSpan
6967
import kotlinx.coroutines.ExperimentalCoroutinesApi
7068
import kotlinx.coroutines.flow.first
7169
import kotlinx.coroutines.runBlocking
72-
import org.greenrobot.eventbus.EventBus
73-
import third.parties.fresco.BetterImageSpan
7470
import java.text.DateFormat
7571
import java.util.Date
7672
import java.util.regex.Pattern
@@ -248,69 +244,6 @@ object DisplayUtils {
248244
return chip
249245
}
250246

251-
fun searchAndReplaceWithMentionSpan(
252-
key: String,
253-
context: Context,
254-
text: Spanned,
255-
id: String,
256-
roomToken: String?,
257-
label: String,
258-
type: String,
259-
conversationUser: User,
260-
@XmlRes chipXmlRes: Int,
261-
viewThemeUtils: ViewThemeUtils,
262-
isFederated: Boolean
263-
): Spannable {
264-
val spannableString: Spannable = SpannableString(text)
265-
val stringText = text.toString()
266-
val keyWithBrackets = "{$key}"
267-
val m = Pattern.compile(keyWithBrackets, Pattern.CASE_INSENSITIVE or Pattern.LITERAL or Pattern.MULTILINE)
268-
.matcher(spannableString)
269-
val clickableSpan: ClickableSpan = object : ClickableSpan() {
270-
override fun onClick(widget: View) {
271-
EventBus.getDefault().post(UserMentionClickEvent(id))
272-
}
273-
}
274-
var lastStartIndex = 0
275-
var mentionChipSpan: MentionChipSpan
276-
while (m.find()) {
277-
val start = stringText.indexOf(m.group(), lastStartIndex)
278-
val end = start + m.group().length
279-
lastStartIndex = end
280-
val drawableForChip = getDrawableForMentionChipSpan(
281-
context,
282-
id,
283-
roomToken,
284-
label,
285-
conversationUser,
286-
type,
287-
chipXmlRes,
288-
null,
289-
viewThemeUtils,
290-
isFederated
291-
)
292-
mentionChipSpan = MentionChipSpan(
293-
drawableForChip,
294-
BetterImageSpan.ALIGN_CENTER,
295-
id,
296-
label
297-
)
298-
spannableString.setSpan(mentionChipSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
299-
if (chipXmlRes == R.xml.chip_you) {
300-
spannableString.setSpan(
301-
viewThemeUtils.talk.themeForegroundColorSpan(context),
302-
start,
303-
end,
304-
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
305-
)
306-
}
307-
if ("user" == type && conversationUser.userId != id && !isFederated) {
308-
spannableString.setSpan(clickableSpan, start, end, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
309-
}
310-
}
311-
return spannableString
312-
}
313-
314247
fun searchAndColor(text: Spannable, searchText: String, @ColorInt color: Int, textSize: Int): Spannable {
315248
val spannableString: Spannable = SpannableString(text)
316249
val stringText = text.toString()

app/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,7 @@ How to translate with transifex:
875875
<string name="danger_zone">Danger zone</string>
876876
<string name="nc_filter">Filter conversations</string>
877877
<string name="mentioned">Mentioned</string>
878+
<string name="mention_open_chat_with">Open chat with %1$s</string>
878879
<string name="unread">Unread</string>
879880
<string name="nc_settings_http_value" translatable="false">3128</string>
880881
<string name="nc_settings_direct_value" translatable="false">8080</string>
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.talk.ui.chat
9+
10+
import org.junit.Assert.assertFalse
11+
import org.junit.Assert.assertNotNull
12+
import org.junit.Assert.assertTrue
13+
import org.junit.Test
14+
15+
class MentionChipClickabilityTest {
16+
17+
private val activeUserId = "alice"
18+
private val baseUrl = "https://cloud.example.com"
19+
20+
private fun mentionParams(
21+
type: String,
22+
id: String,
23+
name: String = "Alice",
24+
server: String? = null
25+
): Map<String, Map<String, String>> {
26+
val params = mutableMapOf("type" to type, "id" to id, "name" to name)
27+
server?.let { params["server"] = it }
28+
return mapOf("mention1" to params)
29+
}
30+
31+
@Test
32+
fun `local user that is not the active user is clickable`() {
33+
val model = parseMentionChipModel(
34+
key = "mention1",
35+
messageParameters = mentionParams(type = "user", id = "bob", name = "Bob"),
36+
activeUserId = activeUserId,
37+
activeUserBaseUrl = baseUrl,
38+
roomToken = null
39+
)
40+
assertNotNull(model)
41+
assertTrue(model!!.isClickableUserMention)
42+
}
43+
44+
@Test
45+
fun `self-mention is not clickable`() {
46+
val model = parseMentionChipModel(
47+
key = "mention1",
48+
messageParameters = mentionParams(type = "user", id = activeUserId),
49+
activeUserId = activeUserId,
50+
activeUserBaseUrl = baseUrl,
51+
roomToken = null
52+
)
53+
assertNotNull(model)
54+
assertFalse(model!!.isClickableUserMention)
55+
}
56+
57+
@Test
58+
fun `federated user mention is not clickable`() {
59+
val model = parseMentionChipModel(
60+
key = "mention1",
61+
messageParameters = mentionParams(type = "user", id = "bob", server = "remote.example.com"),
62+
activeUserId = activeUserId,
63+
activeUserBaseUrl = baseUrl,
64+
roomToken = null
65+
)
66+
assertNotNull(model)
67+
assertFalse(model!!.isClickableUserMention)
68+
}
69+
70+
@Test
71+
fun `group mention is not clickable`() {
72+
val model = parseMentionChipModel(
73+
key = "mention1",
74+
messageParameters = mentionParams(type = "user-group", id = "devs", name = "Devs"),
75+
activeUserId = activeUserId,
76+
activeUserBaseUrl = baseUrl,
77+
roomToken = null
78+
)
79+
assertNotNull(model)
80+
assertFalse(model!!.isClickableUserMention)
81+
}
82+
83+
@Test
84+
fun `call mention is not clickable`() {
85+
val model = parseMentionChipModel(
86+
key = "mention1",
87+
messageParameters = mentionParams(type = "call", id = "room", name = "all"),
88+
activeUserId = activeUserId,
89+
activeUserBaseUrl = baseUrl,
90+
roomToken = null
91+
)
92+
assertNotNull(model)
93+
assertFalse(model!!.isClickableUserMention)
94+
}
95+
96+
@Test
97+
fun `guest mention is not clickable`() {
98+
val model = parseMentionChipModel(
99+
key = "mention1",
100+
messageParameters = mentionParams(type = "guest", id = "guest/abc", name = "Guest"),
101+
activeUserId = activeUserId,
102+
activeUserBaseUrl = baseUrl,
103+
roomToken = null
104+
)
105+
assertNotNull(model)
106+
assertFalse(model!!.isClickableUserMention)
107+
}
108+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.talk.ui.chat
9+
10+
import org.junit.Assert.assertEquals
11+
import org.junit.Assert.assertFalse
12+
import org.junit.Assert.assertNull
13+
import org.junit.Assert.assertTrue
14+
import org.junit.Test
15+
16+
class MentionChipModelParsingTest {
17+
18+
private val activeUserId = "alice"
19+
private val baseUrl = "https://cloud.example.com"
20+
21+
private fun mentionParams(
22+
type: String,
23+
id: String,
24+
name: String = "Alice",
25+
server: String? = null
26+
): Map<String, Map<String, String>> {
27+
val params = mutableMapOf("type" to type, "id" to id, "name" to name)
28+
server?.let { params["server"] = it }
29+
return mapOf("mention1" to params)
30+
}
31+
32+
@Test
33+
fun `local user id equals rawId`() {
34+
val model = parseMentionChipModel(
35+
key = "mention1",
36+
messageParameters = mentionParams(type = "user", id = "bob"),
37+
activeUserId = activeUserId,
38+
activeUserBaseUrl = baseUrl,
39+
roomToken = null
40+
)
41+
assertEquals("bob", model!!.id)
42+
assertEquals("bob", model.rawId)
43+
assertFalse(model.isFederated)
44+
}
45+
46+
@Test
47+
fun `federated user id is rawId at server`() {
48+
val model = parseMentionChipModel(
49+
key = "mention1",
50+
messageParameters = mentionParams(type = "user", id = "bob", server = "remote.example.com"),
51+
activeUserId = activeUserId,
52+
activeUserBaseUrl = baseUrl,
53+
roomToken = null
54+
)
55+
assertEquals("bob@remote.example.com", model!!.id)
56+
assertEquals("bob", model.rawId)
57+
assertTrue(model.isFederated)
58+
}
59+
60+
@Test
61+
fun `unknown mention type returns null`() {
62+
val model = parseMentionChipModel(
63+
key = "mention1",
64+
messageParameters = mapOf("mention1" to mapOf("type" to "unknown", "id" to "x", "name" to "X")),
65+
activeUserId = activeUserId,
66+
activeUserBaseUrl = baseUrl,
67+
roomToken = null
68+
)
69+
assertNull(model)
70+
}
71+
72+
@Test
73+
fun `missing key returns null`() {
74+
val model = parseMentionChipModel(
75+
key = "mention99",
76+
messageParameters = mentionParams(type = "user", id = "bob"),
77+
activeUserId = activeUserId,
78+
activeUserBaseUrl = baseUrl,
79+
roomToken = null
80+
)
81+
assertNull(model)
82+
}
83+
84+
@Test
85+
fun `isSelfMention is true when rawId equals activeUserId`() {
86+
val model = parseMentionChipModel(
87+
key = "mention1",
88+
messageParameters = mentionParams(type = "user", id = activeUserId),
89+
activeUserId = activeUserId,
90+
activeUserBaseUrl = baseUrl,
91+
roomToken = null
92+
)
93+
assertTrue(model!!.isSelfMention)
94+
}
95+
96+
@Test
97+
fun `isSelfMention is false when rawId differs from activeUserId`() {
98+
val model = parseMentionChipModel(
99+
key = "mention1",
100+
messageParameters = mentionParams(type = "user", id = "carol"),
101+
activeUserId = activeUserId,
102+
activeUserBaseUrl = baseUrl,
103+
roomToken = null
104+
)
105+
assertFalse(model!!.isSelfMention)
106+
}
107+
108+
@Test
109+
fun `isSelfMention is false when activeUserId is null`() {
110+
val model = parseMentionChipModel(
111+
key = "mention1",
112+
messageParameters = mentionParams(type = "user", id = "carol"),
113+
activeUserId = null,
114+
activeUserBaseUrl = baseUrl,
115+
roomToken = null
116+
)
117+
assertFalse(model!!.isSelfMention)
118+
}
119+
}

0 commit comments

Comments
 (0)