Merge pull request #3701 from nextcloud/federated-mentions

Federated Mentions
This commit is contained in:
Marcel Hibbe 2024-03-21 17:28:35 +01:00 committed by GitHub
commit 6e45b0cdc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 952 additions and 853 deletions

View file

@ -1,227 +0,0 @@
/*
* Nextcloud Talk application
*
* @author Mario Danic
* @author Marcel Hibbe
* @author Andy Scherzinger
* Copyright (C) 2021-2022 Andy Scherzinger <info@andy-scherzinger.de>
* Copyright (C) 2022 Marcel Hibbe <dev@mhibbe.de>
* Copyright (C) 2017 Mario Danic <mario@lovelyhq.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.talk.adapters.items;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Build;
import android.view.View;
import com.nextcloud.talk.R;
import com.nextcloud.talk.data.user.model.User;
import com.nextcloud.talk.extensions.ImageViewExtensionsKt;
import com.nextcloud.talk.models.json.mention.Mention;
import com.nextcloud.talk.models.json.status.StatusType;
import com.nextcloud.talk.ui.StatusDrawable;
import com.nextcloud.talk.ui.theme.ViewThemeUtils;
import com.nextcloud.talk.utils.DisplayUtils;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.res.ResourcesCompat;
import eu.davidea.flexibleadapter.FlexibleAdapter;
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem;
import eu.davidea.flexibleadapter.items.IFilterable;
import eu.davidea.flexibleadapter.items.IFlexible;
public class MentionAutocompleteItem extends AbstractFlexibleItem<ParticipantItem.ParticipantItemViewHolder>
implements IFilterable<String> {
private static final float STATUS_SIZE_IN_DP = 9f;
private static final String NO_ICON = "";
public static final String SOURCE_CALLS = "calls";
public static final String SOURCE_GUESTS = "guests";
public static final String SOURCE_GROUPS = "groups";
private String source;
private final String objectId;
private final String displayName;
private final String status;
private final String statusIcon;
private final String statusMessage;
private final User currentUser;
private final Context context;
private final ViewThemeUtils viewThemeUtils;
public MentionAutocompleteItem(
Mention mention,
User currentUser,
Context activityContext, ViewThemeUtils viewThemeUtils) {
this.objectId = mention.getId();
this.displayName = mention.getLabel();
this.source = mention.getSource();
this.status = mention.getStatus();
this.statusIcon = mention.getStatusIcon();
this.statusMessage = mention.getStatusMessage();
this.currentUser = currentUser;
this.context = activityContext;
this.viewThemeUtils = viewThemeUtils;
}
public String getSource() {
return source;
}
public void setSource(String source) {
this.source = source;
}
public String getObjectId() {
return objectId;
}
public String getDisplayName() {
return displayName;
}
@Override
public boolean equals(Object o) {
if (o instanceof MentionAutocompleteItem inItem) {
return (objectId.equals(inItem.objectId) && displayName.equals(inItem.displayName));
}
return false;
}
@Override
public int hashCode() {
return Objects.hash(objectId, displayName);
}
@Override
public int getLayoutRes() {
return R.layout.rv_item_conversation_info_participant;
}
@Override
public ParticipantItem.ParticipantItemViewHolder createViewHolder(View view, FlexibleAdapter<IFlexible> adapter) {
return new ParticipantItem.ParticipantItemViewHolder(view, adapter);
}
@SuppressLint("SetTextI18n")
@Override
public void bindViewHolder(FlexibleAdapter<IFlexible> adapter,
ParticipantItem.ParticipantItemViewHolder holder,
int position,
List<Object> payloads) {
holder.binding.nameText.setTextColor(
ResourcesCompat.getColor(context.getResources(),
R.color.conversation_item_header,
null));
if (adapter.hasFilter()) {
viewThemeUtils.talk.themeAndHighlightText(holder.binding.nameText,
displayName,
String.valueOf(adapter.getFilter(String.class)));
viewThemeUtils.talk.themeAndHighlightText(holder.binding.secondaryText,
"@" + objectId,
String.valueOf(adapter.getFilter(String.class)));
} else {
holder.binding.nameText.setText(displayName);
holder.binding.secondaryText.setText("@" + objectId);
}
if (SOURCE_CALLS.equals(source) || SOURCE_GROUPS.equals(source)) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ImageViewExtensionsKt.loadUserAvatar(
holder.binding.avatarView,
viewThemeUtils.talk.themePlaceholderAvatar(
holder.binding.avatarView,
R.drawable.ic_avatar_group
)
);
} else {
ImageViewExtensionsKt.loadUserAvatar(holder.binding.avatarView, R.drawable.ic_circular_group);
}
} else {
String avatarId = objectId;
if (SOURCE_GUESTS.equals(source)) {
avatarId = displayName;
}
ImageViewExtensionsKt.loadUserAvatar(holder.binding.avatarView, currentUser, avatarId, true, false);
}
drawStatus(holder);
}
private void drawStatus(ParticipantItem.ParticipantItemViewHolder holder) {
float size = DisplayUtils.convertDpToPixel(STATUS_SIZE_IN_DP, context);
holder.binding.userStatusImage.setImageDrawable(new StatusDrawable(
status,
NO_ICON,
size,
context.getResources().getColor(R.color.bg_default),
context));
if (statusMessage != null) {
holder.binding.conversationInfoStatusMessage.setText(statusMessage);
alignUsernameVertical(holder, 0);
} else {
holder.binding.conversationInfoStatusMessage.setText("");
alignUsernameVertical(holder, 10);
}
if (statusIcon != null && !statusIcon.isEmpty()) {
holder.binding.participantStatusEmoji.setText(statusIcon);
} else {
holder.binding.participantStatusEmoji.setVisibility(View.GONE);
}
if (status != null && status.equals(StatusType.DND.getString())) {
if (statusMessage == null || statusMessage.isEmpty()) {
holder.binding.conversationInfoStatusMessage.setText(R.string.dnd);
}
} else if (status != null && status.equals(StatusType.AWAY.getString())) {
if (statusMessage == null || statusMessage.isEmpty()) {
holder.binding.conversationInfoStatusMessage.setText(R.string.away);
}
}
}
private void alignUsernameVertical(ParticipantItem.ParticipantItemViewHolder holder, float densityPixelsFromTop) {
ConstraintLayout.LayoutParams layoutParams =
(ConstraintLayout.LayoutParams) holder.binding.nameText.getLayoutParams();
layoutParams.topMargin = (int) DisplayUtils.convertDpToPixel(densityPixelsFromTop, context);
holder.binding.nameText.setLayoutParams(layoutParams);
}
@Override
public boolean filter(String constraint) {
return objectId != null &&
Pattern
.compile(constraint, Pattern.CASE_INSENSITIVE | Pattern.LITERAL)
.matcher(objectId)
.find() ||
displayName != null &&
Pattern
.compile(constraint, Pattern.CASE_INSENSITIVE | Pattern.LITERAL)
.matcher(displayName)
.find();
}
}

View file

@ -0,0 +1,247 @@
/*
* Nextcloud Talk application
*
* @author Mario Danic
* @author Marcel Hibbe
* @author Andy Scherzinger
* Copyright (C) 2021-2022 Andy Scherzinger <info@andy-scherzinger.de>
* Copyright (C) 2022 Marcel Hibbe <dev@mhibbe.de>
* Copyright (C) 2017 Mario Danic <mario@lovelyhq.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.talk.adapters.items
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.res.ResourcesCompat
import com.nextcloud.talk.R
import com.nextcloud.talk.adapters.items.ParticipantItem.ParticipantItemViewHolder
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.extensions.loadFederatedUserAvatar
import com.nextcloud.talk.extensions.loadUserAvatar
import com.nextcloud.talk.models.json.mention.Mention
import com.nextcloud.talk.models.json.status.StatusType
import com.nextcloud.talk.ui.StatusDrawable
import com.nextcloud.talk.ui.theme.ViewThemeUtils
import com.nextcloud.talk.utils.DisplayUtils
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFilterable
import eu.davidea.flexibleadapter.items.IFlexible
import java.util.Objects
import java.util.regex.Pattern
class MentionAutocompleteItem(
mention: Mention,
private val currentUser: User,
private val context: Context,
@JvmField val roomToken: String,
private val viewThemeUtils: ViewThemeUtils
) : AbstractFlexibleItem<ParticipantItemViewHolder>(), IFilterable<String?> {
@JvmField
var source: String?
@JvmField
val mentionId: String?
@JvmField
val objectId: String?
@JvmField
val displayName: String?
private val status: String?
private val statusIcon: String?
private val statusMessage: String?
init {
mentionId = mention.mentionId
objectId = mention.id
displayName = mention.label
source = mention.source
status = mention.status
statusIcon = mention.statusIcon
statusMessage = mention.statusMessage
}
override fun equals(o: Any?): Boolean {
return if (o is MentionAutocompleteItem) {
objectId == o.objectId && displayName == o.displayName
} else {
false
}
}
override fun hashCode(): Int {
return Objects.hash(objectId, displayName)
}
override fun getLayoutRes(): Int {
return R.layout.rv_item_conversation_info_participant
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<*>?>?): ParticipantItemViewHolder {
return ParticipantItemViewHolder(view, adapter)
}
@SuppressLint("SetTextI18n")
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<*>?>,
holder: ParticipantItemViewHolder,
position: Int,
payloads: List<Any>
) {
holder.binding.nameText.setTextColor(
ResourcesCompat.getColor(
context.resources,
R.color.conversation_item_header,
null
)
)
if (adapter.hasFilter()) {
viewThemeUtils.talk.themeAndHighlightText(
holder.binding.nameText,
displayName,
adapter.getFilter(String::class.java).toString()
)
viewThemeUtils.talk.themeAndHighlightText(
holder.binding.secondaryText,
"@$objectId",
adapter.getFilter(String::class.java).toString()
)
} else {
holder.binding.nameText.text = displayName
holder.binding.secondaryText.text = "@$objectId"
}
var avatarId = objectId
when (source) {
SOURCE_CALLS -> {
run {}
run {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
holder.binding.avatarView.loadUserAvatar(
viewThemeUtils.talk.themePlaceholderAvatar(
holder.binding.avatarView,
R.drawable.ic_avatar_group
)
)
} else {
holder.binding.avatarView.loadUserAvatar(R.drawable.ic_circular_group)
}
}
}
SOURCE_GROUPS -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
holder.binding.avatarView.loadUserAvatar(
viewThemeUtils.talk.themePlaceholderAvatar(
holder.binding.avatarView,
R.drawable.ic_avatar_group
)
)
} else {
holder.binding.avatarView.loadUserAvatar(R.drawable.ic_circular_group)
}
}
SOURCE_FEDERATION -> {
val darkTheme = if (DisplayUtils.isDarkModeOn(context)) 1 else 0
holder.binding.avatarView.loadFederatedUserAvatar(
currentUser,
currentUser.baseUrl!!,
roomToken,
avatarId!!,
darkTheme,
true,
false
)
}
SOURCE_GUESTS -> {
run { avatarId = displayName }
run { holder.binding.avatarView.loadUserAvatar(currentUser, avatarId!!, true, false) }
}
else -> {
holder.binding.avatarView.loadUserAvatar(currentUser, avatarId!!, true, false)
}
}
drawStatus(holder)
}
private fun drawStatus(holder: ParticipantItemViewHolder) {
val size = DisplayUtils.convertDpToPixel(STATUS_SIZE_IN_DP, context)
holder.binding.userStatusImage.setImageDrawable(
StatusDrawable(
status,
NO_ICON,
size,
context.resources.getColor(R.color.bg_default),
context
)
)
if (statusMessage != null) {
holder.binding.conversationInfoStatusMessage.text = statusMessage
alignUsernameVertical(holder, 0f)
} else {
holder.binding.conversationInfoStatusMessage.text = ""
alignUsernameVertical(holder, 10f)
}
if (!statusIcon.isNullOrEmpty()) {
holder.binding.participantStatusEmoji.setText(statusIcon)
} else {
holder.binding.participantStatusEmoji.visibility = View.GONE
}
if (status != null && status == StatusType.DND.string) {
if (statusMessage.isNullOrEmpty()) {
holder.binding.conversationInfoStatusMessage.setText(R.string.dnd)
}
} else if (status != null && status == StatusType.AWAY.string) {
if (statusMessage.isNullOrEmpty()) {
holder.binding.conversationInfoStatusMessage.setText(R.string.away)
}
}
}
private fun alignUsernameVertical(holder: ParticipantItemViewHolder, densityPixelsFromTop: Float) {
val layoutParams = holder.binding.nameText.layoutParams as ConstraintLayout.LayoutParams
layoutParams.topMargin = DisplayUtils.convertDpToPixel(densityPixelsFromTop, context).toInt()
holder.binding.nameText.setLayoutParams(layoutParams)
}
override fun filter(constraint: String?): Boolean {
return objectId != null &&
Pattern
.compile(constraint, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
.matcher(objectId)
.find() ||
displayName != null &&
Pattern
.compile(constraint, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
.matcher(displayName)
.find()
}
companion object {
private const val STATUS_SIZE_IN_DP = 9f
private const val NO_ICON = ""
const val SOURCE_CALLS = "calls"
const val SOURCE_GUESTS = "guests"
const val SOURCE_GROUPS = "groups"
const val SOURCE_FEDERATION = "federated_users"
}
}

View file

@ -301,7 +301,7 @@ public class ParticipantItem extends AbstractFlexibleItem<ParticipantItem.Partic
.matcher(participant.getCalculatedActorId().trim()).find());
}
static class ParticipantItemViewHolder extends FlexibleViewHolder {
public static class ParticipantItemViewHolder extends FlexibleViewHolder {
RvItemConversationInfoParticipantBinding binding;

View file

@ -37,6 +37,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedA
import com.nextcloud.talk.databinding.ItemCustomIncomingLinkPreviewMessageBinding
import com.nextcloud.talk.extensions.loadBotsAvatar
import com.nextcloud.talk.extensions.loadChangelogBotAvatar
import com.nextcloud.talk.extensions.loadFederatedUserAvatar
import com.nextcloud.talk.models.json.chat.ChatMessage
import com.nextcloud.talk.ui.theme.ViewThemeUtils
import com.nextcloud.talk.utils.ApiUtils
@ -172,6 +173,8 @@ class IncomingLinkPreviewMessageViewHolder(incomingView: View, payload: Any) :
binding.messageUserAvatar.loadChangelogBotAvatar()
} else if (message.actorType == "bots") {
binding.messageUserAvatar.loadBotsAvatar()
} else if (message.actorType == "federated_users") {
binding.messageUserAvatar.loadFederatedUserAvatar(message)
}
}

View file

@ -47,6 +47,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedA
import com.nextcloud.talk.databinding.ItemCustomIncomingLocationMessageBinding
import com.nextcloud.talk.extensions.loadBotsAvatar
import com.nextcloud.talk.extensions.loadChangelogBotAvatar
import com.nextcloud.talk.extensions.loadFederatedUserAvatar
import com.nextcloud.talk.models.json.chat.ChatMessage
import com.nextcloud.talk.ui.theme.ViewThemeUtils
import com.nextcloud.talk.utils.ApiUtils
@ -148,6 +149,8 @@ class IncomingLocationMessageViewHolder(incomingView: View, payload: Any) :
binding.messageUserAvatar.loadChangelogBotAvatar()
} else if (message.actorType == "bots") {
binding.messageUserAvatar.loadBotsAvatar()
} else if (message.actorType == "federated_users") {
binding.messageUserAvatar.loadFederatedUserAvatar(message)
}
} else {
if (message.isOneToOneConversation || message.isFormerOneToOneConversation) {

View file

@ -36,6 +36,7 @@ import com.nextcloud.talk.chat.ChatActivity
import com.nextcloud.talk.databinding.ItemCustomIncomingPollMessageBinding
import com.nextcloud.talk.extensions.loadBotsAvatar
import com.nextcloud.talk.extensions.loadChangelogBotAvatar
import com.nextcloud.talk.extensions.loadFederatedUserAvatar
import com.nextcloud.talk.models.json.chat.ChatMessage
import com.nextcloud.talk.polls.ui.PollMainDialogFragment
import com.nextcloud.talk.ui.theme.ViewThemeUtils
@ -179,6 +180,8 @@ class IncomingPollMessageViewHolder(incomingView: View, payload: Any) :
binding.messageUserAvatar.loadChangelogBotAvatar()
} else if (message.actorType == "bots") {
binding.messageUserAvatar.loadBotsAvatar()
} else if (message.actorType == "federated_users") {
binding.messageUserAvatar.loadFederatedUserAvatar(message)
}
}

View file

@ -41,6 +41,7 @@ import com.nextcloud.talk.chat.ChatActivity
import com.nextcloud.talk.databinding.ItemCustomIncomingTextMessageBinding
import com.nextcloud.talk.extensions.loadBotsAvatar
import com.nextcloud.talk.extensions.loadChangelogBotAvatar
import com.nextcloud.talk.extensions.loadFederatedUserAvatar
import com.nextcloud.talk.models.json.chat.ChatMessage
import com.nextcloud.talk.ui.theme.ViewThemeUtils
import com.nextcloud.talk.utils.ApiUtils
@ -182,6 +183,8 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) :
binding.messageUserAvatar.loadChangelogBotAvatar()
} else if (message.actorType == "bots") {
binding.messageUserAvatar.loadBotsAvatar()
} else if (message.actorType == "federated_users") {
binding.messageUserAvatar.loadFederatedUserAvatar(message)
}
}

View file

@ -45,6 +45,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
import com.nextcloud.talk.databinding.ItemCustomIncomingVoiceMessageBinding
import com.nextcloud.talk.extensions.loadChangelogBotAvatar
import com.nextcloud.talk.extensions.loadFederatedUserAvatar
import com.nextcloud.talk.models.json.chat.ChatMessage
import com.nextcloud.talk.ui.theme.ViewThemeUtils
import com.nextcloud.talk.utils.ApiUtils
@ -285,6 +286,8 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
)
binding.messageUserAvatar.visibility = View.VISIBLE
binding.messageUserAvatar.setImageDrawable(drawable)
} else if (message.actorType == "federated_users") {
binding.messageUserAvatar.loadFederatedUserAvatar(message)
}
}

View file

@ -49,6 +49,7 @@ import com.nextcloud.talk.components.filebrowser.webdav.ReadFilesystemOperation
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.databinding.ReactionsInsideMessageBinding
import com.nextcloud.talk.extensions.loadChangelogBotAvatar
import com.nextcloud.talk.extensions.loadFederatedUserAvatar
import com.nextcloud.talk.models.json.chat.ChatMessage
import com.nextcloud.talk.ui.theme.ViewThemeUtils
import com.nextcloud.talk.users.UserManager
@ -109,7 +110,7 @@ abstract class PreviewMessageViewHolder(itemView: View?, payload: Any?) :
@Suppress("NestedBlockDepth", "ComplexMethod", "LongMethod")
override fun onBind(message: ChatMessage) {
super.onBind(message)
image.minimumHeight = DisplayUtils.convertDpToPixel(MIN_IMAGE_HEIGHT, context).toInt()
image.minimumHeight = DisplayUtils.convertDpToPixel(MIN_IMAGE_HEIGHT, context!!).toInt()
time.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
@ -194,6 +195,8 @@ abstract class PreviewMessageViewHolder(itemView: View?, payload: Any?) :
}
if (ACTOR_TYPE_BOTS == message.actorType && ACTOR_ID_CHANGELOG == message.actorId) {
userAvatar.loadChangelogBotAvatar()
} else if (message.actorType == "federated_users") {
userAvatar.loadFederatedUserAvatar(message)
}
}
}

View file

@ -90,7 +90,7 @@ class SystemMessageViewHolder(itemView: View) : MessageHolders.IncomingTextMessa
} else {
individualMap["name"]
}
messageString = DisplayUtils.searchAndColor(messageString, searchText, mentionColor)
messageString = DisplayUtils.searchAndColor(messageString, searchText!!, mentionColor)
}
}
}

View file

@ -27,7 +27,6 @@ import android.text.Editable;
import android.text.Spanned;
import android.widget.EditText;
import third.parties.fresco.BetterImageSpan;
import com.nextcloud.talk.R;
import com.nextcloud.talk.data.user.model.User;
import com.nextcloud.talk.models.json.mention.Mention;
@ -39,7 +38,10 @@ import com.otaliastudios.autocomplete.AutocompleteCallback;
import com.vanniktech.emoji.EmojiRange;
import com.vanniktech.emoji.Emojis;
import java.util.Objects;
import kotlin.OptIn;
import third.parties.fresco.BetterImageSpan;
public class MentionAutocompleteCallback implements AutocompleteCallback<Mention> {
private final ViewThemeUtils viewThemeUtils;
@ -66,26 +68,31 @@ public class MentionAutocompleteCallback implements AutocompleteCallback<Mention
}
String replacement = item.getLabel();
StringBuilder replacementStringBuilder = new StringBuilder(item.getLabel());
StringBuilder replacementStringBuilder = new StringBuilder(Objects.requireNonNull(item.getLabel()));
for (EmojiRange emojiRange : Emojis.emojis(replacement)) {
replacementStringBuilder.delete(emojiRange.range.getStart(), emojiRange.range.getEndInclusive());
}
editable.replace(range.getStart(), range.getEnd(), replacementStringBuilder + " ");
String charSequence = " ";
editable.replace(range.getStart(), range.getEnd(), charSequence + replacementStringBuilder + " ");
String id;
if (item.getMentionId() != null) id = item.getMentionId(); else id = item.getId();
Spans.MentionChipSpan mentionChipSpan =
new Spans.MentionChipSpan(DisplayUtils.getDrawableForMentionChipSpan(context,
item.getId(),
item.getRoomToken(),
item.getLabel(),
conversationUser,
item.getSource(),
R.xml.chip_you,
editText,
viewThemeUtils),
viewThemeUtils,
"federated_users".equals(item.getSource())),
BetterImageSpan.ALIGN_CENTER,
item.getId(), item.getLabel());
id, item.getLabel());
editable.setSpan(mentionChipSpan,
range.getStart(),
range.getStart() + replacementStringBuilder.length(),
range.getStart() + charSequence.length(),
range.getStart() + replacementStringBuilder.length() + charSequence.length(),
Spanned.SPAN_INCLUSIVE_INCLUSIVE);

View file

@ -2142,7 +2142,7 @@ class ChatActivity :
true
)
if (DisplayUtils.isDarkModeOn(supportActionBar?.themedContext)) {
if (DisplayUtils.isDarkModeOn(supportActionBar?.themedContext!!)) {
url = "$url/dark"
}
@ -3584,6 +3584,7 @@ class ChatActivity :
mentionSpan = mentionSpans[i]
var mentionId = mentionSpan.id
if (mentionId.contains(" ") ||
mentionId.contains("@") ||
mentionId.startsWith("guest/") ||
mentionId.startsWith("group/")
) {
@ -3798,6 +3799,7 @@ class ChatActivity :
chatMessage.isFormerOneToOneConversation =
(currentConversation?.type == ConversationType.FORMER_ONE_TO_ONE)
chatMessage.activeUser = conversationUser
chatMessage.roomToken = roomToken
}
if (adapter != null) {

View file

@ -46,6 +46,7 @@ import com.nextcloud.talk.R
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.domain.ConversationType
import com.nextcloud.talk.models.json.chat.ChatMessage
import com.nextcloud.talk.models.json.conversations.Conversation
import com.nextcloud.talk.ui.theme.ViewThemeUtils
import com.nextcloud.talk.utils.ApiUtils
@ -69,6 +70,7 @@ fun ImageView.loadConversationAvatar(
)
}
@Suppress("ReturnCount")
fun ImageView.loadConversationAvatar(
user: User,
conversation: ConversationModel,
@ -126,6 +128,44 @@ fun ImageView.loadUserAvatar(
return loadAvatarInternal(user, imageRequestUri, ignoreCache, null)
}
fun ImageView.loadFederatedUserAvatar(message: ChatMessage): io.reactivex.disposables.Disposable {
val cloudId = message.actorId!!
val darkTheme = if (DisplayUtils.isDarkModeOn(context)) 1 else 0
val ignoreCache = false
val requestBigSize = true
return loadFederatedUserAvatar(
message.activeUser!!,
message.activeUser!!.baseUrl!!,
message.roomToken,
cloudId,
darkTheme,
requestBigSize,
ignoreCache
)
}
@Suppress("LongParameterList")
fun ImageView.loadFederatedUserAvatar(
user: User,
baseUrl: String,
token: String,
cloudId: String,
darkTheme: Int,
requestBigSize: Boolean = true,
ignoreCache: Boolean
): io.reactivex.disposables.Disposable {
val imageRequestUri = ApiUtils.getUrlForFederatedAvatar(
baseUrl,
token,
cloudId,
darkTheme,
requestBigSize
)
Log.d(TAG, "federated avatar URL: $imageRequestUri")
return loadAvatarInternal(user, imageRequestUri, ignoreCache, null)
}
@OptIn(ExperimentalCoilApi::class)
private fun ImageView.loadAvatarInternal(
user: User?,

View file

@ -281,7 +281,7 @@ class LocationPickerActivity :
locationOverlay.setPersonHotspot(PERSON_HOT_SPOT_X, PERSON_HOT_SPOT_Y)
locationOverlay.setPersonIcon(
DisplayUtils.getBitmap(
ResourcesCompat.getDrawable(resources!!, R.drawable.current_location_circle, null)
ResourcesCompat.getDrawable(resources!!, R.drawable.current_location_circle, null)!!
)
)
binding.map.overlays.add(locationOverlay)

View file

@ -159,7 +159,9 @@ data class ChatMessage(
var hiddenByCollapse: Boolean = false,
var openWhenDownloaded: Boolean = true
var openWhenDownloaded: Boolean = true,
var roomToken: String = ""
) : Parcelable, MessageContentType, MessageContentType.Image {

View file

@ -23,12 +23,15 @@ package com.nextcloud.talk.models.json.mention
import android.os.Parcelable
import com.bluelinelabs.logansquare.annotation.JsonField
import com.bluelinelabs.logansquare.annotation.JsonIgnore
import com.bluelinelabs.logansquare.annotation.JsonObject
import kotlinx.parcelize.Parcelize
@Parcelize
@JsonObject
data class Mention(
@JsonField(name = ["mentionId"])
var mentionId: String?,
@JsonField(name = ["id"])
var id: String?,
@JsonField(name = ["label"])
@ -41,8 +44,10 @@ data class Mention(
@JsonField(name = ["statusIcon"])
var statusIcon: String?,
@JsonField(name = ["statusMessage"])
var statusMessage: String?
var statusMessage: String?,
@JsonIgnore
var roomToken: String?
) : Parcelable {
// This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
constructor() : this(null, null, null, null, null, null)
constructor() : this(null, null, null, null, null, null, null, null)
}

View file

@ -131,53 +131,54 @@ public class MentionAutocompletePresenter extends RecyclerViewPresenter<Mention>
ApiUtils.getCredentials(currentUser.getUsername(), currentUser.getToken()),
ApiUtils.getUrlForMentionSuggestions(chatApiVersion, currentUser.getBaseUrl(), roomToken),
queryString, 5, queryMap)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.retry(3)
.subscribe(new Observer<MentionOverall>() {
@Override
public void onSubscribe(@NonNull Disposable d) {
// no actions atm
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.retry(3)
.subscribe(new Observer<MentionOverall>() {
@Override
public void onSubscribe(@NonNull Disposable d) {
// no actions atm
}
@Override
public void onNext(@NonNull MentionOverall mentionOverall) {
List<Mention> mentionsList = mentionOverall.getOcs().getData();
@Override
public void onNext(@NonNull MentionOverall mentionOverall) {
List<Mention> mentionsList = mentionOverall.getOcs().getData();
if (mentionsList.size() == 0) {
adapter.clear();
} else {
List<AbstractFlexibleItem> internalAbstractFlexibleItemList =
new ArrayList<>(mentionsList.size());
for (Mention mention : mentionsList) {
internalAbstractFlexibleItemList.add(
new MentionAutocompleteItem(
mention,
currentUser,
context,
viewThemeUtils));
}
if (adapter.getItemCount() != 0) {
adapter.clear();
}
adapter.updateDataSet(internalAbstractFlexibleItemList);
}
}
@SuppressLint("LongLogTag")
@Override
public void onError(@NonNull Throwable e) {
if (mentionsList.size() == 0) {
adapter.clear();
Log.e(TAG, "failed to get MentionAutocompleteSuggestions", e);
}
} else {
List<AbstractFlexibleItem> internalAbstractFlexibleItemList =
new ArrayList<>(mentionsList.size());
for (Mention mention : mentionsList) {
internalAbstractFlexibleItemList.add(
new MentionAutocompleteItem(
mention,
currentUser,
context,
roomToken,
viewThemeUtils));
}
@Override
public void onComplete() {
// no actions atm
if (adapter.getItemCount() != 0) {
adapter.clear();
}
adapter.updateDataSet(internalAbstractFlexibleItemList);
}
});
}
@SuppressLint("LongLogTag")
@Override
public void onError(@NonNull Throwable e) {
adapter.clear();
Log.e(TAG, "failed to get MentionAutocompleteSuggestions", e);
}
@Override
public void onComplete() {
// no actions atm
}
});
}
@Override
@ -185,9 +186,14 @@ public class MentionAutocompletePresenter extends RecyclerViewPresenter<Mention>
Mention mention = new Mention();
MentionAutocompleteItem mentionAutocompleteItem = (MentionAutocompleteItem) adapter.getItem(position);
if (mentionAutocompleteItem != null) {
mention.setId(mentionAutocompleteItem.getObjectId());
mention.setLabel(mentionAutocompleteItem.getDisplayName());
mention.setSource(mentionAutocompleteItem.getSource());
String mentionId = mentionAutocompleteItem.mentionId;
if (mentionId != null) {
mention.setMentionId(mentionId);
}
mention.setId(mentionAutocompleteItem.objectId);
mention.setLabel(mentionAutocompleteItem.displayName);
mention.setSource(mentionAutocompleteItem.source);
mention.setRoomToken(mentionAutocompleteItem.roomToken);
dispatchClick(mention);
}
return true;

View file

@ -383,6 +383,19 @@ object ApiUtils {
return baseUrl + "/index.php/avatar/" + Uri.encode(name) + "/" + avatarSize
}
@JvmStatic
fun getUrlForFederatedAvatar(
baseUrl: String,
token: String,
cloudId: String,
darkTheme: Int,
requestBigSize: Boolean
): String {
val avatarSize = if (requestBigSize) AVATAR_SIZE_BIG else AVATAR_SIZE_SMALL
val url = "$baseUrl$OCS_API_VERSION$SPREED_API_VERSION/proxy/$token/user-avatar/$avatarSize"
return "$url?cloudId=$cloudId&darkTheme=$darkTheme"
}
@JvmStatic
fun getUrlForGuestAvatar(baseUrl: String?, name: String?, requestBigSize: Boolean): String {
val avatarSize = if (requestBigSize) AVATAR_SIZE_BIG else AVATAR_SIZE_SMALL

View file

@ -1,563 +0,0 @@
/*
* Nextcloud Talk application
*
* @author Mario Danic
* @author Andy Scherzinger
* @author Tim Krüger
* Copyright (C) 2022 Tim Krüger <t@timkrueger.me>
* Copyright (C) 2021 Andy Scherzinger <info@andy-scherzinger.de>
* Copyright (C) 2017-2020 Mario Danic <mario@lovelyhq.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.talk.utils;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Build;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextPaint;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.text.method.LinkMovementMethod;
import android.text.style.AbsoluteSizeSpan;
import android.text.style.ClickableSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan;
import android.util.TypedValue;
import android.view.View;
import android.view.Window;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.TextView;
import com.google.android.material.chip.ChipDrawable;
import com.nextcloud.talk.R;
import com.nextcloud.talk.application.NextcloudTalkApplication;
import com.nextcloud.talk.data.user.model.User;
import com.nextcloud.talk.events.UserMentionClickEvent;
import com.nextcloud.talk.extensions.ImageViewExtensionsKt;
import com.nextcloud.talk.ui.theme.ViewThemeUtils;
import com.nextcloud.talk.utils.text.Spans;
import org.greenrobot.eventbus.EventBus;
import java.text.DateFormat;
import java.util.Date;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import androidx.annotation.ColorInt;
import androidx.annotation.ColorRes;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.annotation.XmlRes;
import androidx.core.content.ContextCompat;
import androidx.core.content.res.ResourcesCompat;
import androidx.core.graphics.ColorUtils;
import androidx.core.graphics.drawable.DrawableCompat;
import androidx.emoji2.text.EmojiCompat;
import coil.Coil;
import coil.request.ImageRequest;
import coil.target.Target;
import coil.transform.CircleCropTransformation;
import third.parties.fresco.BetterImageSpan;
import static com.nextcloud.talk.utils.FileSortOrder.SORT_A_TO_Z_ID;
import static com.nextcloud.talk.utils.FileSortOrder.SORT_BIG_TO_SMALL_ID;
import static com.nextcloud.talk.utils.FileSortOrder.SORT_NEW_TO_OLD_ID;
import static com.nextcloud.talk.utils.FileSortOrder.SORT_OLD_TO_NEW_ID;
import static com.nextcloud.talk.utils.FileSortOrder.SORT_SMALL_TO_BIG_ID;
import static com.nextcloud.talk.utils.FileSortOrder.SORT_Z_TO_A_ID;
public class DisplayUtils {
private static final String TAG = DisplayUtils.class.getSimpleName();
private static final int INDEX_LUMINATION = 2;
private static final double MAX_LIGHTNESS = 0.92;
private static final String TWITTER_HANDLE_PREFIX = "@";
private static final String HTTP_PROTOCOL = "http://";
private static final String HTTPS_PROTOCOL = "https://";
private static final int DATE_TIME_PARTS_SIZE = 2;
public static Boolean isDarkModeOn(Context context) {
int currentNightMode = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
return currentNightMode == Configuration.UI_MODE_NIGHT_YES;
}
public static void setClickableString(String string, String url, TextView textView) {
SpannableString spannableString = new SpannableString(string);
spannableString.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
NextcloudTalkApplication
.Companion
.getSharedApplication()
.getApplicationContext()
.startActivity(browserIntent);
}
@Override
public void updateDrawState(@NonNull TextPaint ds) {
super.updateDrawState(ds);
ds.setUnderlineText(false);
}
}, 0, string.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
textView.setText(spannableString);
textView.setMovementMethod(LinkMovementMethod.getInstance());
}
public static Bitmap getBitmap(Drawable drawable) {
Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(),
drawable.getIntrinsicHeight(),
Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
drawable.draw(canvas);
return bitmap;
}
public static float convertDpToPixel(float dp, Context context) {
return Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp,
context.getResources().getDisplayMetrics()) + 0.5f);
}
public static float convertPixelToDp(float px, Context context) {
return px / context.getResources().getDisplayMetrics().density;
}
public static Drawable getTintedDrawable(Resources res, @DrawableRes int drawableResId, @ColorRes int colorResId) {
Drawable drawable = ResourcesCompat.getDrawable(res, drawableResId, null);
int color = res.getColor(colorResId);
if (drawable != null) {
drawable.setTint(color);
}
return drawable;
}
public static Drawable getDrawableForMentionChipSpan(Context context,
String id,
CharSequence label,
User conversationUser,
String type,
@XmlRes int chipResource,
@Nullable EditText emojiEditText,
ViewThemeUtils viewThemeUtils) {
ChipDrawable chip = ChipDrawable.createFromResource(context, chipResource);
chip.setText(EmojiCompat.get().process(label));
chip.setEllipsize(TextUtils.TruncateAt.MIDDLE);
if (chipResource == R.xml.chip_you) {
viewThemeUtils.material.colorChipDrawable(context, chip);
}
Configuration config = context.getResources().getConfiguration();
chip.setLayoutDirection(config.getLayoutDirection());
int drawable;
boolean isCallOrGroup =
"call".equals(type) || "calls".equals(type) || "groups".equals(type) || "user-group".equals(type);
if (!isCallOrGroup) {
if (chipResource == R.xml.chip_you) {
drawable = R.drawable.mention_chip;
} else {
drawable = R.drawable.accent_circle;
}
chip.setChipIconResource(drawable);
} else {
chip.setChipIconResource(R.drawable.ic_circular_group);
}
chip.setBounds(0, 0, chip.getIntrinsicWidth(), chip.getIntrinsicHeight());
if (!isCallOrGroup) {
String url = ApiUtils.getUrlForAvatar(conversationUser.getBaseUrl(), id, true);
if ("guests".equals(type) || "guest".equals(type)) {
url = ApiUtils.getUrlForGuestAvatar(
conversationUser.getBaseUrl(),
String.valueOf(label), true);
}
ImageRequest imageRequest = new ImageRequest.Builder(context)
.data(url)
.crossfade(true)
.transformations(new CircleCropTransformation())
.target(new Target() {
@Override
public void onStart(@Nullable Drawable drawable) {
}
@Override
public void onError(@Nullable Drawable drawable) {
}
@Override
public void onSuccess(@NonNull Drawable drawable) {
chip.setChipIcon(drawable);
// A hack to refresh the chip icon
if (emojiEditText != null) {
emojiEditText.post(() -> emojiEditText.setTextKeepState(
emojiEditText.getText(),
TextView.BufferType.SPANNABLE));
}
}
})
.build();
Coil.imageLoader(context).enqueue(imageRequest);
}
return chip;
}
public static Spannable searchAndReplaceWithMentionSpan(String key, Context context, Spanned text,
String id, String label, String type,
User conversationUser,
@XmlRes int chipXmlRes,
ViewThemeUtils viewThemeUtils) {
Spannable spannableString = new SpannableString(text);
String stringText = text.toString();
String keyWithBrackets = "{" + key + "}";
Matcher m = Pattern.compile(keyWithBrackets, Pattern.CASE_INSENSITIVE | Pattern.LITERAL | Pattern.MULTILINE)
.matcher(spannableString);
ClickableSpan clickableSpan = new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
EventBus.getDefault().post(new UserMentionClickEvent(id));
}
};
int lastStartIndex = -1;
Spans.MentionChipSpan mentionChipSpan;
while (m.find()) {
int start = stringText.indexOf(m.group(), lastStartIndex);
int end = start + m.group().length();
lastStartIndex = end;
Drawable drawableForChip = DisplayUtils.getDrawableForMentionChipSpan(context,
id,
label,
conversationUser,
type,
chipXmlRes,
null,
viewThemeUtils);
mentionChipSpan = new Spans.MentionChipSpan(drawableForChip,
BetterImageSpan.ALIGN_CENTER,
id,
label);
spannableString.setSpan(mentionChipSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
if (chipXmlRes == R.xml.chip_you) {
spannableString.setSpan(
viewThemeUtils.talk.themeForegroundColorSpan(context),
start,
end,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if ("user".equals(type) && !conversationUser.getUserId().equals(id)) {
spannableString.setSpan(clickableSpan, start, end, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
}
}
return spannableString;
}
public static Spannable searchAndColor(Spannable text, String searchText, @ColorInt int color) {
Spannable spannableString = new SpannableString(text);
String stringText = text.toString();
if (TextUtils.isEmpty(text) || TextUtils.isEmpty(searchText)) {
return spannableString;
}
Matcher m = Pattern.compile(searchText,
Pattern.CASE_INSENSITIVE | Pattern.LITERAL | Pattern.MULTILINE)
.matcher(spannableString);
int textSize = NextcloudTalkApplication.Companion.getSharedApplication().getResources().getDimensionPixelSize(R.dimen
.chat_text_size);
int lastStartIndex = -1;
while (m.find()) {
int start = stringText.indexOf(m.group(), lastStartIndex);
int end = start + m.group().length();
lastStartIndex = end;
spannableString.setSpan(new ForegroundColorSpan(color), start, end,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
spannableString.setSpan(new StyleSpan(Typeface.BOLD), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
spannableString.setSpan(new AbsoluteSizeSpan(textSize), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
return spannableString;
}
public static Drawable getMessageSelector(@ColorInt int normalColor,
@ColorInt int selectedColor,
@ColorInt int pressedColor,
@DrawableRes int shape) {
Drawable vectorDrawable = ContextCompat.getDrawable(NextcloudTalkApplication.Companion.getSharedApplication()
.getApplicationContext(),
shape);
Drawable drawable = DrawableCompat.wrap(vectorDrawable).mutate();
DrawableCompat.setTintList(
drawable,
new ColorStateList(
new int[][]{
new int[]{android.R.attr.state_selected},
new int[]{android.R.attr.state_pressed},
new int[]{-android.R.attr.state_pressed, -android.R.attr.state_selected}
},
new int[]{selectedColor, pressedColor, normalColor}
));
return drawable;
}
/**
* Sets the color of the status bar to {@code color}.
*
* @param activity activity
* @param color the color
*/
public static void applyColorToStatusBar(Activity activity, @ColorInt int color) {
Window window = activity.getWindow();
boolean isLightTheme = lightTheme(color);
if (window != null) {
View decor = window.getDecorView();
if (isLightTheme) {
int systemUiFlagLightStatusBar;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
systemUiFlagLightStatusBar = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR |
View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR;
} else {
systemUiFlagLightStatusBar = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
}
decor.setSystemUiVisibility(systemUiFlagLightStatusBar);
} else {
decor.setSystemUiVisibility(0);
}
window.setStatusBarColor(color);
}
}
/**
* Tests if light color is set
*
* @param color the color
* @return true if primaryColor is lighter than MAX_LIGHTNESS
*/
@SuppressWarnings("CLI_CONSTANT_LIST_INDEX")
public static boolean lightTheme(int color) {
float[] hsl = colorToHSL(color);
// spotbugs dislikes fixed index access
// which is enforced by having such an
// array from Android-API itself
return hsl[INDEX_LUMINATION] >= MAX_LIGHTNESS;
}
private static float[] colorToHSL(int color) {
float[] hsl = new float[3];
ColorUtils.RGBToHSL(Color.red(color), Color.green(color), Color.blue(color), hsl);
return hsl;
}
public static void applyColorToNavigationBar(Window window, @ColorInt int color) {
window.setNavigationBarColor(color);
}
/**
* beautifies a given URL by removing any http/https protocol prefix.
*
* @param url to be beautified url
* @return beautified url
*/
public static String beautifyURL(@Nullable String url) {
if (TextUtils.isEmpty(url)) {
return "";
}
if (url.length() >= 7 && HTTP_PROTOCOL.equalsIgnoreCase(url.substring(0, 7))) {
return url.substring(HTTP_PROTOCOL.length()).trim();
}
if (url.length() >= 8 && HTTPS_PROTOCOL.equalsIgnoreCase(url.substring(0, 8))) {
return url.substring(HTTPS_PROTOCOL.length()).trim();
}
return url.trim();
}
/**
* beautifies a given twitter handle by prefixing it with an @ in case it is missing.
*
* @param handle to be beautified twitter handle
* @return beautified twitter handle
*/
public static String beautifyTwitterHandle(@Nullable String handle) {
if (handle != null) {
String trimmedHandle = handle.trim();
if (TextUtils.isEmpty(trimmedHandle)) {
return "";
}
if (trimmedHandle.startsWith(TWITTER_HANDLE_PREFIX)) {
return trimmedHandle;
} else {
return TWITTER_HANDLE_PREFIX + trimmedHandle;
}
} else {
return "";
}
}
public static void loadAvatarImage(User user, ImageView avatarImageView, boolean deleteCache) {
if (user != null && avatarImageView != null) {
String avatarId;
if (!TextUtils.isEmpty(user.getUserId())) {
avatarId = user.getUserId();
} else {
avatarId = user.getUsername();
}
if (avatarId != null) {
ImageViewExtensionsKt.loadUserAvatar(avatarImageView, user, avatarId, true, deleteCache);
}
}
}
public static @StringRes
int getSortOrderStringId(FileSortOrder sortOrder) {
switch (sortOrder.getName()) {
case SORT_Z_TO_A_ID:
return R.string.menu_item_sort_by_name_z_a;
case SORT_NEW_TO_OLD_ID:
return R.string.menu_item_sort_by_date_newest_first;
case SORT_OLD_TO_NEW_ID:
return R.string.menu_item_sort_by_date_oldest_first;
case SORT_BIG_TO_SMALL_ID:
return R.string.menu_item_sort_by_size_biggest_first;
case SORT_SMALL_TO_BIG_ID:
return R.string.menu_item_sort_by_size_smallest_first;
case SORT_A_TO_Z_ID:
default:
return R.string.menu_item_sort_by_name_a_z;
}
}
/**
* calculates the relative time string based on the given modification timestamp.
*
* @param context the app's context
* @param modificationTimestamp the UNIX timestamp of the file modification time in milliseconds.
* @return a relative time string
*/
public static CharSequence getRelativeTimestamp(Context context, long modificationTimestamp, boolean showFuture) {
return getRelativeDateTimeString(context,
modificationTimestamp,
DateUtils.SECOND_IN_MILLIS,
DateUtils.WEEK_IN_MILLIS,
0,
showFuture);
}
public static CharSequence getRelativeDateTimeString(Context c,
long time,
long minResolution,
long transitionResolution,
int flags,
boolean showFuture) {
CharSequence dateString = "";
// in Future
if (!showFuture && time > System.currentTimeMillis()) {
return DisplayUtils.unixTimeToHumanReadable(time);
}
// < 60 seconds -> seconds ago
long diff = System.currentTimeMillis() - time;
if (diff > 0 && diff < 60 * 1000 && minResolution == DateUtils.SECOND_IN_MILLIS) {
return c.getString(R.string.secondsAgo);
} else {
dateString = DateUtils.getRelativeDateTimeString(c, time, minResolution, transitionResolution, flags);
}
String[] parts = dateString.toString().split(",");
if (parts.length == DATE_TIME_PARTS_SIZE) {
if (parts[1].contains(":") && !parts[0].contains(":")) {
return parts[0];
} else if (parts[0].contains(":") && !parts[1].contains(":")) {
return parts[1];
}
}
// dateString contains unexpected format. fallback: use relative date time string from android api as is.
return dateString.toString();
}
/**
* Converts Unix time to human readable format
*
* @param milliseconds that have passed since 01/01/1970
* @return The human readable time for the users locale
*/
public static String unixTimeToHumanReadable(long milliseconds) {
Date date = new Date(milliseconds);
DateFormat df = DateFormat.getDateTimeInstance();
return df.format(date);
}
public static String ellipsize(String text, int maxLength) {
if (text.length() > maxLength) {
return text.substring(0, maxLength - 1) + "";
}
return text;
}
}

