Files
cls 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
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

304 lines
11 KiB
Dart

part of '../message_content.dart';
const _messageMediaCarouselHeight = 220.0;
@immutable
class _MessageGalleryItem {
final String url;
final String semanticLabel;
final double? aspectRatio;
const _MessageGalleryItem({
required this.url,
required this.semanticLabel,
required this.aspectRatio,
});
}
@immutable
class _TrailingImageGallery {
final String content;
final List<_MessageGalleryItem> items;
const _TrailingImageGallery({required this.content, required this.items});
}
class _MessageGalleryPrecache extends HookWidget {
final List<ImageProvider<Object>> providers;
final int focusedIndex;
const _MessageGalleryPrecache({
required this.providers,
required this.focusedIndex,
});
@override
Widget build(BuildContext context) {
final providerSignature = Object.hashAll(providers);
useEffect(() {
var cancelled = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (cancelled || !context.mounted) {
return;
}
for (var index = focusedIndex - 2; index <= focusedIndex + 2; index++) {
if (index < 0 || index >= providers.length) {
continue;
}
unawaited(
precacheImage(providers[index], context, onError: (_, _) {}),
);
}
});
return () => cancelled = true;
}, [focusedIndex, providerSignature]);
return const SizedBox.shrink();
}
}
_TrailingImageGallery? _extractTrailingImageGallery(
String content,
Map<String, ImetaEntry> imetaByUrl,
String fallbackLabel,
) {
final lines = content.split('\n');
var cursor = lines.length - 1;
while (cursor >= 0 && lines[cursor].trim().isEmpty) {
cursor -= 1;
}
final items = <_MessageGalleryItem>[];
final imagePattern = RegExp(r'^!\[([^\]]*)\]\((https?://[^)\s]+)\)$');
while (cursor >= 0) {
final match = imagePattern.firstMatch(lines[cursor].trim());
if (match == null) break;
final url = match.group(2)!;
final imeta = imetaByUrl[url];
if (classifyMediaUrl(url, imeta: imeta) == MessageMediaKind.video) {
break;
}
final markdownLabel = match.group(1)?.trim();
items.insert(
0,
_MessageGalleryItem(
url: url,
semanticLabel:
imeta?.alt ??
(markdownLabel?.isNotEmpty == true
? markdownLabel!
: fallbackLabel),
aspectRatio: imeta?.aspectRatio,
),
);
cursor -= 1;
}
if (items.length < 2) return null;
return _TrailingImageGallery(
content: lines.take(cursor + 1).join('\n').trimRight(),
items: items,
);
}
class _MessageImageCarousel extends HookConsumerWidget {
final List<_MessageGalleryItem> items;
final double leadingOverflow;
final double trailingOverflow;
final VoidCallback? onReply;
final MediaViewerMoreAction? onMore;
const _MessageImageCarousel({
super.key,
required this.items,
required this.leadingOverflow,
required this.trailingOverflow,
required this.onReply,
required this.onMore,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final itemSignature = items.map((item) => item.url).join('\u0000');
final heroTags = useMemoized(
() => [for (var index = 0; index < items.length; index++) Object()],
[itemSignature],
);
final controller = usePageController(viewportFraction: 0.9);
final currentIndex = useState(0);
final mediaAuth = ref.watch(mediaGetAuthServiceProvider);
final mediaClient = ref.watch(mediaHttpClientProvider);
return Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.imagesCount(items.length),
key: const ValueKey('message-media-carousel-count'),
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: Grid.half + Grid.quarter),
LayoutBuilder(
builder: (context, constraints) {
final contentWidth = constraints.hasBoundedWidth
? constraints.maxWidth
: _messageMediaMaxWidth(context);
final carouselWidth =
contentWidth + leadingOverflow + trailingOverflow;
final leadingExtent = leadingOverflow;
final isLeftToRight =
Directionality.of(context) == TextDirection.ltr;
final itemTrailingPaddings = [
for (var index = 0; index < items.length; index++)
index == items.length - 1 ? Grid.gutter : Grid.half,
];
final previewDecodeWidths = [
for (var index = 0; index < items.length; index++)
math.max(
1.0,
carouselWidth * controller.viewportFraction -
itemTrailingPaddings[index],
),
];
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
final previewProviders = [
for (var index = 0; index < items.length; index++)
ResizeImage.resizeIfNeeded(
(previewDecodeWidths[index] * devicePixelRatio).ceil(),
null,
MediaImageProvider(
url: items[index].url,
auth: mediaAuth,
client: mediaClient,
),
),
];
final viewerItems = [
for (var index = 0; index < items.length; index++)
MediaViewerImage(
url: items[index].url,
heroTag: heroTags[index],
semanticLabel: items[index].semanticLabel,
previewDecodeWidth: previewDecodeWidths[index],
aspectRatio: items[index].aspectRatio,
preloadProvider: previewProviders[index],
),
];
final carousel = SizedBox(
key: const ValueKey('message-media-carousel'),
width: carouselWidth,
height: _messageMediaCarouselHeight,
child: PageView.builder(
controller: controller,
allowImplicitScrolling: true,
// Keep the first image aligned with the message body, but
// allow later pages to paint through the avatar gutter as
// the row scrolls.
clipBehavior: Clip.none,
padEnds: false,
itemCount: items.length,
onPageChanged: (index) => currentIndex.value = index,
itemBuilder: (context, index) {
final item = items[index];
return Padding(
key: ValueKey('message-media-carousel-page:${item.url}'),
padding: EdgeInsetsDirectional.only(
end: itemTrailingPaddings[index],
),
child: Semantics(
button: true,
excludeSemantics: true,
label: context.l10n.openMedia(item.semanticLabel),
child: GestureDetector(
key: ValueKey(
'message-media-carousel-item:${item.url}',
),
onTap: () => openImageViewer(
context,
imageUrl: item.url,
heroTag: heroTags[index],
semanticLabel: item.semanticLabel,
previewDecodeWidth: previewDecodeWidths[index],
aspectRatio: item.aspectRatio,
galleryItems: viewerItems,
initialIndex: index,
onReply: onReply,
onMore: onMore,
),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(
color: context.colors.outlineVariant,
),
),
child: MediaViewerHero(
tag: heroTags[index],
child: ClipRRect(
borderRadius: BorderRadius.circular(Radii.md),
child: MediaImage(
url: item.url,
decodeWidth: previewDecodeWidths[index],
fit: BoxFit.cover,
semanticLabel: item.semanticLabel,
errorBuilder: (_, _, _) =>
_MediaPreviewFallback(
icon: LucideIcons.imageOff,
label: context.l10n.imageUnavailable,
),
),
),
),
),
),
),
);
},
),
);
final carouselSurface = Stack(
clipBehavior: Clip.none,
children: [
carousel,
_MessageGalleryPrecache(
providers: previewProviders,
focusedIndex: currentIndex.value,
),
],
);
if (leadingExtent <= 0 && trailingOverflow <= 0) {
return carouselSurface;
}
return SizedBox(
width: contentWidth,
height: _messageMediaCarouselHeight,
child: OverflowBox(
alignment: AlignmentDirectional.centerStart,
minWidth: carouselWidth,
maxWidth: carouselWidth,
child: Transform.translate(
offset: Offset(
isLeftToRight ? -leadingExtent : leadingExtent,
0,
),
child: carouselSurface,
),
),
);
},
),
],
),
);
}
}