diff --git a/FEATURES.md b/FEATURES.md index e7328197ab..21c8fa4351 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -35,6 +35,7 @@ Here you can find some extra features and changes compared to Element Android (w - Render media captions ([MSC2530](https://github.com/matrix-org/matrix-spec-proposals/pull/2530)) - Escape @room in the reply fallback to avoid unintentional room pings when replying - Render sticker body in room/thread preview +- Pinned messages, contributed by [cintek](https://github.com/cintek) [for Element](https://github.com/vector-im/element-android/pull/7762) - Branding (name, app icon, links) - Show a toast instead of a snackbar after copying text, in order to not block the input area right after copying diff --git a/changelog.d/7762.feature b/changelog.d/7762.feature new file mode 100644 index 0000000000..485acf9415 --- /dev/null +++ b/changelog.d/7762.feature @@ -0,0 +1 @@ +Added lab feature to pin/unpin messages diff --git a/library/ui-strings/src/main/res/values/strings_sc.xml b/library/ui-strings/src/main/res/values/strings_sc.xml index 15f57952ed..9e72dc6109 100644 --- a/library/ui-strings/src/main/res/values/strings_sc.xml +++ b/library/ui-strings/src/main/res/values/strings_sc.xml @@ -239,4 +239,14 @@ ⚠️ This setting by default (unless overridden by your homeserver\'s configuration) enables access to \"scalar\", Element\'s integration manager which is unfortunately proprietary, i.e. its source code is not open and can not be checked by the public or the SchildiChat developers. + + %1$s pinned a message. + %1$s unpinned a message. + You pinned a message. + You unpinned a message. + Open Pinned Messages + Pin + Unpin + Pinned Messages + Enable Pinned Messages diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt index bad8b3766d..fed6fbe2d3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt @@ -30,6 +30,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent +import org.matrix.android.sdk.api.session.room.model.pinnedmessages.PinnedEventsStateContent import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent import org.matrix.android.sdk.api.session.room.model.relation.isReply import org.matrix.android.sdk.api.session.room.model.relation.shouldRenderInThread @@ -497,3 +498,11 @@ fun Event.supportsNotification() = fun Event.isContentReportable() = this.getClearType() in EventType.MESSAGE + EventType.STATE_ROOM_BEACON_INFO.values + +fun Event.getIdsOfPinnedEvents(): List? { + return getClearContent()?.toModel()?.eventIds +} + +fun Event.getPreviousIdsOfPinnedEvents(): List? { + return resolvedPrevContent()?.toModel()?.eventIds +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt index 170254078f..4cdb2403fc 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt @@ -45,6 +45,7 @@ object EventType { const val STATE_ROOM_NAME = "m.room.name" const val STATE_ROOM_TOPIC = "m.room.topic" const val STATE_ROOM_AVATAR = "m.room.avatar" + const val STATE_ROOM_PINNED_EVENT = "m.room.pinned_events" const val STATE_ROOM_MEMBER = "m.room.member" const val STATE_ROOM_THIRD_PARTY_INVITE = "m.room.third_party_invite" const val STATE_ROOM_CREATE = "m.room.create" @@ -67,7 +68,6 @@ object EventType { const val STATE_ROOM_CANONICAL_ALIAS = "m.room.canonical_alias" const val STATE_ROOM_HISTORY_VISIBILITY = "m.room.history_visibility" const val STATE_ROOM_RELATED_GROUPS = "m.room.related_groups" - const val STATE_ROOM_PINNED_EVENT = "m.room.pinned_events" const val STATE_ROOM_ENCRYPTION = "m.room.encryption" const val STATE_ROOM_SERVER_ACL = "m.room.server_acl" diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/pinnedmessages/PinnedEventsStateContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/pinnedmessages/PinnedEventsStateContent.kt new file mode 100644 index 0000000000..646cf62cda --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/pinnedmessages/PinnedEventsStateContent.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.api.session.room.model.pinnedmessages + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Class representing a pinned event content. + */ +@JsonClass(generateAdapter = true) +data class PinnedEventsStateContent( + @Json(name = "pinned") val eventIds: List +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/state/StateService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/state/StateService.kt index 6ca63c2c49..851dea8b9f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/state/StateService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/state/StateService.kt @@ -66,6 +66,16 @@ interface StateService { */ suspend fun deleteAvatar() + /** + * Pin an event of the room. + */ + suspend fun pinEvent(eventId: String) + + /** + * Unpin an event of the room. + */ + suspend fun unpinEvent(eventId: String) + /** * Send a state event to the room. * @param eventType The type of event to send. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt index 350d60d396..6a44e240c1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt @@ -45,7 +45,7 @@ interface Timeline { /** * This must be called before any other method after creating the timeline. It ensures the underlying database is open */ - fun start(rootThreadEventId: String? = null) + fun start(rootThreadEventId: String? = null, isFromPinnedEventsTimeline: Boolean = false) /** * This must be called when you don't need the timeline. It ensures the underlying database get closed. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineSettings.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineSettings.kt index fd6732d0d1..64c6a8f068 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineSettings.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineSettings.kt @@ -32,6 +32,10 @@ data class TimelineSettings( * The root thread eventId if this is a thread timeline, or null if this is NOT a thread timeline. */ val rootThreadEventId: String? = null, + /** + * True if the timeline is a pinned messages timeline. + */ + val isFromPinnedEventsTimeline: Boolean = false, /** * If true Sender Info shown in room will get the latest data information (avatar + displayName). */ @@ -42,4 +46,9 @@ data class TimelineSettings( * Returns true if this is a thread timeline or false otherwise. */ fun isThreadTimeline() = rootThreadEventId != null + + /** + * Returns true if this is a pinned messages timeline or false otherwise. + */ + fun isPinnedEventsTimeline() = isFromPinnedEventsTimeline } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt index aa4bdb1dd4..8a6a58b46a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt @@ -240,6 +240,17 @@ internal interface RoomAPI { @GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/state") suspend fun getRoomState(@Path("roomId") roomId: String): List + /** + * Get specific state event of a room + * Ref: https://matrix.org/docs/spec/client_server/r0.6.1#get-matrix-client-r0-rooms-roomid-state-eventtype-statekey + */ + @GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/state/{eventType}/{state_key}") + suspend fun getRoomState( + @Path("roomId") roomId: String, + @Path("eventType") eventType: String, + @Path("state_key") stateKey: String + ): Content + /** * Paginate relations for event based in normal topological order. * @param roomId the room Id diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt index ad47b82428..7a12bf8896 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt @@ -22,8 +22,10 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import org.matrix.android.sdk.api.query.QueryStateEventValue +import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.getIdsOfPinnedEvents import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.room.model.GuestAccess import org.matrix.android.sdk.api.session.room.model.RoomCanonicalAliasContent @@ -31,6 +33,7 @@ import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility import org.matrix.android.sdk.api.session.room.model.RoomJoinRules import org.matrix.android.sdk.api.session.room.model.RoomJoinRulesAllowEntry import org.matrix.android.sdk.api.session.room.model.RoomJoinRulesContent +import org.matrix.android.sdk.api.session.room.model.pinnedmessages.PinnedEventsStateContent import org.matrix.android.sdk.api.session.room.state.StateService import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.api.util.MimeTypes @@ -170,6 +173,32 @@ internal class DefaultStateService @AssistedInject constructor( ) } + override suspend fun pinEvent(eventId: String) { + val pinnedEvents = getStateEvent(EventType.STATE_ROOM_PINNED_EVENT, QueryStringValue.Equals("")) + ?.getIdsOfPinnedEvents() + ?.toMutableList() + pinnedEvents?.add(eventId) + val newListOfPinnedEvents = pinnedEvents?.toList() ?: return + setPinnedEvents(newListOfPinnedEvents) + } + + override suspend fun unpinEvent(eventId: String) { + val pinnedEvents = getStateEvent(EventType.STATE_ROOM_PINNED_EVENT, QueryStringValue.Equals("")) + ?.getIdsOfPinnedEvents() + ?.toMutableList() + pinnedEvents?.remove(eventId) + val newListOfPinnedEvents = pinnedEvents?.toList() ?: return + setPinnedEvents(newListOfPinnedEvents) + } + + private suspend fun setPinnedEvents(eventIds: List) { + sendStateEvent( + eventType = EventType.STATE_ROOM_PINNED_EVENT, + body = PinnedEventsStateContent(eventIds).toContent(), + stateKey = "" + ) + } + override suspend fun setJoinRulePublic() { updateJoinRule(RoomJoinRules.PUBLIC, null) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt index c9d4d51ff5..c0df680307 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt @@ -37,6 +37,9 @@ import kotlinx.coroutines.withContext import okhttp3.internal.closeQuietly import org.matrix.android.sdk.api.MatrixCoroutineDispatchers import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.getIdsOfPinnedEvents import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.sender.SenderInfo import org.matrix.android.sdk.api.session.room.timeline.Timeline @@ -69,8 +72,9 @@ internal class DefaultTimeline( private val settings: TimelineSettings, private val coroutineDispatchers: MatrixCoroutineDispatchers, private val clock: Clock, + private val stateEventDataSource: StateEventDataSource, + private val timelineEventDataSource: TimelineEventDataSource, localEchoEventFactory: LocalEchoEventFactory, - stateEventDataSource: StateEventDataSource, paginationTask: PaginationTask, getEventTask: GetContextOfEventTask, fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, @@ -105,6 +109,8 @@ internal class DefaultTimeline( private var targetEventId = initialEventId private val dimber = Dimber("TimelineChunks", DbgUtil.DBG_TIMELINE_CHUNKS) + private var isFromPinnedEventsTimeline = false + private val strategyDependencies = LoadTimelineStrategy.Dependencies( timelineSettings = settings, realm = backgroundRealm, @@ -136,7 +142,11 @@ internal class DefaultTimeline( override fun addListener(listener: Timeline.Listener): Boolean { listeners.add(listener) timelineScope.launch { - val snapshot = strategy.buildSnapshot() + val snapshot = if (isFromPinnedEventsTimeline) { + getPinnedEvents() + } else { + strategy.buildSnapshot() + } withContext(coroutineDispatchers.main) { tryOrNull { listener.onTimelineUpdated(snapshot) } } @@ -152,7 +162,7 @@ internal class DefaultTimeline( listeners.clear() } - override fun start(rootThreadEventId: String?) { + override fun start(rootThreadEventId: String?, isFromPinnedEventsTimeline: Boolean) { timelineScope.launch { loadRoomMembersIfNeeded() } @@ -161,6 +171,7 @@ internal class DefaultTimeline( if (isStarted.compareAndSet(false, true)) { isFromThreadTimeline = rootThreadEventId != null this@DefaultTimeline.rootThreadEventId = rootThreadEventId + this@DefaultTimeline.isFromPinnedEventsTimeline = isFromPinnedEventsTimeline // / val realm = Realm.getInstance(realmConfiguration) ensureReadReceiptAreLoaded(realm) @@ -267,7 +278,12 @@ internal class DefaultTimeline( } } Timber.v("$baseLogMessage: result $loadMoreResult") - val hasMoreToLoad = loadMoreResult != LoadMoreResult.REACHED_END + val hasMoreToLoad = if (isFromPinnedEventsTimeline) { + !areAllPinnedEventsLoaded() + } else { + loadMoreResult != LoadMoreResult.REACHED_END + } + updateState(direction) { it.copy(loading = false, hasMoreToLoad = hasMoreToLoad, hasLoadedAtLeastOnce = true) } @@ -378,7 +394,11 @@ internal class DefaultTimeline( } private suspend fun postSnapshot() { - val snapshot = strategy.buildSnapshot() + val snapshot = if (isFromPinnedEventsTimeline) { + getPinnedEvents() + } else { + strategy.buildSnapshot() + } Timber.v("Post snapshot of ${snapshot.size} events") // Async debugging to not slow down things too much dimber.exec { @@ -405,6 +425,25 @@ internal class DefaultTimeline( } } + private fun getIdsOfPinnedEvents(): List { + return stateEventDataSource + .getStateEvent(roomId, EventType.STATE_ROOM_PINNED_EVENT, QueryStringValue.Equals("")) + ?.getIdsOfPinnedEvents() + .orEmpty() + } + + private fun getPinnedEvents(): List { + return getIdsOfPinnedEvents() + .mapNotNull { id -> + timelineEventDataSource.getTimelineEvent(roomId, id) + } + .reversed() + } + + private fun areAllPinnedEventsLoaded(): Boolean { + return getIdsOfPinnedEvents().size == getPinnedEvents().size + } + private fun onNewTimelineEvents(eventIds: List) { timelineScope.launch(coroutineDispatchers.main) { listeners.forEach { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt index af66ba42b4..dff50eb96c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt @@ -85,6 +85,7 @@ internal class DefaultTimelineService @AssistedInject constructor( lightweightSettingsStorage = lightweightSettingsStorage, clock = clock, stateEventDataSource = stateEventDataSource, + timelineEventDataSource = timelineEventDataSource, localEchoEventFactory = localEchoEventFactory ) } diff --git a/vector-config/src/main/res/values/config-settings.xml b/vector-config/src/main/res/values/config-settings.xml index c12af1f568..fc312d28b9 100755 --- a/vector-config/src/main/res/values/config-settings.xml +++ b/vector-config/src/main/res/values/config-settings.xml @@ -39,6 +39,7 @@ true true + false true false false diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 9b82818aea..97d198a6cd 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -151,6 +151,7 @@ + +
    +
  • + Fluent UI System Icons +
    + MIT License + + Copyright (c) 2020 Microsoft Corporation +
  • +
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+

Apache License
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt index e891405ec3..ebdeab62cf 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt @@ -30,6 +30,8 @@ import org.matrix.android.sdk.api.session.widgets.model.Widget import org.matrix.android.sdk.api.util.MatrixItem sealed class RoomDetailAction : VectorViewModelAction { + data class PinEvent(val eventId: String) : RoomDetailAction() + data class UnpinEvent(val eventId: String) : RoomDetailAction() data class SendSticker(val stickerContent: MessageStickerContent) : RoomDetailAction() data class SendMedia(val attachments: List, val compressBeforeSending: Boolean) : RoomDetailAction() data class TimelineEventTurnsVisible(val event: TimelineEvent) : RoomDetailAction() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt index 44bb0c5793..21baa4900a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt @@ -84,6 +84,7 @@ data class RoomDetailViewState( val isSharingLiveLocation: Boolean = false, val showKeyboardWhenPresented: Boolean = false, val sharedData: SharedData? = null, + val isFromPinnedEventsTimeline: Boolean = false, ) : MavericksState { constructor(args: TimelineArgs) : this( @@ -98,6 +99,7 @@ data class RoomDetailViewState( rootThreadEventId = args.threadTimelineArgs?.rootThreadEventId, showKeyboardWhenPresented = args.threadTimelineArgs?.showKeyboard.orFalse(), sharedData = args.sharedData, + isFromPinnedEventsTimeline = args.pinnedEventsTimelineArgs != null, ) fun isCallOptionAvailable(): Boolean { @@ -122,5 +124,7 @@ data class RoomDetailViewState( fun isThreadTimeline() = rootThreadEventId != null + fun isPinnedEventsTimeline() = isFromPinnedEventsTimeline + fun isLocalRoom() = RoomLocalEcho.isLocalEchoId(roomId) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index 229c08261b..0e586b4177 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -176,6 +176,7 @@ import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever import im.vector.app.features.home.room.detail.upgrade.MigrateRoomBottomSheet import im.vector.app.features.home.room.detail.views.RoomDetailLazyLoadedViews import im.vector.app.features.home.room.detail.widget.RoomWidgetsBottomSheet +import im.vector.app.features.home.room.pinnedmessages.arguments.PinnedEventsTimelineArgs import im.vector.app.features.home.room.threads.ThreadsManager import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.home.room.typing.TypingHelper @@ -413,6 +414,10 @@ class TimelineFragment : ) } + if (isPinnedEventsTimeline()) { + views.hideComposerViews() + } + timelineViewModel.observeViewEvents { when (it) { is RoomDetailViewEvents.Failure -> displayErrorMessage(it) @@ -1067,6 +1072,10 @@ class TimelineFragment : requireActivity().restart() true } + R.id.open_pinned_events -> { + navigateToPinnedEvents() + true + } R.id.menu_timeline_thread_list -> { navigateToThreadList() true @@ -1390,7 +1399,7 @@ class TimelineFragment : } private fun updateJumpToReadMarkerViewVisibility() { - if (isThreadTimeLine()) return + if (isThreadTimeLine() || isPinnedEventsTimeline()) return viewLifecycleOwner.lifecycleScope.launch { withResumed { viewLifecycleOwner.lifecycleScope.launch { @@ -1480,6 +1489,9 @@ class TimelineFragment : vectorBaseActivity.finish() } updateLiveLocationIndicator(mainState.isSharingLiveLocation) + if (isPinnedEventsTimeline()) { + views.hideComposerViews() + } } private fun handleRoomSummaryFailure(asyncRoomSummary: Fail) { @@ -1536,6 +1548,19 @@ class TimelineFragment : } views.includeThreadToolbar.roomToolbarThreadTitleTextView.text = resources.getText(R.string.thread_timeline_title) } + isPinnedEventsTimeline() -> { + withState(timelineViewModel) { state -> + timelineArgs.let { + val matrixItem = MatrixItem.RoomItem(it.roomId, state.asyncRoomSummary()?.displayName, state.asyncRoomSummary()?.avatarUrl) + avatarRenderer.render(matrixItem, views.includeThreadToolbar.roomToolbarThreadImageView) + views.includeThreadToolbar.roomToolbarThreadShieldImageView.render(state.asyncRoomSummary()?.roomEncryptionTrustLevel) + views.includeThreadToolbar.roomToolbarThreadSubtitleTextView.text = state.asyncRoomSummary()?.displayName + } + } + views.includeRoomToolbar.roomToolbarContentView.isVisible = false + views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = true + views.includeThreadToolbar.roomToolbarThreadTitleTextView.text = resources.getText(R.string.pinned_events_timeline_title) + } else -> { views.includeRoomToolbar.roomToolbarContentView.isVisible = true views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = false @@ -1863,7 +1888,7 @@ class TimelineFragment : this.view?.hideKeyboard() MessageActionsBottomSheet - .newInstance(roomId, informationData, isThreadTimeLine()) + .newInstance(roomId, informationData, isThreadTimeLine(), isPinnedEventsTimeline()) .show(requireActivity().supportFragmentManager, "MESSAGE_CONTEXTUAL_ACTIONS") return true @@ -2159,6 +2184,15 @@ class TimelineFragment : requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) } } + is EventSharedAction.PinEvent -> { + timelineViewModel.handle(RoomDetailAction.PinEvent(action.eventId)) + } + is EventSharedAction.UnpinEvent -> { + timelineViewModel.handle(RoomDetailAction.UnpinEvent(action.eventId)) + } + is EventSharedAction.ViewPinnedEventInRoom -> { + handleViewInRoomAction(action.eventId) + } is EventSharedAction.ReplyInThread -> { if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) { onReplyInThreadClicked(action) @@ -2339,6 +2373,27 @@ class TimelineFragment : } } + /** + * Navigate to pinned events for the current room using the PinnedEventsActivity. + */ + private fun navigateToPinnedEvents() { + context?.let { + val pinnedEventsTimelineArgs = PinnedEventsTimelineArgs( + roomId = timelineArgs.roomId, + ) + navigator.openPinnedEvents(it, pinnedEventsTimelineArgs) + } + } + + private fun handleViewInRoomAction(eventId: String) { + val newRoom = timelineArgs.copy(threadTimelineArgs = null, pinnedEventsTimelineArgs = null, eventId = eventId) + context?.let { con -> + val intent = RoomDetailActivity.newIntent(con, newRoom, false) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK + con.startActivity(intent) + } + } + // VectorInviteView.Callback override fun onAcceptInvite() { timelineViewModel.handle(RoomDetailAction.AcceptInvite) @@ -2421,6 +2476,11 @@ class TimelineFragment : */ private fun isThreadTimeLine(): Boolean = withState(timelineViewModel) { it.isThreadTimeline() } + /** + * Returns true if the current room is a Pinned Messages room, false otherwise. + */ + private fun isPinnedEventsTimeline(): Boolean = withState(timelineViewModel) { it.isPinnedEventsTimeline() } + /** * Returns true if the current room is a local room, false otherwise. */ diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt index 514a2e1dc9..78581cc090 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt @@ -105,6 +105,7 @@ import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.crypto.verification.EVerificationState import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.getIdsOfPinnedEvents import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.content.WithHeldCode import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage @@ -263,10 +264,12 @@ class TimelineViewModel @AssistedInject constructor( } private fun initSafe(room: Room, timeline: Timeline) { - timeline.start(initialState.rootThreadEventId) + timeline.start(initialState.rootThreadEventId, initialState.isFromPinnedEventsTimeline) timeline.addListener(this) observeMembershipChanges() - observeSummaryState() + if (!initialState.isPinnedEventsTimeline()) { + observeSummaryState() + } getUnreadState() observeSyncState() observeDataStore() @@ -535,6 +538,8 @@ class TimelineViewModel @AssistedInject constructor( override fun handle(action: RoomDetailAction) { when (action) { + is RoomDetailAction.PinEvent -> handlePinEvent(action) + is RoomDetailAction.UnpinEvent -> handleUnpinEvent(action) is RoomDetailAction.ComposerFocusChange -> handleComposerFocusChange(action) is RoomDetailAction.SendMedia -> handleSendMedia(action) is RoomDetailAction.SendSticker -> handleSendSticker(action) @@ -944,6 +949,7 @@ class TimelineViewModel @AssistedInject constructor( else -> false } } + initialState.isPinnedEventsTimeline() -> false else -> { when (itemId) { R.id.timeline_setting -> false // replaced by show_room_info (downstream) @@ -954,6 +960,7 @@ class TimelineViewModel @AssistedInject constructor( // Show Join conference button only if there is an active conf id not joined. Otherwise fallback to default video disabled. ^ R.id.join_conference -> !state.isCallOptionAvailable() && state.jitsiState.confId != null && !state.jitsiState.hasJoined R.id.search -> state.isSearchAvailable() + R.id.open_pinned_events -> vectorPreferences.arePinnedEventsEnabled() && areTherePinnedEvents() R.id.menu_timeline_thread_list -> vectorPreferences.areThreadMessagesEnabled() // SC extras start R.id.show_room_info -> true // SC @@ -1163,6 +1170,44 @@ class TimelineViewModel @AssistedInject constructor( } } + private fun handlePinEvent(action: RoomDetailAction.PinEvent) { + viewModelScope.launch(Dispatchers.IO) { + try { + room + ?.stateService() + ?.pinEvent(action.eventId) + _viewEvents.post(RoomDetailViewEvents.ActionSuccess(action)) + } catch (failure: Throwable) { + _viewEvents.post(RoomDetailViewEvents.ActionFailure(action, failure)) + } + } + } + + private fun handleUnpinEvent(action: RoomDetailAction.UnpinEvent) { + viewModelScope.launch(Dispatchers.IO) { + try { + room + ?.stateService() + ?.unpinEvent(action.eventId) + _viewEvents.post(RoomDetailViewEvents.ActionSuccess(action)) + } catch (failure: Throwable) { + _viewEvents.post(RoomDetailViewEvents.ActionFailure(action, failure)) + } + } + } + + private fun getIdsOfPinnedEvents(): List? { + return room + ?.stateService() + ?.getStateEvent(EventType.STATE_ROOM_PINNED_EVENT, QueryStringValue.Equals("")) + ?.getIdsOfPinnedEvents() + } + + private fun areTherePinnedEvents(): Boolean { + val idsOfPinnedEvents = getIdsOfPinnedEvents() ?: return false + return idsOfPinnedEvents.isNotEmpty() + } + private fun handleResendEvent(action: RoomDetailAction.ResendMessage) { if (room == null) return val targetEventId = action.eventId diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/arguments/TimelineArgs.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/arguments/TimelineArgs.kt index 57dc49800c..dfeae679ca 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/arguments/TimelineArgs.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/arguments/TimelineArgs.kt @@ -17,6 +17,7 @@ package im.vector.app.features.home.room.detail.arguments import android.os.Parcelable +import im.vector.app.features.home.room.pinnedmessages.arguments.PinnedEventsTimelineArgs import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.share.SharedData import kotlinx.parcelize.Parcelize @@ -30,6 +31,7 @@ data class TimelineArgs( val openAtFirstUnread: Boolean? = null, val openAnonymously: Boolean = false, val threadTimelineArgs: ThreadTimelineArgs? = null, + val pinnedEventsTimelineArgs: PinnedEventsTimelineArgs? = null, val switchToParentSpace: Boolean = false, val isInviteAlreadyAccepted: Boolean = false ) : Parcelable diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt index 7bf9f536f2..6679aa1d08 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt @@ -53,6 +53,15 @@ sealed class EventSharedAction( data class ReplyInThread(val eventId: String, val startsThread: Boolean) : EventSharedAction(R.string.reply_in_thread, R.drawable.ic_reply_in_thread) + data class PinEvent(val eventId: String) : + EventSharedAction(R.string.pinning_event, R.drawable.ic_pin_event) + + data class UnpinEvent(val eventId: String) : + EventSharedAction(R.string.unpinning_event, R.drawable.ic_unpin_event) + + data class ViewPinnedEventInRoom(val eventId: String) : + EventSharedAction(R.string.view_in_room, R.drawable.ic_threads_view_in_room_24) + object ViewInRoom : EventSharedAction(R.string.view_in_room, R.drawable.ic_threads_view_in_room_24) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionState.kt index f547734651..9d76410129 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionState.kt @@ -35,7 +35,8 @@ data class ToggleState( data class ActionPermissions( val canSendMessage: Boolean = false, val canReact: Boolean = false, - val canRedact: Boolean = false + val canRedact: Boolean = false, + val canPinEvent: Boolean = false ) data class MessageActionState( @@ -50,14 +51,16 @@ data class MessageActionState( val actions: List = emptyList(), val expendedReportContentMenu: Boolean = false, val actionPermissions: ActionPermissions = ActionPermissions(), - val isFromThreadTimeline: Boolean = false + val isFromThreadTimeline: Boolean = false, + val isFromPinnedEventsTimeline: Boolean = false ) : MavericksState { constructor(args: TimelineEventFragmentArgs) : this( roomId = args.roomId, eventId = args.eventId, informationData = args.informationData, - isFromThreadTimeline = args.isFromThreadTimeline + isFromThreadTimeline = args.isFromThreadTimeline, + isFromPinnedEventsTimeline = args.isFromPinnedEventsTimeline ) fun senderName(): String = informationData.memberName?.toString() ?: "" diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsBottomSheet.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsBottomSheet.kt index 53d9e2aa99..9f6a117dfd 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsBottomSheet.kt @@ -93,14 +93,15 @@ class MessageActionsBottomSheet : } companion object { - fun newInstance(roomId: String, informationData: MessageInformationData, isFromThreadTimeline: Boolean): MessageActionsBottomSheet { + fun newInstance(roomId: String, informationData: MessageInformationData, isFromThreadTimeline: Boolean, isFromPinnedEventsTimeline: Boolean): MessageActionsBottomSheet { return MessageActionsBottomSheet().apply { setArguments( TimelineEventFragmentArgs( informationData.eventId, roomId, informationData, - isFromThreadTimeline + isFromThreadTimeline, + isFromPinnedEventsTimeline ) ) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt index dc16f9eda5..9b1e9cbc0c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt @@ -42,9 +42,11 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.keysbackup.KeysBackupState import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.getIdsOfPinnedEvents import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage import org.matrix.android.sdk.api.session.events.model.isContentReportable import org.matrix.android.sdk.api.session.events.model.isTextMessage @@ -131,7 +133,8 @@ class MessageActionsViewModel @AssistedInject constructor( val canReact = powerLevelsHelper.isUserAllowedToSend(session.myUserId, false, EventType.REACTION) val canRedact = powerLevelsHelper.isUserAbleToRedact(session.myUserId) val canSendMessage = powerLevelsHelper.isUserAllowedToSend(session.myUserId, false, EventType.MESSAGE) - val permissions = ActionPermissions(canSendMessage = canSendMessage, canRedact = canRedact, canReact = canReact) + val canPinEvent = powerLevelsHelper.isUserAllowedToSend(session.myUserId, true, EventType.STATE_ROOM_PINNED_EVENT) + val permissions = ActionPermissions(canSendMessage = canSendMessage, canRedact = canRedact, canReact = canReact, canPinEvent = canPinEvent) setState { copy(actionPermissions = permissions) } @@ -337,6 +340,15 @@ class MessageActionsViewModel @AssistedInject constructor( ) { val eventId = timelineEvent.eventId if (!timelineEvent.root.isRedacted()) { + if (initialState.isFromPinnedEventsTimeline && vectorPreferences.arePinnedEventsEnabled()) { + add(EventSharedAction.ViewPinnedEventInRoom(eventId)) + if (actionPermissions.canPinEvent) { + add(EventSharedAction.UnpinEvent(eventId)) + } + } else { + + // wrong indention for merge-ability + if (canReply(timelineEvent, messageContent, actionPermissions)) { add(EventSharedAction.Reply(eventId)) } @@ -370,6 +382,20 @@ class MessageActionsViewModel @AssistedInject constructor( add(EventSharedAction.ViewReactions(informationData)) } + if (actionPermissions.canPinEvent && vectorPreferences.arePinnedEventsEnabled()) { + val isPinned = room + ?.stateService() + ?.getStateEvent(EventType.STATE_ROOM_PINNED_EVENT, QueryStringValue.Equals("")) + ?.getIdsOfPinnedEvents() + ?.contains(eventId) + .orFalse() + if (isPinned) { + add(EventSharedAction.UnpinEvent(eventId)) + } else { + add(EventSharedAction.PinEvent(eventId)) + } + } + if (canQuote(timelineEvent, messageContent, actionPermissions) && !vectorPreferences.simplifiedMode()) { add(EventSharedAction.Quote(eventId)) } @@ -407,7 +433,7 @@ class MessageActionsViewModel @AssistedInject constructor( ) } } - } + }} // wrong indention on purpose - end if (vectorPreferences.developerMode()) { if (timelineEvent.isEncrypted() && timelineEvent.root.mCryptoError != null) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/TimelineEventFragmentArgs.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/TimelineEventFragmentArgs.kt index 2bd3c54d52..e6d14bdc7f 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/TimelineEventFragmentArgs.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/TimelineEventFragmentArgs.kt @@ -25,5 +25,6 @@ data class TimelineEventFragmentArgs( val eventId: String, val roomId: String, val informationData: MessageInformationData, - val isFromThreadTimeline: Boolean = false + val isFromThreadTimeline: Boolean = false, + val isFromPinnedEventsTimeline: Boolean = false ) : Parcelable diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index 84b71ceedf..5bc77c659c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -76,6 +76,7 @@ class TimelineItemFactory @Inject constructor( EventType.STATE_ROOM_TOPIC, EventType.STATE_ROOM_AVATAR, EventType.STATE_ROOM_MEMBER, + EventType.STATE_ROOM_PINNED_EVENT, EventType.STATE_ROOM_THIRD_PARTY_INVITE, EventType.STATE_ROOM_CANONICAL_ALIAS, EventType.STATE_ROOM_JOIN_RULES, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt index 837b828bf6..8e213e4ffd 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt @@ -31,6 +31,8 @@ import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.content.EncryptionEventContent +import org.matrix.android.sdk.api.session.events.model.getIdsOfPinnedEvents +import org.matrix.android.sdk.api.session.events.model.getPreviousIdsOfPinnedEvents import org.matrix.android.sdk.api.session.events.model.isThread import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.GuestAccess @@ -90,6 +92,7 @@ class NoticeEventFormatter @Inject constructor( EventType.STATE_ROOM_WIDGET_LEGACY -> formatWidgetEvent(event, senderName) EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(event, senderName, isDm) EventType.STATE_ROOM_POWER_LEVELS -> formatRoomPowerLevels(event, senderName) + EventType.STATE_ROOM_PINNED_EVENT -> formatPinnedEvent(event, senderName) EventType.CALL_INVITE, EventType.CALL_CANDIDATES, EventType.CALL_HANGUP, @@ -122,6 +125,27 @@ class NoticeEventFormatter @Inject constructor( } } + private fun formatPinnedEvent(event: Event, disambiguatedDisplayName: String): CharSequence? { + val idsOfPinnedEvents: List = event.getIdsOfPinnedEvents() ?: return null + val previousIdsOfPinnedEvents: List? = event.getPreviousIdsOfPinnedEvents() + // An event was pinned + val pinnedEventString = if (event.resolvedPrevContent() == null || previousIdsOfPinnedEvents != null && previousIdsOfPinnedEvents.size < idsOfPinnedEvents.size) { + if (event.isSentByCurrentUser()) { + sp.getString(R.string.notice_user_pinned_event_by_you, disambiguatedDisplayName) + } else { + sp.getString(R.string.notice_user_pinned_event, disambiguatedDisplayName) + } + // An event was unpinned + } else { + if (event.isSentByCurrentUser()) { + sp.getString(R.string.notice_user_unpinned_event_by_you, disambiguatedDisplayName) + } else { + sp.getString(R.string.notice_user_unpinned_event, disambiguatedDisplayName) + } + } + return pinnedEventString + } + private fun formatRoomPowerLevels(event: Event, disambiguatedDisplayName: String): CharSequence? { val powerLevelsContent: PowerLevelsContent = event.content.toModel() ?: return null val previousPowerLevelsContent: PowerLevelsContent = event.resolvedPrevContent().toModel() ?: return null diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt index 2dcb6cc6d8..1d28285a10 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt @@ -38,6 +38,7 @@ object TimelineDisplayableEvents { EventType.STATE_ROOM_HISTORY_VISIBILITY, EventType.STATE_ROOM_SERVER_ACL, EventType.STATE_ROOM_POWER_LEVELS, + EventType.STATE_ROOM_PINNED_EVENT, EventType.CALL_INVITE, EventType.CALL_HANGUP, EventType.CALL_ANSWER, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/merged/MergedTimelines.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/merged/MergedTimelines.kt index 58ad08f026..76ceb4b688 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/merged/MergedTimelines.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/merged/MergedTimelines.kt @@ -113,7 +113,7 @@ class MergedTimelines( secondaryTimeline.removeAllListeners() } - override fun start(rootThreadEventId: String?) { + override fun start(rootThreadEventId: String?, isFromPinnedEventsTimeline: Boolean) { mainTimeline.start() secondaryTimeline.start() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/pinnedevents/PinnedEventsActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/pinnedevents/PinnedEventsActivity.kt new file mode 100644 index 0000000000..ca673a4177 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/pinnedevents/PinnedEventsActivity.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2022 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.pinnedmessages + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.core.extensions.replaceFragment +import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.databinding.ActivityPinnedEventsBinding +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.TimelineFragment +import im.vector.app.features.home.room.detail.arguments.TimelineArgs +import im.vector.app.features.home.room.pinnedmessages.arguments.PinnedEventsTimelineArgs +import im.vector.lib.core.utils.compat.getParcelableCompat +import javax.inject.Inject + +@AndroidEntryPoint +class PinnedEventsActivity : VectorBaseActivity() { + + @Inject lateinit var avatarRenderer: AvatarRenderer + + override fun getBinding() = ActivityPinnedEventsBinding.inflate(layoutInflater) + + override fun getCoordinatorLayout() = views.coordinatorLayout + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initFragment() + } + + private fun initFragment() { + if (isFirstCreation()) { + val args = getPinnedEventsTimelineArgs() + if (args == null) { + finish() + } else { + initPinnedEventsTimelineFragment(args) + } + } + } + + private fun initPinnedEventsTimelineFragment(pinnedEventsTimelineArgs: PinnedEventsTimelineArgs) = + replaceFragment( + views.pinnedEventsActivityFragmentContainer, + TimelineFragment::class.java, + TimelineArgs( + roomId = pinnedEventsTimelineArgs.roomId, + pinnedEventsTimelineArgs = pinnedEventsTimelineArgs + ) + ) + + private fun getPinnedEventsTimelineArgs(): PinnedEventsTimelineArgs? = intent?.extras?.getParcelableCompat(PINNED_EVENTS_TIMELINE_ARGS) + + companion object { + const val PINNED_EVENTS_TIMELINE_ARGS = "PINNED_EVENTS_TIMELINE_ARGS" + + fun newIntent( + context: Context, + pinnedEventsTimelineArgs: PinnedEventsTimelineArgs?, + ): Intent { + return Intent(context, PinnedEventsActivity::class.java).apply { + putExtra(PINNED_EVENTS_TIMELINE_ARGS, pinnedEventsTimelineArgs) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/pinnedevents/arguments/PinnedEventsTimelineArgs.kt b/vector/src/main/java/im/vector/app/features/home/room/pinnedevents/arguments/PinnedEventsTimelineArgs.kt new file mode 100644 index 0000000000..2c81c2f4d5 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/pinnedevents/arguments/PinnedEventsTimelineArgs.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 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.pinnedmessages.arguments + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class PinnedEventsTimelineArgs( + val roomId: String +) : Parcelable diff --git a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt index 21494f019b..cb00a69dd6 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt @@ -60,6 +60,8 @@ import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.home.room.detail.search.SearchActivity import im.vector.app.features.home.room.detail.search.SearchArgs import im.vector.app.features.home.room.filtered.FilteredRoomsActivity +import im.vector.app.features.home.room.pinnedmessages.arguments.PinnedEventsTimelineArgs +import im.vector.app.features.home.room.pinnedmessages.PinnedEventsActivity import im.vector.app.features.home.room.threads.ThreadsActivity import im.vector.app.features.home.room.threads.arguments.ThreadListArgs import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs @@ -609,6 +611,15 @@ class DefaultNavigator @Inject constructor( ) } + override fun openPinnedEvents(context: Context, pinnedEventsTimelineArgs: PinnedEventsTimelineArgs) { + context.startActivity( + PinnedEventsActivity.newIntent( + context = context, + pinnedEventsTimelineArgs = pinnedEventsTimelineArgs + ) + ) + } + override fun openScreenSharingPermissionDialog( screenCaptureIntent: Intent, activityResultLauncher: ActivityResultLauncher diff --git a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt index 7c1e825b0e..c2ee411772 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt @@ -27,6 +27,7 @@ import androidx.fragment.app.FragmentActivity import im.vector.app.features.analytics.plan.ViewRoom import im.vector.app.features.crypto.recover.SetupMode import im.vector.app.features.displayname.getBestName +import im.vector.app.features.home.room.pinnedmessages.arguments.PinnedEventsTimelineArgs import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.location.LocationData import im.vector.app.features.location.LocationSharingMode @@ -200,6 +201,8 @@ interface Navigator { fun openThreadList(context: Context, threadTimelineArgs: ThreadTimelineArgs) + fun openPinnedEvents(context: Context, pinnedEventsTimelineArgs: PinnedEventsTimelineArgs) + fun openScreenSharingPermissionDialog( screenCaptureIntent: Intent, activityResultLauncher: ActivityResultLauncher 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 d2fb3493c8..2d62eed68d 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 @@ -278,6 +278,8 @@ class VectorPreferences @Inject constructor( private const val SETTINGS_LABS_ENABLE_ELEMENT_CALL_PERMISSION_SHORTCUTS = "SETTINGS_LABS_ENABLE_ELEMENT_CALL_PERMISSION_SHORTCUTS" + private const val SETTINGS_LABS_ENABLE_PINNED_EVENTS = "SETTINGS_LABS_ENABLE_PINNED_EVENTS" + // This key will be used to identify clients with the old thread support enabled io.element.thread const val SETTINGS_LABS_ENABLE_THREAD_MESSAGES_OLD_CLIENTS = "SETTINGS_LABS_ENABLE_THREAD_MESSAGES" @@ -1436,6 +1438,10 @@ class VectorPreferences @Inject constructor( return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_ELEMENT_CALL_PERMISSION_SHORTCUTS, false) } + fun arePinnedEventsEnabled(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_PINNED_EVENTS, getDefault(R.bool.settings_labs_pinned_events_default)) + } + /** * Indicates whether or not thread messages are enabled. */ diff --git a/vector/src/main/res/drawable/ic_open_pinned_events.xml b/vector/src/main/res/drawable/ic_open_pinned_events.xml new file mode 100644 index 0000000000..389db91616 --- /dev/null +++ b/vector/src/main/res/drawable/ic_open_pinned_events.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_pin_event.xml b/vector/src/main/res/drawable/ic_pin_event.xml new file mode 100644 index 0000000000..b0341a8aa8 --- /dev/null +++ b/vector/src/main/res/drawable/ic_pin_event.xml @@ -0,0 +1,4 @@ + + + diff --git a/vector/src/main/res/drawable/ic_unpin_event.xml b/vector/src/main/res/drawable/ic_unpin_event.xml new file mode 100644 index 0000000000..514d21ec17 --- /dev/null +++ b/vector/src/main/res/drawable/ic_unpin_event.xml @@ -0,0 +1,4 @@ + + + diff --git a/vector/src/main/res/layout/activity_pinned_events.xml b/vector/src/main/res/layout/activity_pinned_events.xml new file mode 100644 index 0000000000..93a75fe2e3 --- /dev/null +++ b/vector/src/main/res/layout/activity_pinned_events.xml @@ -0,0 +1,24 @@ + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/menu/menu_timeline.xml b/vector/src/main/res/menu/menu_timeline.xml index 4a19e19065..fed21fc34d 100644 --- a/vector/src/main/res/menu/menu_timeline.xml +++ b/vector/src/main/res/menu/menu_timeline.xml @@ -40,6 +40,16 @@ app:showAsAction="always" tools:visible="true" /> + + + + +