Merge branch 'release/0.6.0' into main

This commit is contained in:
Benoit Marty 2024-09-12 15:05:17 +02:00
commit e80cfe4a29
36 changed files with 297 additions and 103 deletions

View file

@ -36,7 +36,7 @@ jobs:
./tools/localazy/importSupportedLocalesFromLocalazy.py
./tools/test/generateAllScreenshots.py
- name: Create Pull Request for Strings
uses: peter-evans/create-pull-request@v6
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
commit-message: Sync Strings from Localazy

View file

@ -23,7 +23,7 @@ jobs:
- name: Run SAS String script
run: ./tools/sas/import_sas_strings.py
- name: Create Pull Request for SAS Strings
uses: peter-evans/create-pull-request@v6
uses: peter-evans/create-pull-request@v7
with:
commit-message: Sync SAS Strings
title: Sync SAS Strings

View file

@ -1,6 +1,7 @@
<component name="ProjectDictionaryState">
<dictionary name="shared">
<words>
<w>agpl</w>
<w>backstack</w>
<w>blurhash</w>
<w>fdroid</w>
@ -17,6 +18,7 @@
<w>securebackup</w>
<w>showkase</w>
<w>snackbar</w>
<w>spdx</w>
<w>swipeable</w>
<w>textfields</w>
<w>tombstoned</w>

View file

@ -1,3 +1,35 @@
Changes in Element X v0.5.3 (2024-09-10)
========================================
### ✨ Features
* Add banner for optional migration to simplified sliding sync by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3429
### 🙌 Improvements
* Timeline : remove the encrypted history banner by @ganfra in https://github.com/element-hq/element-x-android/pull/3410
### 🐛 Bugfixes
* Fix new logins with Simplified SS using the proxy by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3417
* Ensure Call is not hang up when user is asked to grant system permissions by @bmarty in https://github.com/element-hq/element-x-android/pull/3419
* Wait for a room with joined state in `/sync` after creating it by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3421
* [Bugfix] : fix self verification flow by @ganfra in https://github.com/element-hq/element-x-android/pull/3426
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3425
### 🚧 In development 🚧
* [Feature] Pinned messages list by @ganfra in https://github.com/element-hq/element-x-android/pull/3392
* Pinned messages banner : adjust indicator to match design. by @ganfra in https://github.com/element-hq/element-x-android/pull/3415
### Dependency upgrades
* Update plugin dependencycheck to v10.0.4 by @renovate in https://github.com/element-hq/element-x-android/pull/3372
* Update plugin detekt to v1.23.7 by @renovate in https://github.com/element-hq/element-x-android/pull/3424
### Others
* Delete old log files by @bmarty in https://github.com/element-hq/element-x-android/pull/3413
* Recovery key formatting and wording iteration by @bmarty in https://github.com/element-hq/element-x-android/pull/3409
* Change license to AGPL by @bmarty in https://github.com/element-hq/element-x-android/pull/3422
* Remove Wait list screen by @bmarty in https://github.com/element-hq/element-x-android/pull/3428
Changes in Element X v0.5.2 (2024-09-05)
=========================================

View file

