Commit 3c2ec791 authored by Wilko Manger's avatar Wilko Manger

Fix member events not keeping to 1 line

parent 22373dd7
Pipeline #432 passed with stages
in 7 minutes and 15 seconds
A new version has been pushed to F-droid!
- Add ability to create group chats!
- Show chat creation events ('Wilko has created this group')!
- Show emote messages correctly!
- Handle display name changes!
Display names of messages will now be as they
were at time of sending.
- Don't show invite and join events in direct chats
This is only happens for the two initial users in the direct
chat. If someone invites someone else to the direct chat
(trough another client), the invitation will show up
in the timeline.
- Use the `timeout` parameter while syncing.
This means that receiving new messages should be
way quicker! (Thanks Mathieu!)
- Store messages retrieved remotely (thanks Mathieu!)
This means that scrolling up in a chat will be faster
now, because the messages are cached.
- Always show a date header above the oldest event
- Show replies correctly in chat overview
- Show sent state icon next to own message in chat overview
- Show newly joined rooms at the top in the chat overview
- Use a bit bolder font for chat names in overview
![Preview image 1](https://git.pattle.im/pattle/app/raw/v0.6.0/CHANGELOG/0.6.0-1.png)
![Preview image 2](https://git.pattle.im/pattle/app/raw/v0.6.0/CHANGELOG/0.6.0-2.png)
To install this release, add the following repo in F-droid:
https://fdroid.pattle.im/?fingerprint=E91F63CA6AE04F8E7EA53E52242EAF8779559209B8A342F152F9E7265E3EA729
And install 'Pattle'.
Or download the APK from the link.
If you stumble upon any issues,
[please report them](https://git.pattle.im/pattle/app/issues)!
You can now [login via GitHub and Gitlab.com](https://git.pattle.im/users/sign_in)
, so it's really easy to do!
Follow development here: [#app:pattle.im](https://matrix.to/#/#sdk:pattle.im)!
There is now also a room for the Matrix Dart SDK (which is
being developed for Pattle): [#sdk:pattle.im](https://matrix.to/#/#sdk:pattle.im).
If you would like to support me, you can now do so
via [Liberapay](https://liberapay.com/wilko/) and
[Patreon](https://www.patreon.com/pattle_app).
...@@ -29,13 +29,19 @@ import 'package:pattle/src/ui/start/phase/identity/username_page.dart'; ...@@ -29,13 +29,19 @@ import 'package:pattle/src/ui/start/phase/identity/username_page.dart';
import 'package:pattle/src/ui/start/phase/key/password_page.dart'; import 'package:pattle/src/ui/start/phase/key/password_page.dart';
import 'package:pattle/src/ui/start/start_page.dart'; import 'package:pattle/src/ui/start/start_page.dart';
import 'package:pattle/src/ui/main/overview/create/group/create_group_members_page.dart';
import 'ui/main/overview/create/group/create_group_details_page.dart';
final routes = { final routes = {
Routes.root: (Object params) => MaterialPageRoute( Routes.root: (Object params) => MaterialPageRoute(
settings: RouteSettings(name: Routes.root),
builder: (context) => InitialPage() builder: (context) => InitialPage()
), ),
Routes.chats: (Object arguments) => MaterialPageRoute( Routes.chats: (Object arguments) => MaterialPageRoute(
settings: RouteSettings(name: Routes.chats),
builder: (context) { builder: (context) {
if (arguments is Room) { if (arguments is Room) {
return ChatPage(arguments); return ChatPage(arguments);
...@@ -44,19 +50,32 @@ final routes = { ...@@ -44,19 +50,32 @@ final routes = {
} }
} }
), ),
Routes.chatsNew: (Object arguments) => MaterialPageRoute(
settings: RouteSettings(name: Routes.chatsNew),
builder: (context) => CreateGroupMembersPage()
),
Routes.chatsNewDetails: (Object arguments) => MaterialPageRoute(
settings: RouteSettings(name: Routes.chatsNewDetails),
builder: (context) => CreateGroupDetailsPage()
),
Routes.image: (Object arguments) => MaterialPageRoute( Routes.image: (Object arguments) => MaterialPageRoute(
builder: (context) => ImagePage(arguments) settings: RouteSettings(name: Routes.image),
builder: (context) => ImagePage(arguments)
), ),
Routes.start: (Object params) => MaterialPageRoute( Routes.start: (Object params) => MaterialPageRoute(
settings: RouteSettings(name: Routes.start),
builder: (context) => StartPage() builder: (context) => StartPage()
), ),
Routes.startAdvanced: (Object params) => MaterialPageRoute( Routes.startAdvanced: (Object params) => MaterialPageRoute(
settings: RouteSettings(name: Routes.startAdvanced),
builder: (context) => AdvancedPage() builder: (context) => AdvancedPage()
), ),
Routes.startUsername: (Object params) => MaterialPageRoute( Routes.startUsername: (Object params) => MaterialPageRoute(
settings: RouteSettings(name: Routes.startUsername),
builder: (context) => UsernamePage() builder: (context) => UsernamePage()
), ),
Routes.startPassword: (Object params) => MaterialPageRoute( Routes.startPassword: (Object params) => MaterialPageRoute(
settings: RouteSettings(name: Routes.startPassword),
builder: (context) => PasswordPage() builder: (context) => PasswordPage()
), ),
}; };
...@@ -73,6 +92,9 @@ class Routes { ...@@ -73,6 +92,9 @@ class Routes {
static const startAdvanced = '/start/advanced'; static const startAdvanced = '/start/advanced';
static const startUsername = '/start/username'; static const startUsername = '/start/username';
static const startPassword = '/start/password'; static const startPassword = '/start/password';
static const chatsNew = '/chats/new';
static const chatsNewDetails = '/chats/new/details';
} }
......
...@@ -75,7 +75,7 @@ class ChatBloc { ...@@ -75,7 +75,7 @@ class ChatBloc {
// Remember: 'previous' is actually next in time // Remember: 'previous' is actually next in time
RoomEvent previousEvent; RoomEvent previousEvent;
RoomEvent event; RoomEvent event;
await for(event in room.timeline.upTo(_eventCount)) { await for(event in room.timeline.upTo(count: _eventCount)) {
var shouldIgnore = false; var shouldIgnore = false;
// In direct chats, don't show the invite event between this user // In direct chats, don't show the invite event between this user
......
...@@ -41,7 +41,7 @@ class ChatOverviewBloc { ...@@ -41,7 +41,7 @@ class ChatOverviewBloc {
final latestEvent = await room.timeline.all() final latestEvent = await room.timeline.all()
.firstWhere((event) => !ignoredEvents.contains(event.runtimeType), orElse: () => null); .firstWhere((event) => !ignoredEvents.contains(event.runtimeType), orElse: () => null);
var latestEventForSorting = await room.timeline.upTo(10) var latestEventForSorting = await room.timeline.upTo(count: 10)
.firstWhere( .firstWhere(
(event) => (event) =>
(event is! MemberChangeEvent (event is! MemberChangeEvent
......
...@@ -61,7 +61,13 @@ class ChatOverviewPageState extends State<ChatOverviewPage> { ...@@ -61,7 +61,13 @@ class ChatOverviewPageState extends State<ChatOverviewPage> {
), ),
) )
], ],
) ),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.of(context).pushNamed(Routes.chatsNew);
},
child: Icon(Icons.chat),
),
); );
} }
......
// Copyright (C) 2019 Wilko Manger
//
// This file is part of Pattle.
//
// Pattle is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Pattle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Pattle. If not, see <https://www.gnu.org/licenses/>.
import 'dart:collection';
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:pattle/src/ui/util/user.dart';
import 'package:rxdart/rxdart.dart';
import 'package:pattle/src/di.dart' as di;
final bloc = CreateGroupBloc();
class CreateGroupBloc {
final me = di.getLocalUser();
PublishSubject<List<User>> _userSubj = PublishSubject<List<User>>();
Stream<List<User>> get users => _userSubj.stream.distinct();
bool _isCreatingRoom = false;
PublishSubject<bool> _isCreatingRoomSubj = PublishSubject<bool>();
Stream<bool> get isCreatingRoom => _isCreatingRoomSubj.stream.distinct();
final usersToAdd = List<User>();
String groupName;
Future<void> loadMembers() async {
final users = HashSet<User>(
equals: (User a, User b) => a.isIdenticalTo(b),
hashCode: (User user) => user.id.hashCode
);
// Load members of some rooms, in the future
// this'll be based on activity and what not
await for (final room in me.rooms.upTo(count: 10)) {
await for (final user in room.members.upTo(count: 20)) {
if (!user.isIdenticalTo(me)) {
users.add(user);
}
}
}
_userSubj.add(
users.toList(growable: false)
..sort((User a, User b) => displayNameOf(a).compareTo(displayNameOf(b)))
);
}
Future<RoomId> createRoom() async {
if (!_isCreatingRoom) {
_isCreatingRoom = true;
_isCreatingRoomSubj.add(true);
return me.rooms.create(
name: groupName,
invitees: usersToAdd
).whenComplete(() {
_isCreatingRoom = false;
_isCreatingRoomSubj.add(false);
});
}
return null;
}
}
\ No newline at end of file
// Copyright (C) 2019 Wilko Manger
//
// This file is part of Pattle.
//
// Pattle is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Pattle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Pattle. If not, see <https://www.gnu.org/licenses/>.
import 'package:flutter/material.dart';
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:pattle/src/app.dart';
import 'package:pattle/src/ui/main/widgets/error.dart';
import 'package:pattle/src/ui/main/widgets/user_avatar.dart';
import 'package:pattle/src/ui/resources/localizations.dart';
import 'package:pattle/src/ui/util/matrix_image.dart';
import 'package:pattle/src/ui/util/user.dart';
import 'package:pattle/src/ui/main/overview/create/group/create_group_bloc.dart';
class CreateGroupDetailsPageState extends State<CreateGroupDetailsPage> {
@override
void initState() {
super.initState();
bloc.loadMembers();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(l(context).newGroup)
),
body: Column(
children: <Widget>[
ErrorBanner(),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Expanded(
child: Padding(
padding: EdgeInsets.all(16),
child: TextField(
onChanged: (text) {
bloc.groupName = text;
},
maxLines: 1,
decoration: InputDecoration(
labelText: l(context).groupName,
filled: true
),
),
)
)
],
),
Text(l(context).participants),
Expanded(
child: _buildUserList(context)
)
],
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
// TODO: Navigate to newly created
await bloc.createRoom();
Navigator.of(context).popUntil(
(route) => route.settings.name == Routes.chats
);
},
child: StreamBuilder<bool>(
stream: bloc.isCreatingRoom,
builder: (BuildContext context, AsyncSnapshot<bool> snapshot) {
final isCreatingRoom = snapshot.data ?? false;
if (isCreatingRoom) {
return SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
)
);
} else {
return Icon(Icons.check);
}
},
),
),
);
}
Widget _buildUserList(BuildContext context) {
final children = List<Widget>();
for (final user in bloc.usersToAdd) {
children.add(Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Flexible(
child: Padding(
padding: EdgeInsets.all(8),
child: UserAvatar(
user: user,
radius: 32
)
),
),
Text(
displayNameOf(user),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
));
}
return GridView.count(
crossAxisCount: 4,
children: children
);
}
}
class CreateGroupDetailsPage extends StatefulWidget {
@override
State<StatefulWidget> createState() => CreateGroupDetailsPageState();
}
\ No newline at end of file
// Copyright (C) 2019 Wilko Manger
//
// This file is part of Pattle.
//
// Pattle is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Pattle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Pattle. If not, see <https://www.gnu.org/licenses/>.
import 'package:flutter/material.dart';
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:pattle/src/app.dart';
import 'package:pattle/src/ui/main/widgets/error.dart';
import 'package:pattle/src/ui/main/widgets/user_avatar.dart';
import 'package:pattle/src/ui/resources/localizations.dart';
import 'package:pattle/src/ui/util/matrix_image.dart';
import 'package:pattle/src/ui/util/user.dart';
import 'package:pattle/src/ui/main/overview/create/group/create_group_bloc.dart';
class CreateGroupMembersPageState extends State<CreateGroupMembersPage> {
@override
void initState() {
super.initState();
bloc.loadMembers();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(l(context).newGroup)
),
body: Column(
children: <Widget>[
ErrorBanner(),
Expanded(
child: _buildUserList(context)
)
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.of(context).pushNamed(Routes.chatsNewDetails);
},
child: Icon(Icons.arrow_forward),
),
);
}
Widget _buildUserList(BuildContext context) {
return StreamBuilder<List<User>>(
stream: bloc.users,
builder: (BuildContext context, AsyncSnapshot<List<User>> snapshot) {
final users = snapshot.data;
if (users != null) {
return ListView.builder(
itemCount: users.length,
itemBuilder: (BuildContext context, int index)
=> _buildUser(context, users[index]),
);
} else {
return Container();
}
},
);
}
Widget _buildUser(BuildContext context, User user) {
final avatarSize = 42.0;
Widget avatar = UserAvatar(
user: user,
radius: avatarSize * 0.5,
);
// TODO: Add checkmark animation
if (bloc.usersToAdd.contains(user)) {
avatar = Stack(
overflow: Overflow.visible,
children: <Widget>[
avatar,
SizedBox(
width: avatarSize,
height: avatarSize,
child: Align(
alignment: Alignment(1.5, 1.5),
child: ClipOval(
child: Container(
color: Colors.white,
child: Icon(
Icons.check_circle,
color: Theme.of(context).primaryColor,
),
)
)
),
)
],
);
}
return ListTile(
leading: avatar,
title: Text(
displayNameOf(user),
style: TextStyle(
fontWeight: FontWeight.w600
),
),
onTap: () {
setState(() {
if (bloc.usersToAdd.contains(user)) {
bloc.usersToAdd.remove(user);
} else {
bloc.usersToAdd.add(user);
}
});
},
);
}
}
class CreateGroupMembersPage extends StatefulWidget {
@override
State<StatefulWidget> createState() => CreateGroupMembersPageState();
}
\ No newline at end of file
...@@ -32,6 +32,8 @@ class MemberSubtitle extends Subtitle { ...@@ -32,6 +32,8 @@ class MemberSubtitle extends Subtitle {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return RichText( return RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan( text: TextSpan(
style: textStyle(context), style: textStyle(context),
children: spanFor(context, event) children: spanFor(context, event)
......
...@@ -23,13 +23,18 @@ import 'package:pattle/src/di.dart' as di; ...@@ -23,13 +23,18 @@ import 'package:pattle/src/di.dart' as di;
final syncBloc = SyncBloc(); final syncBloc = SyncBloc();
class SyncBloc { class SyncBloc {
var started = false;
LocalUser _user = di.getLocalUser(); LocalUser _user = di.getLocalUser();
ReplaySubject<SyncState> _syncSubj = ReplaySubject<SyncState>(maxSize: 1); ReplaySubject<SyncState> _syncSubj = ReplaySubject<SyncState>(maxSize: 1);
Observable<SyncState> get stream => _syncSubj.stream; Observable<SyncState> get stream => _syncSubj.stream;
Future<void> start() async { Future<void> start() async {
_user.sendAllUnsent(); if (!started) {
_syncSubj.addStream(_user.sync()); _user.sendAllUnsent();
_syncSubj.addStream(_user.sync());
started = true;
}
} }
} }
\ No newline at end of file
// Copyright (C) 2019 wilko
//
// This file is part of Pattle.
//
// Pattle is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Pattle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Pattle. If not, see <https://www.gnu.org/licenses/>.
import 'package:flutter/material.dart';
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:pattle/src/ui/util/matrix_image.dart';
class UserAvatar extends StatelessWidget {
final User user;
final double radius;
UserAvatar({this.user, this.radius});
@override
Widget build(BuildContext context) {
if (user.avatarUrl != null) {
return CircleAvatar(
radius: radius,
backgroundImage: MatrixImage(
user.avatarUrl,
width: 64,
height: 64
),
);
} else {
return CircleAvatar(
radius: radius,
child: Icon(
Icons.person,
size: radius,
),
);
}
}
}
\ No newline at end of file
...@@ -40,6 +40,9 @@ class Strings { ...@@ -40,6 +40,9 @@ class Strings {
final typeAMessage = 'Type a message'; final typeAMessage = 'Type a message';
final you = 'You'; final you = 'You';
final andOthers = 'and others'; final andOthers = 'and others';
final newGroup = 'New group';
final groupName = 'Group name';
final participants = 'Participants';
final connectionLost = final connectionLost =
'Connection has been lost.\n' 'Connection has been lost.\n'
......
...@@ -36,7 +36,7 @@ FutureOr<String> nameOf(BuildContext context, Room room) { ...@@ -36,7 +36,7 @@ FutureOr<String> nameOf(BuildContext context, Room room) {
} }
// TODO: Make upTo FutureOr // TODO: Make upTo FutureOr
return room.members.upTo(6).toList().then((members) { return room.members.upTo(count: 6).toList().then((members) {
var name = ''; var name = '';
if (members != null) { if (members != null) {
if (members.length == 1) { if (members.length == 1) {
......
...@@ -215,14 +215,14 @@ packages: ...@@ -215,14 +215,14 @@ packages:
name: matrix_sdk name: matrix_sdk
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.10.0" version: "0.12.0"
matrix_sdk_sqflite: matrix_sdk_sqflite:
dependency: "direct main" dependency: "direct main"
description: description:
name: matrix_sdk_sqflite name: matrix_sdk_sqflite
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.7.0" version: "0.8.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
......
...@@ -12,8 +12,8 @@ dependencies: ...@@ -12,8 +12,8 @@ dependencies:
injector: ^1.0.6 injector: ^1.0.6
matrix_sdk: ^0.10.0 matrix_sdk: ^0.1