Profile Images #355

Merged
erinn merged 9 commits from custom_profile_images into trunk 2022-02-07 23:16:47 +00:00
10 changed files with 119 additions and 39 deletions
Showing only changes of commit 3d85883f8e - Show all commits

View File

@ -77,7 +77,7 @@ class FlwtchWorker(context: Context, parameters: WorkerParameters) :
}
val loader = FlutterInjector.instance().flutterLoader()
val key = loader.getLookupKeyForAsset("assets/" + data.getString("Picture"))//"assets/profiles/001-centaur.png")
val key = loader.getLookupKeyForAsset("assets/" + data.getString("picture"))//"assets/profiles/001-centaur.png")
val fh = applicationContext.assets.open(key)

View File

@ -106,7 +106,7 @@ class CwtchNotifier {
profileCN.getProfile(data["ProfileOnion"])?.contactList.add(ContactInfoState(data["ProfileOnion"], int.parse(data["ConversationID"]), data["GroupID"],
blocked: false, // we created
accepted: true, // we created
imagePath: data["PicturePath"],
imagePath: data["picture"],
nickname: data["GroupName"],
status: status,
server: data["GroupServer"],
@ -147,7 +147,7 @@ class CwtchNotifier {
var messageID = int.parse(data["Index"]);
var timestamp = DateTime.tryParse(data['TimestampReceived'])!;
var senderHandle = data['RemotePeer'];
var senderImage = data['Picture'];
var senderImage = data['picture'];
var isAuto = data['Auto'] == "true";
String? contenthash = data['ContentHash'];
var selectedProfile = appState.selectedProfile == data["ProfileOnion"];
@ -199,7 +199,7 @@ class CwtchNotifier {
if (data["ProfileOnion"] != data["RemotePeer"]) {
var idx = int.parse(data["Index"]);
var senderHandle = data['RemotePeer'];
var senderImage = data['Picture'];
var senderImage = data['picture'];
var timestampSent = DateTime.tryParse(data['TimestampSent'])!;
var contact = profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier);
var currentTotal = contact!.totalMessages;
@ -301,7 +301,7 @@ class CwtchNotifier {
profileCN.getProfile(data["ProfileOnion"])?.contactList.add(ContactInfoState(data["ProfileOnion"], identifier, groupInvite["GroupID"],
blocked: false, // NewGroup only issued on accepting invite
accepted: true, // NewGroup only issued on accepting invite
imagePath: data["PicturePath"],
imagePath: data["picture"],
nickname: groupInvite["GroupName"],
server: groupInvite["ServerHost"],
status: status,
@ -322,15 +322,28 @@ class CwtchNotifier {
profileCN.getProfile(data["ProfileOnion"])?.contactList.resort();
break;
case "NewRetValMessageFromPeer":
if (data["Path"] == "profile.name") {
if (data["Path"] == "profile.name" && data["Exists"] == "true") {
if (data["Data"].toString().trim().length > 0) {
// Update locally on the UI...
if (profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"]) != null) {
profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"])!.nickname = data["Data"];
}
}
} else if (data['Path'] == "profile.picture") {
// Not yet..
} else if (data['Path'] == "profile.custom-profile-image" && data["Exists"] == "true") {
EnvironmentConfig.debugLog("received ret val of custom profile image: $data");
String fileKey = data['Data'];
String filePath = data['FilePath'];
bool downloaded = data['FileDownloadFinished'] == "true";
if (downloaded) {
if (profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"]) != null) {
profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"])!.imagePath = filePath;
}
} else {
var contact = profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"]);
if (contact != null) {
profileCN.getProfile(data["ProfileOnion"])?.waitForDownloadComplete(contact.identifier, fileKey);
}
}
} else {
EnvironmentConfig.debugLog("unhandled ret val event: ${data['Path']}");
}

View File

@ -17,6 +17,7 @@ class ProfileInfoState extends ChangeNotifier {
int _unreadMessages = 0;
bool _online = false;
Map<String, FileDownloadProgress> _downloads = Map<String, FileDownloadProgress>();
Map<String, int> _downloadTriggers = Map<String, int>();
// assume profiles are encrypted...this will be set to false
// in the constructor if the profile is encrypted with the defacto password.
@ -178,22 +179,14 @@ class ProfileInfoState extends ChangeNotifier {
this._contacts.resort();
}
void newMessage(int identifier, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data, String? contenthash, bool selectedProfile, bool selectedConversation) {
void newMessage(
int identifier, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data, String? contenthash, bool selectedProfile, bool selectedConversation) {
if (!selectedProfile) {
unreadMessages++;
notifyListeners();
}
contactList.newMessage(
identifier,
messageID,
timestamp,
senderHandle,
senderImage,
isAuto,
data,
contenthash,
selectedConversation);
contactList.newMessage(identifier, messageID, timestamp, senderHandle, senderImage, isAuto, data, contenthash, selectedConversation);
}
void downloadInit(String fileKey, int numChunks) {
@ -232,6 +225,15 @@ class ProfileInfoState extends ChangeNotifier {
// so setting numChunks correctly shouldn't matter
this.downloadInit(fileKey, 1);
}
// Update the contact with a custom profile image if we are
// waiting for one...
if (this._downloadTriggers.containsKey(fileKey)) {
int identifier = this._downloadTriggers[fileKey]!;
this.contactList.getContact(identifier)!.imagePath = finalPath;
notifyListeners();
}
// only update if different
if (!this._downloads[fileKey]!.complete) {
this._downloads[fileKey]!.timeEnd = DateTime.now();
@ -308,4 +310,9 @@ class ProfileInfoState extends ChangeNotifier {
}
return prettyBytes((bytes / seconds).round()) + "/s";
}
void waitForDownloadComplete(int identifier, String fileKey) {
_downloadTriggers[fileKey] = identifier;
notifyListeners();
}
}

View File

@ -28,7 +28,5 @@ class ProfileListState extends ChangeNotifier {
notifyListeners();
}
int generateUnreadCount(String selectedProfile) => _profiles.where( (p) => p.onion != selectedProfile ).fold(0, (i, p) => i + p.unreadMessages);
int generateUnreadCount(String selectedProfile) => _profiles.where((p) => p.onion != selectedProfile).fold(0, (i, p) => i + p.unreadMessages);
}

View File

@ -4,7 +4,9 @@ import 'dart:math';
import 'package:cwtch/config.dart';
import 'package:cwtch/cwtch/cwtch.dart';
import 'package:cwtch/models/appstate.dart';
import 'package:cwtch/models/profile.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cwtch/widgets/buttontextfield.dart';
@ -66,6 +68,37 @@ class _AddEditProfileViewState extends State<AddEditProfileView> {
});
}
void _showFilePicker(BuildContext ctx) async {
sarah marked this conversation as resolved Outdated
Outdated
Review

instead of copying aroind the _filepicker function can we make it a reusable widget that takes a function to handle the payload?

instead of copying aroind the _filepicker function can we make it a reusable widget that takes a function to handle the payload?
// only allow one file picker at a time
// note: ideally we would destroy file picker when leaving a conversation
// but we don't currently have that option.
// we need to store AppState in a variable because ctx might be destroyed
// while awaiting for pickFiles.
var appstate = Provider.of<AppState>(ctx, listen: false);
appstate.disableFilePicker = true;
// currently lockParentWindow only works on Windows...
FilePickerResult? result = await FilePicker.platform.pickFiles(lockParentWindow: true);
appstate.disableFilePicker = false;
if (result != null && result.files.first.path != null) {
File file = File(result.files.first.path!);
// We have a maximum number of bytes we can represent in terms of
// a manifest (see : https://git.openprivacy.ca/cwtch.im/cwtch/src/branch/master/protocol/files/manifest.go#L25)
if (file.lengthSync() <= 10737418240) {
var profile = Provider.of<ProfileInfoState>(context, listen: false).onion;
// Share this image publically (conversation handle == -1)
Provider.of<FlwtchState>(context, listen: false).cwtch.ShareFile(profile, -1, file.path);
dan marked this conversation as resolved Outdated
Outdated
Review

Is there anyway ShareFile can be made more generic and return the filehandle or what not and then a seperate call here can set the profile Attribute for picture. calling sharefile this way to set profile picture feels like a pretty hacky and hidden API "feature".

This may be out of scope but what happens if we pick a new profile pic, is the previous one still being shared as well?

Is there anyway ShareFile can be made more generic and return the filehandle or what not and then a seperate call here can set the profile Attribute for picture. calling sharefile this way to set profile picture feels like a pretty hacky and hidden API "feature". This may be out of scope but what happens if we pick a new profile pic, is the previous one still being shared as well?
Outdated
Review

. calling sharefile this way to set profile picture feels like a pretty hacky and hidden API "feature".

Yup: https://git.openprivacy.ca/cwtch.im/libcwtch-go/src/branch/trunk/lib.go#L741

This falls into the question of how we want to manage file sharing going forward. Currently there is a lot of design space unexplored e.g. do we want to support arbitrary public files? How should we manage them? Do we want to expose specific apis for that v.s. conversation specific apis.

For now, because profile images are the only public files shared, the API does that work for simplicity. It is very likely if we want to go down the path of multiple public files (e.g. keys, stickers, audio) then we are going to need a radically different API (and it will likely not involve returning a filekey to the UI).

This may be out of scope but what happens if we pick a new profile pic, is the previous one still being shared as well?

It will continue being shared until cwtch is restarted or until 30 days after it was last shared. At some point we will probably want to build an explicit "unshareFile" api.

> . calling sharefile this way to set profile picture feels like a pretty hacky and hidden API "feature". Yup: https://git.openprivacy.ca/cwtch.im/libcwtch-go/src/branch/trunk/lib.go#L741 This falls into the question of how we want to manage file sharing going forward. Currently there is a lot of design space unexplored e.g. do we want to support arbitrary public files? How should we manage them? Do we want to expose specific apis for that v.s. conversation specific apis. For now, because profile images are the only public files shared, the API does that work for simplicity. It is very likely if we want to go down the path of multiple public files (e.g. keys, stickers, audio) then we are going to need a radically different API (and it will likely not involve returning a filekey to the UI). > This may be out of scope but what happens if we pick a new profile pic, is the previous one still being shared as well? It will continue being shared until cwtch is restarted or until 30 days after it was last shared. At some point we will probably want to build an explicit "unshareFile" api.
Outdated
Review

ugh, i hate it, but its appropriatly commented at least and assumed to prolly be changed with in the year as we continue to develop this so fine, approved! lol

ugh, i hate it, but its appropriatly commented at least and assumed to prolly be changed with in the year as we continue to develop this so fine, approved! lol
// update the image cache locally
Provider.of<ProfileInfoState>(context, listen: false).imagePath = file.path;
} else {
final snackBar = SnackBar(
content: Text(AppLocalizations.of(context)!.msgFileTooBig),
duration: Duration(seconds: 4),
);
ScaffoldMessenger.of(ctx).showSnackBar(snackBar);
}
}
}
// A few implementation notes
// We use Visibility to hide optional structures when they are not requested.
// We used SizedBox for inter-widget height padding in columns, otherwise elements can render a little too close together.
@ -89,14 +122,20 @@ class _AddEditProfileViewState extends State<AddEditProfileView> {
Visibility(
visible: Provider.of<ProfileInfoState>(context).onion.isNotEmpty,
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
ProfileImage(
imagePath: Provider.of<ProfileInfoState>(context).imagePath,
diameter: 120,
maskOut: false,
border: theme.theme.portraitOnlineBorderColor,
badgeTextColor: Colors.red,
badgeColor: Colors.red,
)
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
_showFilePicker(context);
},
child: ProfileImage(
imagePath: Provider.of<ProfileInfoState>(context).imagePath,
diameter: 120,
maskOut: false,
border: theme.theme.portraitOnlineBorderColor,
badgeTextColor: Colors.red,
badgeColor: Colors.red,
)))
])),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
CwtchLabel(label: AppLocalizations.of(context)!.displayNameLabel),

View File

@ -86,11 +86,10 @@ class _ContactsViewState extends State<ContactsView> {
Navigator.of(context).pop();
},
),
StreamBuilder<bool>(
stream: Provider.of<AppState>(context).getUnreadProfileNotifyStream(),
builder: (BuildContext context, AsyncSnapshot<bool> unreadCountSnapshot) {
int unreadCount = Provider.of<ProfileListState>(context).generateUnreadCount(Provider.of<AppState>(context).selectedProfile ?? "") ;
int unreadCount = Provider.of<ProfileListState>(context).generateUnreadCount(Provider.of<AppState>(context).selectedProfile ?? "");
return Visibility(
visible: unreadCount > 0,

View File

@ -152,6 +152,7 @@ class MessageRowState extends State<MessageRow> with SingleTickerProviderStateMi
];
} else {
var contact = Provider.of<ContactInfoState>(context);
ContactInfoState? sender = Provider.of<ProfileInfoState>(context).contactList.findContact(Provider.of<MessageMetadata>(context).senderHandle);
Widget wdgPortrait = GestureDetector(
onTap: !isGroup
? null
@ -162,7 +163,8 @@ class MessageRowState extends State<MessageRow> with SingleTickerProviderStateMi
padding: EdgeInsets.all(4.0),
child: ProfileImage(
diameter: 48.0,
imagePath: Provider.of<MessageMetadata>(context).senderImage ?? contact.imagePath,
// default to the contact image...otherwise use a derived sender image...
imagePath: sender?.imagePath ?? Provider.of<MessageMetadata>(context).senderImage!,
border: contact.status == "Authenticated" ? Provider.of<Settings>(context).theme.portraitOnlineBorderColor : Provider.of<Settings>(context).theme.portraitOfflineBorderColor,
badgeTextColor: Colors.red,
badgeColor: Colors.red,

View File

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:cwtch/themes/opaque.dart';
import 'package:provider/provider.dart';
@ -23,8 +25,11 @@ class ProfileImage extends StatefulWidget {
class _ProfileImageState extends State<ProfileImage> {
@override
Widget build(BuildContext context) {
var image = Image(
image: AssetImage("assets/" + widget.imagePath),
var file = new File(widget.imagePath);
var image = Image.file(
file,
cacheWidth: 512,
cacheHeight: 512,
filterQuality: FilterQuality.medium,
// We need some theme specific blending here...we might want to consider making this a theme level attribute
colorBlendMode: !widget.maskOut
@ -36,6 +41,21 @@ class _ProfileImageState extends State<ProfileImage> {
isAntiAlias: true,
width: widget.diameter,
height: widget.diameter,
errorBuilder: (context, error, stackTrace) {
// on android the above will fail for asset images, in which case try to load them the original way
return Image.asset(widget.imagePath,
filterQuality: FilterQuality.medium,
// We need some theme specific blending here...we might want to consider making this a theme level attribute
colorBlendMode: !widget.maskOut
? Provider.of<Settings>(context).theme.mode == mode_dark
? BlendMode.softLight
: BlendMode.darken
: BlendMode.srcOut,
color: Provider.of<Settings>(context).theme.portraitBackgroundColor,
isAntiAlias: true,
width: widget.diameter,
height: widget.diameter);
},
);
return RepaintBoundary(

View File

@ -105,11 +105,12 @@ class _ProfileRowState extends State<ProfileRow> {
void _pushEditProfile({onion: "", displayName: "", profileImage: "", encrypted: true}) {
Provider.of<ErrorHandler>(context, listen: false).reset();
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (BuildContext context) {
builder: (BuildContext bcontext) {
var profile = Provider.of<FlwtchState>(bcontext).profs.getProfile(onion)!;
return MultiProvider(
providers: [
ChangeNotifierProvider<ProfileInfoState>(
create: (_) => ProfileInfoState(onion: onion, nickname: displayName, imagePath: profileImage, encrypted: encrypted),
ChangeNotifierProvider<ProfileInfoState>.value(
value: profile,
),
],
builder: (context, widget) => AddEditProfileView(),

View File

@ -8,7 +8,8 @@ doNothing(String x) {}
// Provides a styled Text Field for use in Form Widgets.
// Callers must provide a text controller, label helper text and a validator.
class CwtchTextField extends StatefulWidget {
CwtchTextField({required this.controller, this.hintText = "", this.validator, this.autofocus = false, this.onChanged = doNothing, this.number = false, this.multiLine = false, this.key, this.testKey});
CwtchTextField(
{required this.controller, this.hintText = "", this.validator, this.autofocus = false, this.onChanged = doNothing, this.number = false, this.multiLine = false, this.key, this.testKey});
final TextEditingController controller;
final String hintText;
final FormFieldValidator? validator;