Skip to content

Commit bf5597e

Browse files
kornsitticlaude
andcommitted
chore: Apply changes from version 4.20.11+1
Support patch off 4.20.11 (customers pinned to that line). New-direct-chat first-message + 1:1 header fixes: - guard member firstWhere + attach message/loading listeners before lookup - resolve 1:1 header from cache with retry on collection emits (no extra network) - no reset() on create/open/jump init paths (keep live-collection reactor alive) - dismiss chat loading toast via close()/BlocListener - example android toolchain bump (Kotlin 2.1.20 / AGP 8.9.1 / Gradle 8.11.1) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fb4e00e commit bf5597e

7 files changed

Lines changed: 155 additions & 31 deletions

File tree

example/android/gradle/wrapper/gradle-wrapper.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
22
distributionPath=wrapper/dists
33
zipStoreBase=GRADLE_USER_HOME
44
zipStorePath=wrapper/dists
5-
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
5+
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip

example/android/settings.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ pluginManagement {
1818

1919
plugins {
2020
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
21-
id "com.android.application" version "8.7.3" apply false
22-
id "org.jetbrains.kotlin.android" version "1.9.0" apply false
21+
id "com.android.application" version "8.9.1" apply false
22+
id "org.jetbrains.kotlin.android" version "2.1.20" apply false
2323
}
2424

2525
include ":app"

lib/v4/chat/message/bloc/chat_page_bloc.dart

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -347,9 +347,18 @@ class ChatPageBloc extends Bloc<ChatPageEvent, ChatPageState> {
347347

348348
final channelId = channel.channelId;
349349
if (channelId != null) {
350-
initLiveCollection(channelId);
350+
// Await init so the message-stream + loading-state listeners and the
351+
// live collection's reactor are fully wired before the first page is
352+
// loaded. Then load the first page directly WITHOUT reset(): reset()
353+
// stops the reactor and forces an empty first-page query whose
354+
// deletePagingIdByHash wipes paging-ids, which races the user's first
355+
// optimistic send (the create flow drops them straight into a ready
356+
// chat) and makes that message invisible until re-entry (PDT new-chat
357+
// message-not-shown). Keeping the reactor alive lets the optimistic
358+
// message keep its paging-id.
359+
await initLiveCollection(channelId);
351360
addEvent(ChatPageEventChannelIdChanged(channelId));
352-
addEvent(ChatPageEventRefresh());
361+
liveCollection?.loadNext();
353362
addEvent(const ChatPageEventFetchMuteState());
354363
}
355364
});
@@ -508,12 +517,16 @@ class ChatPageBloc extends Bloc<ChatPageEvent, ChatPageState> {
508517
addEvent(ChatPageSetAroundMessage(
509518
aroundMessageId: event.aroundMessageId));
510519

511-
// Reinitialize the live collection with the aroundMessageId
512-
initLiveCollection(state.channelId,
520+
// Reinitialize the live collection with the aroundMessageId. Await so the
521+
// rebuilt collection + listeners are wired, then load the first page
522+
// WITHOUT reset(): reset() stops the reactor and forces an empty-first-page
523+
// paging-id wipe that can hide an in-flight optimistic send. Keeping the
524+
// reactor alive avoids that race.
525+
await initLiveCollection(state.channelId,
513526
aroundMessageId: event.aroundMessageId);
514527

515-
// Refresh to load messages around the target message
516-
addEvent(ChatPageEventRefresh());
528+
// Load messages around the target message.
529+
liveCollection?.loadNext();
517530
});
518531

