Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 26 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,32 @@
"create_link_invalid": "Url is invalid",
"create_microblog": "Microblog",
"create_community": "Community",
"community_nameMbinHelp": "2–25 characters. Letters, numbers and underscores only.",
"community_nameInvalidCharacters": "Only letters, numbers and underscores are allowed.",
"community_nameTooShort": "Name must be at least {count} characters.",
"@community_nameTooShort": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"community_nameTooLong": "Name must be at most {count} characters.",
"@community_nameTooLong": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"community_nameUseSuggestion": "Use “{name}”",
"@community_nameUseSuggestion": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"microblog_communityHelperText": "Defaults to the 'random' community for microblogs since that's where Mbin stores uncatagorized microblogs.",
"selectCommunity": "Select a community ...",
"title": "Title",
Expand Down
67 changes: 61 additions & 6 deletions lib/src/screens/explore/community_owner_panel.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:interstellar/src/controller/controller.dart';
import 'package:interstellar/src/controller/server.dart';
import 'package:interstellar/src/models/community.dart';
import 'package:interstellar/src/models/user.dart';
import 'package:interstellar/src/screens/explore/user_item.dart';
import 'package:interstellar/src/utils/mbin_community_name.dart';
import 'package:interstellar/src/utils/utils.dart';
import 'package:interstellar/src/widgets/loading_button.dart';
import 'package:interstellar/src/widgets/markdown/drafts_controller.dart';
Expand Down Expand Up @@ -112,23 +114,75 @@ class _CommunityOwnerPanelGeneralState
widget.data?.isPostingRestrictedToMods ?? false;
}

String? _mbinNameError(BuildContext context, MbinCommunityNameIssue? issue) {
switch (issue) {
case MbinCommunityNameIssue.invalidCharacters:
return l(context).community_nameInvalidCharacters;
case MbinCommunityNameIssue.tooShort:
return l(context).community_nameTooShort(mbinCommunityNameMinLength);
case MbinCommunityNameIssue.tooLong:
return l(context).community_nameTooLong(mbinCommunityNameMaxLength);
case null:
return null;
}
}

@override
Widget build(BuildContext context) {
final descriptionDraftController = context.watch<DraftsController>().auto(
'community:description${widget.data == null ? '' : ':${widget.data}'}',
);

final isCreating = widget.data == null;
// Mbin is the only backend that rejects names outside of
// /^[a-zA-Z0-9_]{2,25}$/, so only validate/suggest for it. The name field
// itself is only shown while creating; edits never touch the name.
final enforceMbinName =
isCreating &&
context.watch<AppController>().serverSoftware == ServerSoftware.mbin;

final name = _nameController.text;
final mbinNameIssue = enforceMbinName ? mbinCommunityNameIssue(name) : null;
final mbinNameSuggestion = enforceMbinName
? suggestMbinCommunityName(name)
: null;
final nameInvalidForMbin =
enforceMbinName && !isValidMbinCommunityName(name);
final mbinNameErrorText = _mbinNameError(context, mbinNameIssue);

return ListView(
padding: const EdgeInsets.all(16),
children: [
if (widget.data == null)
if (isCreating)
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: TextEditor(
_nameController,
label: 'Name',
onChanged: (_) => setState(() {}),
maxLength: 25,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextEditor(
_nameController,
label: 'Name',
onChanged: (_) => setState(() {}),
maxLength: enforceMbinName ? mbinCommunityNameMaxLength : 25,
helperText: enforceMbinName
? l(context).community_nameMbinHelp
: null,
errorText: mbinNameErrorText,
),
if (mbinNameSuggestion case final suggestion?)
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
onPressed: () => setState(() {
_nameController.text = suggestion;
}),
icon: const Icon(Symbols.auto_fix_high_rounded),
label: Text(
l(context).community_nameUseSuggestion(suggestion),
),
),
),
],
),
),
Padding(
Expand Down Expand Up @@ -180,6 +234,7 @@ class _CommunityOwnerPanelGeneralState
child: LoadingFilledButton(
onPressed:
_nameController.text.isEmpty ||
nameInvalidForMbin ||
_titleController.text.isEmpty ||
(_titleController.text == widget.data?.title &&
_descriptionController.text ==
Expand Down
61 changes: 61 additions & 0 deletions lib/src/utils/mbin_community_name.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/// Helpers for validating and repairing Mbin magazine (community) names.
///
/// Mbin restricts magazine names to 2-25 characters consisting only of
/// letters, digits and underscores (`RegPatterns::MAGAZINE_NAME` /
/// `/^[a-zA-Z0-9_]{2,25}$/` upstream). Lemmy and PieFed use different rules,
/// so callers should only apply these checks when talking to an Mbin server.
library;

const int mbinCommunityNameMinLength = 2;
const int mbinCommunityNameMaxLength = 25;

final RegExp _mbinCommunityNameRegExp = RegExp(r'^[a-zA-Z0-9_]{2,25}$');
final RegExp _mbinCommunityNameInvalidChars = RegExp(r'[^a-zA-Z0-9_]');

/// Whether [name] is a valid Mbin magazine name that can be submitted as-is.
bool isValidMbinCommunityName(String name) =>
_mbinCommunityNameRegExp.hasMatch(name);

/// The reason [name] is not a valid Mbin magazine name, or `null` when it is
/// valid (or still empty, which is treated as "not entered yet").
MbinCommunityNameIssue? mbinCommunityNameIssue(String name) {
if (name.isEmpty) return null;
if (_mbinCommunityNameInvalidChars.hasMatch(name)) {
return MbinCommunityNameIssue.invalidCharacters;
}
if (name.length < mbinCommunityNameMinLength) {
return MbinCommunityNameIssue.tooShort;
}
if (name.length > mbinCommunityNameMaxLength) {
return MbinCommunityNameIssue.tooLong;
}
return null;
}

enum MbinCommunityNameIssue { invalidCharacters, tooShort, tooLong }

/// A best-effort valid name derived from [name], or `null` when nothing
/// usable can be salvaged (e.g. the input has no letters/digits at all) or
/// when [name] is already valid.
String? suggestMbinCommunityName(String name) {
if (isValidMbinCommunityName(name)) return null;

// Replace every run of unsupported characters (whitespace, punctuation,
// accented letters, ...) with a single underscore, then tidy up the
// underscores so the result reads naturally.
var suggestion = name
.replaceAll(_mbinCommunityNameInvalidChars, '_')
.replaceAll(RegExp(r'_+'), '_')
.replaceAll(RegExp(r'^_+|_+$'), '');

if (suggestion.length > mbinCommunityNameMaxLength) {
suggestion = suggestion
.substring(0, mbinCommunityNameMaxLength)
.replaceAll(RegExp(r'_+$'), '');
}

if (suggestion.length < mbinCommunityNameMinLength) return null;
if (suggestion == name) return null;

return suggestion;
}
8 changes: 8 additions & 0 deletions lib/src/widgets/text_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ class TextEditor extends StatelessWidget {
this.keyboardType,
this.label,
this.hint,
this.helperText,
this.errorText,
this.onChanged,
this.enabled,
this.maxLength,
Expand All @@ -17,6 +19,8 @@ class TextEditor extends StatelessWidget {
final TextInputType? keyboardType;
final String? label;
final String? hint;
final String? helperText;
final String? errorText;
final void Function(String)? onChanged;
final bool? enabled;
final int? maxLength;
Expand All @@ -31,6 +35,10 @@ class TextEditor extends StatelessWidget {
border: const OutlineInputBorder(),
labelText: label,
hintText: hint,
helperText: helperText,
helperMaxLines: 3,
errorText: errorText,
errorMaxLines: 3,
),
onChanged: onChanged,
enabled: enabled,
Expand Down
4 changes: 4 additions & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ dependencies:
unifiedpush_platform_interface: ^4.0.0
unifiedpush_storage_interface: ^1.0.0

dev_dependencies:
flutter_test:
sdk: flutter

# Needed for 16kb page, remove once this pr (https://github.com/google/webcrypto.dart/pull/238) is merged and the next version is released.
dependency_overrides:
webcrypto:
Expand Down
96 changes: 96 additions & 0 deletions test/utils/mbin_community_name_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:interstellar/src/utils/mbin_community_name.dart';

void main() {
group('isValidMbinCommunityName', () {
test('accepts letters, digits and underscores within 2-25 chars', () {
expect(isValidMbinCommunityName('ab'), isTrue);
expect(isValidMbinCommunityName('under_score'), isTrue);
expect(isValidMbinCommunityName('Mixed_Case_123'), isTrue);
expect(isValidMbinCommunityName('a' * 25), isTrue);
});

test('rejects empty, too short and too long names', () {
expect(isValidMbinCommunityName(''), isFalse);
expect(isValidMbinCommunityName('a'), isFalse);
expect(isValidMbinCommunityName('a' * 26), isFalse);
});

test('rejects unsupported characters', () {
expect(isValidMbinCommunityName('with space'), isFalse);
expect(isValidMbinCommunityName('with-hyphen'), isFalse);
expect(isValidMbinCommunityName('dot.separated'), isFalse);
expect(isValidMbinCommunityName('accenté'), isFalse);
});
});

group('mbinCommunityNameIssue', () {
const invalid = MbinCommunityNameIssue.invalidCharacters;
const tooShort = MbinCommunityNameIssue.tooShort;
const tooLong = MbinCommunityNameIssue.tooLong;

test('returns null for an empty (not yet entered) name', () {
expect(mbinCommunityNameIssue(''), isNull);
});

test('returns null for a valid name', () {
expect(mbinCommunityNameIssue('valid_Name_123'), isNull);
});

test('reports invalid characters ahead of length problems', () {
expect(mbinCommunityNameIssue('hello world'), invalid);
expect(mbinCommunityNameIssue('!'), invalid);
});

test('reports names that are too short', () {
expect(mbinCommunityNameIssue('a'), tooShort);
});

test('reports names longer than 25 characters', () {
expect(mbinCommunityNameIssue('a' * 26), tooLong);
});
});

group('suggestMbinCommunityName', () {
test('returns null when the name is already valid', () {
expect(suggestMbinCommunityName('already_valid'), isNull);
});

test('replaces unsupported runs with a single underscore', () {
final result = suggestMbinCommunityName('My Cool Community!');
expect(result, 'My_Cool_Community');
expect(suggestMbinCommunityName('a...b---c'), 'a_b_c');
});

test('trims leading and trailing underscores', () {
expect(suggestMbinCommunityName(' hello!! '), 'hello');
});

test('truncates to 25 characters without a trailing underscore', () {
final s = suggestMbinCommunityName('abcdefghijklmnopqrstuvwx yz');
expect(s, 'abcdefghijklmnopqrstuvwx');
expect(isValidMbinCommunityName(s!), isTrue);
});

test('returns null when nothing usable can be salvaged', () {
expect(suggestMbinCommunityName('a'), isNull);
expect(suggestMbinCommunityName(' '), isNull);
expect(suggestMbinCommunityName('日本語'), isNull);
});

test('always produces a valid name when it returns one', () {
const inputs = [
'hello world',
'Trailing punctuation???',
'***leading',
'lots of spaces',
'cafe-society #2',
];
for (final input in inputs) {
final suggestion = suggestMbinCommunityName(input);
if (suggestion == null) continue;
expect(isValidMbinCommunityName(suggestion), isTrue, reason: input);
}
});
});
}