9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
273 lines
10 KiB
Dart
273 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../../shared/l10n/l10n.dart';
|
|
import '../../shared/theme/theme.dart';
|
|
import '../../shared/widgets/avatar_image.dart';
|
|
import '../../shared/widgets/buzz_loading_indicator.dart';
|
|
import '../../shared/widgets/frosted_app_bar.dart';
|
|
import '../../shared/widgets/frosted_scaffold.dart';
|
|
import '../channels/message_content.dart';
|
|
import '../profile/profile_provider.dart';
|
|
import '../profile/user_cache_provider.dart';
|
|
import 'note_card.dart';
|
|
import 'pulse_actions.dart';
|
|
import 'pulse_models.dart';
|
|
|
|
/// Full-screen page for composing a new note or replying to one.
|
|
///
|
|
/// When [replyTo] is null this composes a new top-level note; when set the
|
|
/// page shows the note being replied to as context and the action button
|
|
/// reads "Reply". The text field auto-focuses on open so the keyboard is
|
|
/// ready immediately.
|
|
class ComposeNotePage extends HookConsumerWidget {
|
|
final UserNote? replyTo;
|
|
|
|
const ComposeNotePage({super.key, this.replyTo});
|
|
|
|
bool get _isReply => replyTo != null;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final controller = useTextEditingController();
|
|
final focusNode = useFocusNode();
|
|
final isSending = useState(false);
|
|
final hasText = useListenableSelector(
|
|
controller,
|
|
() => controller.text.trim().isNotEmpty,
|
|
);
|
|
final profile = ref.watch(profileProvider).asData?.value;
|
|
|
|
// Auto-focus the field once the page has mounted.
|
|
useEffect(() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
focusNode.requestFocus();
|
|
});
|
|
return null;
|
|
}, const []);
|
|
|
|
Future<void> submit() async {
|
|
if (!hasText || isSending.value) return;
|
|
isSending.value = true;
|
|
try {
|
|
await publishNote(ref, content: controller.text, replyTo: replyTo);
|
|
if (context.mounted) Navigator.of(context).pop(true);
|
|
} catch (_) {
|
|
// Keep the page open so the draft isn't lost; re-enable the button.
|
|
isSending.value = false;
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
return FrostedScaffold(
|
|
resizeToAvoidBottomInset: true,
|
|
appBar: FrostedAppBar(
|
|
leading: TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text(context.l10n.cancel),
|
|
),
|
|
actions: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: Grid.xxs),
|
|
child: FilledButton(
|
|
onPressed: hasText && !isSending.value ? submit : null,
|
|
style: FilledButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
|
minimumSize: const Size(0, 36),
|
|
shape: const StadiumBorder(),
|
|
),
|
|
child: isSending.value
|
|
? SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: BuzzLoadingIndicator(
|
|
size: 16,
|
|
semanticLabel: _isReply
|
|
? context.l10n.pulseSendingReply
|
|
: context.l10n.pulsePublishingPost,
|
|
),
|
|
)
|
|
: Text(
|
|
_isReply ? context.l10n.pulseReply : context.l10n.pulsePost,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: SafeArea(
|
|
top: false,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(height: frostedAppBarHeight(context)),
|
|
if (_isReply) _ReplyContext(note: replyTo!),
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(Grid.xs),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
AvatarImage(
|
|
imageUrl: profile?.avatarUrl,
|
|
radius: 18,
|
|
backgroundColor: context.colors.primaryContainer,
|
|
fallback: Text(
|
|
profile?.initial ?? '?',
|
|
style: context.textTheme.labelMedium?.copyWith(
|
|
color: context.colors.onPrimaryContainer,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: Grid.xxs),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
minLines: 3,
|
|
maxLines: null,
|
|
autofocus: true,
|
|
keyboardType: TextInputType.multiline,
|
|
textInputAction: TextInputAction.newline,
|
|
style: context.textTheme.bodyLarge,
|
|
decoration: InputDecoration(
|
|
hintText: _isReply
|
|
? context.l10n.pulsePostYourReply
|
|
: context.l10n.pulseWhatOnMind,
|
|
border: InputBorder.none,
|
|
enabledBorder: InputBorder.none,
|
|
focusedBorder: InputBorder.none,
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
vertical: Grid.half,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A read-only preview of the note being replied to, laid out like a list
|
|
/// row (avatar + name + time + content) but non-interactive. Capped in
|
|
/// height so a long note doesn't push the editor off-screen.
|
|
class _ReplyContext extends ConsumerWidget {
|
|
final UserNote note;
|
|
|
|
const _ReplyContext({required this.note});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final pubkey = note.pubkey.toLowerCase();
|
|
final profile =
|
|
ref.watch(userCacheProvider.select((cache) => cache[pubkey])) ??
|
|
ref.read(userCacheProvider.notifier).get(pubkey);
|
|
final displayName = profile?.label ?? _shortPubkey(pubkey);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(Grid.gutter, Grid.xs, Grid.gutter, 0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
context.l10n.pulseReplyingTo(displayName),
|
|
style: context.textTheme.labelMedium?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: Grid.xs),
|
|
// Mirror the NoteCard list-row layout, read-only. Shrink-wraps to
|
|
// the content; long notes are capped to ~3 lines via the parent
|
|
// page scroll + ellipsis on the content.
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
AvatarImage(
|
|
imageUrl: profile?.avatarUrl,
|
|
radius: 18,
|
|
backgroundColor: context.colors.primaryContainer,
|
|
fallback: Text(
|
|
(profile?.initial ?? displayName[0]).toUpperCase(),
|
|
style: context.textTheme.labelMedium?.copyWith(
|
|
color: context.colors.onPrimaryContainer,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: Grid.xs),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
displayName,
|
|
maxLines: 1,
|
|
style: messageUsernameTextStyle,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: Grid.half),
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: Grid.xxl),
|
|
child: Text(
|
|
formatPulseRelativeTime(
|
|
note.createdAt,
|
|
l10n: context.l10n,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: messageTimestampTextStyle.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: Grid.half),
|
|
// Rich content (images, links, mentions) like the list
|
|
// row, but clipped to a compact max height so a tall
|
|
// image or long note can't blow up the page.
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(maxHeight: 132),
|
|
child: ClipRect(
|
|
child: Align(
|
|
alignment: Alignment.topLeft,
|
|
heightFactor: 1,
|
|
child: MessageContent(
|
|
content: note.content,
|
|
tags: note.tags,
|
|
baseStyle: messageBodyTextStyle.copyWith(
|
|
color: context.colors.onSurface,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: Grid.xs),
|
|
Divider(
|
|
height: 1,
|
|
color: context.colors.outlineVariant.withValues(alpha: 0.5),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _shortPubkey(String pubkey) =>
|
|
pubkey.length >= 8 ? '${pubkey.substring(0, 8)}...' : pubkey;
|
|
}
|