Gomuks SDK Rewrite #2

Closed
Henry-Hiles wants to merge 34 commits from go into main
23 changed files with 885 additions and 805 deletions
Showing only changes of commit a28bced44d - Show all commits

shows room but not really

Henry Hiles 2026-01-27 19:09:43 +00:00
No known key found for this signature in database

View file

@ -11,6 +11,8 @@ import "package:nexus/controllers/top_level_spaces_controller.dart";
import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/models/client_state.dart"; import "package:nexus/models/client_state.dart";
import "package:nexus/models/login.dart"; import "package:nexus/models/login.dart";
import "package:nexus/models/report.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/sync_data.dart"; import "package:nexus/models/sync_data.dart";
import "package:nexus/models/sync_status.dart"; import "package:nexus/models/sync_status.dart";
import "package:nexus/src/third_party/gomuks.g.dart"; import "package:nexus/src/third_party/gomuks.g.dart";
@ -118,6 +120,23 @@ class ClientController extends AsyncNotifier<int> {
} }
} }
Future<void> leaveRoom(Room room) async {
if (room.metadata == null) return;
await sendCommand("leave_room", {"room_id": room.metadata!.id});
}
Future<void> reportEvent(Report report) =>
sendCommand("report_event", report.toJson());
Future<void> markRead(Room room) async {
if (room.events.isEmpty || room.metadata == null) return;
await sendCommand("mark_read", {
"room_id": room.metadata?.id,
"receipt_type": "m.read",
"event_id": room.events.last.eventId,
});
}
Future<bool> login(Login login) async { Future<bool> login(Login login) async {
try { try {
await sendCommand("login", login.toJson()); await sendCommand("login", login.toJson());

View file

@ -0,0 +1,24 @@
import "package:ffi/ffi.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/src/third_party/gomuks.g.dart";
class HeaderController extends AsyncNotifier<Map<String, String>> {
@override
Future<Map<String, String>> build() async {
final handle = await ref.watch(ClientController.provider.future);
final info = GomuksGetAccountInfo(handle);
final headers = {
"authorization":
"Bearer ${info.access_token.cast<Utf8>().toDartString()}",
};
GomuksFreeAccountInfo(info);
return headers;
}
static final provider =
AsyncNotifierProvider<HeaderController, Map<String, String>>(
HeaderController.new,
);
}

View file

@ -2,13 +2,9 @@ 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_chat_core/flutter_chat_core.dart" as chat; import "package:flutter_chat_core/flutter_chat_core.dart" as chat;
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart";
import "package:nexus/controllers/avatar_controller.dart";
import "package:nexus/controllers/events_controller.dart";
import "package:nexus/helpers/extensions/event_to_message.dart";
import "package:nexus/helpers/extensions/list_to_messages.dart";
import "package:fluttertagger/fluttertagger.dart" as tagger; import "package:fluttertagger/fluttertagger.dart" as tagger;
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/models/room.dart";
class RoomChatController extends AsyncNotifier<ChatController> { class RoomChatController extends AsyncNotifier<ChatController> {
final Room room; final Room room;
@ -16,52 +12,54 @@ class RoomChatController extends AsyncNotifier<ChatController> {
@override @override
Future<ChatController> build() async { Future<ChatController> build() async {
final timeline = await ref.watch(EventsController.provider(room).future); // final timeline = await ref.watch(EventsController.provider(room).future);
ref.onDispose( return InMemoryChatController();
room.client.onTimelineEvent.stream.listen((event) async {
if (event.roomId != room.id) return;
if (event.type == EventTypes.Redaction) { // ref.onDispose(
final controller = await future; // room.client.onTimelineEvent.stream.listen((event) async {
final message = controller.messages.firstWhereOrNull( // if (event.roomId != room.metadata.id) return;
(message) => message.id == event.redacts,
);
if (message == null) return;
await controller.removeMessage(message); // if (event.type == "m.room.redaction") {
} else { // final controller = await future;
final message = await event.toMessage(includeEdits: true, timeline); // final message = controller.messages.firstWhereOrNull(
if (event.relationshipType == RelationshipTypes.edit) { // (message) => message.id == event.redacts,
final controller = await future; // );
final oldMessage = controller.messages.firstWhereOrNull( // if (message == null) return;
(element) => element.id == event.relationshipEventId,
);
if (oldMessage == null || message == null) return;
return await updateMessage(
oldMessage,
message.copyWith(
id: oldMessage.id,
replyToMessageId: oldMessage.replyToMessageId,
metadata: {
...(oldMessage.metadata ?? {}),
...((message.metadata ?? {}).filterMap(
(key, value) => value == null ? null : MapEntry(key, value),
)),
},
),
);
}
if (message != null) {
return await insertMessage(message);
}
}
}).cancel,
);
return InMemoryChatController( // await controller.removeMessage(message);
messages: await timeline.events.toMessages(room, timeline), // } else {
); // final message = await event.toMessage(includeEdits: true, timeline);
// if (event.relationshipType == RelationshipTypes.edit) {
// final controller = await future;
// final oldMessage = controller.messages.firstWhereOrNull(
// (element) => element.id == event.relationshipEventId,
// );
// if (oldMessage == null || message == null) return;
// return await updateMessage(
// oldMessage,
// message.copyWith(
// id: oldMessage.id,
// replyToMessageId: oldMessage.replyToMessageId,
// metadata: {
// ...(oldMessage.metadata ?? {}),
// ...((message.metadata ?? {}).filterMap(
// (key, value) => value == null ? null : MapEntry(key, value),
// )),
// },
// ),
// );
// }
// if (message != null) {
// return await insertMessage(message);
// }
// }
// }).cancel,
// );
// return InMemoryChatController(
// messages: await timeline.events.toMessages(room, timeline),
// );
} }
Future<void> insertMessage(Message message) async { Future<void> insertMessage(Message message) async {
@ -79,37 +77,29 @@ class RoomChatController extends AsyncNotifier<ChatController> {
} }
Future<void> deleteMessage(Message message, {String? reason}) async { Future<void> deleteMessage(Message message, {String? reason}) async {
final controller = await future; // final controller = await future;
await controller.removeMessage(message); // await controller.removeMessage(message);
await room.redactEvent(message.id, reason: reason); // await room.redactEvent(message.id, reason: reason);
} }
Future<void> loadOlder() async { Future<void> loadOlder() async {
final currentEvents = await future; // final currentEvents = await future;
await ref.watch(EventsController.provider(room).notifier).prev(); // await ref.watch(EventsController.provider(room).notifier).prev();
final timeline = await ref.watch(EventsController.provider(room).future); // final timeline = await ref.watch(EventsController.provider(room).future);
final controller = await future; // final controller = await future;
await controller.insertAllMessages( // await controller.insertAllMessages(
await timeline.events // await timeline.events
.where( // .where(
(event) => !currentEvents.messages.any( // (event) => !currentEvents.messages.any(
(existingEvent) => existingEvent.id == event.eventId, // (existingEvent) => existingEvent.id == event.eventId,
), // ),
) // )
.toList() // .toList()
.toMessages(room, timeline), // .toMessages(room, timeline),
index: 0, // index: 0,
); // );
ref.notifyListeners(); // ref.notifyListeners();
}
Future<void> markRead() async {
if (!room.hasNewMessages) return;
final controller = await future;
final id = controller.messages.last.id;
await room.setReadMarker(id, mRead: id);
} }
Future<void> updateMessage(Message message, Message newMessage) async => Future<void> updateMessage(Message message, Message newMessage) async =>
@ -121,37 +111,37 @@ class RoomChatController extends AsyncNotifier<ChatController> {
required RelationType relationType, required RelationType relationType,
Message? relation, Message? relation,
}) async { }) async {
var taggedMessage = message; // var taggedMessage = message;
for (final tag in tags) { // for (final tag in tags) {
final escaped = RegExp.escape(tag.id); // final escaped = RegExp.escape(tag.id);
final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?"); // final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?");
taggedMessage = taggedMessage.replaceAllMapped( // taggedMessage = taggedMessage.replaceAllMapped(
pattern, // pattern,
(match) => match.group(1)!, // (match) => match.group(1)!,
); // );
} // }
await room.sendTextEvent( // await room.sendTextEvent(
taggedMessage, // taggedMessage,
editEventId: relationType == RelationType.edit ? relation?.id : null, // editEventId: relationType == RelationType.edit ? relation?.id : null,
inReplyTo: (relationType == RelationType.reply && relation != null) // inReplyTo: (relationType == RelationType.reply && relation != null)
? await room.getEventById(relation.id) // ? await room.getEventById(relation.id)
: null, // : null,
); // );
} }
Future<chat.User> resolveUser(String id) async { Future<chat.User> resolveUser(String id) async {
final user = await room.client.getUserProfile(id); // final user = await room.client.getUserProfile(id);
return chat.User( return chat.User(
id: id, id: id,
name: user.displayname, // name: user.displayname,
imageSource: user.avatarUrl == null // imageSource: user.avatarUrl == null
? null // ? null
: (await ref.watch( // : (await ref.watch(
AvatarController.provider(user.avatarUrl!.toString()).future, // AvatarController.provider(user.avatarUrl!.toString()).future,
)).toString(), // )).toString(),
); );
} }

View file

@ -15,35 +15,34 @@ class SpacesController extends Notifier<IList<Space>> {
final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider); final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider);
final spaceEdges = ref.watch(SpaceEdgesController.provider); final spaceEdges = ref.watch(SpaceEdgesController.provider);
ISet<String> collectChildIds(String spaceId) { final childRoomsBySpaceId = IMap.fromEntries(
ISet<String> result = ISet<String>();
void walk(String currentId) {
final children = spaceEdges[currentId] ?? IList<SpaceEdge>();
for (final edge in children) {
final childId = edge.childId;
if (!result.contains(childId)) {
result = result.add(childId);
walk(childId);
}
}
}
walk(spaceId);
return result;
}
final spaceIdToChildren = IMap.fromEntries(
topLevelSpaceIds.map((spaceId) { topLevelSpaceIds.map((spaceId) {
final children = collectChildIds( ISet<String> walk(String currentId) {
final children = spaceEdges[currentId] ?? IList<SpaceEdge>();
return children.fold<ISet<String>>(const ISet.empty(), (acc, edge) {
final childId = edge.childId;
final isSpace = spaceEdges.containsKey(childId);
return acc
.addAll(!isSpace ? ISet([childId]) : const ISet.empty())
.addAll(isSpace ? walk(childId) : const ISet.empty());
});
}
return MapEntry(
spaceId, spaceId,
).map((id) => rooms[id]).nonNulls.toIList(); walk(spaceId).map((id) => rooms[id]).nonNulls.toIList(),
return MapEntry(spaceId, children); );
}), }),
); );
final allNestedRoomIds = spaceIdToChildren.values final allNestedRoomIds = childRoomsBySpaceId.values
.expand((l) => l) .expand((l) => l)
.map((r) => rooms.entries.firstWhere((e) => e.value == r).key) .map(
(room) =>
rooms.entries.firstWhere((entry) => entry.value == room).key,
)
.toISet(); .toISet();
final dmRooms = rooms.values final dmRooms = rooms.values
@ -55,7 +54,8 @@ class SpacesController extends Notifier<IList<Space>> {
(e) => (e) =>
e.value.metadata?.dmUserId == null && e.value.metadata?.dmUserId == null &&
!allNestedRoomIds.contains(e.key) && !allNestedRoomIds.contains(e.key) &&
!topLevelSpaceIds.contains(e.key), !topLevelSpaceIds.contains(e.key) &&
!spaceEdges.containsKey(e.key),
) )
.map((e) => e.value) .map((e) => e.value)
.toIList(); .toIList();
@ -65,7 +65,7 @@ class SpacesController extends Notifier<IList<Space>> {
final room = rooms[id]; final room = rooms[id];
if (room == null) return null; if (room == null) return null;
final children = spaceIdToChildren[id] ?? IList<Room>(); final children = childRoomsBySpaceId[id] ?? IList<Room>();
return Space( return Space(
id: id, id: id,
title: room.metadata?.name ?? "Unnamed Room", title: room.metadata?.name ?? "Unnamed Room",

View file

@ -0,0 +1,7 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/header_controller.dart";
extension GetHeaders on WidgetRef {
Map<String, String> get headers =>
watch(HeaderController.provider).requireValue;
}

View file

@ -0,0 +1,40 @@
extension LinkToMention on String {
/// Extracts a Matrix identifier from this string.
///
/// Supports:
/// - https://matrix.to/#/...
/// - matrix:roomid/...
/// - matrix:r/...
/// - matrix:u/...
///
/// Returns the decoded identifier (e.g. "#room:matrix.org")
/// or null if this is not a Matrix link.
String? get mention {
final trimmed = trim();
final matrixTo = RegExp(
r"^https?://matrix\.to/#/([^/?#]+)",
caseSensitive: false,
);
final matrixToMatch = matrixTo.firstMatch(trimmed);
if (matrixToMatch != null) {
return Uri.decodeComponent(matrixToMatch.group(1)!);
}
if (trimmed.toLowerCase().startsWith("matrix:")) {
try {
final uri = Uri.parse(trimmed);
if (uri.pathSegments.isNotEmpty) {
final identifier = uri.pathSegments.last;
if (identifier.isNotEmpty) {
return Uri.decodeComponent(identifier);
}
}
} catch (_) {}
}
return null;
}
}

View file

@ -4,6 +4,7 @@ import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/header_controller.dart";
import "package:nexus/controllers/multi_provider_controller.dart"; import "package:nexus/controllers/multi_provider_controller.dart";
import "package:nexus/controllers/shared_prefs_controller.dart"; import "package:nexus/controllers/shared_prefs_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/better_when.dart";
@ -108,6 +109,7 @@ class App extends StatelessWidget {
IListConst([ IListConst([
SharedPrefsController.provider, SharedPrefsController.provider,
ClientController.provider, ClientController.provider,
HeaderController.provider,
]), ]),
), ),
) )

View file

@ -8,6 +8,7 @@ abstract class ClientState with _$ClientState {
required bool isInitialized, required bool isInitialized,
required bool isLoggedIn, required bool isLoggedIn,
required bool isVerified, required bool isVerified,
required String userId,
}) = _ClientState; }) = _ClientState;
factory ClientState.fromJson(Map<String, Object?> json) => factory ClientState.fromJson(Map<String, Object?> json) =>

