* Model the fake voice recorder on a cancellable recording job
FakeVoiceRecorder pushed a whole recording synchronously from a single
suspending call, which made it both weaker and stricter than
DefaultVoiceRecorder.
Every level emission carried the same elapsed time and the emissions were
coalesced by Molecule, so no intermediate recording state was observable and
the level-limit test had to skip half the emissions to compensate. Each level
now advances the test time source and suspends for its share of the recording
duration, so the states arrive one at a time and that magic number is gone.
Call verification moved to the constructor lambdas the rest of the repo uses,
in place of hand-rolled counters and assertCalls. The counters discarded the
argument, so nothing pinned that cancelling a recording stops it with
cancelled = true; that is now asserted. Truth is no longer needed by the
fixture module.
A second startRecord() no longer raises an error where production only warns
and returns, and stopping or deleting terminates an in-flight emission loop
the way cancelling the real recording job does, so a stale recording state can
no longer arrive after the recording has finished.
* Keep each recording's level loop to its own recording
The level loop checked a single isRecording flag, which cannot tell "this
recording was cancelled" from "a later recording has started". After a cancel
followed by a new start, the first loop resumed, saw the flag set again, and
advanced the shared test time source, so the new recording reported the elapsed
time of both. Each recording now carries an id and its loop stops as soon as
that id is no longer the active one.
* Give the room name in the timeline top bar a 48dp tap target
The row carrying the room avatar and name is the control that opens the
room details, but it is only as tall as its content, which is under the
48dp minimum recommended for a touch target. Constrain it to a minimum
height of 48dp. The children stay centred inside the app bar, so nothing
moves visually.
* Update screenshots
The accessibility snapshot of the timeline is the only one the change
affects: the node which opens the room details now reports a 48dp tall
bounding box. The room name, the avatar and the icons stay where they
were.
* Refresh the room members when a mention is started
The mention suggestions read room.membersStateFlow, which is a cache filled
only by an explicit updateMembers() call. Nothing calls it while a room is
open, so a member who joined after the room was opened never appears in the
suggestion list until the room is left and entered again.
Call updateMembers() when a mention search starts. The trigger is
distinctUntilChanged on "the current suggestion is a mention", so it fires
once per mention rather than on every keystroke, and fetchRoomMembers already
no-ops while a fetch is in flight.
The timeline action sheet offered Forward on a live location share, but
forwarding one always failed: Mode.Live maps to the SDK's
MsgLikeKind.LiveLocation, which RoomContentForwarder rejects outright. So
the action was shown for exactly the set of events on which it could
never work.
canBeForwarded() now returns true for a location event only when its mode
is Static. Ended live shares keep Mode.Live and fail for the same reason,
so they are excluded too. ActionListPresenter needs no change: its check
is the function's only call site in the repo, so the single gate covers
the timeline sheet, the threaded timeline and the pinned messages list.
Hiding the action mirrors Element Web and the sticker exclusion already
present in the same function. Converting a live share into a static
m.location on forward would be a product decision and is out of scope.
Relates to https://github.com/element-hq/element-x-android/issues/7190
* Expand the emoji picker to full height when the keyboard is shown
The picker content is rendered with fillMaxSize, so the sheet measures itself to
the full window height. That makes the partially expanded anchor available, and
the modal sheet auto-shows at that anchor, half way down the screen.
The sheet reserves the IME inset through its content window insets, but applies
that padding at its own bottom edge, which is off-screen while it rests half way
down. The band that is both on-screen and above the keyboard is then only about
a tenth of the screen, which is why the matching emoji end up behind it.
Expand the sheet while the keyboard is visible so its bottom edge returns to the
screen edge and the inset it already reserves lands on top of the keyboard.
Modifier.imePadding on the sheet content is not an option: the sheet applies the
insets with windowInsetsPadding, which consumes them, so a nested imePadding
reads zero.
* Cover the keyboard driven sheet expansion with a test
The IME reaction moves into ExpandSheetWhenImeIsVisible so a Robolectric test can
drive it: dispatching window insets to the composition flips
WindowInsets.isImeVisible, and the sheet state is observable from the test. Both
the shown and the hidden case are asserted, which is the patch coverage this
change was missing.
* Do not leave a blank strip below the expanded emoji search
The 10dp of bottom padding on the search bar separates the collapsed
field from the category tabs underneath it. It stayed applied once the
search bar expanded to fill the picker, so the picker stopped 10dp short
of its own bottom edge.
That strip used to sit below the visible part of a half height sheet.
Now that the sheet expands against the keyboard it reads as a gap
between the results and the keyboard, so only pad while collapsed.
Restore the snapshot backed set the store started with and declare
allowedEventIds as SnapshotStateSet so the interface says out loud that
reads of it are observable.
TimelineProtectionPresenter reads the set inside derivedStateOf, so a
plain set silently stops the timeline from re-rendering when an event is
revealed. Naming the type keeps that requirement from being mistaken for
an implementation detail.
The store no longer needs a mutableStateSetOf: an ordinary Set kept in a
mutableStateOf does the same job, and matches DefaultMediaPreviewConfigStateStore.
The Compose state itself has to stay. TimelineProtectionPresenter derives
ProtectionState from the store inside a derivedStateOf, so the set must be a
snapshot object for a reveal to invalidate it and reach the timeline. Backing it
with a plain mutableSetOf leaves the presenter with nothing to observe and it
stops emitting after ShowContent.
The pinned events timeline is built with an exclusion filter rather than an
allowlist, so an event of a type the app has no renderer for reaches both
pinned surfaces. The banner falls through to the formatter catch-all and reads
"Unsupported event", and the list renders an unknown content tile.
Drop unknown content and message-like or state events that failed to parse from
both surfaces, along with any day divider left without an event to introduce,
and show the empty state when nothing renderable remains. The spec makes this
the client's call. Such an event can still be unpinned from the room timeline,
where it is still rendered.
Undecryptable and redacted events are untouched: they carry information the
user needs.
The set of revealed event ids lived on TimelineProtectionPresenter, which is
scoped to the room graph. That graph is rebuilt every time the room is opened,
so the set was empty on re-entry and every revealed image was hidden again.
Move it into a session scoped TimelineProtectionStore. The store is in memory
rather than persisted: revealing media is a privacy control and there is no
hide action today, so keeping the reveals for the session leaves restarting the
app as a way to undo one, and the set is dropped on logout.
When the replied to event could not be fetched, the reply preview rendered the
raw SDK error string in critical styling, and tapping it emitted a focus
request that could never succeed, so the user got a dead end dialog.
Render the localised error_message_not_found string in the neutral italic style
already used for redacted and undecryptable replies, and drop the click handler
for that case. The existing key is reused rather than adding a new one; a "you
do not have access" wording was rejected because this state covers any failure
to load the event, not only a permission problem.
A permalink written as a matrix: URI stayed plain text, while the same
link in matrix.to form became a pill. The pillifier only scanned
Patterns.WEB_URL, which cannot match a URI with no web scheme, even though
the permalink parser already understands matrix: URIs.
Those URIs are now scanned for as well, before the web pass, since
Patterns.WEB_URL otherwise claims the bare domain inside the URI and
whichever pass runs first wins the range. Each match also gets a URLSpan:
the timeline only reports URLSpans as clicks, so without one the pill
would look right and do nothing.
The composer path is untouched, since it pillifies without permalinks.
Relates to https://github.com/element-hq/element-x-android/issues/5319
Consecutive messages from the same sender were grouped into one bubble
block no matter how far apart they were sent, so a reply hours later
looked like part of the earlier burst and lost its own timestamp header.
computeGroupPosition now folds a five minute limit into the two
groupability flags it already computes. The delta is symmetric between a
pair of neighbours, so both items always agree on where the block splits
and every branch of the existing decision table keeps working unchanged.
Breaking a group on reactions, which the iOS client also does, is a
separate divergence and is not part of this.
Relates to https://github.com/element-hq/element-x-android/issues/4757
* Import Compound tokens from `v10.2.3`
* Remove manually added `bgSubtleTertiary` semantic color: it's in Compound now
* Bump enterprise module to a compatible commit
* Fix insets in several screens
UI components in the room list, room member list, etc. now have better insets based on `WindowInsets.safeDrawing`
* Update screenshots
---------
Co-authored-by: ElementBot <android@element.io>
* Use the right insets in the home screen UI
That's `WindowInsets.statusBar` for the `Scaffold` component, then `WindowInsets.navigationBar` for the `LazyColum.contentPadding`
* Apply it to other screens with long lists of items too
* Apply the insets to `PreferencePage` too
* Apply inset changes to other screens with scrollable content
* Add helper insets/padding values based on review suggestions.
* Use the newly added insets/padding values in several scaffold and scrollable columns
---------
Co-authored-by: ElementBot <android@element.io>
* When no URL is provided to `ContentValidationState.collectMediaState`, assume it's valid
That way we can do:
`if (validationState.isValid() && state.collectMediaState(possiblyNullUrl).isValid()) ...`
* Remove the `eventId` param from `ContentScannerService.scan`
It's not really used, since we then provide a state that can be mutated, and fetching that is what the previous parameter was used for
* Make sure to always validate the medias in the viewer, both the actual media and the thumbnail
* Update screenshots
---------
Co-authored-by: ElementBot <android@element.io>