Skip to content

Commit

Permalink
feat(neon_talk): Add room list
Browse files Browse the repository at this point in the history
Signed-off-by: provokateurin <[email protected]>
  • Loading branch information
provokateurin committed Feb 25, 2024
1 parent a819d28 commit 8905c6a
Show file tree
Hide file tree
Showing 12 changed files with 414 additions and 4 deletions.
4 changes: 4 additions & 0 deletions packages/neon/neon_talk/lib/src/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:neon_talk/src/options.dart';
import 'package:neon_talk/src/pages/main.dart';
import 'package:neon_talk/src/routes.dart';
import 'package:nextcloud/nextcloud.dart';
import 'package:rxdart/rxdart.dart';

/// Implementation of the server `talk` app.
@experimental
Expand Down Expand Up @@ -39,4 +40,7 @@ class TalkApp extends AppImplementation<TalkBloc, TalkOptions> {

@override
final RouteBase route = $talkAppRoute;

@override
BehaviorSubject<int> getUnreadCounter(TalkBloc bloc) => bloc.unreadCounter;
}
51 changes: 49 additions & 2 deletions packages/neon/neon_talk/lib/src/blocs/talk.dart
Original file line number Diff line number Diff line change
@@ -1,19 +1,66 @@
import 'dart:async';

import 'package:built_collection/built_collection.dart';
import 'package:meta/meta.dart';
import 'package:neon_framework/blocs.dart';
import 'package:neon_framework/models.dart';
import 'package:neon_framework/utils.dart';
import 'package:nextcloud/spreed.dart' as spreed;
import 'package:rxdart/rxdart.dart';

/// Bloc for fetching Talk rooms
sealed class TalkBloc implements InteractiveBloc {
/// Creates a new Talk Bloc instance.
@internal
factory TalkBloc(Account account) => _TalkBloc(account);

/// The list of rooms.
BehaviorSubject<Result<BuiltList<spreed.Room>>> get rooms;

/// The total number of unread messages.
BehaviorSubject<int> get unreadCounter;
}

class _TalkBloc extends InteractiveBloc implements TalkBloc {
_TalkBloc(this.account);
_TalkBloc(this.account) {
rooms.listen((result) {
if (!result.hasData) {
return;
}

var unread = 0;
for (final room in result.requireData) {
unread += room.unreadMessages;
}
unreadCounter.add(unread);
});

unawaited(refresh());
}

final Account account;

@override
Future<void> refresh() async {}
final rooms = BehaviorSubject();

@override
final unreadCounter = BehaviorSubject();

@override
void dispose() {
unawaited(rooms.close());
unawaited(unreadCounter.close());
super.dispose();
}

@override
Future<void> refresh() async {
await RequestManager.instance.wrapNextcloud(
account: account,
cacheKey: 'talk-rooms',
subject: rooms,
rawResponse: account.client.spreed.room.getRoomsRaw(),
unwrap: (response) => response.body.ocs.data,
);
}
}
59 changes: 57 additions & 2 deletions packages/neon/neon_talk/lib/src/pages/main.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,65 @@
import 'package:flutter/material.dart';
import 'package:neon_framework/blocs.dart';
import 'package:neon_framework/utils.dart';
import 'package:neon_framework/widgets.dart';
import 'package:neon_talk/src/blocs/talk.dart';
import 'package:neon_talk/src/widgets/unread_indicator.dart';
import 'package:nextcloud/spreed.dart' as spreed;

/// The main page displaying the chat list.
class TalkMainPage extends StatelessWidget {
class TalkMainPage extends StatefulWidget {
/// Creates a new Talk main page.
const TalkMainPage({super.key});

@override
Widget build(BuildContext context) => const Placeholder();
State<TalkMainPage> createState() => _TalkMainPageState();
}

class _TalkMainPageState extends State<TalkMainPage> {
late TalkBloc bloc;

@override
void initState() {
super.initState();

bloc = NeonProvider.of<TalkBloc>(context);

bloc.errors.listen((error) {
NeonError.showSnackbar(context, error);
});
}

@override
Widget build(BuildContext context) => ResultBuilder.behaviorSubject(
subject: bloc.rooms,
builder: (context, rooms) => NeonListView(
scrollKey: 'talk-rooms',
isLoading: rooms.isLoading,
error: rooms.error,
onRefresh: bloc.refresh,
itemCount: rooms.data?.length ?? 0,
itemBuilder: (context, index) => buildRoom(rooms.requireData[index]),
),
);

Widget buildRoom(spreed.Room room) => ListTile(
title: Text(room.displayName),
subtitle: buildMessagePreview(room.lastMessage.chatMessage),
trailing: TalkUnreadIndicator(
room: room,
),
);

Widget buildMessagePreview(spreed.ChatMessage? chatMessage) {
final buffer = StringBuffer();

if (chatMessage != null) {
buffer.write('${chatMessage.actorDisplayName}: ${chatMessage.message}');
}

return Text(
buffer.toString(),
maxLines: 1,
);
}
}
54 changes: 54 additions & 0 deletions packages/neon/neon_talk/lib/src/widgets/unread_indicator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:nextcloud/spreed.dart' as spreed;

/// Displays the number of unread messages and whether the user was mentioned directly or indirectly for a given [room].
class TalkUnreadIndicator extends StatelessWidget {
/// Creates a new Talk unread indicator.
const TalkUnreadIndicator({
required this.room,
super.key,
});

/// The room that the indicator will display unread messages and mentions for.
final spreed.Room room;

@override
Widget build(BuildContext context) {
if (room.unreadMessages == 0) {
return const SizedBox.shrink();
}

final Color textColor;
final Color? backgroundColor;
final Color borderColor;

if (room.unreadMentionDirect || spreed.RoomType.fromValue(room.type).isSingleUser) {
textColor = Theme.of(context).colorScheme.onPrimary;
backgroundColor = Theme.of(context).colorScheme.primary;
borderColor = Theme.of(context).colorScheme.onBackground;
} else {
textColor = Theme.of(context).colorScheme.onBackground;
backgroundColor = null;
borderColor = room.unreadMention ? Theme.of(context).colorScheme.primary : Colors.grey;
}

return Chip(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(50)),
side: BorderSide(
color: borderColor,
),
),
padding: const EdgeInsets.all(2),
backgroundColor: backgroundColor,
label: Text(
room.unreadMessages.toString(),
style: TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
color: textColor,
),
),
);
}
}
5 changes: 5 additions & 0 deletions packages/neon/neon_talk/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,15 @@ dependencies:
url: https://github.com/nextcloud/neon
path: packages/neon_framework
nextcloud: ^5.0.2
rxdart: ^0.27.0