@ -0,0 +1,2 @@
Element X is the new generation of Element for professional and personal use on mobile. Its the fastest Matrix client with a seamless & intuitive user interface.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -1,23 +1,35 @@
Element X is the future Element.
Element X brings you both sovereign & seamless collaboration built on Matrix.
It is the brand new, and fastest ever, Matrix client. It is for personal and community use, and will support enterprise functionality later this year.
The collaboration capabilities include chat & video calls with the modern set of features such as:
• public & private channels
• room moderation & access conUpdatetrol
• replies, reactions, polls, read receipts, pinned messages, etc.
• simultaneous chat & calls (picture in picture)
• decentralized & federated communication across organizations
A complete new build, Element X transforms performance. Its not just the fastest Matrix client, its also fresher and more reliable.
All this comes in a secure & sovereign fashion without compromising responsiveness or overall usability of the app:
• enterprise-grade single sign-on
• easy & secure login & device verification via QR-code
• end to end encryption & zero trust
• protection against MITM & other cyber attacks
Its so fast for a number of reasons, but in particular weve introduced a completely new syncing service (sliding sync). So even in big end-to-end encrypted chat rooms it operates incredibly quickly.
If youre a new user, use the new Element X app from the start. Compared to the current Element app you will get:
• greatly enhanced performance, sleek user interface and overall better user experience
• enterprise-grade support for single sign-on (OIDC)
• QR-code based login & device verification
• natively integrated Element Call for video calls
• continuous improvements, bug fixes and new features
Its fresher because weve rebuilt the entire user experience. All the power of Matrix - and the complexity of decentralized end-to-end encryption - is now hidden under a beautiful and intuitive user interface using the very latest frameworks and accessibility features.
Element X delivers speed, usability and reliability on the decentralized Matrix open standard.
If youre an existing user, using the current Element app - check out the new Element X and start planning your transition. The current Element app will be phased out and will only get critical security updates.
<b>Own your data</b>
Matrix-based, Element X lets you self-host your data or choose from any free public server (the default is matrix.org, but there are plenty of others to choose from). However you host, you have ownership; its your data. Youre not the product. Youre in control.
<b>Interoperate natively</b>
Enjoy the freedom of the Matrix open standard! You have native interoperability with any other Matrix-based app. So just like email, it doesn't matter if your friends are on a different Matrix-based app you can still connect and chat.
Enjoy the freedom of the Matrix open standard! You have native interoperability with any other Matrix-based app. So just like email, it doesn't matter if your friends, partners or customers are on a different Matrix-based app - you can still connect.
<b>Encrypt your data</b>
Enjoy your right to private conversations - free from data mining, ads and all the rest of it - and stay secure. Only the people in your conversation can read your messages. And Element X E2EE applies to voice and video calls too.
Enjoy your right to private conversations - free from data mining, ads and all the rest of it - and stay secure. Only the people in your conversation can read your messages.
<b>Chat across multiple devices</b>
Stay in touch wherever you are with fully synchronized message history across all your devices, even those running traditional Element, and on the web at https://app.element.io
Stay in touch wherever you are with fully synchronized message history across all your devices, even those running Element legacy app, and on the web at https://app.element.io

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View file

@ -1 +1 @@
Fastest ever Matrix client
Sovereign. Seamless. On Matrix

View file

@ -1 +1 @@
Element X - Secure messenger
Element X - Secure Chat & Call

View file

