Compare commits
10 commits
13c2a4062b
...
a08c200d65
| Author | SHA1 | Date | |
|---|---|---|---|
|
a08c200d65 |
|||
|
228ff1051f |
|||
|
2451555479 |
|||
|
a28592d11e |
|||
|
7016cc4205 |
|||
|
e4f091cb0f |
|||
|
49d480d1e6 |
|||
|
7850117cb6 |
|||
|
fd5eaa2725 |
|||
|
1834ae2c5b |
18 changed files with 415 additions and 270 deletions
|
|
@ -1,3 +1,5 @@
|
||||||
|
# Development Documentation
|
||||||
|
|
||||||
## Build instructions
|
## Build instructions
|
||||||
|
|
||||||
CBuild instructions can be found in [README.md](./README.md#build-it-yourself).
|
CBuild instructions can be found in [README.md](./README.md#build-it-yourself).
|
||||||
|
|
@ -9,3 +11,54 @@ You can run the following command to update the Gomuks submodule:
|
||||||
```sh
|
```sh
|
||||||
git submodule update --remote
|
git submodule update --remote
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
### Controllers and Helpers ([Riverpod](https://pub.dev/packages/riverpod))
|
||||||
|
|
||||||
|
Controllers live in `lib/controllers/` and provide a source that exposes data and logic via Riverpod providers, allowing other parts of the code to watch state changes with ref.watch (`ref.watch(MyController.provider)`), access the current value with ref.read (`ref.read(MyController.provider)`), and run helper methods on those classes using the notifier:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
ref.watch(MyController.provider.notifier).helperMethod()
|
||||||
|
```
|
||||||
|
|
||||||
|
We use an object oriented style for controllers, where `provider` is a static member on the controller class. E.g.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class MyController extends AsyncNotifier<DataThisControllerExposes> {
|
||||||
|
final SomeInputType input;
|
||||||
|
MyController(this.input);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DataThisControllerExposes> build() async {
|
||||||
|
return input.foo;
|
||||||
|
}
|
||||||
|
|
||||||
|
static final provider =
|
||||||
|
AsyncNotifierProvider.family<MyController, DataThisControllerExposes, SomeInputType>(
|
||||||
|
AuthorController.new,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Providers which are not controllers, e.g. they expose no data, only methods, should instead live in `lib/helpers/`. For an example, see `lib/helpers/launch_helper.dart`. Other, non-provider helpers, like extensions or helper methods can also go in `lib/helpers/`.
|
||||||
|
|
||||||
|
### Don't use StatefulWidgets ([Flutter Hooks](https://pub.dev/packages/flutter_hooks))
|
||||||
|
|
||||||
|
This project uses Flutter Hooks to help with boilerplate that StatefulWidgets create. Instead of using a StatefulWidget, we just use hooks like `useState` or `useEffect` in the build method of a `HookWidget`, which is a drop in replacement for `StatelessWidget`. If you need both a `WidgetRef` to watch providers, and access to hooks, use `HookConsumerWidget`.
|
||||||
|
|
||||||
|
### Models ([Freezed](https://pub.dev/packages/freezed))
|
||||||
|
|
||||||
|
We use Freezed for our models to avoid boilerplate and enforce an immutable style of state and data modeling throughout the code. See their documentation for more info, or see our existing models in `lib/models/`.
|
||||||
|
|
||||||
|
### Immutable Data Collections ([Fast Immutable Collections](https://pub.dev/packages/fast_immutable_collections))
|
||||||
|
|
||||||
|
When possible, use immutable collections instead of the mutable equivalent. For example, use `IMap` over `Map`, `IList` over `List`, `ISet` over `Set`. This matches the immutable style of Riverpod and Freezed.
|
||||||
|
|
||||||
|
### Don't create globals
|
||||||
|
|
||||||
|
When possible, we prefer not to create global variables or methods. You can usually replace a global variable with a Riverpod controller, and a global method with an extension method.
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
All contributions must follow the [Federated Nexus Code of Conduct](https://federated.nexus/code/).
|
||||||
|
|
|
||||||
56
lib/controllers/reactions_controller.dart
Normal file
56
lib/controllers/reactions_controller.dart
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:nexus/controllers/client_controller.dart";
|
||||||
|
import "package:nexus/controllers/rooms_controller.dart";
|
||||||
|
import "package:nexus/models/configs/reactions_config.dart";
|
||||||
|
import "package:nexus/models/content/reaction.dart";
|
||||||
|
import "package:nexus/models/requests/get_related_events_request.dart";
|
||||||
|
|
||||||
|
class ReactionsController extends AsyncNotifier<IMap<String, IList<String>>> {
|
||||||
|
final ReactionsConfig config;
|
||||||
|
ReactionsController(this.config);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<IMap<String, IList<String>>> build() async {
|
||||||
|
final eventInfo = ref.watch(
|
||||||
|
RoomsController.provider.select((value) {
|
||||||
|
final event = value[config.roomId]?.events[config.eventRowId];
|
||||||
|
return event == null ? null : (event.eventId, event.reactions);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
final reactionEvents = eventInfo?.$2.isNotEmpty == true
|
||||||
|
? await ref
|
||||||
|
.watch(ClientController.provider.notifier)
|
||||||
|
.getRelatedEvents(
|
||||||
|
GetRelatedEventsRequest(
|
||||||
|
roomId: config.roomId,
|
||||||
|
eventId: eventInfo!.$1,
|
||||||
|
relationType: "m.annotation",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return reactionEvents
|
||||||
|
?.where((event) => event.redactedBy == null)
|
||||||
|
.fold<IMap<String, IList<String>>>(IMap(), (acc, event) {
|
||||||
|
if (event.content case ReactionContent(:final key?)) {
|
||||||
|
return acc.update(
|
||||||
|
key,
|
||||||
|
(list) => list.add(event.sender),
|
||||||
|
ifAbsent: () => IList([event.sender]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}) ??
|
||||||
|
const IMap.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
static final provider =
|
||||||
|
AsyncNotifierProvider.family<
|
||||||
|
ReactionsController,
|
||||||
|
IMap<String, IList<String>>,
|
||||||
|
ReactionsConfig
|
||||||
|
>(ReactionsController.new);
|
||||||
|
}
|
||||||
|
|
@ -37,13 +37,14 @@ class RoomChatController extends AsyncNotifier<IList<Event>> {
|
||||||
await ref.read(RoomsController.provider.notifier).addState(roomId, state);
|
await ref.read(RoomsController.provider.notifier).addState(roomId, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// While there are under 30 messages, try up to load more messages until there's no more or we have 20 messages.
|
// While there are under 20 events, try to load more
|
||||||
if (room.hasMore && room.timeline.length < 30) {
|
// until there's no more or the conditions are met.
|
||||||
|
if (room.hasMore && room.timeline.length < 20) {
|
||||||
loadOlder();
|
loadOlder();
|
||||||
}
|
}
|
||||||
|
|
||||||
return room.timeline
|
return room.timeline
|
||||||
.toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0))
|
.toEntryIList(compare: (a, b) => (b?.key ?? 0).compareTo(a?.key ?? 0))
|
||||||
.map((entry) {
|
.map((entry) {
|
||||||
if (entry.value == null) return null;
|
if (entry.value == null) return null;
|
||||||
|
|
||||||
|
|
|
||||||
6
lib/helpers/extensions/string_to_color.dart
Normal file
6
lib/helpers/extensions/string_to_color.dart
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import "package:color_hash/color_hash.dart";
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
|
||||||
|
extension ToColor on String {
|
||||||
|
Color get colorHash => ColorHash(this, lightness: .7, saturation: .7).color;
|
||||||
|
}
|
||||||
|
|
@ -67,8 +67,8 @@ void main() async {
|
||||||
await windowManager.setMinimumSize(Size.square(500));
|
await windowManager.setMinimumSize(Size.square(500));
|
||||||
}
|
}
|
||||||
|
|
||||||
// FlutterError.onError = (FlutterErrorDetails details) =>
|
FlutterError.onError = (FlutterErrorDetails details) =>
|
||||||
// showError(details.exception.toString(), details.stack);
|
showError(details.exception.toString(), details.stack);
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
|
|
|
||||||
14
lib/models/configs/reactions_config.dart
Normal file
14
lib/models/configs/reactions_config.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
part "reactions_config.freezed.dart";
|
||||||
|
part "reactions_config.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ReactionsConfig with _$ReactionsConfig {
|
||||||
|
const factory ReactionsConfig({
|
||||||
|
required String roomId,
|
||||||
|
required int eventRowId,
|
||||||
|
}) = _ReactionsConfig;
|
||||||
|
|
||||||
|
factory ReactionsConfig.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$ReactionsConfigFromJson(json);
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,7 @@ class VerifyPage extends HookConsumerWidget {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"Enter your recovery key or passphrase below to unlock encrypted messages.\nYour passphrase is usually not the same as your password.",
|
"Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.",
|
||||||
),
|
),
|
||||||
SizedBox(height: 12),
|
SizedBox(height: 12),
|
||||||
FormTextInput(
|
FormTextInput(
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import "package:flutter/material.dart";
|
|
||||||
import "package:nexus/models/event.dart";
|
|
||||||
import "package:nexus/widgets/reaction_row.dart";
|
|
||||||
|
|
||||||
class EventWrapper extends StatelessWidget {
|
|
||||||
final Event event;
|
|
||||||
final Widget child;
|
|
||||||
final bool isFlashing;
|
|
||||||
const EventWrapper(
|
|
||||||
this.event,
|
|
||||||
this.child, {
|
|
||||||
this.isFlashing = false,
|
|
||||||
super.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return ClipRRect(
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
|
||||||
child: AnimatedContainer(
|
|
||||||
padding: isFlashing ? EdgeInsets.all(8) : EdgeInsets.all(0),
|
|
||||||
color: isFlashing
|
|
||||||
? Theme.of(context).colorScheme.onSurface.withAlpha(50)
|
|
||||||
: Colors.transparent,
|
|
||||||
duration: Duration(milliseconds: 250),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
child,
|
|
||||||
if (event.sendError != null && event.sendError != "not sent")
|
|
||||||
Text(
|
|
||||||
event.sendError!,
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: theme.colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ReactionRow(event),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
20
lib/widgets/flash_wrapper.dart
Normal file
20
lib/widgets/flash_wrapper.dart
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
|
||||||
|
class FlashWrapper extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
final bool isFlashing;
|
||||||
|
const FlashWrapper(this.child, {this.isFlashing = false, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => ClipRRect(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
padding: isFlashing ? EdgeInsets.all(8) : EdgeInsets.all(0),
|
||||||
|
color: isFlashing
|
||||||
|
? Theme.of(context).colorScheme.onSurface.withAlpha(50)
|
||||||
|
: Colors.transparent,
|
||||||
|
duration: Duration(milliseconds: 250),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import "package:color_hash/color_hash.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:nexus/controllers/author_controller.dart";
|
import "package:nexus/controllers/author_controller.dart";
|
||||||
import "package:nexus/helpers/extensions/better_when.dart";
|
import "package:nexus/helpers/extensions/better_when.dart";
|
||||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||||
|
import "package:nexus/helpers/extensions/string_to_color.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
|
|
||||||
class MessageDisplayname extends ConsumerWidget {
|
class MessageDisplayname extends ConsumerWidget {
|
||||||
|
|
@ -35,11 +35,7 @@ class MessageDisplayname extends ConsumerWidget {
|
||||||
style:
|
style:
|
||||||
style ??
|
style ??
|
||||||
TextStyle(
|
TextStyle(
|
||||||
color: ColorHash(
|
color: event.sender.colorHash,
|
||||||
event.sender,
|
|
||||||
lightness: .7,
|
|
||||||
saturation: .7,
|
|
||||||
).color,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import "package:color_hash/color_hash.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";
|
||||||
import "package:nexus/controllers/members_by_status_controller.dart";
|
import "package:nexus/controllers/members_by_status_controller.dart";
|
||||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||||
|
import "package:nexus/helpers/extensions/string_to_color.dart";
|
||||||
import "package:nexus/models/configs/members_by_status_config.dart";
|
import "package:nexus/models/configs/members_by_status_config.dart";
|
||||||
import "package:nexus/models/content/membership.dart";
|
import "package:nexus/models/content/membership.dart";
|
||||||
import "package:nexus/models/membership_status.dart";
|
import "package:nexus/models/membership_status.dart";
|
||||||
|
|
@ -94,11 +94,7 @@ class MemberList extends HookConsumerWidget {
|
||||||
displayName ?? member.stateKey!.localpart,
|
displayName ?? member.stateKey!.localpart,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: ColorHash(
|
color: member.stateKey!.colorHash,
|
||||||
member.stateKey!,
|
|
||||||
lightness: .7,
|
|
||||||
saturation: .8,
|
|
||||||
).color,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -61,39 +61,42 @@ class AudioPlayer extends HookConsumerWidget {
|
||||||
return "$minutes:$seconds";
|
return "$minutes:$seconds";
|
||||||
}
|
}
|
||||||
|
|
||||||
return Card(
|
return SizedBox(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
height: 60,
|
||||||
child: Padding(
|
child: Card(
|
||||||
padding: EdgeInsetsGeometry.only(left: 8, right: 16),
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
child: Row(
|
child: Padding(
|
||||||
children: [
|
padding: EdgeInsetsGeometry.only(left: 8, right: 16),
|
||||||
IconButton(
|
child: Row(
|
||||||
onPressed: player.playOrPause,
|
children: [
|
||||||
icon: Icon(
|
IconButton(
|
||||||
playing.value ? Icons.pause_circle : Icons.play_circle,
|
onPressed: player.playOrPause,
|
||||||
|
icon: Icon(
|
||||||
|
playing.value ? Icons.pause_circle : Icons.play_circle,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
SizedBox(width: 8),
|
||||||
SizedBox(width: 8),
|
Text(
|
||||||
Text(
|
format(position.value),
|
||||||
format(position.value),
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Slider(
|
|
||||||
min: 0,
|
|
||||||
max: duration.value.inMilliseconds <= 0
|
|
||||||
? 1
|
|
||||||
: duration.value.inMilliseconds.toDouble(),
|
|
||||||
value: position.value.inMilliseconds.toDouble(),
|
|
||||||
onChanged: (value) =>
|
|
||||||
player.seek(Duration(milliseconds: value.toInt())),
|
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
Text(
|
child: Slider(
|
||||||
format(duration.value),
|
min: 0,
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
max: duration.value.inMilliseconds <= 0
|
||||||
),
|
? 1
|
||||||
],
|
: duration.value.inMilliseconds.toDouble(),
|
||||||
|
value: position.value.inMilliseconds.toDouble(),
|
||||||
|
onChanged: (value) =>
|
||||||
|
player.seek(Duration(milliseconds: value.toInt())),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
format(duration.value),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,15 @@ import "package:flutter_hooks/flutter_hooks.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/cross_cache_controller.dart";
|
import "package:nexus/controllers/cross_cache_controller.dart";
|
||||||
|
import "package:nexus/controllers/reactions_controller.dart";
|
||||||
import "package:nexus/controllers/room_chat_controller.dart";
|
import "package:nexus/controllers/room_chat_controller.dart";
|
||||||
import "package:nexus/helpers/extensions/get_headers.dart";
|
import "package:nexus/helpers/extensions/get_headers.dart";
|
||||||
import "package:nexus/helpers/extensions/mxc_to_https.dart";
|
import "package:nexus/helpers/extensions/mxc_to_https.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/models/configs/reactions_config.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
|
import "package:nexus/main.dart";
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
|
||||||
class ReactionRow extends ConsumerWidget {
|
class ReactionRow extends ConsumerWidget {
|
||||||
final Event event;
|
final Event event;
|
||||||
|
|
@ -18,100 +22,99 @@ class ReactionRow extends ConsumerWidget {
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final clientState = ref.watch(ClientStateController.provider);
|
final clientState = ref.watch(ClientStateController.provider);
|
||||||
|
|
||||||
return SizedBox.shrink();
|
return switch (ref.watch(
|
||||||
|
ReactionsController.provider(
|
||||||
|
ReactionsConfig(roomId: event.roomId, eventRowId: event.rowId),
|
||||||
|
),
|
||||||
|
)) {
|
||||||
|
AsyncData(value: final IMap<String, IList<String>>? reactors) ||
|
||||||
|
AsyncLoading(value: final reactors) => Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: event.reactions
|
||||||
|
.where((_, value) => value != 0)
|
||||||
|
.mapTo(
|
||||||
|
(reaction, count) => HookBuilder(
|
||||||
|
builder: (context) {
|
||||||
|
final enabled = useState(true);
|
||||||
|
|
||||||
// TODO: IMPL
|
final selected =
|
||||||
// return Wrap(
|
reactors?[reaction]?.contains(clientState!.userId) ??
|
||||||
// spacing: 4,
|
false;
|
||||||
// runSpacing: 4,
|
return Tooltip(
|
||||||
// children: clientState?.homeserverUrl == null
|
message: reactors?[reaction]?.join(", ") ?? "",
|
||||||
// ? []
|
child: ChoiceChip(
|
||||||
// : event.reactions
|
showCheckmark: false,
|
||||||
// .mapTo(
|
selected: selected,
|
||||||
// (reaction, reactors) => HookBuilder(
|
label: Row(
|
||||||
// builder: (context) {
|
mainAxisSize: MainAxisSize.min,
|
||||||
// final enabled = useState(true);
|
spacing: 8,
|
||||||
// final selected = reactors.contains(clientState!.userId);
|
children: [
|
||||||
// return Tooltip(
|
Flexible(
|
||||||
// message: reactors.join(", "),
|
child: reaction.startsWith("mxc://")
|
||||||
// child: ChoiceChip(
|
? Image(
|
||||||
// showCheckmark: false,
|
height: 20,
|
||||||
// selected: selected,
|
image: CachedNetworkImage(
|
||||||
// label: Row(
|
headers: ref.headers,
|
||||||
// mainAxisSize: MainAxisSize.min,
|
Uri.parse(reaction)
|
||||||
// spacing: 8,
|
.mxcToHttps(
|
||||||
// children: [
|
clientState!.homeserverUrl!,
|
||||||
// Flexible(
|
)
|
||||||
// child: reaction.startsWith("mxc://")
|
.toString(),
|
||||||
// ? Image(
|
ref.watch(CrossCacheController.provider),
|
||||||
// height: 20,
|
),
|
||||||
// image: CachedNetworkImage(
|
)
|
||||||
// headers: ref.headers,
|
: Text(
|
||||||
// Uri.parse(reaction)
|
reaction,
|
||||||
// .mxcToHttps(
|
overflow: TextOverflow.ellipsis,
|
||||||
// clientState.homeserverUrl!,
|
),
|
||||||
// )
|
),
|
||||||
// .toString(),
|
Text(
|
||||||
// ref.watch(
|
count.toString(),
|
||||||
// CrossCacheController.provider,
|
overflow: TextOverflow.ellipsis,
|
||||||
// ),
|
),
|
||||||
// ),
|
],
|
||||||
// )
|
),
|
||||||
// : Text(
|
onSelected: enabled.value
|
||||||
// reaction,
|
? (value) async {
|
||||||
// overflow: TextOverflow.ellipsis,
|
enabled.value = false;
|
||||||
// ),
|
try {
|
||||||
// ),
|
final controller = ref.watch(
|
||||||
// Text(
|
RoomChatController.provider(
|
||||||
// reactors.length.toString(),
|
event.roomId,
|
||||||
// overflow: TextOverflow.ellipsis,
|
).notifier,
|
||||||
// ),
|
);
|
||||||
// ],
|
|
||||||
// ),
|
|
||||||
// onSelected: enabled.value
|
|
||||||
// ? (value) async {
|
|
||||||
// enabled.value = false;
|
|
||||||
// try {
|
|
||||||
// final roomId = ref.watch(
|
|
||||||
// SelectedRoomController.provider.select(
|
|
||||||
// (value) => value?.metadata?.id,
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
// if (roomId == null ||
|
|
||||||
// clientState.userId == null) {
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// final controller = ref.watch(
|
if (selected) {
|
||||||
// RoomChatController.provider(
|
await controller
|
||||||
// roomId,
|
.removeReaction(
|
||||||
// ).notifier,
|
reaction,
|
||||||
// );
|
event,
|
||||||
|
clientState!.userId!,
|
||||||
|
)
|
||||||
|
.onError(showError);
|
||||||
|
} else {
|
||||||
|
await controller
|
||||||
|
.sendReaction(reaction, event)
|
||||||
|
.onError(showError);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
enabled.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
|
||||||
// if (selected) {
|
AsyncError(:final error, :final stackTrace) => ErrorDialog(
|
||||||
// await controller
|
error,
|
||||||
// .removeReaction(
|
stackTrace,
|
||||||
// reaction,
|
),
|
||||||
// event,
|
};
|
||||||
// clientState.userId!,
|
|
||||||
// )
|
|
||||||
// .onError(showError);
|
|
||||||
// } else {
|
|
||||||
// await controller
|
|
||||||
// .sendReaction(reaction, event)
|
|
||||||
// .onError(showError);
|
|
||||||
// }
|
|
||||||
// } finally {
|
|
||||||
// enabled.value = true;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// : null,
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
// },
|
|
||||||
// ),
|
|
||||||
// )
|
|
||||||
// .toList(),
|
|
||||||
// );
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,13 @@ import "package:nexus/widgets/expandable_image.dart";
|
||||||
import "package:nexus/widgets/html/html.dart";
|
import "package:nexus/widgets/html/html.dart";
|
||||||
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
|
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
|
||||||
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
||||||
import "package:nexus/widgets/link_preview.dart";
|
import "package:nexus/widgets/url_preview.dart";
|
||||||
import "package:nexus/widgets/loading.dart";
|
import "package:nexus/widgets/loading.dart";
|
||||||
import "package:nexus/widgets/players/video.dart";
|
import "package:nexus/widgets/players/video.dart";
|
||||||
import "package:nexus/widgets/players/audio.dart";
|
import "package:nexus/widgets/players/audio.dart";
|
||||||
|
import "package:nexus/widgets/reaction_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/file_card.dart";
|
import "package:nexus/widgets/file_card.dart";
|
||||||
import "package:timeago/timeago.dart";
|
import "package:timeago/timeago.dart";
|
||||||
import "package:flutter_linkify/flutter_linkify.dart";
|
import "package:flutter_linkify/flutter_linkify.dart";
|
||||||
|
|
@ -214,7 +216,7 @@ class EventRenderer extends ConsumerWidget {
|
||||||
textStyle: textStyle,
|
textStyle: textStyle,
|
||||||
formattedBody!.replaceAllMapped(
|
formattedBody!.replaceAllMapped(
|
||||||
RegExp(
|
RegExp(
|
||||||
r"(<a\b[^>]*>.*?<\/a>)|(\\bhttps?:\/\/[^\s<]+)",
|
r"(<a\b[^>]*>.*?<\/a>)|(\bhttps?:\/\/[^\s<]+)",
|
||||||
caseSensitive: false,
|
caseSensitive: false,
|
||||||
dotAll: true,
|
dotAll: true,
|
||||||
),
|
),
|
||||||
|
|
@ -355,16 +357,21 @@ class EventRenderer extends ConsumerWidget {
|
||||||
style: errorStyle,
|
style: errorStyle,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
if (event.lastEditRowId != 0)
|
if (event.lastEditRowId != 0)
|
||||||
Text(
|
Text(
|
||||||
"(edited)",
|
"(edited)",
|
||||||
style: theme.textTheme.labelSmall,
|
style: theme.textTheme.labelSmall,
|
||||||
),
|
),
|
||||||
|
|
||||||
if (linkify(body).firstWhereOrNull(
|
if (linkify(body).firstWhereOrNull(
|
||||||
(element) => element is UrlElement,
|
(element) => element is UrlElement,
|
||||||
)
|
)
|
||||||
case final UrlElement link?)
|
case final UrlElement link?)
|
||||||
LinkPreview(link.url),
|
UrlPreview(link.url),
|
||||||
|
|
||||||
|
SizedBox(height: 4),
|
||||||
|
ReactionRow(event),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -396,35 +403,49 @@ class EventRenderer extends ConsumerWidget {
|
||||||
content.status
|
content.status
|
||||||
? null
|
? null
|
||||||
: MembershipRenderer(event),
|
: MembershipRenderer(event),
|
||||||
AvatarContent() => Row(
|
AvatarContent() => GenericEventRenderer(Icons.numbers, [
|
||||||
spacing: 4,
|
Padding(
|
||||||
children: [
|
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||||
Padding(
|
child: Icon(Icons.numbers),
|
||||||
padding: EdgeInsets.symmetric(horizontal: 4),
|
),
|
||||||
child: Icon(Icons.numbers),
|
Flexible(child: MessageDisplayname(event)),
|
||||||
),
|
Expanded(child: Text("changed the room avatar")),
|
||||||
Flexible(child: MessageDisplayname(event)),
|
]),
|
||||||
Expanded(child: Text("changed the room avatar")),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return child == null
|
return Column(
|
||||||
? textOnly
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
? Text("Unknown event type", style: errorStyle)
|
children: [
|
||||||
: SizedBox.shrink()
|
if (child != null) ...[
|
||||||
: (textOnly
|
if (textOnly)
|
||||||
? child
|
child
|
||||||
: GestureDetector(
|
else
|
||||||
onSecondaryTapUp: contextMenuCallback,
|
GestureDetector(
|
||||||
onLongPressStart: contextMenuCallback,
|
onSecondaryTapUp: contextMenuCallback,
|
||||||
child: Padding(
|
onLongPressStart: contextMenuCallback,
|
||||||
padding: isGrouped
|
child: Padding(
|
||||||
? EdgeInsets.zero
|
padding: isGrouped ? EdgeInsets.zero : EdgeInsets.only(top: 8),
|
||||||
: EdgeInsets.only(top: 8),
|
child: child,
|
||||||
child: child,
|
),
|
||||||
),
|
),
|
||||||
));
|
|
||||||
|
if (event.content is! MessageContent)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsetsGeometry.only(left: 12),
|
||||||
|
child: ReactionRow(event),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (event.sendError != null && event.sendError != "not sent")
|
||||||
|
Text(
|
||||||
|
event.sendError!,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else if (textOnly)
|
||||||
|
Text("Unknown event type", style: errorStyle),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
lib/widgets/renderers/generic_event.dart
Normal file
22
lib/widgets/renderers/generic_event.dart
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
|
||||||
|
class GenericEventRenderer extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final List<Widget> children;
|
||||||
|
const GenericEventRenderer(this.icon, this.children, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: Icon(Icons.people),
|
||||||
|
),
|
||||||
|
Expanded(child: Wrap(spacing: 4, children: children)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import "package:color_hash/color_hash.dart";
|
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||||
|
import "package:nexus/helpers/extensions/string_to_color.dart";
|
||||||
import "package:nexus/models/content/membership.dart";
|
import "package:nexus/models/content/membership.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/membership_status.dart";
|
import "package:nexus/models/membership_status.dart";
|
||||||
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
||||||
|
import "package:nexus/widgets/renderers/generic_event.dart";
|
||||||
|
|
||||||
class MembershipRenderer extends StatelessWidget {
|
class MembershipRenderer extends StatelessWidget {
|
||||||
final Event event;
|
final Event event;
|
||||||
|
|
@ -19,55 +20,37 @@ class MembershipRenderer extends StatelessWidget {
|
||||||
);
|
);
|
||||||
|
|
||||||
return switch (event.content) {
|
return switch (event.content) {
|
||||||
MembershipContent content => Row(
|
MembershipContent content => GenericEventRenderer(Icons.people, [
|
||||||
spacing: 8,
|
InkWell(
|
||||||
children: [
|
onTapUp: (details) => context.showUserPopover(
|
||||||
Padding(
|
content,
|
||||||
padding: EdgeInsets.symmetric(horizontal: 4),
|
event.stateKey!,
|
||||||
child: Icon(Icons.people),
|
globalPosition: details.globalPosition,
|
||||||
),
|
),
|
||||||
Expanded(
|
child: Text(
|
||||||
child: Wrap(
|
overflow: TextOverflow.ellipsis,
|
||||||
spacing: 4,
|
content.displayName ?? event.stateKey!.localpart,
|
||||||
children: [
|
maxLines: 1,
|
||||||
InkWell(
|
style: TextStyle(
|
||||||
onTapUp: (details) => context.showUserPopover(
|
color: event.sender.colorHash,
|
||||||
content,
|
fontWeight: FontWeight.bold,
|
||||||
event.stateKey!,
|
|
||||||
globalPosition: details.globalPosition,
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
content.displayName ?? event.stateKey!.localpart,
|
|
||||||
maxLines: 1,
|
|
||||||
style: TextStyle(
|
|
||||||
color: ColorHash(
|
|
||||||
event.sender,
|
|
||||||
lightness: .7,
|
|
||||||
saturation: .7,
|
|
||||||
).color,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
maxLines: 1,
|
|
||||||
"${switch (content.status) {
|
|
||||||
MembershipStatus.invite => "was invited to",
|
|
||||||
MembershipStatus.join => "joined",
|
|
||||||
MembershipStatus.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"),
|
|
||||||
MembershipStatus.ban => "was banned from",
|
|
||||||
MembershipStatus.knock => "asked to join",
|
|
||||||
}} the room${event.sender == event.stateKey ? "" : " by "}",
|
|
||||||
),
|
|
||||||
if (event.sender != event.stateKey) MessageDisplayname(event),
|
|
||||||
if (content.reason != null) Text("for \"${content.reason}\""),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
Text(
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
"${switch (content.status) {
|
||||||
|
MembershipStatus.invite => "was invited to",
|
||||||
|
MembershipStatus.join => "joined",
|
||||||
|
MembershipStatus.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"),
|
||||||
|
MembershipStatus.ban => "was banned from",
|
||||||
|
MembershipStatus.knock => "asked to join",
|
||||||
|
}} the room${event.sender == event.stateKey ? "" : " by "}",
|
||||||
|
),
|
||||||
|
if (event.sender != event.stateKey) MessageDisplayname(event),
|
||||||
|
if (content.reason != null) Text("for \"${content.reason}\""),
|
||||||
|
]),
|
||||||
_ => SizedBox.shrink(),
|
_ => SizedBox.shrink(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import "package:nexus/widgets/emoji_picker_button.dart";
|
||||||
import "package:nexus/widgets/renderers/event.dart";
|
import "package:nexus/widgets/renderers/event.dart";
|
||||||
import "package:nexus/widgets/member_list.dart";
|
import "package:nexus/widgets/member_list.dart";
|
||||||
import "package:nexus/widgets/room_appbar.dart";
|
import "package:nexus/widgets/room_appbar.dart";
|
||||||
import "package:nexus/widgets/event_wrapper.dart";
|
import "package:nexus/widgets/flash_wrapper.dart";
|
||||||
import "package:nexus/widgets/error_dialog.dart";
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
import "package:nexus/widgets/form_text_input.dart";
|
import "package:nexus/widgets/form_text_input.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
|
|
@ -319,6 +319,8 @@ class RoomChat extends HookConsumerWidget {
|
||||||
].toIList();
|
].toIList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final controllerData = ref.watch(controllerProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: RoomAppbar(
|
appBar: RoomAppbar(
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
|
|
@ -337,7 +339,7 @@ class RoomChat extends HookConsumerWidget {
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: switch (ref.watch(controllerProvider)) {
|
child: switch (controllerData) {
|
||||||
AsyncData(:final value) ||
|
AsyncData(:final value) ||
|
||||||
AsyncLoading(:final value?) => CustomScrollView(
|
AsyncLoading(:final value?) => CustomScrollView(
|
||||||
reverse: true,
|
reverse: true,
|
||||||
|
|
@ -346,14 +348,14 @@ class RoomChat extends HookConsumerWidget {
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: EdgeInsetsGeometry.only(bottom: 64),
|
padding: EdgeInsetsGeometry.only(bottom: 64),
|
||||||
),
|
),
|
||||||
|
|
||||||
SuperSliverList.builder(
|
SuperSliverList.builder(
|
||||||
listController: listController.value,
|
listController: listController.value,
|
||||||
itemCount: value.length,
|
itemCount: value.length,
|
||||||
itemBuilder: (_, index) {
|
itemBuilder: (_, index) {
|
||||||
final event = value[index];
|
final event = value[index];
|
||||||
final previousEvent = value.getOrNull(index + 1);
|
final previousEvent = value.getOrNull(index + 1);
|
||||||
return EventWrapper(
|
return FlashWrapper(
|
||||||
event,
|
|
||||||
EventRenderer(
|
EventRenderer(
|
||||||
event,
|
event,
|
||||||
onTapReply: () async {
|
onTapReply: () async {
|
||||||
|
|
@ -392,6 +394,20 @@ class RoomChat extends HookConsumerWidget {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 36),
|
||||||
|
child: Center(
|
||||||
|
child: controllerData is AsyncLoading
|
||||||
|
? Loading()
|
||||||
|
: ElevatedButton(
|
||||||
|
onPressed: notifier.loadOlder,
|
||||||
|
child: Text("Load More"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
AsyncLoading() => Loading(),
|
AsyncLoading() => Loading(),
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ import "package:nexus/helpers/extensions/better_when.dart";
|
||||||
import "package:nexus/helpers/extensions/get_headers.dart";
|
import "package:nexus/helpers/extensions/get_headers.dart";
|
||||||
import "package:nexus/helpers/launch_helper.dart";
|
import "package:nexus/helpers/launch_helper.dart";
|
||||||
|
|
||||||
class LinkPreview extends ConsumerWidget {
|
class UrlPreview extends ConsumerWidget {
|
||||||
final String link;
|
final String link;
|
||||||
const LinkPreview(this.link, {super.key});
|
const UrlPreview(this.link, {super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) => ConstrainedBox(
|
Widget build(BuildContext context, WidgetRef ref) => ConstrainedBox(
|
||||||
Loading…
Add table
Add a link
Reference in a new issue