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
69 changes: 69 additions & 0 deletions lib/src/api/client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,40 @@ class RestrictedAuthException implements Exception {
}
}

/// Thrown when the server responds with an error status and a structured
/// problem body (RFC 7807 / RFC 2616-style JSON), e.g.
/// `{"type": ..., "title": ..., "status": 400, "detail": "..."}`.
///
/// Callers that know a given failure is really a validation problem can catch
/// this and surface [detail] as a friendly, inline message. Everything else can
/// keep relying on [toString], which stays human-readable.
class ServerErrorException implements Exception {
ServerErrorException({
required this.statusCode,
required this.uri,
required this.rawBody,
this.title,
this.detail,
});

final int statusCode;

final Uri uri;

/// Raw response body, kept for logging / debugging.
final String rawBody;

/// Short summary of the error, if the server provided one.
final String? title;

/// Human-readable explanation of the error, suitable for showing to users.
final String? detail;

@override
String toString() =>
detail ?? title ?? 'Request failed with status $statusCode: $rawBody';
}

class ServerClient {
ServerClient({
required this.httpClient,
Expand Down Expand Up @@ -198,6 +232,12 @@ class ServerClient {
throw RestrictedAuthException(response.body, url);
}

// Prefer a structured problem body (e.g. `{"title": ..., "detail": ...}`)
// so callers can show `detail` as a friendly, inline message instead of a
// raw JSON blob.
final structured = _tryParseServerError(url, response);
if (structured != null) throw structured;

var message = 'Request failed with status ${response.statusCode}';

if (response.reasonPhrase != null) {
Expand All @@ -210,6 +250,35 @@ class ServerClient {

throw http.ClientException(message, url);
}

static ServerErrorException? _tryParseServerError(
Uri url,
http.Response response,
) {
if (response.body.isEmpty) return null;

try {
final decoded = jsonDecode(utf8.decode(response.bodyBytes));
if (decoded is! Map) return null;

final title = decoded['title'];
final detail = decoded['detail'];

// Only treat this as a structured error if it actually carries a
// human-readable message; otherwise fall back to the generic exception.
if (title is! String && detail is! String) return null;

return ServerErrorException(
statusCode: response.statusCode,
uri: url,
rawBody: response.body,
title: title is String ? title : null,
detail: detail is String ? detail : null,
);
} catch (_) {
return null;
}
}
}

extension BodyJson on http.Response {
Expand Down
134 changes: 111 additions & 23 deletions lib/src/screens/explore/community_owner_panel.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:interstellar/src/api/client.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 @@ -99,6 +102,13 @@ class _CommunityOwnerPanelGeneralState
late bool _isAdult;
late bool _isPostingRestrictedToMods;

/// Name validation message returned by the server, and the name it was
/// returned for. It is only shown while the field still holds that name, so
/// it clears itself on any edit, including the suggestion button setting the
/// controller text directly.
String? _nameServerError;
String? _nameServerErrorFor;

@override
void initState() {
super.initState();
Expand All @@ -112,23 +122,78 @@ 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);
final serverNameError = _nameServerErrorFor == name
? _nameServerError
: null;

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: serverNameError ?? 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 +245,7 @@ class _CommunityOwnerPanelGeneralState
child: LoadingFilledButton(
onPressed:
_nameController.text.isEmpty ||
nameInvalidForMbin ||
_titleController.text.isEmpty ||
(_titleController.text == widget.data?.title &&
_descriptionController.text ==
Expand All @@ -190,23 +256,45 @@ class _CommunityOwnerPanelGeneralState
? null
: () async {
final ac = context.read<AppController>();
final result = widget.data == null
? await ac.api.communityModeration.create(
name: _nameController.text,
title: _titleController.text,
description: _descriptionController.text,
isAdult: _isAdult,
isPostingRestrictedToMods:
_isPostingRestrictedToMods,
)
: await ac.api.communityModeration.edit(
widget.data!.id,
title: _titleController.text,
description: _descriptionController.text,
isAdult: _isAdult,
isPostingRestrictedToMods:
_isPostingRestrictedToMods,
);

if (isCreating) {
final DetailedCommunityModel result;
try {
result = await ac.api.communityModeration.create(
name: _nameController.text,
title: _titleController.text,
description: _descriptionController.text,
isAdult: _isAdult,
isPostingRestrictedToMods: _isPostingRestrictedToMods,
);
} on ServerErrorException catch (e) {
// Name problems only the server can know about (already
// taken, reserved, instance policy) come back as a 400
// with a human-readable detail. Show it on the Name
// field instead of letting the global snackbar fire.
if (e.statusCode == 400 && e.detail != null) {
setState(() {
_nameServerError = e.detail;
_nameServerErrorFor = _nameController.text;
});
return;
}
rethrow;
}

await descriptionDraftController.discard();

widget.onUpdate(result);
return;
}

final result = await ac.api.communityModeration.edit(
widget.data!.id,
title: _titleController.text,
description: _descriptionController.text,
isAdult: _isAdult,
isPostingRestrictedToMods: _isPostingRestrictedToMods,
);

await descriptionDraftController.discard();

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
Loading