@ -18,11 +18,13 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
@ -32,7 +34,8 @@ class PinnedEventsTimelineProvider @Inject constructor(
private val networkMonitor: NetworkMonitor,
private val featureFlagService: FeatureFlagService,
) : TimelineProvider {
private val _timelineStateFlow: MutableStateFlow<AsyncData<Timeline>> = MutableStateFlow(AsyncData.Uninitialized)
private val _timelineStateFlow: MutableStateFlow<AsyncData<Timeline>> =
MutableStateFlow(AsyncData.Uninitialized)
override fun activeTimelineFlow(): StateFlow<Timeline?> {
return _timelineStateFlow
@ -44,25 +47,46 @@ class PinnedEventsTimelineProvider @Inject constructor(
val timelineStateFlow = _timelineStateFlow
fun launchIn(scope: CoroutineScope) {
_timelineStateFlow.subscriptionCount
.map { count -> count > 0 }
.distinctUntilChanged()
.onEach { isActive ->
if (isActive) {
onActive()
} else {
onInactive()
}
}
.launchIn(scope)
}
private suspend fun onActive() = coroutineScope {
combine(
featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents),
networkMonitor.connectivity
) {
// do not use connectivity here as data can be loaded from cache, it's just to trigger retry if needed
isEnabled, _ ->
) { isEnabled, _ ->
// do not use connectivity here as data can be loaded from cache, it's just to trigger retry if needed
isEnabled
}
.onEach { isFeatureEnabled ->
if (isFeatureEnabled) {
loadTimelineIfNeeded()
} else {
_timelineStateFlow.value = AsyncData.Uninitialized
resetTimeline()
}
}
.onCompletion {
invokeOnTimeline { close() }
}
.launchIn(scope)
.launchIn(this)
}
private suspend fun onInactive() {
resetTimeline()
}
private suspend fun resetTimeline() {
invokeOnTimeline {
close()
}
_timelineStateFlow.emit(AsyncData.Uninitialized)
}
suspend fun invokeOnTimeline(action: suspend Timeline.() -> Unit) {

View file

@ -15,7 +15,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
@ -27,6 +26,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@ -55,16 +55,23 @@ import kotlin.time.Duration.Companion.milliseconds
class PinnedMessagesListPresenter @AssistedInject constructor(
@Assisted private val navigator: PinnedMessagesListNavigator,
private val room: MatrixRoom,
private val timelineItemsFactory: TimelineItemsFactory,
timelineItemsFactoryCreator: TimelineItemsFactory.Creator,
private val timelineProvider: PinnedEventsTimelineProvider,
private val snackbarDispatcher: SnackbarDispatcher,
actionListPresenterFactory: ActionListPresenter.Factory,
private val appCoroutineScope: CoroutineScope,
) : Presenter<PinnedMessagesListState> {
@AssistedFactory
interface Factory {
fun create(navigator: PinnedMessagesListNavigator): PinnedMessagesListPresenter
}
private val timelineItemsFactory: TimelineItemsFactory = timelineItemsFactoryCreator.create(
config = TimelineItemsFactoryConfig(
computeReadReceipts = false,
computeReactions = false,
)
)
private val actionListPresenter = actionListPresenterFactory.create(PinnedMessagesListTimelineActionPostProcessor())
@Composable
@ -93,10 +100,9 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
}
)
val coroutineScope = rememberCoroutineScope()
fun handleEvents(event: PinnedMessagesListEvents) {
when (event) {
is PinnedMessagesListEvents.HandleAction -> coroutineScope.handleTimelineAction(event.action, event.event)
is PinnedMessagesListEvents.HandleAction -> appCoroutineScope.handleTimelineAction(event.action, event.event)
}
}

View file

@ -22,6 +22,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
@ -54,7 +55,7 @@ import kotlinx.coroutines.withContext
const val FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS = 200L
class TimelinePresenter @AssistedInject constructor(
private val timelineItemsFactory: TimelineItemsFactory,
timelineItemsFactoryCreator: TimelineItemsFactory.Creator,
private val timelineItemIndexer: TimelineItemIndexer,
private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers,
@ -71,6 +72,13 @@ class TimelinePresenter @AssistedInject constructor(
fun create(navigator: MessagesNavigator): TimelinePresenter
}
private val timelineItemsFactory: TimelineItemsFactory = timelineItemsFactoryCreator.create(
config = TimelineItemsFactoryConfig(
computeReadReceipts = true,
computeReactions = true,
)
)
@Composable
override fun present(): TimelineState {
val localScope = rememberCoroutineScope()

View file

@ -7,6 +7,9 @@
package io.element.android.features.messages.impl.timeline.factories
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
import io.element.android.features.messages.impl.timeline.diff.TimelineItemsCacheInvalidator
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory
@ -26,15 +29,21 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
class TimelineItemsFactory @Inject constructor(
class TimelineItemsFactory @AssistedInject constructor(
@Assisted config: TimelineItemsFactoryConfig,
eventItemFactoryCreator: TimelineItemEventFactory.Creator,
private val dispatchers: CoroutineDispatchers,
private val eventItemFactory: TimelineItemEventFactory,
private val virtualItemFactory: TimelineItemVirtualFactory,
private val timelineItemGrouper: TimelineItemGrouper,
private val timelineItemIndexer: TimelineItemIndexer,
) {
@AssistedFactory
interface Creator {
fun create(config: TimelineItemsFactoryConfig): TimelineItemsFactory
}
private val eventItemFactory = eventItemFactoryCreator.create(config)
private val _timelineItems = MutableSharedFlow<ImmutableList<TimelineItem>>(replay = 1)
private val lock = Mutex()
private val diffCache = MutableListDiffCache<TimelineItem>()

View file

@ -0,0 +1,18 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.factories
/**
* Some data used to configure the creation of timeline items.
* @param computeReadReceipts when false, read receipts will be empty.
* @param computeReactions when false, reactions will be empty.
*/
data class TimelineItemsFactoryConfig(
val computeReadReceipts: Boolean,
val computeReactions: Boolean,
)

View file

@ -7,6 +7,10 @@
package io.element.android.features.messages.impl.timeline.factories.event
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
import io.element.android.features.messages.impl.timeline.groups.canBeDisplayedInBubbleBlock
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.features.messages.impl.timeline.model.AggregatedReactionSender
@ -26,17 +30,23 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.ui.messages.reply.map
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.text.DateFormat
import java.util.Date
import javax.inject.Inject
class TimelineItemEventFactory @Inject constructor(
class TimelineItemEventFactory @AssistedInject constructor(
@Assisted private val config: TimelineItemsFactoryConfig,
private val contentFactory: TimelineItemContentFactory,
private val matrixClient: MatrixClient,
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
private val permalinkParser: PermalinkParser,
) {
@AssistedFactory
interface Creator {
fun create(config: TimelineItemsFactoryConfig): TimelineItemEventFactory
}
suspend fun create(
currentTimelineItem: MatrixTimelineItem.Event,
index: Int,
@ -92,8 +102,11 @@ class TimelineItemEventFactory @Inject constructor(
}
private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions {
if (!config.computeReactions) {
return TimelineItemReactions(reactions = persistentListOf())
}
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
var aggregatedReactions = event.reactions.map { reaction ->
var aggregatedReactions = this.event.reactions.map { reaction ->
// Sort reactions within an aggregation by timestamp descending.
// This puts the most recent at the top, useful in cases like the
// reaction summary view or getting the most recent reaction.
@ -129,6 +142,9 @@ class TimelineItemEventFactory @Inject constructor(
private fun MatrixTimelineItem.Event.computeReadReceiptState(
roomMembers: List<RoomMember>,
): TimelineItemReadReceipts {
if (!config.computeReadReceipts) {
return TimelineItemReadReceipts(receipts = persistentListOf())
}
return TimelineItemReadReceipts(
receipts = event.receipts
.map { receipt ->

View file

@ -18,7 +18,7 @@ import io.element.android.features.messages.impl.actionlist.FakeActionListPresen
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.draft.FakeComposerDraftService
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
import io.element.android.features.messages.impl.messagecomposer.DefaultMessageComposerContext
import io.element.android.features.messages.impl.messagecomposer.FakeRoomAliasSuggestionsDataSource
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
@ -1024,7 +1024,7 @@ class MessagesPresenterTest {
permissionsPresenterFactory,
)
val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
room = matrixRoom,
dispatchers = coroutineDispatchers,
appScope = this,

View file

@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.fixtures
import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFailedToParseMessageFactory
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFailedToParseStateFactory
@ -39,40 +40,56 @@ import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorW
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
internal fun TestScope.aTimelineItemsFactoryCreator(
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
): TimelineItemsFactory.Creator {
return object : TimelineItemsFactory.Creator {
override fun create(config: TimelineItemsFactoryConfig): TimelineItemsFactory {
return aTimelineItemsFactory(config, timelineItemIndexer)
}
}
}
internal fun TestScope.aTimelineItemsFactory(
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer()
config: TimelineItemsFactoryConfig,
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
): TimelineItemsFactory {
val timelineEventFormatter = aTimelineEventFormatter()
val matrixClient = FakeMatrixClient()
return TimelineItemsFactory(
dispatchers = testCoroutineDispatchers(),
eventItemFactory = TimelineItemEventFactory(
contentFactory = TimelineItemContentFactory(
messageFactory = TimelineItemContentMessageFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
featureFlagService = FakeFeatureFlagService(),
htmlConverterProvider = FakeHtmlConverterProvider(),
eventItemFactoryCreator = object : TimelineItemEventFactory.Creator {
override fun create(config: TimelineItemsFactoryConfig): TimelineItemEventFactory {
return TimelineItemEventFactory(
contentFactory = TimelineItemContentFactory(
messageFactory = TimelineItemContentMessageFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
featureFlagService = FakeFeatureFlagService(),
htmlConverterProvider = FakeHtmlConverterProvider(),
permalinkParser = FakePermalinkParser(),
textPillificationHelper = FakeTextPillificationHelper(),
),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation()
),
pollFactory = TimelineItemContentPollFactory(FakeFeatureFlagService(), FakePollContentStateFactory()),
utdFactory = TimelineItemContentUTDFactory(),
roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter),
profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter),
stateFactory = TimelineItemContentStateFactory(timelineEventFormatter),
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
),
matrixClient = matrixClient,
lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
permalinkParser = FakePermalinkParser(),
textPillificationHelper = FakeTextPillificationHelper(),
),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation()
),
pollFactory = TimelineItemContentPollFactory(FakeFeatureFlagService(), FakePollContentStateFactory()),
utdFactory = TimelineItemContentUTDFactory(),
roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter),
profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter),
stateFactory = TimelineItemContentStateFactory(timelineEventFormatter),
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
),
matrixClient = matrixClient,
lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
permalinkParser = FakePermalinkParser(),
),
config = config
)
}
},
virtualItemFactory = TimelineItemVirtualFactory(
daySeparatorFactory = TimelineItemDaySeparatorFactory(
FakeDaySeparatorFormatter()
@ -80,6 +97,7 @@ internal fun TestScope.aTimelineItemsFactory(
),
timelineItemGrouper = TimelineItemGrouper(),
timelineItemIndexer = timelineItemIndexer,
config = config
)
}

View file

@ -10,7 +10,7 @@ package io.element.android.features.messages.impl.pinned.list
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.networkmonitor.api.NetworkMonitor
@ -35,11 +35,14 @@ import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class PinnedMessagesListPresenterTest {
@Test
fun `present - initial state feature disabled`() = runTest {
@ -155,6 +158,7 @@ class PinnedMessagesListPresenterTest {
val filledState = awaitItem() as PinnedMessagesListState.Filled
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Redact, eventItem))
advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
assert(redactEventLambda)
.isCalledOnce()
@ -184,9 +188,11 @@ class PinnedMessagesListPresenterTest {
pinnedEventsTimeline.unpinEventLambda = successUnpinEventLambda
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Unpin, eventItem))
advanceUntilIdle()
pinnedEventsTimeline.unpinEventLambda = failureUnpinEventLambda
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Unpin, eventItem))
advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
@ -221,6 +227,7 @@ class PinnedMessagesListPresenterTest {
val filledState = awaitItem() as PinnedMessagesListState.Filled
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.ViewInTimeline, eventItem))
advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
assert(onViewInTimelineClickLambda)
.isCalledOnce()
@ -249,6 +256,7 @@ class PinnedMessagesListPresenterTest {
val filledState = awaitItem() as PinnedMessagesListState.Filled
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.ViewSource, eventItem))
advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
assert(onShowEventDebugInfoClickLambda)
.isCalledOnce()
@ -277,6 +285,7 @@ class PinnedMessagesListPresenterTest {
val filledState = awaitItem() as PinnedMessagesListState.Filled
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Forward, eventItem))
advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
assert(onForwardEventClickLambda)
.isCalledOnce()
@ -318,10 +327,11 @@ class PinnedMessagesListPresenterTest {
return PinnedMessagesListPresenter(
navigator = navigator,
room = room,
timelineItemsFactory = aTimelineItemsFactory(),
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
timelineProvider = timelineProvider,
snackbarDispatcher = SnackbarDispatcher(),
actionListPresenterFactory = FakeActionListPresenter.Factory,
appCoroutineScope = this,
)
}
}

View file

@ -14,9 +14,8 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.FakeMessagesNavigator
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
import io.element.android.features.messages.impl.timeline.components.aCriticalShield
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
@ -662,7 +661,6 @@ import kotlin.time.Duration.Companion.seconds
liveTimeline = timeline,
canUserSendMessageResult = { _, _ -> Result.success(true) }
),
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory(),
redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),
endPollAction: EndPollAction = FakeEndPollAction(),
@ -671,7 +669,7 @@ import kotlin.time.Duration.Companion.seconds
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
room = room,
dispatchers = testCoroutineDispatchers(),
appScope = this,

View file

@ -9,12 +9,13 @@ package io.element.android.features.preferences.impl.root
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser
data class PreferencesRootState(
val myUser: MatrixUser,
val version: String,
val deviceId: String?,
val deviceId: DeviceId?,
val showSecureBackup: Boolean,
val showSecureBackupBadge: Boolean,
val accountManagementUrl: String?,

View file

@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.root
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
@ -18,7 +19,7 @@ fun aPreferencesRootState(
) = PreferencesRootState(
myUser = myUser,
version = "Version 1.1 (1)",
deviceId = "ILAKNDNASDLK",
deviceId = DeviceId("ILAKNDNASDLK"),
showSecureBackup = true,
showSecureBackupBadge = true,
accountManagementUrl = "aUrl",

View file

@ -36,6 +36,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserProvider
import io.element.android.libraries.ui.strings.CommonStrings
@ -229,7 +230,7 @@ private fun ColumnScope.GeneralSection(
@Composable
private fun ColumnScope.Footer(
version: String,
deviceId: String?,
deviceId: DeviceId?,
onClick: (() -> Unit)?,
) {
val text = remember(version, deviceId) {

View file

@ -19,7 +19,7 @@ datastore = "1.0.0"
constraintlayout = "2.1.4"
constraintlayout_compose = "1.0.1"
lifecycle = "2.8.4"
activity = "1.9.1"
activity = "1.9.2"
media3 = "1.4.1"
camera = "1.3.4"
@ -162,7 +162,7 @@ jsoup = "org.jsoup:jsoup:1.18.1"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0"
timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.42"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.43"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }

View file

@ -115,7 +115,7 @@ enum class FeatureFlags(
key = "feature.pinnedEvents",
title = "Pinned Events",
description = "Allow user to pin events in a room",
defaultValue = { false },
defaultValue = { true },
isFinished = false,
),
SyncOnPush(

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.api
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
@ -41,7 +42,7 @@ import java.util.Optional
interface MatrixClient : Closeable {
val sessionId: SessionId
val deviceId: String
val deviceId: DeviceId
val userProfile: StateFlow<MatrixUser>
val roomListService: RoomListService
val mediaLoader: MatrixMediaLoader

View file

@ -0,0 +1,15 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.core
import java.io.Serializable
@JvmInline
value class DeviceId(val value: String) : Serializable {
override fun toString(): String = value
}

View file

@ -7,9 +7,11 @@
package io.element.android.libraries.matrix.api.oidc
import io.element.android.libraries.matrix.api.core.DeviceId
sealed interface AccountManagementAction {
data object Profile : AccountManagementAction
data object SessionsList : AccountManagementAction
data class SessionView(val deviceId: String) : AccountManagementAction
data class SessionEnd(val deviceId: String) : AccountManagementAction
data class SessionView(val deviceId: DeviceId) : AccountManagementAction
data class SessionEnd(val deviceId: DeviceId) : AccountManagementAction
}

View file

@ -12,6 +12,7 @@ import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
@ -120,7 +121,7 @@ class RustMatrixClient(
sessionDelegate: RustClientSessionDelegate,
) : MatrixClient {
override val sessionId: UserId = UserId(client.userId())
override val deviceId: String = client.deviceId()
override val deviceId: DeviceId = DeviceId(client.deviceId())
override val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-$sessionId")
private val innerRoomListService = syncService.roomListService()
@ -173,13 +174,13 @@ class RustMatrixClient(
roomListService = roomListService,
innerRoomListService = innerRoomListService,
sessionId = sessionId,
deviceId = deviceId,
notificationSettingsService = notificationSettingsService,
sessionCoroutineScope = sessionCoroutineScope,
dispatchers = dispatchers,
systemClock = clock,
roomContentForwarder = RoomContentForwarder(innerRoomListService),
roomSyncSubscriber = roomSyncSubscriber,
getSessionData = { sessionStore.getSession(sessionId.value)!! },
)
override val mediaLoader: MatrixMediaLoader = RustMediaLoader(

View file

@ -13,8 +13,8 @@ import org.matrix.rustcomponents.sdk.AccountManagementAction as RustAccountManag
fun AccountManagementAction.toRustAction(): RustAccountManagementAction {
return when (this) {
AccountManagementAction.Profile -> RustAccountManagementAction.Profile
is AccountManagementAction.SessionEnd -> RustAccountManagementAction.SessionEnd(deviceId)
is AccountManagementAction.SessionView -> RustAccountManagementAction.SessionView(deviceId)
is AccountManagementAction.SessionEnd -> RustAccountManagementAction.SessionEnd(deviceId.value)
is AccountManagementAction.SessionView -> RustAccountManagementAction.SessionView(deviceId.value)
AccountManagementAction.SessionsList -> RustAccountManagementAction.SessionsList
}
}

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomAlias
@ -52,7 +53,6 @@ import io.element.android.libraries.matrix.impl.util.MessageEventContent
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
import io.element.android.libraries.matrix.impl.widget.generateWidgetWebViewUrl
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -89,6 +89,7 @@ import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
@OptIn(ExperimentalCoroutinesApi::class)
class RustMatrixRoom(
override val sessionId: SessionId,
private val deviceId: DeviceId,
private val roomListItem: RoomListItem,
private val innerRoom: InnerRoom,
innerTimeline: InnerTimeline,
@ -97,7 +98,6 @@ class RustMatrixRoom(
private val coroutineDispatchers: CoroutineDispatchers,
private val systemClock: SystemClock,
private val roomContentForwarder: RoomContentForwarder,
private val sessionData: SessionData,
private val roomSyncSubscriber: RoomSyncSubscriber,
private val matrixRoomInfoMapper: MatrixRoomInfoMapper,
) : MatrixRoom {
@ -124,7 +124,7 @@ class RustMatrixRoom(
override fun call(typingUserIds: List<String>) {
channel.trySend(
typingUserIds
.filter { it != sessionData.userId }
.filter { it != sessionId.value }
.map(::UserId)
)
}
@ -188,6 +188,7 @@ class RustMatrixRoom(
innerRoom.pinnedEventsTimeline(
internalIdPrefix = "pinned_events",
maxEventsToLoad = 100u,
maxConcurrentRequests = 10u,
).let { inner ->
createTimeline(inner, mode = Timeline.Mode.PINNED_EVENTS)
}
@ -606,7 +607,7 @@ class RustMatrixRoom(
room = innerRoom,
widgetCapabilitiesProvider = object : WidgetCapabilitiesProvider {
override fun acquireCapabilities(capabilities: WidgetCapabilities): WidgetCapabilities {
return getElementCallRequiredPermissions(sessionId.value, sessionData.deviceId)
return getElementCallRequiredPermissions(sessionId.value, deviceId.value)
}
},
)

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.impl.room
import androidx.collection.lruCache
import io.element.android.appconfig.TimelineConfig
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
@ -18,7 +19,6 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.awaitLoaded
import io.element.android.libraries.matrix.impl.roomlist.fullRoomWithTimeline
import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -38,6 +38,7 @@ private const val CACHE_SIZE = 16
class RustRoomFactory(
private val sessionId: SessionId,
private val deviceId: DeviceId,
private val notificationSettingsService: NotificationSettingsService,
private val sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
@ -46,7 +47,6 @@ class RustRoomFactory(
private val roomListService: RoomListService,
private val innerRoomListService: InnerRoomListService,
private val roomSyncSubscriber: RoomSyncSubscriber,
private val getSessionData: suspend () -> SessionData,
) {
@OptIn(ExperimentalCoroutinesApi::class)
private val dispatcher = dispatchers.io.limitedParallelism(1)
@ -108,6 +108,7 @@ class RustRoomFactory(
val liveTimeline = roomReferences.fullRoom.timeline()
RustMatrixRoom(
sessionId = sessionId,
deviceId = deviceId,
roomListItem = roomReferences.roomListItem,
innerRoom = roomReferences.fullRoom,
innerTimeline = liveTimeline,
@ -116,7 +117,6 @@ class RustRoomFactory(
coroutineDispatchers = dispatchers,
systemClock = systemClock,
roomContentForwarder = roomContentForwarder,
sessionData = getSessionData(),
roomSyncSubscriber = roomSyncSubscriber,
matrixRoomInfoMapper = matrixRoomInfoMapper,
)

View file

@ -61,9 +61,11 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.EditedContent
import org.matrix.rustcomponents.sdk.EventTimelineItem
import org.matrix.rustcomponents.sdk.FormattedBody
import org.matrix.rustcomponents.sdk.MessageFormat
import org.matrix.rustcomponents.sdk.PollData
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
@ -274,8 +276,15 @@ class RustTimeline(
withContext(dispatcher) {
runCatching<Unit> {
getEventTimelineItem(originalEventId, transactionId).use { item ->
val editedContent = EditedContent.RoomMessage(
content = MessageEventContent.from(
body = body,
htmlBody = htmlBody,
intentionalMentions = intentionalMentions
),
)
inner.edit(
newContent = MessageEventContent.from(body, htmlBody, intentionalMentions),
newContent = editedContent,
item = item,
)
}
@ -434,16 +443,21 @@ class RustTimeline(
inner.getEventTimelineItemByEventId(
eventId = pollStartId.value
)
pollStartEvent.use {
inner.editPoll(
val editedContent = EditedContent.PollStart(
pollData = PollData(
question = question,
answers = answers,
maxSelections = maxSelections.toUByte(),
pollKind = pollKind.toInner(),
editItem = pollStartEvent,
),
)
pollStartEvent.use {
inner.edit(
newContent = editedContent,
item = it,
)
}
}
}.map { }
}
override suspend fun sendPollResponse(

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.test
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
@ -58,7 +59,7 @@ import java.util.Optional
class FakeMatrixClient(
override val sessionId: SessionId = A_SESSION_ID,
override val deviceId: String = "A_DEVICE_ID",
override val deviceId: DeviceId = A_DEVICE_ID,
override val sessionCoroutineScope: CoroutineScope = TestScope(),
private val userDisplayName: String? = A_USER_NAME,
private val userAvatarUrl: String? = AN_AVATAR_URL,

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.test
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
@ -47,6 +48,7 @@ val AN_EVENT_ID = EventId("\$anEventId")
val AN_EVENT_ID_2 = EventId("\$anEventId2")
val A_ROOM_ALIAS = RoomAlias("#alias1:domain")
val A_TRANSACTION_ID = TransactionId("aTransactionId")
val A_DEVICE_ID = DeviceId("ILAKNDNASDLK")
val A_UNIQUE_ID = UniqueId("aUniqueId")
val A_UNIQUE_ID_2 = UniqueId("aUniqueId2")

View file

@ -42,12 +42,12 @@ import org.gradle.jvm.toolchain.JavaLanguageVersion
// Note: 2 digits max for each value
private const val versionMajor = 0
private const val versionMinor = 5
private const val versionMinor = 6
// Note: even values are reserved for regular release, odd values for hotfix release.
// When creating a hotfix, you should decrease the value, since the current value
// is the value for the next regular release.
private const val versionPatch = 3
private const val versionPatch = 0
object Versions {
val versionCode = 4_000_000 + versionMajor * 1_00_00 + versionMinor * 1_00 + versionPatch

View file

@ -177,9 +177,8 @@ printf "Committing...\n"
git commit -a -m 'version++'
printf "\n================================================================================\n"
printf "Wait for the GitHub action https://github.com/element-hq/element-x-android/actions/workflows/release.yml?query=branch%%3Amain to build the 'main' branch.\n"
printf "Please enter the url of the github action (!!! WARNING: NOT THE URL OF THE ARTIFACT ANYMORE !!!)\n"
read -p "For instance https://github.com/element-hq/element-x-android/actions/runs/9065756777: " runUrl
printf "The GitHub action https://github.com/element-hq/element-x-android/actions/workflows/release.yml?query=branch%%3Amain should have start a new run.\n"
read -p "Please enter the url of the run, no need to wait for it to complete (example: https://github.com/element-hq/element-x-android/actions/runs/9065756777): " runUrl
targetPath="./tmp/Element/${version}"
@ -270,7 +269,7 @@ printf "File app-fdroid-x86_64-release-signed.apk:\n"
"${buildToolsPath}"/aapt dump badging "${fdroidTargetPath}"/app-fdroid-x86_64-release-signed.apk | grep package
printf "\n"
read -p "Does it look correct? Press enter when it's done."
read -p "Does it look correct? Press enter when it's done. "
printf "\n================================================================================\n"
printf "The APKs in ${fdroidTargetPath} have been signed!\n"
@ -363,7 +362,7 @@ read -p ". Press enter to continue. "
printf "\n================================================================================\n"
printf "Update the project release notes:\n\n"
read -p "Copy the content of the release note generated by GitHub to the file CHANGES.md and press enter to commit the change. \n"
read -p "Copy the content of the release note generated by GitHub to the file CHANGES.md and press enter to commit the change. "
printf "\n================================================================================\n"
printf "Committing...\n"