Skip to content

Support reporting a message - #2284

Merged
chrisbobbe merged 11 commits into
zulip:mainfrom
chrisbobbe:pr-report-message
Apr 29, 2026
Merged

chrisbobbe merged 11 commits into
zulip:mainfrom
chrisbobbe:pr-report-message

Conversation

@chrisbobbe

@chrisbobbe chrisbobbe commented Apr 21, 2026

Copy link
Copy Markdown
Collaborator

Fixes #1972.

cc @alya

Screenshots:

Details Screenshot 2026-04-21 at 1 48 20 PM
Light Dark
Screenshot 2026-04-21 at 1 48 30 PM Screenshot 2026-04-21 at 1 48 34 PM
Screenshot 2026-04-21 at 1 48 42 PM Screenshot 2026-04-21 at 1 48 45 PM
Screenshot 2026-04-21 at 1 48 52 PM Screenshot 2026-04-21 at 1 48 55 PM
Screenshot 2026-04-21 at 1 49 28 PM Screenshot 2026-04-21 at 1 49 30 PM
Screenshot 2026-04-21 at 1 49 42 PM Screenshot 2026-04-21 at 1 49 46 PM
Screenshot 2026-04-21 at 1 49 56 PM Screenshot 2026-04-21 at 1 49 59 PM
Screenshot 2026-04-21 at 1 50 13 PM Screenshot 2026-04-21 at 1 50 16 PM
Screenshot 2026-04-21 at 1 50 42 PM Screenshot 2026-04-21 at 1 50 44 PM

@chrisbobbe

Copy link
Copy Markdown
Collaborator Author

Oops, the dropdown menu should have 4.0 elevation instead of 3.0. Fixed now and pushed, but four of those screenshots are out of date. Here are new ones:

Light Dark
Screenshot 2026-04-21 at 2 04 41 PM Screenshot 2026-04-21 at 2 04 46 PM
Screenshot 2026-04-21 at 2 04 52 PM Screenshot 2026-04-21 at 2 04 54 PM

@chrisbobbe chrisbobbe added maintainer review PR ready for review by Zulip maintainers product review Added by maintainers when a PR needs product review labels Apr 21, 2026

@gnprice gnprice left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Only did a brief skim; comments from that below.)

Comment thread lib/widgets/actions.dart Outdated
@@ -394,6 +394,47 @@ abstract final class ZulipAction {
showErrorDialog(context: context, title: title, message: errorMessage);
}
}

/// Report a message to the server admins.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The realm admins, right?

