Merge remote-tracking branch 'cintek/pin_messages' into sc

Fixes https://github.com/SchildiChat/SchildiChat-android/issues/202

@SpiritCroc edit:
- change some merge-unfriendly things, e.g.
    - indention
    - strings_sc.xml
    - don't touch what shouldn't be touched
- add icon license to third party
- add FEATURES.md entry

Conflicts:
	library/ui-strings/src/main/res/values/strings.xml
	matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt
	matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt
	vector-config/src/main/res/values/config-settings.xml
	vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
	vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
	vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
	vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt

Change-Id: I490e42d50276edce3e7b099a11dba90d040d0dc0
This commit is contained in:
SpiritCroc 2023-08-02 19:16:29 +02:00
commit befb402dbe
41 changed files with 561 additions and 20 deletions

View file

@ -35,6 +35,7 @@ Here you can find some extra features and changes compared to Element Android (w
- Render media captions ([MSC2530](https://github.com/matrix-org/matrix-spec-proposals/pull/2530)) - Render media captions ([MSC2530](https://github.com/matrix-org/matrix-spec-proposals/pull/2530))
- Escape @room in the reply fallback to avoid unintentional room pings when replying - Escape @room in the reply fallback to avoid unintentional room pings when replying
- Render sticker body in room/thread preview - Render sticker body in room/thread preview
- Pinned messages, contributed by [cintek](https://github.com/cintek) [for Element](https://github.com/vector-im/element-android/pull/7762)
- Branding (name, app icon, links) - Branding (name, app icon, links)
- Show a toast instead of a snackbar after copying text, in order to not block the input area right after copying - Show a toast instead of a snackbar after copying text, in order to not block the input area right after copying

1
changelog.d/7762.feature Normal file
View file

@ -0,0 +1 @@
Added lab feature to pin/unpin messages

View file

@ -239,4 +239,14 @@
<string name="settings_integrations_scalar_warning">⚠️ This setting by default (unless overridden by your homeserver\'s configuration) enables access to \"scalar\", Element\'s integration manager which is unfortunately proprietary, i.e. its source code is not open and can not be checked by the public or the SchildiChat developers.</string> <string name="settings_integrations_scalar_warning">⚠️ This setting by default (unless overridden by your homeserver\'s configuration) enables access to \"scalar\", Element\'s integration manager which is unfortunately proprietary, i.e. its source code is not open and can not be checked by the public or the SchildiChat developers.</string>
<!-- Pinned messages -->
<string name="notice_user_pinned_event">%1$s pinned a message.</string>
<string name="notice_user_unpinned_event">%1$s unpinned a message.</string>
<string name="notice_user_pinned_event_by_you">You pinned a message.</string>
<string name="notice_user_unpinned_event_by_you">You unpinned a message.</string>
<string name="action_open_pinned_events">Open Pinned Messages</string>
<string name="pinning_event">Pin</string>
<string name="unpinning_event">Unpin</string>
<string name="pinned_events_timeline_title">Pinned Messages</string>
<string name="labs_enable_pinned_events">Enable Pinned Messages</string>
</resources> </resources>

View file

@ -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.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType 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.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.RelationDefaultContent
import org.matrix.android.sdk.api.session.room.model.relation.isReply import org.matrix.android.sdk.api.session.room.model.relation.isReply
import org.matrix.android.sdk.api.session.room.model.relation.shouldRenderInThread import org.matrix.android.sdk.api.session.room.model.relation.shouldRenderInThread
@ -497,3 +498,11 @@ fun Event.supportsNotification() =
fun Event.isContentReportable() = fun Event.isContentReportable() =
this.getClearType() in EventType.MESSAGE + EventType.STATE_ROOM_BEACON_INFO.values this.getClearType() in EventType.MESSAGE + EventType.STATE_ROOM_BEACON_INFO.values
fun Event.getIdsOfPinnedEvents(): List<String>? {
return getClearContent()?.toModel<PinnedEventsStateContent>()?.eventIds
}
fun Event.getPreviousIdsOfPinnedEvents(): List<String>? {
return resolvedPrevContent()?.toModel<PinnedEventsStateContent>()?.eventIds
}

View file

@ -45,6 +45,7 @@ object EventType {
const val STATE_ROOM_NAME = "m.room.name" const val STATE_ROOM_NAME = "m.room.name"
const val STATE_ROOM_TOPIC = "m.room.topic" const val STATE_ROOM_TOPIC = "m.room.topic"
const val STATE_ROOM_AVATAR = "m.room.avatar" 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_MEMBER = "m.room.member"
const val STATE_ROOM_THIRD_PARTY_INVITE = "m.room.third_party_invite" const val STATE_ROOM_THIRD_PARTY_INVITE = "m.room.third_party_invite"
const val STATE_ROOM_CREATE = "m.room.create" 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_CANONICAL_ALIAS = "m.room.canonical_alias"
const val STATE_ROOM_HISTORY_VISIBILITY = "m.room.history_visibility" const val STATE_ROOM_HISTORY_VISIBILITY = "m.room.history_visibility"
const val STATE_ROOM_RELATED_GROUPS = "m.room.related_groups" 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_ENCRYPTION = "m.room.encryption"
const val STATE_ROOM_SERVER_ACL = "m.room.server_acl" const val STATE_ROOM_SERVER_ACL = "m.room.server_acl"

View file

@ -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<String>
)

View file

@ -66,6 +66,16 @@ interface StateService {
*/ */
suspend fun deleteAvatar() 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. * Send a state event to the room.
* @param eventType The type of event to send. * @param eventType The type of event to send.

View file

@ -45,7 +45,7 @@ interface Timeline {
/** /**
* This must be called before any other method after creating the timeline. It ensures the underlying database is open * 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. * This must be called when you don't need the timeline. It ensures the underlying database get closed.

View file

@ -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. * The root thread eventId if this is a thread timeline, or null if this is NOT a thread timeline.
*/ */
val rootThreadEventId: String? = null, 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). * 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. * Returns true if this is a thread timeline or false otherwise.
*/ */
fun isThreadTimeline() = rootThreadEventId != null fun isThreadTimeline() = rootThreadEventId != null
/**
* Returns true if this is a pinned messages timeline or false otherwise.
*/
fun isPinnedEventsTimeline() = isFromPinnedEventsTimeline
} }

View file

@ -240,6 +240,17 @@ internal interface RoomAPI {
@GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/state") @GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/state")
suspend fun getRoomState(@Path("roomId") roomId: String): List<Event> suspend fun getRoomState(@Path("roomId") roomId: String): List<Event>
/**
* 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. * Paginate relations for event based in normal topological order.
* @param roomId the room Id * @param roomId the room Id

View file

@ -22,8 +22,10 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import org.matrix.android.sdk.api.query.QueryStateEventValue 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.Event
import org.matrix.android.sdk.api.session.events.model.EventType 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.events.model.toContent
import org.matrix.android.sdk.api.session.room.model.GuestAccess import org.matrix.android.sdk.api.session.room.model.GuestAccess
import org.matrix.android.sdk.api.session.room.model.RoomCanonicalAliasContent 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.RoomJoinRules
import org.matrix.android.sdk.api.session.room.model.RoomJoinRulesAllowEntry 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.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.session.room.state.StateService
import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.api.util.MimeTypes 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<String>) {
sendStateEvent(
eventType = EventType.STATE_ROOM_PINNED_EVENT,
body = PinnedEventsStateContent(eventIds).toContent(),
stateKey = ""
)
}
override suspend fun setJoinRulePublic() { override suspend fun setJoinRulePublic() {
updateJoinRule(RoomJoinRules.PUBLIC, null) updateJoinRule(RoomJoinRules.PUBLIC, null)
} }

View file

@ -37,6 +37,9 @@ import kotlinx.coroutines.withContext
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import org.matrix.android.sdk.api.MatrixCoroutineDispatchers import org.matrix.android.sdk.api.MatrixCoroutineDispatchers
import org.matrix.android.sdk.api.extensions.tryOrNull 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.model.Membership
import org.matrix.android.sdk.api.session.room.sender.SenderInfo import org.matrix.android.sdk.api.session.room.sender.SenderInfo
import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.Timeline
@ -69,8 +72,9 @@ internal class DefaultTimeline(
private val settings: TimelineSettings, private val settings: TimelineSettings,
private val coroutineDispatchers: MatrixCoroutineDispatchers, private val coroutineDispatchers: MatrixCoroutineDispatchers,
private val clock: Clock, private val clock: Clock,
private val stateEventDataSource: StateEventDataSource,
private val timelineEventDataSource: TimelineEventDataSource,
localEchoEventFactory: LocalEchoEventFactory, localEchoEventFactory: LocalEchoEventFactory,
stateEventDataSource: StateEventDataSource,
paginationTask: PaginationTask, paginationTask: PaginationTask,
getEventTask: GetContextOfEventTask, getEventTask: GetContextOfEventTask,
fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, fetchTokenAndPaginateTask: FetchTokenAndPaginateTask,
@ -105,6 +109,8 @@ internal class DefaultTimeline(
private var targetEventId = initialEventId private var targetEventId = initialEventId
private val dimber = Dimber("TimelineChunks", DbgUtil.DBG_TIMELINE_CHUNKS) private val dimber = Dimber("TimelineChunks", DbgUtil.DBG_TIMELINE_CHUNKS)
private var isFromPinnedEventsTimeline = false
private val strategyDependencies = LoadTimelineStrategy.Dependencies( private val strategyDependencies = LoadTimelineStrategy.Dependencies(
timelineSettings = settings, timelineSettings = settings,
realm = backgroundRealm, realm = backgroundRealm,
@ -136,7 +142,11 @@ internal class DefaultTimeline(
override fun addListener(listener: Timeline.Listener): Boolean { override fun addListener(listener: Timeline.Listener): Boolean {
listeners.add(listener) listeners.add(listener)
timelineScope.launch { timelineScope.launch {
val snapshot = strategy.buildSnapshot() val snapshot = if (isFromPinnedEventsTimeline) {
getPinnedEvents()
} else {
strategy.buildSnapshot()
}
withContext(coroutineDispatchers.main) { withContext(coroutineDispatchers.main) {
tryOrNull { listener.onTimelineUpdated(snapshot) } tryOrNull { listener.onTimelineUpdated(snapshot) }
} }
@ -152,7 +162,7 @@ internal class DefaultTimeline(
listeners.clear() listeners.clear()
} }
override fun start(rootThreadEventId: String?) { override fun start(rootThreadEventId: String?, isFromPinnedEventsTimeline: Boolean) {
timelineScope.launch { timelineScope.launch {
loadRoomMembersIfNeeded() loadRoomMembersIfNeeded()
} }
@ -161,6 +171,7 @@ internal class DefaultTimeline(
if (isStarted.compareAndSet(false, true)) { if (isStarted.compareAndSet(false, true)) {
isFromThreadTimeline = rootThreadEventId != null isFromThreadTimeline = rootThreadEventId != null
this@DefaultTimeline.rootThreadEventId = rootThreadEventId this@DefaultTimeline.rootThreadEventId = rootThreadEventId
this@DefaultTimeline.isFromPinnedEventsTimeline = isFromPinnedEventsTimeline
// / // /
val realm = Realm.getInstance(realmConfiguration) val realm = Realm.getInstance(realmConfiguration)
ensureReadReceiptAreLoaded(realm) ensureReadReceiptAreLoaded(realm)
@ -267,7 +278,12 @@ internal class DefaultTimeline(
} }
} }
Timber.v("$baseLogMessage: result $loadMoreResult") Timber.v("$baseLogMessage: result $loadMoreResult")
val hasMoreToLoad = loadMoreResult != LoadMoreResult.REACHED_END val hasMoreToLoad = if (isFromPinnedEventsTimeline) {
!areAllPinnedEventsLoaded()
} else {
loadMoreResult != LoadMoreResult.REACHED_END
}
updateState(direction) { updateState(direction) {
it.copy(loading = false, hasMoreToLoad = hasMoreToLoad, hasLoadedAtLeastOnce = true) it.copy(loading = false, hasMoreToLoad = hasMoreToLoad, hasLoadedAtLeastOnce = true)
} }
@ -378,7 +394,11 @@ internal class DefaultTimeline(
} }
private suspend fun postSnapshot() { private suspend fun postSnapshot() {
val snapshot = strategy.buildSnapshot() val snapshot = if (isFromPinnedEventsTimeline) {
getPinnedEvents()
} else {
strategy.buildSnapshot()
}
Timber.v("Post snapshot of ${snapshot.size} events") Timber.v("Post snapshot of ${snapshot.size} events")
// Async debugging to not slow down things too much // Async debugging to not slow down things too much
dimber.exec { dimber.exec {
@ -405,6 +425,25 @@ internal class DefaultTimeline(
} }
} }
private fun getIdsOfPinnedEvents(): List<String> {
return stateEventDataSource
.getStateEvent(roomId, EventType.STATE_ROOM_PINNED_EVENT, QueryStringValue.Equals(""))
?.getIdsOfPinnedEvents()
.orEmpty()
}
private fun getPinnedEvents(): List<TimelineEvent> {
return getIdsOfPinnedEvents()
.mapNotNull { id ->
timelineEventDataSource.getTimelineEvent(roomId, id)
}
.reversed()
}
private fun areAllPinnedEventsLoaded(): Boolean {
return getIdsOfPinnedEvents().size == getPinnedEvents().size
}
private fun onNewTimelineEvents(eventIds: List<String>) { private fun onNewTimelineEvents(eventIds: List<String>) {
timelineScope.launch(coroutineDispatchers.main) { timelineScope.launch(coroutineDispatchers.main) {
listeners.forEach { listeners.forEach {

View file

@ -85,6 +85,7 @@ internal class DefaultTimelineService @AssistedInject constructor(
lightweightSettingsStorage = lightweightSettingsStorage, lightweightSettingsStorage = lightweightSettingsStorage,
clock = clock, clock = clock,
stateEventDataSource = stateEventDataSource, stateEventDataSource = stateEventDataSource,
timelineEventDataSource = timelineEventDataSource,
localEchoEventFactory = localEchoEventFactory localEchoEventFactory = localEchoEventFactory
) )
} }

View file

@ -39,6 +39,7 @@
<!-- Level 1: Labs --> <!-- Level 1: Labs -->
<bool name="settings_labs_deferred_dm_visible">true</bool> <bool name="settings_labs_deferred_dm_visible">true</bool>
<bool name="settings_labs_deferred_dm_default">true</bool> <bool name="settings_labs_deferred_dm_default">true</bool>
<bool name="settings_labs_pinned_events_default">false</bool>
<bool name="settings_labs_thread_messages_default">true</bool> <bool name="settings_labs_thread_messages_default">true</bool>
<bool name="settings_labs_new_app_layout_default">false</bool> <bool name="settings_labs_new_app_layout_default">false</bool>
<bool name="settings_labs_new_session_manager_default">false</bool> <bool name="settings_labs_new_session_manager_default">false</bool>

View file

@ -151,6 +151,7 @@
<activity android:name=".features.roomdirectory.roompreview.RoomPreviewActivity" /> <activity android:name=".features.roomdirectory.roompreview.RoomPreviewActivity" />
<activity android:name=".features.home.room.filtered.FilteredRoomsActivity" /> <activity android:name=".features.home.room.filtered.FilteredRoomsActivity" />
<activity android:name=".features.home.room.threads.ThreadsActivity" /> <activity android:name=".features.home.room.threads.ThreadsActivity" />
<activity android:name=".features.home.room.pinnedmessages.PinnedEventsActivity" />
<activity <activity
android:name=".features.home.room.detail.RoomDetailActivity" android:name=".features.home.room.detail.RoomDetailActivity"

View file

@ -298,6 +298,35 @@ THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
</li> </li>
</ul> </ul>
<ul>
<li>
<b>Fluent UI System Icons</b>
<br/>
MIT License
Copyright (c) 2020 Microsoft Corporation
</li>
</ul>
<pre>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
</pre>
<h3> <h3>
Apache License Apache License
<br/> <br/>

View file

@ -30,6 +30,8 @@ import org.matrix.android.sdk.api.session.widgets.model.Widget
import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.MatrixItem
sealed class RoomDetailAction : VectorViewModelAction { sealed class RoomDetailAction : VectorViewModelAction {
data class PinEvent(val eventId: String) : RoomDetailAction()
data class UnpinEvent(val eventId: String) : RoomDetailAction()
data class SendSticker(val stickerContent: MessageStickerContent) : RoomDetailAction() data class SendSticker(val stickerContent: MessageStickerContent) : RoomDetailAction()
data class SendMedia(val attachments: List<ContentAttachmentData>, val compressBeforeSending: Boolean) : RoomDetailAction() data class SendMedia(val attachments: List<ContentAttachmentData>, val compressBeforeSending: Boolean) : RoomDetailAction()
data class TimelineEventTurnsVisible(val event: TimelineEvent) : RoomDetailAction() data class TimelineEventTurnsVisible(val event: TimelineEvent) : RoomDetailAction()

View file

@ -84,6 +84,7 @@ data class RoomDetailViewState(
val isSharingLiveLocation: Boolean = false, val isSharingLiveLocation: Boolean = false,
val showKeyboardWhenPresented: Boolean = false, val showKeyboardWhenPresented: Boolean = false,
val sharedData: SharedData? = null, val sharedData: SharedData? = null,
val isFromPinnedEventsTimeline: Boolean = false,
) : MavericksState { ) : MavericksState {
constructor(args: TimelineArgs) : this( constructor(args: TimelineArgs) : this(
@ -98,6 +99,7 @@ data class RoomDetailViewState(
rootThreadEventId = args.threadTimelineArgs?.rootThreadEventId, rootThreadEventId = args.threadTimelineArgs?.rootThreadEventId,
showKeyboardWhenPresented = args.threadTimelineArgs?.showKeyboard.orFalse(), showKeyboardWhenPresented = args.threadTimelineArgs?.showKeyboard.orFalse(),
sharedData = args.sharedData, sharedData = args.sharedData,
isFromPinnedEventsTimeline = args.pinnedEventsTimelineArgs != null,
) )
fun isCallOptionAvailable(): Boolean { fun isCallOptionAvailable(): Boolean {
@ -122,5 +124,7 @@ data class RoomDetailViewState(
fun isThreadTimeline() = rootThreadEventId != null fun isThreadTimeline() = rootThreadEventId != null
fun isPinnedEventsTimeline() = isFromPinnedEventsTimeline
fun isLocalRoom() = RoomLocalEcho.isLocalEchoId(roomId) fun isLocalRoom() = RoomLocalEcho.isLocalEchoId(roomId)
} }

View file

@ -176,6 +176,7 @@ import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
import im.vector.app.features.home.room.detail.upgrade.MigrateRoomBottomSheet import im.vector.app.features.home.room.detail.upgrade.MigrateRoomBottomSheet
import im.vector.app.features.home.room.detail.views.RoomDetailLazyLoadedViews 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.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.ThreadsManager
import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs
import im.vector.app.features.home.room.typing.TypingHelper import im.vector.app.features.home.room.typing.TypingHelper
@ -413,6 +414,10 @@ class TimelineFragment :
) )
} }
if (isPinnedEventsTimeline()) {
views.hideComposerViews()
}
timelineViewModel.observeViewEvents { timelineViewModel.observeViewEvents {
when (it) { when (it) {
is RoomDetailViewEvents.Failure -> displayErrorMessage(it) is RoomDetailViewEvents.Failure -> displayErrorMessage(it)
@ -1067,6 +1072,10 @@ class TimelineFragment :
requireActivity().restart() requireActivity().restart()
true true
} }
R.id.open_pinned_events -> {
navigateToPinnedEvents()
true
}
R.id.menu_timeline_thread_list -> { R.id.menu_timeline_thread_list -> {
navigateToThreadList() navigateToThreadList()
true true
@ -1390,7 +1399,7 @@ class TimelineFragment :
} }
private fun updateJumpToReadMarkerViewVisibility() { private fun updateJumpToReadMarkerViewVisibility() {
if (isThreadTimeLine()) return if (isThreadTimeLine() || isPinnedEventsTimeline()) return
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
withResumed { withResumed {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
@ -1480,6 +1489,9 @@ class TimelineFragment :
vectorBaseActivity.finish() vectorBaseActivity.finish()
} }
updateLiveLocationIndicator(mainState.isSharingLiveLocation) updateLiveLocationIndicator(mainState.isSharingLiveLocation)
if (isPinnedEventsTimeline()) {
views.hideComposerViews()
}
} }
private fun handleRoomSummaryFailure(asyncRoomSummary: Fail<RoomSummary>) { private fun handleRoomSummaryFailure(asyncRoomSummary: Fail<RoomSummary>) {
@ -1536,6 +1548,19 @@ class TimelineFragment :
} }
views.includeThreadToolbar.roomToolbarThreadTitleTextView.text = resources.getText(R.string.thread_timeline_title) 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 -> { else -> {
views.includeRoomToolbar.roomToolbarContentView.isVisible = true views.includeRoomToolbar.roomToolbarContentView.isVisible = true
views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = false views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = false
@ -1863,7 +1888,7 @@ class TimelineFragment :
this.view?.hideKeyboard() this.view?.hideKeyboard()
MessageActionsBottomSheet MessageActionsBottomSheet
.newInstance(roomId, informationData, isThreadTimeLine()) .newInstance(roomId, informationData, isThreadTimeLine(), isPinnedEventsTimeline())
.show(requireActivity().supportFragmentManager, "MESSAGE_CONTEXTUAL_ACTIONS") .show(requireActivity().supportFragmentManager, "MESSAGE_CONTEXTUAL_ACTIONS")
return true return true
@ -2159,6 +2184,15 @@ class TimelineFragment :
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) 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 -> { is EventSharedAction.ReplyInThread -> {
if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) { if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) {
onReplyInThreadClicked(action) onReplyInThreadClicked(action)
@ -2339,6 +2373,27 @@ class TimelineFragment :
} }
} }
/**
* Navigate to pinned events for the current room using the PinnedEventsActivity.
*/
private fun navigateToPinnedEvents() {
context?.let {
val pinnedEventsTimelineArgs = PinnedEventsTimelineArgs(
roomId = timelineArgs.roomId,
)
navigator.openPinnedEvents(it, pinnedEventsTimelineArgs)
}
}
private fun handleViewInRoomAction(eventId: String) {
val newRoom = timelineArgs.copy(threadTimelineArgs = null, pinnedEventsTimelineArgs = null, eventId = eventId)
context?.let { con ->
val intent = RoomDetailActivity.newIntent(con, newRoom, false)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
con.startActivity(intent)
}
}
// VectorInviteView.Callback // VectorInviteView.Callback
override fun onAcceptInvite() { override fun onAcceptInvite() {
timelineViewModel.handle(RoomDetailAction.AcceptInvite) timelineViewModel.handle(RoomDetailAction.AcceptInvite)
@ -2421,6 +2476,11 @@ class TimelineFragment :
*/ */
private fun isThreadTimeLine(): Boolean = withState(timelineViewModel) { it.isThreadTimeline() } 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. * Returns true if the current room is a local room, false otherwise.
*/ */

View file

@ -105,6 +105,7 @@ import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.crypto.verification.EVerificationState import org.matrix.android.sdk.api.session.crypto.verification.EVerificationState
import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.getIdsOfPinnedEvents
import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.content.WithHeldCode import org.matrix.android.sdk.api.session.events.model.content.WithHeldCode
import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage
@ -263,10 +264,12 @@ class TimelineViewModel @AssistedInject constructor(
} }
private fun initSafe(room: Room, timeline: Timeline) { private fun initSafe(room: Room, timeline: Timeline) {
timeline.start(initialState.rootThreadEventId) timeline.start(initialState.rootThreadEventId, initialState.isFromPinnedEventsTimeline)
timeline.addListener(this) timeline.addListener(this)
observeMembershipChanges() observeMembershipChanges()
observeSummaryState() if (!initialState.isPinnedEventsTimeline()) {
observeSummaryState()
}
getUnreadState() getUnreadState()
observeSyncState() observeSyncState()
observeDataStore() observeDataStore()
@ -535,6 +538,8 @@ class TimelineViewModel @AssistedInject constructor(
override fun handle(action: RoomDetailAction) { override fun handle(action: RoomDetailAction) {
when (action) { when (action) {
is RoomDetailAction.PinEvent -> handlePinEvent(action)
is RoomDetailAction.UnpinEvent -> handleUnpinEvent(action)
is RoomDetailAction.ComposerFocusChange -> handleComposerFocusChange(action) is RoomDetailAction.ComposerFocusChange -> handleComposerFocusChange(action)
is RoomDetailAction.SendMedia -> handleSendMedia(action) is RoomDetailAction.SendMedia -> handleSendMedia(action)
is RoomDetailAction.SendSticker -> handleSendSticker(action) is RoomDetailAction.SendSticker -> handleSendSticker(action)
@ -944,6 +949,7 @@ class TimelineViewModel @AssistedInject constructor(
else -> false else -> false
} }
} }
initialState.isPinnedEventsTimeline() -> false
else -> { else -> {
when (itemId) { when (itemId) {
R.id.timeline_setting -> false // replaced by show_room_info (downstream) R.id.timeline_setting -> false // replaced by show_room_info (downstream)
@ -954,6 +960,7 @@ class TimelineViewModel @AssistedInject constructor(
// Show Join conference button only if there is an active conf id not joined. Otherwise fallback to default video disabled. ^ // 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.join_conference -> !state.isCallOptionAvailable() && state.jitsiState.confId != null && !state.jitsiState.hasJoined
R.id.search -> state.isSearchAvailable() R.id.search -> state.isSearchAvailable()
R.id.open_pinned_events -> vectorPreferences.arePinnedEventsEnabled() && areTherePinnedEvents()
R.id.menu_timeline_thread_list -> vectorPreferences.areThreadMessagesEnabled() R.id.menu_timeline_thread_list -> vectorPreferences.areThreadMessagesEnabled()
// SC extras start // SC extras start
R.id.show_room_info -> true // SC R.id.show_room_info -> true // SC
@ -1163,6 +1170,44 @@ class TimelineViewModel @AssistedInject constructor(
} }
} }
private fun handlePinEvent(action: RoomDetailAction.PinEvent) {
viewModelScope.launch(Dispatchers.IO) {
try {
room
?.stateService()
?.pinEvent(action.eventId)
_viewEvents.post(RoomDetailViewEvents.ActionSuccess(action))
} catch (failure: Throwable) {
_viewEvents.post(RoomDetailViewEvents.ActionFailure(action, failure))
}
}
}
private fun handleUnpinEvent(action: RoomDetailAction.UnpinEvent) {
viewModelScope.launch(Dispatchers.IO) {
try {
room
?.stateService()
?.unpinEvent(action.eventId)
_viewEvents.post(RoomDetailViewEvents.ActionSuccess(action))
} catch (failure: Throwable) {
_viewEvents.post(RoomDetailViewEvents.ActionFailure(action, failure))
}
}
}
private fun getIdsOfPinnedEvents(): List<String>? {
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) { private fun handleResendEvent(action: RoomDetailAction.ResendMessage) {
if (room == null) return if (room == null) return
val targetEventId = action.eventId val targetEventId = action.eventId

View file

@ -17,6 +17,7 @@
package im.vector.app.features.home.room.detail.arguments package im.vector.app.features.home.room.detail.arguments
import android.os.Parcelable 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.home.room.threads.arguments.ThreadTimelineArgs
import im.vector.app.features.share.SharedData import im.vector.app.features.share.SharedData
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -30,6 +31,7 @@ data class TimelineArgs(
val openAtFirstUnread: Boolean? = null, val openAtFirstUnread: Boolean? = null,
val openAnonymously: Boolean = false, val openAnonymously: Boolean = false,
val threadTimelineArgs: ThreadTimelineArgs? = null, val threadTimelineArgs: ThreadTimelineArgs? = null,
val pinnedEventsTimelineArgs: PinnedEventsTimelineArgs? = null,
val switchToParentSpace: Boolean = false, val switchToParentSpace: Boolean = false,
val isInviteAlreadyAccepted: Boolean = false val isInviteAlreadyAccepted: Boolean = false
) : Parcelable ) : Parcelable

View file

@ -53,6 +53,15 @@ sealed class EventSharedAction(
data class ReplyInThread(val eventId: String, val startsThread: Boolean) : data class ReplyInThread(val eventId: String, val startsThread: Boolean) :
EventSharedAction(R.string.reply_in_thread, R.drawable.ic_reply_in_thread) 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 : object ViewInRoom :
EventSharedAction(R.string.view_in_room, R.drawable.ic_threads_view_in_room_24) EventSharedAction(R.string.view_in_room, R.drawable.ic_threads_view_in_room_24)

View file

@ -35,7 +35,8 @@ data class ToggleState(
data class ActionPermissions( data class ActionPermissions(
val canSendMessage: Boolean = false, val canSendMessage: Boolean = false,
val canReact: Boolean = false, val canReact: Boolean = false,
val canRedact: Boolean = false val canRedact: Boolean = false,
val canPinEvent: Boolean = false
) )
data class MessageActionState( data class MessageActionState(
@ -50,14 +51,16 @@ data class MessageActionState(
val actions: List<EventSharedAction> = emptyList(), val actions: List<EventSharedAction> = emptyList(),
val expendedReportContentMenu: Boolean = false, val expendedReportContentMenu: Boolean = false,
val actionPermissions: ActionPermissions = ActionPermissions(), val actionPermissions: ActionPermissions = ActionPermissions(),
val isFromThreadTimeline: Boolean = false val isFromThreadTimeline: Boolean = false,
val isFromPinnedEventsTimeline: Boolean = false
) : MavericksState { ) : MavericksState {
constructor(args: TimelineEventFragmentArgs) : this( constructor(args: TimelineEventFragmentArgs) : this(
roomId = args.roomId, roomId = args.roomId,
eventId = args.eventId, eventId = args.eventId,
informationData = args.informationData, informationData = args.informationData,
isFromThreadTimeline = args.isFromThreadTimeline isFromThreadTimeline = args.isFromThreadTimeline,
isFromPinnedEventsTimeline = args.isFromPinnedEventsTimeline
) )
fun senderName(): String = informationData.memberName?.toString() ?: "" fun senderName(): String = informationData.memberName?.toString() ?: ""

View file

@ -93,14 +93,15 @@ class MessageActionsBottomSheet :
} }
companion object { 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 { return MessageActionsBottomSheet().apply {
setArguments( setArguments(
TimelineEventFragmentArgs( TimelineEventFragmentArgs(
informationData.eventId, informationData.eventId,
roomId, roomId,
informationData, informationData,
isFromThreadTimeline isFromThreadTimeline,
isFromPinnedEventsTimeline
) )
) )
} }

View file

@ -42,9 +42,11 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.matrix.android.sdk.api.extensions.orFalse 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.Session
import org.matrix.android.sdk.api.session.crypto.keysbackup.KeysBackupState 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.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.isAttachmentMessage
import org.matrix.android.sdk.api.session.events.model.isContentReportable import org.matrix.android.sdk.api.session.events.model.isContentReportable
import org.matrix.android.sdk.api.session.events.model.isTextMessage 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 canReact = powerLevelsHelper.isUserAllowedToSend(session.myUserId, false, EventType.REACTION)
val canRedact = powerLevelsHelper.isUserAbleToRedact(session.myUserId) val canRedact = powerLevelsHelper.isUserAbleToRedact(session.myUserId)
val canSendMessage = powerLevelsHelper.isUserAllowedToSend(session.myUserId, false, EventType.MESSAGE) 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 { setState {
copy(actionPermissions = permissions) copy(actionPermissions = permissions)
} }
@ -337,6 +340,15 @@ class MessageActionsViewModel @AssistedInject constructor(
) { ) {
val eventId = timelineEvent.eventId val eventId = timelineEvent.eventId
if (!timelineEvent.root.isRedacted()) { if (!timelineEvent.root.isRedacted()) {
if (initialState.isFromPinnedEventsTimeline && vectorPreferences.arePinnedEventsEnabled()) {
add(EventSharedAction.ViewPinnedEventInRoom(eventId))
if (actionPermissions.canPinEvent) {
add(EventSharedAction.UnpinEvent(eventId))
}
} else {
// wrong indention for merge-ability
if (canReply(timelineEvent, messageContent, actionPermissions)) { if (canReply(timelineEvent, messageContent, actionPermissions)) {
add(EventSharedAction.Reply(eventId)) add(EventSharedAction.Reply(eventId))
} }
@ -370,6 +382,20 @@ class MessageActionsViewModel @AssistedInject constructor(
add(EventSharedAction.ViewReactions(informationData)) add(EventSharedAction.ViewReactions(informationData))
} }
if (actionPermissions.canPinEvent && vectorPreferences.arePinnedEventsEnabled()) {
val isPinned = room
?.stateService()
?.getStateEvent(EventType.STATE_ROOM_PINNED_EVENT, QueryStringValue.Equals(""))
?.getIdsOfPinnedEvents()
?.contains(eventId)
.orFalse()
if (isPinned) {
add(EventSharedAction.UnpinEvent(eventId))
} else {
add(EventSharedAction.PinEvent(eventId))
}
}
if (canQuote(timelineEvent, messageContent, actionPermissions) && !vectorPreferences.simplifiedMode()) { if (canQuote(timelineEvent, messageContent, actionPermissions) && !vectorPreferences.simplifiedMode()) {
add(EventSharedAction.Quote(eventId)) add(EventSharedAction.Quote(eventId))
} }
@ -407,7 +433,7 @@ class MessageActionsViewModel @AssistedInject constructor(
) )
} }
} }
} }} // wrong indention on purpose - end
if (vectorPreferences.developerMode()) { if (vectorPreferences.developerMode()) {
if (timelineEvent.isEncrypted() && timelineEvent.root.mCryptoError != null) { if (timelineEvent.isEncrypted() && timelineEvent.root.mCryptoError != null) {

View file

@ -25,5 +25,6 @@ data class TimelineEventFragmentArgs(
val eventId: String, val eventId: String,
val roomId: String, val roomId: String,
val informationData: MessageInformationData, val informationData: MessageInformationData,
val isFromThreadTimeline: Boolean = false val isFromThreadTimeline: Boolean = false,
val isFromPinnedEventsTimeline: Boolean = false
) : Parcelable ) : Parcelable

View file

@ -76,6 +76,7 @@ class TimelineItemFactory @Inject constructor(
EventType.STATE_ROOM_TOPIC, EventType.STATE_ROOM_TOPIC,
EventType.STATE_ROOM_AVATAR, EventType.STATE_ROOM_AVATAR,
EventType.STATE_ROOM_MEMBER, EventType.STATE_ROOM_MEMBER,
EventType.STATE_ROOM_PINNED_EVENT,
EventType.STATE_ROOM_THIRD_PARTY_INVITE, EventType.STATE_ROOM_THIRD_PARTY_INVITE,
EventType.STATE_ROOM_CANONICAL_ALIAS, EventType.STATE_ROOM_CANONICAL_ALIAS,
EventType.STATE_ROOM_JOIN_RULES, EventType.STATE_ROOM_JOIN_RULES,

View file

@ -31,6 +31,8 @@ import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType 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.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.isThread
import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.GuestAccess import org.matrix.android.sdk.api.session.room.model.GuestAccess
@ -90,6 +92,7 @@ class NoticeEventFormatter @Inject constructor(
EventType.STATE_ROOM_WIDGET_LEGACY -> formatWidgetEvent(event, senderName) EventType.STATE_ROOM_WIDGET_LEGACY -> formatWidgetEvent(event, senderName)
EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(event, senderName, isDm) EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(event, senderName, isDm)
EventType.STATE_ROOM_POWER_LEVELS -> formatRoomPowerLevels(event, senderName) EventType.STATE_ROOM_POWER_LEVELS -> formatRoomPowerLevels(event, senderName)
EventType.STATE_ROOM_PINNED_EVENT -> formatPinnedEvent(event, senderName)
EventType.CALL_INVITE, EventType.CALL_INVITE,
EventType.CALL_CANDIDATES, EventType.CALL_CANDIDATES,
EventType.CALL_HANGUP, EventType.CALL_HANGUP,
@ -122,6 +125,27 @@ class NoticeEventFormatter @Inject constructor(
} }
} }
private fun formatPinnedEvent(event: Event, disambiguatedDisplayName: String): CharSequence? {
val idsOfPinnedEvents: List<String> = event.getIdsOfPinnedEvents() ?: return null
val previousIdsOfPinnedEvents: List<String>? = 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? { private fun formatRoomPowerLevels(event: Event, disambiguatedDisplayName: String): CharSequence? {
val powerLevelsContent: PowerLevelsContent = event.content.toModel() ?: return null val powerLevelsContent: PowerLevelsContent = event.content.toModel() ?: return null
val previousPowerLevelsContent: PowerLevelsContent = event.resolvedPrevContent().toModel() ?: return null val previousPowerLevelsContent: PowerLevelsContent = event.resolvedPrevContent().toModel() ?: return null

View file

@ -38,6 +38,7 @@ object TimelineDisplayableEvents {
EventType.STATE_ROOM_HISTORY_VISIBILITY, EventType.STATE_ROOM_HISTORY_VISIBILITY,
EventType.STATE_ROOM_SERVER_ACL, EventType.STATE_ROOM_SERVER_ACL,
EventType.STATE_ROOM_POWER_LEVELS, EventType.STATE_ROOM_POWER_LEVELS,
EventType.STATE_ROOM_PINNED_EVENT,
EventType.CALL_INVITE, EventType.CALL_INVITE,
EventType.CALL_HANGUP, EventType.CALL_HANGUP,
EventType.CALL_ANSWER, EventType.CALL_ANSWER,

View file

@ -113,7 +113,7 @@ class MergedTimelines(
secondaryTimeline.removeAllListeners() secondaryTimeline.removeAllListeners()
} }
override fun start(rootThreadEventId: String?) { override fun start(rootThreadEventId: String?, isFromPinnedEventsTimeline: Boolean) {
mainTimeline.start() mainTimeline.start()
secondaryTimeline.start() secondaryTimeline.start()
} }

View file

@ -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<ActivityPinnedEventsBinding>() {
@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)
}
}
}
}

View file

@ -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

View file

@ -60,6 +60,8 @@ import im.vector.app.features.home.room.detail.arguments.TimelineArgs
import im.vector.app.features.home.room.detail.search.SearchActivity import im.vector.app.features.home.room.detail.search.SearchActivity
import im.vector.app.features.home.room.detail.search.SearchArgs 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.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.ThreadsActivity
import im.vector.app.features.home.room.threads.arguments.ThreadListArgs import im.vector.app.features.home.room.threads.arguments.ThreadListArgs
import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs
@ -609,6 +611,15 @@ class DefaultNavigator @Inject constructor(
) )
} }
override fun openPinnedEvents(context: Context, pinnedEventsTimelineArgs: PinnedEventsTimelineArgs) {
context.startActivity(
PinnedEventsActivity.newIntent(
context = context,
pinnedEventsTimelineArgs = pinnedEventsTimelineArgs
)
)
}
override fun openScreenSharingPermissionDialog( override fun openScreenSharingPermissionDialog(
screenCaptureIntent: Intent, screenCaptureIntent: Intent,
activityResultLauncher: ActivityResultLauncher<Intent> activityResultLauncher: ActivityResultLauncher<Intent>

View file

@ -27,6 +27,7 @@ import androidx.fragment.app.FragmentActivity
import im.vector.app.features.analytics.plan.ViewRoom import im.vector.app.features.analytics.plan.ViewRoom
import im.vector.app.features.crypto.recover.SetupMode import im.vector.app.features.crypto.recover.SetupMode
import im.vector.app.features.displayname.getBestName 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.home.room.threads.arguments.ThreadTimelineArgs
import im.vector.app.features.location.LocationData import im.vector.app.features.location.LocationData
import im.vector.app.features.location.LocationSharingMode import im.vector.app.features.location.LocationSharingMode
@ -200,6 +201,8 @@ interface Navigator {
fun openThreadList(context: Context, threadTimelineArgs: ThreadTimelineArgs) fun openThreadList(context: Context, threadTimelineArgs: ThreadTimelineArgs)
fun openPinnedEvents(context: Context, pinnedEventsTimelineArgs: PinnedEventsTimelineArgs)
fun openScreenSharingPermissionDialog( fun openScreenSharingPermissionDialog(
screenCaptureIntent: Intent, screenCaptureIntent: Intent,
activityResultLauncher: ActivityResultLauncher<Intent> activityResultLauncher: ActivityResultLauncher<Intent>

View file

@ -278,6 +278,8 @@ class VectorPreferences @Inject constructor(
private const val SETTINGS_LABS_ENABLE_ELEMENT_CALL_PERMISSION_SHORTCUTS = "SETTINGS_LABS_ENABLE_ELEMENT_CALL_PERMISSION_SHORTCUTS" private const val SETTINGS_LABS_ENABLE_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 // 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" const val SETTINGS_LABS_ENABLE_THREAD_MESSAGES_OLD_CLIENTS = "SETTINGS_LABS_ENABLE_THREAD_MESSAGES"
@ -1436,6 +1438,10 @@ class VectorPreferences @Inject constructor(
return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_ELEMENT_CALL_PERMISSION_SHORTCUTS, false) 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. * Indicates whether or not thread messages are enabled.
*/ */

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="m21.068,7.758 l-4.826,-4.826a2.75,2.75 0,0 0,-4.404 0.715l-2.435,4.87a0.75,0.75 0,0 1,-0.426 0.374L4.81,10.33a1.25,1.25 0,0 0,-0.476 2.065L7.439,15.5 3,19.94V21h1.06l4.44,-4.44 3.104,3.105a1.25,1.25 0,0 0,2.066 -0.476l1.44,-4.166a0.75,0.75 0,0 1,0.373 -0.426l4.87,-2.435a2.75,2.75 0,0 0,0.715 -4.404Z"
android:fillColor="#70a81d"/>
</vector>

View file

@ -0,0 +1,4 @@
<vector android:height="30dp" android:viewportHeight="24"
android:viewportWidth="24" android:width="30dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#212121" android:pathData="m16.242,2.932 l4.826,4.826a2.75,2.75 0,0 1,-0.715 4.404l-4.87,2.435a0.75,0.75 0,0 0,-0.374 0.426l-1.44,4.166a1.25,1.25 0,0 1,-2.065 0.476L8.5,16.561 4.06,21L3,21v-1.06l4.44,-4.44 -3.105,-3.104a1.25,1.25 0,0 1,0.476 -2.066l4.166,-1.44a0.75,0.75 0,0 0,0.426 -0.373l2.435,-4.87a2.75,2.75 0,0 1,4.405 -0.715ZM20.008,8.818 L15.182,3.992a1.25,1.25 0,0 0,-2.002 0.325l-2.435,4.871a2.25,2.25 0,0 1,-1.278 1.12l-3.789,1.31 6.705,6.704 1.308,-3.789a2.25,2.25 0,0 1,1.12 -1.277l4.872,-2.436a1.25,1.25 0,0 0,0.325 -2.002Z"/>
</vector>

View file

@ -0,0 +1,4 @@
<vector android:height="30dp" android:viewportHeight="24"
android:viewportWidth="24" android:width="30dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#212121" android:pathData="M3.28,2.22a0.75,0.75 0,0 0,-1.06 1.06l5.905,5.905L4.81,10.33a1.25,1.25 0,0 0,-0.476 2.065L7.439,15.5 3,19.94L3,21h1.06l4.44,-4.44 3.105,3.105a1.25,1.25 0,0 0,2.065 -0.476l1.145,-3.313 5.905,5.904a0.75,0.75 0,0 0,1.06 -1.06L3.28,2.22ZM13.635,14.696 L12.383,18.322 5.678,11.617 9.304,10.365 13.635,14.696ZM19.683,10.82 L15.896,12.714 17.014,13.832 20.354,12.162a2.75,2.75 0,0 0,0.714 -4.404l-4.825,-4.826a2.75,2.75 0,0 0,-4.405 0.715l-1.67,3.34 1.118,1.117 1.894,-3.787a1.25,1.25 0,0 1,2.002 -0.325l4.826,4.826a1.25,1.25 0,0 1,-0.325 2.002Z"/>
</vector>

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/coordinatorLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/pinnedEventsActivityFragmentContainer"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -40,6 +40,16 @@
app:showAsAction="always" app:showAsAction="always"
tools:visible="true" /> tools:visible="true" />
<!-- We always want to show this item as an icon -->
<item
android:id="@+id/open_pinned_events"
android:icon="@drawable/ic_open_pinned_events"
android:title="@string/action_open_pinned_events"
android:visible="true"
app:iconTint="?colorPrimary"
app:showAsAction="always"
tools:visible="true" />
<!-- We always want to show this item as an icon --> <!-- We always want to show this item as an icon -->
<item <item
android:id="@+id/menu_timeline_thread_list" android:id="@+id/menu_timeline_thread_list"

View file

@ -130,6 +130,11 @@
android:title="@string/labs_enable_latex_maths" /> android:title="@string/labs_enable_latex_maths" />
<!--</im.vector.app.core.preference.VectorPreferenceCategory>--> <!--</im.vector.app.core.preference.VectorPreferenceCategory>-->
<im.vector.app.core.preference.VectorSwitchPreference
android:defaultValue="@bool/settings_labs_pinned_events_default"
android:key="SETTINGS_LABS_ENABLE_PINNED_EVENTS"
android:title="@string/labs_enable_pinned_events" />
<im.vector.app.core.preference.VectorSwitchPreference <im.vector.app.core.preference.VectorSwitchPreference
android:defaultValue="@bool/settings_labs_thread_messages_default" android:defaultValue="@bool/settings_labs_thread_messages_default"
android:key="SETTINGS_LABS_ENABLE_THREAD_MESSAGES_FINAL" android:key="SETTINGS_LABS_ENABLE_THREAD_MESSAGES_FINAL"