news for new blog post

This commit is contained in:
otsmr 2026-07-20 14:40:27 +02:00
parent fb9acc690a
commit 9f8cb6f735
13 changed files with 616 additions and 4 deletions

View file

@ -1,6 +1,7 @@
import 'package:get_it/get_it.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/api.service.dart';
import 'package:twonly/src/services/news.service.dart';
import 'package:twonly/src/services/user.service.dart';
final GetIt locator = GetIt.instance;
@ -9,9 +10,11 @@ void setupLocator() {
locator
..registerLazySingleton<UserService>(UserService.new)
..registerLazySingleton<ApiService>(ApiService.new)
..registerLazySingleton<TwonlyDB>(TwonlyDB.new);
..registerLazySingleton<TwonlyDB>(TwonlyDB.new)
..registerLazySingleton<NewsService>(NewsService.new);
}
UserService get userService => locator<UserService>();
ApiService get apiService => locator<ApiService>();
TwonlyDB get twonlyDB => locator<TwonlyDB>();
NewsService get newsService => locator<NewsService>();

View file

@ -175,6 +175,13 @@ Future<void> postStartupTasks() async {
// 2. Service initializations
unawaited(finishStartedPreprocessing());
unawaited(createPushAvatars());
unawaited(newsService.init().then((_) {
final lastDownload = newsService.lastDownloadedAt;
if (lastDownload == null ||
DateTime.now().difference(lastDownload) >= const Duration(days: 7)) {
newsService.fetchFeed();
}
}));
unawaited(UserDiscoveryService.verifyInitializationOnStartup());

View file

@ -48,6 +48,7 @@ class Routes {
static const String settingsHelpFaqVerifyBadge =
'/settings/help/faq/verifybadge';
static const String settingsHelpContactUs = '/settings/help/contact_us';
static const String settingsHelpNews = '/settings/help/news';
static const String settingsHelpDiagnostics = '/settings/help/diagnostics';
static const String settingsHelpUserStudy = '/settings/help/user_study';
static const String settingsHelpUserStudyQuestionnaire =

View file

@ -4183,6 +4183,12 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Memories Backup'**
String get memoriesBackupTitle;
/// No description provided for @settingsHelpNews.
///
/// In en, this message translates to:
/// **'News'**
String get settingsHelpNews;
}
class _AppLocalizationsDelegate

View file

@ -2421,4 +2421,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get memoriesBackupTitle => 'Memories Backup';
@override
String get settingsHelpNews => 'Neuigkeiten';
}

View file

@ -2400,4 +2400,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get memoriesBackupTitle => 'Memories Backup';
@override
String get settingsHelpNews => 'News';
}

@ -1 +1 @@
Subproject commit f72c61787096a44e2252058c08e4c14a0a8e5cff
Subproject commit bb353aa572623d20578f470d2c683ead79669ed8

View file