519532
on<ChatPageSetAroundMessage>((event, emit) async {
@@ -555,9 +568,14 @@ class ChatPageBloc extends Bloc<ChatPageEvent, ChatPageState> {
555568
if (channelId != null && channelId.isNotEmpty) {
556569
addEvent(ChatPageSetAroundMessage(aroundMessageId: jumpToMessageId));
557570

571+
// initLiveCollection assigns liveCollection and attaches its listeners
572+
// synchronously (before its first await) on a fresh open, so loadNext()
573+
// below runs against the live collection. Load directly WITHOUT reset()
574+
// so the reactor is never stopped (avoids the empty-first-page paging-id
575+
// wipe racing an optimistic send).
558576
initLiveCollection(channelId, aroundMessageId: jumpToMessageId);
559577
addEvent(ChatPageEventChannelIdChanged(channelId));
560-
addEvent(ChatPageEventRefresh());
578+
liveCollection?.loadNext();
561579
addEvent(const ChatPageEventFetchMuteState());
562580
} else if (userId != null && userId.isNotEmpty) {
563581
addEvent(ChatPageEventCreateNewChannel(userId: userId));
@@ -622,6 +640,9 @@ class ChatPageBloc extends Bloc<ChatPageEvent, ChatPageState> {
622640

623641
@override
624642
Future<void> close() {
643+
// PDT-3317: guarantee the global loading toast is dismissed when leaving
644+
// the page so a stuck "loading" toast can never leak across the app.
645+
toastBloc.add(AmityToastDismissIfLoading());
625646
_scrollController.dispose();
626647
subscription.cancel();
627648
_jumpToMessageTimeoutTimer?.cancel();
@@ -630,7 +651,34 @@ class ChatPageBloc extends Bloc<ChatPageEvent, ChatPageState> {
630651
return super.close();
631652
}
632653

633-
void initLiveCollection(String channelId, {String? aroundMessageId}) async {
654+
/// Resolve the 1:1 header's other participant from the LOCAL cache only (no
655+
/// network `getMembers()`). No-op once `state.channelMember` is set, so the
656+
/// message-listener retry stops as soon as the header fills.
657+
Future<void> _resolveHeaderFromCache(String channelId) async {
658+
if (state.channelMember != null) return;
659+
try {
660+
final list = await AmityChatClient.newChannelRepository()
661+
.membership(channelId)
662+
.getMembersFromCache();
663+
AmityChannelMember? otherMember;
664+
for (final member in list) {
665+
if (member.userId != AmityCoreClient.getUserId()) {
666+
otherMember = member;
667+
break;
668+
}
669+
}
670+
671+
if (otherMember?.user != null) {
672+
addEvent(ChatPageHeaderEventChanged(channelMember: otherMember!));
673+
// User will be updated in the HeaderEventChanged handler
674+
}
675+
} catch (_) {
676+
// Cache not ready yet; a later collection emit retries.
677+
}
678+
}
679+
680+
Future<void> initLiveCollection(String channelId,
681+
{String? aroundMessageId}) async {
634682
if (liveCollection != null) {
635683
liveCollection?.getStreamController().close();
636684
await liveCollection?.dispose();
@@ -651,26 +699,30 @@ class ChatPageBloc extends Bloc<ChatPageEvent, ChatPageState> {
651699

652700
liveCollection = query.getLiveCollection();
653701

654-
final list = await AmityChatClient.newChannelRepository()
655-
.membership(channelId)
656-
.getMembersFromCache();
657-
final otherMember = list
658-
.firstWhere((element) => element.userId != AmityCoreClient.getUserId());
659-
660-
if (otherMember.user != null) {
661-
addEvent(ChatPageHeaderEventChanged(channelMember: otherMember));
662-
// User will be updated in the HeaderEventChanged handler
663-
}
664-
702+
// Attach the message + loading-state listeners FIRST so the page can
703+
// render and the loading state can reach `false` regardless of the
704+
// member-cache lookup below. The lookup used an unguarded `firstWhere`
705+
// that throws "Bad state: No element" when the member cache is empty or
706+
// holds only the current user (a never-messaged contact whose channel
707+
// exists with no messages). Because the throw happened before these
708+
// listeners attached, the chat hung on loading.
665709
liveCollection?.getStreamController().stream.listen((event) {
666710
addEvent(
667711
ChatPageEventChanged(messages: event, isFetching: state.isFetching));
712+
// Brand-new chat: member cache is empty at init, so the header starts
713+
// blank. Retry the cache read on each emit until it resolves — no network
714+
// getMembers(); no-op once state.channelMember is set.
715+
if (state.channelMember == null) _resolveHeaderFromCache(channelId);
668716
});
669717

670718
liveCollection?.observeLoadingState().listen((event) {
671719
addEvent(ChatPageEventLoadingStateUpdated(isFetching: event));
672720
});
673721

722+
// First attempt at init; may be empty for a just-created channel, in which
723+
// case the message-listener retry above resolves it once the cache fills.
724+
await _resolveHeaderFromCache(channelId);
725+
674726
// Observe Network Connectivity status here
675727
subscription =
676728
Connectivity().onConnectivityChanged.listen((connectivityEvent) {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import 'package:amity_uikit_beta_service/v4/chat/message/bloc/chat_page_bloc.dart';
2+
3+
/// Pure decision helpers for the one-to-one chat loading toast lifecycle.
4+
///
5+
/// Extracted from `chat_page.dart` so the show/dismiss conditions are
6+
/// unit-testable without the Amity SDK, and so the dismiss can be driven
7+
/// from a `BlocListener` (every state) instead of `BlocBuilder.builder`
8+
/// (coalesced per frame).
9+
10+
/// The loading toast should be shown only while the page is still in its
11+
/// initial state and the chat was not opened as a just-created conversation.
12+
bool shouldShowChatLoadingToast(ChatPageState state, bool isJustCreated) =>
13+
state is ChatPageStateInitial && !isJustCreated;
14+
15+
/// The loading toast should be dismissed as soon as the page has left the
16+
/// initial state and is no longer fetching.
17+
bool shouldDismissChatLoadingToast(ChatPageState state) =>
18+
!state.isFetching && state is! ChatPageStateInitial;

lib/v4/chat/message/chat_page.dart

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:amity_uikit_beta_service/l10n/localization_helper.dart';
44
import 'package:amity_uikit_beta_service/v4/chat/full_text_message.dart';
55
import 'package:amity_uikit_beta_service/v4/chat/message/amity_message_bubble.dart';
66
import 'package:amity_uikit_beta_service/v4/chat/message/bloc/chat_page_bloc.dart';
7+
import 'package:amity_uikit_beta_service/v4/chat/message/chat_loading_toast.dart';
78
import 'package:amity_uikit_beta_service/v4/chat/message/components/amity_conversation_chat_user_action_component.dart';
89
import 'package:amity_uikit_beta_service/v4/chat/message_composer/message_composer.dart';
910
import 'package:amity_uikit_beta_service/v4/chat/message_composer/message_composer_action.dart';
@@ -85,24 +86,27 @@ class AmityChatPage extends NewBasePage {
8586
if (state.bounceTargetIndex != null && bounceAnimator != null) {
8687
bounceAnimator!.animateItem(state.bounceTargetIndex!);
8788
}
89+
// Dismiss the loading toast here (not in builder): the
90+
// listener runs for every state, so the transient
91+
// {not-Initial, isFetching:false} state is never coalesced
92+
// away the way it is in BlocBuilder.builder.
93+
if (shouldDismissChatLoadingToast(state)) {
94+
context
95+
.read<AmityToastBloc>()
96+
.add(AmityToastDismissIfLoading());
97+
_handleToastDismissal(context, state);
98+
}
8899
},
89100
child: BlocBuilder<ChatPageBloc, ChatPageState>(
90101
key: Key("${channelId ?? ""}_${userId ?? ""}"),
91102
builder: (context, state) {
92-
if (state is ChatPageStateInitial && !isJustCreated) {
103+
if (shouldShowChatLoadingToast(state, isJustCreated)) {
93104
context.read<AmityToastBloc>().add(AmityToastLoading(
94105
message: context.l10n.chat_loading,
95106
icon: AmityToastIcon.loading,
96107
bottomPadding: toastBottomPadding));
97108
}
98109

99-
if (!state.isFetching && state is! ChatPageStateInitial) {
100-
context
101-
.read<AmityToastBloc>()
102-
.add(AmityToastDismissIfLoading());
103-
_handleToastDismissal(context, state);
104-
}
105-
106110
final isLoadingUserAvatar =
107111
state.avatarUrl == null && state.userDisplayName == null;
108112

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: amity_uikit_beta_service
22
description: Amity UIkit opensource developed by SLE team to enable social feature in Flutter.
3-
version: 4.20.11
3+
version: 4.20.11+1
44
homepage: https://github.com/ThanakornThanom/ThanakornThanom--flutter_amity_uikit_beta_service
55

66
environment:
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import 'package:flutter/widgets.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:amity_uikit_beta_service/v4/chat/message/bloc/chat_page_bloc.dart';
4+
import 'package:amity_uikit_beta_service/v4/chat/message/chat_loading_toast.dart';
5+
6+
void main() {
7+
TestWidgetsFlutterBinding.ensureInitialized();
8+
9+
ChatPageStateInitial initialState() => ChatPageStateInitial(
10+
channelId: 'c1',
11+
userDisplayName: null,
12+
avatarUrl: null,
13+
scrollController: ScrollController(),
14+
);
15+
16+
ChatPageState changedState({required bool isFetching}) => ChatPageState(
17+
channelId: 'c1',
18+
messages: const [],
19+
scrollController: ScrollController(),
20+
isFetching: isFetching,
21+
);
22+
23+
group('shouldShowChatLoadingToast', () {
24+
test('shows on Initial when not just-created', () {
25+
expect(shouldShowChatLoadingToast(initialState(), false), isTrue);
26+
});
27+
test('suppressed when just-created', () {
28+
expect(shouldShowChatLoadingToast(initialState(), true), isFalse);
29+
});
30+
test('not shown once out of Initial', () {
31+
expect(
32+
shouldShowChatLoadingToast(changedState(isFetching: true), false),
33+
isFalse);
34+
});
35+
});
36+
37+
group('shouldDismissChatLoadingToast', () {
38+
test('dismisses on first non-Initial state with isFetching=false', () {
39+
expect(shouldDismissChatLoadingToast(changedState(isFetching: false)),
40+
isTrue);
41+
});
42+
test('does not dismiss while still Initial', () {
43+
expect(shouldDismissChatLoadingToast(initialState()), isFalse);
44+
});
45+
test('does not dismiss while fetching', () {
46+
expect(shouldDismissChatLoadingToast(changedState(isFetching: true)),
47+
isFalse);
48+
});
49+
});
50+
}

0 commit comments

Comments
 (0)