Remove flutter chat #26

Manually merged
Henry-Hiles merged 108 commits from remove-flutter-chat into main 2026-05-22 15:26:28 -04:00
25 changed files with 255 additions and 466 deletions
Showing only changes of commit cf5d1ad5d9 - Show all commits

building, but not yet working

Still a lot to re-implement
Henry Hiles 2026-05-17 21:08:17 -04:00
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs

View file

@ -1,4 +1,3 @@
import "dart:developer";
import "dart:ffi"; import "dart:ffi";
import "dart:io"; import "dart:io";
import "dart:isolate"; import "dart:isolate";
@ -123,9 +122,12 @@ class ClientController extends AsyncNotifier<int> {
} }
debugPrint("Finished handling $muksEventType..."); debugPrint("Finished handling $muksEventType...");
} catch (error, stackTrace) { } catch (error, stackTrace) {
debugPrintStack(stackTrace: stackTrace, label: error.toString()); if (kDebugMode) {
debugger(); debugPrintStack(stackTrace: stackTrace, label: error.toString());
showError(error, stackTrace); rethrow;
} else {
showError(error, stackTrace);
}
} }
}); });

View file

@ -39,14 +39,13 @@ class RoomChatController extends AsyncNotifier<IList<Event>> {
state: state.fold( state: state.fold(
const IMap.empty(), const IMap.empty(),
(previousValue, stateEvent) => previousValue.add( (previousValue, stateEvent) => previousValue.add(
stateEvent.type.type, stateEvent.type,
(previousValue[stateEvent.type.type] ?? const IMap.empty()) (previousValue[stateEvent.type] ?? const IMap.empty()).addAll(
.addAll( IMap({
IMap({ if (stateEvent.stateKey != null)
if (stateEvent.stateKey != null) stateEvent.stateKey!: stateEvent.rowId,
stateEvent.stateKey!: stateEvent.rowId, }),
}), ),
),
), ),
), ),
), ),

View file

@ -7,7 +7,7 @@ part "avatar.g.dart";
@freezed @freezed
abstract class AvatarContent extends Content with _$AvatarContent { abstract class AvatarContent extends Content with _$AvatarContent {
AvatarContent._(); AvatarContent._();
const factory AvatarContent({ImageInfo? info, Uri? url}) = _AvatarContent; factory AvatarContent({ImageInfo? info, Uri? url}) = _AvatarContent;
factory AvatarContent.fromJson(Map<String, Object?> json) => factory AvatarContent.fromJson(Map<String, Object?> json) =>
_$AvatarContentFromJson(json); _$AvatarContentFromJson(json);

View file

@ -7,10 +7,8 @@ part "canonical_alias.g.dart";
abstract class CanonicalAliasContent extends Content abstract class CanonicalAliasContent extends Content
with _$CanonicalAliasContent { with _$CanonicalAliasContent {
CanonicalAliasContent._(); CanonicalAliasContent._();
const factory CanonicalAliasContent({ factory CanonicalAliasContent({String? alias, @Default([]) altAliases}) =
String? alias, _CanonicalAliasContent;
@Default([]) altAliases,
}) = _CanonicalAliasContent;
factory CanonicalAliasContent.fromJson(Map<String, Object?> json) => factory CanonicalAliasContent.fromJson(Map<String, Object?> json) =>
_$CanonicalAliasContentFromJson(json); _$CanonicalAliasContentFromJson(json);

View file

@ -1,5 +1,4 @@
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/avatar.dart";
import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/canonical_alias.dart";
import "package:nexus/models/content/create.dart"; import "package:nexus/models/content/create.dart";
@ -28,7 +27,6 @@ class Content {
Content.fromJson)(eventJson); Content.fromJson)(eventJson);
} }
@JsonEnum(valueField: "type")
enum EventType { enum EventType {
encrypted("m.room.encrypted", Content.fromJson), encrypted("m.room.encrypted", Content.fromJson),
redaction("m.room.redaction", RedactionContent.fromJson), redaction("m.room.redaction", RedactionContent.fromJson),

View file

@ -7,7 +7,7 @@ part "create.g.dart";
@freezed @freezed
abstract class CreateContent extends Content with _$CreateContent { abstract class CreateContent extends Content with _$CreateContent {
CreateContent._(); CreateContent._();
const factory CreateContent({ factory CreateContent({
@JsonKey(name: "creator") String? creatorId, @JsonKey(name: "creator") String? creatorId,
@JsonKey(name: "additional_creators") @JsonKey(name: "additional_creators")

View file

@ -6,7 +6,7 @@ part "encryption.g.dart";
@freezed @freezed
abstract class EncryptionContent extends Content with _$EncryptionContent { abstract class EncryptionContent extends Content with _$EncryptionContent {
EncryptionContent._(); EncryptionContent._();
const factory EncryptionContent({ factory EncryptionContent({
required String algorithm, required String algorithm,
@JsonKey(name: "rotation_period_ms") @JsonKey(name: "rotation_period_ms")

View file

@ -8,7 +8,7 @@ part "join_rules.g.dart";
@freezed @freezed
abstract class JoinRulesContent extends Content with _$JoinRulesContent { abstract class JoinRulesContent extends Content with _$JoinRulesContent {
JoinRulesContent._(); JoinRulesContent._();
const factory JoinRulesContent({ factory JoinRulesContent({
required JoinRule joinRule, required JoinRule joinRule,
@Default(IList.empty()) IList<AllowCondition> allow, @Default(IList.empty()) IList<AllowCondition> allow,
}) = _JoinRulesContent; }) = _JoinRulesContent;

View file

@ -7,7 +7,7 @@ part "membership.g.dart";
@freezed @freezed
abstract class MembershipContent extends Content with _$MembershipContent { abstract class MembershipContent extends Content with _$MembershipContent {
MembershipContent._(); MembershipContent._();
const factory MembershipContent({ factory MembershipContent({
@JsonKey(name: "displayname") required String displayName, @JsonKey(name: "displayname") required String displayName,
@JsonKey(name: "membership") required MembershipStatus status, @JsonKey(name: "membership") required MembershipStatus status,
Uri? avatarUrl, Uri? avatarUrl,

View file

@ -9,7 +9,7 @@ part "message.g.dart";
@Freezed(unionKey: "msgtype", fallbackUnion: "default") @Freezed(unionKey: "msgtype", fallbackUnion: "default")
abstract class MessageContent extends Content with _$MessageContent { abstract class MessageContent extends Content with _$MessageContent {
MessageContent._(); MessageContent._();
const factory MessageContent({ factory MessageContent({
required String msgtype, required String msgtype,
required String body, required String body,
String? format, String? format,
@ -17,7 +17,7 @@ abstract class MessageContent extends Content with _$MessageContent {
}) = TextMessageContent; }) = TextMessageContent;
@FreezedUnionValue("m.image") @FreezedUnionValue("m.image")
const factory MessageContent.image({ factory MessageContent.image({
required String body, required String body,
String? format, String? format,
String? formattedBody, String? formattedBody,
@ -28,7 +28,7 @@ abstract class MessageContent extends Content with _$MessageContent {
}) = ImageMessageContent; }) = ImageMessageContent;
@FreezedUnionValue("m.file") @FreezedUnionValue("m.file")
const factory MessageContent.file({ factory MessageContent.file({
required String body, required String body,
String? format, String? format,
String? formattedBody, String? formattedBody,
@ -39,7 +39,7 @@ abstract class MessageContent extends Content with _$MessageContent {
}) = FileMessageContent; }) = FileMessageContent;
@FreezedUnionValue("m.audio") @FreezedUnionValue("m.audio")
const factory MessageContent.audio({ factory MessageContent.audio({
required String body, required String body,
String? format, String? format,
String? formattedBody, String? formattedBody,
@ -50,7 +50,7 @@ abstract class MessageContent extends Content with _$MessageContent {
}) = AudioMessageContent; }) = AudioMessageContent;
@FreezedUnionValue("m.video") @FreezedUnionValue("m.video")
const factory MessageContent.video({ factory MessageContent.video({
required String body, required String body,
String? format, String? format,
String? formattedBody, String? formattedBody,
@ -58,13 +58,11 @@ abstract class MessageContent extends Content with _$MessageContent {
String? filename, String? filename,
AudioInfo? info, AudioInfo? info,
String? url, String? url,
}) = AudioMessageContent; }) = VideoMessageContent;
@FreezedUnionValue("m.location") @FreezedUnionValue("m.location")
const factory MessageContent.location({ factory MessageContent.location({required String body, required Uri geoUri}) =
required String body, LocationMessageContent;
required Uri geoUri,
}) = LocationMessageContent;
factory MessageContent.fromJson(Map<String, Object?> json) => factory MessageContent.fromJson(Map<String, Object?> json) =>
_$MessageContentFromJson(json); _$MessageContentFromJson(json);

View file

@ -6,7 +6,7 @@ part "name.g.dart";
@freezed @freezed
abstract class NameContent extends Content with _$NameContent { abstract class NameContent extends Content with _$NameContent {
NameContent._(); NameContent._();
const factory NameContent({required String name}) = _NameContent; factory NameContent({required String name}) = _NameContent;
factory NameContent.fromJson(Map<String, Object?> json) => factory NameContent.fromJson(Map<String, Object?> json) =>
_$NameContentFromJson(json); _$NameContentFromJson(json);

View file

@ -7,9 +7,8 @@ part "pinned_events.g.dart";
@freezed @freezed
abstract class PinnedEventsContent extends Content with _$PinnedEventsContent { abstract class PinnedEventsContent extends Content with _$PinnedEventsContent {
PinnedEventsContent._(); PinnedEventsContent._();
const factory PinnedEventsContent({ factory PinnedEventsContent({@Default(IList.empty()) IList<String> pinned}) =
@Default(IList.empty()) IList<String> pinned, _PinnedEventsContent;
}) = _PinnedEventsContent;
factory PinnedEventsContent.fromJson(Map<String, Object?> json) => factory PinnedEventsContent.fromJson(Map<String, Object?> json) =>
_$PinnedEventsContentFromJson(json); _$PinnedEventsContentFromJson(json);

View file

@ -9,7 +9,7 @@ part "power_levels.g.dart";
abstract class PowerLevelsContent extends Content with _$PowerLevelsContent { abstract class PowerLevelsContent extends Content with _$PowerLevelsContent {
PowerLevelsContent._(); PowerLevelsContent._();
const factory PowerLevelsContent({ factory PowerLevelsContent({
@Default(IMap.empty()) IMap<String, int> events, @Default(IMap.empty()) IMap<String, int> events,
@Default(IMap.empty()) IMap<String, int> users, @Default(IMap.empty()) IMap<String, int> users,
Notifications? notifications, Notifications? notifications,

View file

@ -8,7 +8,7 @@ String? keyFromJson(Map<String, dynamic> json) => json["m.relates_to"]?["key"];
@freezed @freezed
abstract class ReactionContent extends Content with _$ReactionContent { abstract class ReactionContent extends Content with _$ReactionContent {
ReactionContent._(); ReactionContent._();
const factory ReactionContent({@JsonKey(fromJson: keyFromJson) String? key}) = factory ReactionContent({@JsonKey(fromJson: keyFromJson) String? key}) =
_ReactionContent; _ReactionContent;
factory ReactionContent.fromJson(Map<String, Object?> json) => factory ReactionContent.fromJson(Map<String, Object?> json) =>

View file

@ -6,7 +6,7 @@ part "redaction.g.dart";
@freezed @freezed
abstract class RedactionContent extends Content with _$RedactionContent { abstract class RedactionContent extends Content with _$RedactionContent {
RedactionContent._(); RedactionContent._();
const factory RedactionContent({String? reason, String? redacts}) = factory RedactionContent({String? reason, String? redacts}) =
_RedactionContent; _RedactionContent;
factory RedactionContent.fromJson(Map<String, Object?> json) => factory RedactionContent.fromJson(Map<String, Object?> json) =>

View file

@ -7,7 +7,7 @@ part "server_acl.g.dart";
@freezed @freezed
abstract class ServerACLContent extends Content with _$ServerACLContent { abstract class ServerACLContent extends Content with _$ServerACLContent {
ServerACLContent._(); ServerACLContent._();
const factory ServerACLContent({ factory ServerACLContent({
@Default(IList.empty()) IList<String> allow, @Default(IList.empty()) IList<String> allow,
@Default(IList.empty()) IList<String> deny, @Default(IList.empty()) IList<String> deny,
@Default(true) allowIpLiterals, @Default(true) allowIpLiterals,

View file

@ -7,7 +7,7 @@ part "topic.g.dart";
@freezed @freezed
abstract class TopicContent extends Content with _$TopicContent { abstract class TopicContent extends Content with _$TopicContent {
TopicContent._(); TopicContent._();
const factory TopicContent({ factory TopicContent({
required String topic, required String topic,
@JsonKey(name: "m.topic") TopicContentBlock? content, @JsonKey(name: "m.topic") TopicContentBlock? content,
}) = _TopicContent; }) = _TopicContent;
@ -18,7 +18,7 @@ abstract class TopicContent extends Content with _$TopicContent {
@freezed @freezed
abstract class TopicContentBlock with _$TopicContentBlock { abstract class TopicContentBlock with _$TopicContentBlock {
const factory TopicContentBlock({ factory TopicContentBlock({
@Default(IList.empty()) @Default(IList.empty())
@JsonKey(name: "m.text") @JsonKey(name: "m.text")
IList<TextualRepresentation> representations, IList<TextualRepresentation> representations,
@ -30,7 +30,7 @@ abstract class TopicContentBlock with _$TopicContentBlock {
@freezed @freezed
abstract class TextualRepresentation with _$TextualRepresentation { abstract class TextualRepresentation with _$TextualRepresentation {
const factory TextualRepresentation({ factory TextualRepresentation({
required String body, required String body,
@Default("text/plain") String mimetype, @Default("text/plain") String mimetype,
}) = _TextualRepresentation; }) = _TextualRepresentation;

View file

@ -6,8 +6,8 @@ import "package:nexus/models/profile.dart";
part "event.freezed.dart"; part "event.freezed.dart";
part "event.g.dart"; part "event.g.dart";
Profile? pmpFromJson(Map<String, dynamic> json) { Profile? pmpFromJson(Map<String, dynamic>? json) {
final pmp = json["content"]?["com.beeper.per_message_profile"]; final pmp = json?["content"]?["com.beeper.per_message_profile"];
return pmp == null ? null : Profile.fromJson(pmp); return pmp == null ? null : Profile.fromJson(pmp);
} }
@ -19,7 +19,7 @@ abstract class Event with _$Event {
required String roomId, required String roomId,
required String eventId, required String eventId,
required String sender, required String sender,
required EventType type, required String type,
String? stateKey, String? stateKey,
@EpochDateTimeConverter() required DateTime timestamp, @EpochDateTimeConverter() required DateTime timestamp,
IMap<String, dynamic>? decrypted, IMap<String, dynamic>? decrypted,
@ -36,7 +36,7 @@ abstract class Event with _$Event {
@JsonKey(name: "last_edit_rowid") int? lastEditRowId, @JsonKey(name: "last_edit_rowid") int? lastEditRowId,
@UnreadTypeConverter() UnreadType? unreadType, @UnreadTypeConverter() UnreadType? unreadType,
@JsonKey(fromJson: pmpFromJson) Profile? pmp, @JsonKey(fromJson: pmpFromJson) Profile? pmp,
@JsonKey(fromJson: Content.fromJson) required Content content, @JsonKey(fromJson: Content.fromEventJson) required Content content,
}) = _Event; }) = _Event;
factory Event.fromJson(Map<String, Object?> json) => _$EventFromJson(json); factory Event.fromJson(Map<String, Object?> json) => _$EventFromJson(json);

View file

@ -1,3 +1,4 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
@ -5,10 +6,14 @@ class EventText extends StatelessWidget {
final Event event; final Event event;
final bool textOnly; final bool textOnly;
final int? maxLines; final int? maxLines;
final VoidCallback? onTapReply;
final IList<PopupMenuEntry> Function(Event event)? getEventOptions;
const EventText( const EventText(
this.event, { this.event, {
this.onTapReply,
this.textOnly = false, this.textOnly = false,
this.maxLines, this.maxLines,
this.getEventOptions,
super.key, super.key,
}); });

View file

@ -1,100 +0,0 @@
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/event_controller.dart";
import "package:nexus/controllers/message_controller.dart";
import "package:nexus/controllers/selected_room_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/configs/message_config.dart";
import "package:nexus/models/requests/get_event_request.dart";
import "package:nexus/widgets/chat_page/html/quoted.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart";
typedef OnTapReply = void Function(Message message)?;
class ReplyWidget extends ConsumerWidget {
final Message message;
final bool alwaysShow;
final MessageGroupStatus? groupStatus;
final OnTapReply onTapReply;
const ReplyWidget(
this.message, {
required this.groupStatus,
this.onTapReply,
this.alwaysShow = false,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final room = ref.watch(SelectedRoomController.provider);
return message.replyToMessageId == null || room == null
? SizedBox.shrink()
: Padding(
padding: EdgeInsets.only(bottom: 12),
child: Quoted(
ref
.watch(
EventController.provider(
GetEventRequest(
room: room,
eventId: message.replyToMessageId!,
),
),
)
.betterWhen(
loading: () => Text("Fetching event..."),
data: (event) => event == null
? SizedBox.shrink()
: ref
.watch(
MessageController.provider(
MessageConfig(room: room, event: event),
),
)
.betterWhen(
loading: () => Text("Parsing message..."),
data: (replyMessage) {
if (replyMessage == null) {
return SizedBox.shrink();
}
return InkWell(
onTap: () => onTapReply?.call(replyMessage),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
MessageAvatar(replyMessage),
Flexible(
child: MessageDisplayname(
replyMessage,
clickable: false,
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Flexible(
child: Text(
replyMessage.metadata!["body"],
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.labelMedium,
maxLines: 1,
),
),
],
),
);
},
),
),
),
);
}
}

View file

@ -11,21 +11,21 @@ import "package:nexus/controllers/selected_room_controller.dart";
import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/controllers/room_chat_controller.dart";
import "package:nexus/controllers/via_controller.dart"; import "package:nexus/controllers/via_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/show_context_menu.dart";
import "package:nexus/models/configs/power_level_config.dart"; import "package:nexus/models/configs/power_level_config.dart";
import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/report_request.dart"; import "package:nexus/models/requests/report_request.dart";
import "package:nexus/widgets/chat_page/composer/chat_box.dart"; import "package:nexus/widgets/chat_page/composer/chat_box.dart";
import "package:nexus/widgets/chat_page/emoji_picker_button.dart"; import "package:nexus/widgets/chat_page/emoji_picker_button.dart";
import "package:nexus/widgets/chat_page/expandable_image_message.dart"; import "package:nexus/widgets/chat_page/event_text.dart";
import "package:nexus/widgets/chat_page/member_list.dart"; import "package:nexus/widgets/chat_page/member_list.dart";
import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart";
import "package:nexus/widgets/chat_page/room_appbar.dart"; import "package:nexus/widgets/chat_page/room_appbar.dart";
import "package:nexus/widgets/chat_page/wrappers/text_message_wrapper.dart"; import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart";
import "package:nexus/widgets/chat_page/reply_widget.dart";
import "package:nexus/widgets/form_text_input.dart"; import "package:nexus/widgets/form_text_input.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:super_sliver_list/super_sliver_list.dart";
class RoomChat extends HookConsumerWidget { class RoomChat extends HookConsumerWidget {
final bool isDesktop; final bool isDesktop;
@ -38,17 +38,21 @@ class RoomChat extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(ClientController.provider.notifier); final relatedEvent = useState<Event?>(null);
final relatedMessage = useState<Message?>(null);
final memberListOpened = useState<bool>(showMembersByDefault);
final relationType = useState(RelationType.reply); final relationType = useState(RelationType.reply);
final memberListOpened = useState<bool>(showMembersByDefault);
final listController = useRef(ListController());
final scrollController = useScrollController();
// TODO: Do things on scroll to top or bottom
final userId = ref.watch(ClientStateController.provider)?.userId; final userId = ref.watch(ClientStateController.provider)?.userId;
final roomId = ref.watch( final roomId = ref.watch(
SelectedRoomController.provider.select((value) => value?.metadata?.id), SelectedRoomController.provider.select((value) => value?.metadata?.id),
); );
final theme = Theme.of(context); final theme = Theme.of(context);
final danger = theme.colorScheme.error;
if (roomId == null || userId == null) { if (roomId == null || userId == null) {
return Scaffold( return Scaffold(
@ -73,7 +77,7 @@ class RoomChat extends HookConsumerWidget {
onKeyEvent: (_, event) { onKeyEvent: (_, event) {
if (event is KeyDownEvent && if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) { event.logicalKey == LogicalKeyboardKey.escape) {
relatedMessage.value = null; relatedEvent.value = null;
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@ -81,8 +85,9 @@ class RoomChat extends HookConsumerWidget {
}, },
); );
List<PopupMenuEntry> getMessageOptions(Message message) { IList<PopupMenuEntry> getEventOptions(Event event) {
final isSentByMe = message.sender == userId; final danger = theme.colorScheme.error;
final isSentByMe = event.sender == userId;
return [ return [
if (ref.watch( if (ref.watch(
PowerLevelController.provider( PowerLevelController.provider(
@ -113,7 +118,7 @@ class RoomChat extends HookConsumerWidget {
onPressed: () async { onPressed: () async {
Navigator.of(context).pop(); Navigator.of(context).pop();
await notifier await notifier
.sendReaction(emoji, message) .sendReaction(emoji, event)
.onError(showError); .onError(showError);
}, },
icon: Text(emoji), icon: Text(emoji),
@ -123,7 +128,7 @@ class RoomChat extends HookConsumerWidget {
context: context, context: context,
onPressed: Navigator.of(context).pop, onPressed: Navigator.of(context).pop,
onSelection: (emoji) => onSelection: (emoji) =>
notifier.sendReaction(emoji, message).onError(showError), notifier.sendReaction(emoji, event).onError(showError),
), ),
], ],
), ),
@ -135,16 +140,16 @@ class RoomChat extends HookConsumerWidget {
)) ))
PopupMenuItem( PopupMenuItem(
onTap: () { onTap: () {
relatedMessage.value = message; relatedEvent.value = event;
relationType.value = RelationType.reply; relationType.value = RelationType.reply;
composerNode.requestFocus(); composerNode.requestFocus();
}, },
child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")), child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")),
), ),
if (message is TextMessage && isSentByMe) if (event.content is MessageContent && isSentByMe)
PopupMenuItem( PopupMenuItem(
onTap: () { onTap: () {
relatedMessage.value = message; relatedEvent.value = event;
relationType.value = RelationType.edit; relationType.value = RelationType.edit;
composerNode.requestFocus(); composerNode.requestFocus();
}, },
@ -160,7 +165,7 @@ class RoomChat extends HookConsumerWidget {
await Clipboard.setData( await Clipboard.setData(
ClipboardData( ClipboardData(
text: text:
"matrix:roomid/${room.metadata?.id.substring(1)}/e/${message.id}$vias)", "matrix:roomid/${room.metadata?.id.substring(1)}/e/${event.eventId}$vias)",
), ),
); );
}, },
@ -168,7 +173,7 @@ class RoomChat extends HookConsumerWidget {
), ),
if (ref.watch( if (ref.watch(
PowerLevelController.provider( PowerLevelController.provider(
PowerLevelConfig.redaction(targetUser: message.authorId), PowerLevelConfig.redaction(targetUser: event.sender),
), ),
)) ))
PopupMenuItem( PopupMenuItem(
@ -205,7 +210,7 @@ class RoomChat extends HookConsumerWidget {
Navigator.of(context).pop(); Navigator.of(context).pop();
await notifier await notifier
.deleteMessage( .deleteMessage(
message, event,
reason: deleteReasonController.text, reason: deleteReasonController.text,
) )
.onError(showError); .onError(showError);
@ -254,15 +259,17 @@ class RoomChat extends HookConsumerWidget {
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
client.reportEvent( ref
ReportRequest( .watch(ClientController.provider.notifier)
roomId: roomId, .reportEvent(
eventId: message.id, ReportRequest(
reason: reasonController.text.isEmpty roomId: roomId,
? null eventId: event.eventId,
: reasonController.text, reason: reasonController.text.isEmpty
), ? null
); : reasonController.text,
),
);
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
child: Text("Report"), child: Text("Report"),
@ -277,16 +284,9 @@ class RoomChat extends HookConsumerWidget {
title: Text("Report", style: TextStyle(color: danger)), title: Text("Report", style: TextStyle(color: danger)),
), ),
), ),
]; ].toIList();
} }
final chatTheme = ChatTheme.fromThemeData(theme).copyWith(
colors: ChatColors.fromThemeData(theme).copyWith(
primary: theme.colorScheme.primaryContainer,
onPrimary: theme.colorScheme.onPrimaryContainer,
),
);
return Scaffold( return Scaffold(
appBar: RoomAppbar( appBar: RoomAppbar(
isDesktop: isDesktop, isDesktop: isDesktop,
@ -299,178 +299,55 @@ class RoomChat extends HookConsumerWidget {
body: Row( body: Row(
children: [ children: [
Expanded( Expanded(
child: Column( child: Stack(
children: [ children: [
Expanded( Positioned.fill(
child: ref child: ref
.watch(controllerProvider) .watch(controllerProvider)
.betterWhen( .betterWhen(
data: (controller) => Chat( data: (events) => SuperListView.builder(
currentUserId: userId, controller: scrollController,
theme: chatTheme, listController: listController.value,
onMessageSecondaryTap: itemCount: events.length,
( itemBuilder: (_, index) => MessageWrapper(
context, events[index],
message, { EventText(
required index, events[index],
TapUpDetails? details, onTapReply: () =>
}) => details?.globalPosition == null listController.value.animateToItem(
? null index: index,
: context.showContextMenu( scrollController: scrollController,
globalPosition: details!.globalPosition, alignment: 0.5,
children: getMessageOptions(message), duration: (_) =>
), Duration(milliseconds: 250),
onMessageLongPress: curve: (_) => Curves.easeInOut,
( ),
context, getEventOptions: getEventOptions,
message, {
required details,
required index,
}) => context.showContextMenu(
globalPosition: details.globalPosition,
children: getMessageOptions(message),
),
builders: Builders(
loadMoreBuilder: (_) => SizedBox.shrink(),
chatAnimatedListBuilder: (_, itemBuilder) =>
ChatAnimatedList(
itemBuilder: itemBuilder,
onEndReached:
ref.watch(
SelectedRoomController.provider.select(
(room) => room?.hasMore == true,
),
)
? notifier.loadOlder
: null,
onStartReached: () async {
final room = ref.watch(
SelectedRoomController.provider,
);
return room == null
? null
: await client.markRead(room);
},
bottomPadding: 72,
),
composerBuilder: (_) => ChatBox(
node: composerNode,
onSend:
(
text, {
required shouldMention,
required tags,
}) => notifier
.send(
text,
tags: tags,
relationType: relationType.value,
shouldMention: shouldMention,
relation: relatedMessage.value,
)
.onError(showError),
relationType: relationType.value,
relatedEvent: relatedMessage.value,
onDismiss: () => relatedMessage.value = null,
), ),
// TODO: Reimplement grouping
textMessageBuilder: isGrouped: false,
( // TODO: Reimplement flashing
context, isFlashing: false,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => TextMessageWrapper(
message,
content: message.text,
groupStatus: groupStatus,
onTapReply: notifier.scrollToEvent,
updateMessage: controller.updateMessage,
isSentByMe: isSentByMe,
),
imageMessageBuilder:
(
context,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => TextMessageWrapper(
message,
content: message.text,
groupStatus: groupStatus,
onTapReply: notifier.scrollToEvent,
updateMessage: controller.updateMessage,
isSentByMe: isSentByMe,
extra: ExpandableImageMessage(message),
),
fileMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => MessageWrapper(
message,
InkWell(
onTap: () => showDialog(
context: context,
builder: (_) => Dialog(
child: Text(
"TODO: Download Attachments",
),
),
),
child: FlyerChatFileMessage(
topWidget: ReplyWidget(
message,
onTapReply: notifier.scrollToEvent,
groupStatus: groupStatus,
),
message: message,
index: index,
),
),
groupStatus,
),
systemMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatSystemMessage(
message: message,
index: index,
),
unsupportedMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => Text(
"${message.sender} sent ${message.metadata?["eventType"]}",
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.grey,
),
),
), ),
resolveUser: (_) async => null,
chatController: controller,
), ),
), ),
), ),
ChatBox(
node: composerNode,
onSend: (text, {required shouldMention, required tags}) =>
notifier
.send(
text,
tags: tags,
relationType: relationType.value,
shouldMention: shouldMention,
relation: relatedEvent.value,
)
.onError(showError),
relationType: relationType.value,
relatedEvent: relatedEvent.value,
onDismiss: () => relatedEvent.value = null,
),
], ],
), ),
), ),

View file

@ -1,27 +1,32 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/chat_page/wrappers/reaction_row.dart"; import "package:nexus/widgets/chat_page/wrappers/reaction_row.dart";
import "package:timeago/timeago.dart"; import "package:timeago/timeago.dart";
class MessageWrapper extends StatelessWidget { class MessageWrapper extends StatelessWidget {
final Message message; final Event event;
final Widget child; final Widget child;
final MessageGroupStatus? groupStatus; final bool isGrouped;
const MessageWrapper(this.message, this.child, this.groupStatus, {super.key}); final bool isFlashing;
const MessageWrapper(
this.event,
this.child, {
this.isGrouped = false,
this.isFlashing = false,
super.key,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final error = message.metadata?["error"];
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(12)), borderRadius: BorderRadius.all(Radius.circular(12)),
child: AnimatedContainer( child: AnimatedContainer(
padding: message.metadata?["flashing"] == true padding: isFlashing ? EdgeInsets.all(8) : EdgeInsets.all(0),
? EdgeInsets.all(8) color: isFlashing
: EdgeInsets.all(0),
color: message.metadata?["flashing"] == true
? Theme.of(context).colorScheme.onSurface.withAlpha(50) ? Theme.of(context).colorScheme.onSurface.withAlpha(50)
: Colors.transparent, : Colors.transparent,
duration: Duration(milliseconds: 250), duration: Duration(milliseconds: 250),
@ -29,48 +34,44 @@ class MessageWrapper extends StatelessWidget {
spacing: 8, spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
groupStatus?.isFirst != false isGrouped ? SizedBox(width: 40) : MessageAvatar(event, height: 40),
? MessageAvatar(message, height: 40)
: SizedBox(width: 40),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4, spacing: 4,
children: [ children: [
if (groupStatus?.isFirst != false) if (!isGrouped)
Row( Row(
spacing: 4, spacing: 4,
children: [ children: [
Flexible( Flexible(
child: MessageDisplayname( child: MessageDisplayname(
message, event,
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
), ),
if (message.deliveredAt != null && Tooltip(
groupStatus?.isFirst != false) message: event.timestamp.toString(),
Tooltip( child: Text(
message: message.deliveredAt!.toString(), format(event.timestamp),
child: Text( style: theme.textTheme.labelSmall?.copyWith(
format(message.deliveredAt!), color: Colors.grey,
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.grey,
),
), ),
), ),
),
], ],
), ),
child, child,
if (error != null && error != "not sent") if (event.sendError != null && event.sendError != "not sent")
Text( Text(
error, event.sendError!,
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.error, color: theme.colorScheme.error,
), ),
), ),
ReactionRow(message), ReactionRow(event),
], ],
), ),
), ),

View file

@ -1,5 +1,4 @@
import "package:cross_cache/cross_cache.dart"; import "package:cross_cache/cross_cache.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
@ -10,106 +9,110 @@ import "package:nexus/controllers/selected_room_controller.dart";
import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/models/event.dart";
class ReactionRow extends ConsumerWidget { class ReactionRow extends ConsumerWidget {
final Message message; final Event event;
const ReactionRow(this.message, {super.key}); const ReactionRow(this.event, {super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final clientState = ref.watch(ClientStateController.provider); final clientState = ref.watch(ClientStateController.provider);
return Wrap( return SizedBox.shrink();
spacing: 4,
runSpacing: 4,
children: clientState?.homeserverUrl == null || message.reactions == null
? []
: message.reactions!
.mapTo(
(reaction, reactors) => HookBuilder(
builder: (context) {
final enabled = useState(true);
final selected = reactors.contains(clientState!.userId);
return Tooltip(
message: reactors.join(", "),
child: ChoiceChip(
showCheckmark: false,
selected: selected,
label: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Flexible(
child: reaction.startsWith("mxc://")
? Image(
height: 20,
image: CachedNetworkImage(
headers: ref.headers,
Uri.parse(reaction)
.mxcToHttps(
clientState.homeserverUrl!,
)
.toString(),
ref.watch(
CrossCacheController.provider,
),
),
)
: Text(
reaction,
overflow: TextOverflow.ellipsis,
),
),
Text(
reactors.length.toString(),
overflow: TextOverflow.ellipsis,
),
],
),
onSelected: enabled.value
? (value) async {
enabled.value = false;
try {
final roomId = ref.watch(
SelectedRoomController.provider.select(
(value) => value?.metadata?.id,
),
);
if (roomId == null ||
clientState.userId == null) {
return;
}
final controller = ref.watch( // TODO: IMPL
RoomChatController.provider( // return Wrap(
roomId, // spacing: 4,
).notifier, // runSpacing: 4,
); // children: clientState?.homeserverUrl == null
// ? []
// : event.reactions
// .mapTo(
// (reaction, reactors) => HookBuilder(
// builder: (context) {
// final enabled = useState(true);
// final selected = reactors.contains(clientState!.userId);
// return Tooltip(
// message: reactors.join(", "),
// child: ChoiceChip(
// showCheckmark: false,
// selected: selected,
// label: Row(
// mainAxisSize: MainAxisSize.min,
// spacing: 8,
// children: [
// Flexible(
// child: reaction.startsWith("mxc://")
// ? Image(
// height: 20,
// image: CachedNetworkImage(
// headers: ref.headers,
// Uri.parse(reaction)
// .mxcToHttps(
// clientState.homeserverUrl!,
// )
// .toString(),
// ref.watch(
// CrossCacheController.provider,
// ),
// ),
// )
// : Text(
// reaction,
// overflow: TextOverflow.ellipsis,
// ),
// ),
// Text(
// reactors.length.toString(),
// overflow: TextOverflow.ellipsis,
// ),
// ],
// ),
// onSelected: enabled.value
// ? (value) async {
// enabled.value = false;
// try {
// final roomId = ref.watch(
// SelectedRoomController.provider.select(
// (value) => value?.metadata?.id,
// ),
// );
// if (roomId == null ||
// clientState.userId == null) {
// return;
// }
if (selected) { // final controller = ref.watch(
await controller // RoomChatController.provider(
.removeReaction( // roomId,
reaction, // ).notifier,
message, // );
clientState.userId!,
) // if (selected) {
.onError(showError); // await controller
} else { // .removeReaction(
await controller // reaction,
.sendReaction(reaction, message) // event,
.onError(showError); // clientState.userId!,
} // )
} finally { // .onError(showError);
enabled.value = true; // } else {
} // await controller
} // .sendReaction(reaction, event)
: null, // .onError(showError);
), // }
); // } finally {
}, // enabled.value = true;
), // }
) // }
.toList(), // : null,
); // ),
// );
// },
// ),
// )
// .toList(),
// );
} }
} }

View file

@ -1196,6 +1196,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
super_sliver_list:
dependency: "direct main"
description:
name: super_sliver_list
sha256: b1e1e64d08ce40e459b9bb5d9f8e361617c26b8c9f3bb967760b0f436b6e3f56
url: "https://pub.dev"
source: hosted
version: "0.4.1"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:

View file

@ -52,6 +52,7 @@ dependencies:
git: git:
url: https://github.com/Henry-Hiles/emoji_text_field url: https://github.com/Henry-Hiles/emoji_text_field
flutter_blurhash: ^0.9.1 flutter_blurhash: ^0.9.1
super_sliver_list: ^0.4.1
dev_dependencies: dev_dependencies:
build_runner: 2.15.0 build_runner: 2.15.0