@ -37,6 +37,7 @@ import 'package:twonly/src/visual/views/settings/help/diagnostics.view.dart';
import 'package:twonly/src/visual/views/settings/help/faq.view.dart';
import 'package:twonly/src/visual/views/settings/help/faq/verification_badge_faq.view.dart';
import 'package:twonly/src/visual/views/settings/help/help.view.dart';
import 'package:twonly/src/visual/views/settings/help/news.view.dart';
import 'package:twonly/src/visual/views/settings/notification.view.dart';
import 'package:twonly/src/visual/views/settings/privacy.view.dart';
import 'package:twonly/src/visual/views/settings/privacy/block_users.view.dart';
@ -259,6 +260,10 @@ final routerProvider = GoRouter(
path: 'contact_us',
builder: (context, state) => const ContactUsView(),
),
GoRoute(
path: 'news',
builder: (context, state) => const NewsView(),
),
GoRoute(
path: 'diagnostics',
builder: (context, state) => const DiagnosticsView(),

View file

@ -0,0 +1,257 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/utils/log.dart';
class BlogEntry {
BlogEntry({
required this.title,
required this.link,
required this.guid,
required this.description,
required this.imageUrl,
this.pubDate,
});
factory BlogEntry.fromJson(Map<String, dynamic> json) => BlogEntry(
title: (json['title'] as String?) ?? '',
link: (json['link'] as String?) ?? '',
guid: (json['guid'] as String?) ?? '',
description: (json['description'] as String?) ?? '',
imageUrl: (json['imageUrl'] as String?) ?? '',
pubDate: json['pubDate'] != null ? DateTime.tryParse(json['pubDate'] as String) : null,
);
final String title;
final String link;
final String guid;
final String description;
final String imageUrl;
final DateTime? pubDate;
Map<String, dynamic> toJson() => {
'title': title,
'link': link,
'guid': guid,
'description': description,
'imageUrl': imageUrl,
'pubDate': pubDate?.toIso8601String(),
};
@override
String toString() {
return 'BlogEntry(title: $title, link: $link, guid: $guid, description: $description, imageUrl: $imageUrl, pubDate: $pubDate)';
}
}
class NewsService {
List<BlogEntry> entries = [];
Set<String> openedGuids = {};
DateTime? lastDownloadedAt;
bool hasLoadedBefore = false;
final ValueNotifier<int> unreadCountNotifier = ValueNotifier<int>(0);
File get _cacheFile => File(join(AppEnvironment.supportDir, 'news_feed.json'));
Future<void> init() async {
try {
await loadCache();
} catch (e) {
Log.error('Failed to load news cache: $e');
}
}
Future<void> loadCache() async {
final file = _cacheFile;
if (!file.existsSync()) {
return;
}
final content = await file.readAsString();
final json = jsonDecode(content) as Map<String, dynamic>;
hasLoadedBefore = json['hasLoadedBefore'] as bool? ?? false;
final lastDownloadedStr = json['lastDownloadedAt'] as String?;
if (lastDownloadedStr != null) {
lastDownloadedAt = DateTime.tryParse(lastDownloadedStr);
}
final openedList = json['openedGuids'] as List<dynamic>? ?? [];
openedGuids = openedList.map((e) => e.toString()).toSet();
final entriesList = json['entries'] as List<dynamic>? ?? [];
entries = entriesList
.map((e) => BlogEntry.fromJson(e as Map<String, dynamic>))
.toList();
_updateUnreadCount();
}
Future<void> saveCache() async {
final file = _cacheFile;
final json = {
'hasLoadedBefore': hasLoadedBefore,
'lastDownloadedAt': lastDownloadedAt?.toIso8601String(),
'openedGuids': openedGuids.toList(),
'entries': entries.map((e) => e.toJson()).toList(),
};
await file.writeAsString(jsonEncode(json));
}
void _updateUnreadCount() {
var count = 0;
for (final entry in entries) {
if (!openedGuids.contains(entry.guid)) {
count++;
}
}
unreadCountNotifier.value = count;
}
Future<void> markAllAsRead() async {
for (final entry in entries) {
openedGuids.add(entry.guid);
}
_updateUnreadCount();
await saveCache();
}
Future<void> fetchFeed({http.Client? client, bool force = false}) async {
try {
final lang = ui.PlatformDispatcher.instance.locale.languageCode;
final url = lang == 'de'
? 'https://twonly.eu/de/blog/rss.xml'
: 'https://twonly.eu/en/blog/rss.xml';
final response = client != null
? await client.get(Uri.parse(url))
: await http.get(Uri.parse(url));
if (response.statusCode != 200) {
Log.error('Failed to download RSS feed: code ${response.statusCode}');
return;
}
final xml = utf8.decode(response.bodyBytes);
final newEntries = _parseRss(xml);
if (!hasLoadedBefore) {
// First run: mark all fetched as already opened
for (final entry in newEntries) {
openedGuids.add(entry.guid);
}
hasLoadedBefore = true;
}
entries = newEntries;
lastDownloadedAt = DateTime.now();
_updateUnreadCount();
await saveCache();
Log.info('Successfully fetched ${entries.length} news entries. Unread count: ${unreadCountNotifier.value}');
} catch (e) {
Log.error('Failed to fetch news feed: $e');
}
}
List<BlogEntry> _parseRss(String xml) {
final entries = <BlogEntry>[];
final itemRegex = RegExp(r'<item>([\s\S]*?)<\/item>');
final matches = itemRegex.allMatches(xml);
for (final match in matches) {
final itemContent = match.group(1) ?? '';
final titleMatch = RegExp(r'<title>([\s\S]*?)<\/title>').firstMatch(itemContent);
final title = _stripCdata(titleMatch?.group(1) ?? '');
final linkMatch = RegExp(r'<link>([\s\S]*?)<\/link>').firstMatch(itemContent);
final link = _stripCdata(linkMatch?.group(1) ?? '');
final guidMatch = RegExp(r'<guid[^>]*?>([\s\S]*?)<\/guid>').firstMatch(itemContent);
final guid = _stripCdata(guidMatch?.group(1) ?? link);
final descMatch = RegExp(r'<description>([\s\S]*?)<\/description>').firstMatch(itemContent);
final descriptionRaw = _stripCdata(descMatch?.group(1) ?? '');
final description = descriptionRaw.replaceAll(RegExp('<[^>]*>'), '');
var imageUrl = '';
final enclosureMatch = RegExp(r'<enclosure\s+[^>]*?url="([^"]+)"').firstMatch(itemContent);
if (enclosureMatch != null) {
imageUrl = enclosureMatch.group(1) ?? '';
}
if (imageUrl.isEmpty) {
final mediaMatch = RegExp(r'<media:content\s+[^>]*?url="([^"]+)"').firstMatch(itemContent);
if (mediaMatch != null) {
imageUrl = mediaMatch.group(1) ?? '';
}
}
final pubDateMatch = RegExp(r'<pubDate>([\s\S]*?)<\/pubDate>').firstMatch(itemContent);
DateTime? pubDate;
if (pubDateMatch != null) {
final pubDateStr = pubDateMatch.group(1) ?? '';
pubDate = DateTime.tryParse(pubDateStr);
pubDate ??= _parseRfc2822(pubDateStr);
}
entries.add(BlogEntry(
title: title,
link: link,
guid: guid,
description: description,
imageUrl: imageUrl,
pubDate: pubDate,
));
}
return entries;
}
String _stripCdata(String input) {
var s = input.trim();
if (s.startsWith('<![CDATA[')) {
s = s.substring(9);
}
if (s.endsWith(']]>')) {
s = s.substring(0, s.length - 3);
}
return s.trim();
}
DateTime? _parseRfc2822(String dateString) {
try {
var cleaned = dateString.trim();
if (cleaned.contains(',')) {
cleaned = cleaned.split(',')[1].trim();
}
final parts = cleaned.split(RegExp(r'\s+'));
if (parts.length < 4) return null;
final day = int.tryParse(parts[0]) ?? 1;
final monthStr = parts[1].toLowerCase();
final year = int.tryParse(parts[2]) ?? DateTime.now().year;
final timeParts = parts[3].split(':');
final hour = timeParts.isNotEmpty ? (int.tryParse(timeParts[0]) ?? 0) : 0;
final minute = timeParts.length > 1 ? (int.tryParse(timeParts[1]) ?? 0) : 0;
final second = timeParts.length > 2 ? (int.tryParse(timeParts[2]) ?? 0) : 0;
const months = {
'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 'jun': 6,
'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12
};
final monthStringPart = monthStr.length > 3 ? monthStr.substring(0, 3) : monthStr;
final month = months[monthStringPart] ?? 1;
return DateTime.utc(year, month, day, hour, minute, second);
} catch (e) {
Log.warn('Failed to parse date: $e');
return null;
}
}
}

View file

@ -17,8 +17,8 @@ import 'package:twonly/src/visual/components/connection_status.comp.dart';
import 'package:twonly/src/visual/components/notification_badge.comp.dart';
import 'package:twonly/src/visual/themes/light.dart';
import 'package:twonly/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_list_components/feedback_btn.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_list_components/group_list_item.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_list_components/news_btn.comp.dart';
import 'package:twonly/src/visual/views/onboarding/setup/components/finish_setup.comp.dart';
import 'package:twonly/src/visual/views/settings/backup/components/missing_backup_setup.comp.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/missing_recovery_contacts.comp.dart';
@ -184,7 +184,7 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
],
),
actions: [
const FeedbackIconButtonComp(),
const NewsIconButtonComp(),
ValueListenableBuilder<int>(
valueListenable: _badgeCount,
builder: (context, badgeCount, child) {

View file

@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/notification_badge.comp.dart';
class NewsIconButtonComp extends StatelessWidget {
const NewsIconButtonComp({super.key});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: newsService.unreadCountNotifier,
builder: (context, count, child) {
return NotificationBadgeComp(
count: count.toString(),
child: IconButton(
onPressed: () => context.push(Routes.settingsHelpNews),
color: Colors.grey,
tooltip: context.lang.settingsHelpNews,
icon: const FaIcon(FontAwesomeIcons.newspaper, size: 19),
),
);
},
);
}
}

View file

@ -0,0 +1,120 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:url_launcher/url_launcher.dart';
class NewsView extends StatefulWidget {
const NewsView({super.key});
@override
State<NewsView> createState() => _NewsViewState();
}
class _NewsViewState extends State<NewsView> {
@override
void initState() {
super.initState();
// Mark all as read when entering the page
newsService.markAllAsRead();
}
@override
Widget build(BuildContext context) {
final entries = newsService.entries;
return Scaffold(
appBar: AppBar(
title: Text(context.lang.settingsHelpNews),
),
body: entries.isEmpty
? Center(
child: Text(
'No news articles found.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.grey,
),
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: entries.length,
itemBuilder: (context, index) {
final entry = entries[index];
return Card(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 2,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: InkWell(
onTap: () => launchUrl(
Uri.parse(entry.link),
mode: LaunchMode.externalApplication,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (entry.imageUrl.isNotEmpty)
CachedNetworkImage(
imageUrl: entry.imageUrl,
height: 180,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
height: 180,
color: Colors.grey.withValues(alpha: 0.1),
child: const Center(
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url, error) => Container(
height: 180,
color: Colors.grey.withValues(alpha: 0.1),
child: const Icon(Icons.broken_image, size: 50, color: Colors.grey),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (entry.pubDate != null) ...[
Text(
DateFormat.yMMMMd(
Localizations.localeOf(context).toString(),
).format(entry.pubDate!),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey,
),
),
const SizedBox(height: 8),
],
Text(
entry.title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
entry.description,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.color.onSurface.withValues(alpha: 0.8),
),
),
],
),
),
],
),
),
);
},
),
);
}
}

