Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
7452f05
feat(samples): show the channel list and channel side by side on regu…
xsahil03x Sep 29, 2026
b229378
feat(samples): split the channel list and channel at a fold or hinge
xsahil03x Sep 29, 2026
20c54ef
chore(samples): update the Android Gradle wrapper to 9.3.1
xsahil03x Sep 29, 2026
8511290
feat(ui): add automaticallyImplyLeading to StreamChannelPage and Stre…
xsahil03x Sep 29, 2026
78fa203
feat(samples): make the channel pane behave like a split view's detail
xsahil03x Sep 29, 2026
59ed593
fix(samples): stop an open channel's state leaking into the next one
xsahil03x Sep 29, 2026
c8fd516
fix(samples): open drafts in the channel pane
xsahil03x Sep 29, 2026
ab9f6e4
docs(ui): note that onBackPressed is not called without a back button
xsahil03x Sep 29, 2026
3216023
docs(samples): tidy the split view docs and constants
xsahil03x Sep 29, 2026
1b1fbbc
fix(samples): keep the channel list's selection in place while scrolling
xsahil03x Sep 29, 2026
30c93db
fix(ui): stop StreamThreadListTile overflowing when narrow
xsahil03x Sep 29, 2026
13def83
feat(ui): add StreamThreadListTile.selected and copyWith
xsahil03x Sep 29, 2026
07b69dc
feat(samples): open threads, drafts and reminders in the channel pane
xsahil03x Sep 29, 2026
7de3804
feat(samples): open the drawer over both panes
xsahil03x Sep 29, 2026
f07067f
fix(ui): stop the message actions modal throwing after its message le…
xsahil03x Sep 29, 2026
6b963b4
fix(samples): swap channels in place beside the list and close their …
xsahil03x Sep 29, 2026
c8a2564
feat(samples): show one pane on regular windows narrower than 840
xsahil03x Sep 30, 2026
81baeb5
fix(samples): keep the channel list's selection from showing behind t…
xsahil03x Sep 30, 2026
96d4ed7
feat(samples): ease the split into place when a fold appears or disap…
xsahil03x Sep 30, 2026
ac627b8
fix(samples): close the login progress dialog without asking the rout…
xsahil03x Sep 30, 2026
ae99d38
fix(samples): update the Crashlytics Gradle plugin to 3.0.8 for Gradle 9
xsahil03x Sep 30, 2026
d50bf7f
refactor(samples): classify the window by width and height separately
xsahil03x Oct 1, 2026
f8641c7
Merge branch 'sahil/flu-823-iphone-duo-polish-viewer-fit-thumbnails-a…
xsahil03x Oct 1, 2026
5f43140
Merge commit '419161627' into worktree-flu-826-two-pane
xsahil03x Oct 1, 2026
c590111
feat(samples): let people resize the split view's panes
xsahil03x Oct 2, 2026
054f216
Merge #3021 into the FLU-826 branch
xsahil03x Oct 2, 2026
4e82cd0
Merge #3021 into the FLU-826 branch
xsahil03x Oct 12, 2026
73d0bd5
fix(samples): close the split view's drawer on back before the channel
xsahil03x Oct 15, 2026
4ac15ab
fix(samples): keep the split view's resized width across one-pane lay…
xsahil03x Oct 16, 2026
febffee
fix(samples): resize the split view by touch only from a handle
xsahil03x Oct 16, 2026
dec3f8e
docs(samples): describe the split view's handle and resized width acc…
xsahil03x Oct 16, 2026
d9d02db
Revert "fix(samples): update the Crashlytics Gradle plugin to 3.0.8 f…
xsahil03x Oct 16, 2026
8ff68d0
Revert "chore(samples): update the Android Gradle wrapper to 9.3.1"
xsahil03x Oct 16, 2026
b81c16f
Merge the FLU-823 branch into the FLU-826 branch
xsahil03x Oct 16, 2026
dffea08
Merge the FLU-823 branch into the FLU-826 branch
xsahil03x Oct 16, 2026
cc1bc92
fix(samples): open a channel from a notification or DM with goNamed
xsahil03x Oct 16, 2026
40bcf16
fix(ui): drop a message action picked after its message leaves the tree
xsahil03x Oct 16, 2026
86348b0
Merge the FLU-823 branch into the FLU-826 branch
xsahil03x Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
- Polls are now shown translated into the current user's language, following `StreamMessageTranslationConfiguration` like the message text.
- Added `PollTranslationX`, `PollOptionTranslationX` and `PollVoteTranslationX`, to read the translations of a poll, its options and its answers.
- Added `MessageTranslationLanguage`, the language `StreamMessageContent` shows a message translated into, for custom attachments to show translations too.
- Added `automaticallyImplyLeading` to `StreamChannelPage` and `StreamThreadPage`, which hides the header's back button when `false`.
- Added a `selected` parameter to `StreamThreadListTile`, which highlights the tile, and `StreamThreadListTile.copyWith`.
- `StreamMessageListView` now shows who is typing at the newest end of the list with `StreamMessageListTypingIndicator`, replacing the indicator above `StreamChannelPage`'s composer. Replace it with `StreamMessageListViewBuilders.typingIndicator`, or hide it with `StreamMessageListViewConfiguration.showTypingIndicator`.
- Added `StreamTypingUsersBuilder`, to build a widget from the users typing in a channel or thread.

