parent
a54004736b
commit
0fcbc275cd
13 changed files with 326 additions and 1 deletions
|
|
@ -320,6 +320,11 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
|
|
||||||
Future<void> logout() => _sendCommand("logout");
|
Future<void> logout() => _sendCommand("logout");
|
||||||
|
|
||||||
|
Future<void> setTyping(String roomId, Duration timeout) => _sendCommand(
|
||||||
|
"set_typing",
|
||||||
|
{"room_id": roomId, "timeout": timeout.inMilliseconds},
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> markRead(Room room) async {
|
Future<void> markRead(Room room) async {
|
||||||
if (room.timeline.isEmpty || room.metadata == null) return;
|
if (room.timeline.isEmpty || room.metadata == null) return;
|
||||||
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
|
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import "package:nexus/controllers/rooms.dart";
|
||||||
import "package:nexus/controllers/space_edges.dart";
|
import "package:nexus/controllers/space_edges.dart";
|
||||||
import "package:nexus/controllers/sync_status.dart";
|
import "package:nexus/controllers/sync_status.dart";
|
||||||
import "package:nexus/controllers/top_level_spaces.dart";
|
import "package:nexus/controllers/top_level_spaces.dart";
|
||||||
|
import "package:nexus/controllers/typing.dart";
|
||||||
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:ffi/ffi.dart";
|
import "package:ffi/ffi.dart";
|
||||||
|
|
@ -63,6 +64,10 @@ class GomuksListenerController extends AsyncNotifier<void> {
|
||||||
event.roomId: .new(events: .new({event.rowId: event})),
|
event.roomId: .new(events: .new({event.rowId: event})),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
case "typing":
|
||||||
|
ref
|
||||||
|
.read(TypingController.provider.notifier)
|
||||||
|
.set(.fromJson(decodedMuksEvent));
|
||||||
case "events_decrypted":
|
case "events_decrypted":
|
||||||
final decrypted = EventsDecrypted.fromJson(decodedMuksEvent);
|
final decrypted = EventsDecrypted.fromJson(decodedMuksEvent);
|
||||||
final metadata = ref
|
final metadata = ref
|
||||||
|
|
@ -96,6 +101,7 @@ class GomuksListenerController extends AsyncNotifier<void> {
|
||||||
ref.invalidate(roomProvider);
|
ref.invalidate(roomProvider);
|
||||||
ref.invalidate(accountDataProvider);
|
ref.invalidate(accountDataProvider);
|
||||||
ref.invalidate(invitesProvider);
|
ref.invalidate(invitesProvider);
|
||||||
|
ref.invalidate(TypingController.provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
ref
|
ref
|
||||||
|
|
@ -153,6 +159,7 @@ class GomuksListenerController extends AsyncNotifier<void> {
|
||||||
if (paused) {
|
if (paused) {
|
||||||
GomuksPauseSync(handle);
|
GomuksPauseSync(handle);
|
||||||
} else {
|
} else {
|
||||||
|
ref.invalidate(TypingController.provider);
|
||||||
GomuksResumeSync(handle);
|
GomuksResumeSync(handle);
|
||||||
}
|
}
|
||||||
}, fireImmediately: true);
|
}, fireImmediately: true);
|
||||||
|
|
|
||||||
34
lib/controllers/room_typing.dart
Normal file
34
lib/controllers/room_typing.dart
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:nexus/controllers/client_state.dart";
|
||||||
|
import "package:nexus/controllers/settings.dart";
|
||||||
|
import "package:nexus/controllers/typing.dart";
|
||||||
|
|
||||||
|
/// The other users typing in a room, or none if typing indicators are hidden
|
||||||
|
/// in settings.
|
||||||
|
class RoomTypingController(final String roomId)
|
||||||
|
extends Notifier<IList<String>> {
|
||||||
|
@override
|
||||||
|
IList<String> build() {
|
||||||
|
final shouldShow = ref.watch(
|
||||||
|
SettingsController.provider.select(
|
||||||
|
(settings) => settings.value?.typingIndicators != .neither,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!shouldShow) return const .empty();
|
||||||
|
|
||||||
|
final userId = ref.watch(
|
||||||
|
ClientStateController.provider.select((state) => state?.userId),
|
||||||
|
);
|
||||||
|
|
||||||
|
return ref
|
||||||
|
.watch(TypingController.provider.select((typing) => typing[roomId]))
|
||||||
|
?.remove(userId ?? "") ??
|
||||||
|
const .empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
static final provider = NotifierProvider.family
|
||||||
|
.autoDispose<RoomTypingController, IList<String>, String>(
|
||||||
|
RoomTypingController.new,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import "package:nexus/helpers/extensions/distributor_name.dart";
|
||||||
import "package:nexus/models/account_data.dart";
|
import "package:nexus/models/account_data.dart";
|
||||||
import "package:nexus/models/room_order.dart";
|
import "package:nexus/models/room_order.dart";
|
||||||
import "package:nexus/models/settings_category.dart";
|
import "package:nexus/models/settings_category.dart";
|
||||||
|
import "package:nexus/models/typing_indicators.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
||||||
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
||||||
|
|
@ -183,6 +184,34 @@ class SettingsSectionsController
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
.new(
|
||||||
|
title: "Privacy",
|
||||||
|
icon: Icons.privacy_tip,
|
||||||
|
settings: .new([
|
||||||
|
.new(
|
||||||
|
title: "Typing Indicators",
|
||||||
|
description: "Show when others are typing, and let others see when you're typing.",
|
||||||
|
icon: Icons.keyboard,
|
||||||
|
builder: (title, description, icon) =>
|
||||||
|
DialogListTile<TypingIndicators>(
|
||||||
|
icon: Icon(icon),
|
||||||
|
title: title,
|
||||||
|
subtitle: Text(description),
|
||||||
|
value: settings.typingIndicators,
|
||||||
|
options: TypingIndicators.values,
|
||||||
|
getName: (option) => switch (option) {
|
||||||
|
.showAndSend => "Show and send",
|
||||||
|
.show => "Only show",
|
||||||
|
.neither => "Neither",
|
||||||
|
},
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(SettingsController.provider.notifier)
|
||||||
|
.set(settings.copyWith(typingIndicators: value))
|
||||||
|
.onError(showError),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
if (ref.watch(ClientStateController.provider)?.isLoggedIn == true)
|
if (ref.watch(ClientStateController.provider)?.isLoggedIn == true)
|
||||||
"Account": .new([
|
"Account": .new([
|
||||||
|
|
|
||||||
15
lib/controllers/typing.dart
Normal file
15
lib/controllers/typing.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:nexus/models/typing.dart";
|
||||||
|
|
||||||
|
class TypingController extends Notifier<IMap<String, IList<String>>> {
|
||||||
|
@override
|
||||||
|
IMap<String, IList<String>> build() => .new();
|
||||||
|
|
||||||
|
void set(Typing typing) => state = state.add(typing.roomId, typing.userIds);
|
||||||
|
|
||||||
|
static final provider =
|
||||||
|
NotifierProvider<TypingController, IMap<String, IList<String>>>(
|
||||||
|
TypingController.new,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
import "package:nexus/models/room_order.dart";
|
import "package:nexus/models/room_order.dart";
|
||||||
|
import "package:nexus/models/typing_indicators.dart";
|
||||||
|
|
||||||
part "settings.freezed.dart";
|
part "settings.freezed.dart";
|
||||||
part "settings.g.dart";
|
part "settings.g.dart";
|
||||||
|
|
@ -16,6 +17,7 @@ class const Settings({
|
||||||
final bool swipeToReply = false,
|
final bool swipeToReply = false,
|
||||||
final bool hideMembershipEvents = false,
|
final bool hideMembershipEvents = false,
|
||||||
final RoomOrder roomOrder = .unreadFirst,
|
final RoomOrder roomOrder = .unreadFirst,
|
||||||
|
final TypingIndicators typingIndicators = .showAndSend,
|
||||||
}) with _$Settings {
|
}) with _$Settings {
|
||||||
Map<String, Object?> toJson() => _$SettingsToJson(this);
|
Map<String, Object?> toJson() => _$SettingsToJson(this);
|
||||||
|
|
||||||
|
|
|
||||||
14
lib/models/typing.dart
Normal file
14
lib/models/typing.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
|
||||||
|
part "typing.freezed.dart";
|
||||||
|
part "typing.g.dart";
|
||||||
|
|
||||||
|
@Freezed(toJson: false, fromJson: false)
|
||||||
|
@JsonSerializable(createToJson: false)
|
||||||
|
class const Typing({
|
||||||
|
required final String roomId,
|
||||||
|
final IList<String> userIds = const .empty(),
|
||||||
|
}) with _$Typing {
|
||||||
|
factory Typing.fromJson(Map<String, Object?> json) => _$TypingFromJson(json);
|
||||||
|
}
|
||||||
1
lib/models/typing_indicators.dart
Normal file
1
lib/models/typing_indicators.dart
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
enum TypingIndicators { showAndSend, show, neither }
|
||||||
41
lib/widgets/bouncing_dots.dart
Normal file
41
lib/widgets/bouncing_dots.dart
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import "dart:math";
|
||||||
|
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
|
||||||
|
/// Three dots that bounce one after another, in a loop.
|
||||||
|
class const BouncingDots({required final Color color, super.key})
|
||||||
|
extends HookWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final controller = useAnimationController(
|
||||||
|
duration: const .new(milliseconds: 1200),
|
||||||
|
);
|
||||||
|
useEffect(() {
|
||||||
|
controller.repeat();
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
final progress = useAnimation(controller);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: .min,
|
||||||
|
spacing: 3,
|
||||||
|
children: [0, 1, 2].map((index) {
|
||||||
|
final phase = (progress - index * 0.15) % 1;
|
||||||
|
final bounce = phase < 0.4 ? sin(phase / 0.4 * pi) : 0.0;
|
||||||
|
|
||||||
|
return Transform.translate(
|
||||||
|
offset: .new(0, -3 * bounce),
|
||||||
|
child: Opacity(
|
||||||
|
opacity: 0.4 + 0.6 * bounce,
|
||||||
|
child: Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(color: color, shape: .circle),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,8 @@ import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
import "package:fluttertagger/fluttertagger.dart";
|
import "package:fluttertagger/fluttertagger.dart";
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:nexus/controllers/attachment.dart";
|
import "package:nexus/controllers/attachment.dart";
|
||||||
|
import "package:nexus/controllers/client.dart";
|
||||||
|
import "package:nexus/controllers/settings.dart";
|
||||||
import "package:nexus/controllers/image_picker.dart";
|
import "package:nexus/controllers/image_picker.dart";
|
||||||
import "package:nexus/helpers/file_dialog_helper.dart";
|
import "package:nexus/helpers/file_dialog_helper.dart";
|
||||||
import "package:image_picker/image_picker.dart";
|
import "package:image_picker/image_picker.dart";
|
||||||
|
|
@ -94,6 +96,44 @@ class const Composer(
|
||||||
return null;
|
return null;
|
||||||
}, [relationType, relatedEvent == null]);
|
}, [relationType, relatedEvent == null]);
|
||||||
|
|
||||||
|
final sinceTypingSent = useMemoized(Stopwatch.new);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
final client = ref.read(ClientController.provider.notifier);
|
||||||
|
|
||||||
|
void stopTyping() {
|
||||||
|
if (!sinceTypingSent.isRunning) return;
|
||||||
|
sinceTypingSent
|
||||||
|
..stop()
|
||||||
|
..reset();
|
||||||
|
client.setTyping(roomId, .zero).ignore();
|
||||||
|
}
|
||||||
|
|
||||||
|
void onChanged() {
|
||||||
|
final shouldSend =
|
||||||
|
ref.read(SettingsController.provider).value?.typingIndicators ==
|
||||||
|
.showAndSend;
|
||||||
|
|
||||||
|
if (!shouldSend || controller.text.isEmpty) return stopTyping();
|
||||||
|
|
||||||
|
if (sinceTypingSent.isRunning &&
|
||||||
|
sinceTypingSent.elapsed < const .new(seconds: 5)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sinceTypingSent
|
||||||
|
..reset()
|
||||||
|
..start();
|
||||||
|
client.setTyping(roomId, const .new(seconds: 10)).ignore();
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.addListener(onChanged);
|
||||||
|
return () {
|
||||||
|
controller.removeListener(onChanged);
|
||||||
|
stopTyping();
|
||||||
|
};
|
||||||
|
}, [roomId]);
|
||||||
|
|
||||||
final attachment = ref.watch(AttachmentController.provider(roomId));
|
final attachment = ref.watch(AttachmentController.provider(roomId));
|
||||||
|
|
||||||
Future<void> attach(Future<XFile?> pick) async {
|
Future<void> attach(Future<XFile?> pick) async {
|
||||||
|
|
|
||||||
104
lib/widgets/composer/typing_indicator.dart
Normal file
104
lib/widgets/composer/typing_indicator.dart
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import "package:collection/collection.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/controllers/room_typing.dart";
|
||||||
|
import "package:nexus/controllers/user.dart";
|
||||||
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
|
import "package:nexus/widgets/bouncing_dots.dart";
|
||||||
|
|
||||||
|
class const TypingIndicator(final String roomId, {super.key})
|
||||||
|
extends ConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final typing = ref.watch(RoomTypingController.provider(roomId));
|
||||||
|
|
||||||
|
// Past three people, only the first two are named
|
||||||
|
final named = typing.length > 3 ? 2 : typing.length;
|
||||||
|
final subjects = [
|
||||||
|
...typing
|
||||||
|
.take(named)
|
||||||
|
.map(
|
||||||
|
(typingUserId) => TextSpan(
|
||||||
|
text:
|
||||||
|
ref
|
||||||
|
.watch(
|
||||||
|
UserController.provider(
|
||||||
|
.new(roomId: roomId, userId: typingUserId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.value
|
||||||
|
?.displayName ??
|
||||||
|
typingUserId.localpart,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: .bold,
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (typing.length > named)
|
||||||
|
TextSpan(text: "${typing.length - named} others"),
|
||||||
|
];
|
||||||
|
|
||||||
|
final spans = [
|
||||||
|
...subjects.expandIndexed(
|
||||||
|
(index, subject) => [
|
||||||
|
if (index > 0)
|
||||||
|
TextSpan(
|
||||||
|
text: index < subjects.length - 1
|
||||||
|
? ", "
|
||||||
|
: subjects.length > 2
|
||||||
|
? ", and "
|
||||||
|
: " and ",
|
||||||
|
),
|
||||||
|
subject,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
TextSpan(text: subjects.length == 1 ? " is typing..." : " are typing..."),
|
||||||
|
];
|
||||||
|
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: Durations.medium2,
|
||||||
|
switchInCurve: Curves.easeOutCubic,
|
||||||
|
switchOutCurve: Curves.easeInCubic,
|
||||||
|
transitionBuilder: (child, animation) => FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: ScaleTransition(
|
||||||
|
scale: animation,
|
||||||
|
alignment: .bottomLeft,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: typing.isEmpty
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: Align(
|
||||||
|
alignment: .bottomLeft,
|
||||||
|
child: Material(
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
|
elevation: 1,
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
child: Padding(
|
||||||
|
padding: .symmetric(horizontal: 12, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: .min,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
BouncingDots(color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
Flexible(
|
||||||
|
child: Text.rich(
|
||||||
|
TextSpan(children: spans),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: .ellipsis,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,9 @@ class const ChatTimeline({
|
||||||
required final IList<PopupMenuEntry> Function(Event) getEventOptions,
|
required final IList<PopupMenuEntry> Function(Event) getEventOptions,
|
||||||
required final int? highlightedEvent,
|
required final int? highlightedEvent,
|
||||||
required final double composerHeight,
|
required final double composerHeight,
|
||||||
|
|
||||||
|
/// The typing indicator's height, or zero while it's hidden.
|
||||||
|
required final double typingIndicatorHeight,
|
||||||
final void Function(int rowId)? onSwipe,
|
final void Function(int rowId)? onSwipe,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
|
|
@ -40,7 +43,22 @@ class const ChatTimeline({
|
||||||
keyboardDismissBehavior: .onDrag,
|
keyboardDismissBehavior: .onDrag,
|
||||||
controller: scroll.scrollController,
|
controller: scroll.scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverToBoxAdapter(child: SizedBox(height: composerHeight)),
|
SliverToBoxAdapter(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Makes room for the typing indicator, with a small gap above it,
|
||||||
|
// so it doesn't cover messages
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: Durations.medium2,
|
||||||
|
curve: Curves.easeInOutCubicEmphasized,
|
||||||
|
height: typingIndicatorHeight == 0
|
||||||
|
? 0
|
||||||
|
: typingIndicatorHeight + 4,
|
||||||
|
),
|
||||||
|
SizedBox(height: composerHeight),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
SuperSliverList.builder(
|
SuperSliverList.builder(
|
||||||
itemCount: scroll.liveRows.length,
|
itemCount: scroll.liveRows.length,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import "package:nexus/helpers/hooks/chat_scroll.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/relation_type.dart";
|
import "package:nexus/models/relation_type.dart";
|
||||||
import "package:nexus/widgets/composer/composer.dart";
|
import "package:nexus/widgets/composer/composer.dart";
|
||||||
|
import "package:nexus/widgets/composer/typing_indicator.dart";
|
||||||
import "package:nexus/widgets/error_dialog.dart";
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
import "package:nexus/widgets/loading.dart";
|
import "package:nexus/widgets/loading.dart";
|
||||||
import "package:nexus/widgets/pinned_events_drawer.dart";
|
import "package:nexus/widgets/pinned_events_drawer.dart";
|
||||||
|
|
@ -46,6 +47,7 @@ final class const RoomChat({
|
||||||
final highlightedEvent = useState<int?>(null);
|
final highlightedEvent = useState<int?>(null);
|
||||||
|
|
||||||
final composerSize = useState<double>(64);
|
final composerSize = useState<double>(64);
|
||||||
|
final typingIndicatorSize = useState<double>(0);
|
||||||
|
|
||||||
final userId = ref.watch(ClientStateController.provider)?.userId;
|
final userId = ref.watch(ClientStateController.provider)?.userId;
|
||||||
final memberListOpened = ref
|
final memberListOpened = ref
|
||||||
|
|
@ -252,6 +254,8 @@ final class const RoomChat({
|
||||||
: null,
|
: null,
|
||||||
highlightedEvent: highlightedEvent.value,
|
highlightedEvent: highlightedEvent.value,
|
||||||
composerHeight: composerSize.value,
|
composerHeight: composerSize.value,
|
||||||
|
typingIndicatorHeight:
|
||||||
|
typingIndicatorSize.value,
|
||||||
),
|
),
|
||||||
AsyncLoading _ => Loading(),
|
AsyncLoading _ => Loading(),
|
||||||
AsyncError(
|
AsyncError(
|
||||||
|
|
@ -262,6 +266,17 @@ final class const RoomChat({
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: composerSize.value,
|
||||||
|
left: 12,
|
||||||
|
// To give space for jump to bottom button
|
||||||
|
right: 72,
|
||||||
|
child: MeasureSize(
|
||||||
|
onChange: (size) =>
|
||||||
|
typingIndicatorSize.value = size.height,
|
||||||
|
child: TypingIndicator(roomId),
|
||||||
|
),
|
||||||
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: composerSize.value,
|
bottom: composerSize.value,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue