Able to render offsite media

This commit is contained in:
2026-01-01 02:00:09 +08:00
parent adb231278c
commit ec71125fa9
13 changed files with 352 additions and 349 deletions

View File

@@ -3,11 +3,11 @@ import 'package:flutter/material.dart';
import 'package:island/models/activitypub.dart';
import 'package:material_symbols_icons/symbols.dart';
class ActorAvatarWidget extends StatelessWidget {
class ActorPictureWidget extends StatelessWidget {
final SnActivityPubActor actor;
final double radius;
const ActorAvatarWidget({super.key, required this.actor, this.radius = 16});
const ActorPictureWidget({super.key, required this.actor, this.radius = 16});
@override
Widget build(BuildContext context) {
@@ -29,10 +29,12 @@ class ActorAvatarWidget extends StatelessWidget {
backgroundImage: CachedNetworkImageProvider(avatarUrl),
radius: radius,
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
child: Icon(
Symbols.person,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
child: avatarUrl.isNotEmpty
? null
: Icon(
Symbols.person,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
Positioned(
right: 0,

View File

@@ -4,11 +4,13 @@ import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/main.dart';
import 'package:island/talker.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:top_snackbar_flutter/top_snack_bar.dart';
import 'package:url_launcher/url_launcher.dart';
void showSnackBar(String message, {SnackBarAction? action}) {
final context = globalOverlay.currentState!.context;
@@ -373,3 +375,20 @@ Future<bool> showConfirmAlert(
);
return result ?? false;
}
Future<void> openExternalLink(Uri url, WidgetRef ref) async {
final whitelistDomains = ['solian.app', 'solsynth.dev'];
if (whitelistDomains.any(
(domain) => url.host == domain || url.host.endsWith('.$domain'),
)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
final value = await showConfirmAlert(
'openLinkConfirmDescription'.tr(args: [url.toString()]),
'openLinkConfirm'.tr(),
);
if (value) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
}
}

View File

@@ -62,7 +62,7 @@ class CloudFileLightbox extends HookConsumerWidget {
controller: photoViewController,
heroAttributes: PhotoViewHeroAttributes(tag: heroTag),
imageProvider: CloudImageWidget.provider(
fileId: item.id,
file: item,
serverUrl: serverUrl,
original: showOriginal.value,
),
@@ -118,20 +118,21 @@ class CloudFileLightbox extends HookConsumerWidget {
onPressed: showInfoSheet,
shadows: WhiteShadows.standard,
),
FileActionButton.more(
onPressed: () {
final router = GoRouter.of(context);
Navigator.of(context).pop(context);
Future(() {
router.pushNamed(
'fileDetail',
pathParameters: {'id': item.id},
extra: item,
);
});
},
shadows: WhiteShadows.standard,
),
if (item.url != null)
FileActionButton.more(
onPressed: () {
final router = GoRouter.of(context);
Navigator.of(context).pop(context);
Future(() {
router.pushNamed(
'fileDetail',
pathParameters: {'id': item.id},
extra: item,
);
});
},
shadows: WhiteShadows.standard,
),
],
showExtraOnLeft: true,
),

View File

@@ -41,7 +41,7 @@ class CloudFileWidget extends HookConsumerWidget {
appSettingsProvider.select((s) => s.dataSavingMode),
);
final serverUrl = ref.watch(serverUrlProvider);
final uri = '$serverUrl/drive/files/${item.id}';
final uri = item.url ?? '$serverUrl/drive/files/${item.id}';
final unlocked = useState(false);
@@ -529,7 +529,7 @@ class CloudImageWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final serverUrl = ref.watch(serverUrlProvider);
final uri = '$serverUrl/drive/files/${file?.id ?? fileId}';
final uri = file?.url ?? '$serverUrl/drive/files/${file?.id ?? fileId}';
return AspectRatio(
aspectRatio: aspectRatio,
@@ -540,13 +540,15 @@ class CloudImageWidget extends ConsumerWidget {
}
static ImageProvider provider({
required String fileId,
required SnCloudFile file,
required String serverUrl,
bool original = false,
}) {
final uri = original
? '$serverUrl/drive/files/$fileId?original=true'
: '$serverUrl/drive/files/$fileId';
final uri =
file.url ??
(original
? '$serverUrl/drive/files/${file.id}?original=true'
: '$serverUrl/drive/files/${file.id}');
return CachedNetworkImageProvider(uri);
}
}

View File

@@ -167,7 +167,7 @@ class ImageFileContent extends HookConsumerWidget {
),
controller: photoViewController,
imageProvider: CloudImageWidget.provider(
fileId: item.id,
file: item,
serverUrl: ref.watch(serverUrlProvider),
original: showOriginal.value,
),

View File

@@ -21,7 +21,6 @@ import 'package:markdown/markdown.dart' as markdown;
import 'package:markdown_widget/markdown_widget.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:uuid/uuid.dart';
import 'image.dart';
@@ -139,7 +138,7 @@ class MarkdownTextContent extends HookConsumerWidget {
style:
linkStyle ??
TextStyle(color: Theme.of(context).colorScheme.primary),
onTap: (href) {
onTap: (href) async {
final url = Uri.tryParse(href);
if (url != null) {
if (url.scheme == 'solian') {
@@ -147,22 +146,7 @@ class MarkdownTextContent extends HookConsumerWidget {
context.push(fullPath);
return;
}
final whitelistDomains = ['solian.app', 'solsynth.dev'];
if (whitelistDomains.any(
(domain) =>
url.host == domain || url.host.endsWith('.$domain'),
)) {
launchUrl(url, mode: LaunchMode.externalApplication);
return;
}
showConfirmAlert(
'openLinkConfirmDescription'.tr(args: [url.toString()]),
'openLinkConfirm'.tr(),
).then((value) {
if (value) {
launchUrl(url, mode: LaunchMode.externalApplication);
}
});
await openExternalLink(url, ref);
} else {
showSnackBar(
'brokenLink'.tr(args: [href]),

View File

@@ -146,7 +146,7 @@ class PostReplyPreview extends HookConsumerWidget {
}
// Handle actor case
if (post.actor != null) {
return ActorAvatarWidget(actor: post.actor!, radius: radius);
return ActorPictureWidget(actor: post.actor!, radius: radius);
}
// Fallback
return ProfilePictureWidget(fileId: null, radius: radius);
@@ -452,7 +452,7 @@ class ReferencedPostWidget extends StatelessWidget {
}
// Handle actor case
if (post.actor != null) {
return ActorAvatarWidget(actor: post.actor!, radius: radius);
return ActorPictureWidget(actor: post.actor!, radius: radius);
}
// Fallback
return ProfilePictureWidget(fileId: null, radius: radius);
@@ -657,7 +657,7 @@ class ReferencedPostWidget extends StatelessWidget {
}
}
class PostHeader extends StatelessWidget {
class PostHeader extends HookConsumerWidget {
final SnPost item;
final bool isFullPost;
final Widget? trailing;
@@ -695,7 +695,7 @@ class PostHeader extends StatelessWidget {
}
// Handle actor case
if (post.actor != null) {
return ActorAvatarWidget(actor: post.actor!, radius: radius);
return ActorPictureWidget(actor: post.actor!, radius: radius);
}
// Fallback
return ProfilePictureWidget(fileId: null, radius: radius);
@@ -746,7 +746,7 @@ class PostHeader extends StatelessWidget {
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
return Column(
children: [
Row(
@@ -756,12 +756,12 @@ class PostHeader extends StatelessWidget {
GestureDetector(
onTap: isInteractive && _getPublisherName(item) != null
? () {
context.pushNamed(
'publisherProfile',
pathParameters: {
'name': _getPublisherName(item) as String,
},
);
if (item.publisher != null) {
context.pushNamed(
'publisherProfile',
pathParameters: {'name': item.publisher!.name},
);
}
}
: null,
child: _buildProfilePicture(context, item, radius: 16),