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>
304 lines
11 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|