mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
Use grouped message action sheets and fix date layering (#8280)
## Summary Replace message long-press popovers with the shared grouped bottom sheet: an emoji row, Reply / Copy link / Remind me tiles, and remaining actions in regular sections. Match emoji backgrounds to the sections and use the theme primary color for the selected reaction. Keep the scrolling date above the navigation blur in channels and threads. Restore composer focus when the sheet is canceled, keep section gaps consistent, and soften shared dividers to 7% opacity. The reaction details sheet shows all reactors by default. Emoji count chips toggle your own reaction, show your selected state, and update live without dismissing the sheet. Unselected reactions have visible outlines; adding a supported emoji plays the chat-style burst and respects reduced motion. ### Related issue No matching issue found. Related sheet styling: #4911. ### Testing - Full mobile checks and tests passed, including 23 reaction tests and composer-focus regression coverage. - Analysis and file-size checks passed; signed iOS build installed and launched on a physical iPhone. - Full `just ci` stopped during Rust compilation due to low disk space. - On-device visual capture unavailable. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -25,6 +25,15 @@ final class NativeMessagePresentationCoordinator: NSObject, UIAdaptivePresentati
|
||||
result(["supported": true])
|
||||
return
|
||||
}
|
||||
if call.method == "updateReactions" {
|
||||
if data["requestId"] as? String == requestID,
|
||||
let sheet = presented as? NativeReactionDetailsViewController,
|
||||
let reactions = data["reactions"] as? [[String: Any]] {
|
||||
sheet.updateReactions(reactions)
|
||||
}
|
||||
result([:])
|
||||
return
|
||||
}
|
||||
if call.method == "updateProfiles" {
|
||||
if data["requestId"] as? String == requestID,
|
||||
let sheet = presented as? NativeReactionDetailsViewController,
|
||||
@@ -65,6 +74,11 @@ final class NativeMessagePresentationCoordinator: NSObject, UIAdaptivePresentati
|
||||
} else {
|
||||
let sheet = NativeReactionDetailsViewController(data: data)
|
||||
sheet.onClose = { [weak self] in self?.dismiss([:]) }
|
||||
sheet.onToggle = { [weak self] emoji in
|
||||
self?.channel.invokeMethod("toggleReaction", arguments: [
|
||||
"requestId": data["requestId"] as? String ?? "", "emoji": emoji,
|
||||
])
|
||||
}
|
||||
sheet.modalPresentationStyle = .pageSheet
|
||||
if let presentation = sheet.sheetPresentationController {
|
||||
presentation.overrideTraitCollection = UITraitCollection(userInterfaceStyle: sheet.overrideUserInterfaceStyle)
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import UIKit
|
||||
import ImageIO
|
||||
|
||||
/// Native resizable sheet: selected emoji first, with All and per-emoji filters.
|
||||
/// Native reaction toggles above the complete list of reactors.
|
||||
final class NativeReactionDetailsViewController: UIViewController, UITableViewDataSource {
|
||||
var onClose: (() -> Void)?
|
||||
var onToggle: ((String) -> Void)?
|
||||
private let sheetColor: UIColor
|
||||
private let foregroundColor: UIColor
|
||||
private let reactions: [[String: Any]]
|
||||
private let outlineColor: UIColor
|
||||
private var reactions: [[String: Any]]
|
||||
private var profiles: [String: [String: Any]]
|
||||
private var selectedEmoji: String?
|
||||
private let table = UITableView(frame: .zero, style: .plain)
|
||||
private let filters = UIStackView()
|
||||
private var filterHeight: NSLayoutConstraint?
|
||||
private var rows: [(String, [String: Any])] = []
|
||||
private var burstGlyphs: [UILabel] = []
|
||||
|
||||
private func rebuildRows() {
|
||||
rows = reactions.filter { selectedEmoji == nil || $0["emoji"] as? String == selectedEmoji }
|
||||
.flatMap { reaction in
|
||||
rows = reactions.flatMap { reaction in
|
||||
(reaction["users"] as? [String] ?? []).map { ($0, reaction) }
|
||||
}
|
||||
}
|
||||
@@ -31,10 +32,9 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa
|
||||
}
|
||||
sheetColor = color("sheetColor", fallback: .systemGroupedBackground)
|
||||
foregroundColor = color("foregroundColor", fallback: .label)
|
||||
outlineColor = color("outlineColor", fallback: .separator)
|
||||
reactions = data["reactions"] as? [[String: Any]] ?? []
|
||||
profiles = data["profiles"] as? [String: [String: Any]] ?? [:]
|
||||
let initial = data["initialEmoji"] as? String
|
||||
selectedEmoji = reactions.contains { $0["emoji"] as? String == initial } ? initial : nil
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
rebuildRows()
|
||||
overrideUserInterfaceStyle = data["dark"] as? Bool == true ? .dark : .light
|
||||
@@ -132,8 +132,6 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa
|
||||
|
||||
private func rebuildFilters() {
|
||||
for child in filters.arrangedSubviews { child.removeFromSuperview() }
|
||||
let count = reactions.reduce(0) { $0 + ($1["count"] as? Int ?? 0) }
|
||||
addFilter(title: "All \(count)", emoji: nil, reaction: nil)
|
||||
for reaction in reactions {
|
||||
guard let emoji = reaction["emoji"] as? String else { continue }
|
||||
addFilter(title: "\(reaction["count"] as? Int ?? 0)", emoji: emoji, reaction: reaction)
|
||||
@@ -144,20 +142,25 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa
|
||||
var config = UIButton.Configuration.tinted()
|
||||
config.cornerStyle = .capsule
|
||||
config.title = reaction == nil ? title : " \(title)"
|
||||
config.baseBackgroundColor = selectedEmoji == emoji ? .systemBlue : .tertiarySystemFill
|
||||
config.baseForegroundColor = selectedEmoji == emoji ? .systemBlue : .label
|
||||
let button = UIButton(configuration: config, primaryAction: UIAction { [weak self] _ in
|
||||
let selected = reaction?["selected"] as? Bool == true
|
||||
config.baseBackgroundColor = selected ? foregroundColor : .tertiarySystemFill
|
||||
config.baseForegroundColor = selected ? foregroundColor : .label
|
||||
config.background.strokeWidth = 1
|
||||
let stroke = selected ? foregroundColor.withAlphaComponent(0.40) : outlineColor
|
||||
config.background.strokeColor = stroke
|
||||
config.background.strokeColorTransformer = UIConfigurationColorTransformer { _ in stroke }
|
||||
let button = UIButton(configuration: config, primaryAction: UIAction { [weak self] action in
|
||||
guard let self else { return }
|
||||
guard self.selectedEmoji != emoji else { return }
|
||||
self.selectedEmoji = emoji
|
||||
self.rebuildRows()
|
||||
self.rebuildFilters()
|
||||
self.table.reloadData()
|
||||
self.table.setContentOffset(.zero, animated: false)
|
||||
guard let emoji else { return }
|
||||
if !selected, reaction?["burst"] as? Bool == true,
|
||||
let button = action.sender as? UIButton {
|
||||
self.burst(emoji, from: button)
|
||||
}
|
||||
self.onToggle?(emoji)
|
||||
UISelectionFeedbackGenerator().selectionChanged()
|
||||
})
|
||||
button.accessibilityLabel = reaction.map { "\($0["label"] as? String ?? emoji ?? "") \(title)" } ?? title
|
||||
if selectedEmoji == emoji { button.accessibilityTraits.insert(.selected) }
|
||||
if selected { button.accessibilityTraits.insert(.selected) }
|
||||
if let reaction {
|
||||
let glyph = NativeMessageGlyph(data: reaction, size: 22)
|
||||
glyph.isUserInteractionEnabled = false
|
||||
@@ -173,6 +176,82 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa
|
||||
filters.addArrangedSubview(button)
|
||||
}
|
||||
|
||||
/// Mirrors Flutter EmojiBurstController's five-particle, fixed-60Hz physics.
|
||||
/// UIKit owns this sheet, so Flutter's underlying overlay cannot paint above it.
|
||||
private func burst(_ emoji: String, from button: UIView) {
|
||||
guard !UIAccessibility.isReduceMotionEnabled,
|
||||
burstGlyphs.count + 5 <= 160, let window = view.window else { return }
|
||||
let origin = button.convert(CGPoint(x: button.bounds.midX, y: button.bounds.midY), to: window)
|
||||
for _ in 0..<5 {
|
||||
let glyph = UILabel()
|
||||
glyph.text = emoji
|
||||
glyph.font = .systemFont(ofSize: 18 + ceil(CGFloat.random(in: 0..<24)))
|
||||
glyph.sizeToFit()
|
||||
glyph.center = origin
|
||||
glyph.isUserInteractionEnabled = false
|
||||
glyph.isAccessibilityElement = false
|
||||
window.addSubview(glyph)
|
||||
burstGlyphs.append(glyph)
|
||||
var x = origin.x, y = origin.y
|
||||
var xv = CGFloat.random(in: -2.2..<2.2)
|
||||
var yv = -CGFloat.random(in: 2.1..<4.45)
|
||||
var rotation = CGFloat.random(in: -11..<11)
|
||||
let spin = CGFloat.random(in: -2.6..<2.6)
|
||||
let gravity = -CGFloat.random(in: 0.018..<0.036)
|
||||
var scale: CGFloat = 0.25
|
||||
var positions = [NSValue(cgPoint: origin)]
|
||||
var rotations = [rotation * .pi / 180]
|
||||
var scales = [scale]
|
||||
var opacities: [CGFloat] = [1]
|
||||
for frame in 1...108 {
|
||||
rotation += spin
|
||||
yv = (yv + gravity) * 0.998
|
||||
xv *= 0.965
|
||||
x += xv
|
||||
y += yv
|
||||
scale += (1 - scale) * 0.28
|
||||
positions.append(NSValue(cgPoint: CGPoint(x: x, y: y)))
|
||||
rotations.append(rotation * .pi / 180)
|
||||
scales.append(scale)
|
||||
opacities.append(min(1, CGFloat(108 - frame) / (108 * 0.24)))
|
||||
}
|
||||
let values: [(String, [Any])] = [
|
||||
("position", positions), ("transform.rotation.z", rotations),
|
||||
("transform.scale", scales), ("opacity", opacities),
|
||||
]
|
||||
let group = CAAnimationGroup()
|
||||
group.duration = 108.0 / 60.0
|
||||
group.animations = values.map { key, frames in
|
||||
let animation = CAKeyframeAnimation(keyPath: key)
|
||||
animation.values = frames
|
||||
animation.duration = group.duration
|
||||
animation.calculationMode = .linear
|
||||
return animation
|
||||
}
|
||||
glyph.layer.opacity = 0
|
||||
glyph.layer.add(group, forKey: "reaction-burst")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + group.duration) { [weak self, weak glyph] in
|
||||
glyph?.removeFromSuperview()
|
||||
self?.burstGlyphs.removeAll { $0 === glyph }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
burstGlyphs.forEach { $0.removeFromSuperview() }
|
||||
burstGlyphs.removeAll()
|
||||
}
|
||||
|
||||
func updateReactions(_ reactions: [[String: Any]]) {
|
||||
guard !reactions.isEmpty else { onClose?(); return }
|
||||
self.reactions = reactions
|
||||
rebuildRows()
|
||||
guard isViewLoaded else { return }
|
||||
rebuildFilters()
|
||||
table.reloadData()
|
||||
}
|
||||
|
||||
func updateProfiles(_ profiles: [String: [String: Any]]) {
|
||||
self.profiles.merge(profiles) { _, new in new }
|
||||
guard isViewLoaded else { return }
|
||||
|
||||
@@ -268,6 +268,10 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final stickyDateHeaderState = useValueNotifier(
|
||||
StickyDateHeaderState.hidden,
|
||||
[channel.id],
|
||||
);
|
||||
final composerDockHeight = useState(0.0);
|
||||
final composerFocusNode = useFocusNode();
|
||||
final restoreComposerFocus = useRef<VoidCallback?>(null);
|
||||
@@ -610,6 +614,31 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
return FrostedScaffold(
|
||||
foreground:
|
||||
!resolvedChannel.isForum &&
|
||||
timelineEntries.isNotEmpty &&
|
||||
!showInitialConnectionSkeleton &&
|
||||
!showConnectionSkeleton.value &&
|
||||
!messagesState.isLoading
|
||||
? Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top:
|
||||
frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
) +
|
||||
Grid.twelve,
|
||||
child: StickyDateHeader(
|
||||
key: const ValueKey('channel-sticky-date-header'),
|
||||
state: stickyDateHeaderState,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
resizeToAvoidBottomInset:
|
||||
!usesFixedAndroidImeViewport || resolvedChannel.isForum,
|
||||
appBar: FrostedAppBar(
|
||||
@@ -746,6 +775,7 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
),
|
||||
data: (events) {
|
||||
return _MessageList(
|
||||
stickyDateHeaderState: stickyDateHeaderState,
|
||||
entries: timelineEntries,
|
||||
allMessages: timelineMessages,
|
||||
initialMessageId: initialMessageId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
part of '../channel_detail_page.dart';
|
||||
|
||||
class _MessageList extends HookConsumerWidget {
|
||||
final ValueNotifier<StickyDateHeaderState> stickyDateHeaderState;
|
||||
final List<MainTimelineEntry> entries;
|
||||
final List<TimelineMessage> allMessages;
|
||||
final String? initialMessageId;
|
||||
@@ -21,6 +22,7 @@ class _MessageList extends HookConsumerWidget {
|
||||
|
||||
const _MessageList({
|
||||
required this.entries,
|
||||
required this.stickyDateHeaderState,
|
||||
required this.allMessages,
|
||||
required this.initialMessageId,
|
||||
required this.initialThreadRootId,
|
||||
@@ -48,9 +50,6 @@ class _MessageList extends HookConsumerWidget {
|
||||
final displayEntries = groupMembershipTimelineEntries(entries);
|
||||
final itemScrollController = useMemoized(ItemScrollController.new);
|
||||
final itemPositionsListener = useMemoized(ItemPositionsListener.create);
|
||||
final stickyDateHeaderState = useValueNotifier(
|
||||
StickyDateHeaderState.hidden,
|
||||
);
|
||||
final stickyDayTimestamp = useValueNotifier<int?>(null);
|
||||
final timelineViewportHeight = useRef(MediaQuery.sizeOf(context).height);
|
||||
final isLoadingOlder = useState(false);
|
||||
@@ -234,7 +233,9 @@ class _MessageList extends HookConsumerWidget {
|
||||
StickyDateHeaderState state, {
|
||||
int? activeDayTimestamp,
|
||||
}) {
|
||||
stickyDateHeaderState.value = state;
|
||||
stickyDateHeaderState.value = showUnreadNavigation
|
||||
? StickyDateHeaderState.hidden
|
||||
: state;
|
||||
stickyDayTimestamp.value = activeDayTimestamp;
|
||||
}
|
||||
|
||||
@@ -536,11 +537,12 @@ class _MessageList extends HookConsumerWidget {
|
||||
itemPositionsListener,
|
||||
appBarTitleContentHeight,
|
||||
composerBottomInset,
|
||||
showUnreadNavigation,
|
||||
stickyDateHeaderState,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
stickyDateHeaderState.value = StickyDateHeaderState.hidden;
|
||||
stickyDayTimestamp.value = null;
|
||||
return null;
|
||||
}, [channelId]);
|
||||
@@ -880,21 +882,6 @@ class _MessageList extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!showUnreadNavigation)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top:
|
||||
frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
) +
|
||||
Grid.twelve,
|
||||
child: StickyDateHeader(
|
||||
key: const ValueKey('channel-sticky-date-header'),
|
||||
state: stickyDateHeaderState,
|
||||
),
|
||||
),
|
||||
if (showUnreadNavigation)
|
||||
Positioned(
|
||||
left: 0,
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/physics.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/shared/theme/buzz_icons.dart';
|
||||
@@ -23,17 +19,14 @@ import '../../shared/custom_emoji/custom_emoji.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji_provider.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji_render.dart';
|
||||
import '../../shared/emoji/native_emoji_glyph.dart';
|
||||
import '../../shared/emoji/emoji_data_provider.dart';
|
||||
import '../../shared/widgets/sheet_action_section.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../../shared/widgets/native_message_presentation.dart';
|
||||
import '../../shared/emoji/emoji_data_provider.dart';
|
||||
import '../../shared/reminders/remind_me_later_sheet.dart';
|
||||
import '../../shared/reminders/reminder_service.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
import 'channels_provider.dart';
|
||||
import 'emoji_picker.dart';
|
||||
import 'message_action_backdrop_state.dart';
|
||||
import 'message_actions/native_message_action_selection.dart';
|
||||
import 'reaction_row.dart';
|
||||
import 'recent_emoji_provider.dart';
|
||||
import '../../shared/read_state/message_read_state.dart';
|
||||
@@ -44,38 +37,30 @@ import 'thread_follows/thread_follows_provider.dart';
|
||||
import 'timeline_message.dart';
|
||||
import 'unread_badge/is_high_priority_event.dart';
|
||||
|
||||
part 'message_actions/reaction_popover.dart';
|
||||
part 'message_actions/quick_reaction_row.dart';
|
||||
part 'message_actions/message_action_popover.dart';
|
||||
part 'message_actions/message_action_popover_widgets.dart';
|
||||
part 'message_actions/message_reaction_tray.dart';
|
||||
part 'message_actions/native_actions.dart';
|
||||
|
||||
/// Preview length for reminder targets — matches desktop's
|
||||
/// `msg.body.slice(0, 100)`.
|
||||
const _reminderPreviewLength = 100;
|
||||
const _messageActionBackdropBlurSigma = 20.0;
|
||||
const _messageActionBackdropTintOpacity = 0.10;
|
||||
final _messageActionBackdropFilter = ImageFilter.blur(
|
||||
sigmaX: _messageActionBackdropBlurSigma,
|
||||
sigmaY: _messageActionBackdropBlurSigma,
|
||||
);
|
||||
|
||||
/// Presents the actions for [message] as an anchored popover when both
|
||||
/// [anchorRect] and [captureAnchorSnapshot] are supplied, otherwise as a sheet.
|
||||
///
|
||||
/// Popover capture is asynchronous: [captureAnchorSnapshot] must remain valid
|
||||
/// until its future completes, and the returned image becomes this function's
|
||||
/// responsibility to dispose. [onPopoverPreviewVisibilityChanged] reports
|
||||
/// whether the constrained layout actually renders the lifted preview;
|
||||
/// [onPopoverDismissed] runs after the route completes while [context] is still
|
||||
/// mounted. Neither callback runs for sheet fallback or a failed capture.
|
||||
///
|
||||
/// [composerFocusNode] remains caller-owned and must outlive the popover. If it
|
||||
/// has focus when this function is called, the popover unfocuses it and invokes
|
||||
/// [restoreComposerFocus] only after a dismissal with no selected action. The
|
||||
/// restorer must remain callable for the same lifetime and no-op if its composer
|
||||
/// is later disposed or replaced.
|
||||
// A weak, route-owned latch shared by every action, including pointers that
|
||||
// were already down when another selection started dismissing the sheet.
|
||||
final _selectedMessageActionRoutes = Expando<bool>();
|
||||
|
||||
bool _selectMessageAction(BuildContext context) {
|
||||
final route = ModalRoute.of(context);
|
||||
if (route == null ||
|
||||
!route.isCurrent ||
|
||||
_selectedMessageActionRoutes[route] == true) {
|
||||
return false;
|
||||
}
|
||||
_selectedMessageActionRoutes[route] = true;
|
||||
Navigator.of(context).pop(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Presents message reactions and grouped actions in the shared bottom sheet.
|
||||
/// Snapshot and preview callbacks are not used by the sheet presentation.
|
||||
Future<void> showMessageActions({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
@@ -94,66 +79,22 @@ Future<void> showMessageActions({
|
||||
bool isArchived = false,
|
||||
EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs),
|
||||
}) async {
|
||||
if (NativeMessagePresentation.isSupportedPlatform &&
|
||||
anchorRect != null &&
|
||||
await _showNativeMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: anchorRect,
|
||||
captureAnchorSnapshot: captureAnchorSnapshot,
|
||||
composerFocusNode: composerFocusNode,
|
||||
restoreComposerFocus: restoreComposerFocus,
|
||||
onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged,
|
||||
onPopoverDismissed: onPopoverDismissed,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
final hasReactionOnlyActions = message.isSystem && !canManageMessage;
|
||||
if (anchorRect != null && hasReactionOnlyActions) {
|
||||
_showMessageReactionPopover(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
anchorRect: anchorRect,
|
||||
spotlightPadding: popoverSpotlightPadding,
|
||||
);
|
||||
return;
|
||||
}
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
final restoreFocusOnCancel = composerFocusNode?.hasFocus ?? false;
|
||||
composerFocusNode?.unfocus();
|
||||
|
||||
if (_tryShowMessageActionsPopover(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: anchorRect,
|
||||
captureAnchorSnapshot: captureAnchorSnapshot,
|
||||
onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged,
|
||||
onPopoverDismissed: onPopoverDismissed,
|
||||
composerFocusNode: composerFocusNode,
|
||||
restoreComposerFocus: restoreComposerFocus,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
showBuzzModalBottomSheet<void>(
|
||||
final selectedAction = await showBuzzModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
showCloseButton: false,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 640,
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.7,
|
||||
),
|
||||
builder: (sheetContext) => SafeArea(
|
||||
top: false,
|
||||
child: IconTheme.merge(
|
||||
data: const IconThemeData(size: 22),
|
||||
child: ConstrainedBox(
|
||||
@@ -177,10 +118,10 @@ Future<void> showMessageActions({
|
||||
pageContext: context,
|
||||
pageRef: ref,
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
SizedBox(height: message.isSystem ? Grid.xxs : Grid.xs),
|
||||
if (!message.isSystem) ...[
|
||||
// Fast actions: respond now, hand off context, defer.
|
||||
_FastActionsRow(
|
||||
_MessageQuickActionsRow(
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
allMessages: allMessages,
|
||||
@@ -189,7 +130,8 @@ Future<void> showMessageActions({
|
||||
isArchived: isArchived,
|
||||
pageContext: context,
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
// Section cards supply the other half of the 16pt gap.
|
||||
const SizedBox(height: Grid.xxs),
|
||||
// Triage: come back to this message later.
|
||||
SheetActionSection(
|
||||
children: [
|
||||
@@ -208,7 +150,7 @@ Future<void> showMessageActions({
|
||||
leading: const Icon(BuzzIcons.copy),
|
||||
title: const Text('Copy text'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
if (!_selectMessageAction(sheetContext)) return;
|
||||
// Copy to clipboard
|
||||
final data = ClipboardData(text: message.content);
|
||||
Clipboard.setData(data);
|
||||
@@ -224,7 +166,7 @@ Future<void> showMessageActions({
|
||||
leading: const Icon(BuzzIcons.pencil),
|
||||
title: const Text('Edit message'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
if (!_selectMessageAction(sheetContext)) return;
|
||||
_showEditSheet(
|
||||
context: context,
|
||||
ref: ref,
|
||||
@@ -243,7 +185,7 @@ Future<void> showMessageActions({
|
||||
style: TextStyle(color: sheetContext.colors.error),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
if (!_selectMessageAction(sheetContext)) return;
|
||||
_confirmDelete(
|
||||
context: context,
|
||||
ref: ref,
|
||||
@@ -262,6 +204,9 @@ Future<void> showMessageActions({
|
||||
),
|
||||
),
|
||||
);
|
||||
if (selectedAction != true && restoreFocusOnCancel && context.mounted) {
|
||||
restoreComposerFocus?.call();
|
||||
}
|
||||
}
|
||||
|
||||
/// Image-focused actions shown from the full-screen viewer.
|
||||
@@ -567,7 +512,7 @@ class _MarkReadUnreadTile extends ConsumerWidget {
|
||||
leading: Icon(unread ? BuzzIcons.mailCheck : BuzzIcons.mailOpen),
|
||||
title: Text(unread ? 'Mark read' : 'Mark unread'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
if (!_selectMessageAction(context)) return;
|
||||
final notifier = ref.read(readStateProvider.notifier);
|
||||
if (unread) {
|
||||
// Advances the `msg:` marker and clears this message's own forced
|
||||
@@ -608,7 +553,7 @@ class _FollowThreadTile extends ConsumerWidget {
|
||||
leading: Icon(following ? BuzzIcons.bellOff : BuzzIcons.bellRing),
|
||||
title: Text(following ? 'Unfollow thread' : 'Follow thread'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
if (!_selectMessageAction(context)) return;
|
||||
final notifier = ref.read(threadFollowsProvider.notifier);
|
||||
if (following) {
|
||||
notifier.unfollowThread(rootId);
|
||||
@@ -620,7 +565,7 @@ class _FollowThreadTile extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _FastActionsRow extends ConsumerWidget {
|
||||
class _MessageQuickActionsRow extends ConsumerWidget {
|
||||
final TimelineMessage message;
|
||||
final String channelId;
|
||||
final List<TimelineMessage>? allMessages;
|
||||
@@ -632,7 +577,7 @@ class _FastActionsRow extends ConsumerWidget {
|
||||
/// for the thread push and the copy-link snackbar.
|
||||
final BuildContext pageContext;
|
||||
|
||||
const _FastActionsRow({
|
||||
const _MessageQuickActionsRow({
|
||||
required this.message,
|
||||
required this.channelId,
|
||||
required this.allMessages,
|
||||
@@ -649,11 +594,11 @@ class _FastActionsRow extends ConsumerWidget {
|
||||
|
||||
final tiles = <Widget>[
|
||||
if (messages != null)
|
||||
_FastActionTile(
|
||||
_MessageQuickActionTile(
|
||||
icon: BuzzIcons.messageSquareReply,
|
||||
label: 'Reply',
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
if (!_selectMessageAction(context)) return;
|
||||
Navigator.of(pageContext).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
@@ -668,11 +613,11 @@ class _FastActionsRow extends ConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
_FastActionTile(
|
||||
_MessageQuickActionTile(
|
||||
icon: BuzzIcons.link2,
|
||||
label: 'Copy link',
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
if (!_selectMessageAction(context)) return;
|
||||
copyToClipboard(
|
||||
pageContext,
|
||||
messageLinkFor(message: message, channelId: channelId),
|
||||
@@ -681,7 +626,7 @@ class _FastActionsRow extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
if (canRemind)
|
||||
_FastActionTile(
|
||||
_MessageQuickActionTile(
|
||||
icon: BuzzIcons.clock,
|
||||
label: 'Remind me',
|
||||
onTap: () {
|
||||
@@ -689,7 +634,7 @@ class _FastActionsRow extends ConsumerWidget {
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).context;
|
||||
Navigator.of(context).pop();
|
||||
if (!_selectMessageAction(context)) return;
|
||||
showRemindMeLaterSheet(
|
||||
context: rootContext,
|
||||
ref: ref,
|
||||
@@ -706,57 +651,56 @@ class _FastActionsRow extends ConsumerWidget {
|
||||
),
|
||||
];
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
for (var index = 0; index < tiles.length; index++) ...[
|
||||
Expanded(child: tiles[index]),
|
||||
if (index < tiles.length - 1) const SizedBox(width: Grid.twelve),
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var index = 0; index < tiles.length; index++) ...[
|
||||
Expanded(child: tiles[index]),
|
||||
if (index < tiles.length - 1) const SizedBox(width: Grid.twelve),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FastActionTile extends StatelessWidget {
|
||||
class _MessageQuickActionTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _FastActionTile({
|
||||
const _MessageQuickActionTile({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onTap();
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// The full-width tiles deliberately contrast with the compact emoji
|
||||
// reactions immediately above them.
|
||||
Container(
|
||||
height: 68 + (Grid.xxs * 2),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
),
|
||||
Widget build(BuildContext context) => Semantics(
|
||||
button: true,
|
||||
child: Material(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onTap();
|
||||
},
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 68 + (Grid.xxs * 2)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 22, color: context.colors.onSurface),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
@@ -764,51 +708,10 @@ class _FastActionTile extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The emoji shown are the user's own frequently-used set (desktop's
|
||||
/// `useQuickReactionEmojis` behaviour), topped up with [defaultQuickEmojis] so
|
||||
/// the row is full on a fresh install.
|
||||
class _ReactionItemReveal extends StatelessWidget {
|
||||
final Animation<double>? animation;
|
||||
final int index;
|
||||
final Widget child;
|
||||
|
||||
const _ReactionItemReveal({
|
||||
super.key,
|
||||
required this.animation,
|
||||
required this.index,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final parent = animation;
|
||||
if (parent == null) return child;
|
||||
|
||||
final start = 0.06 + (index * 0.11);
|
||||
final opacityEnd = math.min(1.0, start + 0.20);
|
||||
final scaleEnd = math.min(1.0, start + 0.30);
|
||||
final opacity = CurvedAnimation(
|
||||
parent: parent,
|
||||
curve: Interval(start, opacityEnd, curve: Curves.easeOutCubic),
|
||||
);
|
||||
final scale = Tween<double>(begin: 0.86, end: 1).animate(
|
||||
CurvedAnimation(
|
||||
parent: parent,
|
||||
curve: Interval(start, scaleEnd, curve: _reactionSpringCurve),
|
||||
),
|
||||
);
|
||||
|
||||
return FadeTransition(
|
||||
opacity: opacity,
|
||||
child: ScaleTransition(scale: scale, child: child),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders a quick-reaction value: a Unicode glyph as text, a `:shortcode:` as
|
||||
@@ -845,33 +748,48 @@ class _QuickReactionCircle extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
final Widget child;
|
||||
final double size;
|
||||
final String label;
|
||||
final bool? selected;
|
||||
|
||||
const _QuickReactionCircle({
|
||||
required this.onTap,
|
||||
required this.child,
|
||||
required this.size,
|
||||
required this.label,
|
||||
this.selected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onTap();
|
||||
},
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: Color.lerp(
|
||||
context.colors.surface,
|
||||
context.colors.primaryContainer,
|
||||
0.78,
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: label,
|
||||
selected: selected,
|
||||
excludeSemantics: true,
|
||||
onTap: onTap,
|
||||
child: GestureDetector(
|
||||
excludeFromSemantics: true,
|
||||
onTap: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onTap();
|
||||
},
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected == true
|
||||
? context.colors.primary.withValues(alpha: 0.10)
|
||||
: context.colors.surfaceContainerHighest,
|
||||
border: selected == true
|
||||
? Border.all(
|
||||
color: context.colors.primary.withValues(alpha: 0.40),
|
||||
)
|
||||
: null,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
child: child,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,777 +0,0 @@
|
||||
part of '../message_actions.dart';
|
||||
|
||||
const _messageActionRowHeight = 48.0;
|
||||
const _messageActionRowVerticalPadding = Grid.xxs;
|
||||
const _messageActionSeparatorHeight = 0.5;
|
||||
const _messageActionVerticalInset = Grid.half;
|
||||
const _messageActionMenuMaxWidth = 288.0;
|
||||
const _messageActionPreviewMaxWidth = 358.0;
|
||||
const _messageActionPreviewInset = Grid.xxs;
|
||||
const _messageActionGap = Grid.twelve;
|
||||
const _messageActionReactionSelection = '__reaction__';
|
||||
const _messageActionTransitionDuration = _reactionPopoverDuration;
|
||||
const _iosMessageActionTransitionDuration = Duration(milliseconds: 220);
|
||||
const _iosNativeMessageActionSurfaceChannel = MethodChannel(
|
||||
'buzz/native_message_action_surface',
|
||||
);
|
||||
|
||||
bool _messageActionPresentationInFlight = false;
|
||||
bool? _iosNativeMessageActionSurfaceSupported;
|
||||
|
||||
Future<bool> _supportsIosNativeMessageActionSurface() async {
|
||||
if (!Platform.isIOS) return false;
|
||||
final cached = _iosNativeMessageActionSurfaceSupported;
|
||||
if (cached != null) return cached;
|
||||
|
||||
try {
|
||||
final supported =
|
||||
await _iosNativeMessageActionSurfaceChannel.invokeMethod<bool>(
|
||||
'isSupported',
|
||||
) ??
|
||||
false;
|
||||
_iosNativeMessageActionSurfaceSupported = supported;
|
||||
return supported;
|
||||
} on MissingPluginException {
|
||||
_iosNativeMessageActionSurfaceSupported = false;
|
||||
return false;
|
||||
} on PlatformException {
|
||||
_iosNativeMessageActionSurfaceSupported = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool _tryShowMessageActionsPopover({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required TimelineMessage message,
|
||||
required String channelId,
|
||||
required bool canManageMessage,
|
||||
required List<TimelineMessage>? allMessages,
|
||||
required String? currentPubkey,
|
||||
required bool isMember,
|
||||
required bool isArchived,
|
||||
required Rect? anchorRect,
|
||||
required Future<ui.Image> Function()? captureAnchorSnapshot,
|
||||
required ValueChanged<bool>? onPopoverPreviewVisibilityChanged,
|
||||
required VoidCallback? onPopoverDismissed,
|
||||
required FocusNode? composerFocusNode,
|
||||
required VoidCallback? restoreComposerFocus,
|
||||
}) {
|
||||
if (anchorRect == null || captureAnchorSnapshot == null) return false;
|
||||
final shouldRestoreComposerFocus = composerFocusNode?.hasFocus ?? false;
|
||||
unawaited(
|
||||
_showMessageActionsPopover(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: anchorRect,
|
||||
captureAnchorSnapshot: captureAnchorSnapshot,
|
||||
onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged,
|
||||
onPopoverDismissed: onPopoverDismissed,
|
||||
composerFocusNode: composerFocusNode,
|
||||
restoreComposerFocus: restoreComposerFocus,
|
||||
shouldRestoreComposerFocus: shouldRestoreComposerFocus,
|
||||
).then((shown) {
|
||||
if (shown || !context.mounted) return;
|
||||
showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
);
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _showMessageActionsPopover({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required TimelineMessage message,
|
||||
required String channelId,
|
||||
required bool canManageMessage,
|
||||
required List<TimelineMessage>? allMessages,
|
||||
required String? currentPubkey,
|
||||
required bool isMember,
|
||||
required bool isArchived,
|
||||
required Rect anchorRect,
|
||||
required Future<ui.Image> Function() captureAnchorSnapshot,
|
||||
required ValueChanged<bool>? onPopoverPreviewVisibilityChanged,
|
||||
required VoidCallback? onPopoverDismissed,
|
||||
required FocusNode? composerFocusNode,
|
||||
required VoidCallback? restoreComposerFocus,
|
||||
required bool shouldRestoreComposerFocus,
|
||||
}) async {
|
||||
if (_messageActionPresentationInFlight) return true;
|
||||
_messageActionPresentationInFlight = true;
|
||||
|
||||
try {
|
||||
final actions = _buildPopoverMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
);
|
||||
if (actions.isEmpty) return false;
|
||||
final nativeActionSurfaceSupport = _supportsIosNativeMessageActionSurface();
|
||||
final isIos = defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
|
||||
final ui.Image snapshot;
|
||||
try {
|
||||
snapshot = await captureAnchorSnapshot();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
if (!context.mounted) {
|
||||
snapshot.dispose();
|
||||
return false;
|
||||
}
|
||||
final useIosNativeActionSurface = await nativeActionSurfaceSupport;
|
||||
if (!context.mounted) {
|
||||
snapshot.dispose();
|
||||
return false;
|
||||
}
|
||||
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
if (shouldRestoreComposerFocus) composerFocusNode!.unfocus();
|
||||
|
||||
messageActionBackdropActive.value = true;
|
||||
// Give the timeline one frame to replace UIKit glass platform views with
|
||||
// composable Flutter stand-ins before the full-screen blur is presented.
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
if (!context.mounted) {
|
||||
messageActionBackdropActive.value = false;
|
||||
snapshot.dispose();
|
||||
return false;
|
||||
}
|
||||
|
||||
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,
|
||||
onPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged,
|
||||
),
|
||||
);
|
||||
var routePushed = false;
|
||||
try {
|
||||
final popResult = Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).push(dialogRoute);
|
||||
routePushed = true;
|
||||
selectedActionId = await popResult;
|
||||
} finally {
|
||||
if (routePushed) await dialogRoute.completed;
|
||||
messageActionBackdropActive.value = false;
|
||||
snapshot.dispose();
|
||||
if (context.mounted) onPopoverDismissed?.call();
|
||||
}
|
||||
|
||||
for (final action in actions) {
|
||||
if (action.id != selectedActionId) continue;
|
||||
await Future<void>.sync(action.onSelected);
|
||||
break;
|
||||
}
|
||||
if (selectedActionId == null &&
|
||||
shouldRestoreComposerFocus &&
|
||||
context.mounted) {
|
||||
restoreComposerFocus?.call();
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
_messageActionPresentationInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
List<_PopoverMessageAction> _buildPopoverMessageActions({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required TimelineMessage message,
|
||||
required String channelId,
|
||||
required bool canManageMessage,
|
||||
required List<TimelineMessage>? allMessages,
|
||||
required String? currentPubkey,
|
||||
required bool isMember,
|
||||
required bool isArchived,
|
||||
}) {
|
||||
final actions = <_PopoverMessageAction>[];
|
||||
final messages = allMessages;
|
||||
final canRemind = ref.read(reminderServiceProvider) != null;
|
||||
|
||||
if (!message.isSystem) {
|
||||
if (messages != null) {
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'reply',
|
||||
title: 'Reply',
|
||||
icon: BuzzIcons.messageSquareReply,
|
||||
group: _PopoverMessageActionGroup.primary,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: messages,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'copyLink',
|
||||
title: 'Copy link',
|
||||
icon: BuzzIcons.link2,
|
||||
group: _PopoverMessageActionGroup.utility,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
copyToClipboard(
|
||||
context,
|
||||
messageLinkFor(message: message, channelId: channelId),
|
||||
message: 'Message link copied',
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (canRemind) {
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'remind',
|
||||
title: 'Remind me',
|
||||
icon: BuzzIcons.clock,
|
||||
group: _PopoverMessageActionGroup.utility,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
showRemindMeLaterSheet(
|
||||
context: Navigator.of(context, rootNavigator: true).context,
|
||||
ref: ref,
|
||||
target: ReminderTarget(
|
||||
eventId: message.id,
|
||||
channelId: channelId,
|
||||
preview: message.content.characters
|
||||
.take(_reminderPreviewLength)
|
||||
.toString(),
|
||||
authorPubkey: message.pubkey,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final readState = ref.read(readStateProvider);
|
||||
if (readState.isReady) {
|
||||
final unread = messageActionShowsUnread(
|
||||
ref,
|
||||
readState,
|
||||
channelId: channelId,
|
||||
message: message,
|
||||
);
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: unread ? 'markRead' : 'markUnread',
|
||||
title: unread ? 'Mark read' : 'Mark unread',
|
||||
icon: unread ? BuzzIcons.mailCheck : BuzzIcons.mailOpen,
|
||||
group: _PopoverMessageActionGroup.primary,
|
||||
onSelected: () {
|
||||
final notifier = ref.read(readStateProvider.notifier);
|
||||
if (unread) {
|
||||
notifier.markContextRead(
|
||||
msgContextKey(message.id),
|
||||
message.createdAt,
|
||||
);
|
||||
} else {
|
||||
notifier.markContextUnread(
|
||||
msgContextKey(message.id),
|
||||
channelId: channelId,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final rootId = message.rootId ?? message.id;
|
||||
final following = ref.read(threadFollowsProvider).isFollowing(rootId);
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: following ? 'unfollowThread' : 'followThread',
|
||||
title: following ? 'Unfollow thread' : 'Follow thread',
|
||||
icon: following ? BuzzIcons.bellOff : BuzzIcons.bellRing,
|
||||
group: _PopoverMessageActionGroup.utility,
|
||||
onSelected: () {
|
||||
final notifier = ref.read(threadFollowsProvider.notifier);
|
||||
if (following) {
|
||||
notifier.unfollowThread(rootId);
|
||||
} else {
|
||||
notifier.followThread(rootId);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'copyText',
|
||||
title: 'Copy text',
|
||||
icon: BuzzIcons.copy,
|
||||
group: _PopoverMessageActionGroup.utility,
|
||||
onSelected: () =>
|
||||
Clipboard.setData(ClipboardData(text: message.content)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (canManageMessage) {
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'edit',
|
||||
title: 'Edit message',
|
||||
icon: BuzzIcons.pencil,
|
||||
group: _PopoverMessageActionGroup.primary,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
_showEditSheet(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'delete',
|
||||
title: 'Delete message',
|
||||
icon: BuzzIcons.trash2,
|
||||
group: _PopoverMessageActionGroup.destructive,
|
||||
destructive: true,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
_confirmDelete(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: channelId,
|
||||
messageId: message.id,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const actionOrder = {
|
||||
'reply': 0,
|
||||
'markRead': 1,
|
||||
'markUnread': 1,
|
||||
'edit': 2,
|
||||
'copyText': 3,
|
||||
'copyLink': 4,
|
||||
'remind': 5,
|
||||
'followThread': 6,
|
||||
'unfollowThread': 6,
|
||||
'delete': 7,
|
||||
};
|
||||
actions.sort(
|
||||
(left, right) => actionOrder[left.id]!.compareTo(actionOrder[right.id]!),
|
||||
);
|
||||
return actions;
|
||||
}
|
||||
|
||||
enum _PopoverMessageActionGroup { primary, utility, destructive }
|
||||
|
||||
class _PopoverMessageAction {
|
||||
final String id;
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final _PopoverMessageActionGroup group;
|
||||
final bool destructive;
|
||||
final FutureOr<void> Function() onSelected;
|
||||
|
||||
const _PopoverMessageAction({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.group,
|
||||
required this.onSelected,
|
||||
this.destructive = false,
|
||||
});
|
||||
|
||||
String get iosSymbol => switch (id) {
|
||||
'reply' => 'arrowshape.turn.up.left',
|
||||
'markRead' => 'envelope.open',
|
||||
'markUnread' => 'envelope.badge',
|
||||
'edit' => 'pencil',
|
||||
'copyText' => 'doc.on.doc',
|
||||
'copyLink' => 'link',
|
||||
'remind' => 'clock',
|
||||
'followThread' => 'bell',
|
||||
'unfollowThread' => 'bell.slash',
|
||||
'delete' => 'trash',
|
||||
_ => 'ellipsis',
|
||||
};
|
||||
|
||||
Map<String, Object> toPlatformArguments() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'symbol': iosSymbol,
|
||||
'group': group.name,
|
||||
'destructive': destructive,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final viewId = useState<int?>(null);
|
||||
useNativeMessageActionSelection(viewId.value, onSelected);
|
||||
|
||||
return UiKitView(
|
||||
key: const ValueKey('ios-native-message-action-surface'),
|
||||
viewType: 'buzz/native_message_action_surface',
|
||||
creationParams: <String, Object>{
|
||||
'actions': [for (final action in actions) action.toPlatformArguments()],
|
||||
'surfaceColor': context.colors.surface.toARGB32(),
|
||||
'foregroundColor': context.colors.onSurface.toARGB32(),
|
||||
'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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageActionsPopover extends HookWidget {
|
||||
final Rect anchorRect;
|
||||
final ui.Image anchorSnapshot;
|
||||
final Animation<double> animation;
|
||||
final TimelineMessage message;
|
||||
final BuildContext pageContext;
|
||||
final WidgetRef pageRef;
|
||||
final List<_PopoverMessageAction> actions;
|
||||
final bool useIosNativeActionSurface;
|
||||
final ValueChanged<bool>? onPreviewVisibilityChanged;
|
||||
|
||||
const _MessageActionsPopover({
|
||||
required this.anchorRect,
|
||||
required this.anchorSnapshot,
|
||||
required this.animation,
|
||||
required this.message,
|
||||
required this.pageContext,
|
||||
required this.pageRef,
|
||||
required this.actions,
|
||||
required this.useIosNativeActionSurface,
|
||||
required this.onPreviewVisibilityChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final selectionStarted = useRef(false);
|
||||
|
||||
void select(Object? result, [VoidCallback? effect]) {
|
||||
if (selectionStarted.value) return;
|
||||
selectionStarted.value = true;
|
||||
Navigator.of(context).pop(result);
|
||||
effect?.call();
|
||||
}
|
||||
|
||||
void selectAction(String actionId) => select(actionId);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final safeLeft = mediaQuery.padding.left + Grid.xxs;
|
||||
final safeRight =
|
||||
constraints.maxWidth - mediaQuery.padding.right - Grid.xxs;
|
||||
final safeTop = mediaQuery.padding.top + Grid.xxs;
|
||||
final safeBottom =
|
||||
constraints.maxHeight -
|
||||
mediaQuery.padding.bottom -
|
||||
mediaQuery.viewInsets.bottom -
|
||||
Grid.xxs;
|
||||
final availableWidth = math.max(1.0, safeRight - safeLeft);
|
||||
final availableHeight = math.max(1.0, safeBottom - safeTop);
|
||||
final trayWidth = math.min(_reactionTrayMaxWidth, availableWidth);
|
||||
final menuWidth = math.min(_messageActionMenuMaxWidth, availableWidth);
|
||||
final menuLayout = _MessageActionSurfaceLayout.from(context, actions);
|
||||
final preferredMenuHeight = menuLayout.preferredHeight;
|
||||
final minimumMenuHeight = math.min(
|
||||
menuLayout.rowHeight,
|
||||
availableHeight,
|
||||
);
|
||||
final showReactionTray =
|
||||
availableHeight >=
|
||||
_reactionTrayMaxHeight + _messageActionGap + minimumMenuHeight;
|
||||
final trayHeight = showReactionTray ? _reactionTrayMaxHeight : 0.0;
|
||||
final trayGap = showReactionTray ? _messageActionGap : 0.0;
|
||||
final heightAfterTray = availableHeight - trayHeight - trayGap;
|
||||
final showPreview =
|
||||
showReactionTray &&
|
||||
heightAfterTray >= minimumMenuHeight + _messageActionGap + 48.0;
|
||||
final previewMinimumHeight = showPreview ? 48.0 : 0.0;
|
||||
final previewGap = showPreview ? _messageActionGap : 0.0;
|
||||
final menuBudget = math.max(
|
||||
minimumMenuHeight,
|
||||
availableHeight -
|
||||
trayHeight -
|
||||
trayGap -
|
||||
previewMinimumHeight -
|
||||
previewGap,
|
||||
);
|
||||
final menuHeight = math.min(preferredMenuHeight, menuBudget);
|
||||
final previewMaximumHeight = showPreview
|
||||
? math.max(
|
||||
previewMinimumHeight,
|
||||
availableHeight -
|
||||
trayHeight -
|
||||
trayGap -
|
||||
menuHeight -
|
||||
previewGap,
|
||||
)
|
||||
: 0.0;
|
||||
final previewInsetExtent = _messageActionPreviewInset * 2;
|
||||
final previewSize = showPreview
|
||||
? () {
|
||||
final previewWidthRatio =
|
||||
(math.min(_messageActionPreviewMaxWidth, availableWidth) -
|
||||
previewInsetExtent) /
|
||||
math.max(anchorRect.width, 1);
|
||||
final previewHeightRatio =
|
||||
(previewMaximumHeight - previewInsetExtent) /
|
||||
math.max(anchorRect.height, 1);
|
||||
final previewScale = math.min(
|
||||
1.0,
|
||||
math.max(
|
||||
0.0,
|
||||
math.min(previewWidthRatio, previewHeightRatio),
|
||||
),
|
||||
);
|
||||
return Size(
|
||||
(anchorRect.width * previewScale) + previewInsetExtent,
|
||||
(anchorRect.height * previewScale) + previewInsetExtent,
|
||||
);
|
||||
}()
|
||||
: Size.zero;
|
||||
final totalHeight =
|
||||
trayHeight + trayGap + previewSize.height + previewGap + menuHeight;
|
||||
final contentTop = math.max(safeTop, safeBottom - totalHeight);
|
||||
final surfaceLeft =
|
||||
safeLeft + ((availableWidth - math.max(trayWidth, menuWidth)) / 2);
|
||||
final trayRect = Rect.fromLTWH(
|
||||
surfaceLeft,
|
||||
contentTop,
|
||||
trayWidth,
|
||||
trayHeight,
|
||||
);
|
||||
final trayHostWidth = math.min(
|
||||
trayWidth + _reactionTraySpringAllowance,
|
||||
safeRight - surfaceLeft,
|
||||
);
|
||||
final previewRect = Rect.fromLTWH(
|
||||
surfaceLeft,
|
||||
trayRect.bottom + trayGap,
|
||||
previewSize.width,
|
||||
previewSize.height,
|
||||
);
|
||||
final menuRect = Rect.fromLTWH(
|
||||
surfaceLeft,
|
||||
previewRect.bottom + previewGap,
|
||||
menuWidth,
|
||||
menuHeight,
|
||||
);
|
||||
return _MessageActionPreviewVisibility(
|
||||
visible: showPreview,
|
||||
onChanged: onPreviewVisibilityChanged,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: BackdropFilter(
|
||||
key: const ValueKey('message-actions-backdrop-filter'),
|
||||
filter: _messageActionBackdropFilter,
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) {
|
||||
final opacity = Curves.easeOutCubic.transform(
|
||||
animation.value,
|
||||
);
|
||||
return ColoredBox(
|
||||
key: const ValueKey('message-actions-background'),
|
||||
color: context.colors.inverseSurface.withValues(
|
||||
alpha: _messageActionBackdropTintOpacity * opacity,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('message-actions-backdrop'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => select(null),
|
||||
),
|
||||
),
|
||||
if (showPreview)
|
||||
Positioned.fromRect(
|
||||
rect: previewRect,
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
child: RepaintBoundary(
|
||||
child: _LiftedMessagePreview(
|
||||
anchorSnapshot: anchorSnapshot,
|
||||
),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final movement =
|
||||
(defaultTargetPlatform == TargetPlatform.iOS
|
||||
? Curves.easeOutCubic
|
||||
: Curves.easeInOutCubic)
|
||||
.transform(animation.value);
|
||||
final sourceRect = anchorRect.inflate(
|
||||
_messageActionPreviewInset,
|
||||
);
|
||||
final translation = Offset(
|
||||
ui.lerpDouble(
|
||||
sourceRect.left - previewRect.left,
|
||||
0,
|
||||
movement,
|
||||
)!,
|
||||
ui.lerpDouble(
|
||||
sourceRect.top - previewRect.top,
|
||||
0,
|
||||
movement,
|
||||
)!,
|
||||
);
|
||||
final scaleX = ui.lerpDouble(
|
||||
sourceRect.width / previewRect.width,
|
||||
1,
|
||||
movement,
|
||||
)!;
|
||||
final scaleY = ui.lerpDouble(
|
||||
sourceRect.height / previewRect.height,
|
||||
1,
|
||||
movement,
|
||||
)!;
|
||||
return Transform.translate(
|
||||
offset: translation,
|
||||
child: Transform(
|
||||
alignment: Alignment.topLeft,
|
||||
transform: Matrix4.diagonal3Values(scaleX, scaleY, 1),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (showReactionTray)
|
||||
Positioned(
|
||||
left: trayRect.left,
|
||||
top: trayRect.top,
|
||||
width: trayHostWidth,
|
||||
height: trayRect.height,
|
||||
child: _MessageReactionTray(
|
||||
animation: animation,
|
||||
trayWidth: trayWidth,
|
||||
message: message,
|
||||
pageContext: pageContext,
|
||||
pageRef: pageRef,
|
||||
popResult: _messageActionReactionSelection,
|
||||
onSelected: (result, effect) => select(result, effect),
|
||||
),
|
||||
),
|
||||
Positioned.fromRect(
|
||||
rect: menuRect,
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
child: useIosNativeActionSurface
|
||||
? _IosNativeMessageActionSurface(
|
||||
actions: actions,
|
||||
rowHeight: menuLayout.rowHeight,
|
||||
onSelected: selectAction,
|
||||
)
|
||||
: _MessageActionSurface(
|
||||
actions: actions,
|
||||
onSelected: selectAction,
|
||||
),
|
||||
builder: (context, child) {
|
||||
final appearance = const Interval(
|
||||
0.08,
|
||||
0.82,
|
||||
curve: Curves.easeOutCubic,
|
||||
).transform(animation.value);
|
||||
final fadedChild = Opacity(
|
||||
opacity: appearance,
|
||||
child: child,
|
||||
);
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
return fadedChild;
|
||||
}
|
||||
return Transform.scale(
|
||||
alignment: Alignment.topLeft,
|
||||
scale: ui.lerpDouble(0.96, 1, appearance)!,
|
||||
child: fadedChild,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
part of '../message_actions.dart';
|
||||
|
||||
class _MessageActionPreviewVisibility extends HookWidget {
|
||||
final bool visible;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
final Widget child;
|
||||
|
||||
const _MessageActionPreviewVisibility({
|
||||
required this.visible,
|
||||
required this.onChanged,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
useEffect(() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) onChanged?.call(visible);
|
||||
});
|
||||
return null;
|
||||
}, [visible, onChanged]);
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
class _LiftedMessagePreview extends StatelessWidget {
|
||||
final ui.Image anchorSnapshot;
|
||||
|
||||
const _LiftedMessagePreview({required this.anchorSnapshot});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
key: const ValueKey('message-action-preview'),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
border: Border.all(
|
||||
color: context.colors.outlineVariant.withValues(alpha: 0.7),
|
||||
width: 0.5,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.18),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(_messageActionPreviewInset),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(Radii.xs),
|
||||
child: RawImage(
|
||||
image: anchorSnapshot,
|
||||
fit: BoxFit.fill,
|
||||
filterQuality: FilterQuality.medium,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageActionSurface extends StatelessWidget {
|
||||
final List<_PopoverMessageAction> actions;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
const _MessageActionSurface({
|
||||
required this.actions,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final menuLayout = _MessageActionSurfaceLayout.from(context, actions);
|
||||
return Material(
|
||||
key: const ValueKey('message-action-surface'),
|
||||
color: context.colors.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 10,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.22),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.popover),
|
||||
side: BorderSide(
|
||||
color: context.colors.outlineVariant.withValues(alpha: 0.55),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: _messageActionVerticalInset,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var index = 0; index < actions.length; index++) ...[
|
||||
if (index > 0 &&
|
||||
actions[index - 1].group != actions[index].group)
|
||||
Divider(
|
||||
key: ValueKey(
|
||||
'message-action-divider-${actions[index].group.name}',
|
||||
),
|
||||
height: _messageActionSeparatorHeight,
|
||||
thickness: _messageActionSeparatorHeight,
|
||||
indent: Grid.xs,
|
||||
endIndent: Grid.xs,
|
||||
),
|
||||
_MessageActionRow(
|
||||
action: actions[index],
|
||||
height: menuLayout.rowHeight,
|
||||
onSelected: onSelected,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageActionRow extends StatelessWidget {
|
||||
final _PopoverMessageAction action;
|
||||
final double height;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
const _MessageActionRow({
|
||||
required this.action,
|
||||
required this.height,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final foreground = action.destructive
|
||||
? context.colors.error
|
||||
: context.colors.onSurface;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: action.title,
|
||||
excludeSemantics: true,
|
||||
child: InkWell(
|
||||
key: ValueKey('message-action-${action.id}'),
|
||||
onTap: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onSelected(action.id);
|
||||
},
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 32,
|
||||
child: Center(
|
||||
child: Icon(action.icon, size: 22, color: foreground),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.twelve),
|
||||
Expanded(
|
||||
child: Text(
|
||||
action.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageActionSurfaceLayout {
|
||||
final double rowHeight, preferredHeight;
|
||||
|
||||
const _MessageActionSurfaceLayout({
|
||||
required this.rowHeight,
|
||||
required this.preferredHeight,
|
||||
});
|
||||
|
||||
factory _MessageActionSurfaceLayout.from(
|
||||
BuildContext context,
|
||||
List<_PopoverMessageAction> actions,
|
||||
) {
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: 'Message action',
|
||||
style: context.textTheme.bodyLarge,
|
||||
),
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
maxLines: 1,
|
||||
)..layout();
|
||||
final rowHeight = math.max(
|
||||
_messageActionRowHeight,
|
||||
textPainter.height + (_messageActionRowVerticalPadding * 2),
|
||||
);
|
||||
textPainter.dispose();
|
||||
|
||||
var separatorCount = 0;
|
||||
for (var index = 1; index < actions.length; index++) {
|
||||
if (actions[index - 1].group != actions[index].group) separatorCount += 1;
|
||||
}
|
||||
final preferredHeight =
|
||||
(_messageActionVerticalInset * 2) +
|
||||
(actions.length * rowHeight) +
|
||||
(separatorCount * _messageActionSeparatorHeight);
|
||||
return _MessageActionSurfaceLayout(
|
||||
rowHeight: rowHeight,
|
||||
preferredHeight: preferredHeight,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
part of '../message_actions.dart';
|
||||
|
||||
class _MessageReactionTray extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
final double trayWidth;
|
||||
final TimelineMessage message;
|
||||
final BuildContext pageContext;
|
||||
final WidgetRef pageRef;
|
||||
final Object popResult;
|
||||
final void Function(Object? result, VoidCallback effect) onSelected;
|
||||
|
||||
const _MessageReactionTray({
|
||||
required this.animation,
|
||||
required this.trayWidth,
|
||||
required this.message,
|
||||
required this.pageContext,
|
||||
required this.pageRef,
|
||||
required this.popResult,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _AnimatedReactionTray(
|
||||
trayKey: const ValueKey('message-action-reaction-tray'),
|
||||
animation: animation,
|
||||
trayWidth: trayWidth,
|
||||
scaleAlignment: Alignment.bottomLeft,
|
||||
message: message,
|
||||
pageContext: pageContext,
|
||||
pageRef: pageRef,
|
||||
popResult: popResult,
|
||||
onSelected: onSelected,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
part of '../message_actions.dart';
|
||||
|
||||
Future<bool> _showNativeMessageActions({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required TimelineMessage message,
|
||||
required String channelId,
|
||||
required bool canManageMessage,
|
||||
required List<TimelineMessage>? allMessages,
|
||||
required String? currentPubkey,
|
||||
required bool isMember,
|
||||
required bool isArchived,
|
||||
required Rect anchorRect,
|
||||
required Future<ui.Image> Function()? captureAnchorSnapshot,
|
||||
required FocusNode? composerFocusNode,
|
||||
required VoidCallback? restoreComposerFocus,
|
||||
required ValueChanged<bool>? onPopoverPreviewVisibilityChanged,
|
||||
required VoidCallback? onPopoverDismissed,
|
||||
}) async {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) return false;
|
||||
return NativeMessagePresentation.withLease(() async {
|
||||
if (captureAnchorSnapshot == null) return false;
|
||||
final hadComposerFocus = composerFocusNode?.hasFocus ?? false;
|
||||
final support = await NativeMessagePresentation.present(
|
||||
'supportsMessage',
|
||||
{},
|
||||
);
|
||||
if (support?['supported'] != true) return false;
|
||||
if (!context.mounted) return true;
|
||||
final community = ref.read(relayConfigProvider);
|
||||
Uint8List? previewBytes;
|
||||
try {
|
||||
final snapshot = await captureAnchorSnapshot();
|
||||
try {
|
||||
final bytes = await snapshot.toByteData(format: ui.ImageByteFormat.png);
|
||||
previewBytes = bytes?.buffer.asUint8List();
|
||||
} finally {
|
||||
snapshot.dispose();
|
||||
}
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
if (!context.mounted || ref.read(relayConfigProvider) != community) {
|
||||
return true;
|
||||
}
|
||||
if (previewBytes == null) return false;
|
||||
final callbacks = <String, VoidCallback>{};
|
||||
final actions = <Map<String, Object?>>[];
|
||||
void action(
|
||||
String id,
|
||||
String title,
|
||||
String symbol,
|
||||
VoidCallback callback, {
|
||||
bool destructive = false,
|
||||
}) {
|
||||
callbacks[id] = callback;
|
||||
actions.add({
|
||||
'id': id,
|
||||
'title': title,
|
||||
'symbol': symbol,
|
||||
'destructive': destructive,
|
||||
});
|
||||
}
|
||||
|
||||
if (!message.isSystem) {
|
||||
if (allMessages != null) {
|
||||
action('reply', 'Reply', 'arrowshape.turn.up.left', () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
action('copyLink', 'Copy link', 'link', () {
|
||||
copyToClipboard(
|
||||
context,
|
||||
messageLinkFor(message: message, channelId: channelId),
|
||||
message: 'Message link copied',
|
||||
);
|
||||
});
|
||||
if (ref.read(reminderServiceProvider) != null) {
|
||||
action('remind', 'Remind me', 'clock', () {
|
||||
showRemindMeLaterSheet(
|
||||
context: context,
|
||||
ref: ref,
|
||||
target: ReminderTarget(
|
||||
eventId: message.id,
|
||||
channelId: channelId,
|
||||
preview: message.content.characters
|
||||
.take(_reminderPreviewLength)
|
||||
.toString(),
|
||||
authorPubkey: message.pubkey,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
final readState = ref.read(readStateProvider);
|
||||
if (readState.isReady) {
|
||||
final unread = messageActionShowsUnread(
|
||||
ref,
|
||||
readState,
|
||||
channelId: channelId,
|
||||
message: message,
|
||||
);
|
||||
action('read', unread ? 'Mark read' : 'Mark unread', 'envelope', () {
|
||||
final notifier = ref.read(readStateProvider.notifier);
|
||||
if (unread) {
|
||||
notifier.markContextRead(
|
||||
msgContextKey(message.id),
|
||||
message.createdAt,
|
||||
);
|
||||
} else {
|
||||
notifier.markContextUnread(
|
||||
msgContextKey(message.id),
|
||||
channelId: channelId,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
final rootId = message.rootId ?? message.id;
|
||||
final following = ref.read(threadFollowsProvider).isFollowing(rootId);
|
||||
action(
|
||||
'follow',
|
||||
following ? 'Unfollow thread' : 'Follow thread',
|
||||
following ? 'bell.slash' : 'bell',
|
||||
() {
|
||||
final notifier = ref.read(threadFollowsProvider.notifier);
|
||||
if (following) {
|
||||
notifier.unfollowThread(rootId);
|
||||
} else {
|
||||
notifier.followThread(rootId);
|
||||
}
|
||||
},
|
||||
);
|
||||
action('copy', 'Copy text', 'doc.on.doc', () {
|
||||
Clipboard.setData(ClipboardData(text: message.content));
|
||||
});
|
||||
}
|
||||
if (canManageMessage) {
|
||||
action('edit', 'Edit message', 'pencil', () {
|
||||
_showEditSheet(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
);
|
||||
});
|
||||
action('delete', 'Delete message', 'trash', () {
|
||||
_confirmDelete(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: channelId,
|
||||
messageId: message.id,
|
||||
);
|
||||
}, destructive: true);
|
||||
}
|
||||
final palette = ref.read(customEmojiListProvider);
|
||||
final emojis = quickReactionEmoji(
|
||||
ref.read(recentEmojiProvider),
|
||||
customShortcodes: {for (final e in palette) e.shortcode.toLowerCase()},
|
||||
);
|
||||
final dataset = ref.read(emojiDatasetOrEmptyProvider);
|
||||
Map<Object?, Object?>? response;
|
||||
var ownsPreview = false;
|
||||
if (hadComposerFocus) composerFocusNode!.unfocus();
|
||||
try {
|
||||
response = await NativeMessagePresentation.present(
|
||||
'message',
|
||||
{
|
||||
'x': anchorRect.left,
|
||||
'y': anchorRect.top,
|
||||
'width': anchorRect.width,
|
||||
'height': anchorRect.height,
|
||||
'dark': Theme.of(context).brightness == Brightness.dark,
|
||||
'previewLabel': message.content,
|
||||
'previewBytes': previewBytes,
|
||||
'actions': actions,
|
||||
'reactions': [
|
||||
for (final emoji in emojis)
|
||||
{
|
||||
'emoji': emoji,
|
||||
'label': dataset.displayName(emoji),
|
||||
'selected': message.reactions.any(
|
||||
(r) => r.emoji == emoji && r.reactedByCurrentUser,
|
||||
),
|
||||
if (reactionEmojiUrl(emoji, palette) case final String url) ...{
|
||||
'url': url,
|
||||
'headers': mediaGetHeadersFor(ref, url),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
onPresented: () {
|
||||
if (!context.mounted || ownsPreview) return;
|
||||
ownsPreview = true;
|
||||
onPopoverPreviewVisibilityChanged?.call(true);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (ownsPreview) {
|
||||
onPopoverPreviewVisibilityChanged?.call(false);
|
||||
if (context.mounted) onPopoverDismissed?.call();
|
||||
}
|
||||
if (hadComposerFocus &&
|
||||
response?['busy'] != true &&
|
||||
response?['action'] == null &&
|
||||
context.mounted) {
|
||||
restoreComposerFocus?.call();
|
||||
}
|
||||
}
|
||||
if (response == null) return false;
|
||||
// A native controller can outlive the Flutter page that opened it.
|
||||
if (!context.mounted || ref.read(relayConfigProvider) != community) {
|
||||
return true;
|
||||
}
|
||||
final selected = response['action'];
|
||||
if (selected == 'reaction' && emojis.contains(response['emoji'])) {
|
||||
final emoji = response['emoji']! as String;
|
||||
final existing = message.reactions.where((r) => r.emoji == emoji);
|
||||
if (existing.isNotEmpty && existing.first.reactedByCurrentUser) {
|
||||
toggleReaction(ref, message, emoji);
|
||||
} else {
|
||||
ref.read(recentEmojiProvider.notifier).record(emoji);
|
||||
armReactionBurst(ref, message, emoji);
|
||||
ref.read(channelActionsProvider).addReaction(message.id, emoji);
|
||||
}
|
||||
} else if (selected == 'more') {
|
||||
showAddReactionPicker(context: context, ref: ref, message: message);
|
||||
} else {
|
||||
callbacks[selected]?.call();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -14,28 +14,16 @@ class _QuickReactionRow extends ConsumerWidget {
|
||||
/// bottom sheet, so it must not read through the sheet's disposed ref.
|
||||
final WidgetRef pageRef;
|
||||
|
||||
/// Drives the staged glyph reveal when this row is shown in the popover.
|
||||
/// The bottom sheet leaves this null and retains its existing static row.
|
||||
final Animation<double>? presentationAnimation;
|
||||
|
||||
final Object? popResult;
|
||||
|
||||
/// Selects exactly one popover result and side effect. Bottom sheets leave
|
||||
/// this null and retain their existing local dismissal behavior.
|
||||
final void Function(Object? result, VoidCallback effect)? onSelected;
|
||||
|
||||
const _QuickReactionRow({
|
||||
required this.message,
|
||||
required this.sheetContext,
|
||||
required this.pageContext,
|
||||
required this.pageRef,
|
||||
this.presentationAnimation,
|
||||
this.popResult,
|
||||
this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final dataset = ref.watch(emojiDatasetOrEmptyProvider);
|
||||
final customEmoji = ref.watch(customEmojiListProvider);
|
||||
final emoji = quickReactionEmoji(
|
||||
ref.watch(recentEmojiProvider),
|
||||
@@ -48,6 +36,12 @@ class _QuickReactionRow extends ConsumerWidget {
|
||||
};
|
||||
|
||||
void react(String value) {
|
||||
if (message.reactions.any(
|
||||
(r) => r.emoji == value && r.reactedByCurrentUser,
|
||||
)) {
|
||||
toggleReaction(pageRef, message, value);
|
||||
return;
|
||||
}
|
||||
// The generic picker is also used for composing and statuses. Record
|
||||
// recency here, at the reaction call site, so only reactions drive the
|
||||
// quick-reaction row.
|
||||
@@ -74,22 +68,17 @@ class _QuickReactionRow extends ConsumerWidget {
|
||||
.toDouble();
|
||||
final circles = <Widget>[
|
||||
for (var index = 0; index < emoji.length; index++)
|
||||
_ReactionItemReveal(
|
||||
KeyedSubtree(
|
||||
key: ValueKey('quick-reaction-${emoji[index]}'),
|
||||
animation: presentationAnimation,
|
||||
index: index,
|
||||
child: _QuickReactionCircle(
|
||||
size: circleSize,
|
||||
label: dataset.displayName(emoji[index]),
|
||||
selected: message.reactions.any(
|
||||
(r) => r.emoji == emoji[index] && r.reactedByCurrentUser,
|
||||
),
|
||||
onTap: () {
|
||||
void effect() => react(emoji[index]);
|
||||
|
||||
final select = onSelected;
|
||||
if (select != null) {
|
||||
select(popResult, effect);
|
||||
} else {
|
||||
Navigator.of(sheetContext).pop(popResult);
|
||||
effect();
|
||||
}
|
||||
if (!_selectMessageAction(context)) return;
|
||||
react(emoji[index]);
|
||||
},
|
||||
child: _QuickReactionGlyph(
|
||||
value: emoji[index],
|
||||
@@ -97,23 +86,14 @@ class _QuickReactionRow extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
_ReactionItemReveal(
|
||||
KeyedSubtree(
|
||||
key: const ValueKey('quick-reaction-more'),
|
||||
animation: presentationAnimation,
|
||||
index: emoji.length,
|
||||
child: _QuickReactionCircle(
|
||||
size: circleSize,
|
||||
label: 'Add reaction',
|
||||
onTap: () {
|
||||
void effect() =>
|
||||
showEmojiPicker(context: pageContext, onSelect: react);
|
||||
|
||||
final select = onSelected;
|
||||
if (select != null) {
|
||||
select(popResult, effect);
|
||||
} else {
|
||||
Navigator.of(sheetContext).pop(popResult);
|
||||
effect();
|
||||
}
|
||||
if (!_selectMessageAction(context)) return;
|
||||
showEmojiPicker(context: pageContext, onSelect: react);
|
||||
},
|
||||
child: Icon(
|
||||
BuzzIcons.plus,
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
part of '../message_actions.dart';
|
||||
|
||||
const _reactionTrayMaxWidth = (52.0 * 6) + (Grid.twelve * 5) + (Grid.xxs * 2);
|
||||
const _reactionTrayMaxHeight = 52.0 + (Grid.xxs * 2);
|
||||
const _reactionTraySpringAllowance = 16.0;
|
||||
const _reactionPopoverDuration = Duration(milliseconds: 320);
|
||||
final _reactionSpringCurve = _SpringEaseOutCurve(
|
||||
duration: const Duration(milliseconds: 260),
|
||||
bounce: 0.22,
|
||||
);
|
||||
|
||||
/// Presents the frequently-used reaction tray over a long-pressed message.
|
||||
///
|
||||
/// The conversation is frosted around [anchorRect] so the selected message
|
||||
/// remains visually connected to the tray.
|
||||
void _showMessageReactionPopover({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required TimelineMessage message,
|
||||
required Rect anchorRect,
|
||||
required EdgeInsets spotlightPadding,
|
||||
}) {
|
||||
unawaited(
|
||||
_presentMessageReactionPopover(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
anchorRect: anchorRect,
|
||||
spotlightPadding: spotlightPadding,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _presentMessageReactionPopover({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required TimelineMessage message,
|
||||
required Rect anchorRect,
|
||||
required EdgeInsets spotlightPadding,
|
||||
}) async {
|
||||
if (_messageActionPresentationInFlight) return;
|
||||
_messageActionPresentationInFlight = true;
|
||||
|
||||
try {
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final dialogRoute = RawDialogRoute<void>(
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'Dismiss reaction picker',
|
||||
barrierColor: Colors.transparent,
|
||||
transitionDuration: reduceMotion
|
||||
? Duration.zero
|
||||
: _reactionPopoverDuration,
|
||||
transitionBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
child,
|
||||
pageBuilder: (dialogContext, animation, secondaryAnimation) =>
|
||||
_MessageReactionPopover(
|
||||
anchorRect: anchorRect,
|
||||
spotlightPadding: spotlightPadding,
|
||||
animation: animation,
|
||||
message: message,
|
||||
pageContext: context,
|
||||
pageRef: ref,
|
||||
),
|
||||
);
|
||||
var routePushed = false;
|
||||
messageActionBackdropActive.value = true;
|
||||
try {
|
||||
// Remove UIKit glass views from Flutter's platform-view overlay before the
|
||||
// backdrop filter paints, otherwise they leave sharp rectangular holes.
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
if (!context.mounted) return;
|
||||
final popResult = Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).push(dialogRoute);
|
||||
routePushed = true;
|
||||
await popResult;
|
||||
} finally {
|
||||
if (routePushed) await dialogRoute.completed;
|
||||
messageActionBackdropActive.value = false;
|
||||
}
|
||||
} finally {
|
||||
_messageActionPresentationInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageReactionPopover extends HookWidget {
|
||||
final Rect anchorRect;
|
||||
final EdgeInsets spotlightPadding;
|
||||
final Animation<double> animation;
|
||||
final TimelineMessage message;
|
||||
final BuildContext pageContext;
|
||||
final WidgetRef pageRef;
|
||||
|
||||
const _MessageReactionPopover({
|
||||
required this.anchorRect,
|
||||
required this.spotlightPadding,
|
||||
required this.animation,
|
||||
required this.message,
|
||||
required this.pageContext,
|
||||
required this.pageRef,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final selectionStarted = useRef(false);
|
||||
|
||||
void select(Object? result, [VoidCallback? effect]) {
|
||||
if (selectionStarted.value) return;
|
||||
selectionStarted.value = true;
|
||||
Navigator.of(context).pop(result);
|
||||
effect?.call();
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final safeTop = mediaQuery.padding.top + mediaQuery.viewInsets.top;
|
||||
final safeBottom =
|
||||
constraints.maxHeight -
|
||||
mediaQuery.padding.bottom -
|
||||
mediaQuery.viewInsets.bottom;
|
||||
final availableAbove = anchorRect.top - Grid.xxs - safeTop;
|
||||
final availableBelow = safeBottom - anchorRect.bottom - Grid.xxs;
|
||||
final showAbove =
|
||||
availableAbove >= _reactionTrayMaxHeight ||
|
||||
(availableBelow < _reactionTrayMaxHeight &&
|
||||
availableAbove >= availableBelow);
|
||||
final proposedTop = showAbove
|
||||
? anchorRect.top - _reactionTrayMaxHeight - Grid.xxs
|
||||
: anchorRect.bottom + Grid.xxs;
|
||||
final maxTop = math.max(safeTop, safeBottom - _reactionTrayMaxHeight);
|
||||
final top = proposedTop.clamp(safeTop, maxTop).toDouble();
|
||||
final trayScaleAlignment = showAbove
|
||||
? Alignment.bottomLeft
|
||||
: Alignment.topLeft;
|
||||
final trayWidth = math.min(
|
||||
_reactionTrayMaxWidth,
|
||||
constraints.maxWidth - (Grid.xxs * 2),
|
||||
);
|
||||
final maxLeft = constraints.maxWidth - trayWidth - Grid.xxs;
|
||||
final left = (anchorRect.center.dx - (trayWidth / 2))
|
||||
.clamp(Grid.xxs, maxLeft)
|
||||
.toDouble();
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ClipPath(
|
||||
key: const ValueKey('reaction-popover-background'),
|
||||
clipper: _OutsideAnchorClipper(anchorRect, spotlightPadding),
|
||||
child: BackdropFilter(
|
||||
key: const ValueKey('reaction-popover-backdrop-filter'),
|
||||
filter: _messageActionBackdropFilter,
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) {
|
||||
final opacity = const Interval(
|
||||
0,
|
||||
0.30,
|
||||
curve: Curves.easeOutCubic,
|
||||
).transform(animation.value);
|
||||
return ColoredBox(
|
||||
key: const ValueKey('reaction-popover-background-tint'),
|
||||
color: context.colors.inverseSurface.withValues(
|
||||
alpha: _messageActionBackdropTintOpacity * opacity,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => select(null),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: top,
|
||||
left: left,
|
||||
width: trayWidth + _reactionTraySpringAllowance,
|
||||
height: _reactionTrayMaxHeight,
|
||||
child: _AnimatedReactionTray(
|
||||
trayKey: const ValueKey('reaction-popover-tray'),
|
||||
animation: animation,
|
||||
trayWidth: trayWidth,
|
||||
scaleAlignment: trayScaleAlignment,
|
||||
message: message,
|
||||
pageContext: pageContext,
|
||||
pageRef: pageRef,
|
||||
onSelected: (result, effect) => select(result, effect),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedReactionTray extends StatelessWidget {
|
||||
final Key trayKey;
|
||||
final Animation<double> animation;
|
||||
final double trayWidth;
|
||||
final AlignmentGeometry scaleAlignment;
|
||||
final TimelineMessage message;
|
||||
final BuildContext pageContext;
|
||||
final WidgetRef pageRef;
|
||||
final Object? popResult;
|
||||
final void Function(Object? result, VoidCallback effect)? onSelected;
|
||||
|
||||
const _AnimatedReactionTray({
|
||||
required this.trayKey,
|
||||
required this.animation,
|
||||
required this.trayWidth,
|
||||
required this.scaleAlignment,
|
||||
required this.message,
|
||||
required this.pageContext,
|
||||
required this.pageRef,
|
||||
this.popResult,
|
||||
this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
child: SizedBox(
|
||||
width: trayWidth,
|
||||
height: _reactionTrayMaxHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
child: _QuickReactionRow(
|
||||
message: message,
|
||||
sheetContext: context,
|
||||
pageContext: pageContext,
|
||||
pageRef: pageRef,
|
||||
presentationAnimation: animation,
|
||||
popResult: popResult,
|
||||
onSelected: onSelected,
|
||||
),
|
||||
),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final appearance = const Interval(
|
||||
0.04,
|
||||
0.23,
|
||||
curve: Curves.easeOutCubic,
|
||||
).transform(animation.value);
|
||||
final expansion = const Interval(0.16, 0.92).transform(animation.value);
|
||||
final springExpansion = _reactionSpringCurve.transform(expansion);
|
||||
final width = lerpDouble(
|
||||
_reactionTrayMaxHeight,
|
||||
trayWidth,
|
||||
springExpansion,
|
||||
)!;
|
||||
|
||||
return Opacity(
|
||||
opacity: appearance,
|
||||
child: Transform.scale(
|
||||
alignment: scaleAlignment,
|
||||
scale: lerpDouble(0.95, 1, appearance)!,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: _reactionTrayMaxHeight,
|
||||
child: Material(
|
||||
key: trayKey,
|
||||
color: context.colors.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.2),
|
||||
shape: const StadiumBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
minWidth: trayWidth,
|
||||
maxWidth: trayWidth,
|
||||
minHeight: _reactionTrayMaxHeight,
|
||||
maxHeight: _reactionTrayMaxHeight,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OutsideAnchorClipper extends CustomClipper<Path> {
|
||||
final Rect anchorRect;
|
||||
final EdgeInsets spotlightPadding;
|
||||
|
||||
const _OutsideAnchorClipper(this.anchorRect, this.spotlightPadding);
|
||||
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
final spotlightRect = Rect.fromLTRB(
|
||||
anchorRect.left - spotlightPadding.left,
|
||||
anchorRect.top - spotlightPadding.top,
|
||||
anchorRect.right + spotlightPadding.right,
|
||||
anchorRect.bottom + spotlightPadding.bottom,
|
||||
);
|
||||
return Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(Offset.zero & size)
|
||||
..addRRect(
|
||||
RRect.fromRectAndRadius(spotlightRect, const Radius.circular(Radii.lg)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(_OutsideAnchorClipper oldClipper) =>
|
||||
oldClipper.anchorRect != anchorRect ||
|
||||
oldClipper.spotlightPadding != spotlightPadding;
|
||||
}
|
||||
|
||||
class _SpringEaseOutCurve extends Curve {
|
||||
final double _durationSeconds;
|
||||
final SpringSimulation _simulation;
|
||||
|
||||
_SpringEaseOutCurve({required Duration duration, required double bounce})
|
||||
: _durationSeconds =
|
||||
duration.inMicroseconds / Duration.microsecondsPerSecond,
|
||||
_simulation = SpringSimulation(
|
||||
SpringDescription.withDurationAndBounce(
|
||||
duration: duration,
|
||||
bounce: bounce,
|
||||
),
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
snapToEnd: true,
|
||||
);
|
||||
|
||||
@override
|
||||
double transformInternal(double t) => _simulation.x(t * _durationSeconds);
|
||||
}
|
||||
@@ -49,8 +49,9 @@ const _pillGlyphSize = 16.0;
|
||||
/// Used by channel detail, thread detail, and system message rows to avoid
|
||||
/// duplicating the toggle wiring.
|
||||
void toggleReaction(WidgetRef ref, TimelineMessage message, String emoji) {
|
||||
final reaction = message.reactions.where((r) => r.emoji == emoji).firstOrNull;
|
||||
if (reaction == null) return;
|
||||
final actions = ref.read(channelActionsProvider);
|
||||
final reaction = message.reactions.firstWhere((r) => r.emoji == emoji);
|
||||
if (reaction.reactedByCurrentUser && reaction.currentUserReactionId != null) {
|
||||
actions.removeReaction(reaction.currentUserReactionId!, emoji);
|
||||
} else {
|
||||
@@ -95,7 +96,7 @@ void showAddReactionPicker({
|
||||
);
|
||||
}
|
||||
|
||||
class ReactionRow extends StatelessWidget {
|
||||
class ReactionRow extends HookWidget {
|
||||
/// The message these reactions belong to. Used to match a pending burst to
|
||||
/// the right pill — the same emoji can be pending on a different message.
|
||||
final String messageId;
|
||||
@@ -126,6 +127,23 @@ class ReactionRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final liveReactions = useMemoized(() => ValueNotifier(reactions));
|
||||
useEffect(
|
||||
() => () {
|
||||
scheduleMicrotask(() {
|
||||
liveReactions.value = [];
|
||||
liveReactions.dispose();
|
||||
});
|
||||
},
|
||||
[liveReactions],
|
||||
);
|
||||
final latestToggle = useRef(onToggle)..value = onToggle;
|
||||
useEffect(() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) liveReactions.value = reactions;
|
||||
});
|
||||
return null;
|
||||
}, [reactions]);
|
||||
// With the add button on, an empty row still renders — that affordance is
|
||||
// the point. Without it, an empty row would be dead space.
|
||||
if (reactions.isEmpty && !showAddButton) return const SizedBox.shrink();
|
||||
@@ -144,8 +162,10 @@ class ReactionRow extends StatelessWidget {
|
||||
onLongPress: () => showReactionDetailSheet(
|
||||
context: context,
|
||||
channelId: channelId,
|
||||
reactions: reactions,
|
||||
initialEmoji: reaction.emoji,
|
||||
reactions: liveReactions,
|
||||
onToggle: (emoji) {
|
||||
if (context.mounted) latestToggle.value(emoji);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (showAddButton && onAddReaction != null)
|
||||
@@ -344,199 +364,66 @@ class _ReactionEmoji extends StatelessWidget {
|
||||
Future<void> showReactionDetailSheet({
|
||||
required BuildContext context,
|
||||
required String channelId,
|
||||
required List<TimelineReaction> reactions,
|
||||
required String initialEmoji,
|
||||
required ValueListenable<List<TimelineReaction>> reactions,
|
||||
required ValueChanged<String> onToggle,
|
||||
}) async {
|
||||
if (reactions.isEmpty) return;
|
||||
if (reactions.value.isEmpty) return;
|
||||
if (await _showNativeReactionDetails(
|
||||
context,
|
||||
channelId,
|
||||
reactions,
|
||||
initialEmoji,
|
||||
onToggle,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
showDragHandle: true,
|
||||
backgroundColor: context.colors.surfaceContainerHighest,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(Radii.dialog)),
|
||||
),
|
||||
builder: (_) => _AndroidReactionDetailSheet(
|
||||
channelId: channelId,
|
||||
reactions: reactions,
|
||||
initialEmoji: initialEmoji,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
showBuzzModalBottomSheet<void>(
|
||||
await showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
title: 'Reactions',
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => _ReactionDetailSheet(
|
||||
channelId: channelId,
|
||||
reactions: reactions,
|
||||
initialEmoji: initialEmoji,
|
||||
builder: (_) => ValueListenableBuilder<List<TimelineReaction>>(
|
||||
valueListenable: reactions,
|
||||
builder: (sheetContext, value, _) {
|
||||
if (value.isEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (sheetContext.mounted &&
|
||||
ModalRoute.of(sheetContext)?.isCurrent == true) {
|
||||
Navigator.of(sheetContext).pop();
|
||||
}
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _ReactionDetailSheet(
|
||||
channelId: channelId,
|
||||
reactions: value,
|
||||
onToggle: onToggle,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _AndroidReactionDetailSheet extends HookConsumerWidget {
|
||||
const _AndroidReactionDetailSheet({
|
||||
required this.channelId,
|
||||
required this.reactions,
|
||||
required this.initialEmoji,
|
||||
});
|
||||
|
||||
final String channelId;
|
||||
final List<TimelineReaction> reactions;
|
||||
final String initialEmoji;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cache = ref.watch(userCacheProvider);
|
||||
final names = ref.watch(channelIdentityNamesProvider(channelId));
|
||||
final dataset = ref.watch(emojiDatasetOrEmptyProvider);
|
||||
final pubkeys = reactions
|
||||
.expand((reaction) => reaction.userPubkeys)
|
||||
.toSet()
|
||||
.toList();
|
||||
useEffect(() {
|
||||
if (pubkeys.isNotEmpty) {
|
||||
ref.read(userCacheProvider.notifier).preload(pubkeys);
|
||||
}
|
||||
return null;
|
||||
}, [pubkeys.join(',')]);
|
||||
final total = reactions.fold<int>(
|
||||
0,
|
||||
(sum, reaction) => sum + reaction.count,
|
||||
);
|
||||
final maxHeight = MediaQuery.sizeOf(context).height * 0.65;
|
||||
final desiredHeight =
|
||||
48.0 + pubkeys.length * 56.0 + MediaQuery.viewPaddingOf(context).bottom;
|
||||
return DefaultTabController(
|
||||
length: reactions.length + 1,
|
||||
initialIndex: reactions.indexWhere((r) => r.emoji == initialEmoji) + 1,
|
||||
child: SizedBox(
|
||||
key: const ValueKey('reaction-details-sheet'),
|
||||
height: desiredHeight.clamp(
|
||||
maxHeight < 340 ? maxHeight : 340,
|
||||
maxHeight,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TabBar(
|
||||
isScrollable: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||||
tabAlignment: TabAlignment.start,
|
||||
dividerHeight: 0,
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
indicatorPadding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
indicator: BoxDecoration(
|
||||
color: context.colors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
),
|
||||
labelColor: context.colors.onSurface,
|
||||
unselectedLabelColor: context.colors.onSurfaceVariant,
|
||||
tabs: [
|
||||
Tab(
|
||||
key: const ValueKey('reaction-filter-all'),
|
||||
text: 'All $total',
|
||||
),
|
||||
for (final reaction in reactions)
|
||||
Tab(
|
||||
key: ValueKey('reaction-filter-${reaction.emoji}'),
|
||||
child: Tooltip(
|
||||
message: dataset.displayName(reaction.emoji),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ReactionEmoji(reaction: reaction, size: 20),
|
||||
const SizedBox(width: Grid.quarter),
|
||||
Text('${reaction.count}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
for (var tab = 0; tab <= reactions.length; tab++)
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final filtered = tab == 0
|
||||
? reactions
|
||||
: [reactions[tab - 1]];
|
||||
final rows = [
|
||||
for (final reaction in filtered)
|
||||
for (final pubkey in reaction.userPubkeys.toSet())
|
||||
(pubkey: pubkey, reaction: reaction),
|
||||
];
|
||||
return ListView.builder(
|
||||
key: PageStorageKey('reaction-page-$tab'),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.viewPaddingOf(context).bottom,
|
||||
),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (_, index) {
|
||||
final row = rows[index];
|
||||
return _ReactorTile(
|
||||
profile: cache[row.pubkey.toLowerCase()],
|
||||
pubkey: row.pubkey,
|
||||
displayName: names.labelFor(row.pubkey),
|
||||
reaction: row.reaction,
|
||||
reactionLabel: dataset.displayName(
|
||||
row.reaction.emoji,
|
||||
),
|
||||
compact: true,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReactionDetailSheet extends HookConsumerWidget {
|
||||
final String channelId;
|
||||
final List<TimelineReaction> reactions;
|
||||
final String initialEmoji;
|
||||
final ValueChanged<String> onToggle;
|
||||
|
||||
const _ReactionDetailSheet({
|
||||
required this.channelId,
|
||||
required this.reactions,
|
||||
required this.initialEmoji,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedEmoji = useState<String?>(initialEmoji);
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
final identityNames = ref.watch(channelIdentityNamesProvider(channelId));
|
||||
final dataset = ref.watch(emojiDatasetOrEmptyProvider);
|
||||
final rows = [
|
||||
for (final reaction in reactions)
|
||||
if (selectedEmoji.value == null ||
|
||||
reaction.emoji == selectedEmoji.value)
|
||||
for (final pubkey in reaction.userPubkeys.toSet())
|
||||
(pubkey: pubkey, reaction: reaction),
|
||||
for (final pubkey in reaction.userPubkeys.toSet())
|
||||
(pubkey: pubkey, reaction: reaction),
|
||||
];
|
||||
final pubkeys = rows.map((row) => row.pubkey).toSet().toList();
|
||||
useEffect(() {
|
||||
@@ -545,11 +432,6 @@ class _ReactionDetailSheet extends HookConsumerWidget {
|
||||
}
|
||||
return null;
|
||||
}, [pubkeys.join(',')]);
|
||||
final total = reactions.fold<int>(
|
||||
0,
|
||||
(sum, reaction) => sum + reaction.count,
|
||||
);
|
||||
|
||||
return ConstrainedBox(
|
||||
key: const ValueKey('reaction-details-sheet'),
|
||||
constraints: BoxConstraints(
|
||||
@@ -564,31 +446,40 @@ class _ReactionDetailSheet extends HookConsumerWidget {
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: Grid.half),
|
||||
child: ChoiceChip(
|
||||
key: const ValueKey('reaction-filter-all'),
|
||||
label: Text('All $total'),
|
||||
selected: selectedEmoji.value == null,
|
||||
onSelected: (_) => selectedEmoji.value = null,
|
||||
),
|
||||
),
|
||||
for (final reaction in reactions)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: Grid.half),
|
||||
child: ChoiceChip(
|
||||
key: ValueKey('reaction-filter-${reaction.emoji}'),
|
||||
tooltip: dataset.displayName(reaction.emoji),
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ReactionEmoji(reaction: reaction, size: 20),
|
||||
const SizedBox(width: Grid.quarter),
|
||||
Text('${reaction.count}'),
|
||||
],
|
||||
child: Builder(
|
||||
builder: (chipContext) => FilterChip(
|
||||
key: ValueKey('reaction-toggle-${reaction.emoji}'),
|
||||
tooltip: dataset.displayName(reaction.emoji),
|
||||
side: BorderSide(
|
||||
color: reaction.reactedByCurrentUser
|
||||
? context.colors.primary.withValues(alpha: 0.40)
|
||||
: context.colors.onSurface.withValues(
|
||||
alpha: 0.25,
|
||||
),
|
||||
),
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ReactionEmoji(reaction: reaction, size: 20),
|
||||
const SizedBox(width: Grid.quarter),
|
||||
Text('${reaction.count}'),
|
||||
],
|
||||
),
|
||||
selected: reaction.reactedByCurrentUser,
|
||||
onSelected: (_) {
|
||||
if (!reaction.reactedByCurrentUser) {
|
||||
burstEmojiFromContext(
|
||||
ref,
|
||||
chipContext,
|
||||
reaction.emoji,
|
||||
);
|
||||
}
|
||||
onToggle(reaction.emoji);
|
||||
},
|
||||
),
|
||||
selected: reaction.emoji == selectedEmoji.value,
|
||||
onSelected: (_) => selectedEmoji.value = reaction.emoji,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -618,6 +509,7 @@ class _ReactionDetailSheet extends HookConsumerWidget {
|
||||
displayName: identityNames.labelFor(row.pubkey),
|
||||
reaction: row.reaction,
|
||||
reactionLabel: dataset.displayName(row.reaction.emoji),
|
||||
compact: defaultTargetPlatform == TargetPlatform.android,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -719,20 +611,22 @@ class _ReactorAvatar extends StatelessWidget {
|
||||
Future<bool> _showNativeReactionDetails(
|
||||
BuildContext context,
|
||||
String channelId,
|
||||
List<TimelineReaction> reactions,
|
||||
String initialEmoji,
|
||||
ValueListenable<List<TimelineReaction>> reactions,
|
||||
ValueChanged<String> onToggle,
|
||||
) async {
|
||||
if (!NativeMessagePresentation.isSupportedPlatform) return false;
|
||||
return NativeMessagePresentation.withLease(() async {
|
||||
final container = ProviderScope.containerOf(context, listen: false);
|
||||
final requestId = DateTime.now().microsecondsSinceEpoch.toString();
|
||||
final pubkeys = reactions.expand((r) => r.userPubkeys).toSet().toList();
|
||||
List<String> pubkeys() =>
|
||||
reactions.value.expand((r) => r.userPubkeys).toSet().toList();
|
||||
final sent = <String, ({String name, String? avatarUrl})>{};
|
||||
Map<String, Object?> profiles({bool onlyChanges = false}) {
|
||||
if (reactions.value.isEmpty) return {};
|
||||
final cache = container.read(userCacheProvider);
|
||||
final names = container.read(channelIdentityNamesProvider(channelId));
|
||||
final result = <String, Object?>{};
|
||||
for (final pubkey in pubkeys) {
|
||||
for (final pubkey in pubkeys()) {
|
||||
final value = (
|
||||
name: names.labelFor(pubkey),
|
||||
avatarUrl: cache[pubkey.toLowerCase()]?.avatarUrl,
|
||||
@@ -804,38 +698,59 @@ Future<bool> _showNativeReactionDetails(
|
||||
channelIdentityNamesProvider(channelId),
|
||||
(_, _) => scheduleProfiles(),
|
||||
);
|
||||
final dataset = container.read(emojiDatasetOrEmptyProvider);
|
||||
List<Map<String, Object?>> reactionData() => [
|
||||
for (final reaction in reactions.value)
|
||||
{
|
||||
'emoji': reaction.emoji,
|
||||
'label': dataset.displayName(reaction.emoji),
|
||||
'count': reaction.count,
|
||||
'selected': reaction.reactedByCurrentUser,
|
||||
'burst': isPositiveEmojiParticle(reaction.emoji),
|
||||
'users': reaction.userPubkeys.toSet().toList(),
|
||||
if (reaction.emojiUrl case final String url) ...{
|
||||
'url': url,
|
||||
'headers': container
|
||||
.read(mediaGetAuthServiceProvider)
|
||||
.headersFor(url),
|
||||
},
|
||||
},
|
||||
];
|
||||
void updateReactions() {
|
||||
if (disposed) return;
|
||||
unawaited(
|
||||
NativeMessagePresentation.present('updateReactions', {
|
||||
'requestId': requestId,
|
||||
'reactions': reactionData(),
|
||||
}),
|
||||
);
|
||||
if (reactions.value.isNotEmpty) {
|
||||
container.read(userCacheProvider.notifier).preload(pubkeys());
|
||||
scheduleProfiles();
|
||||
}
|
||||
}
|
||||
|
||||
reactions.addListener(updateReactions);
|
||||
try {
|
||||
final dataset = container.read(emojiDatasetOrEmptyProvider);
|
||||
final sheetColors = utilitySurfaceThemeData(
|
||||
Theme.of(context),
|
||||
).colorScheme;
|
||||
final pending = NativeMessagePresentation.present('reactions', {
|
||||
'requestId': requestId,
|
||||
'initialEmoji': initialEmoji,
|
||||
'sheetColor': sheetColors.surface.toARGB32(),
|
||||
'foregroundColor': sheetColors.primary.toARGB32(),
|
||||
'outlineColor': Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.25).toARGB32(),
|
||||
'dark': Theme.of(context).brightness == Brightness.dark,
|
||||
'profiles': profiles(),
|
||||
'reactions': [
|
||||
for (final reaction in reactions)
|
||||
{
|
||||
'emoji': reaction.emoji,
|
||||
'label': dataset.displayName(reaction.emoji),
|
||||
'count': reaction.count,
|
||||
'users': reaction.userPubkeys.toSet().toList(),
|
||||
if (reaction.emojiUrl case final String url) ...{
|
||||
'url': url,
|
||||
'headers': container
|
||||
.read(mediaGetAuthServiceProvider)
|
||||
.headersFor(url),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
container.read(userCacheProvider.notifier).preload(pubkeys);
|
||||
'reactions': reactionData(),
|
||||
}, onReaction: onToggle);
|
||||
container.read(userCacheProvider.notifier).preload(pubkeys());
|
||||
return await pending != null;
|
||||
} finally {
|
||||
disposed = true;
|
||||
reactions.removeListener(updateReactions);
|
||||
timer?.cancel();
|
||||
subscription.close();
|
||||
namesSubscription.close();
|
||||
|
||||
@@ -832,6 +832,21 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
});
|
||||
|
||||
return FrostedScaffold(
|
||||
foreground: threadViewportVisible
|
||||
? Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: frostedAppBarHeight(context) + Grid.twelve,
|
||||
child: StickyDateHeader(
|
||||
key: const ValueKey('thread-sticky-date-header'),
|
||||
state: stickyDateHeaderState,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
resizeToAvoidBottomInset: !usesFixedAndroidImeViewport,
|
||||
appBar: _threadAppBar(context, ref, channel, currentPubkey),
|
||||
body: Stack(
|
||||
@@ -913,16 +928,6 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (threadViewportVisible)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: frostedAppBarHeight(context) + Grid.twelve,
|
||||
child: StickyDateHeader(
|
||||
key: const ValueKey('thread-sticky-date-header'),
|
||||
state: stickyDateHeaderState,
|
||||
),
|
||||
),
|
||||
if (isMember && !isArchived)
|
||||
AndroidImeLift(
|
||||
child: Align(
|
||||
|
||||
@@ -56,7 +56,7 @@ class AppListCard extends StatelessWidget {
|
||||
// which lands them within a few levels of the card fill — invisible.
|
||||
// Tinting with the text color instead keeps the hairline readable on
|
||||
// the card in both brightnesses.
|
||||
color: context.colors.onSurface.withValues(alpha: 0.12),
|
||||
color: context.colors.onSurface.withValues(alpha: 0.07),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ class FrostedScaffold extends HookWidget {
|
||||
/// using [frostedAppBarHeight] — the scaffold does NOT add automatic padding.
|
||||
final Widget body;
|
||||
|
||||
/// Non-scrolling content painted above the navigation bar and its blur.
|
||||
/// Position this child within the scaffold's full-screen stack.
|
||||
final Widget? foreground;
|
||||
|
||||
/// Optional floating action button, passed through to [Scaffold].
|
||||
final Widget? floatingActionButton;
|
||||
|
||||
@@ -45,6 +49,7 @@ class FrostedScaffold extends HookWidget {
|
||||
super.key,
|
||||
required this.appBar,
|
||||
required this.body,
|
||||
this.foreground,
|
||||
this.floatingActionButton,
|
||||
this.resizeToAvoidBottomInset,
|
||||
this.backgroundColor,
|
||||
@@ -248,7 +253,13 @@ class FrostedScaffold extends HookWidget {
|
||||
// only samples pixels that were already painted behind it. This is the
|
||||
// same composition as channel navigation, so top-level headers blur their
|
||||
// content rather than only the fixed gradient.
|
||||
return [...backdrop, bodyMotion, appBar];
|
||||
return [
|
||||
...backdrop,
|
||||
bodyMotion,
|
||||
appBar,
|
||||
if (foreground != null)
|
||||
Positioned.fill(child: DirectionalTransitionMotion(child: foreground!)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,12 +32,20 @@ class NativeMessagePresentation {
|
||||
String method,
|
||||
Map<String, Object?> arguments, {
|
||||
VoidCallback? onPresented,
|
||||
ValueChanged<String>? onReaction,
|
||||
}) async {
|
||||
if (!isSupportedPlatform) return null;
|
||||
final requestId = 'message-${_nextRequest++}';
|
||||
if (onPresented != null) {
|
||||
_presentedCallbacks[requestId] = onPresented;
|
||||
final requestId =
|
||||
arguments['requestId'] as String? ?? 'message-${_nextRequest++}';
|
||||
if (onPresented != null || onReaction != null) {
|
||||
if (onPresented != null) _presentedCallbacks[requestId] = onPresented;
|
||||
channel.setMethodCallHandler((call) async {
|
||||
if (call.method == 'toggleReaction') {
|
||||
final data = call.arguments as Map;
|
||||
if (data['requestId'] == requestId && data['emoji'] is String) {
|
||||
onReaction?.call(data['emoji'] as String);
|
||||
}
|
||||
}
|
||||
if (call.method == 'messagePresented') {
|
||||
_presentedCallbacks[call.arguments]?.call();
|
||||
}
|
||||
@@ -46,7 +54,7 @@ class NativeMessagePresentation {
|
||||
try {
|
||||
return await channel.invokeMapMethod<Object?, Object?>(method, {
|
||||
...arguments,
|
||||
if (onPresented != null) 'requestId': requestId,
|
||||
'requestId': requestId,
|
||||
});
|
||||
} on MissingPluginException {
|
||||
return null;
|
||||
@@ -54,6 +62,9 @@ class NativeMessagePresentation {
|
||||
return null;
|
||||
} finally {
|
||||
_presentedCallbacks.remove(requestId);
|
||||
if (onPresented != null || onReaction != null) {
|
||||
channel.setMethodCallHandler(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ import 'package:buzz/features/channels/emoji_picker.dart';
|
||||
import 'package:buzz/features/channels/ime_metrics_settle_observer.dart';
|
||||
import 'package:buzz/features/channels/local_message_send_animation_provider.dart';
|
||||
import 'package:buzz/features/channels/message_action_backdrop_state.dart';
|
||||
import 'package:buzz/features/channels/message_actions.dart';
|
||||
import 'package:buzz/features/channels/message_content.dart';
|
||||
import 'package:buzz/features/channels/mobile_huddle_controller.dart';
|
||||
import 'package:buzz/features/channels/reaction_row.dart';
|
||||
@@ -3296,9 +3295,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('long press opens the anchored reaction popover', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('long press opens the reaction sheet', (tester) async {
|
||||
final hapticCalls = <MethodCall>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
@@ -3349,376 +3346,13 @@ void main() {
|
||||
),
|
||||
findsNWidgets(6),
|
||||
);
|
||||
expect(find.byType(BottomSheet), findsNothing);
|
||||
expect(find.byType(BottomSheet), findsOneWidget);
|
||||
expect(find.text('Copy text'), findsNothing);
|
||||
expect(findRichText('joined the channel'), findsOneWidget);
|
||||
expect(hapticCalls, hasLength(1));
|
||||
expect(hapticCalls.single.arguments, 'HapticFeedbackType.mediumImpact');
|
||||
|
||||
Navigator.of(
|
||||
tester.element(find.byKey(const ValueKey('reaction-popover-tray'))),
|
||||
).pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(messageActionBackdropActive.value, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('reaction popover leaves existing reactions in the blur', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_systemMsg(
|
||||
id: 'reacted-system-message',
|
||||
payload: {
|
||||
'type': 'member_joined',
|
||||
'actor': 'alice',
|
||||
'target': 'alice',
|
||||
},
|
||||
),
|
||||
_reaction(
|
||||
id: 'existing-reaction',
|
||||
targetId: 'reacted-system-message',
|
||||
),
|
||||
],
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final messageCenter = tester.getCenter(
|
||||
findRichText('joined the channel'),
|
||||
);
|
||||
final reactionRect = tester.getRect(find.byType(ReactionRow));
|
||||
final reactionCenter = reactionRect.center;
|
||||
final reactionPillTop = Offset(
|
||||
reactionRect.center.dx,
|
||||
reactionRect.top + Grid.half + Grid.quarter,
|
||||
);
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey('system-message-row-reacted-system-message')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final backgroundFinder = find.byKey(
|
||||
const ValueKey('reaction-popover-background'),
|
||||
);
|
||||
final background = tester.widget<ClipPath>(backgroundFinder);
|
||||
final backgroundOrigin = tester.getTopLeft(backgroundFinder);
|
||||
final blurPath = background.clipper!.getClip(
|
||||
tester.getSize(backgroundFinder),
|
||||
);
|
||||
|
||||
expect(blurPath.contains(messageCenter - backgroundOrigin), isFalse);
|
||||
expect(blurPath.contains(reactionPillTop - backgroundOrigin), isTrue);
|
||||
expect(blurPath.contains(reactionCenter - backgroundOrigin), isTrue);
|
||||
|
||||
Navigator.of(
|
||||
tester.element(find.byKey(const ValueKey('reaction-popover-tray'))),
|
||||
).pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(messageActionBackdropActive.value, isFalse);
|
||||
});
|
||||
|
||||
for (final thread in [false, true]) {
|
||||
for (final reactionOnly in [false, true]) {
|
||||
testWidgets(
|
||||
'native header restores after backdrop thread=$thread reactionOnly=$reactionOnly',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
messageActionBackdropActive.value = false;
|
||||
});
|
||||
// Exercise the composable fallback when native menus are unavailable.
|
||||
const presentation = MethodChannel(
|
||||
'buzz/native_message_presentation',
|
||||
);
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
presentation,
|
||||
(_) async => {'supported': false},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(presentation, null),
|
||||
);
|
||||
final event = reactionOnly
|
||||
? _systemMsg(
|
||||
id: 'native-backdrop',
|
||||
payload: {
|
||||
'type': 'member_joined',
|
||||
'actor': 'alice',
|
||||
'target': 'alice',
|
||||
},
|
||||
)
|
||||
: _textMsg(
|
||||
id: 'native-backdrop',
|
||||
pubkey: 'alice',
|
||||
content: 'Native backdrop target',
|
||||
);
|
||||
final timeline = formatTimeline([event]);
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [event],
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
home: thread
|
||||
? ThreadDetailPage(
|
||||
threadHead: timeline.single,
|
||||
allMessages: timeline,
|
||||
channelId: _testChannel.id,
|
||||
currentPubkey: null,
|
||||
isMember: true,
|
||||
isArchived: false,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final header = find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is UiKitView &&
|
||||
widget.viewType == 'buzz/ios_navigation_bar',
|
||||
);
|
||||
expect(header, findsOneWidget);
|
||||
final key = thread
|
||||
? 'thread-message-row-native-backdrop'
|
||||
: reactionOnly
|
||||
? 'system-message-row-native-backdrop'
|
||||
: 'message-row-native-backdrop';
|
||||
await tester.longPress(find.byKey(ValueKey(key)));
|
||||
await tester.pumpAndSettle();
|
||||
expect(messageActionBackdropActive.value, isTrue);
|
||||
expect(header, findsNothing);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(FrostedAppBar),
|
||||
matching: find.byType(UiKitView),
|
||||
),
|
||||
findsNothing,
|
||||
reason: 'Every header control must participate in Flutter blur',
|
||||
);
|
||||
final overlay = find.byKey(
|
||||
ValueKey(
|
||||
reactionOnly
|
||||
? 'reaction-popover-tray'
|
||||
: 'message-action-surface',
|
||||
),
|
||||
);
|
||||
expect(overlay, findsOneWidget);
|
||||
Navigator.of(tester.element(overlay)).pop();
|
||||
await tester.pump();
|
||||
expect(
|
||||
header,
|
||||
findsNothing,
|
||||
reason:
|
||||
'Keep native view suppressed through the dismissal transition',
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(messageActionBackdropActive.value, isFalse);
|
||||
expect(header, findsOneWidget);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('reaction-only and full actions share the stronger backdrop', (
|
||||
tester,
|
||||
) async {
|
||||
addTearDown(() => messageActionBackdropActive.value = false);
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_systemMsg(
|
||||
id: 'reaction-only-blur',
|
||||
payload: {
|
||||
'type': 'member_joined',
|
||||
'actor': 'alice',
|
||||
'target': 'alice',
|
||||
},
|
||||
),
|
||||
_textMsg(
|
||||
id: 'full-action-blur',
|
||||
pubkey: 'alice',
|
||||
content: 'Full action target',
|
||||
createdAt: 1100,
|
||||
),
|
||||
],
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey('system-message-row-reaction-only-blur')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(messageActionBackdropActive.value, isTrue);
|
||||
final reactionBackdrop = tester.widget<BackdropFilter>(
|
||||
find.byKey(const ValueKey('reaction-popover-backdrop-filter')),
|
||||
);
|
||||
final reactionTint = tester.widget<ColoredBox>(
|
||||
find.byKey(const ValueKey('reaction-popover-background-tint')),
|
||||
);
|
||||
|
||||
Navigator.of(
|
||||
tester.element(find.byKey(const ValueKey('reaction-popover-tray'))),
|
||||
).pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(messageActionBackdropActive.value, isFalse);
|
||||
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey('message-row-full-action-blur')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(messageActionBackdropActive.value, isTrue);
|
||||
final fullBackdrop = tester.widget<BackdropFilter>(
|
||||
find.byKey(const ValueKey('message-actions-backdrop-filter')),
|
||||
);
|
||||
final fullTint = tester.widget<ColoredBox>(
|
||||
find.byKey(const ValueKey('message-actions-background')),
|
||||
);
|
||||
|
||||
expect(fullBackdrop.filter, same(reactionBackdrop.filter));
|
||||
expect(fullTint.color, reactionTint.color);
|
||||
|
||||
Navigator.of(
|
||||
tester.element(find.byKey(const ValueKey('message-action-surface'))),
|
||||
).pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(messageActionBackdropActive.value, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('reaction-only popovers serialize concurrent requests', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: const [],
|
||||
home: Scaffold(
|
||||
body: Consumer(
|
||||
builder: (context, ref, _) => TextButton(
|
||||
key: const ValueKey('concurrent-reaction-launcher'),
|
||||
onPressed: () {
|
||||
final message = formatTimeline([
|
||||
_systemMsg(
|
||||
id: 'concurrent-reaction',
|
||||
payload: {
|
||||
'type': 'member_joined',
|
||||
'actor': 'alice',
|
||||
'target': 'alice',
|
||||
},
|
||||
),
|
||||
]).single;
|
||||
const anchorRect = Rect.fromLTWH(32, 260, 300, 72);
|
||||
showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: _channelId,
|
||||
canManageMessage: false,
|
||||
anchorRect: anchorRect,
|
||||
);
|
||||
showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: _channelId,
|
||||
canManageMessage: false,
|
||||
anchorRect: anchorRect,
|
||||
);
|
||||
},
|
||||
child: const Text('Open concurrent reactions'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('concurrent-reaction-launcher')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('reaction-popover-tray')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(messageActionBackdropActive.value, isTrue);
|
||||
|
||||
Navigator.of(
|
||||
tester.element(find.byKey(const ValueKey('reaction-popover-tray'))),
|
||||
).pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(messageActionBackdropActive.value, isFalse);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('concurrent-reaction-launcher')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('reaction-popover-tray')),
|
||||
findsOneWidget,
|
||||
reason: 'The presentation latch must release after dismissal.',
|
||||
);
|
||||
|
||||
Navigator.of(
|
||||
tester.element(find.byKey(const ValueKey('reaction-popover-tray'))),
|
||||
).pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(messageActionBackdropActive.value, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('reaction popover grows right from a fixed left edge', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_systemMsg(
|
||||
id: 'left-growth-target',
|
||||
payload: {
|
||||
'type': 'member_joined',
|
||||
'actor': 'alice',
|
||||
'target': 'alice',
|
||||
},
|
||||
),
|
||||
],
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey('system-message-row-left-growth-target')),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
final earlyRect = tester.getRect(
|
||||
find.byKey(const ValueKey('reaction-popover-tray')),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 80));
|
||||
final laterRect = tester.getRect(
|
||||
find.byKey(const ValueKey('reaction-popover-tray')),
|
||||
);
|
||||
|
||||
expect(laterRect.left, moreOrLessEquals(earlyRect.left));
|
||||
expect(laterRect.width, greaterThan(earlyRect.width));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
Navigator.of(
|
||||
tester.element(find.byKey(const ValueKey('reaction-popover-tray'))),
|
||||
).pop();
|
||||
Navigator.of(tester.element(find.byType(BottomSheet))).pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(messageActionBackdropActive.value, isFalse);
|
||||
});
|
||||
@@ -3759,14 +3393,9 @@ void main() {
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('reaction-popover-tray')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(BottomSheet), findsOneWidget);
|
||||
|
||||
Navigator.of(
|
||||
tester.element(find.byKey(const ValueKey('reaction-popover-tray'))),
|
||||
).pop();
|
||||
Navigator.of(tester.element(find.byType(BottomSheet))).pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(messageActionBackdropActive.value, isFalse);
|
||||
});
|
||||
@@ -3809,24 +3438,15 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-reaction-tray')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const ValueKey('quick-reaction-more')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-preview')),
|
||||
findsOneWidget,
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-surface')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(BottomSheet), findsNothing);
|
||||
expect(find.byType(BottomSheet), findsOneWidget);
|
||||
expect(find.text('Copy text'), findsOneWidget);
|
||||
|
||||
Navigator.of(
|
||||
tester.element(find.byKey(const ValueKey('message-action-surface'))),
|
||||
).pop();
|
||||
Navigator.of(tester.element(find.byType(BottomSheet))).pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(messageActionBackdropActive.value, isFalse);
|
||||
});
|
||||
@@ -5377,6 +4997,30 @@ void main() {
|
||||
final stickyHeader = find.byKey(
|
||||
const ValueKey('channel-sticky-date-header'),
|
||||
);
|
||||
final scaffold = tester.widget<FrostedScaffold>(
|
||||
find
|
||||
.ancestor(
|
||||
of: stickyHeader,
|
||||
matching: find.byType(FrostedScaffold),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
expect(scaffold.foreground, isNotNull);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byWidget(scaffold.foreground!),
|
||||
matching: stickyHeader,
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byWidget(scaffold.body),
|
||||
matching: stickyHeader,
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
final stickySurface = find.byKey(
|
||||
const ValueKey('channel-sticky-date-header-surface'),
|
||||
);
|
||||
@@ -12281,6 +11925,27 @@ void main() {
|
||||
final stickyHeader = find.byKey(
|
||||
const ValueKey('thread-sticky-date-header'),
|
||||
);
|
||||
final scaffold = tester.widget<FrostedScaffold>(
|
||||
find
|
||||
.ancestor(of: stickyHeader, matching: find.byType(FrostedScaffold))
|
||||
.first,
|
||||
);
|
||||
expect(scaffold.foreground, isNotNull);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byWidget(scaffold.foreground!),
|
||||
matching: stickyHeader,
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byWidget(scaffold.body),
|
||||
matching: stickyHeader,
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: stickyHeader,
|
||||
|
||||
@@ -175,9 +175,7 @@ void actionRowTests() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('quick-reaction-more')), findsOneWidget);
|
||||
Navigator.of(
|
||||
tester.element(find.byKey(const ValueKey('reaction-popover-tray'))),
|
||||
).pop();
|
||||
Navigator.of(tester.element(find.byType(BottomSheet))).pop();
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import 'package:buzz/shared/emoji/emoji_data_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:buzz/shared/widgets/native_message_presentation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -111,6 +112,130 @@ class _FakeUserCacheNotifier extends UserCacheNotifier {
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('removing the sole reaction closes the sheet', (tester) async {
|
||||
final reactions = ValueNotifier<List<TimelineReaction>>([
|
||||
_reaction(reactedByCurrentUser: true),
|
||||
]);
|
||||
addTearDown(reactions.dispose);
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
userCacheProvider.overrideWith(() => _FakeUserCacheNotifier({})),
|
||||
],
|
||||
child: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showReactionDetailSheet(
|
||||
context: context,
|
||||
channelId: 'channel',
|
||||
reactions: reactions,
|
||||
onToggle: (_) => reactions.value = [],
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('reaction-toggle-$_fire')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('reaction-details-sheet')), findsNothing);
|
||||
expect(find.text('Open'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a stale toggle for an absent emoji does nothing', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
child: Consumer(
|
||||
builder: (context, ref, _) => TextButton(
|
||||
onPressed: () => toggleReaction(
|
||||
ref,
|
||||
TimelineMessage(
|
||||
id: 'message',
|
||||
pubkey: _alice,
|
||||
createdAt: 1,
|
||||
content: '',
|
||||
),
|
||||
_fire,
|
||||
),
|
||||
child: const Text('Toggle'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Toggle'));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'native toggles use the current callback and stream updated counts',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
final completion = Completer<Map<String, Object?>>();
|
||||
Map<Object?, Object?>? payload;
|
||||
Map<Object?, Object?>? updated;
|
||||
final toggles = <String>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
NativeMessagePresentation.channel,
|
||||
(call) async {
|
||||
if (call.method == 'reactions') {
|
||||
payload = call.arguments as Map;
|
||||
return completion.future;
|
||||
}
|
||||
if (call.method == 'updateReactions') updated = call.arguments as Map;
|
||||
return <String, Object?>{};
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
NativeMessagePresentation.channel,
|
||||
null,
|
||||
),
|
||||
);
|
||||
await _pumpRow(
|
||||
tester,
|
||||
reactions: [_reaction()],
|
||||
onToggle: (emoji) => toggles.add('old:$emoji'),
|
||||
);
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey('reaction-pill-$_fire')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await _pumpRow(
|
||||
tester,
|
||||
reactions: [_reaction(count: 2, reactedByCurrentUser: true)],
|
||||
onToggle: (emoji) => toggles.add('new:$emoji'),
|
||||
);
|
||||
expect((updated!['reactions'] as List).single, containsPair('count', 2));
|
||||
expect(
|
||||
(updated!['reactions'] as List).single,
|
||||
containsPair('selected', true),
|
||||
);
|
||||
expect(updated!['requestId'], payload!['requestId']);
|
||||
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
|
||||
NativeMessagePresentation.channel.name,
|
||||
const StandardMethodCodec().encodeMethodCall(
|
||||
MethodCall('toggleReaction', {
|
||||
'requestId': payload!['requestId'],
|
||||
'emoji': _fire,
|
||||
}),
|
||||
),
|
||||
(_) {},
|
||||
);
|
||||
await tester.pump();
|
||||
expect(toggles, ['new:$_fire']);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pumpAndSettle();
|
||||
expect(updated!['reactions'], isEmpty);
|
||||
completion.complete({});
|
||||
await tester.pumpAndSettle();
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'native reactor labels retain channel qualifications and live updates',
|
||||
(tester) async {
|
||||
@@ -304,7 +429,7 @@ void main() {
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'holding a pill opens native membership with the selected emoji without toggling',
|
||||
'holding a pill opens native membership with all reactions without toggling',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
@@ -341,8 +466,14 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(toggles, 0);
|
||||
expect(messageHolds, 0);
|
||||
expect(payload!['initialEmoji'], _eyes);
|
||||
expect(payload!.containsKey('initialEmoji'), isFalse);
|
||||
expect(
|
||||
((payload!['reactions'] as List).first as Map)['selected'],
|
||||
isFalse,
|
||||
);
|
||||
expect((payload!['reactions'] as List).length, 2);
|
||||
expect(((payload!['reactions'] as List).first as Map)['burst'], isTrue);
|
||||
expect(((payload!['reactions'] as List).last as Map)['burst'], isFalse);
|
||||
final profiles = payload!['profiles'] as Map;
|
||||
expect((profiles[_alice] as Map)['name'], 'Alice');
|
||||
expect(find.byType(BottomSheet), findsNothing);
|
||||
@@ -371,54 +502,39 @@ void main() {
|
||||
find.descendant(of: pill, matching: find.text('1')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Reactions'), findsNothing);
|
||||
expect(find.text('All 2'), findsOneWidget);
|
||||
final controller = DefaultTabController.of(
|
||||
tester.element(find.byType(TabBar)),
|
||||
);
|
||||
expect(controller.index, 2);
|
||||
expect(find.text('Reactions'), findsOneWidget);
|
||||
expect(find.text('All 2'), findsNothing);
|
||||
for (final emoji in [_fire, _eyes]) {
|
||||
expect(
|
||||
find.byKey(ValueKey('reactor-$_alice-$emoji')).hitTestable(),
|
||||
findsOneWidget,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
find.byKey(ValueKey('reactor-$_alice-$_eyes')).hitTestable(),
|
||||
findsOneWidget,
|
||||
tester
|
||||
.widget<FilterChip>(
|
||||
find.byKey(const ValueKey('reaction-toggle-$_fire')),
|
||||
)
|
||||
.selected,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
find.byKey(ValueKey('reactor-$_alice-$_fire')).hitTestable(),
|
||||
findsNothing,
|
||||
tester
|
||||
.widget<FilterChip>(
|
||||
find.byKey(const ValueKey('reaction-toggle-$_eyes')),
|
||||
)
|
||||
.selected,
|
||||
isFalse,
|
||||
);
|
||||
await tester.tap(find.byKey(const ValueKey('reaction-filter-all')));
|
||||
await tester.tap(find.byKey(const ValueKey('reaction-toggle-$_fire')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.index, 0);
|
||||
expect(
|
||||
find.byKey(ValueKey('reactor-$_alice-$_eyes')).hitTestable(),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(ValueKey('reactor-$_alice-$_fire')).hitTestable(),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.byKey(const ValueKey('reaction-filter-$_fire')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.index, 1);
|
||||
expect(
|
||||
find.byKey(ValueKey('reactor-$_alice-$_eyes')).hitTestable(),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.byKey(ValueKey('reactor-$_alice-$_fire')).hitTestable(),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.drag(find.byType(TabBarView), const Offset(-500, 0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.index, 2);
|
||||
expect(
|
||||
find.byKey(ValueKey('reactor-$_alice-$_eyes')).hitTestable(),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(ValueKey('reactor-$_alice-$_fire')).hitTestable(),
|
||||
findsNothing,
|
||||
);
|
||||
expect(toggles, 0);
|
||||
expect(toggles, 1);
|
||||
for (final emoji in [_fire, _eyes]) {
|
||||
expect(
|
||||
find.byKey(ValueKey('reactor-$_alice-$emoji')).hitTestable(),
|
||||
findsOneWidget,
|
||||
);
|
||||
}
|
||||
expect(messageHolds, 0);
|
||||
await tester.tapAt(const Offset(20, 20));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -533,12 +649,42 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byTooltip(':fire:'), findsOneWidget);
|
||||
expect(find.byType(TabBar), findsOneWidget);
|
||||
expect(find.text('All 1'), findsOneWidget);
|
||||
expect(find.byType(FilterChip), findsOneWidget);
|
||||
expect(find.text('All 1'), findsNothing);
|
||||
expect(find.text('Alice'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
for (final entry in [
|
||||
(emoji: _fire, selected: false, bursts: true),
|
||||
(emoji: _fire, selected: true, bursts: false),
|
||||
(emoji: _eyes, selected: false, bursts: false),
|
||||
]) {
|
||||
testWidgets('sheet burst for ${entry.emoji}, selected=${entry.selected}', (
|
||||
tester,
|
||||
) async {
|
||||
final container = await _pumpRow(
|
||||
tester,
|
||||
reactions: [
|
||||
_reaction(emoji: entry.emoji, reactedByCurrentUser: entry.selected),
|
||||
],
|
||||
);
|
||||
final controller = container.read(emojiBurstControllerProvider);
|
||||
await tester.longPress(
|
||||
find.byKey(ValueKey('reaction-pill-${entry.emoji}')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final chip = find.byKey(ValueKey('reaction-toggle-${entry.emoji}'));
|
||||
expect(tester.widget<FilterChip>(chip).side!.width, 1);
|
||||
await tester.tap(chip);
|
||||
await tester.pump();
|
||||
expect(controller.hasParticles, entry.bursts);
|
||||
if (entry.bursts) {
|
||||
expect(controller.debugLastBurstOrigin, tester.getCenter(chip));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
group('reaction bursts', () {
|
||||
testWidgets('adding a positive reaction bursts from the pill', (
|
||||
tester,
|
||||
|
||||
@@ -93,7 +93,7 @@ void main() {
|
||||
final scheme = AppTheme.light().colorScheme;
|
||||
// Derived from the text color, not the scheme's border tokens — those sit
|
||||
// within a few levels of the card fill and vanish.
|
||||
expect(divider.color, scheme.onSurface.withValues(alpha: 0.12));
|
||||
expect(divider.color, scheme.onSurface.withValues(alpha: 0.07));
|
||||
|
||||
// The indent is painted inside the divider's box, so the line starts at
|
||||
// the box's left edge plus the indent — level with the row titles.
|
||||
|
||||
@@ -12,6 +12,43 @@ Border? _appBarBorder(WidgetTester tester) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('foreground paints above header and leaves body interactive', (
|
||||
tester,
|
||||
) async {
|
||||
var taps = 0;
|
||||
const marker = Text('Foreground date');
|
||||
const bar = FrostedAppBar(title: Text('Header'));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: FrostedScaffold(
|
||||
appBar: bar,
|
||||
body: Center(
|
||||
child: TextButton(
|
||||
onPressed: () => taps++,
|
||||
child: const Text('Body action'),
|
||||
),
|
||||
),
|
||||
foreground: const Stack(
|
||||
children: [Positioned(top: 80, left: 0, child: marker)],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final stack = tester.widget<Stack>(
|
||||
find.ancestor(of: find.byWidget(bar), matching: find.byType(Stack)).first,
|
||||
);
|
||||
expect(stack.children.indexOf(bar), lessThan(stack.children.length - 1));
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byWidget(stack.children.last),
|
||||
matching: find.byWidget(marker),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.text('Body action'));
|
||||
expect(taps, 1);
|
||||
});
|
||||
|
||||
testWidgets('utility pages expose the inverted surface hierarchy', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
Reference in New Issue
Block a user