Преглед изворни кода

Merge pull request #3701 from nextcloud/federated-mentions

Federated Mentions
Marcel Hibbe пре 1 година
родитељ
комит
6e45b0cdc7
21 измењених фајлова са 952 додато и 853 уклоњено
  1. 0 227
      app/src/main/java/com/nextcloud/talk/adapters/items/MentionAutocompleteItem.java
  2. 247 0
      app/src/main/java/com/nextcloud/talk/adapters/items/MentionAutocompleteItem.kt
  3. 1 1
      app/src/main/java/com/nextcloud/talk/adapters/items/ParticipantItem.java
  4. 3 0
      app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingLinkPreviewMessageViewHolder.kt
  5. 3 0
      app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingLocationMessageViewHolder.kt
  6. 3 0
      app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingPollMessageViewHolder.kt
  7. 3 0
      app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt
  8. 3 0
      app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt
  9. 4 1
      app/src/main/java/com/nextcloud/talk/adapters/messages/PreviewMessageViewHolder.kt
  10. 1 1
      app/src/main/java/com/nextcloud/talk/adapters/messages/SystemMessageViewHolder.kt
  11. 14 7
      app/src/main/java/com/nextcloud/talk/callbacks/MentionAutocompleteCallback.java
  12. 3 1
      app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
  13. 40 0
      app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt
  14. 1 1
      app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt
  15. 3 1
      app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.kt
  16. 7 2
      app/src/main/java/com/nextcloud/talk/models/json/mention/Mention.kt
  17. 51 45
      app/src/main/java/com/nextcloud/talk/presenters/MentionAutocompletePresenter.java
  18. 13 0
      app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt
  19. 0 563
      app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.java
  20. 540 0
      app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.kt
  21. 12 3
      app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt

+ 0 - 227
app/src/main/java/com/nextcloud/talk/adapters/items/MentionAutocompleteItem.java

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

+ 247 - 0
app/src/main/java/com/nextcloud/talk/adapters/items/MentionAutocompleteItem.kt

@@ -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"
+    }
+}

+ 1 - 1
app/src/main/java/com/nextcloud/talk/adapters/items/ParticipantItem.java

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

+ 3 - 0
app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingLinkPreviewMessageViewHolder.kt

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

+ 3 - 0
app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingLocationMessageViewHolder.kt

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

+ 3 - 0
app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingPollMessageViewHolder.kt

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

+ 3 - 0
app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt

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

+ 3 - 0
app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt

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

+ 4 - 1
app/src/main/java/com/nextcloud/talk/adapters/messages/PreviewMessageViewHolder.kt

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

+ 1 - 1
app/src/main/java/com/nextcloud/talk/adapters/messages/SystemMessageViewHolder.kt

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

+ 14 - 7
app/src/main/java/com/nextcloud/talk/callbacks/MentionAutocompleteCallback.java

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

+ 3 - 1
app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt

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

+ 40 - 0
app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt

@@ -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?,

+ 1 - 1
app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt

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

+ 3 - 1
app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.kt

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

+ 7 - 2
app/src/main/java/com/nextcloud/talk/models/json/mention/Mention.kt

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

+ 51 - 45
app/src/main/java/com/nextcloud/talk/presenters/MentionAutocompletePresenter.java

@@ -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
-                    }
-
-                    @Override
-                    public void onNext(@NonNull MentionOverall mentionOverall) {
-                        List<Mention> mentionsList = mentionOverall.getOcs().getData();
+            .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();
+
+                    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,
+                                    roomToken,
+                                    viewThemeUtils));
+                        }
 
-                        if (mentionsList.size() == 0) {
+                        if (adapter.getItemCount() != 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) {
-                        adapter.clear();
-                        Log.e(TAG, "failed to get MentionAutocompleteSuggestions", e);
-                    }
 
-                    @Override
-                    public void onComplete() {
-                        // no actions atm
+                        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;

+ 13 - 0
app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt

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

+ 0 - 563
app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.java

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

+ 540 - 0
app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.kt

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

+ 12 - 3
app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt

@@ -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://"
     }
 }