diff --git a/changelog.d/7762.feature b/changelog.d/7762.feature new file mode 100644 index 00000000000..485acf9415b --- /dev/null +++ b/changelog.d/7762.feature @@ -0,0 +1 @@ +Added lab feature to pin/unpin messages diff --git a/gradle.properties b/gradle.properties index 2c999af35d5..4e3c90491ff 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ # The setting is particularly useful for tweaking memory settings. # Build Time Optimizations -org.gradle.jvmargs=-Xmx4g -Xms512M -XX:MaxPermSize=2048m -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC +org.gradle.jvmargs=-Xmx4g -Xms512M -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC org.gradle.configureondemand=true org.gradle.parallel=true org.gradle.vfs.watch=true diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 0ab1d85f0f5..59980c0e3e2 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -77,6 +77,10 @@ • Servers matching %s are allowed. • Servers matching IP literals are allowed. • Servers matching IP literals are banned. + %1$s pinned a message. + %1$s unpinned a message. + You pinned a message. + You unpinned a message. %s changed the server ACLs for this room. You changed the server ACLs for this room. @@ -373,6 +377,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 +806,10 @@ 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 Search @@ -3032,6 +3041,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 40ce6ecb5c1..6fe608fd6d5 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(): 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 013b452ced2..6cde754d770 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 00000000000..646cf62cdad --- /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 6ca63c2c49b..851dea8b9f7 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 9ac33c05451..f49bae1b9b0 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, 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 fd6732d0d13..64c6a8f0682 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 31bed90b622..e80c860eb0f 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 @@ -233,11 +233,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 + ): Content /** * 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 64cbef23ecd..24de3e1443a 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/state/DefaultStateService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt index ad47b82428c..7a12bf88961 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 0854cc5cf41..469c387ccc6 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,8 @@ internal class DefaultTimeline( private var isFromThreadTimeline = false private var rootThreadEventId: String? = null + private var isFromPinnedEventsTimeline = false + private val strategyDependencies = LoadTimelineStrategy.Dependencies( timelineSettings = settings, realm = backgroundRealm, @@ -125,7 +131,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) } } @@ -141,7 +151,7 @@ internal class DefaultTimeline( listeners.clear() } - override fun start(rootThreadEventId: String?) { + override fun start(rootThreadEventId: String?, isFromPinnedEventsTimeline: Boolean) { timelineScope.launch { loadRoomMembersIfNeeded() } @@ -150,6 +160,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) @@ -254,7 +265,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) } @@ -334,7 +350,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") withContext(coroutineDispatchers.main) { listeners.forEach { @@ -349,6 +369,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 b1a3d51b36a..65642318432 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 ad9c16c214d..f32b1fd95b0 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 9c8186b2d41..60c072c8ad8 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 897594ffadb..1e4aafc255b 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 @@ -80,6 +80,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( @@ -92,6 +93,7 @@ data class RoomDetailViewState( rootThreadEventId = args.threadTimelineArgs?.rootThreadEventId, showKeyboardWhenPresented = args.threadTimelineArgs?.showKeyboard.orFalse(), sharedData = args.sharedData, + isFromPinnedEventsTimeline = args.pinnedEventsTimelineArgs != null, ) fun isCallOptionAvailable(): Boolean { @@ -113,5 +115,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 6ab20275c25..14f7a2ca934 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.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.html.EventHtmlRenderer @@ -378,6 +379,10 @@ class TimelineFragment : ) } + if (isPinnedEventsTimeline()) { + views.hideComposerViews() + } + timelineViewModel.observeViewEvents { when (it) { is RoomDetailViewEvents.Failure -> displayErrorMessage(it) @@ -877,6 +882,10 @@ class TimelineFragment : callActionsHandler.onVideoCallClicked() true } + R.id.open_pinned_events -> { + navigateToPinnedEvents() + true + } R.id.menu_timeline_thread_list -> { navigateToThreadList() true @@ -1106,7 +1115,7 @@ class TimelineFragment : } private fun updateJumpToReadMarkerViewVisibility() { - if (isThreadTimeLine()) return + if (isThreadTimeLine() || isPinnedEventsTimeline()) return viewLifecycleOwner.lifecycleScope.launchWhenResumed { val state = timelineViewModel.awaitState() val showJumpToUnreadBanner = when (state.unreadState) { @@ -1187,6 +1196,9 @@ class TimelineFragment : vectorBaseActivity.finish() } updateLiveLocationIndicator(mainState.isSharingLiveLocation) + if (isPinnedEventsTimeline()) { + views.hideComposerViews() + } } private fun handleRoomSummaryFailure(asyncRoomSummary: Fail) { @@ -1235,6 +1247,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 @@ -1543,7 +1568,7 @@ class TimelineFragment : this.view?.hideKeyboard() MessageActionsBottomSheet - .newInstance(roomId, informationData, isThreadTimeLine()) + .newInstance(roomId, informationData, isThreadTimeLine(), isPinnedEventsTimeline()) .show(requireActivity().supportFragmentManager, "MESSAGE_CONTEXTUAL_ACTIONS") return true @@ -1795,6 +1820,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) @@ -1974,6 +2008,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) @@ -2027,6 +2082,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 02782783b84..3dc38390519 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.isFromPinnedEventsTimeline) timeline.addListener(this) observeMembershipChanges() - observeSummaryState() + if (!initialState.isPinnedEventsTimeline()) { + observeSummaryState() + } getUnreadState() observeSyncState() observeDataStore() @@ -448,6 +451,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) @@ -827,6 +832,7 @@ class TimelineViewModel @AssistedInject constructor( else -> false } } + initialState.isPinnedEventsTimeline() -> false else -> { when (itemId) { R.id.timeline_setting -> true @@ -837,6 +843,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() R.id.dev_tools -> vectorPreferences.developerMode() else -> false @@ -1023,6 +1030,44 @@ class TimelineViewModel @AssistedInject constructor( _viewEvents.post(RoomDetailViewEvents.NavigateToEvent(targetEventId)) } + 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 a21567acb17..ba99331a00a 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 @@ -28,6 +29,7 @@ data class TimelineArgs( val sharedData: SharedData? = null, val openShareSpaceForId: String? = null, 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 7bf9f536f20..6679aa1d08b 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 f547734651f..9d764101295 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 53d9e2aa991..9f6a117dfdd 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 a6d7e8386f0..feb7e7a444a 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) } @@ -333,74 +336,95 @@ class MessageActionsViewModel @AssistedInject constructor( ) { val eventId = timelineEvent.eventId if (!timelineEvent.root.isRedacted()) { - if (canReply(timelineEvent, messageContent, actionPermissions)) { - add(EventSharedAction.Reply(eventId)) - } + if (initialState.isFromPinnedEventsTimeline && vectorPreferences.arePinnedEventsEnabled()) { + add(EventSharedAction.ViewPinnedEventInRoom(eventId)) + if (actionPermissions.canPinEvent) { + add(EventSharedAction.UnpinEvent(eventId)) + } + } else { + if (canReply(timelineEvent, messageContent, actionPermissions)) { + add(EventSharedAction.Reply(eventId)) + } - if (canReplyInThread(timelineEvent, messageContent, actionPermissions)) { - add(EventSharedAction.ReplyInThread(eventId, !timelineEvent.isRootThread())) - } + if (canReplyInThread(timelineEvent, messageContent, actionPermissions)) { + add(EventSharedAction.ReplyInThread(eventId, !timelineEvent.isRootThread())) + } - if (canViewInRoom(timelineEvent, messageContent, actionPermissions)) { - add(EventSharedAction.ViewInRoom) - } + if (canViewInRoom(timelineEvent, messageContent, actionPermissions)) { + add(EventSharedAction.ViewInRoom) + } - if (canEndPoll(timelineEvent, actionPermissions)) { - add(EventSharedAction.EndPoll(timelineEvent.eventId)) - } + if (canEndPoll(timelineEvent, actionPermissions)) { + add(EventSharedAction.EndPoll(timelineEvent.eventId)) + } - if (canEdit(timelineEvent, session.myUserId, actionPermissions)) { - add(EventSharedAction.Edit(eventId, timelineEvent.root.getClearType())) - } + if (canEdit(timelineEvent, session.myUserId, actionPermissions)) { + add(EventSharedAction.Edit(eventId, timelineEvent.root.getClearType())) + } - if (canCopy(msgType)) { - // TODO copy images? html? see ClipBoard - add(EventSharedAction.Copy(messageContent!!.body)) - } + if (canCopy(msgType)) { + // TODO copy images? html? see ClipBoard + add(EventSharedAction.Copy(messageContent!!.body)) + } - if (timelineEvent.canReact() && actionPermissions.canReact) { - add(EventSharedAction.AddReaction(eventId)) - } + if (timelineEvent.canReact() && actionPermissions.canReact) { + add(EventSharedAction.AddReaction(eventId)) + } - if (canViewReactions(timelineEvent)) { - 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)) { - add(EventSharedAction.Quote(eventId)) - } + if (canViewReactions(timelineEvent)) { + add(EventSharedAction.ViewReactions(informationData)) + } - if (timelineEvent.hasBeenEdited()) { - add(EventSharedAction.ViewEditHistory(informationData)) - } + if (canQuote(timelineEvent, messageContent, actionPermissions)) { + add(EventSharedAction.Quote(eventId)) + } - if (canSave(msgType) && messageContent is MessageWithAttachmentContent) { - add(EventSharedAction.Save(timelineEvent.eventId, messageContent)) - } + if (timelineEvent.hasBeenEdited()) { + add(EventSharedAction.ViewEditHistory(informationData)) + } - if (canShare(msgType)) { - add(EventSharedAction.Share(timelineEvent.eventId, messageContent!!)) - } + if (canSave(msgType) && messageContent is MessageWithAttachmentContent) { + add(EventSharedAction.Save(timelineEvent.eventId, messageContent)) + } - if (canRedact(timelineEvent, actionPermissions)) { - if (timelineEvent.root.getClearType() in EventType.POLL_START.values) { - add( - EventSharedAction.Redact( - eventId, - askForReason = informationData.senderId != session.myUserId, - dialogTitleRes = R.string.delete_poll_dialog_title, - dialogDescriptionRes = R.string.delete_poll_dialog_content - ) - ) - } else { - add( - EventSharedAction.Redact( - eventId, - askForReason = informationData.senderId != session.myUserId, - dialogTitleRes = R.string.delete_event_dialog_title, - dialogDescriptionRes = R.string.delete_event_dialog_content - ) - ) + if (canShare(msgType)) { + add(EventSharedAction.Share(timelineEvent.eventId, messageContent!!)) + } + + if (canRedact(timelineEvent, actionPermissions)) { + if (timelineEvent.root.getClearType() in EventType.POLL_START.values) { + add( + EventSharedAction.Redact( + eventId, + askForReason = informationData.senderId != session.myUserId, + dialogTitleRes = R.string.delete_poll_dialog_title, + dialogDescriptionRes = R.string.delete_poll_dialog_content + ) + ) + } else { + add( + EventSharedAction.Redact( + eventId, + askForReason = informationData.senderId != session.myUserId, + dialogTitleRes = R.string.delete_event_dialog_title, + dialogDescriptionRes = R.string.delete_event_dialog_content + ) + ) + } } } } 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 2bd3c54d525..e6d14bdc7ff 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 ae3ea143a75..2207ffc46a1 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 3f702ed72dc..efc139a7af3 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,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 51e961f2470..4fb0a3943d9 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 58ad08f0264..76ceb4b6885 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 00000000000..ca673a4177f --- /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 00000000000..2c81c2f4d55 --- /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 3970af385ea..73331547765 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.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 @@ -599,6 +601,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 1d67f883a34..a7f736549d9 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 @@ -198,6 +199,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 d46b819cce3..0f9b2c03f0d 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_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" @@ -1112,6 +1114,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 00000000000..389db916168 --- /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 00000000000..b0341a8aa8d --- /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 00000000000..514d21ec17f --- /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 00000000000..93a75fe2e3d --- /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 5c355409321..de85425e26c 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" /> + + + + +