From 7da8b13cde95e1ef9c94232237aa0593354ecaef Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 9 Dec 2020 12:34:22 +0100 Subject: [PATCH] Chat Effects --- CHANGES.md | 1 + build.gradle | 4 + .../session/room/model/message/MessageType.kt | 3 + vector/build.gradle | 3 + .../src/main/assets/open_source_licenses.html | 14 ++ .../im/vector/app/features/command/Command.kt | 3 +- .../app/features/command/CommandParser.kt | 4 + .../app/features/command/ParsedCommand.kt | 1 + .../home/room/detail/ChatEffectManager.kt | 125 ++++++++++++++ .../home/room/detail/RoomDetailFragment.kt | 36 ++++ .../home/room/detail/RoomDetailViewEvents.kt | 3 + .../home/room/detail/RoomDetailViewModel.kt | 159 ++++++++++-------- .../features/settings/VectorPreferences.kt | 5 + .../src/main/res/drawable-anydpi-v26/snow.png | Bin 0 -> 1986 bytes vector/src/main/res/drawable/ic_snow.xml | 13 ++ .../main/res/layout/fragment_room_detail.xml | 15 ++ vector/src/main/res/values/attrs.xml | 1 + vector/src/main/res/values/strings.xml | 4 + vector/src/main/res/values/theme_dark.xml | 3 + vector/src/main/res/values/theme_light.xml | 4 +- .../res/xml/vector_settings_preferences.xml | 6 + 21 files changed, 339 insertions(+), 68 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/ChatEffectManager.kt create mode 100644 vector/src/main/res/drawable-anydpi-v26/snow.png create mode 100644 vector/src/main/res/drawable/ic_snow.xml diff --git a/CHANGES.md b/CHANGES.md index 62bd92006e..d18a18e1c3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,7 @@ Features ✨: - Store encrypted file in cache and cleanup decrypted file at each app start (#2512) - Emoji Keyboard (#2520) - Social login (#2452) + - Support for chat effects in timeline (confetti, snow) (#2535) Improvements πŸ™Œ: - Add Setting Item to Change PIN (#2462) diff --git a/build.gradle b/build.gradle index 6dd61a720c..7531dee61e 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,10 @@ allprojects { includeGroupByRegex 'com\\.github\\.chrisbanes' // PFLockScreen-Android includeGroupByRegex 'com\\.github\\.vector-im' + + //Chat effects + includeGroupByRegex 'com\\.github\\.jetradarmobile' + includeGroupByRegex 'nl\\.dionsegijn' } } maven { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt index 0f133323b0..a2b4e135d1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt @@ -33,4 +33,7 @@ object MessageType { // Add, in local, a fake message type in order to StickerMessage can inherit Message class // Because sticker isn't a message type but a event type without msgtype field const val MSGTYPE_STICKER_LOCAL = "org.matrix.android.sdk.sticker" + + const val MSGTYPE_CONFETTI = "nic.custom.confetti" + const val MSGTYPE_SNOW = "nic.custom.snow" } diff --git a/vector/build.gradle b/vector/build.gradle index 28d8fe5c1b..7bb8ca187c 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -410,6 +410,9 @@ dependencies { // Badge for compatibility implementation 'me.leolin:ShortcutBadger:1.1.22@aar' + // Chat effects + implementation 'nl.dionsegijn:konfetti:1.2.5' + implementation 'com.github.jetradarmobile:android-snowfall:1.2.0' // DI implementation "com.google.dagger:dagger:$daggerVersion" kapt "com.google.dagger:dagger-compiler:$daggerVersion" diff --git a/vector/src/main/assets/open_source_licenses.html b/vector/src/main/assets/open_source_licenses.html index acf0bec14f..bf341e38b7 100755 --- a/vector/src/main/assets/open_source_licenses.html +++ b/vector/src/main/assets/open_source_licenses.html @@ -390,6 +390,11 @@ SOFTWARE.
Copyright (C) 2016 - Niklas Baudy, Ruben Gees, Mario ĐaniΔ‡ and contributors +
  • + JetradarMobile / android-snowfall +
    + Copyright 2016 JetRadar +
  •  Apache License
    @@ -576,5 +581,14 @@ Apache License
         
     
    +
    +    ISC License
    +    
  • + DanielMartinus / Konfetti +
    + Copyright (c) 2017 Dion Segijn +
  • +
    + diff --git a/vector/src/main/java/im/vector/app/features/command/Command.kt b/vector/src/main/java/im/vector/app/features/command/Command.kt index db429f9e58..9702f917c1 100644 --- a/vector/src/main/java/im/vector/app/features/command/Command.kt +++ b/vector/src/main/java/im/vector/app/features/command/Command.kt @@ -44,7 +44,8 @@ enum class Command(val command: String, val parameters: String, @StringRes val d POLL("/poll", "Question | Option 1 | Option 2 ...", R.string.command_description_poll), SHRUG("/shrug", "", R.string.command_description_shrug), PLAIN("/plain", "", R.string.command_description_plain), - DISCARD_SESSION("/discardsession", "", R.string.command_description_discard_session); + DISCARD_SESSION("/discardsession", "", R.string.command_description_discard_session), + CONFETTI("/confetti", "", R.string.command_confetti); val length get() = command.length + 1 diff --git a/vector/src/main/java/im/vector/app/features/command/CommandParser.kt b/vector/src/main/java/im/vector/app/features/command/CommandParser.kt index 94de6bf265..0710faa47d 100644 --- a/vector/src/main/java/im/vector/app/features/command/CommandParser.kt +++ b/vector/src/main/java/im/vector/app/features/command/CommandParser.kt @@ -291,6 +291,10 @@ object CommandParser { Command.DISCARD_SESSION.command -> { ParsedCommand.DiscardSession } + Command.CONFETTI.command -> { + val message = textMessage.substring(Command.CONFETTI.command.length).trim() + ParsedCommand.Confetti(message) + } else -> { // Unknown command ParsedCommand.ErrorUnknownSlashCommand(slashCommand) diff --git a/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt b/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt index 2f8531929a..72c0ac496e 100644 --- a/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt +++ b/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt @@ -55,4 +55,5 @@ sealed class ParsedCommand { class SendShrug(val message: CharSequence) : ParsedCommand() class SendPoll(val question: String, val options: List) : ParsedCommand() object DiscardSession : ParsedCommand() + class Confetti(val message: String) : ParsedCommand() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/ChatEffectManager.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/ChatEffectManager.kt new file mode 100644 index 0000000000..71d751b494 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/ChatEffectManager.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2020 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.app.features.home.room.detail + +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessageType +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import java.util.Timer +import java.util.TimerTask +import javax.inject.Inject + +enum class ChatEffect { + CONFETTI, + SNOW +} + +/** + * A simple chat effect manager helper class + * Used by the view model to know if an event that become visible should trigger a chat effect. + * It also manages effect duration and some cool down, for example if an effect is currently playing, + * any other trigger will be ignored + * For now it uses visibility callback to check for an effect (that means that a fail to decrypt event - more + * precisly an event decrypted with a few delay won't trigger an effect; it's acceptable) + * Events that are more that 10s old won't trigger effects + */ +class ChatEffectManager @Inject constructor() { + + interface Delegate { + fun stopEffects() + fun shouldStartEffect(effect: ChatEffect) + } + + var delegate: Delegate? = null + + private var stopTimer: Timer? = null + + // an in memory store to avoid trigger twice for an event (quick close/open timeline) + private val alreadyPlayed = emptyList().toMutableList() + + fun checkForEffect(event: TimelineEvent) { + val age = event.root.ageLocalTs ?: 0 + val now = System.currentTimeMillis() + // messages older than 10s should not trigger any effect + if ((now - age) >= 10_000) return + val content = event.root.getClearContent()?.toModel() ?: return + val effect = findEffect(content, event) + if (effect != null) { + synchronized(this) { + if (hasAlreadyPlayed(event)) return + markAsAlreadyPlayed(event) + // there is already an effect playing, so ignore + if (stopTimer != null) return + delegate?.shouldStartEffect(effect) + stopTimer = Timer().apply { + schedule(object : TimerTask() { + override fun run() { + stopEffect() + } + }, 6_000) + } + } + } + } + + fun dispose() { + stopTimer?.cancel() + stopTimer = null + delegate = null + alreadyPlayed.clear() + } + + @Synchronized + private fun stopEffect() { + stopTimer = null + delegate?.stopEffects() + } + + private fun markAsAlreadyPlayed(event: TimelineEvent) { + alreadyPlayed.add(event.eventId) + // also put the tx id as fast way to deal with local echo + event.root.unsignedData?.transactionId?.let { + alreadyPlayed.add(it) + } + } + + private fun hasAlreadyPlayed(event: TimelineEvent) : Boolean { + return alreadyPlayed.contains(event.eventId) + || (event.root.unsignedData?.transactionId?.let { alreadyPlayed.contains(it) } ?: false) + } + + private fun findEffect(content: MessageContent, event: TimelineEvent): ChatEffect? { + return when (content.msgType) { + MessageType.MSGTYPE_CONFETTI -> ChatEffect.CONFETTI + MessageType.MSGTYPE_SNOW -> ChatEffect.SNOW + + MessageType.MSGTYPE_TEXT -> { + val text = event.root.getClearContent().toModel()?.body ?: "" + if (text.contains("πŸŽ‰") + || text.contains("🎊")) { + ChatEffect.CONFETTI + } else if (text.contains("⛄️") + || text.contains("β˜ƒοΈ") + || text.contains("❄️")) { + ChatEffect.SNOW + } else null + } + else -> null + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt index aff29bb7a3..4dd9c62a41 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt @@ -21,6 +21,7 @@ import android.app.Activity import android.content.DialogInterface import android.content.Intent import android.content.res.Configuration +import android.graphics.Color import android.graphics.Typeface import android.net.Uri import android.os.Build @@ -48,11 +49,14 @@ import androidx.core.text.toSpannable import androidx.core.util.Pair import androidx.core.view.ViewCompat import androidx.core.view.forEach +import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.lifecycle.Observer import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import androidx.transition.TransitionManager +import butterknife.BindView import com.airbnb.epoxy.EpoxyModel import com.airbnb.epoxy.OnModelBuildFinishedListener import com.airbnb.epoxy.addGlidePreloader @@ -168,6 +172,8 @@ import kotlinx.android.parcel.Parcelize import kotlinx.android.synthetic.main.fragment_room_detail.* import kotlinx.android.synthetic.main.composer_layout.view.* import kotlinx.android.synthetic.main.merge_overlay_waiting_view.* +import nl.dionsegijn.konfetti.models.Shape +import nl.dionsegijn.konfetti.models.Size import org.billcarsonfr.jsonviewer.JSonViewerDialog import org.commonmark.parser.Parser import org.matrix.android.sdk.api.MatrixCallback @@ -378,6 +384,8 @@ class RoomDetailFragment @Inject constructor( is RoomDetailViewEvents.ShowRoomAvatarFullScreen -> it.matrixItem?.let { item -> navigator.openBigImageViewer(requireActivity(), it.view, item) } + is RoomDetailViewEvents.StartChatEffect -> handleChatEffect(it.type) + RoomDetailViewEvents.StopChatEffects -> handleStopChatEffects() }.exhaustive } @@ -386,6 +394,34 @@ class RoomDetailFragment @Inject constructor( } } + private fun handleChatEffect(chatEffect: ChatEffect) { + when (chatEffect) { + ChatEffect.CONFETTI -> { + viewKonfetti.isVisible = true + viewKonfetti.build() + .addColors(Color.YELLOW, Color.GREEN, Color.MAGENTA) + .setDirection(0.0, 359.0) + .setSpeed(2f, 5f) + .setFadeOutEnabled(true) + .setTimeToLive(2000L) + .addShapes(Shape.Square, Shape.Circle) + .addSizes(Size(12)) + .setPosition(-50f, viewKonfetti.width + 50f, -50f, -50f) + .streamFor(150, 3000L) + } + ChatEffect.SNOW -> { + viewSnowFall.isVisible = true + viewSnowFall.restartFalling() + } + } + } + private fun handleStopChatEffects() { + TransitionManager.beginDelayedTransition(rootConstraintLayout) + viewSnowFall.isVisible = false + // when gone the effect is a bit buggy + viewKonfetti.isInvisible = true + } + override fun onImageReady(uri: Uri?) { uri ?: return roomDetailViewModel.handle( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt index d5d94a0ca5..81d3d622e7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt @@ -95,4 +95,7 @@ sealed class RoomDetailViewEvents : VectorViewEvents { // TODO Remove object SlashCommandNotImplemented : SendMessageResult() + + data class StartChatEffect(val type: ChatEffect) : RoomDetailViewEvents() + object StopChatEffects : RoomDetailViewEvents() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt index 21858438b9..75423dabfb 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt @@ -98,7 +98,6 @@ import org.matrix.android.sdk.rx.rx import org.matrix.android.sdk.rx.unwrap import timber.log.Timber import java.io.File -import java.lang.Exception import java.util.UUID import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean @@ -115,8 +114,9 @@ class RoomDetailViewModel @AssistedInject constructor( private val roomSummaryHolder: RoomSummaryHolder, private val typingHelper: TypingHelper, private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager, + private val chatEffectManager: ChatEffectManager, timelineSettingsFactory: TimelineSettingsFactory -) : VectorViewModel(initialState), Timeline.Listener { +) : VectorViewModel(initialState), Timeline.Listener, ChatEffectManager.Delegate { private val room = session.getRoom(initialState.roomId)!! private val eventId = initialState.eventId @@ -171,6 +171,7 @@ class RoomDetailViewModel @AssistedInject constructor( room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear() // Inform the SDK that the room is displayed session.onRoomDisplayed(initialState.roomId) + chatEffectManager.delegate = this } private fun observePowerLevel() { @@ -225,58 +226,58 @@ class RoomDetailViewModel @AssistedInject constructor( override fun handle(action: RoomDetailAction) { when (action) { - is RoomDetailAction.UserIsTyping -> handleUserIsTyping(action) - is RoomDetailAction.SaveDraft -> handleSaveDraft(action) - is RoomDetailAction.SendMessage -> handleSendMessage(action) - is RoomDetailAction.SendMedia -> handleSendMedia(action) - is RoomDetailAction.SendSticker -> handleSendSticker(action) - is RoomDetailAction.TimelineEventTurnsVisible -> handleEventVisible(action) - is RoomDetailAction.TimelineEventTurnsInvisible -> handleEventInvisible(action) - is RoomDetailAction.LoadMoreTimelineEvents -> handleLoadMore(action) - is RoomDetailAction.SendReaction -> handleSendReaction(action) - is RoomDetailAction.AcceptInvite -> handleAcceptInvite() - is RoomDetailAction.RejectInvite -> handleRejectInvite() - is RoomDetailAction.RedactAction -> handleRedactEvent(action) - is RoomDetailAction.UndoReaction -> handleUndoReact(action) - is RoomDetailAction.UpdateQuickReactAction -> handleUpdateQuickReaction(action) - is RoomDetailAction.EnterRegularMode -> handleEnterRegularMode(action) - is RoomDetailAction.EnterEditMode -> handleEditAction(action) - is RoomDetailAction.EnterQuoteMode -> handleQuoteAction(action) - is RoomDetailAction.EnterReplyMode -> handleReplyAction(action) - is RoomDetailAction.DownloadOrOpen -> handleOpenOrDownloadFile(action) - is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action) - is RoomDetailAction.HandleTombstoneEvent -> handleTombstoneEvent(action) - is RoomDetailAction.ResendMessage -> handleResendEvent(action) - is RoomDetailAction.RemoveFailedEcho -> handleRemove(action) - is RoomDetailAction.ResendAll -> handleResendAll() - is RoomDetailAction.MarkAllAsRead -> handleMarkAllAsRead() - is RoomDetailAction.ReportContent -> handleReportContent(action) - is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action) + is RoomDetailAction.UserIsTyping -> handleUserIsTyping(action) + is RoomDetailAction.SaveDraft -> handleSaveDraft(action) + is RoomDetailAction.SendMessage -> handleSendMessage(action) + is RoomDetailAction.SendMedia -> handleSendMedia(action) + is RoomDetailAction.SendSticker -> handleSendSticker(action) + is RoomDetailAction.TimelineEventTurnsVisible -> handleEventVisible(action) + is RoomDetailAction.TimelineEventTurnsInvisible -> handleEventInvisible(action) + is RoomDetailAction.LoadMoreTimelineEvents -> handleLoadMore(action) + is RoomDetailAction.SendReaction -> handleSendReaction(action) + is RoomDetailAction.AcceptInvite -> handleAcceptInvite() + is RoomDetailAction.RejectInvite -> handleRejectInvite() + is RoomDetailAction.RedactAction -> handleRedactEvent(action) + is RoomDetailAction.UndoReaction -> handleUndoReact(action) + is RoomDetailAction.UpdateQuickReactAction -> handleUpdateQuickReaction(action) + is RoomDetailAction.EnterRegularMode -> handleEnterRegularMode(action) + is RoomDetailAction.EnterEditMode -> handleEditAction(action) + is RoomDetailAction.EnterQuoteMode -> handleQuoteAction(action) + is RoomDetailAction.EnterReplyMode -> handleReplyAction(action) + is RoomDetailAction.DownloadOrOpen -> handleOpenOrDownloadFile(action) + is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action) + is RoomDetailAction.HandleTombstoneEvent -> handleTombstoneEvent(action) + is RoomDetailAction.ResendMessage -> handleResendEvent(action) + is RoomDetailAction.RemoveFailedEcho -> handleRemove(action) + is RoomDetailAction.ResendAll -> handleResendAll() + is RoomDetailAction.MarkAllAsRead -> handleMarkAllAsRead() + is RoomDetailAction.ReportContent -> handleReportContent(action) + is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action) is RoomDetailAction.EnterTrackingUnreadMessagesState -> startTrackingUnreadMessages() - is RoomDetailAction.ExitTrackingUnreadMessagesState -> stopTrackingUnreadMessages() - is RoomDetailAction.ReplyToOptions -> handleReplyToOptions(action) - is RoomDetailAction.AcceptVerificationRequest -> handleAcceptVerification(action) - is RoomDetailAction.DeclineVerificationRequest -> handleDeclineVerification(action) - is RoomDetailAction.RequestVerification -> handleRequestVerification(action) - is RoomDetailAction.ResumeVerification -> handleResumeRequestVerification(action) - is RoomDetailAction.ReRequestKeys -> handleReRequestKeys(action) - is RoomDetailAction.TapOnFailedToDecrypt -> handleTapOnFailedToDecrypt(action) - is RoomDetailAction.SelectStickerAttachment -> handleSelectStickerAttachment() - is RoomDetailAction.OpenIntegrationManager -> handleOpenIntegrationManager() - is RoomDetailAction.StartCall -> handleStartCall(action) - is RoomDetailAction.EndCall -> handleEndCall() - is RoomDetailAction.ManageIntegrations -> handleManageIntegrations() - is RoomDetailAction.AddJitsiWidget -> handleAddJitsiConference(action) - is RoomDetailAction.RemoveWidget -> handleDeleteWidget(action.widgetId) - is RoomDetailAction.EnsureNativeWidgetAllowed -> handleCheckWidgetAllowed(action) - is RoomDetailAction.CancelSend -> handleCancel(action) - is RoomDetailAction.OpenOrCreateDm -> handleOpenOrCreateDm(action) - is RoomDetailAction.JumpToReadReceipt -> handleJumpToReadReceipt(action) - RoomDetailAction.QuickActionInvitePeople -> handleInvitePeople() - RoomDetailAction.QuickActionSetAvatar -> handleQuickSetAvatar() - is RoomDetailAction.SetAvatarAction -> handleSetNewAvatar(action) - RoomDetailAction.QuickActionSetTopic -> _viewEvents.post(RoomDetailViewEvents.OpenRoomSettings) - is RoomDetailAction.ShowRoomAvatarFullScreen -> { + is RoomDetailAction.ExitTrackingUnreadMessagesState -> stopTrackingUnreadMessages() + is RoomDetailAction.ReplyToOptions -> handleReplyToOptions(action) + is RoomDetailAction.AcceptVerificationRequest -> handleAcceptVerification(action) + is RoomDetailAction.DeclineVerificationRequest -> handleDeclineVerification(action) + is RoomDetailAction.RequestVerification -> handleRequestVerification(action) + is RoomDetailAction.ResumeVerification -> handleResumeRequestVerification(action) + is RoomDetailAction.ReRequestKeys -> handleReRequestKeys(action) + is RoomDetailAction.TapOnFailedToDecrypt -> handleTapOnFailedToDecrypt(action) + is RoomDetailAction.SelectStickerAttachment -> handleSelectStickerAttachment() + is RoomDetailAction.OpenIntegrationManager -> handleOpenIntegrationManager() + is RoomDetailAction.StartCall -> handleStartCall(action) + is RoomDetailAction.EndCall -> handleEndCall() + is RoomDetailAction.ManageIntegrations -> handleManageIntegrations() + is RoomDetailAction.AddJitsiWidget -> handleAddJitsiConference(action) + is RoomDetailAction.RemoveWidget -> handleDeleteWidget(action.widgetId) + is RoomDetailAction.EnsureNativeWidgetAllowed -> handleCheckWidgetAllowed(action) + is RoomDetailAction.CancelSend -> handleCancel(action) + is RoomDetailAction.OpenOrCreateDm -> handleOpenOrCreateDm(action) + is RoomDetailAction.JumpToReadReceipt -> handleJumpToReadReceipt(action) + RoomDetailAction.QuickActionInvitePeople -> handleInvitePeople() + RoomDetailAction.QuickActionSetAvatar -> handleQuickSetAvatar() + is RoomDetailAction.SetAvatarAction -> handleSetNewAvatar(action) + RoomDetailAction.QuickActionSetTopic -> _viewEvents.post(RoomDetailViewEvents.OpenRoomSettings) + is RoomDetailAction.ShowRoomAvatarFullScreen -> { _viewEvents.post( RoomDetailViewEvents.ShowRoomAvatarFullScreen(action.matrixItem, action.transitionView) ) @@ -549,7 +550,7 @@ class RoomDetailViewModel @AssistedInject constructor( SendMode.EDIT(timelineEvent, currentDraft.text) } } - else -> null + else -> null } ?: SendMode.REGULAR("", fromSharing = false) ) } @@ -592,16 +593,16 @@ class RoomDetailViewModel @AssistedInject constructor( return@withState false } when (itemId) { - R.id.resend_all -> state.asyncRoomSummary()?.hasFailedSending == true - R.id.timeline_setting -> true - R.id.invite -> state.canInvite - R.id.clear_all -> state.asyncRoomSummary()?.hasFailedSending == true - R.id.open_matrix_apps -> true + R.id.resend_all -> state.asyncRoomSummary()?.hasFailedSending == true + R.id.timeline_setting -> true + R.id.invite -> state.canInvite + R.id.clear_all -> state.asyncRoomSummary()?.hasFailedSending == true + R.id.open_matrix_apps -> true R.id.voice_call, - R.id.video_call -> true // always show for discoverability - R.id.hangup_call -> webRtcPeerConnectionManager.currentCall != null - R.id.search -> true - else -> false + R.id.video_call -> true // always show for discoverability + R.id.hangup_call -> webRtcPeerConnectionManager.currentCall != null + R.id.search -> true + else -> false } } @@ -714,6 +715,11 @@ class RoomDetailViewModel @AssistedInject constructor( _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) popDraft() } + is ParsedCommand.Confetti -> { + room.sendTextMessage(slashCommandResult.message, MessageType.MSGTYPE_CONFETTI) + _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) + popDraft() + } is ParsedCommand.SendPoll -> { room.sendPoll(slashCommandResult.question, slashCommandResult.options.mapIndexed { index, s -> OptionItem(s, "$index. $s") }) _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) @@ -742,7 +748,7 @@ class RoomDetailViewModel @AssistedInject constructor( } }.exhaustive } - is SendMode.EDIT -> { + is SendMode.EDIT -> { // is original event a reply? val inReplyTo = state.sendMode.timelineEvent.root.getClearContent().toModel()?.relatesTo?.inReplyTo?.eventId ?: state.sendMode.timelineEvent.root.content.toModel()?.relatesTo?.inReplyTo?.eventId @@ -768,7 +774,7 @@ class RoomDetailViewModel @AssistedInject constructor( _viewEvents.post(RoomDetailViewEvents.MessageSent) popDraft() } - is SendMode.QUOTE -> { + is SendMode.QUOTE -> { val messageContent: MessageContent? = state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel() ?: state.sendMode.timelineEvent.root.getClearContent().toModel() @@ -791,7 +797,7 @@ class RoomDetailViewModel @AssistedInject constructor( _viewEvents.post(RoomDetailViewEvents.MessageSent) popDraft() } - is SendMode.REPLY -> { + is SendMode.REPLY -> { state.sendMode.timelineEvent.let { room.replyToMessage(it, action.text.toString(), action.autoMarkdown) _viewEvents.post(RoomDetailViewEvents.MessageSent) @@ -983,9 +989,29 @@ class RoomDetailViewModel @AssistedInject constructor( visibleEventsObservable.accept(RoomDetailAction.TimelineEventTurnsVisible(event)) } } + + // handle chat effects here + if (vectorPreferences.chatEffectsEnabled()) { + chatEffectManager.checkForEffect(action.event) + } } } + override fun shouldStartEffect(effect: ChatEffect) { + when (effect) { + ChatEffect.CONFETTI -> { + _viewEvents.post(RoomDetailViewEvents.StartChatEffect(ChatEffect.CONFETTI)) + } + ChatEffect.SNOW -> { + _viewEvents.post(RoomDetailViewEvents.StartChatEffect(ChatEffect.SNOW)) + } + } + } + + override fun stopEffects() { + _viewEvents.post(RoomDetailViewEvents.StopChatEffects) + } + private fun handleLoadMore(action: RoomDetailAction.LoadMoreTimelineEvents) { timeline.paginate(action.direction, PAGINATION_COUNT) } @@ -1387,6 +1413,7 @@ class RoomDetailViewModel @AssistedInject constructor( if (vectorPreferences.sendTypingNotifs()) { room.userStopsTyping() } + chatEffectManager.dispose() super.onCleared() } } diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt index c50692df82..16be2b1552 100755 --- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt @@ -97,6 +97,7 @@ class VectorPreferences @Inject constructor(private val context: Context) { private const val SETTINGS_SHOW_AVATAR_DISPLAY_NAME_CHANGES_MESSAGES_KEY = "SETTINGS_SHOW_AVATAR_DISPLAY_NAME_CHANGES_MESSAGES_KEY" private const val SETTINGS_VIBRATE_ON_MENTION_KEY = "SETTINGS_VIBRATE_ON_MENTION_KEY" private const val SETTINGS_SEND_MESSAGE_WITH_ENTER = "SETTINGS_SEND_MESSAGE_WITH_ENTER" + private const val SETTINGS_ENABLE_CHAT_EFFECTS = "SETTINGS_ENABLE_CHAT_EFFECTS" // Help private const val SETTINGS_SHOULD_SHOW_HELP_ON_ROOM_LIST_KEY = "SETTINGS_SHOULD_SHOW_HELP_ON_ROOM_LIST_KEY" @@ -869,6 +870,10 @@ class VectorPreferences @Inject constructor(private val context: Context) { return defaultPrefs.getBoolean(SETTINGS_SECURITY_USE_GRACE_PERIOD_FLAG, true) } + fun chatEffectsEnabled(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_ENABLE_CHAT_EFFECTS, true) + } + /** * Return true if Pin code is disabled, or if user set the settings to see full notification content */ diff --git a/vector/src/main/res/drawable-anydpi-v26/snow.png b/vector/src/main/res/drawable-anydpi-v26/snow.png new file mode 100644 index 0000000000000000000000000000000000000000..0142772cedb2cfe82fe2855d7ef9b0bc6febbafc GIT binary patch literal 1986 zcmV;z2R-Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91HlPCl1ONa40RR91HUIzs0P{mGqyPX1EJ;K`RA>d&n$2reXB5V3&3BU; zO>0mrLB&!^OQAHx3Q=?sn*IY_xKIlH3$h8tg&?$U6kU{B-MGj?sGDr6NF#P3wxrcU zg{~B#mT1zLhI}WJq|fj6ea3gjj58C*WahxhdB4x|+;i?b_r5n9UMa0?2+P@0jmxpO z<*4GaZPc~8VlJn}ti~E%$K}|vmixV~31G98F>dqtqHVUrLKpD7n82%=OdT6*JcjiR z8oQLGZi~lw4z^&aDtP46ficjSz(x&WJ#4Hd%sj-PA+?yLsZfszGaj-1zn15(o#h#p zbjO4!8$4vtYP7DduKMD|iyyVOw;yV1YI>`-wsvbxO%3{{`j_YD=l_uo&&|yZPfSew zH8?nU{piu7zs}CiE~wuML6d~MFNUp}N+S^A%0s3`S}$#qwn(>0+pb)>a^&H|hu<$O zEQ~A{Da>ImYp_;WleO`IFMQ(L(O2=s#iWQzt@JhNW@)Rmt*@`|v+?oq--_gtb2w|U zCTj;D_`)Z?!B7%%PC3%7PRbR0L%QR{i4%uLMn-G;4GK7(&CnB1x&XHtZo9A`mNZ|9vmcfJy$R9;l2 z8<4^$z5@oZfT`%Tj7f2IOlsGI2M_v6hqt(u@GUI6q+DP}t|)SZm>lM2C9g@powsk_ z{<=6Qr5_CjumntC1EY6K6>|&GVj$ZqI}uxM+vUrbzYxr|Out-LB?T5R1#DpSPXX9- z3eoa?#WuU+*s)_>Yq3$}Vv^ks7BGQL3dZ<;Q}tPKw48$E@}5VJ9$n8~e7);n0-F?! zaZ0A6MuaRh`u-xkbr&vN=xA+iJ?H`DV!;G9FiM?@U{4b>RvNrvu;cKU^v>AW*zfBd zom~FG2F8FD%-%>6a*h}klsflV&81714m3A6f1q+%3fRCXa6~NL_A`h9-f75_Mr}t& zM>mb-EW-GS@DV96XKeeWKdBw!^CeUGIh1_3IL z*v9($dS2rzLIPGuJwq&EsE#x_4d!LCA|znVASK`#sR*$|bs)pTZ$(JJn#L~Bl#}!` zfw*(ojTKl&d%MrTucNRh84YDz$%Qp02J=Lxr>95Dm`etnSO#1k zNqCUtZ8UwDqw>efEp+bf1~?zQsuG`uz*Qm zrlnv6E11(8hsqfeM~y>B@^+83{p{JZy-Q0=i`iFht?OU_3zz~nFs^uDs`d=wp5twl zW8>tATlQV)zN=TSe!JF0vi*SpECEx&N8B2p{9tph`Nul>D53iM(oTJ+>d$tzR&{)X zAz*2f1zW)%B{VjDM8HNun3K;)vK*f7*9Y)(Ynhh#!Y951hMlrtic>J&-PN_SaIZNf zIU8q}B$szdA=i2O^yx44FAWnfJ6ia_7e0e;Fn|S2VB@v{E0_zCm>l<;$#Jzv%YBCr zA3k#b{{34oiyUj?17E=>zD+9rbW|`Y%}>PSs#SC3NCEPj4TYWQpuSc03=IwaSt^38 z#hR=geBcY8_;#eg^xR10V{39O!luH4+9t}@V`JmyhK7dZ6-cl0W0RAUV|p!mGCDdsq>bU1lP6E!)tAcT zF;4410)-H5sDFfcCSfXUdJ6c@pBS;xQtnoZ6mA&h#A=h&+2`BDrquVj!| z?merc=!n|-CbutxbJPspB(d~aJsw-I*jd37!a&c%T1~)2ZQbuVF;IiIrH^tv2A4&V zVuGDfUac57^IeXu+vD{OG$!COk6TswKR+Ea U8gR6@i~s-t07*qoM6N<$f@hzt8UO$Q literal 0 HcmV?d00001 diff --git a/vector/src/main/res/drawable/ic_snow.xml b/vector/src/main/res/drawable/ic_snow.xml new file mode 100644 index 0000000000..4149adab9f --- /dev/null +++ b/vector/src/main/res/drawable/ic_snow.xml @@ -0,0 +1,13 @@ + + + diff --git a/vector/src/main/res/layout/fragment_room_detail.xml b/vector/src/main/res/layout/fragment_room_detail.xml index 33f462c0d1..d33f47872d 100644 --- a/vector/src/main/res/layout/fragment_room_detail.xml +++ b/vector/src/main/res/layout/fragment_room_detail.xml @@ -6,6 +6,21 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + + + + diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 46873accfc..f474af84c8 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -877,6 +877,8 @@ Show read receipts Click on the read receipts for a detailed list. Show room member state events + Show chat effects + Use /confetti command or send a message containing ❄️ or πŸŽ‰ Includes invite/join/left/kick/ban events and avatar/display name changes. Show join and leave events Invites, kicks, and bans are unaffected. @@ -2568,6 +2570,8 @@ Show %d devices you can verify with now + Sends the given message with confetti + Unencrypted Encrypted by an unverified device Review where you’re logged in diff --git a/vector/src/main/res/values/theme_dark.xml b/vector/src/main/res/values/theme_dark.xml index 6be0adb907..86fbb57608 100644 --- a/vector/src/main/res/values/theme_dark.xml +++ b/vector/src/main/res/values/theme_dark.xml @@ -200,6 +200,9 @@ @style/WidgetButtonSocialLogin.Facebook.Dark @style/WidgetButtonSocialLogin.Twitter.Dark @style/WidgetButtonSocialLogin.Apple.Dark + + + @android:color/transparent