Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions app/src/main/java/com/nextcloud/utils/text/RichSubjectFormatter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.utils.text

import android.content.Context
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.TextPaint
import android.text.TextUtils
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.view.View
import androidx.core.content.ContextCompat
import com.google.android.material.chip.ChipDrawable
import com.nextcloud.client.account.CurrentAccountProvider
import com.owncloud.android.R
import com.owncloud.android.utils.DisplayUtils
import thirdparties.fresco.BetterImageSpan

class RichSubjectFormatter(private val context: Context, private val currentAccountProvider: CurrentAccountProvider) :
DisplayUtils.AvatarGenerationListener {

fun format(richSubject: String, paramForTag: (String) -> RichSubjectParam?): SpannableStringBuilder {
var text = richSubject
val ssb = SpannableStringBuilder(text)

var start = text.indexOf(PLACEHOLDER_START)
while (start != -1) {
val end = text.indexOf(PLACEHOLDER_END, start) + 1
if (end <= start) break

val param = paramForTag(text.substring(start + 1, end - 1))

val nextSearchStart = when {
param == null -> end

param.isMention -> {
ssb.applyMentionSpan(param, start, end)
end
}

else -> {
val nameEnd = ssb.applyClickableNameSpan(param, start, end)
text = ssb.toString()
nameEnd
}
}

start = text.indexOf(PLACEHOLDER_START, nextSearchStart)
}

return ssb
}

override fun avatarGenerated(avatarDrawable: Drawable, callContext: Any) {
(callContext as? ChipDrawable)?.chipIcon = avatarDrawable
}

override fun shouldCallGeneratedCallback(tag: String, callContext: Any): Boolean = true

private fun SpannableStringBuilder.applyMentionSpan(param: RichSubjectParam, start: Int, end: Int) {
val name = param.name
val chip = mentionChipDrawable(name.orEmpty())
val span = MentionChipSpan(chip, BetterImageSpan.ALIGN_CENTER, param.id.orEmpty(), name)

param.id?.let { id ->
DisplayUtils.setAvatar(
currentAccountProvider.user,
id,
name,
this@RichSubjectFormatter,
context.resources.getDimension(R.dimen.avatar_icon_radius),
context.resources,
chip,
context
)
}

setSpan(span, start, end, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
}

private fun SpannableStringBuilder.applyClickableNameSpan(param: RichSubjectParam, start: Int, end: Int): Int {
val name = param.name.orEmpty()
replace(start, end, name)
val nameEnd = start + name.length

param.onClick?.let { onClick ->
setSpan(
object : ClickableSpan() {
override fun onClick(widget: View) = onClick()
override fun updateDrawState(ds: TextPaint) {
ds.isUnderlineText = false
}
},
start,
nameEnd,
0
)
}

setSpan(StyleSpan(Typeface.BOLD), start, nameEnd, 0)
setSpan(
ForegroundColorSpan(ContextCompat.getColor(context, R.color.text_color)),
start,
nameEnd,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)

return nameEnd
}

private fun mentionChipDrawable(text: String): ChipDrawable =
ChipDrawable.createFromResource(context, R.xml.chip_others).apply {
setEllipsize(TextUtils.TruncateAt.MIDDLE)
layoutDirection = context.resources.configuration.layoutDirection
setText(text)
setChipIconResource(R.drawable.accent_circle)
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
}

companion object {
private const val PLACEHOLDER_START = '{'
private const val PLACEHOLDER_END = '}'
}
}
16 changes: 16 additions & 0 deletions app/src/main/java/com/nextcloud/utils/text/RichSubjectParam.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.utils.text

data class RichSubjectParam(val type: String?, val id: String?, val name: String?, val onClick: (() -> Unit)? = null) {
val isMention: Boolean
get() = type == USER_TYPE

companion object {
private const val USER_TYPE = "user"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,9 @@
package com.owncloud.android.ui.activities.adapter

import android.content.Context
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.TextPaint
import android.text.TextUtils
import android.text.format.DateFormat
import android.text.format.DateUtils
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
Expand All @@ -29,12 +21,12 @@ import androidx.annotation.DrawableRes
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.chip.ChipDrawable
import com.nextcloud.android.common.ui.theme.utils.ColorRole
import com.nextcloud.client.account.CurrentAccountProvider
import com.nextcloud.common.NextcloudClient
import com.nextcloud.utils.GlideHelper
import com.nextcloud.utils.text.MentionChipSpan
import com.nextcloud.utils.text.RichSubjectFormatter
import com.nextcloud.utils.text.RichSubjectParam
import com.owncloud.android.MainApp
import com.owncloud.android.R
import com.owncloud.android.databinding.ActivityListItemBinding
Expand All @@ -53,7 +45,6 @@ import com.owncloud.android.utils.theme.ViewThemeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import thirdparties.fresco.BetterImageSpan
import java.util.Locale
import kotlin.math.floor
import kotlin.math.log
Expand All @@ -67,13 +58,13 @@ open class ActivityListAdapter(
private val isDetailView: Boolean,
private val viewThemeUtils: ViewThemeUtils
) : RecyclerView.Adapter<RecyclerView.ViewHolder>(),
StickyHeaderAdapter,
DisplayUtils.AvatarGenerationListener {
StickyHeaderAdapter {

protected var client: NextcloudClient? = null
val values: MutableList<Any> = mutableListOf()
private val px = getThumbnailDimension()
private var cachedNextcloudClient: NextcloudClient? = null
private val richSubjectFormatter by lazy { RichSubjectFormatter(context, currentAccountProvider) }

// region Public Methods
@Suppress("NotifyDataSetChanged")
Expand Down Expand Up @@ -138,12 +129,6 @@ open class ActivityListAdapter(

override fun isHeader(itemPosition: Int) =
itemPosition in values.indices && getItemViewType(itemPosition) == HEADER_TYPE

override fun avatarGenerated(avatarDrawable: Drawable, callContext: Any) {
(callContext as ChipDrawable).chipIcon = avatarDrawable
}

override fun shouldCallGeneratedCallback(tag: String, callContext: Any): Boolean = true
// endregion

// region Private Methods
Expand Down Expand Up @@ -248,15 +233,6 @@ open class ActivityListAdapter(
}
}

private fun getDrawableForMentionChipSpan(chipResource: Int, text: String): ChipDrawable =
ChipDrawable.createFromResource(context, chipResource).apply {
setEllipsize(TextUtils.TruncateAt.MIDDLE)
layoutDirection = context.resources.configuration.layoutDirection
setText(text)
setChipIconResource(R.drawable.accent_circle)
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
}

private suspend fun nextcloudClient(): NextcloudClient = cachedNextcloudClient ?: withContext(Dispatchers.IO) {
OwnCloudClientManagerFactory.getDefaultSingleton()
.getNextcloudClientFor(currentAccountProvider.user.toOwnCloudAccount(), context)
Expand Down Expand Up @@ -302,83 +278,16 @@ open class ActivityListAdapter(
return imageView
}

private fun addClickablePart(richElement: RichElement): SpannableStringBuilder {
var text = richElement.richSubject
val ssb = SpannableStringBuilder(text)

var start = text.indexOf(PLACEHOLDER_START)
while (start != -1) {
val end = text.indexOf(PLACEHOLDER_END, start) + 1
val tag = text.substring(start + 1, end - 1)
val richObject = richElement.richObjectList.firstOrNull { it.tag.equals(tag, ignoreCase = true) }

val nextSearchStart = when {
richObject == null -> end

richObject.type == USER_TYPE -> {
ssb.applyMentionSpan(richObject, start, end)
end
}

else -> {
val nameEnd = ssb.applyClickableNameSpan(richObject, start, end)
text = ssb.toString()
nameEnd
}
}

start = text.indexOf(PLACEHOLDER_START, nextSearchStart)
}

return ssb
}

private fun SpannableStringBuilder.applyMentionSpan(richObject: RichObject, start: Int, end: Int) {
val name = richObject.name
val chip = getDrawableForMentionChipSpan(R.xml.chip_others, name ?: "")
val span = MentionChipSpan(chip, BetterImageSpan.ALIGN_CENTER, richObject.id ?: "", name)

richObject.id?.let { id ->
DisplayUtils.setAvatar(
currentAccountProvider.user,
id,
name,
this@ActivityListAdapter,
context.resources.getDimension(R.dimen.avatar_icon_radius),
context.resources,
chip,
context
)
private fun addClickablePart(richElement: RichElement): SpannableStringBuilder =
richSubjectFormatter.format(richElement.richSubject) { tag ->
richElement.richObjectList
.firstOrNull { it.tag.equals(tag, ignoreCase = true) }
?.toRichSubjectParam()
}

setSpan(span, start, end, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
}

private fun SpannableStringBuilder.applyClickableNameSpan(richObject: RichObject, start: Int, end: Int): Int {
val name = richObject.name.orEmpty()
replace(start, end, name)
val nameEnd = start + name.length

setSpan(
object : ClickableSpan() {
override fun onClick(widget: View) = activityListInterface.onActivityClicked(richObject)
override fun updateDrawState(ds: TextPaint) {
ds.isUnderlineText = false
}
},
start,
nameEnd,
0
)
setSpan(StyleSpan(Typeface.BOLD), start, nameEnd, 0)
setSpan(
ForegroundColorSpan(context.resources.getColor(R.color.text_color)),
start,
nameEnd,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)

return nameEnd
private fun RichObject.toRichSubjectParam(): RichSubjectParam {
val richObject = this
return RichSubjectParam(type, id, name) { activityListInterface.onActivityClicked(richObject) }
}

private fun getThumbnailDimension(): Int {
Expand All @@ -397,9 +306,6 @@ open class ActivityListAdapter(
const val HEADER_TYPE = 100
const val ACTIVITY_TYPE = 101
private const val COLORED_ICON_SUFFIX = "-color.svg"
private const val PLACEHOLDER_START = '{'
private const val PLACEHOLDER_END = '}'
private const val USER_TYPE = "user"
private const val TIME_PATTERN = "HH:mm"
private const val HEADER_DATE_SKELETON = "EEEE, MMMM d"
private const val PREVIEW_COLUMN_SPACING = 20
Expand Down
Loading
Loading