diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 0ab1d85f0f..a964cb7d6a 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -373,6 +373,7 @@ Are you sure you want to sign out? Voice Call Video Call + Open Pinned Messages View Threads Mark all as read Quick reply @@ -801,6 +802,12 @@ Threads Beta Your homeserver does not currently support threads, so this feature may be unreliable. Some threaded messages may not be reliably available. %sDo you want to enable threads anyway? + + Pin + Unpin + Pinned Messages + %1$s pinned a message. + %1$s unpinned a message. Search @@ -3032,6 +3039,7 @@ Auto Report Decryption Errors. Your system will automatically send logs when an unable to decrypt error occurs + Enable Pinned Messages Enable Thread Messages Note: app will be restarted Show latest user info 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 40ce6ecb5c..d5c0ad46c5 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 @@ -447,3 +448,11 @@ fun Event.supportsNotification() = fun Event.isContentReportable() = this.getClearType() in EventType.MESSAGE + EventType.STATE_ROOM_BEACON_INFO.values + +fun Event.getIdsOfPinnedEvents(): MutableList? { + return getClearContent()?.toModel()?.eventIds +} + +fun Event.getPreviousIdsOfPinnedEvents(): MutableList? { + 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 013b452ced..6cde754d77 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..0475ee0fc4 --- /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: MutableList +) 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..2b008ab732 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,11 @@ interface StateService { */ suspend fun deleteAvatar() + /** + * Pin a message of the room. + */ + suspend fun pinMessage(eventIds: MutableList) + /** * Send a state event to the room. * @param eventType The type of event to send. @@ -103,6 +108,16 @@ interface StateService { */ fun getStateEventsLive(eventTypes: Set, stateKey: QueryStateEventValue): LiveData> + /** + * Get state event containing the IDs of pinned events of the room + */ + fun getPinnedEventsState(): Event? + + /** + * Tells if an event is a pinned message + */ + fun isPinned(eventId: String): Boolean? + suspend fun setJoinRulePublic() suspend fun setJoinRuleInviteOnly() suspend fun setJoinRuleRestricted(allowList: List) 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 9ac33c0545..2e9b87b797 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 @@ -43,7 +43,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, rootPinnedMessageEventId: String? = null) /** * 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..3d9f4e3dc7 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, + /** + * The root pinned message eventId if this is a pinned messages timeline, or null if this is NOT a pinned messages timeline. + */ + val rootPinnedMessageEventId: String? = null, /** * 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 isPinnedMessagesTimeline() = rootPinnedMessageEventId != null } 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 31bed90b62..bf0f482c13 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 @@ -33,6 +33,7 @@ import org.matrix.android.sdk.internal.session.room.membership.RoomMembersRespon import org.matrix.android.sdk.internal.session.room.membership.admin.UserIdAndReason import org.matrix.android.sdk.internal.session.room.membership.joining.InviteBody import org.matrix.android.sdk.internal.session.room.membership.threepid.ThreePidInviteBody +import org.matrix.android.sdk.internal.session.room.pinnedmessages.PinnedEventsStateResponse import org.matrix.android.sdk.internal.session.room.read.ReadBody import org.matrix.android.sdk.internal.session.room.relation.RelationsResponse import org.matrix.android.sdk.internal.session.room.reporting.ReportContentBody @@ -233,11 +234,22 @@ internal interface RoomAPI { ): SendResponse /** - * Get state events of a room + * Get all state events of a room * Ref: https://matrix.org/docs/spec/client_server/r0.6.1#get-matrix-client-r0-rooms-roomid-state */ @GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/state") - suspend fun getRoomState(@Path("roomId") roomId: String): List + suspend fun getAllRoomStates(@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 + ): PinnedEventsStateResponse /** * Paginate relations for event based in normal topological order. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/peeking/ResolveRoomStateTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/peeking/ResolveRoomStateTask.kt index 64cbef23ec..24de3e1443 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/peeking/ResolveRoomStateTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/peeking/ResolveRoomStateTask.kt @@ -36,7 +36,7 @@ internal class DefaultResolveRoomStateTask @Inject constructor( override suspend fun execute(params: ResolveRoomStateTask.Params): List { return executeRequest(globalErrorReceiver) { - roomAPI.getRoomState(params.roomId) + roomAPI.getAllRoomStates(params.roomId) } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/pinnedmessages/PinnedEventsStateResponse.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/pinnedmessages/PinnedEventsStateResponse.kt new file mode 100644 index 0000000000..c964f1c769 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/pinnedmessages/PinnedEventsStateResponse.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2022 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.internal.session.room.pinnedmessages + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class PinnedEventsStateResponse( + /** + * A unique identifier for the event. + */ + @Json(name = "pinned") val pinned: List +) 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..51cf975574 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,23 @@ internal class DefaultStateService @AssistedInject constructor( ) } + override suspend fun pinMessage(eventIds: MutableList) { + sendStateEvent( + eventType = EventType.STATE_ROOM_PINNED_EVENT, + body = PinnedEventsStateContent(eventIds).toContent(), + stateKey = "" + ) + } + + override fun getPinnedEventsState(): Event? { + return getStateEvent(EventType.STATE_ROOM_PINNED_EVENT, QueryStringValue.Equals("")) + } + + override fun isPinned(eventId: String): Boolean? { + val idsOfPinnedEvents: MutableList = getPinnedEventsState()?.getIdsOfPinnedEvents() ?: return null + return idsOfPinnedEvents.contains(eventId) + } + 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 0854cc5cf4..458d2b8af0 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 @@ -34,6 +34,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.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent @@ -63,7 +66,8 @@ internal class DefaultTimeline( private val settings: TimelineSettings, private val coroutineDispatchers: MatrixCoroutineDispatchers, private val clock: Clock, - stateEventDataSource: StateEventDataSource, + private val stateEventDataSource: StateEventDataSource, + private val timelineEventDataSource: TimelineEventDataSource, paginationTask: PaginationTask, getEventTask: GetContextOfEventTask, fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, @@ -95,6 +99,9 @@ internal class DefaultTimeline( private var isFromThreadTimeline = false private var rootThreadEventId: String? = null + private var isFromPinnedMessagesTimeline = false + private var rootPinnedMessageEventId: String? = null + private val strategyDependencies = LoadTimelineStrategy.Dependencies( timelineSettings = settings, realm = backgroundRealm, @@ -125,7 +132,11 @@ internal class DefaultTimeline( override fun addListener(listener: Timeline.Listener): Boolean { listeners.add(listener) timelineScope.launch { - val snapshot = strategy.buildSnapshot() + val snapshot = if (isFromPinnedMessagesTimeline) { + getPinnedEvents() + } else { + strategy.buildSnapshot() + } withContext(coroutineDispatchers.main) { tryOrNull { listener.onTimelineUpdated(snapshot) } } @@ -141,7 +152,7 @@ internal class DefaultTimeline( listeners.clear() } - override fun start(rootThreadEventId: String?) { + override fun start(rootThreadEventId: String?, rootPinnedMessageEventId: String?) { timelineScope.launch { loadRoomMembersIfNeeded() } @@ -150,6 +161,8 @@ internal class DefaultTimeline( if (isStarted.compareAndSet(false, true)) { isFromThreadTimeline = rootThreadEventId != null this@DefaultTimeline.rootThreadEventId = rootThreadEventId + isFromPinnedMessagesTimeline = rootPinnedMessageEventId != null + this@DefaultTimeline.rootPinnedMessageEventId = rootPinnedMessageEventId // / val realm = Realm.getInstance(realmConfiguration) ensureReadReceiptAreLoaded(realm) @@ -254,7 +267,12 @@ internal class DefaultTimeline( } } Timber.v("$baseLogMessage: result $loadMoreResult") - val hasMoreToLoad = loadMoreResult != LoadMoreResult.REACHED_END + val hasMoreToLoad = if (isFromPinnedMessagesTimeline) { + !areAllPinnedMessagesLoaded() + } else { + loadMoreResult != LoadMoreResult.REACHED_END + } + updateState(direction) { it.copy(loading = false, hasMoreToLoad = hasMoreToLoad) } @@ -334,7 +352,11 @@ internal class DefaultTimeline( } private suspend fun postSnapshot() { - val snapshot = strategy.buildSnapshot() + val snapshot = if (isFromPinnedMessagesTimeline) { + getPinnedEvents() + } else { + strategy.buildSnapshot() + } Timber.v("Post snapshot of ${snapshot.size} events") withContext(coroutineDispatchers.main) { listeners.forEach { @@ -349,6 +371,28 @@ internal class DefaultTimeline( } } + private fun getIdsOfPinnedEvents(): MutableList { + return stateEventDataSource + .getStateEvent(roomId, EventType.STATE_ROOM_PINNED_EVENT, QueryStringValue.Equals("")) + ?.getIdsOfPinnedEvents() ?: mutableListOf("") + } + + private fun getPinnedEvents(): List { + val idsOfPinnedEvents = getIdsOfPinnedEvents() + val pinnedEvents = ArrayList() + for (id in idsOfPinnedEvents) { + val timelineEvent = timelineEventDataSource.getTimelineEvent(roomId, id) + if (timelineEvent != null) { + pinnedEvents.add(timelineEvent) + } + } + return pinnedEvents.reversed() + } + + private fun areAllPinnedMessagesLoaded(): 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 b1a3d51b36..6564231843 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 @@ -82,6 +82,7 @@ internal class DefaultTimelineService @AssistedInject constructor( lightweightSettingsStorage = lightweightSettingsStorage, clock = clock, stateEventDataSource = stateEventDataSource, + timelineEventDataSource = timelineEventDataSource, ) } diff --git a/vector-config/src/main/res/values/config-settings.xml b/vector-config/src/main/res/values/config-settings.xml index ad9c16c214..caa60af797 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 false true false diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 9c8186b2d4..9d62904fc4 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -149,6 +149,7 @@ + , 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 897594ffad..b60154192e 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 @@ -71,6 +71,7 @@ data class RoomDetailViewState( val isAllowedToManageWidgets: Boolean = false, val isAllowedToStartWebRTCCall: Boolean = true, val isAllowedToSetupEncryption: Boolean = true, + val rootPinnedMessageEventId: String?, val hasFailedSending: Boolean = false, val jitsiState: JitsiState = JitsiState(), val switchToParentSpace: Boolean = false, @@ -92,6 +93,7 @@ data class RoomDetailViewState( rootThreadEventId = args.threadTimelineArgs?.rootThreadEventId, showKeyboardWhenPresented = args.threadTimelineArgs?.showKeyboard.orFalse(), sharedData = args.sharedData, + rootPinnedMessageEventId = args.pinnedMessagesTimelineArgs?.rootPinnedMessageEventId, ) fun isCallOptionAvailable(): Boolean { @@ -113,5 +115,7 @@ data class RoomDetailViewState( fun isThreadTimeline() = rootThreadEventId != null + fun isPinnedMessagesTimeline() = rootPinnedMessageEventId != null + 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 6ab20275c2..90b9929762 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 @@ -159,6 +159,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.PinnedMessagesTimelineArgs import im.vector.app.features.home.room.threads.ThreadsManager import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.html.EventHtmlRenderer @@ -378,6 +379,11 @@ class TimelineFragment : ) } + if (isPinnedMessagesTimeline()) { + views.composerContainer.isVisible = false + views.voiceMessageRecorderContainer.isVisible = false + } + timelineViewModel.observeViewEvents { when (it) { is RoomDetailViewEvents.Failure -> displayErrorMessage(it) @@ -877,6 +883,10 @@ class TimelineFragment : callActionsHandler.onVideoCallClicked() true } + R.id.open_pinned_messages -> { + navigateToPinnedMessages() + true + } R.id.menu_timeline_thread_list -> { navigateToThreadList() true @@ -1106,7 +1116,7 @@ class TimelineFragment : } private fun updateJumpToReadMarkerViewVisibility() { - if (isThreadTimeLine()) return + if (isThreadTimeLine() || isPinnedMessagesTimeline()) return viewLifecycleOwner.lifecycleScope.launchWhenResumed { val state = timelineViewModel.awaitState() val showJumpToUnreadBanner = when (state.unreadState) { @@ -1235,6 +1245,17 @@ class TimelineFragment : } views.includeThreadToolbar.roomToolbarThreadTitleTextView.text = resources.getText(R.string.thread_timeline_title) } + isPinnedMessagesTimeline() -> { + views.includeRoomToolbar.roomToolbarContentView.isVisible = false + views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = true + timelineArgs.pinnedMessagesTimelineArgs?.let { + val matrixItem = MatrixItem.RoomItem(it.roomId, it.displayName, it.avatarUrl) + avatarRenderer.render(matrixItem, views.includeThreadToolbar.roomToolbarThreadImageView) + views.includeThreadToolbar.roomToolbarThreadShieldImageView.render(it.roomEncryptionTrustLevel) + views.includeThreadToolbar.roomToolbarThreadSubtitleTextView.text = it.displayName + } + views.includeThreadToolbar.roomToolbarThreadTitleTextView.text = resources.getText(R.string.pinned_messages_timeline_title) + } else -> { views.includeRoomToolbar.roomToolbarContentView.isVisible = true views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = false @@ -1543,7 +1564,7 @@ class TimelineFragment : this.view?.hideKeyboard() MessageActionsBottomSheet - .newInstance(roomId, informationData, isThreadTimeLine()) + .newInstance(roomId, informationData, isThreadTimeLine(), isPinnedMessagesTimeline()) .show(requireActivity().supportFragmentManager, "MESSAGE_CONTEXTUAL_ACTIONS") return true @@ -1795,6 +1816,15 @@ class TimelineFragment : requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) } } + is EventSharedAction.PinMessage -> { + timelineViewModel.handle(RoomDetailAction.PinMessage(action.eventId)) + } + is EventSharedAction.UnpinMessage -> { + timelineViewModel.handle(RoomDetailAction.UnpinMessage(action.eventId)) + } + is EventSharedAction.ViewPinnedMessageInRoom -> { + handleViewInRoomAction(action.eventId) + } is EventSharedAction.ReplyInThread -> { if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) { onReplyInThreadClicked(action) @@ -1974,6 +2004,32 @@ class TimelineFragment : } } + /** + * Navigate to pinned messages for the current room using the PinnedMessagesActivity. + */ + private fun navigateToPinnedMessages() = withState(timelineViewModel) { state -> + val pinnedEventId = timelineViewModel.getIdOfLastPinnedEvent() + context?.let { + val pinnedMessagesTimelineArgs = PinnedMessagesTimelineArgs( + roomId = timelineArgs.roomId, + displayName = state.asyncRoomSummary()?.displayName, + roomEncryptionTrustLevel = state.asyncRoomSummary()?.roomEncryptionTrustLevel, + avatarUrl = state.asyncRoomSummary()?.avatarUrl, + rootPinnedMessageEventId = pinnedEventId + ) + navigator.openPinnedMessages(it, pinnedMessagesTimelineArgs) + } + } + + private fun handleViewInRoomAction(eventId: String) { + val newRoom = timelineArgs.copy(threadTimelineArgs = null, pinnedMessagesTimelineArgs = 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) @@ -2027,6 +2083,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 isPinnedMessagesTimeline(): Boolean = withState(timelineViewModel) { it.isPinnedMessagesTimeline() } + /** * 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 02782783b8..8044845301 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 @@ -89,6 +89,7 @@ import org.matrix.android.sdk.api.raw.RawService import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.MXCryptoError 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.LocalEcho import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.content.WithHeldCode @@ -203,10 +204,12 @@ class TimelineViewModel @AssistedInject constructor( } private fun initSafe(room: Room, timeline: Timeline) { - timeline.start(initialState.rootThreadEventId) + timeline.start(initialState.rootThreadEventId, initialState.rootPinnedMessageEventId) timeline.addListener(this) observeMembershipChanges() - observeSummaryState() + if (!initialState.isPinnedMessagesTimeline()) { + observeSummaryState() + } getUnreadState() observeSyncState() observeDataStore() @@ -448,6 +451,8 @@ class TimelineViewModel @AssistedInject constructor( override fun handle(action: RoomDetailAction) { when (action) { + is RoomDetailAction.PinMessage -> handlePinMessage(action) + is RoomDetailAction.UnpinMessage -> handleUnpinMessage(action) is RoomDetailAction.ComposerFocusChange -> handleComposerFocusChange(action) is RoomDetailAction.SendMedia -> handleSendMedia(action) is RoomDetailAction.SendSticker -> handleSendSticker(action) @@ -757,6 +762,14 @@ class TimelineViewModel @AssistedInject constructor( return room?.membershipService()?.getRoomMember(userId) } + fun getIdOfLastPinnedEvent(): String? { + return room + ?.stateService() + ?.getPinnedEventsState() + ?.getIdsOfPinnedEvents() + ?.last() + } + private fun handleComposerFocusChange(action: RoomDetailAction.ComposerFocusChange) { if (room == null) return // Ensure outbound session keys @@ -827,6 +840,7 @@ class TimelineViewModel @AssistedInject constructor( else -> false } } + initialState.isPinnedMessagesTimeline() -> false else -> { when (itemId) { R.id.timeline_setting -> true @@ -837,6 +851,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_messages -> vectorPreferences.arePinnedMessagesEnabled() && areTherePinnedMessages() R.id.menu_timeline_thread_list -> vectorPreferences.areThreadMessagesEnabled() R.id.dev_tools -> vectorPreferences.developerMode() else -> false @@ -1023,6 +1038,47 @@ class TimelineViewModel @AssistedInject constructor( _viewEvents.post(RoomDetailViewEvents.NavigateToEvent(targetEventId)) } + private fun handlePinMessage(action: RoomDetailAction.PinMessage) { + if (room == null) return + val idsOfPinnedMessages = getIdsOfPinnedEvents() + if (idsOfPinnedMessages == null) return + idsOfPinnedMessages.add(action.eventId) + sendPinnedStateEvent(idsOfPinnedMessages, action) + } + + private fun handleUnpinMessage(action: RoomDetailAction.UnpinMessage) { + if (room == null) return + val idsOfPinnedMessages = getIdsOfPinnedEvents() + if (idsOfPinnedMessages == null) return + idsOfPinnedMessages.remove(action.eventId) + sendPinnedStateEvent(idsOfPinnedMessages, action) + } + + private fun getIdsOfPinnedEvents(): MutableList? { + return room + ?.stateService() + ?.getPinnedEventsState() + ?.getIdsOfPinnedEvents() + } + + private fun areTherePinnedMessages(): Boolean { + val idsOfPinnedMessages = getIdsOfPinnedEvents() ?: return false + return idsOfPinnedMessages.isNotEmpty() + } + + private fun sendPinnedStateEvent(eventIds: MutableList, action: RoomDetailAction) { + viewModelScope.launch(Dispatchers.IO) { + try { + room + ?.stateService() + ?.pinMessage(eventIds) + _viewEvents.post(RoomDetailViewEvents.ActionSuccess(action)) + } catch (failure: Throwable) { + _viewEvents.post(RoomDetailViewEvents.ActionFailure(action, failure)) + } + } + } + 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 a21567acb1..d71f8ae7a9 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.PinnedMessagesTimelineArgs import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.share.SharedData import kotlinx.parcelize.Parcelize @@ -28,6 +29,7 @@ data class TimelineArgs( val sharedData: SharedData? = null, val openShareSpaceForId: String? = null, val threadTimelineArgs: ThreadTimelineArgs? = null, + val pinnedMessagesTimelineArgs: PinnedMessagesTimelineArgs? = 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..1e7b7c99ef 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 PinMessage(val eventId: String) : + EventSharedAction(R.string.pinning_message, R.drawable.ic_pin_message) + + data class UnpinMessage(val eventId: String) : + EventSharedAction(R.string.unpinning_message, R.drawable.ic_unpin_message) + + data class ViewPinnedMessageInRoom(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..fa03877219 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 canPinMessage: 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 isFromPinnedMessagesTimeline: Boolean = false ) : MavericksState { constructor(args: TimelineEventFragmentArgs) : this( roomId = args.roomId, eventId = args.eventId, informationData = args.informationData, - isFromThreadTimeline = args.isFromThreadTimeline + isFromThreadTimeline = args.isFromThreadTimeline, + isFromPinnedMessagesTimeline = args.isFromPinnedMessagesTimeline ) 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..c0740f0905 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, isFromPinnedMessagesTimeline: Boolean): MessageActionsBottomSheet { return MessageActionsBottomSheet().apply { setArguments( TimelineEventFragmentArgs( informationData.eventId, roomId, informationData, - isFromThreadTimeline + isFromThreadTimeline, + isFromPinnedMessagesTimeline ) ) } 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 a6d7e8386f..341fa83987 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 @@ -131,7 +131,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 canPinMessage = powerLevelsHelper.isUserAllowedToSend(session.myUserId, false, EventType.STATE_ROOM_PINNED_EVENT) + val permissions = ActionPermissions(canSendMessage = canSendMessage, canRedact = canRedact, canReact = canReact, canPinMessage = canPinMessage) setState { copy(actionPermissions = permissions) } @@ -333,6 +334,13 @@ class MessageActionsViewModel @AssistedInject constructor( ) { val eventId = timelineEvent.eventId if (!timelineEvent.root.isRedacted()) { + if (initialState.isFromPinnedMessagesTimeline) { + if (actionPermissions.canPinMessage && vectorPreferences.arePinnedMessagesEnabled()) { + add(EventSharedAction.UnpinMessage(eventId)) + add(EventSharedAction.ViewPinnedMessageInRoom(eventId)) + } + return + } if (canReply(timelineEvent, messageContent, actionPermissions)) { add(EventSharedAction.Reply(eventId)) } @@ -362,6 +370,16 @@ class MessageActionsViewModel @AssistedInject constructor( add(EventSharedAction.AddReaction(eventId)) } + if (actionPermissions.canPinMessage && vectorPreferences.arePinnedMessagesEnabled()) { + val id: String = timelineEvent.root.eventId ?: return + val isPinned: Boolean = room?.stateService()?.isPinned(id) ?: return + if (isPinned) { + add(EventSharedAction.UnpinMessage(eventId)) + } else { + add(EventSharedAction.PinMessage(eventId)) + } + } + if (canViewReactions(timelineEvent)) { add(EventSharedAction.ViewReactions(informationData)) } 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..17d55ac8b9 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 isFromPinnedMessagesTimeline: 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 ae3ea143a7..2207ffc46a 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 @@ -71,6 +71,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 3f702ed72d..e2956d9d98 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 @@ -28,6 +28,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 @@ -86,6 +88,7 @@ class NoticeEventFormatter @Inject constructor( EventType.STATE_ROOM_WIDGET_LEGACY -> formatWidgetEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName, isDm) EventType.STATE_ROOM_POWER_LEVELS -> formatRoomPowerLevels(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) + EventType.STATE_ROOM_PINNED_EVENT -> formatPinnedEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) EventType.CALL_INVITE, EventType.CALL_CANDIDATES, EventType.CALL_HANGUP, @@ -118,6 +121,19 @@ class NoticeEventFormatter @Inject constructor( } } + private fun formatPinnedEvent(event: Event, disambiguatedDisplayName: String): CharSequence? { + val idsOfPinnedEvents: MutableList = event.getIdsOfPinnedEvents() ?: return null + val previousIdsOfPinnedEvents: MutableList? = event.getPreviousIdsOfPinnedEvents() + // A message was pinned + val pinnedMessageString = if (event.resolvedPrevContent() == null || previousIdsOfPinnedEvents != null && previousIdsOfPinnedEvents.size < idsOfPinnedEvents.size) { + sp.getString(R.string.user_pinned_message, disambiguatedDisplayName) + // A message was unpinned + } else { + sp.getString(R.string.user_unpinned_message, disambiguatedDisplayName) + } + return pinnedMessageString + } + private fun formatRoomPowerLevels(event: Event, disambiguatedDisplayName: String): CharSequence? { val powerLevelsContent: PowerLevelsContent = event.content.toModel() ?: return null val previousPowerLevelsContent: PowerLevelsContent = event.resolvedPrevContent().toModel() ?: return null @@ -178,6 +194,7 @@ class NoticeEventFormatter @Inject constructor( } fun format(event: Event, senderName: String?, isDm: Boolean): CharSequence? { + Timber.v("°°°°°°°°°°°°°°°°°°°format(event: Event, senderName: String?, isDm: Boolean)") return when (val type = event.getClearType()) { EventType.STATE_ROOM_JOIN_RULES -> formatJoinRulesEvent(event, senderName, isDm) EventType.STATE_ROOM_NAME -> formatRoomNameEvent(event, senderName) @@ -872,6 +889,7 @@ class NoticeEventFormatter @Inject constructor( } fun formatRedactedEvent(event: Event): String { + Timber.v("°°°°°°°formatRedactedEvent°°°°°°") return (event .unsignedData ?.redactedEvent 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 51e961f247..4fb0a3943d 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..55d82e9e4f 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?, rootPinnedMessageEventId: String?) { mainTimeline.start() secondaryTimeline.start() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/pinnedmessages/PinnedMessagesActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/pinnedmessages/PinnedMessagesActivity.kt new file mode 100644 index 0000000000..3c5c305a20 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/pinnedmessages/PinnedMessagesActivity.kt @@ -0,0 +1,99 @@ +/* + * 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.ActivityPinnedMessagesBinding +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.PinnedMessagesTimelineArgs +import im.vector.lib.core.utils.compat.getParcelableCompat +import javax.inject.Inject + +@AndroidEntryPoint +class PinnedMessagesActivity : VectorBaseActivity() { + + @Inject lateinit var avatarRenderer: AvatarRenderer + + override fun getBinding() = ActivityPinnedMessagesBinding.inflate(layoutInflater) + + override fun getCoordinatorLayout() = views.coordinatorLayout + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initFragment() + } + + private fun initFragment() { + if (isFirstCreation()) { + when (val fragment = fragmentToNavigate()) { + is DisplayFragment.PinnedMessagesTimeLine -> { + initPinnedMessagesTimelineFragment(fragment.pinnedMessagesTimelineArgs) + } + is DisplayFragment.ErrorFragment -> { + finish() + } + } + } + } + + private fun initPinnedMessagesTimelineFragment(pinnedMessagesTimelineArgs: PinnedMessagesTimelineArgs) = + replaceFragment( + views.pinnedMessagesActivityFragmentContainer, + TimelineFragment::class.java, + TimelineArgs( + roomId = pinnedMessagesTimelineArgs.roomId, + pinnedMessagesTimelineArgs = pinnedMessagesTimelineArgs + ) + ) + + /** + * Determine in witch fragment we should navigate. + */ + private fun fragmentToNavigate(): DisplayFragment { + getPinnedMessagesTimelineArgs()?.let { + return DisplayFragment.PinnedMessagesTimeLine(it) + } + return DisplayFragment.ErrorFragment + } + + private fun getPinnedMessagesTimelineArgs(): PinnedMessagesTimelineArgs? = intent?.extras?.getParcelableCompat(PINNED_MESSAGES_TIMELINE_ARGS) + + companion object { + const val PINNED_MESSAGES_TIMELINE_ARGS = "PINNED_MESSAGES_TIMELINE_ARGS" + + fun newIntent( + context: Context, + pinnedMessagesTimelineArgs: PinnedMessagesTimelineArgs?, + ): Intent { + return Intent(context, PinnedMessagesActivity::class.java).apply { + putExtra(PINNED_MESSAGES_TIMELINE_ARGS, pinnedMessagesTimelineArgs) + } + } + } + + sealed class DisplayFragment { + data class PinnedMessagesTimeLine(val pinnedMessagesTimelineArgs: PinnedMessagesTimelineArgs) : DisplayFragment() + object ErrorFragment : DisplayFragment() + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/pinnedmessages/arguments/PinnedMessagesTimelineArgs.kt b/vector/src/main/java/im/vector/app/features/home/room/pinnedmessages/arguments/PinnedMessagesTimelineArgs.kt new file mode 100644 index 0000000000..daf6bb9240 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/pinnedmessages/arguments/PinnedMessagesTimelineArgs.kt @@ -0,0 +1,30 @@ +/* + * 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 +import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel + +@Parcelize +data class PinnedMessagesTimelineArgs( + val roomId: String, + val displayName: String?, + val avatarUrl: String?, + val roomEncryptionTrustLevel: RoomEncryptionTrustLevel?, + val rootPinnedMessageEventId: 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 3970af385e..58a28386fd 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 @@ -58,6 +58,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.PinnedMessagesTimelineArgs +import im.vector.app.features.home.room.pinnedmessages.PinnedMessagesActivity 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 @@ -599,6 +601,15 @@ class DefaultNavigator @Inject constructor( ) } + override fun openPinnedMessages(context: Context, pinnedMessagesTimelineArgs: PinnedMessagesTimelineArgs) { + context.startActivity( + PinnedMessagesActivity.newIntent( + context = context, + pinnedMessagesTimelineArgs = pinnedMessagesTimelineArgs + ) + ) + } + 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 1d67f883a3..6cba3a298f 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.PinnedMessagesTimelineArgs import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.location.LocationData import im.vector.app.features.location.LocationSharingMode @@ -198,6 +199,8 @@ interface Navigator { fun openThreadList(context: Context, threadTimelineArgs: ThreadTimelineArgs) + fun openPinnedMessages(context: Context, pinnedMessagesTimelineArgs: PinnedMessagesTimelineArgs) + 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 d46b819cce..0278ff91c2 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 @@ -234,6 +234,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_MESSAGES = "SETTINGS_LABS_ENABLE_PINNED_MESSAGES" + // 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" @@ -1112,6 +1114,10 @@ class VectorPreferences @Inject constructor( return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_ELEMENT_CALL_PERMISSION_SHORTCUTS, false) } + fun arePinnedMessagesEnabled(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_PINNED_MESSAGES, getDefault(R.bool.settings_labs_pinned_messages_default)) + } + /** * Indicates whether or not thread messages are enabled. */ diff --git a/vector/src/main/res/drawable/ic_open_pinned_messages.xml b/vector/src/main/res/drawable/ic_open_pinned_messages.xml new file mode 100644 index 0000000000..389db91616 --- /dev/null +++ b/vector/src/main/res/drawable/ic_open_pinned_messages.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_pin_message.xml b/vector/src/main/res/drawable/ic_pin_message.xml new file mode 100644 index 0000000000..9fc7b8cecc --- /dev/null +++ b/vector/src/main/res/drawable/ic_pin_message.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_unpin_message.xml b/vector/src/main/res/drawable/ic_unpin_message.xml new file mode 100644 index 0000000000..0cad148ca7 --- /dev/null +++ b/vector/src/main/res/drawable/ic_unpin_message.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/layout/activity_pinned_messages.xml b/vector/src/main/res/layout/activity_pinned_messages.xml new file mode 100644 index 0000000000..e7b0ef00c9 --- /dev/null +++ b/vector/src/main/res/layout/activity_pinned_messages.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 5c35540932..06f6e51693 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" /> + + + + +