diff --git a/CHANGES.md b/CHANGES.md index 4a7c0c8fdf..093d2c0b86 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -7,6 +7,7 @@ Features ✨: Improvements 🙌: - Search reaction by name or keyword in emoji picker - Handle code tags (#567) + - Support spoiler messages Other changes: - Markdown set to off by default (#412) diff --git a/vector/src/main/java/im/vector/riotx/core/utils/UrlUtils.kt b/vector/src/main/java/im/vector/riotx/core/utils/UrlUtils.kt new file mode 100644 index 0000000000..3de555f66e --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/utils/UrlUtils.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.core.utils + +import java.net.URL + +fun String.isValidUrl(): Boolean { + return try { + URL(this) + true + } catch (t: Throwable) { + false + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/command/Command.kt b/vector/src/main/java/im/vector/riotx/features/command/Command.kt index 2d4cecdf26..7d745b925b 100644 --- a/vector/src/main/java/im/vector/riotx/features/command/Command.kt +++ b/vector/src/main/java/im/vector/riotx/features/command/Command.kt @@ -37,5 +37,6 @@ enum class Command(val command: String, val parameters: String, @StringRes val d KICK_USER("/kick", " [reason]", R.string.command_description_kick_user), CHANGE_DISPLAY_NAME("/nick", "", R.string.command_description_nick), MARKDOWN("/markdown", "", R.string.command_description_markdown), - CLEAR_SCALAR_TOKEN("/clear_scalar_token", "", R.string.command_description_clear_scalar_token); + CLEAR_SCALAR_TOKEN("/clear_scalar_token", "", R.string.command_description_clear_scalar_token), + SPOILER("/spoiler", "", R.string.command_description_spoiler); } diff --git a/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt b/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt index 9cf6510fdb..a9c20a9ec5 100644 --- a/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt +++ b/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt @@ -56,11 +56,12 @@ object CommandParser { return ParsedCommand.ErrorEmptySlashCommand } - when (val slashCommand = messageParts.first()) { + + return when (val slashCommand = messageParts.first()) { Command.CHANGE_DISPLAY_NAME.command -> { val newDisplayName = textMessage.substring(Command.CHANGE_DISPLAY_NAME.command.length).trim() - return if (newDisplayName.isNotEmpty()) { + if (newDisplayName.isNotEmpty()) { ParsedCommand.ChangeDisplayName(newDisplayName) } else { ParsedCommand.ErrorSyntax(Command.CHANGE_DISPLAY_NAME) @@ -69,7 +70,7 @@ object CommandParser { Command.TOPIC.command -> { val newTopic = textMessage.substring(Command.TOPIC.command.length).trim() - return if (newTopic.isNotEmpty()) { + if (newTopic.isNotEmpty()) { ParsedCommand.ChangeTopic(newTopic) } else { ParsedCommand.ErrorSyntax(Command.TOPIC) @@ -78,12 +79,12 @@ object CommandParser { Command.EMOTE.command -> { val message = textMessage.substring(Command.EMOTE.command.length).trim() - return ParsedCommand.SendEmote(message) + ParsedCommand.SendEmote(message) } Command.JOIN_ROOM.command -> { val roomAlias = textMessage.substring(Command.JOIN_ROOM.command.length).trim() - return if (roomAlias.isNotEmpty()) { + if (roomAlias.isNotEmpty()) { ParsedCommand.JoinRoom(roomAlias) } else { ParsedCommand.ErrorSyntax(Command.JOIN_ROOM) @@ -92,14 +93,14 @@ object CommandParser { Command.PART.command -> { val roomAlias = textMessage.substring(Command.PART.command.length).trim() - return if (roomAlias.isNotEmpty()) { + if (roomAlias.isNotEmpty()) { ParsedCommand.PartRoom(roomAlias) } else { ParsedCommand.ErrorSyntax(Command.PART) } } Command.INVITE.command -> { - return if (messageParts.size == 2) { + if (messageParts.size == 2) { val userId = messageParts[1] if (MatrixPatterns.isUserId(userId)) { @@ -112,7 +113,7 @@ object CommandParser { } } Command.KICK_USER.command -> { - return if (messageParts.size >= 2) { + if (messageParts.size >= 2) { val userId = messageParts[1] if (MatrixPatterns.isUserId(userId)) { val reason = textMessage.substring(Command.KICK_USER.command.length @@ -128,7 +129,7 @@ object CommandParser { } } Command.BAN_USER.command -> { - return if (messageParts.size >= 2) { + if (messageParts.size >= 2) { val userId = messageParts[1] if (MatrixPatterns.isUserId(userId)) { val reason = textMessage.substring(Command.BAN_USER.command.length @@ -144,7 +145,7 @@ object CommandParser { } } Command.UNBAN_USER.command -> { - return if (messageParts.size == 2) { + if (messageParts.size == 2) { val userId = messageParts[1] if (MatrixPatterns.isUserId(userId)) { @@ -157,7 +158,7 @@ object CommandParser { } } Command.SET_USER_POWER_LEVEL.command -> { - return if (messageParts.size == 3) { + if (messageParts.size == 3) { val userId = messageParts[1] if (MatrixPatterns.isUserId(userId)) { val powerLevelsAsString = messageParts[2] @@ -177,7 +178,7 @@ object CommandParser { } } Command.RESET_USER_POWER_LEVEL.command -> { - return if (messageParts.size == 2) { + if (messageParts.size == 2) { val userId = messageParts[1] if (MatrixPatterns.isUserId(userId)) { @@ -190,7 +191,7 @@ object CommandParser { } } Command.MARKDOWN.command -> { - return if (messageParts.size == 2) { + if (messageParts.size == 2) { when { "on".equals(messageParts[1], true) -> ParsedCommand.SetMarkdown(true) "off".equals(messageParts[1], true) -> ParsedCommand.SetMarkdown(false) @@ -201,15 +202,20 @@ object CommandParser { } } Command.CLEAR_SCALAR_TOKEN.command -> { - return if (messageParts.size == 1) { + if (messageParts.size == 1) { ParsedCommand.ClearScalarToken } else { ParsedCommand.ErrorSyntax(Command.CLEAR_SCALAR_TOKEN) } } + Command.SPOILER.command -> { + val message = textMessage.substring(Command.SPOILER.command.length).trim() + + ParsedCommand.SendSpoiler(message) + } else -> { // Unknown command - return ParsedCommand.ErrorUnknownSlashCommand(slashCommand) + ParsedCommand.ErrorUnknownSlashCommand(slashCommand) } } } diff --git a/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt b/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt index f6bbed2889..02f5abe540 100644 --- a/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt +++ b/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt @@ -45,4 +45,5 @@ sealed class ParsedCommand { class ChangeDisplayName(val displayName: String) : ParsedCommand() class SetMarkdown(val enable: Boolean) : ParsedCommand() object ClearScalarToken : ParsedCommand() + class SendSpoiler(val message: String) : ParsedCommand() } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index b1c6aa02fb..2436b7a8b4 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -49,6 +49,7 @@ import im.vector.riotx.BuildConfig import im.vector.riotx.R import im.vector.riotx.core.extensions.postLiveEvent import im.vector.riotx.core.platform.VectorViewModel +import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.core.resources.UserPreferencesProvider import im.vector.riotx.core.utils.LiveEvent import im.vector.riotx.core.utils.subscribeLogError @@ -66,6 +67,7 @@ import java.util.concurrent.TimeUnit class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: RoomDetailViewState, userPreferencesProvider: UserPreferencesProvider, private val vectorPreferences: VectorPreferences, + private val stringProvider: StringProvider, private val session: Session ) : VectorViewModel(initialState) { @@ -327,6 +329,14 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro _sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandHandled()) popDraft() } + is ParsedCommand.SendSpoiler -> { + room.sendFormattedTextMessage( + "[${stringProvider.getString(R.string.spoiler)}](${slashCommandResult.message})", + "${slashCommandResult.message}" + ) + _sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandHandled()) + popDraft() + } is ParsedCommand.ChangeTopic -> { handleChangeTopicSlashCommand(slashCommandResult) popDraft() diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageTextItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageTextItem.kt index 564adc50f2..45a6e2e743 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageTextItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageTextItem.kt @@ -24,6 +24,7 @@ import androidx.core.widget.TextViewCompat import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.riotx.R +import im.vector.riotx.core.utils.isValidUrl import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.html.PillImageSpan import kotlinx.coroutines.Dispatchers @@ -49,12 +50,12 @@ abstract class MessageTextItem : AbsMessageItem() { private val mvmtMethod = BetterLinkMovementMethod.newInstance().also { it.setOnLinkClickListener { _, url -> // Return false to let android manage the click on the link, or true if the link is handled by the application - urlClickCallback?.onUrlClicked(url) == true + url.isValidUrl() && urlClickCallback?.onUrlClicked(url) == true } // We need also to fix the case when long click on link will trigger long click on cell it.setOnLinkLongClickListener { tv, url -> // Long clicks are handled by parent, return true to block android to do something with url - if (urlClickCallback?.onUrlLongClicked(url) == true) { + if (url.isValidUrl() && urlClickCallback?.onUrlLongClicked(url) == true) { tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0)) true } else { diff --git a/vector/src/main/java/im/vector/riotx/features/html/EventHtmlRenderer.kt b/vector/src/main/java/im/vector/riotx/features/html/EventHtmlRenderer.kt index dc9e21e440..327677ade1 100644 --- a/vector/src/main/java/im/vector/riotx/features/html/EventHtmlRenderer.kt +++ b/vector/src/main/java/im/vector/riotx/features/html/EventHtmlRenderer.kt @@ -58,5 +58,7 @@ class MatrixHtmlPluginConfigure @Inject constructor(private val context: Context .addHandler(FontTagHandler()) .addHandler(MxLinkTagHandler(GlideApp.with(context), context, avatarRenderer, session)) .addHandler(MxReplyTagHandler()) + // FIXME (P3) SpanHandler is not recreated when theme is change and it depends on theme colors + .addHandler(SpanHandler(context)) } } diff --git a/vector/src/main/java/im/vector/riotx/features/html/SpanHandler.kt b/vector/src/main/java/im/vector/riotx/features/html/SpanHandler.kt new file mode 100644 index 0000000000..dbc09cf0a9 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/html/SpanHandler.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotx.features.html + +import android.content.Context +import im.vector.riotx.R +import im.vector.riotx.features.themes.ThemeUtils +import io.noties.markwon.MarkwonVisitor +import io.noties.markwon.SpannableBuilder +import io.noties.markwon.html.HtmlTag +import io.noties.markwon.html.MarkwonHtmlRenderer +import io.noties.markwon.html.TagHandler + +class SpanHandler(context: Context) : TagHandler() { + + override fun supportedTags() = listOf("span") + + private val spoilerBgColorHidden: Int = ThemeUtils.getColor(context, R.attr.vctr_spoiler_background_color) + private val spoilerBgColorRevealed: Int = ThemeUtils.getColor(context, R.attr.vctr_markdown_block_background_color) + + private val textColor: Int = ThemeUtils.getColor(context, R.attr.riotx_text_primary) + + override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) { + val mxSpoiler = tag.attributes()["data-mx-spoiler"] + if (mxSpoiler != null) { + SpannableBuilder.setSpans( + visitor.builder(), + SpoilerSpan(spoilerBgColorHidden, spoilerBgColorRevealed, textColor), + tag.start(), + tag.end() + ) + } else { + // default thing? + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/html/SpoilerSpan.kt b/vector/src/main/java/im/vector/riotx/features/html/SpoilerSpan.kt new file mode 100644 index 0000000000..d8236f0746 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/html/SpoilerSpan.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.html + +import android.graphics.Color +import android.text.TextPaint +import android.text.style.ClickableSpan +import android.view.View + +class SpoilerSpan(private val bgColorHidden: Int, + private val bgColorRevealed: Int, + private val textColor: Int) : ClickableSpan() { + + override fun onClick(widget: View) { + isHidden = !isHidden + widget.invalidate() + } + + private var isHidden = true + + override fun updateDrawState(tp: TextPaint) { + if (isHidden) { + tp.bgColor = bgColorHidden + tp.color = Color.TRANSPARENT + } else { + tp.bgColor = bgColorRevealed + tp.color = textColor + } + } +} diff --git a/vector/src/main/res/values/attrs.xml b/vector/src/main/res/values/attrs.xml index e9a4296add..c30a1d99d9 100644 --- a/vector/src/main/res/values/attrs.xml +++ b/vector/src/main/res/values/attrs.xml @@ -34,6 +34,7 @@ + diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index eb1eaf1368..be0d97c147 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -1168,6 +1168,8 @@ Changes your display nickname On/Off markdown To fix Matrix Apps management + Sends the given message as a spoiler + Spoiler Markdown has been enabled. Markdown has been disabled. diff --git a/vector/src/main/res/values/theme_dark.xml b/vector/src/main/res/values/theme_dark.xml index f09cb0c874..f61a89482a 100644 --- a/vector/src/main/res/values/theme_dark.xml +++ b/vector/src/main/res/values/theme_dark.xml @@ -101,6 +101,7 @@ #CCC3C3C3 @color/accent_color_dark @android:color/black + #FFFFFFFF #565656 diff --git a/vector/src/main/res/values/theme_light.xml b/vector/src/main/res/values/theme_light.xml index 1da010b8ff..aa343a11fc 100644 --- a/vector/src/main/res/values/theme_light.xml +++ b/vector/src/main/res/values/theme_light.xml @@ -101,6 +101,7 @@ #333C3C3C @color/accent_color_light #FFEEEEEE + #FF000000 #FFF2F2F2