View file

@ -23,7 +23,7 @@ abstract class Event with _$Event {
String? transactionId, String? transactionId,
String? redactedBy, String? redactedBy,
String? relatesTo, String? relatesTo,
String? relatesType, @JsonKey(name: "relates_type") String? relationType,
String? decryptionError, String? decryptionError,
String? sendError, String? sendError,
@Default(IMap.empty()) IMap<String, int> reactions, @Default(IMap.empty()) IMap<String, int> reactions,

View file

@ -0,0 +1 @@
enum RelationType { edit, reply }

14
lib/models/report.dart Normal file
View file

@ -0,0 +1,14 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "report.freezed.dart";
part "report.g.dart";
@freezed
abstract class Report with _$Report {
const factory Report({
required String roomId,
required String eventId,
String? reason,
}) = _Report;
factory Report.fromJson(Map<String, Object?> json) => _$ReportFromJson(json);
}

View file

@ -20,7 +20,9 @@ abstract class RoomMetadata with _$RoomMetadata {
required bool hasMemberList, required bool hasMemberList,
@JsonKey(name: "preview_event_rowid") required int previewEventRowID, @JsonKey(name: "preview_event_rowid") required int previewEventRowID,
@EpochDateTimeConverter() required DateTime sortingTimestamp, @EpochDateTimeConverter() required DateTime sortingTimestamp,
@Default(false) bool markedUnread, required int unreadHighlights,
required int unreadNotifications,
required int unreadMessages,
}) = _RoomMetadata; }) = _RoomMetadata;
factory RoomMetadata.fromJson(Map<String, Object?> json) => factory RoomMetadata.fromJson(Map<String, Object?> json) =>

