Browse Source

Open conversation from friend list

pull/1/head
Tovi Jaeschke-Rogers 2 years ago
parent
commit
c120552a6a
10 changed files with 195 additions and 90 deletions
  1. +22
    -14
      Backend/Database/Seeder/MessageSeeder.go
  2. +6
    -4
      Backend/Models/Conversations.go
  3. +2
    -2
      mobile/lib/models/conversation_users.dart
  4. +66
    -2
      mobile/lib/models/conversations.dart
  5. +5
    -0
      mobile/lib/utils/storage/conversations.dart
  6. +1
    -1
      mobile/lib/utils/storage/database.dart
  7. +8
    -3
      mobile/lib/views/main/conversation/detail.dart
  8. +2
    -1
      mobile/lib/views/main/conversation/list.dart
  9. +63
    -61
      mobile/lib/views/main/conversation/list_item.dart
  10. +20
    -2
      mobile/lib/views/main/friend/list_item.dart

+ 22
- 14
Backend/Database/Seeder/MessageSeeder.go View File

@ -21,7 +21,7 @@ func seedMessage(
keyCiphertext []byte keyCiphertext []byte
plaintext string plaintext string
dataCiphertext []byte dataCiphertext []byte
senderIdCiphertext []byte
senderIDCiphertext []byte
err error err error
) )
@ -42,13 +42,13 @@ func seedMessage(
panic(err) panic(err)
} }
senderIdCiphertext, err = key.aesEncrypt([]byte(primaryUser.ID.String()))
senderIDCiphertext, err = key.aesEncrypt([]byte(primaryUser.ID.String()))
if err != nil { if err != nil {
panic(err) panic(err)
} }
if i%2 == 0 { if i%2 == 0 {
senderIdCiphertext, err = key.aesEncrypt([]byte(secondaryUser.ID.String()))
senderIDCiphertext, err = key.aesEncrypt([]byte(secondaryUser.ID.String()))
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -63,7 +63,7 @@ func seedMessage(
messageData = Models.MessageData{ messageData = Models.MessageData{
Data: base64.StdEncoding.EncodeToString(dataCiphertext), Data: base64.StdEncoding.EncodeToString(dataCiphertext),
SenderID: base64.StdEncoding.EncodeToString(senderIdCiphertext),
SenderID: base64.StdEncoding.EncodeToString(senderIDCiphertext),
SymmetricKey: base64.StdEncoding.EncodeToString(keyCiphertext), SymmetricKey: base64.StdEncoding.EncodeToString(keyCiphertext),
} }
@ -93,10 +93,11 @@ func seedMessage(
func seedConversationDetail(key aesKey) (Models.ConversationDetail, error) { func seedConversationDetail(key aesKey) (Models.ConversationDetail, error) {
var ( var (
messageThread Models.ConversationDetail
name string
nameCiphertext []byte
err error
messageThread Models.ConversationDetail
name string
nameCiphertext []byte
twoUserCiphertext []byte
err error
) )
name = "Test Conversation" name = "Test Conversation"
@ -106,8 +107,14 @@ func seedConversationDetail(key aesKey) (Models.ConversationDetail, error) {
panic(err) panic(err)
} }
twoUserCiphertext, err = key.aesEncrypt([]byte("false"))
if err != nil {
panic(err)
}
messageThread = Models.ConversationDetail{ messageThread = Models.ConversationDetail{
Name: base64.StdEncoding.EncodeToString(nameCiphertext),
Name: base64.StdEncoding.EncodeToString(nameCiphertext),
TwoUser: base64.StdEncoding.EncodeToString(twoUserCiphertext),
} }
err = Database.CreateConversationDetail(&messageThread) err = Database.CreateConversationDetail(&messageThread)
@ -159,14 +166,14 @@ func seedConversationDetailUser(
var ( var (
conversationDetailUser Models.ConversationDetailUser conversationDetailUser Models.ConversationDetailUser
adminString string = "false"
userIdCiphertext []byte
userIDCiphertext []byte
usernameCiphertext []byte usernameCiphertext []byte
adminCiphertext []byte adminCiphertext []byte
associationKeyCiphertext []byte associationKeyCiphertext []byte
publicKeyCiphertext []byte publicKeyCiphertext []byte
adminString = "false"
err error err error
) )
@ -174,7 +181,7 @@ func seedConversationDetailUser(
adminString = "true" adminString = "true"
} }
userIdCiphertext, err = key.aesEncrypt([]byte(user.ID.String()))
userIDCiphertext, err = key.aesEncrypt([]byte(user.ID.String()))
if err != nil { if err != nil {
return conversationDetailUser, err return conversationDetailUser, err
} }
@ -201,7 +208,7 @@ func seedConversationDetailUser(
conversationDetailUser = Models.ConversationDetailUser{ conversationDetailUser = Models.ConversationDetailUser{
ConversationDetailID: conversationDetail.ID, ConversationDetailID: conversationDetail.ID,
UserID: base64.StdEncoding.EncodeToString(userIdCiphertext),
UserID: base64.StdEncoding.EncodeToString(userIDCiphertext),
Username: base64.StdEncoding.EncodeToString(usernameCiphertext), Username: base64.StdEncoding.EncodeToString(usernameCiphertext),
Admin: base64.StdEncoding.EncodeToString(adminCiphertext), Admin: base64.StdEncoding.EncodeToString(adminCiphertext),
AssociationKey: base64.StdEncoding.EncodeToString(associationKeyCiphertext), AssociationKey: base64.StdEncoding.EncodeToString(associationKeyCiphertext),
@ -213,6 +220,7 @@ func seedConversationDetailUser(
return conversationDetailUser, err return conversationDetailUser, err
} }
// SeedMessages seeds messages & conversations for testing
func SeedMessages() { func SeedMessages() {
var ( var (
conversationDetail Models.ConversationDetail conversationDetail Models.ConversationDetail


+ 6
- 4
Backend/Models/Conversations.go View File

@ -4,12 +4,15 @@ import (
"github.com/gofrs/uuid" "github.com/gofrs/uuid"
) )
// ConversationDetail stores the name for the conversation
type ConversationDetail struct { type ConversationDetail struct {
Base Base
Name string `gorm:"not null" json:"name"` // Stored encrypted
Users []ConversationDetailUser ` json:"users"`
Name string `gorm:"not null" json:"name"` // Stored encrypted
Users []ConversationDetailUser ` json:"users"`
TwoUser string `gorm:"not null" json:"two_user"`
} }
// ConversationDetailUser all users associated with a customer
type ConversationDetailUser struct { type ConversationDetailUser struct {
Base Base
ConversationDetailID uuid.UUID `gorm:"not null" json:"conversation_detail_id"` ConversationDetailID uuid.UUID `gorm:"not null" json:"conversation_detail_id"`
@ -21,7 +24,7 @@ type ConversationDetailUser struct {
PublicKey string `gorm:"not null" json:"public_key"` // Stored encrypted PublicKey string `gorm:"not null" json:"public_key"` // Stored encrypted
} }
// Used to link the current user to their conversations
// UserConversation Used to link the current user to their conversations
type UserConversation struct { type UserConversation struct {
Base Base
UserID uuid.UUID `gorm:"type:uuid;column:user_id;not null;" json:"user_id"` UserID uuid.UUID `gorm:"type:uuid;column:user_id;not null;" json:"user_id"`
@ -29,5 +32,4 @@ type UserConversation struct {
ConversationDetailID string `gorm:"not null" json:"conversation_detail_id"` // Stored encrypted ConversationDetailID string `gorm:"not null" json:"conversation_detail_id"` // Stored encrypted
Admin string `gorm:"not null" json:"admin"` // Bool if user is admin of thread, stored encrypted Admin string `gorm:"not null" json:"admin"` // Bool if user is admin of thread, stored encrypted
SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted
// TODO: Add association_key here
} }

+ 2
- 2
mobile/lib/models/conversation_users.dart View File

@ -14,7 +14,7 @@ Future<ConversationUser> getConversationUser(Conversation conversation, String u
final List<Map<String, dynamic>> maps = await db.query( final List<Map<String, dynamic>> maps = await db.query(
'conversation_users', 'conversation_users',
where: 'conversation_id = ? AND user_id = ?', where: 'conversation_id = ? AND user_id = ?',
whereArgs: [conversation.id, userId],
whereArgs: [ conversation.id, userId ],
); );
if (maps.length != 1) { if (maps.length != 1) {
@ -40,7 +40,7 @@ Future<List<ConversationUser>> getConversationUsers(Conversation conversation) a
final List<Map<String, dynamic>> maps = await db.query( final List<Map<String, dynamic>> maps = await db.query(
'conversation_users', 'conversation_users',
where: 'conversation_id = ?', where: 'conversation_id = ?',
whereArgs: [conversation.id],
whereArgs: [ conversation.id ],
orderBy: 'username', orderBy: 'username',
); );


+ 66
- 2
mobile/lib/models/conversations.dart View File

@ -14,7 +14,7 @@ import '/utils/encryption/crypto_utils.dart';
import '/utils/storage/database.dart'; import '/utils/storage/database.dart';
import '/utils/strings.dart'; import '/utils/strings.dart';
Future<Conversation> createConversation(String title, List<Friend> friends) async {
Future<Conversation> createConversation(String title, List<Friend> friends, bool twoUser) async {
final db = await getDatabaseConnection(); final db = await getDatabaseConnection();
MyProfile profile = await MyProfile.getProfile(); MyProfile profile = await MyProfile.getProfile();
@ -30,6 +30,7 @@ Future<Conversation> createConversation(String title, List<Friend> friends) asyn
symmetricKey: base64.encode(symmetricKey), symmetricKey: base64.encode(symmetricKey),
admin: true, admin: true,
name: title, name: title,
twoUser: twoUser,
status: ConversationStatus.pending, status: ConversationStatus.pending,
isRead: true, isRead: true,
); );
@ -105,7 +106,7 @@ Conversation findConversationByDetailId(List<Conversation> conversations, String
} }
} }
// Or return `null`. // Or return `null`.
throw ArgumentError.value(id, "id", "No element with that id");
throw ArgumentError.value(id, 'id', 'No element with that id');
} }
Future<Conversation> getConversationById(String id) async { Future<Conversation> getConversationById(String id) async {
@ -127,6 +128,7 @@ Future<Conversation> getConversationById(String id) async {
symmetricKey: maps[0]['symmetric_key'], symmetricKey: maps[0]['symmetric_key'],
admin: maps[0]['admin'] == 1, admin: maps[0]['admin'] == 1,
name: maps[0]['name'], name: maps[0]['name'],
twoUser: maps[0]['two_user'] == 1,
status: ConversationStatus.values[maps[0]['status']], status: ConversationStatus.values[maps[0]['status']],
isRead: maps[0]['is_read'] == 1, isRead: maps[0]['is_read'] == 1,
); );
@ -145,12 +147,48 @@ Future<List<Conversation>> getConversations() async {
symmetricKey: maps[i]['symmetric_key'], symmetricKey: maps[i]['symmetric_key'],
admin: maps[i]['admin'] == 1, admin: maps[i]['admin'] == 1,
name: maps[i]['name'], name: maps[i]['name'],
twoUser: maps[i]['two_user'] == 1,
status: ConversationStatus.values[maps[i]['status']], status: ConversationStatus.values[maps[i]['status']],
isRead: maps[i]['is_read'] == 1, isRead: maps[i]['is_read'] == 1,
); );
}); });
} }
Future<Conversation?> getTwoUserConversation(String userId) async {
final db = await getDatabaseConnection();
MyProfile profile = await MyProfile.getProfile();
print(userId);
print(profile.id);
final List<Map<String, dynamic>> maps = await db.rawQuery(
'''
SELECT conversations.* FROM conversations
LEFT JOIN conversation_users ON conversation_users.conversation_id = conversations.id
WHERE conversation_users.user_id = ?
AND conversation_users.user_id != ?
AND conversations.two_user = 1
''',
[ userId, profile.id ],
);
if (maps.length != 1) {
return null;
}
return Conversation(
id: maps[0]['id'],
userId: maps[0]['user_id'],
symmetricKey: maps[0]['symmetric_key'],
admin: maps[0]['admin'] == 1,
name: maps[0]['name'],
twoUser: maps[0]['two_user'] == 1,
status: ConversationStatus.values[maps[0]['status']],
isRead: maps[0]['is_read'] == 1,
);
}
class Conversation { class Conversation {
String id; String id;
@ -158,6 +196,7 @@ class Conversation {
String symmetricKey; String symmetricKey;
bool admin; bool admin;
String name; String name;
bool twoUser;
ConversationStatus status; ConversationStatus status;
bool isRead; bool isRead;
@ -167,6 +206,7 @@ class Conversation {
required this.symmetricKey, required this.symmetricKey,
required this.admin, required this.admin,
required this.name, required this.name,
required this.twoUser,
required this.status, required this.status,
required this.isRead, required this.isRead,
}); });
@ -194,6 +234,7 @@ class Conversation {
symmetricKey: base64.encode(symmetricKeyDecrypted), symmetricKey: base64.encode(symmetricKeyDecrypted),
admin: admin == 'true', admin: admin == 'true',
name: 'Unknown', name: 'Unknown',
twoUser: false,
status: ConversationStatus.complete, status: ConversationStatus.complete,
isRead: true, isRead: true,
); );
@ -251,6 +292,7 @@ class Conversation {
'symmetric_key': symmetricKey, 'symmetric_key': symmetricKey,
'admin': admin ? 1 : 0, 'admin': admin ? 1 : 0,
'name': name, 'name': name,
'two_user': twoUser ? 1 : 0,
'status': status.index, 'status': status.index,
'is_read': isRead ? 1 : 0, 'is_read': isRead ? 1 : 0,
}; };
@ -297,6 +339,28 @@ class Conversation {
failedToSend: maps[0]['failed_to_send'] == 1, failedToSend: maps[0]['failed_to_send'] == 1,
); );
} }
Future<String> getName() async {
if (!twoUser) {
return name;
}
MyProfile profile = await MyProfile.getProfile();
final db = await getDatabaseConnection();
List<Map<String, dynamic>> maps = await db.query(
'conversation_users',
where: 'conversation_id = ? AND user_id != ?',
whereArgs: [ id, profile.id ],
);
if (maps.length != 1) {
throw ArgumentError('Invalid user id');
}
return maps[0]['username'];
}
} }


