Profile Images #355
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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']}");
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
// 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
dan
commented
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?
sarah
commented
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).
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.
dan
commented
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),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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;
|
||||
|
|
instead of copying aroind the _filepicker function can we make it a reusable widget that takes a function to handle the payload?