View file

@ -1,6 +1,6 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:nexus/widgets/chat_page/sidebar.dart"; import "package:nexus/widgets/chat_page/sidebar.dart";
// import "package:nexus/widgets/chat_page/room_chat.dart"; import "package:nexus/widgets/chat_page/room_chat.dart";
class ChatPage extends StatelessWidget { class ChatPage extends StatelessWidget {
const ChatPage({super.key}); const ChatPage({super.key});
@ -16,12 +16,12 @@ class ChatPage extends StatelessWidget {
builder: (context) => Row( builder: (context) => Row(
children: [ children: [
if (isDesktop) Sidebar(), if (isDesktop) Sidebar(),
// Expanded( Expanded(
// child: RoomChat( child: RoomChat(
// isDesktop: isDesktop, isDesktop: isDesktop,
// showMembersByDefault: showMembersByDefault, showMembersByDefault: showMembersByDefault,
// ), ),
// ), ),
], ],
), ),
), ),

View file

@ -6,12 +6,14 @@ class AvatarOrHash extends StatelessWidget {
final String title; final String title;
final Widget? fallback; final Widget? fallback;
final bool hasBadge; final bool hasBadge;
final int badgeNumber;
final double height; final double height;
final Map<String, String> headers; final Map<String, String> headers;
const AvatarOrHash( const AvatarOrHash(
this.avatar, this.avatar,
this.title, { this.title, {
this.fallback, this.fallback,
this.badgeNumber = 0,
this.hasBadge = false, this.hasBadge = false,
this.height = 24, this.height = 24,
required this.headers, required this.headers,
@ -30,6 +32,7 @@ class AvatarOrHash extends StatelessWidget {
child: Center( child: Center(
child: Badge( child: Badge(
isLabelVisible: hasBadge, isLabelVisible: hasBadge,
label: badgeNumber != 0 ? Text(badgeNumber.toString()) : null,
smallSize: 12, smallSize: 12,
backgroundColor: Theme.of(context).colorScheme.primary, backgroundColor: Theme.of(context).colorScheme.primary,
child: ClipRRect( child: ClipRRect(

View file

@ -5,10 +5,9 @@ import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_hooks/flutter_hooks.dart"; 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:matrix/matrix.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/widgets/chat_page/mention_overlay.dart"; import "package:nexus/models/room.dart";
import "package:nexus/widgets/chat_page/relation_preview.dart"; import "package:nexus/widgets/chat_page/relation_preview.dart";
class ChatBox extends HookConsumerWidget { class ChatBox extends HookConsumerWidget {
@ -94,7 +93,6 @@ class ChatBox extends HookConsumerWidget {
relatedMessage: relatedMessage, relatedMessage: relatedMessage,
relationType: relationType, relationType: relationType,
onDismiss: onDismiss, onDismiss: onDismiss,
room: room,
), ),
Container( Container(
color: theme.colorScheme.surfaceContainerHighest, color: theme.colorScheme.surfaceContainerHighest,
@ -105,20 +103,21 @@ class ChatBox extends HookConsumerWidget {
PopupMenuButton( PopupMenuButton(
itemBuilder: (context) => [], itemBuilder: (context) => [],
icon: Icon(Icons.add), icon: Icon(Icons.add),
enabled: room.canSendDefaultMessages, // enabled: room.canSendDefaultMessages, TODO: Permissions check
), ),
Expanded( Expanded(
child: FlutterTagger( child: FlutterTagger(
triggerStrategy: TriggerStrategy.eager, triggerStrategy: TriggerStrategy.eager,
overlay: MentionOverlay( overlay: SizedBox.shrink(),
room, // MentionOverlay( TODO: Fix
query: query.value, // room,
triggerCharacter: triggerCharacter.value, // query: query.value,
addTag: ({required id, required name}) { // triggerCharacter: triggerCharacter.value,
controller.value.addTag(id: id, name: name); // addTag: ({required id, required name}) {
node.requestFocus(); // controller.value.addTag(id: id, name: name);
}, // node.requestFocus();
), // },
// ),
controller: controller.value, controller: controller.value,
onSearch: (newQuery, newTriggerCharacter) { onSearch: (newQuery, newTriggerCharacter) {
triggerCharacter.value = newTriggerCharacter; triggerCharacter.value = newTriggerCharacter;
@ -126,13 +125,13 @@ class ChatBox extends HookConsumerWidget {
}, },
triggerCharacterAndStyles: {"@": style, "#": style}, triggerCharacterAndStyles: {"@": style, "#": style},
builder: (context, key) => TextFormField( builder: (context, key) => TextFormField(
enabled: room.canSendDefaultMessages, // enabled: room.canSendDefaultMessages,
maxLines: 12, maxLines: 12,
minLines: 1, minLines: 1,
decoration: InputDecoration( decoration: InputDecoration(
hintText: room.canSendDefaultMessages // hintText: room.canSendDefaultMessages
? "Your message here..." // ? "Your message here..."
: "You don't have permission to send messages in this room...", // : "You don't have permission to send messages in this room...",
border: InputBorder.none, border: InputBorder.none,
), ),
controller: controller.value, controller: controller.value,
@ -143,7 +142,8 @@ class ChatBox extends HookConsumerWidget {
), ),
), ),
IconButton( IconButton(
onPressed: room.canSendDefaultMessages ? send : null, onPressed: send,
// onPressed: room.canSendDefaultMessages ? send : null,
icon: Icon(Icons.send), icon: Icon(Icons.send),
), ),
], ],

View file

@ -2,21 +2,16 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart"; import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart";
import "package:matrix/matrix.dart"; import "package:nexus/helpers/extensions/link_to_mention.dart";
import "package:nexus/controllers/thumbnail_controller.dart";
import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/models/image_data.dart";
import "package:nexus/widgets/chat_page/html/mention_chip.dart"; import "package:nexus/widgets/chat_page/html/mention_chip.dart";
import "package:nexus/widgets/chat_page/html/spoiler_text.dart"; import "package:nexus/widgets/chat_page/html/spoiler_text.dart";
import "package:nexus/widgets/chat_page/html/code_block.dart"; import "package:nexus/widgets/chat_page/html/code_block.dart";
import "package:nexus/widgets/chat_page/html/quoted.dart"; import "package:nexus/widgets/chat_page/html/quoted.dart";
import "package:nexus/widgets/error_dialog.dart";
class Html extends ConsumerWidget { class Html extends ConsumerWidget {
final String html; final String html;
final Client client; const Html(this.html, {super.key});
const Html(this.html, {required this.client, super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) => HtmlWidget( Widget build(BuildContext context, WidgetRef ref) => HtmlWidget(
@ -38,61 +33,60 @@ class Html extends ConsumerWidget {
) )
: null, : null,
"blockquote" => Quoted(Html(element.innerHtml, client: client)), "blockquote" => Quoted(Html(element.innerHtml)),
"a" => "a" =>
element.attributes["href"]?.parseIdentifierIntoParts() == null element.attributes["href"]?.mention == null
? null ? null
: InlineCustomWidget(child: MentionChip(element.text)), : InlineCustomWidget(child: MentionChip(element.text)),
"img" => // "img" => TODO: Img support
element.attributes["src"] == null // element.attributes["src"] == null
? null // ? null
: Consumer( // : Consumer(
builder: (_, ref, _) => ref // builder: (_, ref, _) => ref
.watch( // .watch(
ThumbnailController.provider( // ThumbnailController.provider(
ImageData( // ImageData(
uri: element.attributes["src"]!, // uri: element.attributes["src"]!,
height: height, // height: height,
width: width, // width: width,
), // ),
), // ),
) // )
.when( // .when(
data: (uri) { // data: (uri) {
if (uri == null) return SizedBox.shrink(); // if (uri == null) return SizedBox.shrink();
return InlineCustomWidget(
child: Image.network(
uri,
headers: client.headers,
errorBuilder: (_, error, _) => Text(
"Image Failed to Load",
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
height: height.toDouble(),
width: width?.toDouble(),
loadingBuilder: (_, child, loadingProgress) =>
loadingProgress == null
? child
: CircularProgressIndicator(),
),
);
},
error: ErrorDialog.new,
loading: () => InlineCustomWidget(
child: SizedBox(
width: width?.toDouble(),
height: height.toDouble(),
child: CircularProgressIndicator(),
),
),
),
),
// return InlineCustomWidget(
// child: Image.network(
// uri,
// headers: client.headers,
// errorBuilder: (_, error, _) => Text(
// "Image Failed to Load",
// style: TextStyle(
// color: Theme.of(context).colorScheme.error,
// ),
// ),
// height: height.toDouble(),
// width: width?.toDouble(),
// loadingBuilder: (_, child, loadingProgress) =>
// loadingProgress == null
// ? child
// : CircularProgressIndicator(),
// ),
// );
// },
// error: ErrorDialog.new,
// loading: () => InlineCustomWidget(
// child: SizedBox(
// width: width?.toDouble(),
// height: height.toDouble(),
// child: CircularProgressIndicator(),
// ),
// ),
// ),
// ),
("del" || ("del" ||
"h1" || "h1" ||
"h2" || "h2" ||

View file

@ -1,5 +1,5 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:matrix/matrix.dart"; import "package:nexus/helpers/extensions/link_to_mention.dart";
class MentionChip extends StatelessWidget { class MentionChip extends StatelessWidget {
final String label; final String label;
@ -8,7 +8,7 @@ class MentionChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) => ActionChip( Widget build(BuildContext context) => ActionChip(
label: Text( label: Text(
label.parseIdentifierIntoParts()?.primaryIdentifier ?? label, label.mention ?? label,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimary, color: Theme.of(context).colorScheme.onPrimary,

View file

@ -1,22 +1,16 @@
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:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:matrix/matrix.dart";
import "package:nexus/controllers/avatar_controller.dart";
import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class RelationPreview extends ConsumerWidget { class RelationPreview extends ConsumerWidget {
final Message? relatedMessage; final Message? relatedMessage;
final RelationType relationType; final RelationType relationType;
final VoidCallback onDismiss; final VoidCallback onDismiss;
final Room room;
const RelationPreview({ const RelationPreview({
required this.relatedMessage, required this.relatedMessage,
required this.relationType, required this.relationType,
required this.onDismiss, required this.onDismiss,
required this.room,
super.key, super.key,
}); });
@ -37,18 +31,18 @@ class RelationPreview extends ConsumerWidget {
"Editing message:", "Editing message:",
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
AvatarOrHash( // AvatarOrHash(
ref // ref
.watch( // .watch(
AvatarController.provider( // AvatarController.provider(
relatedMessage!.metadata!["avatarUrl"], // relatedMessage!.metadata!["avatarUrl"],
), // ),
) // )
.whenOrNull(data: (data) => data), // .whenOrNull(data: (data) => data),
relatedMessage!.metadata!["displayName"].toString(), // relatedMessage!.metadata!["displayName"].toString(),
headers: room.client.headers, // headers: room.client.headers,
height: 16, // height: 16,
), // ),
Text( Text(
relatedMessage!.metadata?["displayName"] ?? relatedMessage!.metadata?["displayName"] ??
relatedMessage!.authorId, relatedMessage!.authorId,

View file

@ -1,13 +1,13 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/models/room.dart";
import "package:nexus/models/full_room.dart";
import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/chat_page/room_menu.dart"; import "package:nexus/widgets/chat_page/room_menu.dart";
class RoomAppbar extends StatelessWidget implements PreferredSizeWidget { class RoomAppbar extends StatelessWidget implements PreferredSizeWidget {
final bool isDesktop; final bool isDesktop;
final FullRoom room; final Room room;
final void Function(BuildContext context) onOpenMemberList; final void Function(BuildContext context) onOpenMemberList;
final void Function(BuildContext context) onOpenDrawer; final void Function(BuildContext context) onOpenDrawer;
const RoomAppbar( const RoomAppbar(
@ -24,22 +24,27 @@ class RoomAppbar extends StatelessWidget implements PreferredSizeWidget {
@override @override
Widget build(BuildContext context) => Appbar( Widget build(BuildContext context) => Appbar(
leading: isDesktop leading: isDesktop
? AvatarOrHash( ? null
room.avatar, // AvatarOrHash( TODO: Images
room.title, // room.avatar,
height: 24, // room.title,
fallback: Icon(Icons.numbers), // height: 24,
headers: room.roomData.client.headers, // fallback: Icon(Icons.numbers),
) // headers: room.roomData.client.headers,
// )
: DrawerButton(onPressed: () => onOpenDrawer(context)), : DrawerButton(onPressed: () => onOpenDrawer(context)),
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
title: Column( title: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(room.title, overflow: TextOverflow.ellipsis, maxLines: 1), Text(
if (room.roomData.topic.isNotEmpty) room.metadata?.name ?? "Unnamed Room",
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
if (room.metadata?.topic?.isNotEmpty == true)
Text( Text(
room.roomData.topic, room.metadata!.topic!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium?.copyWith( style: Theme.of(context).textTheme.labelMedium?.copyWith(
@ -54,7 +59,7 @@ class RoomAppbar extends StatelessWidget implements PreferredSizeWidget {
onPressed: () => onOpenMemberList(context), onPressed: () => onOpenMemberList(context),
icon: Icon(Icons.people), icon: Icon(Icons.people),
), ),
RoomMenu(room.roomData), RoomMenu(room),
], ].toIList(),
); );
} }

File diff suppressed because it is too large Load diff

View file

@ -2,27 +2,20 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter/services.dart"; import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/widgets/form_text_input.dart"; import "package:nexus/widgets/form_text_input.dart";
class RoomMenu extends StatelessWidget { class RoomMenu extends ConsumerWidget {
final Room room; final Room room;
final IList<Room> children; final IList<Room> children;
const RoomMenu(this.room, {this.children = const IList.empty(), super.key}); const RoomMenu(this.room, {this.children = const IList.empty(), super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final danger = Theme.of(context).colorScheme.error; final danger = Theme.of(context).colorScheme.error;
final client = ref.watch(ClientController.provider.notifier);
void markRead(String roomId) async {
// TODO: Set parent read
for (final child in children) {
// await child.setReadMarker( TODO: Set children read
// child.roomData.lastEvent?.eventId,
// mRead: child.roomData.lastEvent?.eventId,
// );
}
}
return PopupMenuButton( return PopupMenuButton(
itemBuilder: (_) => [ itemBuilder: (_) => [
@ -33,45 +26,51 @@ class RoomMenu extends StatelessWidget {
// }, // },
// child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), // child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")),
// ), // ),
// PopupMenuItem( PopupMenuItem(
// onTap: () => markRead(room.id), onTap: () async {
// child: ListTile( await client.markRead(room);
// leading: Icon(Icons.check), await Future.wait(children.map((child) => client.markRead(child)));
// title: Text("Mark as Read"), },
// ), child: ListTile(
// ), leading: Icon(Icons.check),
// PopupMenuItem( title: Text("Mark as Read"),
// onTap: () => showDialog( ),
// context: context, ),
// builder: (context) => AlertDialog( PopupMenuItem(
// title: Text("Leave Room"), onTap: () => showDialog(
// content: Text( context: context,
// "Are you sure you want to leave \"${room.getLocalizedDisplayname()}\"?", builder: (context) => AlertDialog(
// ), title: Text("Leave Room"),
// actions: [ content: Text(
// TextButton( "Are you sure you want to leave \"${room.metadata?.name ?? "Unnamed Room"}\"?",
// onPressed: Navigator.of(context).pop, ),
// child: Text("Cancel"), actions: [
// ), TextButton(
// TextButton( onPressed: Navigator.of(context).pop,
// onPressed: () async { child: Text("Cancel"),
// Navigator.of(context).pop(); ),
// final snackbar = ScaffoldMessenger.of( TextButton(
// context, onPressed: () async {
// ).showSnackBar(SnackBar(content: Text("Leaving room..."))); Navigator.of(context).pop();
// await room.leave(); final snackbar = ScaffoldMessenger.of(context).showSnackBar(
// snackbar.close(); SnackBar(
// }, content: Text("Leaving room..."),
// child: Text("Leave"), duration: Duration(days: 1),
// ), ),
// ], );
// ), await client.leaveRoom(room);
// ), snackbar.close();
// child: ListTile( },
// leading: Icon(Icons.logout, color: danger), child: Text("Leave"),
// title: Text("Leave", style: TextStyle(color: danger)), ),
// ), ],
// ), ),
),
child: ListTile(
leading: Icon(Icons.logout, color: danger),
title: Text("Leave", style: TextStyle(color: danger)),
),
),
// PopupMenuItem( // PopupMenuItem(
// onTap: () => showDialog( // onTap: () => showDialog(
// context: context, // context: context,

View file

@ -1,3 +1,4 @@
import "package:collection/collection.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:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
@ -39,7 +40,7 @@ class Sidebar extends HookConsumerWidget {
final indexOfSelectedRoom = selectedSpace.children.indexWhere( final indexOfSelectedRoom = selectedSpace.children.indexWhere(
(room) => room.metadata?.id == selectedRoomId, (room) => room.metadata?.id == selectedRoomId,
); );
final selectedRoomIndex = indexOfSelected == -1 final selectedRoomIndex = indexOfSelectedRoom == -1
? selectedSpace.children.isEmpty ? selectedSpace.children.isEmpty
? null ? null
: 0 : 0
@ -65,11 +66,17 @@ class Sidebar extends HookConsumerWidget {
fallback: space.icon == null ? null : Icon(space.icon), fallback: space.icon == null ? null : Icon(space.icon),
space.title, space.title,
headers: {}, // TODO headers: {}, // TODO
hasBadge: false, hasBadge:
// space.children.firstWhereOrNull( TODO space.children.firstWhereOrNull(
// (room) => room.roomData.hasNewMessages, (room) => room.metadata?.unreadMessages != 0,
// ) != ) !=
// null, null,
badgeNumber: space.children.fold(
0,
(previousValue, room) =>
previousValue +
(room.metadata?.unreadNotifications ?? 0),
),
), ),
label: Text(space.title), label: Text(space.title),
padding: EdgeInsets.only(top: 4), padding: EdgeInsets.only(top: 4),
@ -184,13 +191,16 @@ class Sidebar extends HookConsumerWidget {
// space.client.headers, TODO // space.client.headers, TODO
), ),
title: Text( title: Text(
selectedSpace.room?.metadata?.avatar.toString() ?? selectedSpace.title,
selectedSpace.title,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
actions: [ actions: [
if (selectedSpace.room != null) RoomMenu(selectedSpace.room!), if (selectedSpace.room != null)
RoomMenu(
selectedSpace.room!,
children: selectedSpace.children,
),
], ],
), ),
body: NavigationRail( body: NavigationRail(
@ -203,8 +213,9 @@ class Sidebar extends HookConsumerWidget {
(room) => NavigationRailDestination( (room) => NavigationRailDestination(
label: Text(room.metadata?.name ?? "Unnamed Room"), label: Text(room.metadata?.name ?? "Unnamed Room"),
icon: AvatarOrHash( icon: AvatarOrHash(
// hasBadge: room.roomData.hasNewMessages, TODO
null, null,
hasBadge: room.metadata?.unreadMessages != 0,
badgeNumber: room.metadata?.unreadNotifications ?? 0,
// room.avatar, TODO // room.avatar, TODO
room.metadata?.name ?? "Unnamed Room", room.metadata?.name ?? "Unnamed Room",
fallback: selectedSpaceId == "dms" fallback: selectedSpaceId == "dms"

View file

@ -8,11 +8,9 @@ import "package:nexus/widgets/chat_page/html/quoted.dart";
class TopWidget extends ConsumerWidget { class TopWidget extends ConsumerWidget {
final Message message; final Message message;
final bool alwaysShow; final bool alwaysShow;
final Map<String, String> headers;
final MessageGroupStatus? groupStatus; final MessageGroupStatus? groupStatus;
const TopWidget( const TopWidget(
this.message, { this.message, {
required this.headers,
required this.groupStatus, required this.groupStatus,
this.alwaysShow = false, this.alwaysShow = false,
super.key, super.key,
@ -62,11 +60,11 @@ class TopWidget extends ConsumerWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
spacing: 8, spacing: 8,
children: [ children: [
Avatar( // Avatar( TODO: images
userId: replyMessage.authorId, // userId: replyMessage.authorId,
headers: headers, // headers: headers,
size: 16, // size: 16,
), // ),
Flexible( Flexible(
child: Text( child: Text(
replyMessage.metadata?["displayName"] ?? replyMessage.metadata?["displayName"] ??
@ -104,7 +102,7 @@ class TopWidget extends ConsumerWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
spacing: 8, spacing: 8,
children: [ children: [
Avatar(userId: message.authorId, headers: headers), // Avatar(userId: message.authorId, headers: headers), TODO: images
Flexible( Flexible(
child: Text( child: Text(
message.metadata?["displayName"] ?? message.authorId, message.metadata?["displayName"] ?? message.authorId,