+ 5
- 0
mobile/lib/utils/storage/conversations.dart View File

@ -92,6 +92,11 @@ Future<void> updateConversations() async {
base64.decode(conversationDetailJson['name']), base64.decode(conversationDetailJson['name']),
); );
conversation.twoUser = AesHelper.aesDecrypt(
base64.decode(conversation.symmetricKey),
base64.decode(conversationDetailJson['two_user']),
) == 'true';
await db.insert( await db.insert(
'conversations', 'conversations',
conversation.toMap(), conversation.toMap(),


+ 1
- 1
mobile/lib/utils/storage/database.dart View File

@ -29,7 +29,6 @@ Future<Database> getDatabaseConnection() async {
); );
'''); ''');
// TODO: Change users to use its own table, as it is a json blob
await db.execute( await db.execute(
''' '''
CREATE TABLE IF NOT EXISTS conversations( CREATE TABLE IF NOT EXISTS conversations(
@ -38,6 +37,7 @@ Future<Database> getDatabaseConnection() async {
symmetric_key TEXT, symmetric_key TEXT,
admin INTEGER, admin INTEGER,
name TEXT, name TEXT,
two_user INTEGER,
status INTEGER, status INTEGER,
is_read INTEGER is_read INTEGER
); );


+ 8
- 3
mobile/lib/views/main/conversation/detail.dart View File

@ -21,6 +21,7 @@ class ConversationDetail extends StatefulWidget{
} }
class _ConversationDetailState extends State<ConversationDetail> { class _ConversationDetailState extends State<ConversationDetail> {
String conversationName = '';
List<Message> messages = []; List<Message> messages = [];
MyProfile profile = MyProfile(id: '', username: ''); MyProfile profile = MyProfile(id: '', username: '');
@ -31,7 +32,7 @@ class _ConversationDetailState extends State<ConversationDetail> {
return Scaffold( return Scaffold(
appBar: CustomTitleBar( appBar: CustomTitleBar(
title: Text( title: Text(
widget.conversation.name,
conversationName,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -42,7 +43,9 @@ class _ConversationDetailState extends State<ConversationDetail> {
rightHandButton: IconButton( rightHandButton: IconButton(
onPressed: (){ onPressed: (){
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute(builder: (context) => ConversationSettings(conversation: widget.conversation)),
MaterialPageRoute(builder: (context) => ConversationSettings(
conversation: widget.conversation
)),
); );
}, },
icon: Icon( icon: Icon(
@ -51,6 +54,7 @@ class _ConversationDetailState extends State<ConversationDetail> {
), ),
), ),
), ),
body: Stack( body: Stack(
children: <Widget>[ children: <Widget>[
messagesView(), messagesView(),
@ -88,7 +92,7 @@ class _ConversationDetailState extends State<ConversationDetail> {
Expanded( Expanded(
child: TextField( child: TextField(
decoration: InputDecoration( decoration: InputDecoration(
hintText: "Write message...",
hintText: 'Write message...',
hintStyle: TextStyle( hintStyle: TextStyle(
color: Theme.of(context).hintColor, color: Theme.of(context).hintColor,
), ),
@ -134,6 +138,7 @@ class _ConversationDetailState extends State<ConversationDetail> {
} }
Future<void> fetchMessages() async { Future<void> fetchMessages() async {
conversationName = await widget.conversation.getName();
profile = await MyProfile.getProfile(); profile = await MyProfile.getProfile();
messages = await getMessagesForThread(widget.conversation); messages = await getMessagesForThread(widget.conversation);
setState(() {}); setState(() {});


+ 2
- 1
mobile/lib/views/main/conversation/list.dart View File

@ -73,7 +73,8 @@ class _ConversationListState extends State<ConversationList> {
saveCallback: (List<Friend> friendsSelected) async { saveCallback: (List<Friend> friendsSelected) async {
Conversation conversation = await createConversation( Conversation conversation = await createConversation(
conversationName, conversationName,
friendsSelected
friendsSelected,
false,
); );
uploadConversation(conversation); uploadConversation(conversation);


+ 63
- 61
mobile/lib/views/main/conversation/list_item.dart View File

@ -19,83 +19,84 @@ class ConversationListItem extends StatefulWidget{
class _ConversationListItemState extends State<ConversationListItem> { class _ConversationListItemState extends State<ConversationListItem> {
late Conversation conversation; late Conversation conversation;
late String conversationName;
late Message? recentMessage; late Message? recentMessage;
bool loaded = false; bool loaded = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
loaded ? Navigator.push(context, MaterialPageRoute(builder: (context){
return ConversationDetail(
conversation: conversation,
);
})).then(onGoBack) : null;
},
child: Container(
padding: const EdgeInsets.only(left: 16,right: 0,top: 10,bottom: 10),
child: !loaded ? null : Row(
behavior: HitTestBehavior.opaque,
onTap: () {
loaded ? Navigator.push(context, MaterialPageRoute(builder: (context){
return ConversationDetail(
conversation: conversation,
);
})).then(onGoBack) : null;
},
child: Container(
padding: const EdgeInsets.only(left: 16,right: 0,top: 10,bottom: 10),
child: !loaded ? null : Row(
children: <Widget>[
Expanded(
child: Row(
children: <Widget>[ children: <Widget>[
CustomCircleAvatar(
initials: conversationName[0].toUpperCase(),
imagePath: null,
),
const SizedBox(width: 16),
Expanded( Expanded(
child: Row(
child: Align(
alignment: Alignment.centerLeft,
child: Container(
color: Colors.transparent,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
CustomCircleAvatar(
initials: conversation.name[0].toUpperCase(),
imagePath: null,
),
const SizedBox(width: 16),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Container(
color: Colors.transparent,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
conversation.name,
style: const TextStyle(fontSize: 16)
),
recentMessage != null ?
const SizedBox(height: 2) :
const SizedBox.shrink()
,
recentMessage != null ?
Text(
recentMessage!.data,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
fontWeight: conversation.isRead ? FontWeight.normal : FontWeight.bold,
),
) :
const SizedBox.shrink(),
],
),
),
),
Text(
conversationName,
style: const TextStyle(fontSize: 16)
), ),
recentMessage != null ? recentMessage != null ?
Padding(
padding: const EdgeInsets.only(left: 10),
child: Text(
convertToAgo(recentMessage!.createdAt, short: true),
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
)
):
const SizedBox.shrink(),
],
const SizedBox(height: 2) :
const SizedBox.shrink()
,
recentMessage != null ?
Text(
recentMessage!.data,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
fontWeight: conversation.isRead ? FontWeight.normal : FontWeight.bold,
),
) :
const SizedBox.shrink(),
],
), ),
),
), ),
),
recentMessage != null ?
Padding(
padding: const EdgeInsets.only(left: 10),
child: Text(
convertToAgo(recentMessage!.createdAt, short: true),
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
)
):
const SizedBox.shrink(),
], ],
),
), ),
],
), ),
),
); );
} }
@ -107,6 +108,7 @@ class _ConversationListItemState extends State<ConversationListItem> {
Future<void> getConversationData() async { Future<void> getConversationData() async {
conversation = widget.conversation; conversation = widget.conversation;
conversationName = await widget.conversation.getName();
recentMessage = await conversation.getRecentMessage(); recentMessage = await conversation.getRecentMessage();
loaded = true; loaded = true;
setState(() {}); setState(() {});


+ 20
- 2
mobile/lib/views/main/friend/list_item.dart View File

@ -1,5 +1,8 @@
import 'package:Envelope/components/custom_circle_avatar.dart'; import 'package:Envelope/components/custom_circle_avatar.dart';
import 'package:Envelope/models/conversations.dart';
import 'package:Envelope/models/friends.dart'; import 'package:Envelope/models/friends.dart';
import 'package:Envelope/utils/strings.dart';
import 'package:Envelope/views/main/conversation/detail.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class FriendListItem extends StatefulWidget{ class FriendListItem extends StatefulWidget{
@ -19,8 +22,7 @@ class _FriendListItemState extends State<FriendListItem> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () async {
},
onTap: findOrCreateConversation,
child: Container( child: Container(
padding: const EdgeInsets.only(left: 16,right: 16,top: 0,bottom: 20), padding: const EdgeInsets.only(left: 16,right: 16,top: 0,bottom: 20),
child: Row( child: Row(
@ -55,4 +57,20 @@ class _FriendListItemState extends State<FriendListItem> {
), ),
); );
} }
Future<void> findOrCreateConversation() async {
Conversation? conversation = await getTwoUserConversation(widget.friend.friendId);
conversation ??= await createConversation(
generateRandomString(32),
[ widget.friend ],
true,
);
Navigator.push(context, MaterialPageRoute(builder: (context){
return ConversationDetail(
conversation: conversation!,
);
}));
}
} }

Loading…
Cancel
Save