Fix message action accessibility lifecycle

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-16 14:22:57 +01:00
parent e87ee8a7ba
commit f976562b01
4 changed files with 196 additions and 38 deletions
@@ -34,7 +34,8 @@ struct NativeMessageActionDefinition {
}
enum NativeMessageActionSurfaceLayout {
static let rowHeight: CGFloat = 48
static let minimumRowHeight: CGFloat = 48
static let rowVerticalPadding: CGFloat = 4
static let separatorHeight: CGFloat = 0.5
static let verticalInset: CGFloat = 4
static let horizontalInset: CGFloat = 16
@@ -62,11 +63,31 @@ enum NativeMessageActionSurfaceLayout {
max(0, populatedGroups(actions: actions).count - 1)
}
static func preferredHeight(
actions: [NativeMessageActionDefinition]
static func rowHeight(
minimumHeight: CGFloat = minimumRowHeight,
compatibleWith traitCollection: UITraitCollection? = nil
) -> CGFloat {
(verticalInset * 2)
+ (CGFloat(actions.count) * rowHeight)
let labelHeight = UIFont.preferredFont(
forTextStyle: .body,
compatibleWith: traitCollection
).lineHeight
return max(
minimumHeight,
ceil(labelHeight + (rowVerticalPadding * 2))
)
}
static func preferredHeight(
actions: [NativeMessageActionDefinition],
minimumRowHeight: CGFloat = minimumRowHeight,
compatibleWith traitCollection: UITraitCollection? = nil
) -> CGFloat {
let resolvedRowHeight = rowHeight(
minimumHeight: minimumRowHeight,
compatibleWith: traitCollection
)
return (verticalInset * 2)
+ (CGFloat(actions.count) * resolvedRowHeight)
+ (CGFloat(separatorCount(actions: actions)) * separatorHeight)
}
}
@@ -110,6 +131,8 @@ final class NativeMessageActionRowControl: UIControl {
definition: NativeMessageActionDefinition,
foregroundColor: UIColor,
destructiveColor: UIColor,
minimumHeight: CGFloat = NativeMessageActionSurfaceLayout.minimumRowHeight,
compatibleWith traitCollection: UITraitCollection? = nil,
onSelected: @escaping () -> Void
) {
actionImageView = UIImageView(image: UIImage(systemName: definition.symbol))
@@ -121,11 +144,17 @@ final class NativeMessageActionRowControl: UIControl {
actionTitleLabel.text = definition.title
actionTitleLabel.textColor = color
actionTitleLabel.font = UIFont.preferredFont(forTextStyle: .body)
actionTitleLabel.font = UIFont.preferredFont(
forTextStyle: .body,
compatibleWith: traitCollection
)
actionTitleLabel.adjustsFontForContentSizeCategory = true
actionTitleLabel.numberOfLines = 1
actionTitleLabel.adjustsFontSizeToFitWidth = true
actionTitleLabel.minimumScaleFactor = 0.8
actionTitleLabel.numberOfLines = 0
actionTitleLabel.lineBreakMode = .byWordWrapping
actionTitleLabel.setContentCompressionResistancePriority(
.required,
for: .vertical
)
let iconColumn = UIView()
iconColumn.translatesAutoresizingMaskIntoConstraints = false
@@ -159,9 +188,20 @@ final class NativeMessageActionRowControl: UIControl {
equalTo: trailingAnchor,
constant: -NativeMessageActionSurfaceLayout.horizontalInset
),
actionTitleLabel.topAnchor.constraint(
greaterThanOrEqualTo: topAnchor,
constant: NativeMessageActionSurfaceLayout.rowVerticalPadding
),
actionTitleLabel.bottomAnchor.constraint(
lessThanOrEqualTo: bottomAnchor,
constant: -NativeMessageActionSurfaceLayout.rowVerticalPadding
),
actionTitleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
heightAnchor.constraint(
greaterThanOrEqualToConstant: NativeMessageActionSurfaceLayout.rowHeight
greaterThanOrEqualToConstant: NativeMessageActionSurfaceLayout.rowHeight(
minimumHeight: minimumHeight,
compatibleWith: traitCollection
)
),
])
@@ -265,6 +305,15 @@ final class NativeMessageActionSurfacePlatformView: NSObject,
let interfaceStyle = NativeMessageActionSurfaceAppearance.interfaceStyle(
from: arguments?["interfaceStyle"]
)
var minimumRowHeight = NativeMessageActionSurfaceLayout.minimumRowHeight
if let requestedRowHeight = arguments?["rowHeight"] as? NSNumber,
requestedRowHeight.doubleValue.isFinite
{
minimumRowHeight = max(
minimumRowHeight,
CGFloat(requestedRowHeight.doubleValue)
)
}
let actionArguments = arguments?["actions"] as? [[String: Any]]
let actions =
actionArguments?.compactMap(
@@ -317,7 +366,8 @@ final class NativeMessageActionSurfacePlatformView: NSObject,
actions: actions,
foregroundColor: foregroundColor,
destructiveColor: destructiveColor,
separatorColor: separatorColor
separatorColor: separatorColor,
minimumRowHeight: minimumRowHeight
)
}
@@ -329,7 +379,8 @@ final class NativeMessageActionSurfacePlatformView: NSObject,
actions: [NativeMessageActionDefinition],
foregroundColor: UIColor,
destructiveColor: UIColor,
separatorColor: UIColor
separatorColor: UIColor,
minimumRowHeight: CGFloat
) {
let scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
@@ -377,6 +428,7 @@ final class NativeMessageActionSurfacePlatformView: NSObject,
definition: definition,
foregroundColor: foregroundColor,
destructiveColor: destructiveColor,
minimumHeight: minimumRowHeight,
onSelected: { [weak self] in self?.select(definition) }
)
)
+52 -1
View File
@@ -431,7 +431,12 @@ class RunnerTests: XCTestCase {
2
)
XCTAssertEqual(
NativeMessageActionSurfaceLayout.preferredHeight(actions: definitions),
NativeMessageActionSurfaceLayout.preferredHeight(
actions: definitions,
compatibleWith: UITraitCollection(
preferredContentSizeCategory: .large
)
),
153
)
}
@@ -463,6 +468,52 @@ class RunnerTests: XCTestCase {
XCTAssertTrue(selected)
}
@MainActor
func testNativeMessageActionRowExpandsForAccessibilityTypography() throws {
let traits = UITraitCollection(
preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge
)
let definition = try XCTUnwrap(
NativeMessageActionDefinition(
arguments: [
"id": "followThread", "title": "Follow thread",
"symbol": "bell", "group": "utility",
]
)
)
let row = NativeMessageActionRowControl(
definition: definition,
foregroundColor: .label,
destructiveColor: .systemRed,
compatibleWith: traits,
onSelected: {}
)
let fittingSize = row.systemLayoutSizeFitting(
CGSize(width: 288, height: UIView.layoutFittingCompressedSize.height),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
)
row.frame = CGRect(origin: .zero, size: fittingSize)
row.layoutIfNeeded()
let labelFrame = row.convert(
row.actionTitleLabel.bounds,
from: row.actionTitleLabel
)
XCTAssertGreaterThan(fittingSize.height, 48)
XCTAssertGreaterThan(labelFrame.height, 0)
XCTAssertGreaterThanOrEqual(
labelFrame.minY,
NativeMessageActionSurfaceLayout.rowVerticalPadding
)
XCTAssertLessThanOrEqual(
labelFrame.maxY,
fittingSize.height - NativeMessageActionSurfaceLayout.rowVerticalPadding
)
XCTAssertEqual(row.actionTitleLabel.numberOfLines, 0)
XCTAssertFalse(row.actionTitleLabel.adjustsFontSizeToFitWidth)
}
@MainActor
func testNativeMessageActionSurfaceUsesSystemMaterial() {
let effect = NativeMessageActionSurfaceAppearance.backdropEffect(
@@ -155,32 +155,39 @@ Future<bool> _showMessageActionsPopover({
if (shouldRestoreComposerFocus) composerFocusNode!.unfocus();
String? selectedActionId;
final dialogRoute = RawDialogRoute<String>(
barrierDismissible: true,
barrierLabel: 'Dismiss message actions',
barrierColor: Colors.transparent,
transitionDuration: reduceMotion
? Duration.zero
: isIos
? _iosMessageActionTransitionDuration
: _messageActionTransitionDuration,
transitionBuilder: (context, animation, secondaryAnimation, child) =>
child,
pageBuilder: (dialogContext, animation, secondaryAnimation) =>
_MessageActionsPopover(
anchorRect: anchorRect,
anchorSnapshot: snapshot,
animation: animation,
message: message,
pageContext: context,
pageRef: ref,
actions: actions,
useIosNativeActionSurface: useIosNativeActionSurface,
),
);
var routePushed = false;
try {
selectedActionId = await showGeneralDialog<String>(
context: context,
barrierDismissible: true,
barrierLabel: 'Dismiss message actions',
barrierColor: Colors.transparent,
transitionDuration: reduceMotion
? Duration.zero
: isIos
? _iosMessageActionTransitionDuration
: _messageActionTransitionDuration,
transitionBuilder: (context, animation, secondaryAnimation, child) =>
child,
pageBuilder: (dialogContext, animation, secondaryAnimation) =>
_MessageActionsPopover(
anchorRect: anchorRect,
anchorSnapshot: snapshot,
animation: animation,
message: message,
pageContext: context,
pageRef: ref,
actions: actions,
useIosNativeActionSurface: useIosNativeActionSurface,
),
);
final popResult = Navigator.of(
context,
rootNavigator: true,
).push(dialogRoute);
routePushed = true;
selectedActionId = await popResult;
} finally {
if (routePushed) await dialogRoute.completed;
snapshot.dispose();
if (context.mounted) onPopoverDismissed?.call();
}
@@ -447,10 +454,12 @@ class _PopoverMessageAction {
class _IosNativeMessageActionSurface extends HookWidget {
final List<_PopoverMessageAction> actions;
final double rowHeight;
final ValueChanged<String> onSelected;
const _IosNativeMessageActionSurface({
required this.actions,
required this.rowHeight,
required this.onSelected,
});
@@ -479,6 +488,7 @@ class _IosNativeMessageActionSurface extends HookWidget {
'separatorColor': context.colors.outlineVariant.toARGB32(),
'errorColor': context.colors.error.toARGB32(),
'interfaceStyle': context.colors.brightness.name,
'rowHeight': rowHeight,
},
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: (id) => viewId.value = id,
@@ -690,6 +700,7 @@ class _MessageActionsPopover extends StatelessWidget {
child: useIosNativeActionSurface
? _IosNativeMessageActionSurface(
actions: actions,
rowHeight: menuLayout.rowHeight,
onSelected: (actionId) =>
Navigator.of(context).pop(actionId),
)
@@ -215,6 +215,7 @@ Future<_MessageActionsPopoverHarness> _pumpMessageActionsPopover(
bool disableAnimations = false,
EdgeInsets viewInsets = EdgeInsets.zero,
TextScaler textScaler = TextScaler.noScaling,
Future<ui.Image> Function()? captureAnchorSnapshot,
FocusNode? composerFocusNode,
bool composerInitiallyFocused = false,
Rect anchorRect = const Rect.fromLTWH(32, 260, 300, 72),
@@ -262,7 +263,8 @@ Future<_MessageActionsPopoverHarness> _pumpMessageActionsPopover(
currentPubkey: 'self',
isMember: true,
anchorRect: anchorRect,
captureAnchorSnapshot: _testMessageSnapshot,
captureAnchorSnapshot:
captureAnchorSnapshot ?? _testMessageSnapshot,
onPopoverPresented: () => sourceHidden.value = true,
onPopoverDismissed: () => sourceHidden.value = false,
composerFocusNode: composerFocusNode,
@@ -882,6 +884,48 @@ void main() {
await _dismissMessageActionsPopover(tester);
});
testWidgets('keeps the snapshot alive through the reverse transition', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
ui.Image? snapshot;
try {
final prefs = await _mockPrefs();
await _pumpMessageActionsPopover(
tester,
message: _message(),
prefs: prefs,
captureAnchorSnapshot: () async {
snapshot = await _testMessageSnapshot();
return snapshot!;
},
);
final image = snapshot!;
expect(image.debugDisposed, isFalse);
Navigator.of(
tester.element(find.byKey(const ValueKey('message-action-surface'))),
).pop();
await tester.pump();
expect(
find.byKey(const ValueKey('message-action-preview')),
findsOneWidget,
);
expect(image.debugDisposed, isFalse);
await tester.pump(const Duration(milliseconds: 110));
expect(image.debugDisposed, isFalse);
await tester.pumpAndSettle();
expect(image.debugDisposed, isTrue);
expect(tester.takeException(), isNull);
} finally {
final image = snapshot;
if (image != null && !image.debugDisposed) image.dispose();
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('shows parity actions for a regular message', (tester) async {
final prefs = await _mockPrefs();
await _pumpSheet(tester, message: _message(), prefs: prefs);