Merge pull request #2879 from krille-chan/krille/fix-sending-indicator

fix: UX feedback for sending events
This commit is contained in:
Krille-chan 2026-04-19 11:08:07 +09:00 committed by GitHub
commit 27125f8762
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 182 additions and 145 deletions

View file

@ -463,14 +463,17 @@ class ChatController extends State<ChatPageWithRoom>
scrollUpBannerEventId = eventId; scrollUpBannerEventId = eventId;
}); });
bool firstUpdateReceived = false; String? animateInEventId;
void _insert(int index) {
if (index > 0) return;
animateInEventId = timeline?.events.firstOrNull?.eventId;
}
void updateView() { void updateView() {
if (!mounted) return; if (!mounted) return;
setReadMarker(); setReadMarker();
setState(() { setState(() {});
firstUpdateReceived = true;
});
} }
Future<void>? loadTimelineFuture; Future<void>? loadTimelineFuture;
@ -487,6 +490,7 @@ class ChatController extends State<ChatPageWithRoom>
timeline?.cancelSubscriptions(); timeline?.cancelSubscriptions();
timeline = await room.getTimeline( timeline = await room.getTimeline(
onUpdate: updateView, onUpdate: updateView,
onInsert: _insert,
eventContextId: eventContextId, eventContextId: eventContextId,
); );
} catch (e, s) { } catch (e, s) {

View file

@ -117,9 +117,7 @@ class ChatEventList extends StatelessWidget {
// The message at this index: // The message at this index:
final event = events[i]; final event = events[i];
final animateIn = final animateIn = event.eventId == controller.animateInEventId;
event.eventId == timeline.events.first.eventId &&
controller.firstUpdateReceived;
final nextEvent = i + 1 < events.length ? events[i + 1] : null; final nextEvent = i + 1 < events.length ? events[i + 1] : null;
final previousEvent = i > 0 ? events[i - 1] : null; final previousEvent = i > 0 ? events[i - 1] : null;

View file

@ -203,6 +203,8 @@ class Message extends StatelessWidget {
final enterThread = this.enterThread; final enterThread = this.enterThread;
final sender = event.senderFromMemoryOrFallback; final sender = event.senderFromMemoryOrFallback;
final fileSendingStatus = event.fileSendingStatus;
return _AnimateIn( return _AnimateIn(
animateIn: animateIn, animateIn: animateIn,
child: Center( child: Center(
@ -316,9 +318,33 @@ class Message extends StatelessWidget {
height: 16, height: 16,
child: event.status == EventStatus.error child: event.status == EventStatus.error
? const Icon(Icons.error, color: Colors.red) ? const Icon(Icons.error, color: Colors.red)
: event.fileSendingStatus != null : fileSendingStatus != null
? const CircularProgressIndicator.adaptive( ? Stack(
strokeWidth: 1, children: [
Center(
child: switch (fileSendingStatus) {
FileSendingStatus
.generatingThumbnail =>
Icon(
Icons.compress_outlined,
size: 14,
),
FileSendingStatus.encrypting =>
Icon(
Icons.lock_outlined,
size: 14,
),
FileSendingStatus.uploading =>
Icon(
Icons.upload_outlined,
size: 14,
),
},
),
const CircularProgressIndicator.adaptive(
strokeWidth: 1,
),
],
) )
: null, : null,
), ),
@ -430,147 +456,161 @@ class Message extends StatelessWidget {
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
onSelect(event); onSelect(event);
}, },
child: Container( child: AnimatedOpacity(
decoration: BoxDecoration( duration: FluffyThemes.animationDuration,
color: noBubble curve: FluffyThemes.animationCurve,
? Colors.transparent opacity:
: color, event.status.isSending ||
borderRadius: borderRadius, event.type == EventTypes.Encrypted
), ? 0.5
clipBehavior: Clip.antiAlias, : 1,
child: BubbleBackground( child: Container(
colors: colors, decoration: BoxDecoration(
ignore: color: noBubble
noBubble || ? Colors.transparent
!ownMessage || : color,
MediaQuery.highContrastOf(context), borderRadius: borderRadius,
scrollController: scrollController, ),
child: Container( clipBehavior: Clip.antiAlias,
decoration: BoxDecoration( child: BubbleBackground(
borderRadius: BorderRadius.circular( colors: colors,
AppConfig.borderRadius, ignore:
noBubble ||
!ownMessage ||
MediaQuery.highContrastOf(context),
scrollController: scrollController,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
), ),
), constraints: const BoxConstraints(
constraints: const BoxConstraints( maxWidth:
maxWidth: FluffyThemes.columnWidth * 1.5,
FluffyThemes.columnWidth * 1.5, ),
), child: Column(
child: Column( mainAxisSize: .min,
mainAxisSize: .min, crossAxisAlignment:
crossAxisAlignment: CrossAxisAlignment.start,
CrossAxisAlignment.start, children: <Widget>[
children: <Widget>[ if (event.inReplyToEventId(
if (event.inReplyToEventId( includingFallback: false,
includingFallback: false, ) !=
) != null)
null) FutureBuilder<Event?>(
FutureBuilder<Event?>( future: event.getReplyEvent(
future: event.getReplyEvent( timeline,
timeline, ),
), builder: (BuildContext context, snapshot) {
builder: (BuildContext context, snapshot) { final replyEvent =
final replyEvent = snapshot.hasData
snapshot.hasData ? snapshot.data!
? snapshot.data! : Event(
: Event( eventId:
eventId: event
event .inReplyToEventId() ??
.inReplyToEventId() ?? '\$fake_event_id',
'\$fake_event_id', content: {
content: { 'msgtype':
'msgtype': 'm.text', 'm.text',
'body': '...', 'body': '...',
}, },
senderId: senderId:
event.senderId, event.senderId,
type: type:
'm.room.message', 'm.room.message',
room: event.room, room: event.room,
status: status: EventStatus
EventStatus.sent, .sent,
originServerTs: originServerTs:
DateTime.now(), DateTime.now(),
); );
return Padding( return Padding(
padding: padding:
const EdgeInsets.only( const EdgeInsets.only(
left: 16, left: 16,
right: 16, right: 16,
top: 8, top: 8,
), ),
child: Material( child: Material(
color: Colors.transparent, color:
borderRadius: ReplyContent Colors.transparent,
.borderRadius,
child: InkWell(
borderRadius: borderRadius:
ReplyContent ReplyContent
.borderRadius, .borderRadius,
onTap: () => child: InkWell(
scrollToEventId( borderRadius:
replyEvent ReplyContent
.eventId, .borderRadius,
onTap: () =>
scrollToEventId(
replyEvent
.eventId,
),
child: AbsorbPointer(
child: ReplyContent(
replyEvent,
ownMessage:
ownMessage,
timeline:
timeline,
), ),
child: AbsorbPointer(
child: ReplyContent(
replyEvent,
ownMessage:
ownMessage,
timeline: timeline,
), ),
), ),
), ),
), );
); },
},
),
MessageContent(
displayEvent,
textColor: textColor,
linkColor: linkColor,
onInfoTab: onInfoTab,
borderRadius: borderRadius,
timeline: timeline,
selected: selected,
bigEmojis: bigEmojis,
),
if (event.hasAggregatedEvents(
timeline,
RelationshipTypes.edit,
))
Padding(
padding: const EdgeInsets.only(
bottom: 8.0,
left: 16.0,
right: 16.0,
), ),
child: Row( MessageContent(
mainAxisSize: displayEvent,
MainAxisSize.min, textColor: textColor,
spacing: 4.0, linkColor: linkColor,
children: [ onInfoTab: onInfoTab,
Icon( borderRadius: borderRadius,
Icons.edit_outlined, timeline: timeline,
color: textColor selected: selected,
.withAlpha(164), bigEmojis: bigEmojis,
size: 14, ),
), if (event.hasAggregatedEvents(
Text( timeline,
displayEvent RelationshipTypes.edit,
.originServerTs ))
.localizedTimeShort( Padding(
context, padding:
), const EdgeInsets.only(
style: TextStyle( bottom: 8.0,
left: 16.0,
right: 16.0,
),
child: Row(
mainAxisSize:
MainAxisSize.min,
spacing: 4.0,
children: [
Icon(
Icons.edit_outlined,
color: textColor color: textColor
.withAlpha(164), .withAlpha(164),
fontSize: 11, size: 14,
), ),
), Text(
], displayEvent
.originServerTs
.localizedTimeShort(
context,
),
style: TextStyle(
color: textColor
.withAlpha(164),
fontSize: 11,
),
),
],
),
), ),
), ],
], ),
), ),
), ),
), ),
@ -957,15 +997,10 @@ class __AnimateInState extends State<_AnimateIn> {
}); });
}); });
} }
return AnimatedOpacity( return AnimatedSize(
duration: FluffyThemes.animationDuration, duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve, curve: FluffyThemes.animationCurve,
opacity: _animationFinished ? 1 : 0, child: _animationFinished ? widget.child : const SizedBox.shrink(),
child: AnimatedSize(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
child: _animationFinished ? widget.child : const SizedBox.shrink(),
),
); );
} }
} }