dev_dependencies:
build_runner: ^2.4.8
flutter_test:
sdk: flutter
go_router_builder: ^2.4.1
http: ^1.2.1
mocktail: ^1.0.3
neon_lints:
git:
url: https://github.com/nextcloud/neon
Expand Down
134 changes: 134 additions & 0 deletions packages/neon/neon_talk/test/bloc_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import 'dart:convert';

import 'package:built_collection/built_collection.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart';
import 'package:mocktail/mocktail.dart';
import 'package:neon_framework/blocs.dart';
import 'package:neon_framework/models.dart';
import 'package:neon_framework/testing.dart';
import 'package:neon_talk/src/blocs/talk.dart';

Map<String, dynamic> getRoom({
required int id,
required int unreadMessages,
}) =>
{
'actorId': '',
'actorType': '',
'attendeeId': 0,
'attendeePermissions': 0,
'avatarVersion': '',
'breakoutRoomMode': 0,
'breakoutRoomStatus': 0,
'callFlag': 0,
'callPermissions': 0,
'callRecording': 0,
'callStartTime': 0,
'canDeleteConversation': false,
'canEnableSIP': false,
'canLeaveConversation': false,
'canStartCall': false,
'defaultPermissions': 0,
'description': '',
'displayName': '',
'hasCall': false,
'hasPassword': false,
'id': id,
'isFavorite': false,
'lastActivity': 0,
'lastCommonReadMessage': 0,
'lastMessage': <dynamic>[],
'lastPing': 0,
'lastReadMessage': 0,
'listable': 0,
'lobbyState': 0,
'lobbyTimer': 0,
'messageExpiration': 0,
'name': '',
'notificationCalls': 0,
'notificationLevel': 0,
'objectId': '',
'objectType': '',
'participantFlags': 0,
'participantType': 0,
'permissions': 0,
'readOnly': 0,
'sessionId': '',
'sipEnabled': 0,
'token': '',
'type': 0,
'unreadMention': false,
'unreadMentionDirect': false,
'unreadMessages': unreadMessages,
};

Account mockTalkAccount() => mockServer({
RegExp(r'/ocs/v2\.php/apps/spreed/api/v4/room'): {
'get': (match, queryParameters) => Response(
json.encode({
'ocs': {
'meta': {'status': '', 'statuscode': 0},
'data': [
getRoom(
id: 0,
unreadMessages: 0,
),
getRoom(
id: 1,
unreadMessages: 1,
),
getRoom(
id: 2,
unreadMessages: 2,
),
],
},
}),
200,
),
},
});

void main() {
late Account account;
late TalkBloc bloc;

setUpAll(() {
final storage = MockNeonStorage();
when(() => storage.requestCache).thenReturn(null);
});

setUp(() {
account = mockTalkAccount();
bloc = TalkBloc(account);
});

tearDown(() {
bloc.dispose();
});

test('refresh', () async {
expect(
bloc.rooms.transformResult((e) => BuiltList<int>(e.map((r) => r.id))),
emitsInOrder([
Result<BuiltList<int>>.loading(),
Result.success(BuiltList<int>([0, 1, 2])),
Result.success(BuiltList<int>([0, 1, 2])).asLoading(),
Result.success(BuiltList<int>([0, 1, 2])),
]),
);
expect(
bloc.unreadCounter,
emitsInOrder([
3,
3,
3,
]),
);

// The delay is necessary to avoid a race condition with loading twice at the same time
await Future<void>.delayed(const Duration(milliseconds: 1));
await bloc.refresh();
});
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 8905c6a

Please sign in to comment.