Expand Down Expand Up @@ -38,6 +40,8 @@
- Fixed the attachment picker overflowing short screens, such as a phone in landscape.
- Fixed `StreamMessageListView`'s default loading, empty and error states overflowing when the list has little room, such as in landscape with the attachment picker open.
- Fixed the composer's autocomplete suggestions stopping short of the screen edge under a side safe-area inset.
- Fixed `StreamThreadListTile` overflowing when narrow; its timestamp now truncates instead.
- Fixed `StreamMessageItem`'s actions modal throwing when the message leaves the screen while the modal is open.

## 10.5.0

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,7 @@ class DefaultStreamMessageItem extends StatelessWidget {
initialReactionType: initialReaction?.type,
);

if (action is! MessageAction) return;
if (action is! MessageAction || !context.mounted) return;
return _onActionTap(context, channel, action).ignore();
}

Expand Down Expand Up @@ -903,7 +903,7 @@ class DefaultStreamMessageItem extends StatelessWidget {
),
);

if (action is! MessageAction) return;
if (action is! MessageAction || !context.mounted) return;
return _onActionTap(context, channel, action).ignore();
}

Expand Down Expand Up @@ -935,6 +935,8 @@ class DefaultStreamMessageItem extends StatelessWidget {
currentUser: currentUser,
);

// Read up front: the modal can outlive this message, and with it `context`.
final configuration = StreamChatConfiguration.of(context);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes the modal's builder, but the action still runs on context after the modal closes. Line 990 calls _onActionTap(context, channel, action).ignore() after await showStreamDialog(...), with no context.mounted check, and that is the context this comment says can be gone.

If the message leaves the tree while the modal is open:

  • React: _selectReaction calls StreamChatConfiguration.of(context) again (line 1089), the lookup this PR moved up front.
  • Delete / Flag: _maybeDeleteMessage and _maybeFlagMessage call showStreamDialog(context: context).

These throw inside the async _onActionTap, and .ignore() discards the error. The modal closes, nothing happens, and nothing is logged.

Suggested fix:

  • Pass the configuration already read here into _selectReaction.
  • Capture the navigator (and translations) before the await, or bail out or log on !context.mounted.
  • Extend stream_message_item_actions_modal_test.dart to tap an action (for example a reaction) after the message is removed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with bailing out on !context.mounted in 40bcf16, after the actions modal, the bounced-message dialog and the reactions sheet. That matches Flutter's PopupMenuButton, which drops the selection when the button is no longer mounted.

The item only leaves the tree while its modal is open when the message is removed from the channel, so the action is dropped rather than run against a message that's gone. The new test taps Thread Reply after the message is removed and expects onThreadTap not to be called. Thread Reply is one of the actions that never touch context, so the test fails without the check.

final layout = StreamMessageLayout.of(context);
final theme = core.StreamMessageItemTheme.of(context);
final defaults = _StreamMessageItemDefaults(
Expand Down Expand Up @@ -965,7 +967,7 @@ class DefaultStreamMessageItem extends StatelessWidget {
context: context,
useRootNavigator: false,
builder: (_) => StreamChatConfiguration(
data: StreamChatConfiguration.of(context),
data: configuration,
child: StreamMessageLayout(
// The message is re-rendered on top of the modal scrim, so its
// metadata and annotations switch to their on-scrim colors.
Expand All @@ -984,7 +986,7 @@ class DefaultStreamMessageItem extends StatelessWidget {
),
);

if (action is! MessageAction) return;
if (action is! MessageAction || !context.mounted) return;
return _onActionTap(context, channel, action).ignore();
}

Expand Down
7 changes: 7 additions & 0 deletions packages/stream_chat_flutter/lib/src/pages/channel_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ class StreamChannelPage extends StatefulWidget {
super.key,
this.initialScrollIndex,
this.initialAlignment,
this.automaticallyImplyLeading = true,
this.onBackPressed,
this.onChannelAvatarPressed,
});
Expand All @@ -70,6 +71,11 @@ class StreamChannelPage extends StatefulWidget {
/// Initial scroll alignment for the message list.
final double? initialAlignment;

/// Whether the header shows a back button.
///
/// When `false`, [onBackPressed] is never called. Defaults to `true`.
final bool automaticallyImplyLeading;

/// Called when the header's back button is pressed.
///
/// Replaces the default action, which pops the current route. When null the
Expand Down Expand Up @@ -113,6 +119,7 @@ class _StreamChannelPageState extends State<StreamChannelPage> {
@override
Widget build(BuildContext context) {
final appBar = StreamChannelHeader(
automaticallyImplyLeading: widget.automaticallyImplyLeading,
// Leaving this null keeps the header's default back button, which pops
// the route.
onBackPressed: widget.onBackPressed,
Expand Down
7 changes: 7 additions & 0 deletions packages/stream_chat_flutter/lib/src/pages/thread_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class StreamThreadPage extends StatefulWidget {
this.initialScrollIndex,
this.initialAlignment,
this.onViewInChannelTap,
this.automaticallyImplyLeading = true,
this.onBackPressed,
});

Expand All @@ -46,6 +47,11 @@ class StreamThreadPage extends StatefulWidget {
/// Called when the user taps "View in channel".
final void Function(Message message)? onViewInChannelTap;

/// Whether the header shows a back button.
///
/// When `false`, [onBackPressed] is never called. Defaults to `true`.
final bool automaticallyImplyLeading;

/// Called when the header's back button is pressed.
///
/// Replaces the default action, which pops the current route. When null the
Expand Down Expand Up @@ -89,6 +95,7 @@ class _StreamThreadPageState extends State<StreamThreadPage> {
Widget build(BuildContext context) {
final appBar = StreamThreadHeader(
parent: widget.parent,
automaticallyImplyLeading: widget.automaticallyImplyLeading,
// Leaving this null keeps the header's default back button, which pops
// the route.
onBackPressed: widget.onBackPressed,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ class StreamThreadListTile extends StatelessWidget {
User? currentUser,
GestureTapCallback? onTap,
GestureLongPressCallback? onLongPress,
bool selected = false,
}) : props = StreamThreadListTileProps(
thread: thread,
currentUser: currentUser,
onTap: onTap,
onLongPress: onLongPress,
selected: selected,
);

/// Creates a thread list tile from pre-built [props].
Expand All @@ -34,6 +36,26 @@ class StreamThreadListTile extends StatelessWidget {
/// The properties configuring this thread list tile.
final StreamThreadListTileProps props;

/// Creates a copy of this tile but with the given fields replaced with
/// the new values.
StreamThreadListTile copyWith({
Key? key,
Thread? thread,
User? currentUser,
GestureTapCallback? onTap,
GestureLongPressCallback? onLongPress,
bool? selected,
}) {
return StreamThreadListTile(
key: key ?? this.key,
thread: thread ?? props.thread,
currentUser: currentUser ?? props.currentUser,
onTap: onTap ?? props.onTap,
onLongPress: onLongPress ?? props.onLongPress,
selected: selected ?? props.selected,
);
}

@override
Widget build(BuildContext context) {
final builder = context.chatComponentBuilder<StreamThreadListTileProps>();
Expand All @@ -49,6 +71,7 @@ class StreamThreadListTileProps {
this.currentUser,
this.onTap,
this.onLongPress,
this.selected = false,
});

/// The thread displayed by the tile.
Expand All @@ -62,6 +85,11 @@ class StreamThreadListTileProps {

/// Called when the tile is long pressed.
final GestureLongPressCallback? onLongPress;

/// Whether the tile is highlighted as the current selection.
///
/// Defaults to `false`.
final bool selected;
}

class _DefaultStreamThreadListTile extends StatelessWidget {
Expand All @@ -74,6 +102,7 @@ class _DefaultStreamThreadListTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final spacing = context.streamSpacing;
final colorScheme = context.streamColorScheme;

final theme = StreamThreadListTileTheme.of(context);
final defaults = _StreamThreadListTileThemeDefaults(context);
Expand Down Expand Up @@ -110,11 +139,15 @@ class _DefaultStreamThreadListTile extends StatelessWidget {
child: StreamListTileTheme(
data: StreamListTileThemeData(
contentPadding: effectivePadding,
backgroundColor: .all(effectiveBackgroundColor),
backgroundColor: .resolveWith((states) {
if (!states.contains(WidgetState.selected)) return effectiveBackgroundColor;
return .alphaBlend(colorScheme.backgroundSelected, effectiveBackgroundColor);
}),
),
child: StreamListTileContainer(
onTap: props.onTap,
onLongPress: props.onLongPress,
selected: props.selected,
child: Row(
spacing: spacing.sm,
crossAxisAlignment: CrossAxisAlignment.start,
Expand Down Expand Up @@ -353,10 +386,12 @@ class ThreadFooter extends StatelessWidget {
context.translations.threadReplyCountText(replyCount),
style: replyCountStyle,
),
StreamTimestamp(
date: latestActivityAt.toLocal(),
style: timestampStyle,
formatter: timestampFormatter ?? formatRecentDateTime,
Flexible(
child: StreamTimestamp(
date: latestActivityAt.toLocal(),
style: timestampStyle,
formatter: timestampFormatter ?? formatRecentDateTime,
),
),
],
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ void main() {
expect(pressedChannel, isNotNull);
});

testWidgets('shows a back button by default', (tester) async {
await _pumpChannelPage(tester);

expect(find.byType(StreamBackButton), findsOneWidget);
});

testWidgets('hides the back button when automaticallyImplyLeading is false', (tester) async {
await _pumpChannelPage(tester, automaticallyImplyLeading: false);

expect(find.byType(StreamBackButton), findsNothing);
});

testWidgets('tapping back invokes onBackPressed', (tester) async {
var backPressed = 0;
await _pumpChannelPage(tester, onBackPressed: () => backPressed++);
Expand Down Expand Up @@ -243,6 +255,7 @@ Future<void> _pumpChannelPage(
StreamSurfaceStyle? composerSurfaceStyle,
StreamSurfaceStyle? headerSurfaceStyle,
void Function(BuildContext context, Channel channel)? onChannelAvatarPressed,
bool automaticallyImplyLeading = true,
VoidCallback? onBackPressed,
bool pushOntoARoute = false,
}) async {
Expand Down Expand Up @@ -293,6 +306,7 @@ Future<void> _pumpChannelPage(

final page = StreamChannelPage(
onChannelAvatarPressed: onChannelAvatarPressed,
automaticallyImplyLeading: automaticallyImplyLeading,
onBackPressed: onBackPressed,
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,18 @@ void main() {
expect(_backButtonIsFloating(tester), isFalse);
});

testWidgets('shows a back button by default', (tester) async {
await _pumpThreadPage(tester);

expect(find.byType(StreamBackButton), findsOneWidget);
});

testWidgets('hides the back button when automaticallyImplyLeading is false', (tester) async {
await _pumpThreadPage(tester, automaticallyImplyLeading: false);

expect(find.byType(StreamBackButton), findsNothing);
});

testWidgets('tapping back invokes onBackPressed', (tester) async {
var backPressed = 0;
await _pumpThreadPage(tester, onBackPressed: () => backPressed++);
Expand Down Expand Up @@ -209,6 +221,7 @@ Future<void> _pumpThreadPage(
StreamSurfaceStyle surfaceStyle = StreamSurfaceStyle.regular,
StreamSurfaceStyle? headerSurfaceStyle,
void Function(Message message)? onViewInChannelTap,
bool automaticallyImplyLeading = true,
VoidCallback? onBackPressed,
bool pushOntoARoute = false,
}) async {
Expand Down Expand Up @@ -275,6 +288,7 @@ Future<void> _pumpThreadPage(
final page = StreamThreadPage(
parent: parentMessage,
onViewInChannelTap: onViewInChannelTap,
automaticallyImplyLeading: automaticallyImplyLeading,
onBackPressed: onBackPressed,
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';

import '../mocks.dart';

void main() {
testWidgets('keeps the actions modal working after its message leaves the tree', (tester) async {
final client = MockClient();
final channel = _mockChannel(client);

await tester.pumpWidget(_app(client: client, channel: channel, showMessage: true));
await tester.pumpAndSettle();
await _longPress(tester, find.byType(StreamMessageItem));

// Rebuilding the app rebuilds the navigator's routes, the open modal among them.
await tester.pumpWidget(_app(client: client, channel: channel, showMessage: false));

expect(tester.takeException(), isNull);
expect(find.byKey(const Key('MessageItem')), findsOneWidget);
});

testWidgets('StreamMessageItem drops a modal action picked after its message leaves the tree', (tester) async {
final client = MockClient();
final channel = _mockChannel(client);
final threadTaps = <Message>[];
void onThreadTap(Message parent, Message? _) => threadTaps.add(parent);

await tester.pumpWidget(_app(client: client, channel: channel, showMessage: true, onThreadTap: onThreadTap));
await tester.pumpAndSettle();
await _longPress(tester, find.byType(StreamMessageItem));

await tester.pumpWidget(_app(client: client, channel: channel, showMessage: false, onThreadTap: onThreadTap));
await tester.tap(find.text('Thread Reply'));
await tester.pumpAndSettle();

expect(threadTaps, isEmpty);
});
}

MockChannel _mockChannel(MockClient client) {
final currentUser = OwnUser(id: 'current-user');
final clientState = MockClientState();
final channel = MockChannel(ownCapabilities: const [ChannelCapability.sendMessage, ChannelCapability.sendReply]);
final channelState = MockChannelState();

when(() => client.state).thenReturn(clientState);
when(() => clientState.currentUser).thenReturn(currentUser);
when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(currentUser));
when(() => channel.client).thenReturn(client);
when(() => channel.state).thenReturn(channelState);

return channel;
}

Widget _app({
required MockClient client,
required MockChannel channel,
required bool showMessage,
void Function(Message, Message?)? onThreadTap,
}) {
final message = Message(
id: 'test-message',
text: 'Long press me',
createdAt: DateTime(2026),
user: User(id: 'other-user'),
state: MessageState.sent,
);

return MaterialApp(
builder: (context, child) => StreamChat(client: client, child: child),
home: StreamChannel(
channel: channel,
child: Scaffold(
body: showMessage ? StreamMessageItem(message: message, onThreadTap: onThreadTap) : const SizedBox.shrink(),
),
),
);
}

// Holds past kLongPressTimeout, which a plain tester.longPress does not
// reliably cross.
Future<void> _longPress(WidgetTester tester, Finder finder) async {
final gesture = await tester.startGesture(tester.getCenter(finder));
await tester.pump(const Duration(milliseconds: 700));
await gesture.up();
await tester.pumpAndSettle();
}
Loading
Loading