Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
e38307657d |
|||
|
e14ab3e708 |
19 changed files with 492 additions and 96 deletions
2
gomuks
2
gomuks
|
|
@ -1 +1 @@
|
||||||
Subproject commit ba82b6e31c87c99766a8366a72a9f07d643b323f
|
Subproject commit a65299d327e3a533e6c476b0450b09be91fbb827
|
||||||
|
|
@ -8,6 +8,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:ffi/ffi.dart";
|
import "package:ffi/ffi.dart";
|
||||||
import "package:flutter/cupertino.dart";
|
import "package:flutter/cupertino.dart";
|
||||||
import "package:intl/intl.dart";
|
import "package:intl/intl.dart";
|
||||||
|
import "package:nexus/controllers/settings.dart";
|
||||||
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
||||||
import "package:nexus/models/capabilities.dart";
|
import "package:nexus/models/capabilities.dart";
|
||||||
import "package:nexus/models/content/message.dart";
|
import "package:nexus/models/content/message.dart";
|
||||||
|
|
@ -106,39 +107,50 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calls gomuks with [data] as JSON, returning its decoded response.
|
||||||
Future<dynamic> callGomuksMethod(
|
Future<dynamic> callGomuksMethod(
|
||||||
Map<String, dynamic> data,
|
Map<String, dynamic> data,
|
||||||
FutureOr<GomuksResponse> Function(int handle, GomuksBorrowedBuffer data)
|
void Function(
|
||||||
callback,
|
int handle,
|
||||||
|
GomuksOwnedBuffer data,
|
||||||
|
Pointer<NativeFunction<Void Function(GomuksResponse)>> callback,
|
||||||
|
)
|
||||||
|
call,
|
||||||
) async {
|
) async {
|
||||||
final bufferPointer = data.toGomuksBufferPtr();
|
final handle = await future;
|
||||||
|
final buffer = data.toGomuksOwnedBuffer();
|
||||||
|
|
||||||
|
final completer = Completer<dynamic>();
|
||||||
|
late final NativeCallable<Void Function(GomuksResponse)> callable;
|
||||||
|
callable = .listener((GomuksResponse response) {
|
||||||
|
callable.close();
|
||||||
|
try {
|
||||||
|
final json = response.buf.toJsonAndFree();
|
||||||
|
|
||||||
|
if (response.command.cast<Utf8>().toDartString() == "error") {
|
||||||
|
throw GomuksException(json.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
completer.complete(json);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
completer.completeError(error, stackTrace);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final handle = await future;
|
call(handle, buffer, callable.nativeFunction);
|
||||||
|
} catch (_) {
|
||||||
final response = await Isolate.run(
|
// gomuks never got the call, so it won't respond or free the buffer
|
||||||
() => callback(handle, bufferPointer.ref),
|
callable.close();
|
||||||
);
|
GomuksFreeBuffer(buffer);
|
||||||
|
rethrow;
|
||||||
final json = response.buf.toJsonAndFree();
|
|
||||||
|
|
||||||
if (response.command.cast<Utf8>().toDartString() == "error") {
|
|
||||||
throw GomuksException(json.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
return json;
|
|
||||||
} finally {
|
|
||||||
calloc
|
|
||||||
..free(bufferPointer.ref.base)
|
|
||||||
..free(bufferPointer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return completer.future;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<PushNotification?> handlePush(Map<String, dynamic> data) async {
|
Future<PushNotification?> handlePush(Map<String, dynamic> data) async {
|
||||||
final response = await callGomuksMethod(
|
final response = await callGomuksMethod(data, GomuksHandlePushAsync);
|
||||||
data,
|
|
||||||
(handle, data) async => GomuksHandlePush(handle, data),
|
|
||||||
);
|
|
||||||
|
|
||||||
return response?["notification"] == null
|
return response?["notification"] == null
|
||||||
? null
|
? null
|
||||||
|
|
@ -148,11 +160,12 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<dynamic> _sendCommand(
|
Future<dynamic> _sendCommand(
|
||||||
String command, [
|
String command, [
|
||||||
Map<String, dynamic> data = const {},
|
Map<String, dynamic> data = const {},
|
||||||
]) => callGomuksMethod(data, (handle, data) {
|
]) => callGomuksMethod(data, (handle, data, callback) {
|
||||||
final commandPointer = command.toNativeUtf8().cast<Char>();
|
final commandPointer = command.toNativeUtf8().cast<Char>();
|
||||||
try {
|
try {
|
||||||
return GomuksSubmitCommand(handle, commandPointer, data);
|
GomuksSubmitCommandAsync(handle, commandPointer, data, callback);
|
||||||
} finally {
|
} finally {
|
||||||
|
// gomuks copies the command name before returning
|
||||||
calloc.free(commandPointer);
|
calloc.free(commandPointer);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -332,9 +345,14 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
|
|
||||||
if (event == null) return;
|
if (event == null) return;
|
||||||
|
|
||||||
|
// Private until settings have loaded, in case the user turned sending off
|
||||||
|
final sendPublic =
|
||||||
|
ref.read(SettingsController.provider).value?.readReceipts ==
|
||||||
|
.showAndSend;
|
||||||
|
|
||||||
await _sendCommand("mark_read", {
|
await _sendCommand("mark_read", {
|
||||||
"room_id": room.metadata!.id,
|
"room_id": room.metadata!.id,
|
||||||
"receipt_type": "m.read",
|
"receipt_type": sendPublic ? "m.read" : "m.read.private",
|
||||||
"event_id": event.eventId,
|
"event_id": event.eventId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
90
lib/controllers/read_receipts.dart
Normal file
90
lib/controllers/read_receipts.dart
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
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/client_state.dart";
|
||||||
|
import "package:nexus/controllers/room_chat.dart";
|
||||||
|
import "package:nexus/controllers/rooms.dart";
|
||||||
|
import "package:nexus/controllers/settings.dart";
|
||||||
|
import "package:nexus/helpers/extensions/is_shown_as_message.dart";
|
||||||
|
import "package:nexus/models/read_receipt.dart";
|
||||||
|
|
||||||
|
/// Other users' read receipts in a chat, by the event ID of the message to
|
||||||
|
/// show them under: the nearest message at or before what they've read.
|
||||||
|
class ReadReceiptsController(final ChatParams chat)
|
||||||
|
extends Notifier<IMap<String, IList<ReadReceipt>>> {
|
||||||
|
@override
|
||||||
|
IMap<String, IList<ReadReceipt>> build() {
|
||||||
|
final shouldShow = ref.watch(
|
||||||
|
SettingsController.provider.select(
|
||||||
|
(settings) => settings.value?.readReceipts != .neither,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!shouldShow) return const .empty();
|
||||||
|
|
||||||
|
final userId = ref.watch(
|
||||||
|
ClientStateController.provider.select((state) => state?.userId),
|
||||||
|
);
|
||||||
|
// The chat's own timeline, so receipts follow what's actually shown
|
||||||
|
final timeline = ref.watch(
|
||||||
|
RoomChatController.provider(chat)
|
||||||
|
.select((roomChat) => roomChat.value?.timeline),
|
||||||
|
);
|
||||||
|
final room = ref.watch(
|
||||||
|
RoomsController.provider.select(
|
||||||
|
(rooms) => switch (rooms[chat.roomId]) {
|
||||||
|
final room? => (events: room.events, receipts: room.receipts),
|
||||||
|
null => null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (userId == null || timeline == null || room == null) {
|
||||||
|
return const .empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
final events = timeline.map((rowId) => room.events[rowId]).nonNulls;
|
||||||
|
final indexes = IMap.fromEntries(
|
||||||
|
events.indexed.map((entry) => MapEntry(entry.$2.eventId, entry.$1)),
|
||||||
|
);
|
||||||
|
final messages = events.indexed
|
||||||
|
.where((entry) => entry.$2.isShownAsMessage)
|
||||||
|
.toIList();
|
||||||
|
final messageIndexes = messages.map((entry) => entry.$1).toList();
|
||||||
|
|
||||||
|
return room.receipts.values.flattened
|
||||||
|
.where(
|
||||||
|
(receipt) =>
|
||||||
|
receipt.userId != userId &&
|
||||||
|
// Receipts in threads are for the thread, not the timeline
|
||||||
|
receipt.thread == "main",
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(receipt) => switch (indexes[receipt.eventId]) {
|
||||||
|
final index? => (
|
||||||
|
receipt: receipt,
|
||||||
|
message: lowerBound(messageIndexes, index + 1) - 1,
|
||||||
|
),
|
||||||
|
null => null,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.nonNulls
|
||||||
|
.where((entry) => entry.message >= 0)
|
||||||
|
.groupListsBy((entry) => messages[entry.message].$2.eventId)
|
||||||
|
.map(
|
||||||
|
(eventId, entries) => MapEntry(
|
||||||
|
eventId,
|
||||||
|
entries
|
||||||
|
.map((entry) => entry.receipt)
|
||||||
|
.sorted((a, b) => b.timestamp.compareTo(a.timestamp))
|
||||||
|
.toIList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toIMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
static final provider = NotifierProvider.family
|
||||||
|
.autoDispose<
|
||||||
|
ReadReceiptsController,
|
||||||
|
IMap<String, IList<ReadReceipt>>,
|
||||||
|
ChatParams
|
||||||
|
>(ReadReceiptsController.new);
|
||||||
|
}
|
||||||
|
|
@ -156,6 +156,7 @@ class RoomChatController(final ChatParams info)
|
||||||
valueMapper: (event) => event,
|
valueMapper: (event) => event,
|
||||||
),
|
),
|
||||||
hasMore: response.hasMore,
|
hasMore: response.hasMore,
|
||||||
|
receipts: response.receipts,
|
||||||
timeline: IMap.fromIterable(
|
timeline: IMap.fromIterable(
|
||||||
response.events,
|
response.events,
|
||||||
keyMapper: (event) => event.timelineRowId,
|
keyMapper: (event) => event.timelineRowId,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ 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/models/event.dart";
|
||||||
|
import "package:nexus/models/read_receipt.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
|
|
||||||
class RoomsController extends Notifier<IMap<String, Room>> {
|
class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
|
|
@ -33,6 +34,26 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
.new(),
|
.new(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static IMap<String, IList<ReadReceipt>> mergeReceipts(
|
||||||
|
IMap<String, IList<ReadReceipt>> existing,
|
||||||
|
IMap<String, IList<ReadReceipt>> incoming,
|
||||||
|
) => incoming.isEmpty
|
||||||
|
? existing
|
||||||
|
: existing.values.flattened
|
||||||
|
.followedBy(incoming.values.flattened)
|
||||||
|
.groupListsBy((receipt) => (receipt.userId, receipt.thread))
|
||||||
|
.values
|
||||||
|
.map(
|
||||||
|
(receipts) => receipts.reduce(
|
||||||
|
(newest, receipt) => newest.timestamp.isAfter(receipt.timestamp)
|
||||||
|
? newest
|
||||||
|
: receipt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupListsBy((receipt) => receipt.eventId)
|
||||||
|
.map((eventId, receipts) => MapEntry(eventId, receipts.toIList()))
|
||||||
|
.toIMap();
|
||||||
|
|
||||||
/// Adds events to their rooms, without changing the rooms' timelines.
|
/// Adds events to their rooms, without changing the rooms' timelines.
|
||||||
void storeEvents(Iterable<Event> events) => update(
|
void storeEvents(Iterable<Event> events) => update(
|
||||||
.new(
|
.new(
|
||||||
|
|
@ -96,15 +117,11 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
timeline: incoming.reset
|
timeline: incoming.reset
|
||||||
? incoming.timeline
|
? incoming.timeline
|
||||||
: existing.timeline.addAll(incoming.timeline),
|
: existing.timeline.addAll(incoming.timeline),
|
||||||
receipts: incoming.receipts.entries.fold(
|
receipts: mergeReceipts(existing.receipts, incoming.receipts),
|
||||||
existing.receipts,
|
|
||||||
(receiptAcc, event) => receiptAcc.add(
|
|
||||||
event.key,
|
|
||||||
(receiptAcc[event.key] ?? .new()).addAll(event.value),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) ??
|
) ??
|
||||||
incoming,
|
incoming.copyWith(
|
||||||
|
receipts: mergeReceipts(const .empty(), incoming.receipts),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,9 @@ import "package:nexus/controllers/unified_push_distributors.dart";
|
||||||
import "package:nexus/helpers/extensions/distributor_name.dart";
|
import "package:nexus/helpers/extensions/distributor_name.dart";
|
||||||
import "package:nexus/models/account_data.dart";
|
import "package:nexus/models/account_data.dart";
|
||||||
import "package:nexus/models/room_order.dart";
|
import "package:nexus/models/room_order.dart";
|
||||||
|
import "package:nexus/models/settings.dart";
|
||||||
import "package:nexus/models/settings_category.dart";
|
import "package:nexus/models/settings_category.dart";
|
||||||
import "package:nexus/models/typing_indicators.dart";
|
import "package:nexus/models/show_and_send.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
||||||
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
||||||
|
|
@ -32,6 +33,30 @@ class SettingsSectionsController
|
||||||
Future<IMap<String, IList<SettingsCategory>>> build() async {
|
Future<IMap<String, IList<SettingsCategory>>> build() async {
|
||||||
final settings = await ref.watch(SettingsController.provider.future);
|
final settings = await ref.watch(SettingsController.provider.future);
|
||||||
|
|
||||||
|
/// A tile for a [ShowAndSend] setting, saving the settings from [update].
|
||||||
|
Widget showAndSendTile(
|
||||||
|
String title,
|
||||||
|
String description,
|
||||||
|
IconData icon, {
|
||||||
|
required ShowAndSend value,
|
||||||
|
required Settings Function(ShowAndSend value) update,
|
||||||
|
}) => DialogListTile<ShowAndSend>(
|
||||||
|
icon: Icon(icon),
|
||||||
|
title: title,
|
||||||
|
subtitle: Text(description),
|
||||||
|
value: value,
|
||||||
|
options: ShowAndSend.values,
|
||||||
|
getName: (option) => switch (option) {
|
||||||
|
.showAndSend => "Show and send",
|
||||||
|
.show => "Only show",
|
||||||
|
.neither => "Neither",
|
||||||
|
},
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(SettingsController.provider.notifier)
|
||||||
|
.set(update(value))
|
||||||
|
.onError(showError),
|
||||||
|
);
|
||||||
|
|
||||||
return .new({
|
return .new({
|
||||||
"General": .new([
|
"General": .new([
|
||||||
.new(
|
.new(
|
||||||
|
|
@ -192,23 +217,25 @@ class SettingsSectionsController
|
||||||
title: "Typing Indicators",
|
title: "Typing Indicators",
|
||||||
description: "Show when others are typing, and let others see when you're typing.",
|
description: "Show when others are typing, and let others see when you're typing.",
|
||||||
icon: Icons.keyboard,
|
icon: Icons.keyboard,
|
||||||
builder: (title, description, icon) =>
|
builder: (title, description, icon) => showAndSendTile(
|
||||||
DialogListTile<TypingIndicators>(
|
title,
|
||||||
icon: Icon(icon),
|
description,
|
||||||
title: title,
|
icon,
|
||||||
subtitle: Text(description),
|
value: settings.typingIndicators,
|
||||||
value: settings.typingIndicators,
|
update: (value) => settings.copyWith(typingIndicators: value),
|
||||||
options: TypingIndicators.values,
|
),
|
||||||
getName: (option) => switch (option) {
|
),
|
||||||
.showAndSend => "Show and send",
|
.new(
|
||||||
.show => "Only show",
|
title: "Read Receipts",
|
||||||
.neither => "Neither",
|
description: "Show how far others have read, and let others see what you've read. When not sent, read receipts are still sent privately, so your other devices know what you've read.",
|
||||||
},
|
icon: Icons.done_all,
|
||||||
onChanged: (value) => ref
|
builder: (title, description, icon) => showAndSendTile(
|
||||||
.read(SettingsController.provider.notifier)
|
title,
|
||||||
.set(settings.copyWith(typingIndicators: value))
|
description,
|
||||||
.onError(showError),
|
icon,
|
||||||
),
|
value: settings.readReceipts,
|
||||||
|
update: (value) => settings.copyWith(readReceipts: value),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -19,19 +19,21 @@ extension GomuksOwnedBufferToX on GomuksOwnedBuffer {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension JsonToGomuksBuffer on Map<String, dynamic> {
|
extension JsonToGomuksBuffer on Map<String, dynamic> {
|
||||||
|
GomuksOwnedBuffer toGomuksOwnedBuffer() {
|
||||||
|
final bytes = utf8.encode(json.encode(this));
|
||||||
|
final buffer = GomuksAllocBuffer(bytes.length);
|
||||||
|
buffer.base.asTypedList(bytes.length).setAll(0, bytes);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
Pointer<GomuksBorrowedBuffer> toGomuksBufferPtr() {
|
Pointer<GomuksBorrowedBuffer> toGomuksBufferPtr() {
|
||||||
final jsonString = json.encode(this);
|
final bytes = utf8.encode(json.encode(this));
|
||||||
final bytes = utf8.encode(jsonString);
|
|
||||||
|
|
||||||
final dataPtr = calloc<Uint8>(bytes.length);
|
final dataPtr = calloc<Uint8>(bytes.length);
|
||||||
dataPtr.asTypedList(bytes.length).setAll(0, bytes);
|
dataPtr.asTypedList(bytes.length).setAll(0, bytes);
|
||||||
|
|
||||||
final ptr = calloc<GomuksBorrowedBuffer>();
|
return calloc<GomuksBorrowedBuffer>()
|
||||||
|
..ref.base = dataPtr
|
||||||
ptr.ref
|
..ref.length = bytes.length;
|
||||||
..base = dataPtr
|
|
||||||
..length = bytes.length;
|
|
||||||
|
|
||||||
return ptr;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
lib/helpers/extensions/is_shown_as_message.dart
Normal file
15
lib/helpers/extensions/is_shown_as_message.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import "package:nexus/models/content/encrypted.dart";
|
||||||
|
import "package:nexus/models/content/message.dart";
|
||||||
|
import "package:nexus/models/content/sticker.dart";
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
|
|
||||||
|
extension IsShownAsMessage on Event {
|
||||||
|
/// Whether this is shown in the timeline as a message, with MessageRenderer.
|
||||||
|
bool get isShownAsMessage =>
|
||||||
|
redactedBy == null &&
|
||||||
|
relationType != "m.replace" &&
|
||||||
|
switch (content) {
|
||||||
|
MessageContent() || EncryptedContent() || StickerContent() => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
|
import "package:nexus/models/read_receipt.dart";
|
||||||
|
|
||||||
part "paginate.freezed.dart";
|
part "paginate.freezed.dart";
|
||||||
part "paginate.g.dart";
|
part "paginate.g.dart";
|
||||||
|
|
@ -10,6 +11,9 @@ part "paginate.g.dart";
|
||||||
class const Paginate({
|
class const Paginate({
|
||||||
required final IList<Event> events,
|
required final IList<Event> events,
|
||||||
required final IList<Event> relatedEvents,
|
required final IList<Event> relatedEvents,
|
||||||
|
|
||||||
|
/// Read receipts for the events, by event ID.
|
||||||
|
final IMap<String, IList<ReadReceipt>> receipts = const .empty(),
|
||||||
required final bool hasMore,
|
required final bool hasMore,
|
||||||
}) with _$Paginate {
|
}) with _$Paginate {
|
||||||
Map<String, Object?> toJson() => _$PaginateToJson(this);
|
Map<String, Object?> toJson() => _$PaginateToJson(this);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ class const ReadReceipt({
|
||||||
required final String eventId,
|
required final String eventId,
|
||||||
@EpochDateTimeConverter() required final DateTime timestamp,
|
@EpochDateTimeConverter() required final DateTime timestamp,
|
||||||
}) with _$ReadReceipt {
|
}) with _$ReadReceipt {
|
||||||
|
String get thread => switch (threadId) {
|
||||||
|
final threadId? when threadId.isNotEmpty => threadId,
|
||||||
|
_ => "main",
|
||||||
|
};
|
||||||
|
|
||||||
Map<String, Object?> toJson() => _$ReadReceiptToJson(this);
|
Map<String, Object?> toJson() => _$ReadReceiptToJson(this);
|
||||||
|
|
||||||
factory ReadReceipt.fromJson(Map<String, Object?> json) =>
|
factory ReadReceipt.fromJson(Map<String, Object?> json) =>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
import "package:nexus/models/room_order.dart";
|
import "package:nexus/models/room_order.dart";
|
||||||
import "package:nexus/models/typing_indicators.dart";
|
import "package:nexus/models/show_and_send.dart";
|
||||||
|
|
||||||
part "settings.freezed.dart";
|
part "settings.freezed.dart";
|
||||||
part "settings.g.dart";
|
part "settings.g.dart";
|
||||||
|
|
@ -17,7 +17,11 @@ class const Settings({
|
||||||
final bool swipeToReply = false,
|
final bool swipeToReply = false,
|
||||||
final bool hideMembershipEvents = false,
|
final bool hideMembershipEvents = false,
|
||||||
final RoomOrder roomOrder = .unreadFirst,
|
final RoomOrder roomOrder = .unreadFirst,
|
||||||
final TypingIndicators typingIndicators = .showAndSend,
|
final ShowAndSend typingIndicators = .showAndSend,
|
||||||
|
|
||||||
|
/// When not sending, read receipts are still sent privately, so that the
|
||||||
|
/// user's other devices know what has been read.
|
||||||
|
final ShowAndSend readReceipts = .showAndSend,
|
||||||
}) with _$Settings {
|
}) with _$Settings {
|
||||||
Map<String, Object?> toJson() => _$SettingsToJson(this);
|
Map<String, Object?> toJson() => _$SettingsToJson(this);
|
||||||
|
|
||||||
|
|
|
||||||
2
lib/models/show_and_send.dart
Normal file
2
lib/models/show_and_send.dart
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/// Whether to show something from others, and send it to them, e.g. typing indicators or read receipts.
|
||||||
|
enum ShowAndSend { showAndSend, show, neither }
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
enum TypingIndicators { showAndSend, show, neither }
|
|
||||||
39
lib/widgets/avatar_ring.dart
Normal file
39
lib/widgets/avatar_ring.dart
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
|
||||||
|
class const AvatarRing({
|
||||||
|
required final String tooltip,
|
||||||
|
required final VoidCallback onTap,
|
||||||
|
required final Widget child,
|
||||||
|
final double dimension = 24,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
static const ringWidth = 1.5;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: Container(
|
||||||
|
padding: .all(ringWidth),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
shape: .circle,
|
||||||
|
),
|
||||||
|
child: ClipOval(
|
||||||
|
child: SizedBox.square(
|
||||||
|
dimension: dimension,
|
||||||
|
child: Stack(
|
||||||
|
fit: .expand,
|
||||||
|
children: [
|
||||||
|
child,
|
||||||
|
// Over the child, so the ripple shows on top of avatars
|
||||||
|
Material(
|
||||||
|
type: .transparency,
|
||||||
|
child: InkWell(onTap: onTap),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
112
lib/widgets/read_receipts_row.dart
Normal file
112
lib/widgets/read_receipts_row.dart
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/controllers/read_receipts.dart";
|
||||||
|
import "package:nexus/controllers/room_chat.dart";
|
||||||
|
import "package:nexus/controllers/user.dart";
|
||||||
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
|
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||||
|
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||||
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
|
import "package:nexus/widgets/avatar_ring.dart";
|
||||||
|
import "package:nexus/widgets/read_receipts_sheet.dart";
|
||||||
|
|
||||||
|
class const ReadReceiptsRow(
|
||||||
|
final String eventId, {
|
||||||
|
required final ChatParams chat,
|
||||||
|
super.key,
|
||||||
|
}) extends ConsumerWidget {
|
||||||
|
static const avatarSize = 24.0;
|
||||||
|
static const overlap = 6.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final receipts = ref.watch(
|
||||||
|
ReadReceiptsController.provider(chat)
|
||||||
|
.select((receipts) => receipts[eventId] ?? const .empty()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return AnimatedSize(
|
||||||
|
duration: Durations.medium2,
|
||||||
|
curve: Curves.easeInOutCubicEmphasized,
|
||||||
|
alignment: .topRight,
|
||||||
|
child: receipts.isEmpty
|
||||||
|
? const SizedBox(width: .infinity)
|
||||||
|
: Padding(
|
||||||
|
// Clear of the scrollbar
|
||||||
|
padding: .only(right: 16, bottom: 2),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
const width = avatarSize + AvatarRing.ringWidth * 2;
|
||||||
|
final fits =
|
||||||
|
(constraints.maxWidth - width) ~/ (width - overlap) + 1;
|
||||||
|
|
||||||
|
// If they don't all fit, the last spot shows how many more there are
|
||||||
|
final shown = receipts.length <= fits
|
||||||
|
? receipts.length
|
||||||
|
: fits - 1;
|
||||||
|
|
||||||
|
return Align(
|
||||||
|
alignment: .centerRight,
|
||||||
|
child: Wrap(
|
||||||
|
spacing: -overlap,
|
||||||
|
children: [
|
||||||
|
...receipts
|
||||||
|
.take(shown)
|
||||||
|
.map(
|
||||||
|
(receipt) => Consumer(
|
||||||
|
builder: (context, ref, _) {
|
||||||
|
final member = ref
|
||||||
|
.watch(
|
||||||
|
UserController.provider(
|
||||||
|
.new(
|
||||||
|
roomId: chat.roomId,
|
||||||
|
userId: receipt.userId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.value;
|
||||||
|
final name =
|
||||||
|
member?.displayName ??
|
||||||
|
receipt.userId.localpart;
|
||||||
|
|
||||||
|
return AvatarRing(
|
||||||
|
tooltip: name,
|
||||||
|
onTap: () => context.showUserPopover(
|
||||||
|
receipt.userId,
|
||||||
|
roomId: chat.roomId,
|
||||||
|
),
|
||||||
|
child: AvatarOrHash(
|
||||||
|
member?.avatarUrl,
|
||||||
|
name,
|
||||||
|
dimension: avatarSize,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (receipts.length > shown)
|
||||||
|
AvatarRing(
|
||||||
|
tooltip: "Show everyone who has read up to here",
|
||||||
|
onTap: () => context.showAdaptiveSheet(
|
||||||
|
(_) => ReadReceiptsSheet(eventId, chat: chat),
|
||||||
|
),
|
||||||
|
child: ColoredBox(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
"+${receipts.length - shown}",
|
||||||
|
style: theme.textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
lib/widgets/read_receipts_sheet.dart
Normal file
55
lib/widgets/read_receipts_sheet.dart
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/controllers/read_receipts.dart";
|
||||||
|
import "package:nexus/controllers/room_chat.dart";
|
||||||
|
import "package:nexus/controllers/user.dart";
|
||||||
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
|
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||||
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
|
import "package:nexus/widgets/sheet_layout.dart";
|
||||||
|
|
||||||
|
/// Everyone who has read up to an event, kept up to date while open.
|
||||||
|
class const ReadReceiptsSheet(
|
||||||
|
final String eventId, {
|
||||||
|
required final ChatParams chat,
|
||||||
|
super.key,
|
||||||
|
}) extends ConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) => SheetLayout(
|
||||||
|
title: Text("Read by"),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: Navigator.of(context).pop, child: Text("Close")),
|
||||||
|
],
|
||||||
|
children: ref
|
||||||
|
.watch(
|
||||||
|
ReadReceiptsController.provider(chat)
|
||||||
|
.select((receipts) => receipts[eventId] ?? const .empty()),
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(receipt) => Consumer(
|
||||||
|
builder: (context, ref, _) {
|
||||||
|
final member = ref
|
||||||
|
.watch(
|
||||||
|
UserController.provider(
|
||||||
|
.new(roomId: chat.roomId, userId: receipt.userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.value;
|
||||||
|
final name = member?.displayName ?? receipt.userId.localpart;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: .zero,
|
||||||
|
leading: AvatarOrHash(member?.avatarUrl, name, dimension: 40),
|
||||||
|
title: Text(name, overflow: .ellipsis),
|
||||||
|
subtitle: Text(receipt.userId, overflow: .ellipsis),
|
||||||
|
onTap: () => context.showUserPopover(
|
||||||
|
receipt.userId,
|
||||||
|
roomId: chat.roomId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,27 +7,26 @@ import "package:nexus/controllers/event.dart";
|
||||||
import "package:nexus/controllers/room_chat.dart";
|
import "package:nexus/controllers/room_chat.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
import "package:nexus/controllers/rooms.dart";
|
||||||
import "package:nexus/controllers/settings.dart";
|
import "package:nexus/controllers/settings.dart";
|
||||||
|
import "package:nexus/helpers/extensions/is_shown_as_message.dart";
|
||||||
import "package:nexus/helpers/extensions/show_adaptive_menu.dart";
|
import "package:nexus/helpers/extensions/show_adaptive_menu.dart";
|
||||||
import "package:nexus/helpers/hooks/chat_scroll.dart";
|
import "package:nexus/helpers/hooks/chat_scroll.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/content.dart";
|
import "package:nexus/models/content/content.dart";
|
||||||
import "package:nexus/models/content/create.dart";
|
import "package:nexus/models/content/create.dart";
|
||||||
import "package:nexus/models/content/encrypted.dart";
|
|
||||||
import "package:nexus/models/content/history_visibility.dart";
|
import "package:nexus/models/content/history_visibility.dart";
|
||||||
import "package:nexus/models/content/join_rules.dart";
|
import "package:nexus/models/content/join_rules.dart";
|
||||||
import "package:nexus/models/content/membership.dart";
|
import "package:nexus/models/content/membership.dart";
|
||||||
import "package:nexus/models/content/message.dart";
|
|
||||||
import "package:nexus/models/content/pinned_events.dart";
|
import "package:nexus/models/content/pinned_events.dart";
|
||||||
import "package:nexus/models/content/power_levels.dart";
|
import "package:nexus/models/content/power_levels.dart";
|
||||||
import "package:nexus/models/content/server_acl.dart";
|
import "package:nexus/models/content/server_acl.dart";
|
||||||
import "package:nexus/models/content/sticker.dart";
|
|
||||||
import "package:nexus/models/content/topic.dart";
|
import "package:nexus/models/content/topic.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/widgets/error_dialog.dart";
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
||||||
import "package:nexus/widgets/renderers/message.dart";
|
import "package:nexus/widgets/renderers/message.dart";
|
||||||
import "package:nexus/widgets/reaction_row.dart";
|
import "package:nexus/widgets/reaction_row.dart";
|
||||||
|
import "package:nexus/widgets/read_receipts_row.dart";
|
||||||
import "package:nexus/widgets/renderers/membership.dart";
|
import "package:nexus/widgets/renderers/membership.dart";
|
||||||
import "package:nexus/widgets/renderers/generic_event.dart";
|
import "package:nexus/widgets/renderers/generic_event.dart";
|
||||||
import "package:nexus/widgets/swipe_to_reply.dart";
|
import "package:nexus/widgets/swipe_to_reply.dart";
|
||||||
|
|
@ -44,6 +43,10 @@ class const EventRenderer(
|
||||||
|
|
||||||
/// If set, events can be swiped, calling this
|
/// If set, events can be swiped, calling this
|
||||||
final VoidCallback? onSwipe,
|
final VoidCallback? onSwipe,
|
||||||
|
|
||||||
|
/// If set, shows who in this chat has read up to this event, e.g. in the
|
||||||
|
/// timeline.
|
||||||
|
final ChatParams? readReceiptsChat,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends HookConsumerWidget {
|
}) extends HookConsumerWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -96,9 +99,7 @@ class const EventRenderer(
|
||||||
}
|
}
|
||||||
|
|
||||||
final isGrouped =
|
final isGrouped =
|
||||||
previousEvent?.content is MessageContent &&
|
(previousEvent?.isShownAsMessage ?? false) &&
|
||||||
previousEvent?.redactedBy == null &&
|
|
||||||
previousEvent?.relationType != "m.replace" &&
|
|
||||||
event.sender == previousEvent?.sender &&
|
event.sender == previousEvent?.sender &&
|
||||||
event.pmp?.id == previousEvent?.pmp?.id;
|
event.pmp?.id == previousEvent?.pmp?.id;
|
||||||
|
|
||||||
|
|
@ -111,7 +112,26 @@ class const EventRenderer(
|
||||||
TextSpan(text: text),
|
TextSpan(text: text),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final child = event.redactedBy != null || event.relationType == "m.replace"
|
final child = event.isShownAsMessage
|
||||||
|
? MessageRenderer(
|
||||||
|
event,
|
||||||
|
onTapReply: jumpToEvent == null
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final replyEvent = await ref.read(
|
||||||
|
EventController.provider(
|
||||||
|
.new(roomId: roomId, eventId: event.replyTo!),
|
||||||
|
).future,
|
||||||
|
);
|
||||||
|
if (replyEvent != null) {
|
||||||
|
await jumpToEvent!(replyEvent.rowId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isGrouped: isGrouped,
|
||||||
|
maxLines: maxLines,
|
||||||
|
textOnly: textOnly,
|
||||||
|
)
|
||||||
|
: event.redactedBy != null || event.relationType == "m.replace"
|
||||||
? null
|
? null
|
||||||
: switch (event.content) {
|
: switch (event.content) {
|
||||||
Content(:final parseError?, :final stackTrace) => Row(
|
Content(:final parseError?, :final stackTrace) => Row(
|
||||||
|
|
@ -123,27 +143,6 @@ class const EventRenderer(
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
MessageContent() ||
|
|
||||||
EncryptedContent() ||
|
|
||||||
StickerContent() => MessageRenderer(
|
|
||||||
event,
|
|
||||||
onTapReply: jumpToEvent == null
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
final replyEvent = await ref.read(
|
|
||||||
EventController.provider(
|
|
||||||
.new(roomId: roomId, eventId: event.replyTo!),
|
|
||||||
).future,
|
|
||||||
);
|
|
||||||
if (replyEvent != null) {
|
|
||||||
await jumpToEvent!(replyEvent.rowId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isGrouped: isGrouped,
|
|
||||||
maxLines: maxLines,
|
|
||||||
textOnly: textOnly,
|
|
||||||
),
|
|
||||||
|
|
||||||
MembershipContent content => switch (event.previousContent) {
|
MembershipContent content => switch (event.previousContent) {
|
||||||
MembershipContent(:final status) =>
|
MembershipContent(:final status) =>
|
||||||
status == content.status
|
status == content.status
|
||||||
|
|
@ -285,6 +284,10 @@ class const EventRenderer(
|
||||||
...[
|
...[
|
||||||
if (child is! MessageRenderer) ReactionRow(event),
|
if (child is! MessageRenderer) ReactionRow(event),
|
||||||
|
|
||||||
|
// Outside the message itself, so it doesn't move when swiped
|
||||||
|
if (readReceiptsChat case final chat? when event.isShownAsMessage)
|
||||||
|
ReadReceiptsRow(event.eventId, chat: chat),
|
||||||
|
|
||||||
if (event.sendError != null && event.sendError != "not sent")
|
if (event.sendError != null && event.sendError != "not sent")
|
||||||
Padding(
|
Padding(
|
||||||
padding: .only(bottom: 4),
|
padding: .only(bottom: 4),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/controllers/room_chat.dart";
|
||||||
import "package:nexus/helpers/hooks/chat_scroll.dart";
|
import "package:nexus/helpers/hooks/chat_scroll.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/widgets/renderers/event.dart";
|
import "package:nexus/widgets/renderers/event.dart";
|
||||||
|
|
@ -7,7 +8,7 @@ import "package:nexus/widgets/highlight_wrapper.dart";
|
||||||
import "package:super_sliver_list/super_sliver_list.dart";
|
import "package:super_sliver_list/super_sliver_list.dart";
|
||||||
|
|
||||||
class const ChatTimeline({
|
class const ChatTimeline({
|
||||||
required final String roomId,
|
required final ChatParams chat,
|
||||||
required final ChatScroll scroll,
|
required final ChatScroll scroll,
|
||||||
required final JumpToEvent jumpToEvent,
|
required final JumpToEvent jumpToEvent,
|
||||||
required final IList<PopupMenuEntry> Function(Event) getEventOptions,
|
required final IList<PopupMenuEntry> Function(Event) getEventOptions,
|
||||||
|
|
@ -27,10 +28,11 @@ class const ChatTimeline({
|
||||||
EventRenderer(
|
EventRenderer(
|
||||||
eventRowId,
|
eventRowId,
|
||||||
previousEventRowId: previousEventRowId,
|
previousEventRowId: previousEventRowId,
|
||||||
roomId: roomId,
|
roomId: chat.roomId,
|
||||||
jumpToEvent: jumpToEvent,
|
jumpToEvent: jumpToEvent,
|
||||||
getEventOptions: getEventOptions,
|
getEventOptions: getEventOptions,
|
||||||
onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId),
|
onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId),
|
||||||
|
readReceiptsChat: chat,
|
||||||
),
|
),
|
||||||
key: key,
|
key: key,
|
||||||
isHighlighted: highlightedEvent == eventRowId,
|
isHighlighted: highlightedEvent == eventRowId,
|
||||||
|
|
|
||||||
|
|
@ -85,10 +85,11 @@ final class const RoomChat({
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final controllerProvider = RoomChatController.provider((
|
final ChatParams chat = (
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
contextualEvent: contextualEvent.value,
|
contextualEvent: contextualEvent.value,
|
||||||
));
|
);
|
||||||
|
final controllerProvider = RoomChatController.provider(chat);
|
||||||
final notifier = ref.watch(controllerProvider.notifier);
|
final notifier = ref.watch(controllerProvider.notifier);
|
||||||
|
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
final client = ref.read(ClientController.provider.notifier);
|
||||||
|
|
@ -246,7 +247,7 @@ final class const RoomChat({
|
||||||
AsyncLoading(value: _?) ||
|
AsyncLoading(value: _?) ||
|
||||||
AsyncData(value: _) => ChatTimeline(
|
AsyncData(value: _) => ChatTimeline(
|
||||||
scroll: scroll,
|
scroll: scroll,
|
||||||
roomId: roomId,
|
chat: chat,
|
||||||
jumpToEvent: jumpToEvent,
|
jumpToEvent: jumpToEvent,
|
||||||
getEventOptions: getEventOptions,
|
getEventOptions: getEventOptions,
|
||||||
onSwipe: swipeToReply && canReply
|
onSwipe: swipeToReply && canReply
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue