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))
- Escape @room in the reply fallback to avoid unintentional room pings when replying
- Render sticker body in room/thread preview
- Pinned messages, contributed by [cintek](https://github.com/cintek) [for Element](https://github.com/vector-im/element-android/pull/7762)
- Branding (name, app icon, links)
- Show a toast instead of a snackbar after copying text, in order to not block the input area right after copying

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

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.MessageType
import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent
import org.matrix.android.sdk.api.session.room.model.pinnedmessages.PinnedEventsStateContent
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
import org.matrix.android.sdk.api.session.room.model.relation.isReply
import org.matrix.android.sdk.api.session.room.model.relation.shouldRenderInThread
@ -497,3 +498,11 @@ fun Event.supportsNotification() =
fun Event.isContentReportable() =
this.getClearType() in EventType.MESSAGE + EventType.STATE_ROOM_BEACON_INFO.values
fun Event.getIdsOfPinnedEvents(): List<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_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"

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()
/**
* 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.

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
*/
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.

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.
*/
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
}

View File

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

View File

@ -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<String>) {
sendStateEvent(
eventType = EventType.STATE_ROOM_PINNED_EVENT,
body = PinnedEventsStateContent(eventIds).toContent(),
stateKey = ""
)
}
override suspend fun setJoinRulePublic() {
updateJoinRule(RoomJoinRules.PUBLIC, null)
}

View File

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

View File

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

View File

@ -39,6 +39,7 @@
<!-- Level 1: Labs -->
<bool name="settings_labs_deferred_dm_visible">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_new_app_layout_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.home.room.filtered.FilteredRoomsActivity" />
<activity android:name=".features.home.room.threads.ThreadsActivity" />
<activity android:name=".features.home.room.pinnedmessages.PinnedEventsActivity" />
<activity
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>
</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>
Apache License
<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
sealed class RoomDetailAction : VectorViewModelAction {
data class PinEvent(val eventId: String) : RoomDetailAction()
data class UnpinEvent(val eventId: String) : RoomDetailAction()
data class SendSticker(val stickerContent: MessageStickerContent) : RoomDetailAction()
data class SendMedia(val attachments: List<ContentAttachmentData>, val compressBeforeSending: Boolean) : RoomDetailAction()
data class TimelineEventTurnsVisible(val event: TimelineEvent) : RoomDetailAction()

View File

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

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

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

View File

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

View File

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

View File

@ -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<EventSharedAction> = 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() ?: ""

View File

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

View File

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

View File

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

View File

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

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

View File

@ -113,7 +113,7 @@ class MergedTimelines(
secondaryTimeline.removeAllListeners()
}
override fun start(rootThreadEventId: String?) {
override fun start(rootThreadEventId: String?, isFromPinnedEventsTimeline: Boolean) {
mainTimeline.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.SearchArgs
import im.vector.app.features.home.room.filtered.FilteredRoomsActivity
import im.vector.app.features.home.room.pinnedmessages.arguments.PinnedEventsTimelineArgs
import im.vector.app.features.home.room.pinnedmessages.PinnedEventsActivity
import im.vector.app.features.home.room.threads.ThreadsActivity
import im.vector.app.features.home.room.threads.arguments.ThreadListArgs
import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs
@ -609,6 +611,15 @@ class DefaultNavigator @Inject constructor(
)
}
override fun openPinnedEvents(context: Context, pinnedEventsTimelineArgs: PinnedEventsTimelineArgs) {
context.startActivity(
PinnedEventsActivity.newIntent(
context = context,
pinnedEventsTimelineArgs = pinnedEventsTimelineArgs
)
)
}
override fun openScreenSharingPermissionDialog(
screenCaptureIntent: Intent,
activityResultLauncher: ActivityResultLauncher<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.crypto.recover.SetupMode
import im.vector.app.features.displayname.getBestName
import im.vector.app.features.home.room.pinnedmessages.arguments.PinnedEventsTimelineArgs
import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs
import im.vector.app.features.location.LocationData
import im.vector.app.features.location.LocationSharingMode
@ -200,6 +201,8 @@ interface Navigator {
fun openThreadList(context: Context, threadTimelineArgs: ThreadTimelineArgs)
fun openPinnedEvents(context: Context, pinnedEventsTimelineArgs: PinnedEventsTimelineArgs)
fun openScreenSharingPermissionDialog(
screenCaptureIntent: Intent,
activityResultLauncher: ActivityResultLauncher<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_PINNED_EVENTS = "SETTINGS_LABS_ENABLE_PINNED_EVENTS"
// This key will be used to identify clients with the old thread support enabled io.element.thread
const val SETTINGS_LABS_ENABLE_THREAD_MESSAGES_OLD_CLIENTS = "SETTINGS_LABS_ENABLE_THREAD_MESSAGES"
@ -1436,6 +1438,10 @@ class VectorPreferences @Inject constructor(
return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_ELEMENT_CALL_PERMISSION_SHORTCUTS, false)
}
fun arePinnedEventsEnabled(): Boolean {
return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_PINNED_EVENTS, getDefault(R.bool.settings_labs_pinned_events_default))
}
/**
* Indicates whether or not thread messages are enabled.
*/

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"
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 -->
<item
android:id="@+id/menu_timeline_thread_list"

View File

@ -130,6 +130,11 @@
android:title="@string/labs_enable_latex_maths" />
<!--</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
android:defaultValue="@bool/settings_labs_thread_messages_default"
android:key="SETTINGS_LABS_ENABLE_THREAD_MESSAGES_FINAL"