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..66d88f149a 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,9 @@ 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), + SNOW("/snow", "", R.string.command_snow); 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..d458751364 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 @@ -18,6 +18,7 @@ package im.vector.app.features.command import im.vector.app.core.extensions.isEmail import im.vector.app.core.extensions.isMsisdn +import im.vector.app.features.home.room.detail.ChatEffect import org.matrix.android.sdk.api.MatrixPatterns import org.matrix.android.sdk.api.session.identity.ThreePid import timber.log.Timber @@ -291,6 +292,14 @@ object CommandParser { Command.DISCARD_SESSION.command -> { ParsedCommand.DiscardSession } + Command.CONFETTI.command -> { + val message = textMessage.substring(Command.CONFETTI.command.length).trim() + ParsedCommand.SendChatEffect(ChatEffect.CONFETTI, message) + } + Command.SNOW.command -> { + val message = textMessage.substring(Command.SNOW.command.length).trim() + ParsedCommand.SendChatEffect(ChatEffect.SNOW, 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..d17faeafb8 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 @@ -16,6 +16,7 @@ package im.vector.app.features.command +import im.vector.app.features.home.room.detail.ChatEffect import org.matrix.android.sdk.api.session.identity.ThreePid /** @@ -55,4 +56,5 @@ sealed class ParsedCommand { class SendShrug(val message: CharSequence) : ParsedCommand() class SendPoll(val question: String, val options: List) : ParsedCommand() object DiscardSession : ParsedCommand() + class SendChatEffect(val chatEffect: ChatEffect, 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..ee663b9bbd --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/ChatEffectManager.kt @@ -0,0 +1,141 @@ +/* + * 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 +} + +fun ChatEffect.toMessageType(): String { + return when (this) { + ChatEffect.CONFETTI -> MessageType.MSGTYPE_CONFETTI + ChatEffect.SNOW -> MessageType.MSGTYPE_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 + * precisely 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 = mutableListOf() + + 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 + 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 -> { + event.root.getClearContent().toModel()?.body + ?.let { text -> + when { + EMOJIS_FOR_CONFETTI.any { text.contains(it) } -> ChatEffect.CONFETTI + EMOJIS_FOR_SNOW.any { text.contains(it) } -> ChatEffect.SNOW + else -> null + } + } + } + else -> null + } + } + + companion object { + private val EMOJIS_FOR_CONFETTI = listOf( + "πŸŽ‰", + "🎊" + ) + private val EMOJIS_FOR_SNOW = listOf( + "⛄️", + "β˜ƒοΈ", + "❄️" + ) + } +} 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..45efe1e15a 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,13 @@ 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 com.airbnb.epoxy.EpoxyModel import com.airbnb.epoxy.OnModelBuildFinishedListener import com.airbnb.epoxy.addGlidePreloader @@ -168,6 +171,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 +383,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 +393,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..d34d01080e 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() { @@ -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.SendChatEffect -> { + room.sendTextMessage(slashCommandResult.message, slashCommandResult.chatEffect.toMessageType()) + _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()) @@ -983,9 +989,22 @@ 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) { + _viewEvents.post(RoomDetailViewEvents.StartChatEffect(effect)) + } + + override fun stopEffects() { + _viewEvents.post(RoomDetailViewEvents.StopChatEffects) + } + private fun handleLoadMore(action: RoomDetailAction.LoadMoreTimelineEvents) { timeline.paginate(action.direction, PAGINATION_COUNT) } @@ -1387,6 +1406,8 @@ class RoomDetailViewModel @AssistedInject constructor( if (vectorPreferences.sendTypingNotifs()) { room.userStopsTyping() } + chatEffectManager.delegate = null + 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 0000000000..0142772ced Binary files /dev/null and b/vector/src/main/res/drawable-anydpi-v26/snow.png differ diff --git a/vector/src/main/res/layout/fragment_room_detail.xml b/vector/src/main/res/layout/fragment_room_detail.xml index 33f462c0d1..705e4cd882 100644 --- a/vector/src/main/res/layout/fragment_room_detail.xml +++ b/vector/src/main/res/layout/fragment_room_detail.xml @@ -225,4 +225,17 @@ app:maxImageSize="16dp" app:tint="@color/black" /> + + + + diff --git a/vector/src/main/res/values/attrs.xml b/vector/src/main/res/values/attrs.xml index b97a8f3837..51d140ebcf 100644 --- a/vector/src/main/res/values/attrs.xml +++ b/vector/src/main/res/values/attrs.xml @@ -47,6 +47,7 @@ + diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 46873accfc..8376e9e9dd 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,9 @@ Show %d devices you can verify with now + Sends the given message with confetti + Sends the given message with snow + 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