View file

@ -0,0 +1,540 @@
/*
* Nextcloud Talk application
*
* @author Mario Danic
* @author Andy Scherzinger
* @author Tim Krüger
* Copyright (C) 2022 Tim Krüger <t@timkrueger.me>
* Copyright (C) 2021 Andy Scherzinger <info@andy-scherzinger.de>
* Copyright (C) 2017-2020 Mario Danic <mario@lovelyhq.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.talk.utils
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.text.Spannable
import android.text.SpannableString
import android.text.Spanned
import android.text.TextPaint
import android.text.TextUtils
import android.text.format.DateUtils
import android.text.method.LinkMovementMethod
import android.text.style.AbsoluteSizeSpan
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.util.TypedValue
import android.view.View
import android.view.Window
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.annotation.XmlRes
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.drawable.DrawableCompat
import androidx.emoji2.text.EmojiCompat
import coil.Coil.imageLoader
import coil.request.ImageRequest
import coil.target.Target
import coil.transform.CircleCropTransformation
import com.google.android.material.chip.ChipDrawable
import com.nextcloud.talk.R
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.events.UserMentionClickEvent
import com.nextcloud.talk.extensions.loadUserAvatar
import com.nextcloud.talk.ui.theme.ViewThemeUtils
import com.nextcloud.talk.utils.ApiUtils.getUrlForAvatar
import com.nextcloud.talk.utils.ApiUtils.getUrlForFederatedAvatar
import com.nextcloud.talk.utils.ApiUtils.getUrlForGuestAvatar
import com.nextcloud.talk.utils.text.Spans.MentionChipSpan
import org.greenrobot.eventbus.EventBus
import third.parties.fresco.BetterImageSpan
import java.text.DateFormat
import java.util.Date
import java.util.regex.Pattern
object DisplayUtils {
private val TAG = DisplayUtils::class.java.getSimpleName()
private const val INDEX_LUMINATION = 2
private const val MAX_LIGHTNESS = 0.92
private const val TWITTER_HANDLE_PREFIX = "@"
private const val HTTP_PROTOCOL = "http://"
private const val HTTPS_PROTOCOL = "https://"
private const val DATE_TIME_PARTS_SIZE = 2
fun isDarkModeOn(context: Context): Boolean {
val currentNightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return currentNightMode == Configuration.UI_MODE_NIGHT_YES
}
fun setClickableString(string: String, url: String?, textView: TextView) {
val spannableString = SpannableString(string)
spannableString.setSpan(
object : ClickableSpan() {
override fun onClick(widget: View) {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
sharedApplication!!.applicationContext.startActivity(browserIntent)
}
override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
ds.isUnderlineText = false
}
},
0,
string.length,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
textView.text = spannableString
textView.movementMethod = LinkMovementMethod.getInstance()
}
fun getBitmap(drawable: Drawable): Bitmap {
val bitmap = Bitmap.createBitmap(
drawable.intrinsicWidth,
drawable.intrinsicHeight,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
return bitmap
}
@JvmStatic
fun convertDpToPixel(dp: Float, context: Context): Float {
return Math.round(
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, dp,
context.resources.displayMetrics
) + 0.5f
).toFloat()
}
fun convertPixelToDp(px: Float, context: Context): Float {
return px / context.resources.displayMetrics.density
}
fun getTintedDrawable(res: Resources, @DrawableRes drawableResId: Int, @ColorRes colorResId: Int): Drawable? {
val drawable = ResourcesCompat.getDrawable(res, drawableResId, null)
val color = res.getColor(colorResId)
drawable?.setTint(color)
return drawable
}
@JvmStatic
fun getDrawableForMentionChipSpan(
context: Context,
id: String?,
roomToken: String?,
label: CharSequence,
conversationUser: User,
type: String,
@XmlRes chipResource: Int,
emojiEditText: EditText?,
viewThemeUtils: ViewThemeUtils,
isFederated: Boolean
): Drawable {
val chip = ChipDrawable.createFromResource(context, chipResource)
chip.text = EmojiCompat.get().process(label)
chip.ellipsize = TextUtils.TruncateAt.MIDDLE
if (chipResource == R.xml.chip_you) {
viewThemeUtils.material.colorChipDrawable(context, chip)
}
val config = context.resources.configuration
chip.setLayoutDirection(config.layoutDirection)
val drawable: Int
val isCallOrGroup = "call" == type || "calls" == type || "groups" == type || "user-group" == type
if (!isCallOrGroup) {
drawable = if (chipResource == R.xml.chip_you) {
R.drawable.mention_chip
} else {
R.drawable.accent_circle
}
chip.setChipIconResource(drawable)
} else {
chip.setChipIconResource(R.drawable.ic_circular_group)
}
chip.setBounds(0, 0, chip.intrinsicWidth, chip.intrinsicHeight)
if (!isCallOrGroup) {
var url = getUrlForAvatar(conversationUser.baseUrl, id, false)
if ("guests" == type || "guest" == type) {
url = getUrlForGuestAvatar(
conversationUser.baseUrl, label.toString(), true
)
}
if (isFederated) {
val darkTheme = if (isDarkModeOn(context)) 1 else 0
url = getUrlForFederatedAvatar(
conversationUser.baseUrl!!,
roomToken!!, id!!,
darkTheme, false
)
}
val imageRequest: ImageRequest = ImageRequest.Builder(context)
.data(url)
.crossfade(true)
.transformations(CircleCropTransformation())
.target(object : Target {
override fun onStart(placeholder: Drawable?) {}
override fun onError(error: Drawable?) {
chip.chipIcon = error
}
override fun onSuccess(result: Drawable) {
chip.chipIcon = result
// A hack to refresh the chip icon
emojiEditText?.post {
emojiEditText.setTextKeepState(
emojiEditText.getText(),
TextView.BufferType.SPANNABLE
)
}
}
})
.build()
imageLoader(context).enqueue(imageRequest)
}
return chip
}
fun searchAndReplaceWithMentionSpan(
key: String,
context: Context,
text: Spanned,
id: String,
roomToken: String?,
label: String,
type: String,
conversationUser: User,
@XmlRes chipXmlRes: Int,
viewThemeUtils: ViewThemeUtils,
isFederated: Boolean
): Spannable {
val spannableString: Spannable = SpannableString(text)
val stringText = text.toString()
val keyWithBrackets = "{$key}"
val m = Pattern.compile(keyWithBrackets, Pattern.CASE_INSENSITIVE or Pattern.LITERAL or Pattern.MULTILINE)
.matcher(spannableString)
val clickableSpan: ClickableSpan = object : ClickableSpan() {
override fun onClick(widget: View) {
EventBus.getDefault().post(UserMentionClickEvent(id))
}
}
var lastStartIndex = 0
var mentionChipSpan: MentionChipSpan
while (m.find()) {
val start = stringText.indexOf(m.group(), lastStartIndex)
val end = start + m.group().length
lastStartIndex = end
val drawableForChip = getDrawableForMentionChipSpan(
context,
id,
roomToken,
label,
conversationUser,
type,
chipXmlRes,
null,
viewThemeUtils,
isFederated
)
mentionChipSpan = MentionChipSpan(
drawableForChip,
BetterImageSpan.ALIGN_CENTER,
id,
label
)
spannableString.setSpan(mentionChipSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
if (chipXmlRes == R.xml.chip_you) {
spannableString.setSpan(
viewThemeUtils.talk.themeForegroundColorSpan(context),
start,
end,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
if ("user" == type && conversationUser.userId != id) {
spannableString.setSpan(clickableSpan, start, end, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
}
}
return spannableString
}
fun searchAndColor(text: Spannable, searchText: String, @ColorInt color: Int): Spannable {
val spannableString: Spannable = SpannableString(text)
val stringText = text.toString()
if (TextUtils.isEmpty(text) || TextUtils.isEmpty(searchText)) {
return spannableString
}
val m = Pattern.compile(
searchText,
Pattern.CASE_INSENSITIVE or Pattern.LITERAL or Pattern.MULTILINE
)
.matcher(spannableString)
val textSize = sharedApplication!!.resources.getDimensionPixelSize(R.dimen.chat_text_size)
var lastStartIndex = -1
while (m.find()) {
val start = stringText.indexOf(m.group(), lastStartIndex)
val end = start + m.group().length
lastStartIndex = end
spannableString.setSpan(
ForegroundColorSpan(color),
start,
end,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
spannableString.setSpan(StyleSpan(Typeface.BOLD), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
spannableString.setSpan(AbsoluteSizeSpan(textSize), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
return spannableString
}
fun getMessageSelector(
@ColorInt normalColor: Int,
@ColorInt selectedColor: Int,
@ColorInt pressedColor: Int,
@DrawableRes shape: Int
): Drawable {
val vectorDrawable = ContextCompat.getDrawable(
sharedApplication!!.applicationContext,
shape
)
val drawable = DrawableCompat.wrap(vectorDrawable!!).mutate()
DrawableCompat.setTintList(
drawable,
ColorStateList(
arrayOf(
intArrayOf(android.R.attr.state_selected),
intArrayOf(android.R.attr.state_pressed),
intArrayOf(-android.R.attr.state_pressed, -android.R.attr.state_selected)
),
intArrayOf(selectedColor, pressedColor, normalColor)
)
)
return drawable
}
/**
* Sets the color of the status bar to `color`.
*
* @param activity activity
* @param color the color
*/
fun applyColorToStatusBar(activity: Activity, @ColorInt color: Int) {
val window = activity.window
val isLightTheme = lightTheme(color)
if (window != null) {
val decor = window.decorView
if (isLightTheme) {
val systemUiFlagLightStatusBar: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or
View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
} else {
View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
}
decor.systemUiVisibility = systemUiFlagLightStatusBar
} else {
decor.systemUiVisibility = 0
}
window.statusBarColor = color
}
}
/**
* Tests if light color is set
*
* @param color the color
* @return true if primaryColor is lighter than MAX_LIGHTNESS
*/
fun lightTheme(color: Int): Boolean {
val hsl = colorToHSL(color)
// spotbugs dislikes fixed index access
// which is enforced by having such an
// array from Android-API itself
return hsl[INDEX_LUMINATION] >= MAX_LIGHTNESS
}
private fun colorToHSL(color: Int): FloatArray {
val hsl = FloatArray(3)
ColorUtils.RGBToHSL(Color.red(color), Color.green(color), Color.blue(color), hsl)
return hsl
}
fun applyColorToNavigationBar(window: Window, @ColorInt color: Int) {
window.navigationBarColor = color
}
/**
* beautifies a given URL by removing any http/https protocol prefix.
*
* @param url to be beautified url
* @return beautified url
*/
@Suppress("ReturnCount")
fun beautifyURL(url: String?): String {
if (TextUtils.isEmpty(url)) {
return ""
}
if (url!!.length >= 7 && HTTP_PROTOCOL.equals(url.substring(0, 7), ignoreCase = true)) {
return url.substring(HTTP_PROTOCOL.length).trim { it <= ' ' }
}
return if (url.length >= 8 && HTTPS_PROTOCOL.equals(url.substring(0, 8), ignoreCase = true)) {
url.substring(HTTPS_PROTOCOL.length).trim { it <= ' ' }
} else {
url.trim { it <= ' ' }
}
}
/**
* beautifies a given twitter handle by prefixing it with an @ in case it is missing.
*
* @param handle to be beautified twitter handle
* @return beautified twitter handle
*/
fun beautifyTwitterHandle(handle: String?): String {
return if (handle != null) {
val trimmedHandle = handle.trim { it <= ' ' }
if (TextUtils.isEmpty(trimmedHandle)) {
return ""
}
if (trimmedHandle.startsWith(TWITTER_HANDLE_PREFIX)) {
trimmedHandle
} else {
TWITTER_HANDLE_PREFIX + trimmedHandle
}
} else {
""
}
}
fun loadAvatarImage(user: User?, avatarImageView: ImageView?, deleteCache: Boolean) {
if (user != null && avatarImageView != null) {
val avatarId: String? = if (!TextUtils.isEmpty(user.userId)) {
user.userId
} else {
user.username
}
if (avatarId != null) {
avatarImageView.loadUserAvatar(user, avatarId, true, deleteCache)
}
}
}
@StringRes
fun getSortOrderStringId(sortOrder: FileSortOrder): Int {
return when (sortOrder.name) {
FileSortOrder.SORT_Z_TO_A_ID -> R.string.menu_item_sort_by_name_z_a
FileSortOrder.SORT_NEW_TO_OLD_ID -> R.string.menu_item_sort_by_date_newest_first
FileSortOrder.SORT_OLD_TO_NEW_ID -> R.string.menu_item_sort_by_date_oldest_first
FileSortOrder.SORT_BIG_TO_SMALL_ID -> R.string.menu_item_sort_by_size_biggest_first
FileSortOrder.SORT_SMALL_TO_BIG_ID -> R.string.menu_item_sort_by_size_smallest_first
FileSortOrder.SORT_A_TO_Z_ID -> R.string.menu_item_sort_by_name_a_z
else -> R.string.menu_item_sort_by_name_a_z
}
}
/**
* calculates the relative time string based on the given modification timestamp.
*
* @param context the app's context
* @param modificationTimestamp the UNIX timestamp of the file modification time in milliseconds.
* @return a relative time string
*/
fun getRelativeTimestamp(context: Context, modificationTimestamp: Long, showFuture: Boolean): CharSequence {
return getRelativeDateTimeString(
context,
modificationTimestamp,
DateUtils.SECOND_IN_MILLIS,
DateUtils.WEEK_IN_MILLIS,
0,
showFuture
)
}
@Suppress("ReturnCount")
private fun getRelativeDateTimeString(
c: Context,
time: Long,
minResolution: Long,
transitionResolution: Long,
flags: Int,
showFuture: Boolean
): CharSequence {
val dateString: CharSequence
// in Future
if (!showFuture && time > System.currentTimeMillis()) {
return unixTimeToHumanReadable(time)
}
// < 60 seconds -> seconds ago
val diff = System.currentTimeMillis() - time
dateString = if (diff > 0 && diff < 60 * 1000 && minResolution == DateUtils.SECOND_IN_MILLIS) {
return c.getString(R.string.secondsAgo)
} else {
DateUtils.getRelativeDateTimeString(c, time, minResolution, transitionResolution, flags)
}
val parts = dateString.toString().split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
if (parts.size == DATE_TIME_PARTS_SIZE) {
if (parts[1].contains(":") && !parts[0].contains(":")) {
return parts[0]
} else if (parts[0].contains(":") && !parts[1].contains(":")) {
return parts[1]
}
}
// dateString contains unexpected format. fallback: use relative date time string from android api as is.
return dateString.toString()
}
/**
* Converts Unix time to human readable format
*
* @param milliseconds that have passed since 01/01/1970
* @return The human readable time for the users locale
*/
fun unixTimeToHumanReadable(milliseconds: Long): String {
val date = Date(milliseconds)
val df = DateFormat.getDateTimeInstance()
return df.format(date)
}
fun ellipsize(text: String, maxLength: Int): String {
return if (text.length > maxLength) {
text.substring(0, maxLength - 1) + ""
} else {
text
}
}
}

View file

@ -127,16 +127,24 @@ class MessageUtils(val context: Context) {
} else {
R.xml.chip_others
}
val id = if (individualHashMap["server"] != null) {
individualHashMap["id"] + "@" + individualHashMap["server"]
} else {
individualHashMap["id"]
}
messageStringInternal = DisplayUtils.searchAndReplaceWithMentionSpan(
key,
key!!,
themingContext,
messageStringInternal,
individualHashMap["id"]!!,
id!!,
message.roomToken,
individualHashMap["name"]!!,
individualHashMap["type"]!!,
message.activeUser!!,
chip,
viewThemeUtils
viewThemeUtils,
individualHashMap["server"] != null
)
}
@ -174,5 +182,6 @@ class MessageUtils(val context: Context) {
companion object {
private const val TAG = "MessageUtils"
const val MAX_REPLY_LENGTH = 250
const val HTTPS_PROTOCOL = "https://"
}
}