View file

@ -0,0 +1,178 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:path/path.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/services/news.service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
late NewsService newsServiceInstance;
setUp(() async {
tempDir = Directory.systemTemp.createTempSync('news_service_test');
AppEnvironment.initTesting(
customCacheDir: join(tempDir.path, 'cache'),
customSupportDir: join(tempDir.path, 'support'),
);
Directory(AppEnvironment.supportDir).createSync(recursive: true);
Directory(AppEnvironment.cacheDir).createSync(recursive: true);
await locator.reset();
locator.registerSingleton<NewsService>(NewsService());
newsServiceInstance = newsService;
});
tearDown(() async {
if (tempDir.existsSync()) {
tempDir.deleteSync(recursive: true);
}
});
const rssXml = '''
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
<channel>
<title>twonly Blog</title>
<link>https://twonly.eu/en/blog/</link>
<description>Latest news, privacy insights, and development updates from twonly.</description>
<language>en</language>
<lastBuildDate>Mon, 20 Jul 2026 12:23:32 GMT</lastBuildDate>
<atom:link href="https://twonly.eu/en/blog/rss.xml" rel="self" type="application/rss+xml" />
<item>
<title><![CDATA[Finding Friends Without Phone Numbers]]></title>
<link>https://twonly.eu/en/blog/2026-mutual-friends.html</link>
<guid isPermaLink="true">https://twonly.eu/en/blog/2026-mutual-friends.html</guid>
<pubDate>Sun, 03 May 2026 00:00:00 GMT</pubDate>
<author><![CDATA[Tobias Müller]]></author>
<description><![CDATA[Finding your friends on a messenger app usually means giving up your phone number. Weve built a way to find and verify your contacts through the people you already know, all while keeping your personal identifiers private. No phone numbers, no tracking.]]></description>
<enclosure url="https://twonly.eu/assets/blog/2026-mutual-friends/2026-mutual-friends.webp" length="258020" type="image/webp" />
<media:content url="https://twonly.eu/assets/blog/2026-mutual-friends/2026-mutual-friends.webp" medium="image" type="image/webp" />
</item>
</channel>
</rss>''';
const rssXmlWithTwoItems = '''
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
<channel>
<title>twonly Blog</title>
<link>https://twonly.eu/en/blog/</link>
<description>Latest news, privacy insights, and development updates from twonly.</description>
<language>en</language>
<lastBuildDate>Mon, 20 Jul 2026 12:23:32 GMT</lastBuildDate>
<atom:link href="https://twonly.eu/en/blog/rss.xml" rel="self" type="application/rss+xml" />
<item>
<title><![CDATA[Finding Friends Without Phone Numbers]]></title>
<link>https://twonly.eu/en/blog/2026-mutual-friends.html</link>
<guid isPermaLink="true">https://twonly.eu/en/blog/2026-mutual-friends.html</guid>
<pubDate>Sun, 03 May 2026 00:00:00 GMT</pubDate>
<author><![CDATA[Tobias Müller]]></author>
<description><![CDATA[Finding your friends on a messenger app usually means giving up your phone number. Weve built a way to find and verify your contacts through the people you already know, all while keeping your personal identifiers private. No phone numbers, no tracking.]]></description>
<enclosure url="https://twonly.eu/assets/blog/2026-mutual-friends/2026-mutual-friends.webp" length="258020" type="image/webp" />
<media:content url="https://twonly.eu/assets/blog/2026-mutual-friends/2026-mutual-friends.webp" medium="image" type="image/webp" />
</item>
<item>
<title><![CDATA[New Update Available]]></title>
<link>https://twonly.eu/en/blog/new-update.html</link>
<guid isPermaLink="true">https://twonly.eu/en/blog/new-update.html</guid>
<pubDate>Mon, 20 Jul 2026 12:00:00 GMT</pubDate>
<author><![CDATA[Tobias Müller]]></author>
<description><![CDATA[A brand <a href="https://twonly.eu">new version</a> of twonly has been released.]]></description>
<enclosure url="https://twonly.eu/assets/blog/new-update/new-update.webp" length="12345" type="image/webp" />
</item>
</channel>
</rss>''';
test('Initial fetch feed marks all articles as read', () async {
final mockClient = MockClient((request) async {
return http.Response.bytes(utf8.encode(rssXml), 200);
});
await newsServiceInstance.init();
expect(newsServiceInstance.hasLoadedBefore, isFalse);
expect(newsServiceInstance.unreadCountNotifier.value, 0);
await newsServiceInstance.fetchFeed(client: mockClient);
expect(newsServiceInstance.hasLoadedBefore, isTrue);
expect(newsServiceInstance.entries.length, 1);
expect(newsServiceInstance.unreadCountNotifier.value, 0);
expect(
newsServiceInstance.openedGuids.contains(
'https://twonly.eu/en/blog/2026-mutual-friends.html',
),
isTrue,
);
});
test('Subsequent fetch feed detects new articles as unread', () async {
final mockClient1 = MockClient((request) async {
return http.Response.bytes(utf8.encode(rssXml), 200);
});
await newsServiceInstance.init();
await newsServiceInstance.fetchFeed(client: mockClient1);
expect(newsServiceInstance.unreadCountNotifier.value, 0);
final mockClient2 = MockClient((request) async {
return http.Response.bytes(utf8.encode(rssXmlWithTwoItems), 200);
});
await newsServiceInstance.fetchFeed(client: mockClient2);
expect(newsServiceInstance.entries.length, 2);
expect(newsServiceInstance.unreadCountNotifier.value, 1);
expect(
newsServiceInstance.entries[1].description,
'A brand new version of twonly has been released.',
);
});
test('markAllAsRead clears unread count', () async {
final mockClient1 = MockClient((request) async {
return http.Response.bytes(utf8.encode(rssXml), 200);
});
await newsServiceInstance.init();
await newsServiceInstance.fetchFeed(client: mockClient1);
final mockClient2 = MockClient((request) async {
return http.Response.bytes(utf8.encode(rssXmlWithTwoItems), 200);
});
await newsServiceInstance.fetchFeed(client: mockClient2);
expect(newsServiceInstance.unreadCountNotifier.value, 1);
await newsServiceInstance.markAllAsRead();
expect(newsServiceInstance.unreadCountNotifier.value, 0);
});
test('Cache loads correctly and restores state', () async {
final mockClient = MockClient((request) async {
return http.Response.bytes(utf8.encode(rssXml), 200);
});
await newsServiceInstance.init();
await newsServiceInstance.fetchFeed(client: mockClient);
final newServiceInstance = NewsService();
await newServiceInstance.init();
expect(newServiceInstance.hasLoadedBefore, isTrue);
expect(newServiceInstance.entries.length, 1);
expect(newServiceInstance.unreadCountNotifier.value, 0);
expect(
newServiceInstance.openedGuids.contains(
'https://twonly.eu/en/blog/2026-mutual-friends.html',
),
isTrue,
);
});
}