Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
a2b934b
refactor: replace relay connection check with delayed relay error sta…
codeswot Sep 22, 2025
44644b4
feat: implement delayed relay error handling and optimize connection …
codeswot Sep 22, 2025
17631fe
Merge branch 'master' of github.com:parres-hq/whitenoise_flutter
codeswot Sep 23, 2025
476f0d9
Merge branch 'master' of github.com:parres-hq/whitenoise_flutter
codeswot Sep 25, 2025
e69e0f0
Merge branch 'master' of github.com:parres-hq/whitenoise_flutter
codeswot Sep 26, 2025
68b926c
Merge branch 'master' of github.com:parres-hq/whitenoise_flutter
codeswot Sep 30, 2025
c7e2a70
Merge branch 'master' of github.com:parres-hq/whitenoise_flutter
codeswot Oct 1, 2025
b6fa9fb
Merge branch 'master' of github.com:parres-hq/whitenoise_flutter
codeswot Oct 1, 2025
4299063
Merge branch 'master' of github.com:parres-hq/whitenoise_flutter
codeswot Oct 2, 2025
be040c1
Refactor group chat creation to use createGroupProvider
codeswot Oct 3, 2025
47f2520
Add create group state and provider
codeswot Oct 3, 2025
0571e5f
Add discardChanges to group creation flow
codeswot Oct 3, 2025
01dc865
Refactor image utils usage in CreateGroupNotifier
codeswot Oct 3, 2025
3c87763
Merge branch 'master' of github.com:parres-hq/whitenoise_flutter
codeswot Oct 3, 2025
6a0041b
Merge branch 'master' into 535-implement-group-image-upload-on-group-…
codeswot Oct 3, 2025
38b53fe
Refactor group image upload to handle result and update group data
codeswot Oct 4, 2025
a38954e
Merge branch 'master' of github.com:parres-hq/whitenoise_flutter
codeswot Oct 6, 2025
59a3c76
Merge branch '535-implement-group-image-upload-on-group-creation' int…
codeswot Oct 9, 2025
9665166
feat: add group description handling and improve group creation flow
codeswot Oct 10, 2025
bc87da4
Refactor generated code for state management classes to improve reada…
codeswot Oct 10, 2025
a7b076f
Merge branch 'master' into patch-create-group
codeswot Oct 10, 2025
70981f9
feat: reset group creation state in discardChanges method
codeswot Oct 10, 2025
41aebf7
Merge branch 'master' of github.com:parres-hq/whitenoise_flutter
codeswot Oct 10, 2025
12d32f1
Merge branch 'master' into patch-create-group
codeswot Oct 10, 2025
84e2055
Merge branch 'patch-create-group' of github.com:parres-hq/whitenoise_…
codeswot Oct 10, 2025
2954d11
refactor: remove stackTrace from CreateGroupState and related classes
codeswot Oct 10, 2025
3af61a6
refactor: streamline code formatting in state classes for consistency
codeswot Oct 10, 2025
a9f9390
Merge branch 'master' of github.com:parres-hq/whitenoise_flutter
codeswot Oct 11, 2025
49a4f2a
resolve conficts
codeswot Oct 11, 2025
b6527fb
feat: update localization and improve group chat UI elements
codeswot Oct 11, 2025
e25c831
feat: restore group chat UI elements and improve invite message local…
codeswot Oct 11, 2025
7a1dfec
feat: add error message for retrying after an error occurs in multipl…
codeswot Oct 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
Binary file not shown.
Binary file modified android/app/src/main/jniLibs/x86_64/librust_lib_whitenoise.so
Binary file not shown.
248 changes: 248 additions & 0 deletions lib/config/providers/create_group_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:whitenoise/config/providers/active_account_provider.dart';
import 'package:whitenoise/config/providers/active_pubkey_provider.dart';
import 'package:whitenoise/config/providers/group_provider.dart';
import 'package:whitenoise/config/states/create_group_state.dart';
import 'package:whitenoise/domain/models/contact_model.dart';
import 'package:whitenoise/domain/services/image_picker_service.dart';
import 'package:whitenoise/src/rust/api/groups.dart';
import 'package:whitenoise/src/rust/api/users.dart';
import 'package:whitenoise/src/rust/api/utils.dart' as rust_utils;
import 'package:whitenoise/utils/localization_extensions.dart';

class CreateGroupNotifier extends StateNotifier<CreateGroupState> {
final _logger = Logger('CreateGroupNotifier');
static final _imagePickerService = ImagePickerService();
final Ref ref;

CreateGroupNotifier(this.ref) : super(const CreateGroupState());

void updateGroupName(String groupName) {
final isValid = groupName.trim().isNotEmpty;
state = state.copyWith(
groupName: groupName,
isGroupNameValid: isValid,
error: null,
);
}

void updateGroupDescription(String groupDescription) {
state = state.copyWith(
groupDescription: groupDescription,
error: null,
);
}

Future<void> pickGroupImage() async {
try {
final imagePath = await _imagePickerService.pickProfileImage();
if (imagePath != null) {
state = state.copyWith(
selectedImagePath: imagePath,
error: null,
);
}
} catch (e, st) {
_logger.severe('pickGroupImage', e, st);
state = state.copyWith(
error: 'Failed to pick group image',
);
}
}

Future<void> filterContactsWithKeyPackage(
List<ContactModel> selectedContacts,
) async {
try {
final filteredContacts = await _filterContactsByKeyPackage(selectedContacts);
final contactsWithKeyPackage = filteredContacts['withKeyPackage']!;
final contactsWithoutKeyPackage = filteredContacts['withoutKeyPackage']!;

state = state.copyWith(
contactsWithKeyPackage: contactsWithKeyPackage,
contactsWithoutKeyPackage: contactsWithoutKeyPackage,
shouldShowInviteSheet: contactsWithoutKeyPackage.isNotEmpty,
);

if (contactsWithKeyPackage.isEmpty) {
state = state.copyWith(isCreatingGroup: false);
return;
}
} catch (e, st) {
_logger.severe('filterContactsWithKeyPackage', e, st);
state = state.copyWith(
error: 'Error filtering contacts: ${e.toString()}',
isCreatingGroup: false,
);
}
}

Future<void> createGroup({
ValueChanged<Group?>? onGroupCreated,
}) async {
if (!state.isGroupNameValid) return;
if (state.contactsWithKeyPackage.isEmpty) return;

state = state.copyWith(isCreatingGroup: true, error: null);

try {
final createdGroup = await _createGroupWithContacts(state.contactsWithKeyPackage);

if (createdGroup != null) {
if (state.selectedImagePath != null && state.selectedImagePath!.isNotEmpty) {
final activePubkey = ref.read(activePubkeyProvider) ?? '';
if (activePubkey.isEmpty) {
throw Exception('No active pubkey available');
}

final uploadResult = await _uploadGroupImage(
createdGroup.mlsGroupId,
activePubkey,
);

if (uploadResult != null) {
await createdGroup.updateGroupData(
accountPubkey: activePubkey,
groupData: FlutterGroupDataUpdate(
imageKey: uploadResult.imageKey,
imageHash: uploadResult.encryptedHash,
imageNonce: uploadResult.imageNonce,
),
);
}
}

onGroupCreated?.call(createdGroup);

state = state.copyWith(
isCreatingGroup: false,
shouldShowInviteSheet: false,
contactsWithoutKeyPackage: [],
);
} else {
state = state.copyWith(
error: 'ui.failedToCreateGroup'.tr(),
isCreatingGroup: false,
);
}
} catch (e, st) {
_logger.severe('createGroup', e, st);
state = state.copyWith(
error: 'ui.errorCreatingGroup'.tr(),
isCreatingGroup: false,
);
}
}

Future<Group?> _createGroupWithContacts(List<ContactModel> contactsWithKeyPackage) async {
final groupName = state.groupName.trim();
final groupDescription = state.groupDescription.trim();
final notifier = ref.read(groupsProvider.notifier);

return await notifier.createNewGroup(
groupName: groupName,
groupDescription: groupDescription,
memberPublicKeyHexs: contactsWithKeyPackage.map((c) => c.publicKey).toList(),
adminPublicKeyHexs: [],
);
}

Future<UploadGroupImageResult?> _uploadGroupImage(String groupId, String accountPubkey) async {
if (state.selectedImagePath == null || state.selectedImagePath!.isEmpty) return null;

state = state.copyWith(isUploadingImage: true, error: null);

try {
final imageUtils = ref.read(wnImageUtilsProvider);
final imageType = await imageUtils.getMimeTypeFromPath(state.selectedImagePath!);
if (imageType == null) {
throw Exception(
'Could not determine image type from file path: ${state.selectedImagePath}',
);
}

final serverUrl = await rust_utils.getDefaultBlossomServerUrl();

final result = await uploadGroupImage(
accountPubkey: accountPubkey,
groupId: groupId,
filePath: state.selectedImagePath!,
imageType: imageType,
serverUrl: serverUrl,
);

state = state.copyWith(
isUploadingImage: false,
error: null,
);
return result;
} catch (e, st) {
_logger.severe('_uploadGroupImage', e, st);
state = state.copyWith(
error: 'Failed to upload group image: ${e.toString()}',
isUploadingImage: false,
);
}
return null;
}

Future<Map<String, List<ContactModel>>> _filterContactsByKeyPackage(
List<ContactModel> contacts,
) async {
final contactsWithKeyPackage = <ContactModel>[];
final contactsWithoutKeyPackage = <ContactModel>[];

for (final contact in contacts) {
try {
final hasKeyPackage = await userHasKeyPackage(pubkey: contact.publicKey);

if (hasKeyPackage) {
contactsWithKeyPackage.add(contact);
} else {
contactsWithoutKeyPackage.add(contact);
}
} catch (e) {
contactsWithoutKeyPackage.add(contact);
}
}

return {
'withKeyPackage': contactsWithKeyPackage,
'withoutKeyPackage': contactsWithoutKeyPackage,
};
}

void clearError() {
state = state.copyWith(
error: null,
);
}

void dismissInviteSheet() {
state = state.copyWith(
shouldShowInviteSheet: false,
contactsWithoutKeyPackage: [],
);
}

void discardChanges() {
state = state.copyWith(
groupName: '',
groupDescription: '',
isGroupNameValid: false,
isCreatingGroup: false,
isUploadingImage: false,
selectedImagePath: null,
error: null,
contactsWithKeyPackage: [],
contactsWithoutKeyPackage: [],
shouldShowInviteSheet: false,
);
}
}

final createGroupProvider = StateNotifierProvider<CreateGroupNotifier, CreateGroupState>(
CreateGroupNotifier.new,
);
28 changes: 28 additions & 0 deletions lib/config/states/create_group_state.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:whitenoise/domain/models/contact_model.dart';

part 'create_group_state.freezed.dart';

@freezed
sealed class CreateGroupState with _$CreateGroupState {
const factory CreateGroupState({
@Default('') String groupName,
@Default('') String groupDescription,
@Default(false) bool isGroupNameValid,
@Default(false) bool isCreatingGroup,
@Default(false) bool isUploadingImage,
String? selectedImagePath,
String? error,
@Default([]) List<ContactModel> contactsWithoutKeyPackage,
@Default([]) List<ContactModel> contactsWithKeyPackage,
@Default(false) bool shouldShowInviteSheet,
}) = _CreateGroupState;

const CreateGroupState._();

bool get canCreateGroup =>
isGroupNameValid &&
!isCreatingGroup &&
!isUploadingImage &&
contactsWithKeyPackage.isNotEmpty;
}
Loading