Comment thread lib/widgets/input.dart
Comment on lines +231 to +235
final counter = Semantics(
container: true,
liveRegion: true,
child: Text(
'$length/$maxLengthCodePoints',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to be showing the counter unconditionally? I think in many places we prefer showing it only when the input is approaching (or has reached) the limit.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually this is the first time we'll be showing a counter anywhere :) so there isn't a consistency constraint here.

I think it's helpful to show it unconditionally, in case the interaction goes something unlike "start typing from zero until I get warned about length", e.g. if it involves deciding whether there's room to paste in some text, like to focus attention on an excerpt when you're reporting a very long message.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the counters I'm thinking of may all be in the web app — e.g. I think we have one on message content. It would definitely be annoying if we showed a persistent counter there.

I don't have a strong preference on whether to show one here.

@chrisbobbe
chrisbobbe force-pushed the pr-report-message branch 2 times, most recently from d9b291a to 4c9561d Compare April 24, 2026 22:52
@chrisbobbe

Copy link
Copy Markdown
Collaborator Author

Thanks for the skim, Greg! Revision pushed, fixing the nit and adding a comment about the counter:

          // Could show this conditionally, just as you approach the limit;
          // I lean toward always showing it in case you want to make an early
          // decision on whether to try pasting in some text, for example.

Since this is in maintainer review, I'll await @rajveermalviya's review next.

@rajveermalviya rajveermalviya left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @chrisbobbe! All LGTM, small comments below.

Moving over to Greg's review.

Comment thread lib/widgets/actions.dart
@@ -395,7 +395,7 @@ abstract final class ZulipAction {
}
}

/// Report a message to the server admins.
/// Report a message to the realm admins.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

report_message: Support reporting a message.
…

nit: I think this change was meant to be part of a previous commit, namely:

actions: Add ZulipAction.reportMessage

?

Comment on lines +98 to +103
_ReportTypeDropdown(
selectedType: _selectedType,
requestInProgress: _requestInProgress,
onSelected: (value) {
setState(() => _selectedType = value);
}),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On web the report type defaults to "Spam", see: https://github.com/zulip/zulip/blob/7b72738eccb30ef3f818534048ffeccb4b329ffa/web/src/message_report.ts#L230

Should we follow that here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, that's reasonable. I'll have it default to the first item in server_report_message_types.

if (success) {
Navigator.pop(context);
} else {
setState(() => _requestInProgress = false);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (after async call):

Suggested change
setState(() => _requestInProgress = false);
if (mounted) setState(() => _requestInProgress = false);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this handled already, just above the if/else?

    if (!mounted) return;
    if (success) {
      Navigator.pop(context);
    } else {
      setState(() => _requestInProgress = false);
    }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah oops. I somehow missed that line, please ignore the comment.

@rajveermalviya rajveermalviya added integration review Added by maintainers when PR may be ready for integration and removed maintainer review PR ready for review by Zulip maintainers labels Apr 27, 2026
@rajveermalviya
rajveermalviya requested a review from gnprice April 27, 2026 18:27
@chrisbobbe

Copy link
Copy Markdown
Collaborator Author

Thanks for the review! Revision pushed.

@gnprice gnprice left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for building this, and @rajveermalviya for the previous review! Various comments below; I think all are small.

I haven't tried manually playing with the UI.

Comment thread lib/widgets/button.dart
Comment on lines -96 to -100
case (ZulipWebUiKitButtonAttention.minimal, ZulipWebUiKitButtonIntent.warning):
case (ZulipWebUiKitButtonAttention.low, ZulipWebUiKitButtonIntent.warning):
throw UnimplementedError();
case (ZulipWebUiKitButtonAttention.medium, ZulipWebUiKitButtonIntent.warning):
return designVariables.btnLabelAttMediumIntWarning;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think this is easier to read when each of the pairs is a case line in the same format, rather than some coming after an ||.

That can be arranged by saying:

Color result;
switch ((attention, intent)) {
  // …
  case (ZulipWebUiKitButtonAttention.medium, ZulipWebUiKitButtonIntent.warning):
    result = designVariables.btnLabelAttMediumIntWarning;
  // …
}

and then the analyzer will verify that result is always set before it gets used.

Comment thread lib/widgets/dialog.dart Outdated
Comment on lines +40 to +43
titlePadding: EdgeInsets.fromLTRB(16, 12, 16, 4),
backgroundColor: designVariables.bgContextMenu,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Text(title,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: put titlePadding next to title?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(maybe shape, then background, then title, content, actions, since shape seems like it's the outermost aspect)

Comment thread lib/widgets/button.dart Outdated
Comment on lines +230 to +235
).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) {
Color result = _backgroundColor(designVariables).resolve(states);
if (states.contains(WidgetState.disabled)) {
result = result.withFadedAlpha(0.5);
}
return result;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can this be folded into _backgroundColor?

I agree it's good to keep this out of the switch statement, rather than make each of those cases more complex. Seems like it could go after the switch, though.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's a nice cleanup, thanks!

Comment thread lib/widgets/actions.dart Outdated
Comment on lines +414 to +419
if (!context.mounted) return false;
final zulipLocalizations = ZulipLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(behavior: SnackBarBehavior.floating,
content: Text(zulipLocalizations.reportMessageSuccess)));
return true;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this part belongs outside the try. If showing the snack bar somehow fails, it doesn't seem helpful to show an error dialog instead (if we even managed to do so in that situation).

This is an example of keeping the try block tightly scoped:
https://github.com/zulip/zulip-mobile/blob/main/docs/style.md#catch-specific

Comment thread test/widgets/input_test.dart Outdated
return tester.widget<EditableText>(find.byType(EditableText)).cursorColor;
}

testWidgets('smoke: empty controller shows 0/max', (tester) async {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the specifics seem clear enough:

Suggested change
testWidgets('smoke: empty controller shows 0/max', (tester) async {
testWidgets('empty controller shows 0/max', (tester) async {

Comment thread lib/widgets/report_message.dart Outdated
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: shorthand

(possibly we should enable a lint rule for that; not sure if there are many cases where it'd make the code less rather than more readable)

Comment thread lib/widgets/report_message.dart Outdated
Comment on lines +178 to +183
validator: (value) {
if (value == null) {
return zulipLocalizations.reportMessageReasonRequired;
}
return null;
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be impossible now that it has a default, right?

Comment thread lib/widgets/report_message.dart Outdated
Comment on lines +246 to +250
/// This is a [ValueGetter] so that the [validator] reads the latest value
/// at call time. When the dropdown value changes, [Form] validates all
/// fields synchronously via [FormField.didChange] before the parent has
/// a chance to rebuild this widget with updated props.
final ValueGetter<String?> selectedType;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, interesting subtlety.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will change a bit in the next revision:

  /// This is a [ValueGetter] so that [TextFormField.validator] reads the
  /// latest value at call time. When the dropdown value changes, [Form]
  /// triggers all its fields' validators *before* this widget rebuilds
  /// and receives the new [selectedType].

because Claude pointed out that "synchronously" isn't quite right:

Details
❯ OK, and the reasoning in that dartdoc is sound?

⏺ Let me check Flutter's actual behavior to verify the reasoning.

  Searched for 5 patterns, read 3 files (ctrl+o to expand)

⏺ The conclusion is sound, but one phrase is misleading. Let me walk through what actually happens:

  When the dropdown value changes:
  1. DropdownMenuFormField's state calls field.didChange(value), which calls FormFieldState.didChange →
  Form._fieldDidChange() → Form._forceRebuild() (a setState). Then it calls the user's onSelected
  (lib/.../dropdown_menu_form_field.dart:224-228), which triggers our parent's setState updating
  _selectedType — synchronously.
  2. On the next frame, parent rebuilds first; during parent's rebuild it calls updateChild on the Form
  element. Form is already dirty, so _FormState.build runs.
  3. Inside _FormState.build, the autovalidate switch (form.dart:280-294) calls _validate before returning
  the new child widget tree. _validate iterates _fields and calls each field.validate(), which uses
  widget.validator from the FormFieldState's current widget — which is still the old TextFormField from the
  previous build.
  4. Only after Form.build returns does the framework propagate updates down to the description field,
  giving it a fresh TextFormField with a validator closure that captures the new selectedType.

  So a plain String? selectedType prop would be stale at validate time (the closure captured the old prop
  value). A ValueGetter reads _selectedType from parent state at call time, and parent state was already
  updated synchronously in step 1, so it sees the new value. The conclusion (use a ValueGetter) holds.

  The misleading bit is "[Form] validates all fields synchronously via [FormField.didChange]".
  FormField.didChange doesn't validate synchronously — it calls Form._fieldDidChange, which only does
  setState. Validation runs later, during Form.build on the next frame. The key property isn't synchrony;
  it's that Form.build calls _validate before its child subtree gets rebuilt with updated props.

  Suggested rewording:

  /// This is a [ValueGetter] so that [TextFormField.validator] reads the
  /// latest value at call time. When the dropdown value changes, [Form]
  /// schedules a rebuild during which autovalidation runs [Form._validate]
  /// against all fields' current validators — before the parent's rebuild
  /// has propagated updated props down to this widget.

  Want me to apply that?

✻ Brewed for 4m 25s

※ recap: Fixing failing tests in commit 8d99a7c on the report_message dialog; bug is fixed and tests pass.
  Next: decide whether to apply the proposed dartdoc rewording on `_ReportDescriptionField.selectedType`.
  (disable recaps in /config)

❯ Is there a simpler way to explain it? Maybe: "This is a [ValueGetter] so that [TextFormField.validator]
  reads the latest value at call time. When the dropdown value changes, [Form] triggers all its fields'
  validators *before* this widget rebuilds and receives the new [selectedType]." Feel very free to push
  back or tweak as needed, I'm just thinking about what's most useful and comprehensible to the reader.

⏺ Yes, that's better — it gets the load-bearing fact (validators run before this widget rebuilds with new
  props) without dragging the reader through Form internals. I'd apply it as-is.

Comment on lines +263 to +269
validator: (value) {
if (selectedType() == kMessageReportTypeOther
&& (value == null || value.trim().isEmpty)) {
return zulipLocalizations.reportMessageDescriptionRequired;
}
return null;
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this might be clearest as a private helper method

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

… oh I see, but it needs the context to get the localizations; and this is a stateless widget so methods can't just get the context from the state. Probably this is simplest, then.

(If this were 2-3x longer than it is, a private method would still be worth it; this would just have to say something like (value) => _validate(context, value).)

Comment thread test/widgets/report_message_test.dart Outdated
Comment on lines +29 to +30
Future<void> showFromMessageList(WidgetTester tester, {
required StreamMessage message,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feature exists for DMs too, right? Ideally we'd have a test that exercises that, to ensure we don't accidentally make some sort of assumption that would break there.

(For example, if we were to add a rendering of the message at the top of the dialog — seems like a reasonable thing to do for confirmation that the user is acting on the message they intend to — then that might come with an indication of where it was sent, and that would involve some different code paths for DMs vs. channel/stream messages.)

@chrisbobbe

Copy link
Copy Markdown
Collaborator Author

Thanks for the review! Revision pushed.

@gnprice gnprice left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great! One nit; please merge at will.

Comment thread lib/widgets/dialog.dart Outdated
Comment on lines +42 to +48
title: Text(title,
style: TextStyle(
color: designVariables.title,
fontSize: 22,
height: 30 / 22,
).merge(weightVariableTextStyle(context, wght: 600))),
titlePadding: EdgeInsets.fromLTRB(16, 12, 16, 4),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'd be inclined to put the padding before the thing it's padding around — that's the order they'd appear if using a Padding widget directly, and fits the usual Flutter order of wrapper/metadata first, child/payload later

…pper

Drives a text field's counter widget, cursor color, and
currentValueLength/maxValueLength semantics from a Unicode code-point
count, matching what the Zulip server counts when validating text
input (e.g. for report-message `description`).

Uses a single listener on the caller's TextEditingController to
rebuild the counter and to toggle an ambient TextSelectionThemeData
override for the cursor color.  Replaces what would otherwise be a
reliance on Flutter's TextField.maxLength machinery, which counts
grapheme clusters rather than code points (flutter/flutter#182907).
This can be triggered in English with the label 'Your Zulip
organization URL' when you crank up the text-size setting.
Fixes zulip#1972.

Adds a "Report message" item in the message action sheet, gated on the
realm having a moderation-request channel configured. Tapping it opens
the new ReportMessageDialog, with a reason dropdown and an optional
description; submitting posts via ZulipAction.reportMessage.

The description field uses ZulipCodePointLengthLimit, so the "n/1000"
counter and the cursor's over-limit color are both driven by
Unicode code points, matching the server's `description` limit.
Over-limit input isn't prevented locally; submissions are rejected
by the server and surfaced as an error dialog.
@chrisbobbe
chrisbobbe merged commit a48fad2 into zulip:main Apr 29, 2026
2 checks passed
@chrisbobbe
chrisbobbe deleted the pr-report-message branch April 29, 2026 23:21
@chrisbobbe

Copy link
Copy Markdown
Collaborator Author

Thanks! Done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

integration review Added by maintainers when PR may be ready for integration product review Added by maintainers when a PR needs product review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support reporting a message

3 participants