From 87e126ec4d3732485e0454fc6a9d25b59236aebb Mon Sep 17 00:00:00 2001
From: RarDog
Date: Fri, 4 Sep 2026 12:26:15 +0300
Subject: [PATCH] fix: collapsible cloud mirrors, tag panel sanitization, and
clean announcement HTML (v2.3.1)
---
README.md | 2 +-
lib/app/app_version.dart | 4 +-
lib/app/changelog.dart | 9 ++
lib/backend/backend.dart | 1 +
lib/backend/models/post.dart | 20 +++
.../services/cloud_link_extractor.dart | 40 +++++-
.../presentation/artist_posts_screen.dart | 34 ++++-
.../presentation/post_details_screen.dart | 4 +-
.../widgets/cloud_mirrors_card.dart | 116 ++++++++----------
.../presentation/widgets/post_tags_panel.dart | 41 ++++++-
pubspec.yaml | 2 +-
test/backend/cloud_link_extractor_test.dart | 11 ++
test/features/cloud_mirrors_card_test.dart | 6 +-
13 files changed, 204 insertions(+), 86 deletions(-)
diff --git a/README.md b/README.md
index d1cbee5..037a330 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart
index 5ba95c1..2ff0f52 100644
--- a/lib/app/app_version.dart
+++ b/lib/app/app_version.dart
@@ -1,3 +1,3 @@
-const appDisplayVersion = '2.3.0';
-const appBuildNumber = 40;
+const appDisplayVersion = '2.3.1';
+const appBuildNumber = 41;
const appReleaseChannel = 'Stable';
diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart
index 99f762d..38e642b 100644
--- a/lib/app/changelog.dart
+++ b/lib/app/changelog.dart
@@ -1,4 +1,13 @@
const lunarisChangelog = [
+ LunarisChange(
+ version: '2.3.1',
+ title: 'Cloud Mirrors & Author Announcements Fixes',
+ bullets: [
+ 'Collapsible Cloud Mirrors: the cloud drives section is now folded by default into a sleek header card matching the Tags and Comments cards, expandable on tap.',
+ 'Tag Panel Sanitation: filtered out cloud drive links, JSON metadata, and URLs from appearing in the post tags panel and tag counters.',
+ 'Author Announcements HTML Cleaning: stripped all raw
, , and formatting artifacts from artist announcements, rendering clean readable text and clickable service link chips.',
+ ],
+ ),
LunarisChange(
version: '2.3.0',
title: 'Artist Cloud Drives & External Video Mirrors',
diff --git a/lib/backend/backend.dart b/lib/backend/backend.dart
index af9f3e2..2bbc0cf 100644
--- a/lib/backend/backend.dart
+++ b/lib/backend/backend.dart
@@ -34,5 +34,6 @@ export 'services/settings_service.dart';
export 'services/update_service.dart';
export 'services/viewed_history_service.dart';
export 'services/backup_service.dart';
+export 'services/cloud_link_extractor.dart';
export 'utils/smart_blacklist.dart';
export 'utils/media_quality.dart';
diff --git a/lib/backend/models/post.dart b/lib/backend/models/post.dart
index b175386..55e4810 100644
--- a/lib/backend/models/post.dart
+++ b/lib/backend/models/post.dart
@@ -60,6 +60,26 @@ class Post {
return null;
}
+ List get cleanTags {
+ return tags.where((tag) {
+ final t = tag.trim();
+ if (t.isEmpty) return false;
+ if (t == 'cloud_mirror') return false;
+ if (t.startsWith('{') && t.endsWith('}')) return false;
+ if (t.contains('"url"') || t.contains('"service"')) return false;
+ if (t.startsWith('http://') || t.startsWith('https://')) return false;
+ if (t.contains('mega.nz') ||
+ t.contains('drive.google.com') ||
+ t.contains('dropbox.com') ||
+ t.contains('pixeldrain.com') ||
+ t.contains('catbox.moe') ||
+ t.contains('mediafire.com')) {
+ return false;
+ }
+ return true;
+ }).toList(growable: false);
+ }
+
Post copyWith({
String? id,
String? providerId,
diff --git a/lib/backend/services/cloud_link_extractor.dart b/lib/backend/services/cloud_link_extractor.dart
index d61d990..544cc98 100644
--- a/lib/backend/services/cloud_link_extractor.dart
+++ b/lib/backend/services/cloud_link_extractor.dart
@@ -292,19 +292,49 @@ class CloudLinkExtractor {
return null;
}
- /// Extracts clean text (stripping HTML tags) suitable for author commentary display.
+ /// Extracts clean, readable text (stripping HTML tags and formatting links) suitable for author commentary and announcements.
static String cleanCommentary(String? rawHtml) {
if (rawHtml == null || rawHtml.trim().isEmpty) return '';
- var text = rawHtml
+ var text = rawHtml;
+
+ // Format anchor tags: TEXT
+ text = text.replaceAllMapped(
+ RegExp(r"""]*href=["']([^"']+)["'][^>]*>(.*?)""",
+ caseSensitive: false, dotAll: true),
+ (match) {
+ final href = match.group(1)?.trim() ?? '';
+ final label = match.group(2)?.replaceAll(RegExp(r'<[^>]*>'), '').trim() ?? '';
+ if (label.isEmpty || label == href || href.contains(label)) {
+ return href;
+ }
+ return '$label ($href)';
+ },
+ );
+
+ // Convert line breaks and paragraph endings
+ text = text
.replaceAll(RegExp(r'
', caseSensitive: false), '\n')
- .replaceAll(RegExp(r'<\/p>', caseSensitive: false), '\n\n')
- .replaceAll(RegExp(r'<[^>]*>'), '')
+ .replaceAll(RegExp(r'<\/p\s*>', caseSensitive: false), '\n\n')
+ .replaceAll(RegExp(r'<\/div\s*>', caseSensitive: false), '\n')
+ .replaceAll(RegExp(r'', caseSensitive: false), '• ')
+ .replaceAll(RegExp(r'<\/li\s*>', caseSensitive: false), '\n');
+
+ // Strip remaining HTML tags
+ text = text.replaceAll(RegExp(r'<[^>]*>'), '');
+
+ // Decode HTML entities
+ text = text
.replaceAll(' ', ' ')
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
- .replaceAll(''', "'");
+ .replaceAll(''', "'")
+ .replaceAll(''', "'");
+
+ // Normalize multiple consecutive blank lines
+ text = text.replaceAll(RegExp(r'\n{3,}'), '\n\n');
+
return text.trim();
}
}
diff --git a/lib/features/artists/presentation/artist_posts_screen.dart b/lib/features/artists/presentation/artist_posts_screen.dart
index 8730d4b..590e725 100644
--- a/lib/features/artists/presentation/artist_posts_screen.dart
+++ b/lib/features/artists/presentation/artist_posts_screen.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
+import 'package:url_launcher/url_launcher.dart';
import '../../../app/app.dart';
import '../../../app/responsive.dart';
@@ -230,6 +231,9 @@ class _ArtistPostsScreenState extends ConsumerState {
separatorBuilder: (_, __) => const Divider(height: 24),
itemBuilder: (context, index) {
final a = _announcements[index];
+ final cleanContent = CloudLinkExtractor.cleanCommentary(a.content);
+ final detectedLinks = CloudLinkExtractor.extractLinks(content: a.content);
+
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -244,9 +248,35 @@ class _ArtistPostsScreenState extends ConsumerState {
),
const SizedBox(height: 6),
SelectableText(
- a.content,
- style: theme.textTheme.bodyMedium,
+ cleanContent.isNotEmpty ? cleanContent : a.content,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ height: 1.45,
+ ),
),
+ if (detectedLinks.isNotEmpty) ...[
+ const SizedBox(height: 10),
+ Wrap(
+ spacing: 8,
+ runSpacing: 6,
+ children: detectedLinks.map((link) {
+ return ActionChip(
+ avatar: Icon(
+ link.iconData,
+ size: 16,
+ color: link.brandColor,
+ ),
+ label: Text(
+ link.title.isNotEmpty ? link.title : link.serviceName,
+ style: const TextStyle(fontSize: 12),
+ ),
+ onPressed: () => launchUrl(
+ Uri.parse(link.url),
+ mode: LaunchMode.externalApplication,
+ ),
+ );
+ }).toList(),
+ ),
+ ],
],
);
},
diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart
index ce2842c..88f6be3 100644
--- a/lib/features/post/presentation/post_details_screen.dart
+++ b/lib/features/post/presentation/post_details_screen.dart
@@ -339,7 +339,7 @@ class PostDetailsScreen extends ConsumerWidget {
),
const SizedBox(width: 8),
Text(
- '${strings.tags} (${post.tags.length})',
+ '${strings.tags} (${post.cleanTags.length})',
style: Theme.of(context)
.textTheme
.titleMedium
@@ -515,7 +515,7 @@ class PostDetailsScreen extends ConsumerWidget {
color: Theme.of(context).colorScheme.primary,
),
title: Text(
- '${strings.tags} (${post.tags.length})',
+ '${strings.tags} (${post.cleanTags.length})',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
diff --git a/lib/features/post/presentation/widgets/cloud_mirrors_card.dart b/lib/features/post/presentation/widgets/cloud_mirrors_card.dart
index 076ec39..cf2f15f 100644
--- a/lib/features/post/presentation/widgets/cloud_mirrors_card.dart
+++ b/lib/features/post/presentation/widgets/cloud_mirrors_card.dart
@@ -13,6 +13,7 @@ class CloudMirrorsCard extends StatelessWidget {
this.commentary,
this.onPlayStream,
this.onDownloadStream,
+ this.initiallyExpanded = false,
});
final List links;
@@ -20,6 +21,7 @@ class CloudMirrorsCard extends StatelessWidget {
final String? commentary;
final ValueChanged? onPlayStream;
final ValueChanged? onDownloadStream;
+ final bool initiallyExpanded;
@override
Widget build(BuildContext context) {
@@ -36,84 +38,68 @@ class CloudMirrorsCard extends StatelessWidget {
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
- borderRadius: BorderRadius.circular(20),
+ borderRadius: BorderRadius.circular(18),
border: Border.all(
- color: theme.colorScheme.primary.withValues(alpha: 0.2),
+ color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
),
),
- padding: const EdgeInsets.all(16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // Header
- Row(
- children: [
- Container(
- padding: const EdgeInsets.all(8),
- decoration: BoxDecoration(
- color: theme.colorScheme.primary.withValues(alpha: 0.15),
- shape: BoxShape.circle,
- ),
- child: Icon(
- Icons.cloud_sync_rounded,
- size: 20,
- color: theme.colorScheme.primary,
+ clipBehavior: Clip.antiAlias,
+ child: ExpansionTile(
+ tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
+ childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
+ initiallyExpanded: initiallyExpanded,
+ shape: const Border(),
+ collapsedShape: const Border(),
+ leading: Icon(
+ Icons.cloud_sync_rounded,
+ color: theme.colorScheme.primary,
+ ),
+ title: Row(
+ children: [
+ Expanded(
+ child: Text(
+ strings.ru
+ ? 'Облачные диски и зеркала (${links.length})'
+ : 'Cloud Mirrors & Drives (${links.length})',
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.bold,
),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
),
- const SizedBox(width: 10),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
+ ),
+ if (links.any((l) => l.isStreamable)) ...[
+ const SizedBox(width: 8),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+ decoration: BoxDecoration(
+ color: Colors.green.withValues(alpha: 0.15),
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: Colors.green.withValues(alpha: 0.4)),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
children: [
+ const Icon(Icons.play_circle_fill_rounded,
+ size: 13, color: Colors.green),
+ const SizedBox(width: 4),
Text(
- strings.ru
- ? 'Облачные диски и зеркала'
- : 'Cloud Mirrors & Drives',
- style: theme.textTheme.titleMedium?.copyWith(
+ strings.ru ? 'Плеер' : 'Stream',
+ style: theme.textTheme.labelSmall?.copyWith(
+ color: Colors.green,
fontWeight: FontWeight.bold,
- ),
- ),
- Text(
- strings.ru
- ? '${links.length} внешних источников'
- : '${links.length} external sources',
- style: theme.textTheme.bodySmall?.copyWith(
- color: theme.colorScheme.onSurfaceVariant,
+ fontSize: 10,
),
),
],
),
),
- if (links.any((l) => l.isStreamable))
- Container(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
- decoration: BoxDecoration(
- color: Colors.green.withValues(alpha: 0.15),
- borderRadius: BorderRadius.circular(8),
- border: Border.all(color: Colors.green.withValues(alpha: 0.4)),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- const Icon(Icons.play_circle_fill_rounded,
- size: 14, color: Colors.green),
- const SizedBox(width: 4),
- Text(
- strings.ru ? 'Плеер доступен' : 'Streamable',
- style: theme.textTheme.labelSmall?.copyWith(
- color: Colors.green,
- fontWeight: FontWeight.bold,
- ),
- ),
- ],
- ),
- ),
],
- ),
-
+ ],
+ ),
+ children: [
// Archive Password Banner
if (detectedPassword.isNotEmpty) ...[
- const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
@@ -166,22 +152,22 @@ class CloudMirrorsCard extends StatelessWidget {
],
),
),
+ const SizedBox(height: 10),
],
// Links list
if (links.isNotEmpty) ...[
- const SizedBox(height: 14),
...links.map((link) => _buildLinkTile(context, link, strings)),
],
// Commentary / Description
if (commentary != null && commentary!.trim().isNotEmpty) ...[
- const SizedBox(height: 14),
+ const SizedBox(height: 10),
Divider(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4),
height: 1,
),
- const SizedBox(height: 12),
+ const SizedBox(height: 10),
Row(
children: [
Icon(
diff --git a/lib/features/post/presentation/widgets/post_tags_panel.dart b/lib/features/post/presentation/widgets/post_tags_panel.dart
index 1b078da..eb9d656 100644
--- a/lib/features/post/presentation/widgets/post_tags_panel.dart
+++ b/lib/features/post/presentation/widgets/post_tags_panel.dart
@@ -219,6 +219,14 @@ class _PostTagsPanelState extends ConsumerState {
);
}
+ static const _ignoredTagGroupKeys = {
+ 'cloud_links',
+ 'description',
+ 'content',
+ 'links',
+ 'external_links',
+ };
+
Map> _groups(Post post) {
if (post.tagGroups.isNotEmpty) {
final ordered = >{};
@@ -230,17 +238,40 @@ class _PostTagsPanelState extends ConsumerState {
'meta',
'general',
]) {
- final tags = post.tagGroups[key];
- if (tags != null && tags.isNotEmpty) ordered[key] = tags;
+ final tags = _filterValidTags(post.tagGroups[key]);
+ if (tags.isNotEmpty) ordered[key] = tags;
}
for (final entry in post.tagGroups.entries) {
- if (entry.value.isNotEmpty) {
- ordered.putIfAbsent(entry.key, () => entry.value);
+ if (_ignoredTagGroupKeys.contains(entry.key.toLowerCase())) continue;
+ final tags = _filterValidTags(entry.value);
+ if (tags.isNotEmpty) {
+ ordered.putIfAbsent(entry.key, () => tags);
}
}
return ordered;
}
- return post.tags.isEmpty ? const {} : {'general': post.tags};
+ final filtered = _filterValidTags(post.tags);
+ return filtered.isEmpty ? const {} : {'general': filtered};
+ }
+
+ List _filterValidTags(List? rawTags) {
+ if (rawTags == null || rawTags.isEmpty) return const [];
+ return rawTags.where((tag) {
+ final t = tag.trim();
+ if (t.isEmpty) return false;
+ if (t.startsWith('{') && t.endsWith('}')) return false;
+ if (t.contains('"url"') || t.contains('"service"')) return false;
+ if (t.startsWith('http://') || t.startsWith('https://')) return false;
+ if (t.contains('mega.nz') ||
+ t.contains('drive.google.com') ||
+ t.contains('dropbox.com') ||
+ t.contains('pixeldrain.com') ||
+ t.contains('catbox.moe') ||
+ t.contains('mediafire.com')) {
+ return false;
+ }
+ return true;
+ }).toList(growable: false);
}
String _label(String key) {
diff --git a/pubspec.yaml b/pubspec.yaml
index bda5087..721f26a 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
-version: 2.3.0+40
+version: 2.3.1+41
environment:
sdk: ">=3.3.0 <4.0.0"
diff --git a/test/backend/cloud_link_extractor_test.dart b/test/backend/cloud_link_extractor_test.dart
index 5c2b46a..b6958c7 100644
--- a/test/backend/cloud_link_extractor_test.dart
+++ b/test/backend/cloud_link_extractor_test.dart
@@ -85,6 +85,17 @@ void main() {
expect(clean, 'Hello world!\nCheck out the new video.\n\n& enjoy!');
});
+ test('cleans HTML commentary and announcements correctly without style artifacts', () {
+ const html =
+ 'Thank you!
Here\'s the link to the archive
https://www.patreon.com/posts/full-fugtrup-14581297
and the link to my discord server, if you\'d like to join it https://discord.com/invite/xR2AbkX
';
+ final clean = CloudLinkExtractor.cleanCommentary(html);
+ expect(clean.contains(''), isFalse);
+ expect(clean.contains('Thank you!'), isTrue);
+ expect(clean.contains('https://discord.com/invite/xR2AbkX'), isTrue);
+ });
+
test('encodes and decodes CloudMediaLink to and from JSON', () {
const link = CloudMediaLink(
url: 'https://mega.nz/file/123#abc',
diff --git a/test/features/cloud_mirrors_card_test.dart b/test/features/cloud_mirrors_card_test.dart
index 0f963b2..ffe9383 100644
--- a/test/features/cloud_mirrors_card_test.dart
+++ b/test/features/cloud_mirrors_card_test.dart
@@ -35,6 +35,7 @@ void main() {
strings: const AppStrings('ru'),
commentary: 'Special Patreon bonus animation for all patrons.',
onPlayStream: (stream) => playedStream = stream,
+ initiallyExpanded: true,
),
),
),
@@ -42,9 +43,8 @@ void main() {
);
// Verify header and counts
- expect(find.text('Облачные диски и зеркала'), findsOneWidget);
- expect(find.text('2 внешних источников'), findsOneWidget);
- expect(find.text('Плеер доступен'), findsOneWidget);
+ expect(find.text('Облачные диски и зеркала (2)'), findsOneWidget);
+ expect(find.text('Плеер'), findsOneWidget);
// Verify password banner
expect(find.textContaining('archive_pass_42'), findsOneWidget);