feat: Make all text in chat selectable on desktop
This commit is contained in:
parent
7d7e234142
commit
809ee213b6
4 changed files with 223 additions and 195 deletions
|
|
@ -31,104 +31,106 @@ class ChatEventList extends StatelessWidget {
|
||||||
thisEventsKeyMap[controller.timeline!.events[i].eventId] = i;
|
thisEventsKeyMap[controller.timeline!.events[i].eventId] = i;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.custom(
|
return SelectionArea(
|
||||||
padding: EdgeInsets.only(
|
child: ListView.custom(
|
||||||
top: 16,
|
padding: EdgeInsets.only(
|
||||||
bottom: 4,
|
top: 16,
|
||||||
left: horizontalPadding,
|
bottom: 4,
|
||||||
right: horizontalPadding,
|
left: horizontalPadding,
|
||||||
),
|
right: horizontalPadding,
|
||||||
reverse: true,
|
),
|
||||||
controller: controller.scrollController,
|
reverse: true,
|
||||||
keyboardDismissBehavior: PlatformInfos.isIOS
|
controller: controller.scrollController,
|
||||||
? ScrollViewKeyboardDismissBehavior.onDrag
|
keyboardDismissBehavior: PlatformInfos.isIOS
|
||||||
: ScrollViewKeyboardDismissBehavior.manual,
|
? ScrollViewKeyboardDismissBehavior.onDrag
|
||||||
childrenDelegate: SliverChildBuilderDelegate(
|
: ScrollViewKeyboardDismissBehavior.manual,
|
||||||
(BuildContext context, int i) {
|
childrenDelegate: SliverChildBuilderDelegate(
|
||||||
// Footer to display typing indicator and read receipts:
|
(BuildContext context, int i) {
|
||||||
if (i == 0) {
|
// Footer to display typing indicator and read receipts:
|
||||||
if (controller.timeline!.isRequestingFuture) {
|
if (i == 0) {
|
||||||
return const Center(
|
if (controller.timeline!.isRequestingFuture) {
|
||||||
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
return const Center(
|
||||||
|
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (controller.timeline!.canRequestFuture) {
|
||||||
|
return Center(
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: controller.requestFuture,
|
||||||
|
icon: const Icon(Icons.refresh_outlined),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SeenByRow(controller),
|
||||||
|
TypingIndicators(controller),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (controller.timeline!.canRequestFuture) {
|
|
||||||
return Center(
|
|
||||||
child: IconButton(
|
|
||||||
onPressed: controller.requestFuture,
|
|
||||||
icon: const Icon(Icons.refresh_outlined),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
SeenByRow(controller),
|
|
||||||
TypingIndicators(controller),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request history button or progress indicator:
|
// Request history button or progress indicator:
|
||||||
if (i == controller.timeline!.events.length + 1) {
|
if (i == controller.timeline!.events.length + 1) {
|
||||||
if (controller.timeline!.isRequestingHistory) {
|
if (controller.timeline!.isRequestingHistory) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
if (controller.timeline!.canRequestHistory) {
|
||||||
|
return Center(
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: controller.requestHistory,
|
||||||
|
icon: const Icon(Icons.refresh_outlined),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
if (controller.timeline!.canRequestHistory) {
|
|
||||||
return Center(
|
|
||||||
child: IconButton(
|
|
||||||
onPressed: controller.requestHistory,
|
|
||||||
icon: const Icon(Icons.refresh_outlined),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
// The message at this index:
|
// The message at this index:
|
||||||
final event = controller.timeline!.events[i - 1];
|
final event = controller.timeline!.events[i - 1];
|
||||||
|
|
||||||
return AutoScrollTag(
|
return AutoScrollTag(
|
||||||
key: ValueKey(event.eventId),
|
key: ValueKey(event.eventId),
|
||||||
index: i - 1,
|
index: i - 1,
|
||||||
controller: controller.scrollController,
|
controller: controller.scrollController,
|
||||||
child: event.isVisibleInGui
|
child: event.isVisibleInGui
|
||||||
? Message(
|
? Message(
|
||||||
event,
|
event,
|
||||||
onSwipe: (direction) =>
|
onSwipe: (direction) =>
|
||||||
controller.replyAction(replyTo: event),
|
controller.replyAction(replyTo: event),
|
||||||
onInfoTab: controller.showEventInfo,
|
onInfoTab: controller.showEventInfo,
|
||||||
onAvatarTab: (Event event) => showAdaptiveBottomSheet(
|
onAvatarTab: (Event event) => showAdaptiveBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (c) => UserBottomSheet(
|
builder: (c) => UserBottomSheet(
|
||||||
user: event.senderFromMemoryOrFallback,
|
user: event.senderFromMemoryOrFallback,
|
||||||
outerContext: context,
|
outerContext: context,
|
||||||
onMention: () => controller.sendController.text +=
|
onMention: () => controller.sendController.text +=
|
||||||
'${event.senderFromMemoryOrFallback.mention} ',
|
'${event.senderFromMemoryOrFallback.mention} ',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
onSelect: controller.onSelectMessage,
|
||||||
onSelect: controller.onSelectMessage,
|
scrollToEventId: (String eventId) =>
|
||||||
scrollToEventId: (String eventId) =>
|
controller.scrollToEventId(eventId),
|
||||||
controller.scrollToEventId(eventId),
|
longPressSelect: controller.selectedEvents.isNotEmpty,
|
||||||
longPressSelect: controller.selectedEvents.isNotEmpty,
|
selected: controller.selectedEvents
|
||||||
selected: controller.selectedEvents
|
.any((e) => e.eventId == event.eventId),
|
||||||
.any((e) => e.eventId == event.eventId),
|
timeline: controller.timeline!,
|
||||||
timeline: controller.timeline!,
|
displayReadMarker:
|
||||||
displayReadMarker:
|
controller.readMarkerEventId == event.eventId &&
|
||||||
controller.readMarkerEventId == event.eventId &&
|
controller.timeline?.allowNewEvent == false,
|
||||||
controller.timeline?.allowNewEvent == false,
|
nextEvent: i < controller.timeline!.events.length
|
||||||
nextEvent: i < controller.timeline!.events.length
|
? controller.timeline!.events[i]
|
||||||
? controller.timeline!.events[i]
|
: null,
|
||||||
: null,
|
)
|
||||||
)
|
: const SizedBox.shrink(),
|
||||||
: const SizedBox.shrink(),
|
);
|
||||||
);
|
},
|
||||||
},
|
childCount: controller.timeline!.events.length + 2,
|
||||||
childCount: controller.timeline!.events.length + 2,
|
findChildIndexCallback: (key) =>
|
||||||
findChildIndexCallback: (key) =>
|
controller.findChildIndexCallback(key, thisEventsKeyMap),
|
||||||
controller.findChildIndexCallback(key, thisEventsKeyMap),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import 'package:fluffychat/utils/string_color.dart';
|
||||||
import 'package:fluffychat/widgets/avatar.dart';
|
import 'package:fluffychat/widgets/avatar.dart';
|
||||||
import 'package:fluffychat/widgets/matrix.dart';
|
import 'package:fluffychat/widgets/matrix.dart';
|
||||||
import '../../../config/app_config.dart';
|
import '../../../config/app_config.dart';
|
||||||
|
import '../../../widgets/hover_builder.dart';
|
||||||
import 'message_content.dart';
|
import 'message_content.dart';
|
||||||
import 'message_reactions.dart';
|
import 'message_reactions.dart';
|
||||||
import 'reply_content.dart';
|
import 'reply_content.dart';
|
||||||
|
|
@ -44,10 +45,6 @@ class Message extends StatelessWidget {
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Indicates wheither the user may use a mouse instead
|
|
||||||
/// of touchscreen.
|
|
||||||
static bool useMouse = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!{
|
if (!{
|
||||||
|
|
@ -117,31 +114,42 @@ class Message extends StatelessWidget {
|
||||||
: Theme.of(context).colorScheme.primaryContainer;
|
: Theme.of(context).colorScheme.primaryContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
final row = Row(
|
final row = InkWell(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
onTap: longPressSelect ? () => onSelect!(event) : null,
|
||||||
mainAxisAlignment: rowMainAxisAlignment,
|
onLongPress: () => onSelect!(event),
|
||||||
children: [
|
child: HoverBuilder(
|
||||||
sameSender || ownMessage
|
builder: (context, hovered) => Row(
|
||||||
? SizedBox(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: rowMainAxisAlignment,
|
||||||
|
children: [
|
||||||
|
if (hovered || selected)
|
||||||
|
SizedBox(
|
||||||
width: Avatar.defaultSize,
|
width: Avatar.defaultSize,
|
||||||
child: Padding(
|
height: Avatar.defaultSize - 8,
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
child: Checkbox.adaptive(
|
||||||
child: Center(
|
value: selected,
|
||||||
child: SizedBox(
|
onChanged: (_) => onSelect?.call(event),
|
||||||
width: 16,
|
),
|
||||||
height: 16,
|
)
|
||||||
child: event.status == EventStatus.sending
|
else if (sameSender || ownMessage)
|
||||||
? const CircularProgressIndicator.adaptive(
|
SizedBox(
|
||||||
strokeWidth: 2,
|
width: Avatar.defaultSize,
|
||||||
)
|
child: Center(
|
||||||
: event.status == EventStatus.error
|
child: SizedBox(
|
||||||
? const Icon(Icons.error, color: Colors.red)
|
width: 16,
|
||||||
: null,
|
height: 16,
|
||||||
),
|
child: event.status == EventStatus.sending
|
||||||
|
? const CircularProgressIndicator.adaptive(
|
||||||
|
strokeWidth: 2,
|
||||||
|
)
|
||||||
|
: event.status == EventStatus.error
|
||||||
|
? const Icon(Icons.error, color: Colors.red)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: FutureBuilder<User?>(
|
else
|
||||||
|
FutureBuilder<User?>(
|
||||||
future: event.fetchSenderUser(),
|
future: event.fetchSenderUser(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final user =
|
final user =
|
||||||
|
|
@ -153,61 +161,59 @@ class Message extends StatelessWidget {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (!sameSender)
|
if (!sameSender)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 8.0, bottom: 4),
|
padding: const EdgeInsets.only(left: 8.0, bottom: 4),
|
||||||
child: ownMessage || event.room.isDirectChat
|
child: ownMessage || event.room.isDirectChat
|
||||||
? const SizedBox(height: 12)
|
? const SizedBox(height: 12)
|
||||||
: FutureBuilder<User?>(
|
: FutureBuilder<User?>(
|
||||||
future: event.fetchSenderUser(),
|
future: event.fetchSenderUser(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final displayname =
|
final displayname =
|
||||||
snapshot.data?.calcDisplayname() ??
|
snapshot.data?.calcDisplayname() ??
|
||||||
event.senderFromMemoryOrFallback
|
event.senderFromMemoryOrFallback
|
||||||
.calcDisplayname();
|
.calcDisplayname();
|
||||||
return Text(
|
return Text(
|
||||||
displayname,
|
displayname,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: (Theme.of(context).brightness ==
|
color: (Theme.of(context).brightness ==
|
||||||
Brightness.light
|
Brightness.light
|
||||||
? displayname.color
|
? displayname.color
|
||||||
: displayname.lightColorText),
|
: displayname.lightColorText),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
alignment: alignment,
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: Material(
|
||||||
|
color: noBubble ? Colors.transparent : color,
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(AppConfig.borderRadius),
|
||||||
),
|
),
|
||||||
),
|
padding: noBubble || noPadding
|
||||||
Container(
|
? EdgeInsets.zero
|
||||||
alignment: alignment,
|
: const EdgeInsets.symmetric(
|
||||||
padding: const EdgeInsets.only(left: 8),
|
horizontal: 16,
|
||||||
child: Material(
|
vertical: 8,
|
||||||
color: noBubble ? Colors.transparent : color,
|
),
|
||||||
borderRadius: borderRadius,
|
constraints: const BoxConstraints(
|
||||||
clipBehavior: Clip.antiAlias,
|
maxWidth: FluffyThemes.columnWidth * 1.5,
|
||||||
child: Container(
|
),
|
||||||
decoration: BoxDecoration(
|
child: Column(
|
||||||
borderRadius:
|
|
||||||
BorderRadius.circular(AppConfig.borderRadius),
|
|
||||||
),
|
|
||||||
padding: noBubble || noPadding
|
|
||||||
? EdgeInsets.zero
|
|
||||||
: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
constraints: const BoxConstraints(
|
|
||||||
maxWidth: FluffyThemes.columnWidth * 1.5,
|
|
||||||
),
|
|
||||||
child: Stack(
|
|
||||||
children: <Widget>[
|
|
||||||
Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
|
@ -284,15 +290,15 @@ class Message extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
Widget container;
|
Widget container;
|
||||||
if (event.hasAggregatedEvents(timeline, RelationshipTypes.reaction) ||
|
if (event.hasAggregatedEvents(timeline, RelationshipTypes.reaction) ||
|
||||||
|
|
@ -392,26 +398,18 @@ class Message extends StatelessWidget {
|
||||||
direction: SwipeDirection.endToStart,
|
direction: SwipeDirection.endToStart,
|
||||||
onSwipe: onSwipe,
|
onSwipe: onSwipe,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: MouseRegion(
|
child: Container(
|
||||||
onEnter: (_) => useMouse = true,
|
color: selected
|
||||||
onExit: (_) => useMouse = false,
|
? Theme.of(context).primaryColor.withAlpha(100)
|
||||||
child: InkWell(
|
: Theme.of(context).primaryColor.withAlpha(0),
|
||||||
onTap: longPressSelect || useMouse ? () => onSelect!(event) : null,
|
constraints: const BoxConstraints(
|
||||||
onLongPress: () => onSelect!(event),
|
maxWidth: FluffyThemes.columnWidth * 2.5,
|
||||||
child: Container(
|
|
||||||
color: selected
|
|
||||||
? Theme.of(context).primaryColor.withAlpha(100)
|
|
||||||
: Theme.of(context).primaryColor.withAlpha(0),
|
|
||||||
constraints: const BoxConstraints(
|
|
||||||
maxWidth: FluffyThemes.columnWidth * 2.5,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8.0,
|
|
||||||
vertical: 4.0,
|
|
||||||
),
|
|
||||||
child: container,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8.0,
|
||||||
|
vertical: 4.0,
|
||||||
|
),
|
||||||
|
child: container,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ class CustomScrollBehavior extends MaterialScrollBehavior {
|
||||||
@override
|
@override
|
||||||
Set<PointerDeviceKind> get dragDevices => {
|
Set<PointerDeviceKind> get dragDevices => {
|
||||||
PointerDeviceKind.touch,
|
PointerDeviceKind.touch,
|
||||||
PointerDeviceKind.mouse,
|
|
||||||
PointerDeviceKind.trackpad,
|
PointerDeviceKind.trackpad,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
lib/widgets/hover_builder.dart
Normal file
29
lib/widgets/hover_builder.dart
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class HoverBuilder extends StatefulWidget {
|
||||||
|
final Widget Function(BuildContext context, bool hovered) builder;
|
||||||
|
const HoverBuilder({required this.builder, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HoverBuilder> createState() => _HoverBuilderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HoverBuilderState extends State<HoverBuilder> {
|
||||||
|
bool hovered = false;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => hovered
|
||||||
|
? null
|
||||||
|
: setState(() {
|
||||||
|
hovered = true;
|
||||||
|
}),
|
||||||
|
onExit: (_) => !hovered
|
||||||
|
? null
|
||||||
|
: setState(() {
|
||||||
|
hovered = false;
|
||||||
|
}),
|
||||||
|
child: widget.builder(context, hovered),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue