Merge branch 'main' into android

This commit is contained in:
zaaach 2026-03-23 22:52:57 -04:00
commit cf2150466e
27 changed files with 423 additions and 312 deletions

220
README.md
View file

@ -15,113 +15,115 @@ A simple and user-friendly Matrix client made with Flutter and the Matrix Dart S
## Progress ## Progress
- [ ] New logo - [ ] New logo
- [ ] Make context menus appear as bottom sheets on mobile - [ ] Make context menus appear as bottom sheets on mobile
- [x] Move from the Dart SDK to the Gomuks SDK with Dart bindings: https://git.federated.nexus/Henry-Hiles/nexus/pulls/2 - [x] Move from the Dart SDK to the Gomuks SDK with Dart bindings: https://git.federated.nexus/Henry-Hiles/nexus/pulls/2
- [ ] Allow using remote gomuks over websocket - [ ] Allow using remote gomuks over websocket
- [ ] Platform Support - [ ] Platform Support
- [x] Linux - [x] Linux
- [x] Windows - [x] Windows
- [ ] MacOS - [ ] MacOS
- [ ] Android - [ ] Android
- [ ] iOS - [ ] iOS
- [ ] Web (may not be possible) - [ ] Web (may not be possible)
- [x] Login - [x] Login
- [x] Username / password auth - [x] Username / password auth
- [ ] OAuth / OIDC - [ ] OAuth / OIDC
- [x] Improve initial sync experience - [x] Improve initial sync experience
- [x] Rooms / Spaces - [x] Rooms / Spaces
- [x] Displaying and choosing - [x] Displaying and choosing
- [x] Reading, showing unread - [x] Reading, showing unread
- [x] Mark as read button on rooms and spaces - [x] Mark as read button on rooms and spaces
- [ ] Searching - [ ] Searching
- [ ] Creating (Rooms, Spaces, and DMs) - [ ] Creating (Rooms, Spaces, and DMs)
- [x] Joining - [x] Joining
- [ ] Parse vias - [ ] Parse vias
- [x] Using a text/uri/link - [x] Using a text/uri/link
- [x] Plain text - [x] Plain text
- [x] `matrix:` Uri - [x] `matrix:` Uri
- [x] Matrix.to link - [x] Matrix.to link
- [ ] From space - [ ] From space
- [ ] Exploring - [ ] Exploring
- [x] Leaving - [x] Leaving
- [x] Subspaces - [x] Subspaces
- [x] Messages - [x] Messages
- [x] Encryption - [x] Encryption
- [x] Restoring crypto identity from a recovery passphrase/key - [x] Restoring crypto identity from a recovery passphrase/key
- [x] Sending - [x] Sending
- [x] Plain text - [x] Plain text
- [x] HTML/Markdown - [x] HTML/Markdown
- [x] Replies - [x] Replies
- [x] Choose ping on/off - [x] Choose ping on/off
- [ ] Attachments - [ ] Per message profiles
- [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391) - [ ] Attachments
- [x] Mentions - [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391)
- [x] Users - [x] Mentions
- [x] Rooms - [x] Users
- [ ] Inline emoji picker (Putting this here since it'll be implemented the same way as mentions) - [x] Rooms
- [ ] Custom emojis/stickers - [ ] Inline emoji picker (Putting this here since it'll be implemented the same way as mentions)
- [ ] GIFs using Gomuks' GIF proxies - [ ] Custom emojis/stickers
- [x] Recieving - [ ] GIFs using Gomuks' GIF proxies
- [x] Plain text - [x] Recieving
- [x] HTML - [x] Plain text
- [x] Replies - [x] Per message profiles
- [x] Viewing - [x] HTML
- [ ] Jump to original message - [x] Replies
- [x] In loaded timeline - [x] Viewing
- [ ] Out of loaded timeline - [ ] Jump to original message
- [x] Edits - [x] In loaded timeline
- [x] Attachments - [ ] Out of loaded timeline
- [x] Unencrypted - [x] Edits
- [ ] Encrypted - [x] Attachments
- [x] Blurhashing - [x] Unencrypted
- [ ] Downloading attachments - [ ] Encrypted
- [x] Opening attachments in their own view - [x] Blurhashing
- [ ] Polls: Waiting on https://github.com/SwanFlutter/dynamic_polls/issues/1 - [ ] Downloading attachments
- [x] Mentions - [x] Opening attachments in their own view
- [x] Users - [ ] Polls: Waiting on https://github.com/SwanFlutter/dynamic_polls/issues/1
- [x] Rooms - [x] Mentions
- [ ] Plain text (not sure if I want to add this or not, I probably won't unless there's interest) - [x] Users
- [x] Matrix URIs - [x] Rooms
- [x] Matrix.to links - [ ] Plain text (not sure if I want to add this or not, I probably won't unless there's interest)
- [ ] Do some fancy fetching to get nice names - [x] Matrix URIs
- [ ] Make clickable - [x] Matrix.to links
- [x] Custom emojis/stickers - [ ] Do some fancy fetching to get nice names
- [x] History loading - [ ] Make clickable
- [x] Backwards - [x] Custom emojis/stickers
- [ ] Forwards - [x] History loading
- [x] Editing - [x] Backwards
- [x] Deleting - [ ] Forwards
- [ ] Reactions: Waiting on https://github.com/flyerhq/flutter_chat_ui/pull/838 or me doing a custom impl - [x] Editing
- [ ] Pins - [x] Deleting
- [ ] Displaying - [ ] Reactions: Waiting on https://github.com/flyerhq/flutter_chat_ui/pull/838 or me doing a custom impl
- [ ] Creating - [ ] Pins
- [ ] Threads - [ ] Displaying
- [ ] Profile popouts - [ ] Creating
- [ ] Copy link to [room, space] - [ ] Threads
- [ ] Reporting - [ ] Profile popouts
- [x] Events - [ ] Copy link to [room, space]
- [ ] Rooms - [ ] Reporting
- [ ] Notifications using UnifiedPush - [x] Events
- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195) - [ ] Rooms
- [ ] Invites - [ ] Notifications using UnifiedPush
- [ ] Settings - [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195)
- [ ] Light/Dark mode - [ ] Invites
- [ ] SSD or CSD - [ ] Settings
- [ ] Show media by default - [ ] Light/Dark mode
- [ ] Dynamic Theming - [ ] SSD or CSD
- [ ] Devices - [ ] Show media by default
- [ ] Viewing devices - [ ] Dynamic Theming
- [ ] Verifying devices - [ ] Devices
- [ ] URL preview: Server / Client / None - [ ] Viewing devices
- [ ] Account changes - [ ] Verifying devices
- [ ] Display name - [ ] URL preview: Server / Client / None
- [ ] Profile picture - [ ] Account changes
- [ ] Timezone - [ ] Display name
- [ ] Pronouns - [ ] Profile picture
- [ ] Password - [ ] Timezone
- [ ] About - [ ] Pronouns
- [x] Log Out - [ ] Password
- [ ] About
- [x] Log Out
## Build Instructions ## Build Instructions
@ -136,8 +138,8 @@ cd nexus
#### Linux #### Linux
- With Nix: Either use direnv, or `nix flake develop` - With Nix: Either use direnv, or `nix flake develop`
- Without Nix: Install Flutter, Go, Olm, Git, Clang, and GLibc. - Without Nix: Install Flutter, Go, Olm, Git, Clang, and GLibc.
#### Windows / MacOS #### Windows / MacOS

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

View file

@ -0,0 +1,44 @@
import "dart:async";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/members_controller.dart";
import "package:nexus/models/configs/author_config.dart";
import "package:nexus/models/membership.dart";
class AuthorController extends AsyncNotifier<Membership> {
final AuthorConfig config;
AuthorController(this.config);
@override
Future<Membership> build() async {
var member = await ref.watch(
MembersController.provider(config.room).selectAsync(
(value) => value.firstWhereOrNull(
(membership) => membership.userId == config.message.authorId,
),
),
);
final pmp = config.message.metadata?["pmp"] == null
? null
: Membership.fromContent(
IMap(config.message.metadata?["pmp"]),
config.message.authorId,
);
return Membership(
avatarUrl: pmp?.avatarUrl ?? member?.avatarUrl,
displayName:
pmp?.displayName ??
member?.displayName ??
config.message.authorId.substring(1).split(":").first,
userId: config.message.authorId,
);
}
static final provider = AsyncNotifierProvider.family
.autoDispose<AuthorController, Membership, AuthorConfig>(
AuthorController.new,
);
}

View file

@ -1,25 +1,39 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/event.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/models/membership.dart";
import "package:nexus/models/requests/get_room_state_request.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
class MembersController extends Notifier<IList<Event>> { class MembersController extends AsyncNotifier<IList<Membership>> {
final Room room; final Room room;
MembersController(this.room); MembersController(this.room);
@override @override
IList<Event> build() => (room.state["m.room.member"]?.values ?? []) Future<IList<Membership>> build() async {
.map( if (room.metadata == null) return const IList.empty();
(eventRowId) =>
room.events.firstWhereOrNull((event) => event.rowId == eventRowId),
)
.nonNulls
.where((member) => member.content["membership"] == "join")
.toIList();
static final provider = NotifierProvider.family final state = await ref
.autoDispose<MembersController, IList<Event>, Room>( .watch(ClientController.provider.notifier)
.getRoomState(
GetRoomStateRequest(
roomId: room.metadata!.id,
fetchMembers: room.metadata!.hasMemberList == false,
includeMembers: true,
),
);
return state.nonNulls
.where((member) => member.content["membership"] == "join")
.map(
(membership) =>
Membership.fromContent(membership.content, membership.stateKey!),
)
.toIList();
}
static final provider =
AsyncNotifierProvider.family<MembersController, IList<Membership>, Room>(
MembersController.new, MembersController.new,
); );
} }

View file

@ -2,9 +2,8 @@ import "package:collection/collection.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/members_controller.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart";
import "package:nexus/models/message_config.dart"; import "package:nexus/models/configs/message_config.dart";
class MessageController extends AsyncNotifier<Message?> { class MessageController extends AsyncNotifier<Message?> {
final MessageConfig config; final MessageConfig config;
@ -27,12 +26,6 @@ class MessageController extends AsyncNotifier<Message?> {
if (!ref.mounted) return null; if (!ref.mounted) return null;
final members = ref.read(MembersController.provider(config.room));
final author = members.firstWhereOrNull(
(member) => member.stateKey == event.authorId,
);
if (!ref.mounted) return null;
final content = (event.decrypted ?? event.content); final content = (event.decrypted ?? event.content);
final type = (config.event.decryptedType ?? config.event.type); final type = (config.event.decryptedType ?? config.event.type);
final newContent = content["m.new_content"] as Map?; final newContent = content["m.new_content"] as Map?;
@ -52,14 +45,11 @@ class MessageController extends AsyncNotifier<Message?> {
"timelineId": event.timelineRowId, "timelineId": event.timelineRowId,
"big": event.localContent?.bigEmoji == true, "big": event.localContent?.bigEmoji == true,
"eventType": type, "eventType": type,
"avatarUrl": author?.content["avatar_url"], "pmp": event.content["com.beeper.per_message_profile"],
"editSource": "editSource":
event.localContent?.editSource ?? event.localContent?.editSource ??
newContent?["body"] ?? newContent?["body"] ??
content["body"], content["body"],
"displayName": author?.content["displayname"]?.isNotEmpty == true
? author?.content["displayname"]
: event.authorId.substring(1).split(":")[0],
"txnId": config.event.transactionId, "txnId": config.event.transactionId,
}; };

View file

@ -2,8 +2,8 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/message_controller.dart"; import "package:nexus/controllers/message_controller.dart";
import "package:nexus/models/message_config.dart"; import "package:nexus/models/configs/message_config.dart";
import "package:nexus/models/messages_config.dart"; import "package:nexus/models/configs/messages_config.dart";
class MessagesController extends AsyncNotifier<IList<Message>> { class MessagesController extends AsyncNotifier<IList<Message>> {
final MessagesConfig config; final MessagesConfig config;

View file

@ -1,5 +1,4 @@
import "dart:async"; import "dart:async";
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
@ -11,8 +10,8 @@ import "package:nexus/controllers/message_controller.dart";
import "package:nexus/controllers/messages_controller.dart"; import "package:nexus/controllers/messages_controller.dart";
import "package:nexus/controllers/new_events_controller.dart"; import "package:nexus/controllers/new_events_controller.dart";
import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/models/message_config.dart"; import "package:nexus/models/configs/messages_config.dart";
import "package:nexus/models/messages_config.dart"; import "package:nexus/models/configs/message_config.dart";
import "package:nexus/models/requests/get_room_state_request.dart"; import "package:nexus/models/requests/get_room_state_request.dart";
import "package:nexus/models/requests/paginate_request.dart"; import "package:nexus/models/requests/paginate_request.dart";
import "package:nexus/models/requests/redact_event_request.dart"; import "package:nexus/models/requests/redact_event_request.dart";
@ -31,11 +30,7 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
if (room == null) return InMemoryChatController(); if (room == null) return InMemoryChatController();
final state = await client.getRoomState( final state = await client.getRoomState(
GetRoomStateRequest( GetRoomStateRequest(roomId: roomId),
roomId: roomId,
fetchMembers: room.metadata?.hasMemberList == false,
includeMembers: true,
),
); );
ref ref

View file

@ -36,6 +36,7 @@ class RoomsController extends Notifier<IMap<String, Room>> {
return acc.add( return acc.add(
roomId, roomId,
existing?.copyWith( existing?.copyWith(
hasMore: incoming.hasMore,
metadata: incoming.metadata ?? existing.metadata, metadata: incoming.metadata ?? existing.metadata,
events: events!, events: events!,
state: incoming.state.entries.fold( state: incoming.state.entries.fold(

View file

@ -0,0 +1,14 @@
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/room.dart";
part "author_config.freezed.dart";
part "author_config.g.dart";
@freezed
abstract class AuthorConfig with _$AuthorConfig {
const factory AuthorConfig({required Message message, required Room room}) =
_AuthorConfig;
factory AuthorConfig.fromJson(Map<String, Object?> json) =>
_$AuthorConfigFromJson(json);
}

View file

@ -0,0 +1,22 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
part "membership.freezed.dart";
@freezed
abstract class Membership with _$Membership {
const Membership._();
const factory Membership({
required Uri? avatarUrl,
required String displayName,
required String userId,
}) = _Membership;
factory Membership.fromContent(
IMap<String, dynamic> content,
String userId,
) => Membership(
avatarUrl: Uri.tryParse(content["avatar_url"] ?? ""),
userId: userId,
displayName: content["displayname"] ?? userId.substring(1).split(":").first,
);
}

View file

@ -6,7 +6,7 @@ part "get_room_state_request.g.dart";
abstract class GetRoomStateRequest with _$GetRoomStateRequest { abstract class GetRoomStateRequest with _$GetRoomStateRequest {
const factory GetRoomStateRequest({ const factory GetRoomStateRequest({
required String roomId, required String roomId,
required bool fetchMembers, @Default(false) bool fetchMembers,
@Default(false) bool includeMembers, @Default(false) bool includeMembers,
}) = _GetRoomStateRequest; }) = _GetRoomStateRequest;

View file

@ -8,8 +8,8 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/controllers/room_chat_controller.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/widgets/chat_page/mention_overlay.dart"; import "package:nexus/widgets/chat_page/composer/mention_overlay.dart";
import "package:nexus/widgets/chat_page/relation_preview.dart"; import "package:nexus/widgets/chat_page/composer/relation_preview.dart";
class ChatBox extends HookConsumerWidget { class ChatBox extends HookConsumerWidget {
final Message? relatedMessage; final Message? relatedMessage;
@ -86,10 +86,11 @@ class ChatBox extends HookConsumerWidget {
child: Column( child: Column(
children: [ children: [
RelationPreview( RelationPreview(
relatedMessage,
room: room,
shouldMention: shouldMention.value, shouldMention: shouldMention.value,
toggleShouldMention: () => toggleShouldMention: () =>
shouldMention.value = !shouldMention.value, shouldMention.value = !shouldMention.value,
relatedMessage: relatedMessage,
relationType: relationType, relationType: relationType,
onDismiss: onDismiss, onDismiss: onDismiss,
), ),

View file

@ -2,6 +2,7 @@ import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/members_controller.dart"; import "package:nexus/controllers/members_controller.dart";
import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
@ -31,55 +32,47 @@ class MentionOverlay extends ConsumerWidget {
color: Theme.of(context).colorScheme.surfaceContainerHigh, color: Theme.of(context).colorScheme.surfaceContainerHigh,
padding: EdgeInsets.all(8), padding: EdgeInsets.all(8),
child: switch (triggerCharacter) { child: switch (triggerCharacter) {
"@" => Consumer( "@" =>
builder: (_, ref, _) { ref
final members = ref.watch(MembersController.provider(room)); .watch(MembersController.provider(room))
return ListView( .betterWhen(
children: data: (members) => ListView(
(query.isEmpty children:
? members (query.isEmpty
: members.where( ? members
(member) => : members.where(
member.stateKey?.toLowerCase().contains( (member) =>
query.toLowerCase(), member.userId.toLowerCase().contains(
) == query.toLowerCase(),
true || ) ==
(member.content["displayname"] as String?) true ||
?.toLowerCase() member.displayName
.contains(query.toLowerCase()) == .toLowerCase()
true, .contains(
)) query.toLowerCase(),
.map( ) ==
(member) => ListTile( true,
leading: AvatarOrHash( ))
Uri.tryParse( .map(
member.content["avatar_url"] ?? "", (member) => ListTile(
), leading: AvatarOrHash(
member.content["displayname"] ?? "", member.avatarUrl,
), member.displayName,
title: Text( ),
member.content["displayname"] as String? ?? title: Text(member.displayName),
member.stateKey ?? subtitle: Text(member.userId),
"Unknown User", onTap: () => addTag(
), id: "[@${member.displayName}](https://matrix.to/#/${member.userId})",
subtitle: member.stateKey != null name: member.userId
? Text(member.stateKey!) .substring(1)
: null,
onTap: () => addTag(
id: "[@${member.content["displayname"]}](https://matrix.to/#/${member.stateKey})",
name:
member.stateKey
?.substring(1)
.split(":") .split(":")
.first ?? .first,
"Unknown User", ),
), ),
), )
) .toList(),
.toList(), ),
); ),
},
),
"#" => ListView( "#" => ListView(
children: children:
(query.isEmpty (query.isEmpty

View file

@ -2,7 +2,9 @@ import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/models/room.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart";
class RelationPreview extends ConsumerWidget { class RelationPreview extends ConsumerWidget {
final Message? relatedMessage; final Message? relatedMessage;
@ -10,8 +12,11 @@ class RelationPreview extends ConsumerWidget {
final VoidCallback onDismiss; final VoidCallback onDismiss;
final bool shouldMention; final bool shouldMention;
final VoidCallback toggleShouldMention; final VoidCallback toggleShouldMention;
const RelationPreview({ final Room room;
required this.relatedMessage,
const RelationPreview(
this.relatedMessage, {
required this.room,
required this.relationType, required this.relationType,
required this.onDismiss, required this.onDismiss,
required this.shouldMention, required this.shouldMention,
@ -36,14 +41,10 @@ class RelationPreview extends ConsumerWidget {
"Editing message:", "Editing message:",
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
AvatarOrHash( MessageAvatar(relatedMessage!, room),
Uri.tryParse(relatedMessage?.metadata?["avatarUrl"] ?? ""), MessageDisplayname(
relatedMessage?.metadata?["displayName"]?.toString() ?? "", relatedMessage!,
height: 16, room,
),
Text(
relatedMessage!.metadata?["displayName"] ??
relatedMessage!.authorId,
style: theme.textTheme.labelMedium?.copyWith( style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),

View file

@ -22,6 +22,10 @@ class Html extends ConsumerWidget {
html, html,
textStyle: textStyle, textStyle: textStyle,
customWidgetBuilder: (element) { customWidgetBuilder: (element) {
if (element.attributes.keys.contains("data-mx-profile-fallback")) {
return SizedBox.shrink();
}
if (element.attributes.keys.contains("data-mx-spoiler")) { if (element.attributes.keys.contains("data-mx-spoiler")) {
return InlineCustomWidget(child: SpoilerText(text: element.text)); return InlineCustomWidget(child: SpoilerText(text: element.text));
} }

View file

@ -0,0 +1,30 @@
import "package:flutter/widgets.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/author_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/configs/author_config.dart";
import "package:nexus/models/room.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class MessageAvatar extends ConsumerWidget {
final Message message;
final Room room;
final double height;
const MessageAvatar(this.message, this.room, {this.height = 16, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => ref
.watch(
AuthorController.provider(AuthorConfig(room: room, message: message)),
)
.betterWhen(
data: (membership) => AvatarOrHash(
membership.avatarUrl,
membership.displayName,
height: height,
),
loading: () =>
AvatarOrHash(null, message.authorId.substring(1), height: height),
);
}

View file

@ -0,0 +1,28 @@
import "package:flutter/widgets.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/author_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/configs/author_config.dart";
import "package:nexus/models/room.dart";
class MessageDisplayname extends ConsumerWidget {
final Message message;
final Room room;
final TextStyle? style;
const MessageDisplayname(this.message, this.room, {this.style, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => ref
.watch(
AuthorController.provider(AuthorConfig(room: room, message: message)),
)
.betterWhen(
data: (membership) => Text(
"${membership.displayName} ${message.metadata?["pmp"] == null ? "" : "(via ${message.authorId})"}",
style: style,
overflow: TextOverflow.ellipsis,
),
loading: () => Text(""),
);
}

View file

@ -1,6 +1,7 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/members_controller.dart"; import "package:nexus/controllers/members_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
@ -10,15 +11,17 @@ class MemberList extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final members = ref.watch(MembersController.provider(room)); final membersProvider = ref.watch(MembersController.provider(room));
return Drawer( return Drawer(
shape: Border(), shape: Border(),
child: ListView( child: Column(
children: [ children: [
AppBar( AppBar(
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
leading: Icon(Icons.people), leading: Icon(Icons.people),
title: Text("Members (${members.length})"), title: Text(
"Members ${membersProvider.when(data: (members) => "${members.length}", error: (_, _) => "", loading: () => "")}",
),
actionsPadding: EdgeInsets.only(right: 4), actionsPadding: EdgeInsets.only(right: 4),
actions: [ actions: [
if (Scaffold.of(context).hasEndDrawer) if (Scaffold.of(context).hasEndDrawer)
@ -29,24 +32,32 @@ class MemberList extends ConsumerWidget {
), ),
], ],
), ),
...members.map( membersProvider.betterWhen(
(member) => ListTile( data: (members) => Expanded(
onTap: () => showDialog( child: ListView(
context: context, children: members
builder: (context) => .map(
Dialog(child: Text("TODO: Open member popover")), (member) => ListTile(
), onTap: () => showDialog(
leading: AvatarOrHash( context: context,
Uri.tryParse(member.content["avatar_url"] ?? ""), builder: (context) =>
member.content["displayname"].toString(), Dialog(child: Text("TODO: Open member popover")),
), ),
title: Text( leading: AvatarOrHash(
member.content["displayname"].toString(), member.avatarUrl,
overflow: TextOverflow.ellipsis, member.displayName,
), ),
subtitle: Text( title: Text(
member.stateKey ?? "Unknown User", member.displayName,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
),
subtitle: Text(
member.userId,
overflow: TextOverflow.ellipsis,
),
),
)
.toList(),
), ),
), ),
), ),

View file

@ -1,15 +1,15 @@
import "dart:math";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/event_controller.dart"; import "package:nexus/controllers/event_controller.dart";
import "package:nexus/controllers/message_controller.dart"; import "package:nexus/controllers/message_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/message_config.dart"; import "package:nexus/models/configs/message_config.dart";
import "package:nexus/models/requests/get_event_request.dart"; import "package:nexus/models/requests/get_event_request.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/chat_page/html/quoted.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)?; typedef OnTapReply = void Function(Message message)?;
@ -61,73 +61,28 @@ class ReplyWidget extends ConsumerWidget {
return SizedBox.shrink(); return SizedBox.shrink();
} }
final smallerText =
message is TextMessage &&
replyMessage.metadata?["body"] != null
? replyMessage.metadata!["body"].substring(
0,
min(
max(
max(
(message as TextMessage)
.text
.length -
(replyMessage
.metadata?["displayName"]
as String)
.length -
5,
message
.metadata?["displayName"]
.length,
),
5,
),
replyMessage.metadata!["body"].length,
),
)
: null;
final replyText =
(smallerText == null ||
smallerText.length ==
replyMessage
.metadata!["body"]
.length)
? replyMessage.metadata!["body"]
: "$smallerText...";
return InkWell( return InkWell(
onTap: () => onTapReply?.call(replyMessage), onTap: () => onTapReply?.call(replyMessage),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
spacing: 8, spacing: 8,
children: [ children: [
AvatarOrHash( MessageAvatar(replyMessage, room),
Uri.tryParse(
replyMessage.metadata?["avatarUrl"] ??
"",
),
replyMessage.metadata?["displayName"] ??
"",
height: 16,
),
Flexible( Flexible(
child: Text( child: MessageDisplayname(
replyMessage replyMessage,
.metadata?["displayName"] ?? room,
replyMessage.authorId,
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.labelMedium .labelMedium
?.copyWith( ?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
overflow: TextOverflow.ellipsis,
), ),
), ),
Flexible( Flexible(
child: Text( child: Text(
replyText, replyMessage.metadata!["body"],
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of( style: Theme.of(
context, context,

View file

@ -13,15 +13,14 @@ import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/helpers/extensions/show_context_menu.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/chat_box.dart"; import "package:nexus/widgets/chat_page/composer/chat_box.dart";
import "package:nexus/widgets/chat_page/image_message.dart"; import "package:nexus/widgets/chat_page/image_message.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/message_wrapper.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/text_message_wrapper.dart"; import "package:nexus/widgets/chat_page/wrappers/text_message_wrapper.dart";
import "package:nexus/widgets/chat_page/reply_widget.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/widgets/loading.dart";
// import "package:dynamic_polls/dynamic_polls.dart"; // import "package:dynamic_polls/dynamic_polls.dart";
class RoomChat extends HookConsumerWidget { class RoomChat extends HookConsumerWidget {
@ -233,7 +232,7 @@ class RoomChat extends HookConsumerWidget {
children: getMessageOptions(message), children: getMessageOptions(message),
), ),
builders: Builders( builders: Builders(
loadMoreBuilder: (_) => Loading(), loadMoreBuilder: (_) => SizedBox.shrink(),
chatAnimatedListBuilder: (_, itemBuilder) => chatAnimatedListBuilder: (_, itemBuilder) =>
ChatAnimatedList( ChatAnimatedList(
@ -320,6 +319,7 @@ class RoomChat extends HookConsumerWidget {
), ),
), ),
groupStatus, groupStatus,
room,
), ),
systemMessageBuilder: systemMessageBuilder:

View file

@ -1,12 +1,21 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/models/room.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart";
class MessageWrapper extends StatelessWidget { class MessageWrapper extends StatelessWidget {
final Message message; final Message message;
final Widget child; final Widget child;
final Room room;
final MessageGroupStatus? groupStatus; final MessageGroupStatus? groupStatus;
const MessageWrapper(this.message, this.child, this.groupStatus, {super.key}); const MessageWrapper(
this.message,
this.child,
this.groupStatus,
this.room, {
super.key,
});
@override @override
Widget build(BuildContext context) => ClipRRect( Widget build(BuildContext context) => ClipRRect(
@ -24,11 +33,7 @@ class MessageWrapper extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
groupStatus?.isFirst != false groupStatus?.isFirst != false
? AvatarOrHash( ? MessageAvatar(message, room, height: 40)
Uri.parse(message.metadata?["avatarUrl"] ?? ""),
height: 40,
message.metadata?["displayName"] ?? "",
)
: SizedBox(width: 40), : SizedBox(width: 40),
Expanded( Expanded(
child: Column( child: Column(
@ -36,9 +41,9 @@ class MessageWrapper extends StatelessWidget {
spacing: 4, spacing: 4,
children: [ children: [
if (groupStatus?.isFirst != false) if (groupStatus?.isFirst != false)
Text( MessageDisplayname(
message.metadata?["displayName"] ?? message.authorId, message,
overflow: TextOverflow.ellipsis, room,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),

View file

@ -3,7 +3,7 @@ import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_link_previewer/flutter_link_previewer.dart"; import "package:flutter_link_previewer/flutter_link_previewer.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/widgets/chat_page/html/html.dart"; import "package:nexus/widgets/chat_page/html/html.dart";
import "package:nexus/widgets/chat_page/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/chat_page/reply_widget.dart";
class TextMessageWrapper extends StatelessWidget { class TextMessageWrapper extends StatelessWidget {
@ -109,6 +109,7 @@ class TextMessageWrapper extends StatelessWidget {
), ),
), ),
groupStatus, groupStatus,
room,
); );
} }
} }

View file

@ -29,10 +29,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: analyzer_buffer name: analyzer_buffer
sha256: aba2f75e63b3135fd1efaa8b6abefe1aa6e41b6bd9806221620fa48f98156033 sha256: "5fcd06b0715ebeee99f03e3f437b3412249969d8d12b191ea8a1d76e42a4e4a1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.11" version: "0.3.1"
analyzer_plugin: analyzer_plugin:
dependency: transitive dependency: transitive
description: description:
@ -521,10 +521,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_riverpod name: flutter_riverpod
sha256: "38ec6c303e2c83ee84512f5fc2a82ae311531021938e63d7137eccc107bf3c02" sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0" version: "3.3.1"
flutter_svg: flutter_svg:
dependency: "direct main" dependency: "direct main"
description: description:
@ -643,10 +643,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: hooks_riverpod name: hooks_riverpod
sha256: b880efcd17757af0aa242e5dceac2fb781a014c22a32435a5daa8f17e9d5d8a9 sha256: "08527ec06aaef75e4b78694e045ef0cd8346594eaf9cc18b0f866398b07b93b1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0" version: "3.3.1"
html: html:
dependency: transitive dependency: transitive
description: description:
@ -1051,26 +1051,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: riverpod name: riverpod
sha256: "16ff608d21e8ea64364f2b7c049c94a02ab81668f78845862b6e88b71dd4935a" sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0" version: "3.2.1"
riverpod_analyzer_utils: riverpod_analyzer_utils:
dependency: transitive dependency: transitive
description: description:
name: riverpod_analyzer_utils name: riverpod_analyzer_utils
sha256: "947b05d04c52a546a2ac6b19ef2a54b08520ff6bdf9f23d67957a4c8df1c3bc0" sha256: e55bc08c084a424e1bbdc303fe8ea75daafe4269b68fd0e0f6f1678413715b66
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0-dev.8" version: "1.0.0-dev.9"
riverpod_lint: riverpod_lint:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: riverpod_lint name: riverpod_lint
sha256: "4d2eb0d19bbe7e3323bd0ce4553b2e6170d161a13914bfdd85a3612329edcb43" sha256: "64e8debf5b719a37d48b9785dd595d34133fdcd84b8fd07157a621c54ab2156f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0" version: "3.1.3"
rxdart: rxdart:
dependency: transitive dependency: transitive
description: description:

View file

@ -21,8 +21,8 @@ dependencies:
sdk: flutter sdk: flutter
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
flutter_riverpod: ^3.0.3 flutter_riverpod: ^3.3.1
hooks_riverpod: ^3.0.3 hooks_riverpod: ^3.3.1
intl: ^0.20.1 intl: ^0.20.1
fast_immutable_collections: ^11.0.0 fast_immutable_collections: ^11.0.0
path_provider: ^2.1.3 path_provider: ^2.1.3
@ -69,7 +69,7 @@ dev_dependencies:
custom_lint: ^0.8.0 custom_lint: ^0.8.0
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
freezed: ^3.2.3 freezed: ^3.2.3
riverpod_lint: ^3.0.3 riverpod_lint: ^3.1.3
flutter_launcher_icons: ^0.14.1 flutter_launcher_icons: ^0.14.1
json_serializable: ^6.11.1 json_serializable: ^6.11.1