···11-import 'package:atproto/atproto.dart' as atp;
21import 'package:atproto/core.dart';
32import 'package:flutter/foundation.dart';
44-import 'package:image/image.dart' as img;
53import 'package:image/image.dart' as img;
64import 'package:image_picker/image_picker.dart';
75···162160163161 return response.data;
164162 } catch (e) {
165165- print('Error creating Spark image post record: $e');
163163+ debugPrint('Error creating Spark image post record: $e');
166164 rethrow;
167165 }
168166 }
+151-4
lib/services/feed_manager.dart
···11import 'package:atproto/core.dart';
22import 'package:bluesky/bluesky.dart';
33-33+import 'package:flutter/foundation.dart';
44+import 'package:sparksocial/services/label_service.dart';
55+import 'package:sparksocial/services/labeler_manager.dart';
46import '../models/feed_post.dart';
57import 'auth_service.dart';
68import 'sprk_client.dart';
···911 static final FeedManager _instance = FeedManager._internal();
1012 factory FeedManager() => _instance;
1113 FeedManager._internal();
1414+1515+ // Referência ao LabelerManager
1616+ LabelerManager? _labelerManager;
1717+1818+ /// Define o LabelerManager a ser usado para filtrar conteúdo
1919+ void setLabelerManager(LabelerManager labelerManager) {
2020+ _labelerManager = labelerManager;
2121+ }
12221323 Future<List<FeedPost>> fetchFeed(int feedType, AuthService authService) async {
1424 switch (feedType) {
···3141 final allPosts = feed.data.feed.map((item) => FeedPost.fromBlueskyFeed(item)).toList();
32423343 // Filter posts to only show those with media that aren't replies
3434- return allPosts.where((post) => post.hasMedia && !post.isReply).toList();
4444+ final filteredPosts = allPosts.where((post) => post.hasMedia && !post.isReply).toList();
4545+4646+ // Fetch labels for posts
4747+ await fetchLabelsForPosts(filteredPosts, authService);
4848+4949+ // Apply label preferences filtering if LabelerManager está disponível
5050+ if (_labelerManager != null) {
5151+ return _applyLabelPreferences(filteredPosts);
5252+ }
5353+5454+ return filteredPosts;
3555 }
36563757 Future<List<FeedPost>> _fetchForYouFeed(AuthService authService) async {
···4565 final allPosts = feed.data.feed.map((item) => FeedPost.fromBlueskyFeed(item)).toList();
46664767 // Filter posts to only show those with media that aren't replies
4848- return allPosts.where((post) => post.hasMedia && !post.isReply).toList();
6868+ final filteredPosts = allPosts.where((post) => post.hasMedia && !post.isReply).toList();
6969+7070+ // Fetch labels for posts
7171+ await fetchLabelsForPosts(filteredPosts, authService);
7272+7373+ // Apply label preferences filtering se LabelerManager estiver disponível
7474+ if (_labelerManager != null) {
7575+ return _applyLabelPreferences(filteredPosts);
7676+ }
7777+7878+ return filteredPosts;
4979 }
50805181 Future<List<FeedPost>> _fetchSparkNewFeed(AuthService authService) async {
···86116 final feedItem = {'post': post};
87117 return FeedPost.fromSparkFeed(feedItem);
88118 }).toList();
119119+120120+ // Fetch labels for the retrieved posts
121121+ await fetchLabelsForPosts(allFeedPosts, authService);
8912290123 // Filter posts to only show those with media that aren't replies
9191- return allFeedPosts.where((post) => post.hasMedia && !post.isReply).toList();
124124+ final filteredPosts = allFeedPosts.where((post) => post.hasMedia && !post.isReply).toList();
125125+126126+ // Apply label preferences filtering se LabelerManager estiver disponível
127127+ if (_labelerManager != null) {
128128+ return _applyLabelPreferences(filteredPosts);
129129+ }
130130+131131+ return filteredPosts;
132132+ }
133133+134134+ /// Update labels for a list of posts at once
135135+ Future<void> fetchLabelsForPosts(List<FeedPost> posts, AuthService authService) async {
136136+ if (posts.isEmpty) return;
137137+138138+ final labelService = LabelService(authService, serviceUrl: 'https://pds.sprk.so');
139139+140140+ try {
141141+ // Collect all URIs for a single query
142142+ final uriPatterns = posts.map((post) => post.uri).toList();
143143+144144+ // Obter a lista de labelers seguidos pelo usuário
145145+ // Se _labelerManager for nulo, use o labeler padrão como fallback
146146+ List<String> labelerSources = _labelerManager?.followedLabelers ?? [LabelerManager.defaultLabelerDid];
147147+148148+ // Use the LabelService to get detailed label information grouped by URI
149149+ final labelsByUri = await labelService.getLabelsWithDetails(
150150+ uriPatterns: uriPatterns,
151151+ sources: labelerSources,
152152+ );
153153+154154+ // Update each post with its specific labels
155155+ for (final post in posts) {
156156+ if (labelsByUri.containsKey(post.uri)) {
157157+ // Create a new list for labels
158158+ final labelValues = labelsByUri[post.uri]!
159159+ .map((label) => label['val'] as String)
160160+ .toList();
161161+162162+ // Update the post's labels - use reflection to set the property directly
163163+ // or create a copy of the post with updated labels
164164+ try {
165165+ post.setLabels(labelValues);
166166+ debugPrint('Post ${post.uri} has labels: ${post.labels}');
167167+ } catch (e) {
168168+ debugPrint('Could not update labels for post ${post.uri}: $e');
169169+ }
170170+ }
171171+ }
172172+ } catch (e) {
173173+ debugPrint('Error fetching labels for multiple posts: $e');
174174+ }
175175+ }
176176+177177+ /// Apply label preferences to filter the posts
178178+ List<FeedPost> _applyLabelPreferences(List<FeedPost> posts) {
179179+ try {
180180+ if (_labelerManager == null) {
181181+ return posts;
182182+ }
183183+184184+ // First remove any posts that should be hidden based on label preferences
185185+ final visiblePosts = posts.where((post) {
186186+ // If the post has no labels, it's always visible
187187+ if (post.labels.isEmpty) return true;
188188+189189+ // Special label '!hide' always hides the post regardless of other settings
190190+ if (post.labels.contains('!hide')) {
191191+ debugPrint('Post ${post.uri} has !hide label and will be hidden');
192192+ return false;
193193+ }
194194+195195+ // Check if any label should hide this post based on preferences
196196+ bool shouldHide = _labelerManager!.shouldHideContent(post.labels);
197197+ if (shouldHide) {
198198+ debugPrint('Post ${post.uri} should be hidden based on label preferences');
199199+ }
200200+ return !shouldHide;
201201+ }).toList();
202202+203203+ // Return the filtered list
204204+ return visiblePosts;
205205+ } catch (e) {
206206+ // If there's any error, just return the original posts
207207+ debugPrint('Error applying label preferences: $e');
208208+ return posts;
209209+ }
210210+ }
211211+212212+ /// Check if a post should show a warning based on its labels
213213+ bool shouldWarnContent(FeedPost post) {
214214+ if (post.labels.isEmpty || _labelerManager == null) return false;
215215+216216+ try {
217217+ // Special label '!warn' always shows a warning regardless of other settings
218218+ if (post.labels.contains('!warn')) {
219219+ return true;
220220+ }
221221+222222+ return _labelerManager!.shouldWarnContent(post.labels);
223223+ } catch (e) {
224224+ debugPrint('Error checking if content should be warned: $e');
225225+ return false;
226226+ }
227227+ }
228228+229229+ /// Get warning messages for a post
230230+ List<String> getWarningMessages(FeedPost post) {
231231+ if (post.labels.isEmpty || _labelerManager == null) return [];
232232+233233+ try {
234234+ return _labelerManager!.getWarningMessages(post.labels);
235235+ } catch (e) {
236236+ debugPrint('Error getting warning messages: $e');
237237+ return [];
238238+ }
92239 }
93240}
+33-27
lib/services/feed_settings_service.dart
···11import 'package:flutter/material.dart';
22import 'package:shared_preferences/shared_preferences.dart';
3344-class FeedType {
55- static const int following = 0;
66- static const int forYou = 1;
77- static const int latest = 2;
44+// this whole file will need to be refactored when we add modular feed types
55+// for now, I just transformed the gambiarra enum class into an actual enum
66+// when we add modular feed types, this enum will be replaced by a class
77+enum FeedType {
88+ following(0, 'Following'),
99+ forYou(1, 'For You'),
1010+ latest(2, 'Latest');
1111+1212+ final int value;
1313+ final String name;
1414+1515+ const FeedType(this.value, this.name);
1616+1717+ static FeedType fromValue(int value) {
1818+ return FeedType.values.firstWhere((feedType) => feedType.value == value, orElse: () => FeedType.forYou);
1919+ }
820}
9211022class FeedSettingsService extends ChangeNotifier {
···1931 static const String _keyLatestFeed = 'latest_feed_enabled';
2032 static const String _keyDisableBlur = 'disable_background_blur';
2133 static const String _keySelectedFeed = 'selected_feed_type';
3434+ static const String _keyDisableNsfwContent = 'disable_nsfw_content';
3535+ // there should be a key for each label of each labeler
3636+ // for now, we'll just use the default labels
22372338 // Feed states
2439 bool _followingFeedEnabled = true;
2540 bool _forYouFeedEnabled = true;
2641 bool _latestFeedEnabled = true;
2742 bool _disableVideoBackgroundBlur = false;
2828- int _selectedFeedType = FeedType.forYou;
4343+ FeedType _selectedFeedType = FeedType.forYou;
4444+ bool _disableNsfwContent = true;
29453046 // Getters
3147 bool get followingFeedEnabled => _followingFeedEnabled;
3248 bool get forYouFeedEnabled => _forYouFeedEnabled;
3349 bool get latestFeedEnabled => _latestFeedEnabled;
3450 bool get disableVideoBackgroundBlur => _disableVideoBackgroundBlur;
3535- int get selectedFeedType => _selectedFeedType;
5151+ FeedType get selectedFeedType => _selectedFeedType;
5252+ bool get disableNsfwContent => _disableNsfwContent;
36533754 Future<void> loadPreferences() async {
3855 try {
···4259 _forYouFeedEnabled = prefs.getBool(_keyForYouFeed) ?? true;
4360 _latestFeedEnabled = prefs.getBool(_keyLatestFeed) ?? true;
4461 _disableVideoBackgroundBlur = prefs.getBool(_keyDisableBlur) ?? false;
4545- _selectedFeedType = prefs.getInt(_keySelectedFeed) ?? FeedType.forYou;
6262+ _selectedFeedType = FeedType.values[prefs.getInt(_keySelectedFeed) ?? FeedType.forYou.value];
6363+ _disableNsfwContent = prefs.getBool(_keyDisableNsfwContent) ?? true;
46644765 // Make sure selected feed is enabled
4866 if (!isSelectedFeedEnabled()) {
···6280 await prefs.setBool(_keyForYouFeed, _forYouFeedEnabled);
6381 await prefs.setBool(_keyLatestFeed, _latestFeedEnabled);
6482 await prefs.setBool(_keyDisableBlur, _disableVideoBackgroundBlur);
6565- await prefs.setInt(_keySelectedFeed, _selectedFeedType);
8383+ await prefs.setInt(_keySelectedFeed, _selectedFeedType.value);
8484+ await prefs.setBool(_keyDisableNsfwContent, _disableNsfwContent);
6685 notifyListeners();
6786 } catch (e) {
6887 // Silently handle preference save errors
···110129111130 // Don't allow disabling the currently selected feed
112131 final feedTypeIndex = getFeedTypeFromSetting(settingType);
113113- return feedTypeIndex != _selectedFeedType;
132132+ return feedTypeIndex != _selectedFeedType.value;
114133 }
115134116135 Future<void> toggleFeed(String settingType, bool isEnabled) async {
···140159 notifyListeners();
141160 }
142161143143- Future<void> setSelectedFeedType(int feedType) async {
162162+ Future<void> setSelectedFeedType(FeedType feedType) async {
144163 _selectedFeedType = feedType;
145164 await savePreferences();
146165 notifyListeners();
···149168 int getFeedTypeFromSetting(String settingType) {
150169 switch (settingType) {
151170 case 'following_feed':
152152- return FeedType.following;
171171+ return FeedType.following.value;
153172 case 'for_you_feed':
154154- return FeedType.forYou;
173173+ return FeedType.forYou.value;
155174 case 'latest_feed':
156156- return FeedType.latest;
157157- default:
158158- return FeedType.forYou;
159159- }
160160- }
161161-162162- String getFeedNameFromType(int feedType) {
163163- switch (feedType) {
164164- case FeedType.following:
165165- return 'Following';
166166- case FeedType.forYou:
167167- return 'For You';
168168- case FeedType.latest:
169169- return 'Latest';
175175+ return FeedType.latest.value;
170176 default:
171171- return 'For You';
177177+ return FeedType.forYou.value;
172178 }
173179 }
174180}
+1075
lib/services/label_service.dart
···11+import 'dart:convert';
22+import 'package:atproto/atproto.dart';
33+import 'package:atproto/core.dart';
44+import 'auth_service.dart';
55+66+/// Service for handling label-related operations
77+class LabelService {
88+ final AuthService _authService;
99+ // labeler did
1010+ // if null, we use the sprk pds, which calls the sprk labeler in the backend
1111+ final String? did;
1212+ final String serviceUrl;
1313+ List<String> labelValues = [];
1414+ List<Map<String, dynamic>> labelValueDefinitions = [];
1515+1616+ // Instance cache for each labeler
1717+ static final Map<String, LabelService> _instances = {};
1818+1919+ /// Default constructor
2020+ LabelService(this._authService, {this.did = 'did:plc:pbgyr67hftvpoqtvaurpsctc', this.serviceUrl = 'https://pds.sprk.so'});
2121+2222+ /// Gets or creates a LabelService instance for a specific labeler
2323+ ///
2424+ /// [authService] The authentication service to be used
2525+ /// [labelerDid] The DID of the labeler
2626+ /// [serviceUrl] Optional service URL (PDS) that hosts the labeler
2727+ static LabelService forLabeler(
2828+ AuthService authService,
2929+ String labelerDid,
3030+ {String serviceUrl = 'https://pds.sprk.so'}
3131+ ) {
3232+ // If we already have an instance for this labeler, return it
3333+ if (_instances.containsKey(labelerDid)) {
3434+ return _instances[labelerDid]!;
3535+ }
3636+3737+ // Otherwise, create a new instance
3838+ final service = LabelService(authService, did: labelerDid, serviceUrl: serviceUrl);
3939+ _instances[labelerDid] = service;
4040+ return service;
4141+ }
4242+4343+ /// Clears the instance cache
4444+ static void clearCache() {
4545+ _instances.clear();
4646+ }
4747+4848+ ATProto? get _atproto => _authService.atproto;
4949+5050+ /// Fetches all available label values from the labeler
5151+ ///
5252+ /// This uses the getLabelValues endpoint defined by the labeler
5353+ Future<List<String>> fetchLabelValues() async {
5454+ final client = _atproto;
5555+ if (client == null) {
5656+ throw Exception('ATProto client not available');
5757+ }
5858+5959+ try {
6060+ // Configure header to use the proxy for the labeler
6161+ final Map<String, String> headers = {};
6262+ if (did != null) {
6363+ headers['atproto-proxy'] = '$did#atproto_labeler';
6464+ }
6565+6666+ final responseData = await client.get(
6767+ NSID.parse('com.atproto.label.getLabelValues'),
6868+ headers: headers,
6969+ to: (json) => json as Map<String, dynamic>,
7070+ adaptor: (uint8) => jsonDecode(utf8.decode(uint8)),
7171+ );
7272+7373+ // Update the local cache - create a new list instead of clearing the existing one
7474+ final values = List<String>.from(responseData.data['values'] ?? []);
7575+ labelValues = values;
7676+7777+ return values;
7878+ } catch (e) {
7979+ // Check if this is a 501 Method Not Implemented error
8080+ if (e.toString().contains('501 Method Not Implemented')) {
8181+ // For default labeler, return default values
8282+ if (did == 'did:plc:pbgyr67hftvpoqtvaurpsctc') {
8383+ labelValues = [
8484+ '!hide',
8585+ '!warn',
8686+ 'porn',
8787+ 'sexual',
8888+ 'nudity',
8989+ 'sexual-figurative',
9090+ 'graphic-media',
9191+ 'self-harm',
9292+ 'sensitive',
9393+ 'extremist',
9494+ 'intolerant',
9595+ 'threat',
9696+ 'rude',
9797+ 'illicit',
9898+ 'security',
9999+ 'unsafe-link',
100100+ 'impersonation',
101101+ 'misinformation',
102102+ 'scam',
103103+ 'engagement-farming',
104104+ 'spam',
105105+ 'rumor',
106106+ 'misleading',
107107+ 'inauthentic',
108108+ ];
109109+ return labelValues;
110110+ }
111111+ }
112112+ throw Exception('Error fetching label values: $e');
113113+ }
114114+ }
115115+116116+ /// Fetches detailed definitions for all label values
117117+ ///
118118+ /// This uses the getLabelValueDefinitions endpoint defined by the labeler
119119+ Future<List<Map<String, dynamic>>> fetchLabelValueDefinitions() async {
120120+ final client = _atproto;
121121+ if (client == null) {
122122+ throw Exception('ATProto client not available');
123123+ }
124124+125125+ try {
126126+ // Configure header to use the proxy for the labeler
127127+ final Map<String, String> headers = {};
128128+ if (did != null) {
129129+ headers['atproto-proxy'] = '$did#atproto_labeler';
130130+ }
131131+132132+ final responseData = await client.get(
133133+ NSID.parse('com.atproto.label.getLabelValueDefinitions'),
134134+ headers: headers,
135135+ to: (json) => json as Map<String, dynamic>,
136136+ adaptor: (uint8) => jsonDecode(utf8.decode(uint8)),
137137+ );
138138+139139+ // Extract and convert the definitions
140140+ final definitions = List<Map<String, dynamic>>.from(responseData.data['definitions'] ?? []);
141141+142142+ // Update the local cache - create a new list instead of clearing the existing one
143143+ labelValueDefinitions = definitions;
144144+145145+ return definitions;
146146+ } catch (e) {
147147+ // Check if this is a 501 Method Not Implemented error
148148+ if (e.toString().contains('501 Method Not Implemented')) {
149149+ // For default labeler, return default definitions
150150+ if (did == 'did:plc:pbgyr67hftvpoqtvaurpsctc') {
151151+ final List<Map<String, dynamic>> definitions = [
152152+ {
153153+ 'value': 'spam',
154154+ 'identifier': 'spam',
155155+ 'blurs': 'content',
156156+ 'severity': 'inform',
157157+ 'defaultSetting': 'hide',
158158+ 'adultOnly': false,
159159+ 'locales': [
160160+ {
161161+ 'lang': 'en',
162162+ 'name': 'Spam',
163163+ 'description': 'Unwanted, repeated, or unrelated actions that bother users.',
164164+ },
165165+ ],
166166+ },
167167+ {
168168+ 'value': 'impersonation',
169169+ 'identifier': 'impersonation',
170170+ 'blurs': 'none',
171171+ 'severity': 'inform',
172172+ 'defaultSetting': 'hide',
173173+ 'adultOnly': false,
174174+ 'locales': [
175175+ {
176176+ 'lang': 'en',
177177+ 'name': 'Impersonation',
178178+ 'description': 'Pretending to be someone else without permission.',
179179+ },
180180+ ],
181181+ },
182182+ {
183183+ 'value': 'scam',
184184+ 'identifier': 'scam',
185185+ 'blurs': 'content',
186186+ 'severity': 'alert',
187187+ 'defaultSetting': 'hide',
188188+ 'adultOnly': false,
189189+ 'locales': [
190190+ {
191191+ 'lang': 'en',
192192+ 'name': 'Scam',
193193+ 'description': 'Scams, phishing & fraud.',
194194+ },
195195+ ],
196196+ },
197197+ {
198198+ 'value': 'intolerant',
199199+ 'identifier': 'intolerant',
200200+ 'blurs': 'content',
201201+ 'severity': 'alert',
202202+ 'defaultSetting': 'warn',
203203+ 'adultOnly': false,
204204+ 'locales': [
205205+ {
206206+ 'lang': 'en',
207207+ 'name': 'Intolerance',
208208+ 'description': 'Discrimination against protected groups.',
209209+ },
210210+ ],
211211+ },
212212+ {
213213+ 'value': 'self-harm',
214214+ 'identifier': 'self-harm',
215215+ 'blurs': 'content',
216216+ 'severity': 'alert',
217217+ 'defaultSetting': 'warn',
218218+ 'adultOnly': false,
219219+ 'locales': [
220220+ {
221221+ 'lang': 'en',
222222+ 'name': 'Self-Harm',
223223+ 'description': 'Promotes self-harm, including graphic images, glorifying discussions, or triggering stories.',
224224+ },
225225+ ],
226226+ },
227227+ {
228228+ 'value': 'security',
229229+ 'identifier': 'security',
230230+ 'blurs': 'content',
231231+ 'severity': 'alert',
232232+ 'defaultSetting': 'hide',
233233+ 'adultOnly': false,
234234+ 'locales': [
235235+ {
236236+ 'lang': 'en',
237237+ 'name': 'Security Concerns',
238238+ 'description': 'May be unsafe and could harm your device, steal your info, or get your account hacked.',
239239+ },
240240+ ],
241241+ },
242242+ {
243243+ 'value': 'misleading',
244244+ 'identifier': 'misleading',
245245+ 'blurs': 'content',
246246+ 'severity': 'alert',
247247+ 'defaultSetting': 'warn',
248248+ 'adultOnly': false,
249249+ 'locales': [
250250+ {
251251+ 'lang': 'en',
252252+ 'name': 'Misleading',
253253+ 'description': 'Altered images/videos, deceptive links, or false statements.',
254254+ },
255255+ ],
256256+ },
257257+ {
258258+ 'value': 'threat',
259259+ 'identifier': 'threat',
260260+ 'blurs': 'content',
261261+ 'severity': 'inform',
262262+ 'defaultSetting': 'hide',
263263+ 'adultOnly': false,
264264+ 'locales': [
265265+ {
266266+ 'lang': 'en',
267267+ 'name': 'Threats',
268268+ 'description': 'Promotes violence or harm towards others, including threats, incitement, or advocacy of harm.',
269269+ },
270270+ ],
271271+ },
272272+ {
273273+ 'value': 'unsafe-link',
274274+ 'identifier': 'unsafe-link',
275275+ 'blurs': 'content',
276276+ 'severity': 'alert',
277277+ 'defaultSetting': 'hide',
278278+ 'adultOnly': false,
279279+ 'locales': [
280280+ {
281281+ 'lang': 'en',
282282+ 'name': 'Unsafe link',
283283+ 'description': 'Links to harmful sites with malware, phishing, or violating content that risk security and privacy.',
284284+ },
285285+ ],
286286+ },
287287+ {
288288+ 'value': 'illicit',
289289+ 'identifier': 'illicit',
290290+ 'blurs': 'content',
291291+ 'severity': 'alert',
292292+ 'defaultSetting': 'hide',
293293+ 'adultOnly': false,
294294+ 'locales': [
295295+ {
296296+ 'lang': 'en',
297297+ 'name': 'Illicit',
298298+ 'description': 'Promoting or selling potentially illicit goods, services, or activities.',
299299+ },
300300+ ],
301301+ },
302302+ {
303303+ 'value': 'misinformation',
304304+ 'identifier': 'misinformation',
305305+ 'blurs': 'content',
306306+ 'severity': 'inform',
307307+ 'defaultSetting': 'warn',
308308+ 'adultOnly': false,
309309+ 'locales': [
310310+ {
311311+ 'lang': 'en',
312312+ 'name': 'Misinformation',
313313+ 'description': 'Spreading false or misleading info, including unverified claims and harmful conspiracy theories.',
314314+ },
315315+ ],
316316+ },
317317+ {
318318+ 'value': 'rumor',
319319+ 'identifier': 'rumor',
320320+ 'blurs': 'content',
321321+ 'severity': 'inform',
322322+ 'defaultSetting': 'warn',
323323+ 'adultOnly': false,
324324+ 'locales': [
325325+ {
326326+ 'lang': 'en',
327327+ 'name': 'Rumor',
328328+ 'description': 'Approach with caution, as these claims lack evidence from credible sources.',
329329+ },
330330+ ],
331331+ },
332332+ {
333333+ 'value': 'rude',
334334+ 'identifier': 'rude',
335335+ 'blurs': 'content',
336336+ 'severity': 'inform',
337337+ 'defaultSetting': 'hide',
338338+ 'adultOnly': false,
339339+ 'locales': [
340340+ {
341341+ 'lang': 'en',
342342+ 'name': 'Rude',
343343+ 'description': 'Rude or impolite, including crude language and disrespectful comments, without constructive purpose.',
344344+ },
345345+ ],
346346+ },
347347+ {
348348+ 'value': 'extremist',
349349+ 'identifier': 'extremist',
350350+ 'blurs': 'content',
351351+ 'severity': 'alert',
352352+ 'defaultSetting': 'hide',
353353+ 'adultOnly': false,
354354+ 'locales': [
355355+ {
356356+ 'lang': 'en',
357357+ 'name': 'Extremist',
358358+ 'description': 'Radical views advocating violence, hate, or discrimination against individuals or groups.',
359359+ },
360360+ ],
361361+ },
362362+ {
363363+ 'value': 'sensitive',
364364+ 'identifier': 'sensitive',
365365+ 'blurs': 'content',
366366+ 'severity': 'alert',
367367+ 'defaultSetting': 'warn',
368368+ 'adultOnly': false,
369369+ 'locales': [
370370+ {
371371+ 'lang': 'en',
372372+ 'name': 'Sensitive',
373373+ 'description': 'May be upsetting, covering topics like substance abuse or mental health issues, cautioning sensitive viewers.',
374374+ },
375375+ ],
376376+ },
377377+ {
378378+ 'value': 'engagement-farming',
379379+ 'identifier': 'engagement-farming',
380380+ 'blurs': 'content',
381381+ 'severity': 'alert',
382382+ 'defaultSetting': 'hide',
383383+ 'adultOnly': false,
384384+ 'locales': [
385385+ {
386386+ 'lang': 'en',
387387+ 'name': 'Engagement Farming',
388388+ 'description': 'Insincere content or bulk actions aimed at gaining followers, including frequent follows, posts, and likes.',
389389+ },
390390+ ],
391391+ },
392392+ {
393393+ 'value': 'inauthentic',
394394+ 'identifier': 'inauthentic',
395395+ 'blurs': 'content',
396396+ 'severity': 'alert',
397397+ 'defaultSetting': 'hide',
398398+ 'adultOnly': false,
399399+ 'locales': [
400400+ {
401401+ 'lang': 'en',
402402+ 'name': 'Inauthentic Account',
403403+ 'description': 'Bot or a person pretending to be someone else.',
404404+ },
405405+ ],
406406+ },
407407+ {
408408+ 'value': 'sexual-figurative',
409409+ 'identifier': 'sexual-figurative',
410410+ 'blurs': 'media',
411411+ 'severity': 'none',
412412+ 'defaultSetting': 'show',
413413+ 'adultOnly': true,
414414+ 'locales': [
415415+ {
416416+ 'lang': 'en',
417417+ 'name': 'Sexually Suggestive (Cartoon)',
418418+ 'description': 'Art with explicit or suggestive sexual themes, including provocative imagery or partial nudity.',
419419+ },
420420+ ],
421421+ },
422422+ {
423423+ 'value': 'porn',
424424+ 'identifier': 'porn',
425425+ 'blurs': 'content',
426426+ 'severity': 'alert',
427427+ 'defaultSetting': 'hide',
428428+ 'adultOnly': true,
429429+ 'locales': [
430430+ {
431431+ 'lang': 'en',
432432+ 'name': 'Explicit Content',
433433+ 'description': 'Pornographic or sexually explicit material',
434434+ },
435435+ ],
436436+ },
437437+ {
438438+ 'value': 'nudity',
439439+ 'identifier': 'nudity',
440440+ 'blurs': 'content',
441441+ 'severity': 'alert',
442442+ 'defaultSetting': 'warn',
443443+ 'adultOnly': true,
444444+ 'locales': [
445445+ {
446446+ 'lang': 'en',
447447+ 'name': 'Nudity',
448448+ 'description': 'Content containing nudity',
449449+ },
450450+ ],
451451+ },
452452+ {
453453+ 'value': 'sexual',
454454+ 'identifier': 'sexual',
455455+ 'blurs': 'content',
456456+ 'severity': 'alert',
457457+ 'defaultSetting': 'warn',
458458+ 'adultOnly': true,
459459+ 'locales': [
460460+ {
461461+ 'lang': 'en',
462462+ 'name': 'Sexual Content',
463463+ 'description': 'Content of a sexual nature',
464464+ },
465465+ ],
466466+ },
467467+ {
468468+ 'value': 'graphic-media',
469469+ 'identifier': 'graphic-media',
470470+ 'blurs': 'content',
471471+ 'severity': 'alert',
472472+ 'defaultSetting': 'warn',
473473+ 'adultOnly': false,
474474+ 'locales': [
475475+ {
476476+ 'lang': 'en',
477477+ 'name': 'Graphic Content',
478478+ 'description': 'Disturbing or graphic imagery',
479479+ },
480480+ ],
481481+ },
482482+ ];
483483+484484+ labelValueDefinitions = definitions;
485485+ return definitions;
486486+ }
487487+ }
488488+ throw Exception('Error fetching label definitions: $e');
489489+ }
490490+ }
491491+492492+ /// Gets metadata about the labeler
493493+ ///
494494+ /// Returns information such as name, description, avatar, and associated URLs
495495+ Future<Map<String, dynamic>> getLabelerInfo() async {
496496+ final client = _atproto;
497497+ if (client == null) {
498498+ throw Exception('ATProto client not available');
499499+ }
500500+501501+ try {
502502+ // Configure header to use the proxy for the labeler
503503+ final Map<String, String> headers = {};
504504+ if (did != null) {
505505+ headers['atproto-proxy'] = '$did#atproto_labeler';
506506+ }
507507+508508+ try {
509509+ final responseData = await client.get(
510510+ NSID.parse('com.atproto.label.getLabelerInfo'),
511511+ headers: headers,
512512+ to: (json) => json as Map<String, dynamic>,
513513+ adaptor: (uint8) => jsonDecode(utf8.decode(uint8)),
514514+ );
515515+516516+ return responseData.data;
517517+ } catch (apiError) {
518518+ // Check if this is a 501 Method Not Implemented error
519519+ if (apiError.toString().contains('501 Method Not Implemented')) {
520520+ // Fallback for default labeler
521521+ if (did == 'did:plc:pbgyr67hftvpoqtvaurpsctc') {
522522+ return {
523523+ 'did': did,
524524+ 'displayName': 'Default Labeler',
525525+ 'description': 'System default content labeler'
526526+ };
527527+ } else {
528528+ // Generic fallback for other labelers
529529+ return {
530530+ 'did': did,
531531+ 'displayName': 'Labeler ${did?.substring(0, 10)}...',
532532+ 'description': 'Content labeler'
533533+ };
534534+ }
535535+ }
536536+ // For other API errors, rethrow
537537+ rethrow;
538538+ }
539539+ } catch (e) {
540540+ throw Exception('Error fetching labeler info: $e');
541541+ }
542542+ }
543543+544544+ /// Get all available labels from this labeler with their definitions
545545+ ///
546546+ /// Returns a map of label values to their definitions
547547+ Future<Map<String, Map<String, dynamic>>> getAllLabelsWithDefinitions() async {
548548+ // Create a map of label values to their definitions
549549+ final Map<String, Map<String, dynamic>> result = {};
550550+551551+ try {
552552+ // Try to fetch the latest values and definitions
553553+ try {
554554+ await fetchLabelValues();
555555+ await fetchLabelValueDefinitions();
556556+557557+ for (final definition in labelValueDefinitions) {
558558+ final String value = definition['value'] as String;
559559+ result[value] = definition;
560560+ }
561561+ } catch (apiError) {
562562+ // If we can't fetch (501 or other API errors), use fallbacks for default labeler
563563+ if (did == 'did:plc:pbgyr67hftvpoqtvaurpsctc') {
564564+ // Default fallback labels for the default labeler
565565+ _addDefaultLabels(result);
566566+ }
567567+ }
568568+569569+ return result;
570570+ } catch (e) {
571571+ // Final fallback if everything fails for the default labeler
572572+ if (did == 'did:plc:pbgyr67hftvpoqtvaurpsctc') {
573573+ _addDefaultLabels(result);
574574+ }
575575+576576+ return result;
577577+ }
578578+ }
579579+580580+ /// Adds default label definitions as a fallback
581581+ void _addDefaultLabels(Map<String, Map<String, dynamic>> result) {
582582+ result['spam'] = {
583583+ 'value': 'spam',
584584+ 'identifier': 'spam',
585585+ 'blurs': 'content',
586586+ 'severity': 'inform',
587587+ 'defaultSetting': 'hide',
588588+ 'adultOnly': false,
589589+ 'locales': [
590590+ {
591591+ 'lang': 'en',
592592+ 'name': 'Spam',
593593+ 'description': 'Unwanted, repeated, or unrelated actions that bother users.',
594594+ },
595595+ ],
596596+ 'displayName': 'Spam',
597597+ 'description': 'Unwanted, repeated, or unrelated actions that bother users.'
598598+ };
599599+ result['impersonation'] = {
600600+ 'value': 'impersonation',
601601+ 'identifier': 'impersonation',
602602+ 'blurs': 'none',
603603+ 'severity': 'inform',
604604+ 'defaultSetting': 'hide',
605605+ 'adultOnly': false,
606606+ 'locales': [
607607+ {
608608+ 'lang': 'en',
609609+ 'name': 'Impersonation',
610610+ 'description': 'Pretending to be someone else without permission.',
611611+ },
612612+ ],
613613+ 'displayName': 'Impersonation',
614614+ 'description': 'Pretending to be someone else without permission.'
615615+ };
616616+ result['scam'] = {
617617+ 'value': 'scam',
618618+ 'identifier': 'scam',
619619+ 'blurs': 'content',
620620+ 'severity': 'alert',
621621+ 'defaultSetting': 'hide',
622622+ 'adultOnly': false,
623623+ 'locales': [
624624+ {
625625+ 'lang': 'en',
626626+ 'name': 'Scam',
627627+ 'description': 'Scams, phishing & fraud.',
628628+ },
629629+ ],
630630+ 'displayName': 'Scam',
631631+ 'description': 'Scams, phishing & fraud.'
632632+ };
633633+ result['intolerant'] = {
634634+ 'value': 'intolerant',
635635+ 'identifier': 'intolerant',
636636+ 'blurs': 'content',
637637+ 'severity': 'alert',
638638+ 'defaultSetting': 'warn',
639639+ 'adultOnly': false,
640640+ 'locales': [
641641+ {
642642+ 'lang': 'en',
643643+ 'name': 'Intolerance',
644644+ 'description': 'Discrimination against protected groups.',
645645+ },
646646+ ],
647647+ 'displayName': 'Intolerance',
648648+ 'description': 'Discrimination against protected groups.'
649649+ };
650650+ result['self-harm'] = {
651651+ 'value': 'self-harm',
652652+ 'identifier': 'self-harm',
653653+ 'blurs': 'content',
654654+ 'severity': 'alert',
655655+ 'defaultSetting': 'warn',
656656+ 'adultOnly': false,
657657+ 'locales': [
658658+ {
659659+ 'lang': 'en',
660660+ 'name': 'Self-Harm',
661661+ 'description': 'Promotes self-harm, including graphic images, glorifying discussions, or triggering stories.',
662662+ },
663663+ ],
664664+ 'displayName': 'Self-Harm',
665665+ 'description': 'Promotes self-harm, including graphic images, glorifying discussions, or triggering stories.'
666666+ };
667667+ result['security'] = {
668668+ 'value': 'security',
669669+ 'identifier': 'security',
670670+ 'blurs': 'content',
671671+ 'severity': 'alert',
672672+ 'defaultSetting': 'hide',
673673+ 'adultOnly': false,
674674+ 'locales': [
675675+ {
676676+ 'lang': 'en',
677677+ 'name': 'Security Concerns',
678678+ 'description': 'May be unsafe and could harm your device, steal your info, or get your account hacked.',
679679+ },
680680+ ],
681681+ 'displayName': 'Security Concerns',
682682+ 'description': 'May be unsafe and could harm your device, steal your info, or get your account hacked.'
683683+ };
684684+ result['misleading'] = {
685685+ 'value': 'misleading',
686686+ 'identifier': 'misleading',
687687+ 'blurs': 'content',
688688+ 'severity': 'alert',
689689+ 'defaultSetting': 'warn',
690690+ 'adultOnly': false,
691691+ 'locales': [
692692+ {
693693+ 'lang': 'en',
694694+ 'name': 'Misleading',
695695+ 'description': 'Altered images/videos, deceptive links, or false statements.',
696696+ },
697697+ ],
698698+ 'displayName': 'Misleading',
699699+ 'description': 'Altered images/videos, deceptive links, or false statements.'
700700+ };
701701+ result['threat'] = {
702702+ 'value': 'threat',
703703+ 'identifier': 'threat',
704704+ 'blurs': 'content',
705705+ 'severity': 'inform',
706706+ 'defaultSetting': 'hide',
707707+ 'adultOnly': false,
708708+ 'locales': [
709709+ {
710710+ 'lang': 'en',
711711+ 'name': 'Threats',
712712+ 'description': 'Promotes violence or harm towards others, including threats, incitement, or advocacy of harm.',
713713+ },
714714+ ],
715715+ 'displayName': 'Threats',
716716+ 'description': 'Promotes violence or harm towards others, including threats, incitement, or advocacy of harm.'
717717+ };
718718+ result['unsafe-link'] = {
719719+ 'value': 'unsafe-link',
720720+ 'identifier': 'unsafe-link',
721721+ 'blurs': 'content',
722722+ 'severity': 'alert',
723723+ 'defaultSetting': 'hide',
724724+ 'adultOnly': false,
725725+ 'locales': [
726726+ {
727727+ 'lang': 'en',
728728+ 'name': 'Unsafe link',
729729+ 'description': 'Links to harmful sites with malware, phishing, or violating content that risk security and privacy.',
730730+ },
731731+ ],
732732+ 'displayName': 'Unsafe link',
733733+ 'description': 'Links to harmful sites with malware, phishing, or violating content that risk security and privacy.'
734734+ };
735735+ result['illicit'] = {
736736+ 'value': 'illicit',
737737+ 'identifier': 'illicit',
738738+ 'blurs': 'content',
739739+ 'severity': 'alert',
740740+ 'defaultSetting': 'hide',
741741+ 'adultOnly': false,
742742+ 'locales': [
743743+ {
744744+ 'lang': 'en',
745745+ 'name': 'Illicit',
746746+ 'description': 'Promoting or selling potentially illicit goods, services, or activities.',
747747+ },
748748+ ],
749749+ 'displayName': 'Illicit',
750750+ 'description': 'Promoting or selling potentially illicit goods, services, or activities.'
751751+ };
752752+ result['misinformation'] = {
753753+ 'value': 'misinformation',
754754+ 'identifier': 'misinformation',
755755+ 'blurs': 'content',
756756+ 'severity': 'inform',
757757+ 'defaultSetting': 'warn',
758758+ 'adultOnly': false,
759759+ 'locales': [
760760+ {
761761+ 'lang': 'en',
762762+ 'name': 'Misinformation',
763763+ 'description': 'Spreading false or misleading info, including unverified claims and harmful conspiracy theories.',
764764+ },
765765+ ],
766766+ 'displayName': 'Misinformation',
767767+ 'description': 'Spreading false or misleading info, including unverified claims and harmful conspiracy theories.'
768768+ };
769769+ result['rumor'] = {
770770+ 'value': 'rumor',
771771+ 'identifier': 'rumor',
772772+ 'blurs': 'content',
773773+ 'severity': 'inform',
774774+ 'defaultSetting': 'warn',
775775+ 'adultOnly': false,
776776+ 'locales': [
777777+ {
778778+ 'lang': 'en',
779779+ 'name': 'Rumor',
780780+ 'description': 'Approach with caution, as these claims lack evidence from credible sources.',
781781+ },
782782+ ],
783783+ 'displayName': 'Rumor',
784784+ 'description': 'Approach with caution, as these claims lack evidence from credible sources.'
785785+ };
786786+ result['rude'] = {
787787+ 'value': 'rude',
788788+ 'identifier': 'rude',
789789+ 'blurs': 'content',
790790+ 'severity': 'inform',
791791+ 'defaultSetting': 'hide',
792792+ 'adultOnly': false,
793793+ 'locales': [
794794+ {
795795+ 'lang': 'en',
796796+ 'name': 'Rude',
797797+ 'description': 'Rude or impolite, including crude language and disrespectful comments, without constructive purpose.',
798798+ },
799799+ ],
800800+ 'displayName': 'Rude',
801801+ 'description': 'Rude or impolite, including crude language and disrespectful comments, without constructive purpose.'
802802+ };
803803+ result['extremist'] = {
804804+ 'value': 'extremist',
805805+ 'identifier': 'extremist',
806806+ 'blurs': 'content',
807807+ 'severity': 'alert',
808808+ 'defaultSetting': 'hide',
809809+ 'adultOnly': false,
810810+ 'locales': [
811811+ {
812812+ 'lang': 'en',
813813+ 'name': 'Extremist',
814814+ 'description': 'Radical views advocating violence, hate, or discrimination against individuals or groups.',
815815+ },
816816+ ],
817817+ 'displayName': 'Extremist',
818818+ 'description': 'Radical views advocating violence, hate, or discrimination against individuals or groups.'
819819+ };
820820+ result['sensitive'] = {
821821+ 'value': 'sensitive',
822822+ 'identifier': 'sensitive',
823823+ 'blurs': 'content',
824824+ 'severity': 'alert',
825825+ 'defaultSetting': 'warn',
826826+ 'adultOnly': false,
827827+ 'locales': [
828828+ {
829829+ 'lang': 'en',
830830+ 'name': 'Sensitive',
831831+ 'description': 'May be upsetting, covering topics like substance abuse or mental health issues, cautioning sensitive viewers.',
832832+ },
833833+ ],
834834+ 'displayName': 'Sensitive',
835835+ 'description': 'May be upsetting, covering topics like substance abuse or mental health issues, cautioning sensitive viewers.'
836836+ };
837837+ result['engagement-farming'] = {
838838+ 'value': 'engagement-farming',
839839+ 'identifier': 'engagement-farming',
840840+ 'blurs': 'content',
841841+ 'severity': 'alert',
842842+ 'defaultSetting': 'hide',
843843+ 'adultOnly': false,
844844+ 'locales': [
845845+ {
846846+ 'lang': 'en',
847847+ 'name': 'Engagement Farming',
848848+ 'description': 'Insincere content or bulk actions aimed at gaining followers, including frequent follows, posts, and likes.',
849849+ },
850850+ ],
851851+ 'displayName': 'Engagement Farming',
852852+ 'description': 'Insincere content or bulk actions aimed at gaining followers, including frequent follows, posts, and likes.'
853853+ };
854854+ result['inauthentic'] = {
855855+ 'value': 'inauthentic',
856856+ 'identifier': 'inauthentic',
857857+ 'blurs': 'content',
858858+ 'severity': 'alert',
859859+ 'defaultSetting': 'hide',
860860+ 'adultOnly': false,
861861+ 'locales': [
862862+ {
863863+ 'lang': 'en',
864864+ 'name': 'Inauthentic Account',
865865+ 'description': 'Bot or a person pretending to be someone else.',
866866+ },
867867+ ],
868868+ 'displayName': 'Inauthentic Account',
869869+ 'description': 'Bot or a person pretending to be someone else.'
870870+ };
871871+ result['sexual-figurative'] = {
872872+ 'value': 'sexual-figurative',
873873+ 'identifier': 'sexual-figurative',
874874+ 'blurs': 'media',
875875+ 'severity': 'none',
876876+ 'defaultSetting': 'show',
877877+ 'adultOnly': true,
878878+ 'locales': [
879879+ {
880880+ 'lang': 'en',
881881+ 'name': 'Sexually Suggestive (Cartoon)',
882882+ 'description': 'Art with explicit or suggestive sexual themes, including provocative imagery or partial nudity.',
883883+ },
884884+ ],
885885+ 'displayName': 'Sexually Suggestive (Cartoon)',
886886+ 'description': 'Art with explicit or suggestive sexual themes, including provocative imagery or partial nudity.'
887887+ };
888888+ result['porn'] = {
889889+ 'value': 'porn',
890890+ 'identifier': 'porn',
891891+ 'blurs': 'content',
892892+ 'severity': 'alert',
893893+ 'defaultSetting': 'hide',
894894+ 'adultOnly': true,
895895+ 'locales': [
896896+ {
897897+ 'lang': 'en',
898898+ 'name': 'Explicit Content',
899899+ 'description': 'Pornographic or sexually explicit material',
900900+ },
901901+ ],
902902+ 'displayName': 'Explicit Content',
903903+ 'description': 'Pornographic or sexually explicit material'
904904+ };
905905+ result['nudity'] = {
906906+ 'value': 'nudity',
907907+ 'identifier': 'nudity',
908908+ 'blurs': 'content',
909909+ 'severity': 'alert',
910910+ 'defaultSetting': 'warn',
911911+ 'adultOnly': true,
912912+ 'locales': [
913913+ {
914914+ 'lang': 'en',
915915+ 'name': 'Nudity',
916916+ 'description': 'Content containing nudity',
917917+ },
918918+ ],
919919+ 'displayName': 'Nudity',
920920+ 'description': 'Content containing nudity'
921921+ };
922922+ result['sexual'] = {
923923+ 'value': 'sexual',
924924+ 'identifier': 'sexual',
925925+ 'blurs': 'content',
926926+ 'severity': 'alert',
927927+ 'defaultSetting': 'warn',
928928+ 'adultOnly': true,
929929+ 'locales': [
930930+ {
931931+ 'lang': 'en',
932932+ 'name': 'Sexual Content',
933933+ 'description': 'Content of a sexual nature',
934934+ },
935935+ ],
936936+ 'displayName': 'Sexual Content',
937937+ 'description': 'Content of a sexual nature'
938938+ };
939939+ result['graphic-media'] = {
940940+ 'value': 'graphic-media',
941941+ 'identifier': 'graphic-media',
942942+ 'blurs': 'content',
943943+ 'severity': 'alert',
944944+ 'defaultSetting': 'warn',
945945+ 'adultOnly': true,
946946+ 'locales': [
947947+ {
948948+ 'lang': 'en',
949949+ 'name': 'Graphic Content',
950950+ 'description': 'Disturbing or graphic imagery',
951951+ },
952952+ ],
953953+ 'displayName': 'Graphic Content',
954954+ 'description': 'Disturbing or graphic imagery'
955955+ };
956956+ }
957957+958958+ /// Find labels relevant to the provided AT-URI patterns
959959+ ///
960960+ /// [uriPatterns] List of AT URI patterns to match (boolean 'OR').
961961+ /// Each may be a prefix (ending with '*') or a full URI.
962962+ /// [sources] Optional list of label sources (DIDs) to filter on.
963963+ /// [limit] Results limit (1-250, default 50).
964964+ /// [cursor] Optional cursor for pagination.
965965+ Future<List<String>> queryLabels({
966966+ required List<String> uriPatterns,
967967+ List<String>? sources,
968968+ int limit = 50,
969969+ String? cursor,
970970+ }) async {
971971+ final client = _atproto;
972972+ if (client == null) {
973973+ throw Exception('ATProto client not available');
974974+ }
975975+976976+ try {
977977+ // Configure header to use the proxy for the labeler
978978+ final Map<String, String> headers = {};
979979+ if (did != null) {
980980+ headers['atproto-proxy'] = '$did#atproto_labeler';
981981+ }
982982+983983+ // Prepare parameters
984984+ final Map<String, dynamic> parameters = {
985985+ 'uriPatterns': uriPatterns,
986986+ };
987987+988988+ if (sources != null && sources.isNotEmpty) {
989989+ parameters['sources'] = sources;
990990+ }
991991+992992+ if (limit != 50) {
993993+ parameters['limit'] = limit;
994994+ }
995995+996996+ if (cursor != null) {
997997+ parameters['cursor'] = cursor;
998998+ }
999999+10001000+ final responseData = await client.get(
10011001+ NSID.parse('com.atproto.label.queryLabels'),
10021002+ parameters: parameters,
10031003+ headers: headers,
10041004+ to: (json) => json as Map<String, dynamic>,
10051005+ adaptor: (uint8) => jsonDecode(utf8.decode(uint8)),
10061006+ );
10071007+10081008+ // Extract only the "val" values from labels
10091009+ final labels = List<Map<String, dynamic>>.from(responseData.data['labels'] ?? []);
10101010+ return labels.map((label) => label['val'] as String).toList();
10111011+ } catch (e) {
10121012+ throw Exception('Error fetching labels: $e');
10131013+ }
10141014+ }
10151015+10161016+ /// Get full label data for the provided AT-URI patterns
10171017+ Future<Map<String, List<Map<String, dynamic>>>> getLabelsWithDetails({
10181018+ required List<String> uriPatterns,
10191019+ List<String>? sources,
10201020+ int limit = 50,
10211021+ String? cursor,
10221022+ }) async {
10231023+ final client = _atproto;
10241024+ if (client == null) {
10251025+ throw Exception('ATProto client not available');
10261026+ }
10271027+10281028+ try {
10291029+ // Configure header to use the proxy for the labeler
10301030+ final Map<String, String> headers = {};
10311031+ if (did != null) {
10321032+ headers['atproto-proxy'] = '$did#atproto_labeler';
10331033+ }
10341034+10351035+ // Prepare parameters
10361036+ final Map<String, dynamic> parameters = {
10371037+ 'uriPatterns': uriPatterns,
10381038+ };
10391039+10401040+ if (sources != null && sources.isNotEmpty) {
10411041+ parameters['sources'] = sources;
10421042+ }
10431043+10441044+ if (limit != 50) {
10451045+ parameters['limit'] = limit;
10461046+ }
10471047+10481048+ if (cursor != null) {
10491049+ parameters['cursor'] = cursor;
10501050+ }
10511051+10521052+ final responseData = await client.get(
10531053+ NSID.parse('com.atproto.label.queryLabels'),
10541054+ parameters: parameters,
10551055+ headers: headers,
10561056+ to: (json) => json as Map<String, dynamic>,
10571057+ adaptor: (uint8) => jsonDecode(utf8.decode(uint8)),
10581058+ );
10591059+10601060+ // Group labels by URI
10611061+ final labels = List<Map<String, dynamic>>.from(responseData.data['labels'] ?? []);
10621062+ final Map<String, List<Map<String, dynamic>>> labelsByUri = {};
10631063+10641064+ for (final label in labels) {
10651065+ final postUri = label['uri'] as String;
10661066+ labelsByUri[postUri] ??= [];
10671067+ labelsByUri[postUri]!.add(label);
10681068+ }
10691069+10701070+ return labelsByUri;
10711071+ } catch (e) {
10721072+ throw Exception('Error fetching label details: $e');
10731073+ }
10741074+ }
10751075+}
+676
lib/services/labeler_manager.dart
···11+import 'package:flutter/material.dart';
22+import 'auth_service.dart';
33+import 'label_service.dart';
44+import 'settings_service.dart';
55+66+/// Manages labelers and their preferences
77+class LabelerManager extends ChangeNotifier {
88+ final AuthService _authService;
99+ final SettingsService _settingsService;
1010+1111+ // Cache of labeler details: {labelerDid: {name, description, etc.}}
1212+ final Map<String, Map<String, dynamic>> _labelerDetails = {};
1313+1414+ // Cache of labels and definitions: {labelerDid: {labelValue: definitionMap}}
1515+ final Map<String, Map<String, Map<String, dynamic>>> _labelDefinitions = {};
1616+1717+ // Indicates if we're loading data
1818+ bool _isLoading = false;
1919+2020+ // Default labeler DID - used when no other labelers are configured
2121+ static const String defaultLabelerDid = "did:plc:pbgyr67hftvpoqtvaurpsctc";
2222+2323+ LabelerManager(this._authService, this._settingsService);
2424+2525+ /// Indicates if we're loading data
2626+ bool get isLoading => _isLoading;
2727+2828+ /// Returns a list of DIDs of followed labelers
2929+ List<String> get followedLabelers {
3030+ final labelers = _settingsService.followedLabelers;
3131+ // If no labelers are configured, use the default labeler
3232+ if (labelers.isEmpty) {
3333+ return [defaultLabelerDid];
3434+ }
3535+ return labelers;
3636+ }
3737+3838+ /// Returns details of a specific labeler (null if not available)
3939+ Map<String, dynamic>? getLabelerDetails(String labelerDid) {
4040+ return _labelerDetails[labelerDid];
4141+ }
4242+4343+ /// Returns the label definitions for a specific labeler (empty if not available)
4444+ Map<String, Map<String, dynamic>> getLabelDefinitions(String labelerDid) {
4545+ return _labelDefinitions[labelerDid] ?? {};
4646+ }
4747+4848+ /// Gets a preference for a specific label
4949+ LabelPreference? getLabelPreference(String labelerDid, String labelValue) {
5050+ return _settingsService.getLabelPreference(labelerDid, labelValue);
5151+ }
5252+5353+ /// Sets a preference for a specific label
5454+ Future<void> setLabelPreference(
5555+ String labelerDid,
5656+ String labelValue,
5757+ LabelPreference preference
5858+ ) async {
5959+ await _settingsService.setLabelPreference(labelerDid, labelValue, preference);
6060+ notifyListeners();
6161+ }
6262+6363+ /// Follows a new labeler
6464+ Future<void> followLabeler(String labelerDid) async {
6565+ await _settingsService.addFollowedLabeler(labelerDid);
6666+ await loadLabelerData(labelerDid);
6767+ notifyListeners();
6868+ }
6969+7070+ /// Unfollows a labeler
7171+ Future<void> unfollowLabeler(String labelerDid) async {
7272+ // Don't allow unfollowing the default labeler
7373+ if (labelerDid == defaultLabelerDid) {
7474+ return;
7575+ }
7676+7777+ await _settingsService.removeFollowedLabeler(labelerDid);
7878+ // Remove from caches
7979+ _labelerDetails.remove(labelerDid);
8080+ _labelDefinitions.remove(labelerDid);
8181+ notifyListeners();
8282+ }
8383+8484+ /// Loads data for a specific labeler (details and label definitions)
8585+ Future<void> loadLabelerData(String labelerDid) async {
8686+ // Store current loading state
8787+ final wasLoading = _isLoading;
8888+8989+ // If we weren't already loading, update loading state and notify
9090+ if (!wasLoading) {
9191+ _isLoading = true;
9292+ // Use Future.microtask to avoid calling setState during build
9393+ Future.microtask(() => notifyListeners());
9494+ }
9595+9696+ try {
9797+ // Get service for this labeler
9898+ final labelService = LabelService.forLabeler(_authService, labelerDid);
9999+100100+ // Try to load label definitions even if labeler info fails
101101+ try {
102102+ // Load labeler information
103103+ final labelerInfo = await labelService.getLabelerInfo();
104104+ _labelerDetails[labelerDid] = labelerInfo;
105105+ } catch (e) {
106106+ debugPrint('Error loading labeler info: $e');
107107+ // Fallback for labeler info
108108+ _labelerDetails[labelerDid] = {
109109+ 'displayName': 'Labeler $labelerDid',
110110+ 'description': 'Content labeler'
111111+ };
112112+ }
113113+114114+ try {
115115+ // Load label definitions
116116+ final labelDefs = await labelService.getAllLabelsWithDefinitions();
117117+ _labelDefinitions[labelerDid] = labelDefs;
118118+ } catch (e) {
119119+ debugPrint('Error loading label definitions: $e');
120120+ // For the default labeler, use a fallback if we can't load data
121121+ if (labelerDid == defaultLabelerDid) {
122122+ _createDefaultLabelerFallback();
123123+ }
124124+ }
125125+ } catch (e) {
126126+ debugPrint('Error loading labeler data $labelerDid: $e');
127127+128128+ // For the default labeler, use a fallback if we can't load data
129129+ if (labelerDid == defaultLabelerDid) {
130130+ _createDefaultLabelerFallback();
131131+ }
132132+ } finally {
133133+ _isLoading = false;
134134+135135+ // Use Future.microtask to avoid calling setState during build
136136+ Future.microtask(() => notifyListeners());
137137+ }
138138+ }
139139+140140+ /// Creates fallback data for the default labeler if we can't load the real data
141141+ void _createDefaultLabelerFallback() {
142142+ _labelerDetails[defaultLabelerDid] = {
143143+ 'displayName': 'Default Labeler',
144144+ 'description': 'System default content labeler'
145145+ };
146146+147147+ _labelDefinitions[defaultLabelerDid] = {
148148+ 'spam': {
149149+ 'value': 'spam',
150150+ 'identifier': 'spam',
151151+ 'blurs': 'content',
152152+ 'severity': 'inform',
153153+ 'defaultSetting': 'hide',
154154+ 'adultOnly': false,
155155+ 'locales': [
156156+ {
157157+ 'lang': 'en',
158158+ 'name': 'Spam',
159159+ 'description': 'Unwanted, repeated, or unrelated actions that bother users.',
160160+ },
161161+ ],
162162+ 'displayName': 'Spam',
163163+ 'description': 'Unwanted, repeated, or unrelated actions that bother users.'
164164+ },
165165+ 'impersonation': {
166166+ 'value': 'impersonation',
167167+ 'identifier': 'impersonation',
168168+ 'blurs': 'none',
169169+ 'severity': 'inform',
170170+ 'defaultSetting': 'hide',
171171+ 'adultOnly': false,
172172+ 'locales': [
173173+ {
174174+ 'lang': 'en',
175175+ 'name': 'Impersonation',
176176+ 'description': 'Pretending to be someone else without permission.',
177177+ },
178178+ ],
179179+ 'displayName': 'Impersonation',
180180+ 'description': 'Pretending to be someone else without permission.'
181181+ },
182182+ 'scam': {
183183+ 'value': 'scam',
184184+ 'identifier': 'scam',
185185+ 'blurs': 'content',
186186+ 'severity': 'alert',
187187+ 'defaultSetting': 'hide',
188188+ 'adultOnly': false,
189189+ 'locales': [
190190+ {
191191+ 'lang': 'en',
192192+ 'name': 'Scam',
193193+ 'description': 'Scams, phishing & fraud.',
194194+ },
195195+ ],
196196+ 'displayName': 'Scam',
197197+ 'description': 'Scams, phishing & fraud.'
198198+ },
199199+ 'intolerant': {
200200+ 'value': 'intolerant',
201201+ 'identifier': 'intolerant',
202202+ 'blurs': 'content',
203203+ 'severity': 'alert',
204204+ 'defaultSetting': 'warn',
205205+ 'adultOnly': false,
206206+ 'locales': [
207207+ {
208208+ 'lang': 'en',
209209+ 'name': 'Intolerance',
210210+ 'description': 'Discrimination against protected groups.',
211211+ },
212212+ ],
213213+ 'displayName': 'Intolerance',
214214+ 'description': 'Discrimination against protected groups.'
215215+ },
216216+ 'self-harm': {
217217+ 'value': 'self-harm',
218218+ 'identifier': 'self-harm',
219219+ 'blurs': 'content',
220220+ 'severity': 'alert',
221221+ 'defaultSetting': 'warn',
222222+ 'adultOnly': false,
223223+ 'locales': [
224224+ {
225225+ 'lang': 'en',
226226+ 'name': 'Self-Harm',
227227+ 'description': 'Promotes self-harm, including graphic images, glorifying discussions, or triggering stories.',
228228+ },
229229+ ],
230230+ 'displayName': 'Self-Harm',
231231+ 'description': 'Promotes self-harm, including graphic images, glorifying discussions, or triggering stories.'
232232+ },
233233+ 'security': {
234234+ 'value': 'security',
235235+ 'identifier': 'security',
236236+ 'blurs': 'content',
237237+ 'severity': 'alert',
238238+ 'defaultSetting': 'hide',
239239+ 'adultOnly': false,
240240+ 'locales': [
241241+ {
242242+ 'lang': 'en',
243243+ 'name': 'Security Concerns',
244244+ 'description': 'May be unsafe and could harm your device, steal your info, or get your account hacked.',
245245+ },
246246+ ],
247247+ 'displayName': 'Security Concerns',
248248+ 'description': 'May be unsafe and could harm your device, steal your info, or get your account hacked.'
249249+ },
250250+ 'misleading': {
251251+ 'value': 'misleading',
252252+ 'identifier': 'misleading',
253253+ 'blurs': 'content',
254254+ 'severity': 'alert',
255255+ 'defaultSetting': 'warn',
256256+ 'adultOnly': false,
257257+ 'locales': [
258258+ {
259259+ 'lang': 'en',
260260+ 'name': 'Misleading',
261261+ 'description': 'Altered images/videos, deceptive links, or false statements.',
262262+ },
263263+ ],
264264+ 'displayName': 'Misleading',
265265+ 'description': 'Altered images/videos, deceptive links, or false statements.'
266266+ },
267267+ 'threat': {
268268+ 'value': 'threat',
269269+ 'identifier': 'threat',
270270+ 'blurs': 'content',
271271+ 'severity': 'inform',
272272+ 'defaultSetting': 'hide',
273273+ 'adultOnly': false,
274274+ 'locales': [
275275+ {
276276+ 'lang': 'en',
277277+ 'name': 'Threats',
278278+ 'description': 'Promotes violence or harm towards others, including threats, incitement, or advocacy of harm.',
279279+ },
280280+ ],
281281+ 'displayName': 'Threats',
282282+ 'description': 'Promotes violence or harm towards others, including threats, incitement, or advocacy of harm.'
283283+ },
284284+ 'unsafe-link': {
285285+ 'value': 'unsafe-link',
286286+ 'identifier': 'unsafe-link',
287287+ 'blurs': 'content',
288288+ 'severity': 'alert',
289289+ 'defaultSetting': 'hide',
290290+ 'adultOnly': false,
291291+ 'locales': [
292292+ {
293293+ 'lang': 'en',
294294+ 'name': 'Unsafe link',
295295+ 'description': 'Links to harmful sites with malware, phishing, or violating content that risk security and privacy.',
296296+ },
297297+ ],
298298+ 'displayName': 'Unsafe link',
299299+ 'description': 'Links to harmful sites with malware, phishing, or violating content that risk security and privacy.'
300300+ },
301301+ 'illicit': {
302302+ 'value': 'illicit',
303303+ 'identifier': 'illicit',
304304+ 'blurs': 'content',
305305+ 'severity': 'alert',
306306+ 'defaultSetting': 'hide',
307307+ 'adultOnly': false,
308308+ 'locales': [
309309+ {
310310+ 'lang': 'en',
311311+ 'name': 'Illicit',
312312+ 'description': 'Promoting or selling potentially illicit goods, services, or activities.',
313313+ },
314314+ ],
315315+ 'displayName': 'Illicit',
316316+ 'description': 'Promoting or selling potentially illicit goods, services, or activities.'
317317+ },
318318+ 'misinformation': {
319319+ 'value': 'misinformation',
320320+ 'identifier': 'misinformation',
321321+ 'blurs': 'content',
322322+ 'severity': 'inform',
323323+ 'defaultSetting': 'warn',
324324+ 'adultOnly': false,
325325+ 'locales': [
326326+ {
327327+ 'lang': 'en',
328328+ 'name': 'Misinformation',
329329+ 'description': 'Spreading false or misleading info, including unverified claims and harmful conspiracy theories.',
330330+ },
331331+ ],
332332+ 'displayName': 'Misinformation',
333333+ 'description': 'Spreading false or misleading info, including unverified claims and harmful conspiracy theories.'
334334+ },
335335+ 'rumor': {
336336+ 'value': 'rumor',
337337+ 'identifier': 'rumor',
338338+ 'blurs': 'content',
339339+ 'severity': 'inform',
340340+ 'defaultSetting': 'warn',
341341+ 'adultOnly': false,
342342+ 'locales': [
343343+ {
344344+ 'lang': 'en',
345345+ 'name': 'Rumor',
346346+ 'description': 'Approach with caution, as these claims lack evidence from credible sources.',
347347+ },
348348+ ],
349349+ 'displayName': 'Rumor',
350350+ 'description': 'Approach with caution, as these claims lack evidence from credible sources.'
351351+ },
352352+ 'rude': {
353353+ 'value': 'rude',
354354+ 'identifier': 'rude',
355355+ 'blurs': 'content',
356356+ 'severity': 'inform',
357357+ 'defaultSetting': 'hide',
358358+ 'adultOnly': false,
359359+ 'locales': [
360360+ {
361361+ 'lang': 'en',
362362+ 'name': 'Rude',
363363+ 'description': 'Rude or impolite, including crude language and disrespectful comments, without constructive purpose.',
364364+ },
365365+ ],
366366+ 'displayName': 'Rude',
367367+ 'description': 'Rude or impolite, including crude language and disrespectful comments, without constructive purpose.'
368368+ },
369369+ 'extremist': {
370370+ 'value': 'extremist',
371371+ 'identifier': 'extremist',
372372+ 'blurs': 'content',
373373+ 'severity': 'alert',
374374+ 'defaultSetting': 'hide',
375375+ 'adultOnly': false,
376376+ 'locales': [
377377+ {
378378+ 'lang': 'en',
379379+ 'name': 'Extremist',
380380+ 'description': 'Radical views advocating violence, hate, or discrimination against individuals or groups.',
381381+ },
382382+ ],
383383+ 'displayName': 'Extremist',
384384+ 'description': 'Radical views advocating violence, hate, or discrimination against individuals or groups.'
385385+ },
386386+ 'sensitive': {
387387+ 'value': 'sensitive',
388388+ 'identifier': 'sensitive',
389389+ 'blurs': 'content',
390390+ 'severity': 'alert',
391391+ 'defaultSetting': 'warn',
392392+ 'adultOnly': false,
393393+ 'locales': [
394394+ {
395395+ 'lang': 'en',
396396+ 'name': 'Sensitive',
397397+ 'description': 'May be upsetting, covering topics like substance abuse or mental health issues, cautioning sensitive viewers.',
398398+ },
399399+ ],
400400+ 'displayName': 'Sensitive',
401401+ 'description': 'May be upsetting, covering topics like substance abuse or mental health issues, cautioning sensitive viewers.'
402402+ },
403403+ 'engagement-farming': {
404404+ 'value': 'engagement-farming',
405405+ 'identifier': 'engagement-farming',
406406+ 'blurs': 'content',
407407+ 'severity': 'alert',
408408+ 'defaultSetting': 'hide',
409409+ 'adultOnly': false,
410410+ 'locales': [
411411+ {
412412+ 'lang': 'en',
413413+ 'name': 'Engagement Farming',
414414+ 'description': 'Insincere content or bulk actions aimed at gaining followers, including frequent follows, posts, and likes.',
415415+ },
416416+ ],
417417+ 'displayName': 'Engagement Farming',
418418+ 'description': 'Insincere content or bulk actions aimed at gaining followers, including frequent follows, posts, and likes.'
419419+ },
420420+ 'inauthentic': {
421421+ 'value': 'inauthentic',
422422+ 'identifier': 'inauthentic',
423423+ 'blurs': 'content',
424424+ 'severity': 'alert',
425425+ 'defaultSetting': 'hide',
426426+ 'adultOnly': false,
427427+ 'locales': [
428428+ {
429429+ 'lang': 'en',
430430+ 'name': 'Inauthentic Account',
431431+ 'description': 'Bot or a person pretending to be someone else.',
432432+ },
433433+ ],
434434+ 'displayName': 'Inauthentic Account',
435435+ 'description': 'Bot or a person pretending to be someone else.'
436436+ },
437437+ 'sexual-figurative': {
438438+ 'value': 'sexual-figurative',
439439+ 'identifier': 'sexual-figurative',
440440+ 'blurs': 'media',
441441+ 'severity': 'none',
442442+ 'defaultSetting': 'show',
443443+ 'adultOnly': true,
444444+ 'locales': [
445445+ {
446446+ 'lang': 'en',
447447+ 'name': 'Sexually Suggestive (Cartoon)',
448448+ 'description': 'Art with explicit or suggestive sexual themes, including provocative imagery or partial nudity.',
449449+ },
450450+ ],
451451+ 'displayName': 'Sexually Suggestive (Cartoon)',
452452+ 'description': 'Art with explicit or suggestive sexual themes, including provocative imagery or partial nudity.'
453453+ },
454454+ 'porn': {
455455+ 'value': 'porn',
456456+ 'identifier': 'porn',
457457+ 'blurs': 'content',
458458+ 'severity': 'alert',
459459+ 'defaultSetting': 'hide',
460460+ 'adultOnly': true,
461461+ 'locales': [
462462+ {
463463+ 'lang': 'en',
464464+ 'name': 'Explicit Content',
465465+ 'description': 'Pornographic or sexually explicit material',
466466+ },
467467+ ],
468468+ 'displayName': 'Explicit Content',
469469+ 'description': 'Pornographic or sexually explicit material'
470470+ },
471471+ 'nudity': {
472472+ 'value': 'nudity',
473473+ 'identifier': 'nudity',
474474+ 'blurs': 'content',
475475+ 'severity': 'alert',
476476+ 'defaultSetting': 'warn',
477477+ 'adultOnly': true,
478478+ 'locales': [
479479+ {
480480+ 'lang': 'en',
481481+ 'name': 'Nudity',
482482+ 'description': 'Content containing nudity',
483483+ },
484484+ ],
485485+ 'displayName': 'Nudity',
486486+ 'description': 'Content containing nudity'
487487+ },
488488+ 'sexual': {
489489+ 'value': 'sexual',
490490+ 'identifier': 'sexual',
491491+ 'blurs': 'content',
492492+ 'severity': 'alert',
493493+ 'defaultSetting': 'warn',
494494+ 'adultOnly': true,
495495+ 'locales': [
496496+ {
497497+ 'lang': 'en',
498498+ 'name': 'Sexual Content',
499499+ 'description': 'Content of a sexual nature',
500500+ },
501501+ ],
502502+ 'displayName': 'Sexual Content',
503503+ 'description': 'Content of a sexual nature'
504504+ },
505505+ 'graphic-media': {
506506+ 'value': 'graphic-media',
507507+ 'identifier': 'graphic-media',
508508+ 'blurs': 'content',
509509+ 'severity': 'alert',
510510+ 'defaultSetting': 'warn',
511511+ 'adultOnly': false,
512512+ 'locales': [
513513+ {
514514+ 'lang': 'en',
515515+ 'name': 'Graphic Content',
516516+ 'description': 'Disturbing or graphic imagery',
517517+ },
518518+ ],
519519+ 'displayName': 'Graphic Content',
520520+ 'description': 'Disturbing or graphic imagery'
521521+ },
522522+ };
523523+ }
524524+525525+ /// Loads data for all followed labelers
526526+ Future<void> loadAllFollowedLabelers() async {
527527+ _isLoading = true;
528528+ // Use Future.microtask to avoid calling setState during build
529529+ Future.microtask(() => notifyListeners());
530530+531531+ try {
532532+ final labelers = List<String>.from(followedLabelers);
533533+534534+ for (final labelerDid in labelers) {
535535+ await loadLabelerData(labelerDid);
536536+ }
537537+ } finally {
538538+ _isLoading = false;
539539+ // Use Future.microtask to avoid calling setState during build
540540+ Future.microtask(() => notifyListeners());
541541+ }
542542+ }
543543+544544+ /// Checks if content should be hidden based on its labels
545545+ bool shouldHideContent(List<String> contentLabels) {
546546+ if (contentLabels.isEmpty) return false;
547547+548548+ // First check for special '!hide' label which always hides content
549549+ if (contentLabels.contains('!hide')) {
550550+ return true;
551551+ }
552552+553553+ final settingsService = _settingsService;
554554+555555+ // For each label in the content
556556+ for (final labelValue in contentLabels) {
557557+ // Check in each followed labeler
558558+ for (final labelerDid in followedLabelers) {
559559+ // Get the label definition to check defaultSetting
560560+ final labelDefinition = getLabelDefinitions(labelerDid)[labelValue];
561561+562562+ // Get preference with defaultSetting consideration
563563+ final preference = settingsService.getLabelPreferenceOrDefault(
564564+ labelerDid,
565565+ labelValue,
566566+ labelDefinition
567567+ );
568568+569569+ // If any labeler says to hide, hide
570570+ if (preference == LabelPreference.hide) {
571571+ return true;
572572+ }
573573+ }
574574+ }
575575+576576+ return false;
577577+ }
578578+579579+ /// Checks if content should display a warning based on its labels
580580+ bool shouldWarnContent(List<String> contentLabels) {
581581+ if (contentLabels.isEmpty) return false;
582582+583583+ // First check for special '!warn' label which always warns for content
584584+ if (contentLabels.contains('!warn')) {
585585+ return true;
586586+ }
587587+588588+ final settingsService = _settingsService;
589589+590590+ // For each label in the content
591591+ for (final labelValue in contentLabels) {
592592+ // Check in each followed labeler
593593+ for (final labelerDid in followedLabelers) {
594594+ // Get the label definition to check defaultSetting
595595+ final labelDefinition = getLabelDefinitions(labelerDid)[labelValue];
596596+597597+ // Get preference with defaultSetting consideration
598598+ final preference = settingsService.getLabelPreferenceOrDefault(
599599+ labelerDid,
600600+ labelValue,
601601+ labelDefinition
602602+ );
603603+604604+ // If any labeler says to warn (and none say to hide), warn
605605+ if (preference == LabelPreference.warn) {
606606+ return true;
607607+ }
608608+ }
609609+ }
610610+611611+ return false;
612612+ }
613613+614614+ /// Gets warning messages for content based on its labels
615615+ List<String> getWarningMessages(List<String> contentLabels) {
616616+ final Set<String> warnings = {};
617617+618618+ // Check for special '!warn' label which has a dedicated warning message
619619+ if (contentLabels.contains('!warn')) {
620620+ warnings.add("This content has been flagged by the publisher as requiring a warning");
621621+ }
622622+623623+ final settingsService = _settingsService;
624624+625625+ // For each label in the content
626626+ for (final labelValue in contentLabels) {
627627+ // Skip processing the special labels
628628+ if (labelValue == '!warn' || labelValue == '!hide') continue;
629629+630630+ // Check in each followed labeler
631631+ for (final labelerDid in followedLabelers) {
632632+ // Get the label definition to check defaultSetting
633633+ final labelDefinition = getLabelDefinitions(labelerDid)[labelValue];
634634+635635+ // Get preference with defaultSetting consideration
636636+ final preference = settingsService.getLabelPreferenceOrDefault(
637637+ labelerDid,
638638+ labelValue,
639639+ labelDefinition
640640+ );
641641+642642+ // If the labeler says to warn about this label
643643+ if (preference == LabelPreference.warn) {
644644+ // Get the definition of this label
645645+ final labelDef = _labelDefinitions[labelerDid]?[labelValue];
646646+ if (labelDef != null) {
647647+ // Add the warning message (or the label value if no message)
648648+ String? displayName;
649649+650650+ // Try to get display name from locales first
651651+ if (labelDef['locales'] != null) {
652652+ final locales = labelDef['locales'] as List;
653653+ if (locales.isNotEmpty) {
654654+ final enLocale = locales.first;
655655+ displayName = enLocale['name'] as String?;
656656+ }
657657+ }
658658+659659+ // Fallback to legacy displayName
660660+ displayName ??= labelDef['displayName'] as String?;
661661+662662+ // Fallback to label value
663663+ displayName ??= labelValue;
664664+665665+ warnings.add(displayName);
666666+ } else {
667667+ // If we don't have the definition, use the raw value
668668+ warnings.add("This post contains content that was labeled as $labelValue");
669669+ }
670670+ }
671671+ }
672672+ }
673673+674674+ return warnings.toList();
675675+ }
676676+}
+41-39
lib/services/mod_service.dart
···2222 final authAtProto = _atproto;
2323 if (authAtProto == null || authAtProto.session == null) {
2424 throw Exception('AtProto not initialized');
2525- }
2626- if (service != null) {
2525+ } else if (service != null) {
2726 final report = await service.createReport(subject: subject, reasonType: reasonType, reason: reason);
2827 return report.status.code == 200;
2929- }
2828+ } else {
2929+ final endpoint = NSID.parse('com.atproto.moderation.createReport');
30303131- final endpoint = NSID.parse('com.atproto.moderation.createReport');
3131+ final subjectData = subject.data;
32323333- final subjectData = subject.data;
3333+ Map<String, dynamic> body;
34343535- Map<String, dynamic> body;
3535+ if (subjectData is StrongRef) {
3636+ final strongRef = subjectData.toJson();
3737+ body = {
3838+ 'subject': {'\$type': 'com.atproto.repo.strongRef', 'uri': strongRef['uri'], 'cid': strongRef['cid']},
3939+ 'reasonType': reasonType.value,
4040+ };
4141+ } else if (subjectData is RepoRef) {
4242+ body = {
4343+ 'subject': {'\$type': 'com.atproto.admin.defs.repoRef', 'did': subjectData.did},
4444+ 'reasonType': reasonType.value,
4545+ };
4646+ } else {
4747+ throw Exception('Invalid subject data');
4848+ }
36493737- if (subjectData is StrongRef) {
3838- final strongRef = subjectData.toJson();
3939- body = {
4040- 'subject': {'\$type': 'com.atproto.repo.strongRef', 'uri': strongRef['uri'], 'cid': strongRef['cid']},
4141- 'reasonType': reasonType.value,
4242- };
4343- } else if (subjectData is RepoRef) {
4444- body = {
4545- 'subject': {'\$type': 'com.atproto.admin.defs.repoRef', 'did': subjectData.did},
4646- 'reasonType': reasonType.value,
4747- };
4848- } else {
4949- throw Exception('Invalid subject data');
5050- }
5050+ if (reason != null) {
5151+ body['reason'] = reason;
5252+ }
51535252- if (reason != null) {
5353- body['reason'] = reason;
5454- }
5454+ // Make XRPC call
5555+ // Ensure the service URL has a scheme (https://)
5656+ String serviceUrl = authAtProto.service;
5757+ if (!serviceUrl.startsWith('http://') && !serviceUrl.startsWith('https://')) {
5858+ serviceUrl = 'https://$serviceUrl';
5959+ }
55605656- // Make XRPC call
5757- // Ensure the service URL has a scheme (https://)
5858- String serviceUrl = authAtProto.service;
5959- if (!serviceUrl.startsWith('http://') && !serviceUrl.startsWith('https://')) {
6060- serviceUrl = 'https://$serviceUrl';
6161- }
6161+ // final uri = Uri.parse('$serviceUrl/xrpc/$endpoint');
6262+ // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ if the user account was in another PDS other than sprk.so this would send to the wrong place
6363+ // by default, send to our PDS
6464+ final uri = Uri.parse('https://pds.sprk.so/xrpc/$endpoint');
6565+ final headers = {'Authorization': 'Bearer ${authAtProto.session!.accessJwt}', 'Content-Type': 'application/json'};
62666363- final uri = Uri.parse('$serviceUrl/xrpc/$endpoint');
6464- final headers = {'Authorization': 'Bearer ${authAtProto.session!.accessJwt}', 'Content-Type': 'application/json'};
6767+ debugPrint('Report endpoint URI: $uri');
6868+ debugPrint('Report headers: $headers');
6969+ debugPrint('Report body: $body');
65706666- debugPrint('Report endpoint URI: $uri');
6767- debugPrint('Report headers: $headers');
6868- debugPrint('Report body: $body');
7171+ final response = await http.post(uri, headers: headers, body: jsonEncode(body));
69727070- final response = await http.post(uri, headers: headers, body: jsonEncode(body));
7373+ if (response.statusCode != 200) {
7474+ throw Exception('Failed to create report: ${response.body}');
7575+ }
71767272- if (response.statusCode != 200) {
7373- throw Exception('Failed to create report: ${response.body}');
7777+ return true;
7478 }
7575-7676- return true;
7779 }
7880}
+193
lib/services/settings_service.dart
···11import 'package:flutter/material.dart';
22import 'package:shared_preferences/shared_preferences.dart';
33+import 'dart:convert';
44+55+/// Enum to represent the user's preference for a specific label
66+enum LabelPreference {
77+ show,
88+ warn,
99+ hide,
1010+}
311412class SettingsService extends ChangeNotifier {
513 static const String _feedBlurKey = 'feed_blur_enabled';
1414+ static const String _followedLabelersKey = 'followed_labelers';
1515+ static const String _labelerPreferencesKey = 'labeler_preferences';
1616+ static const String _hideAdultContentKey = 'hide_adult_content';
617718 SharedPreferences? _prefs;
819 bool _isLoading = true;
920 bool _feedBlurEnabled = false;
2121+ bool _hideAdultContent = true; // On by default
2222+ List<String> _followedLabelers = [];
2323+2424+ /// Stores label preferences for each labeler
2525+ /// Format: {labelerDid: {labelValue: preferenceValue}}
2626+ Map<String, Map<String, String>> _labelPreferences = {};
10271128 SettingsService() {
1229 _loadSettings();
···14311532 bool get isLoading => _isLoading;
1633 bool get feedBlurEnabled => _feedBlurEnabled;
3434+ bool get hideAdultContent => _hideAdultContent;
3535+ List<String> get followedLabelers => List.unmodifiable(_followedLabelers);
3636+3737+ /// Returns an immutable copy of all labeler preferences
3838+ Map<String, Map<String, String>> get labelPreferences =>
3939+ Map.unmodifiable(_labelPreferences);
17401841 Future<void> _loadSettings() async {
1942 _prefs = await SharedPreferences.getInstance();
2043 _feedBlurEnabled = _prefs?.getBool(_feedBlurKey) ?? false;
4444+ _hideAdultContent = _prefs?.getBool(_hideAdultContentKey) ?? true; // Default to true if not set
4545+ _followedLabelers = _prefs?.getStringList(_followedLabelersKey) ?? [];
4646+4747+ // Load label preferences
4848+ final prefsJson = _prefs?.getString(_labelerPreferencesKey);
4949+ if (prefsJson != null) {
5050+ final Map<String, dynamic> decoded = jsonDecode(prefsJson);
5151+ _labelPreferences = decoded.map((key, value) => MapEntry(
5252+ key,
5353+ (value as Map<String, dynamic>).map((k, v) => MapEntry(k, v.toString())),
5454+ ));
5555+ }
5656+2157 _isLoading = false;
2258 notifyListeners();
2359 }
···2662 if (_isLoading) await _loadSettings();
2763 _feedBlurEnabled = value;
2864 await _prefs?.setBool(_feedBlurKey, value);
6565+ notifyListeners();
6666+ }
6767+6868+ Future<void> setHideAdultContent(bool value) async {
6969+ if (_isLoading) await _loadSettings();
7070+ _hideAdultContent = value;
7171+ await _prefs?.setBool(_hideAdultContentKey, value);
7272+ notifyListeners();
7373+ }
7474+7575+ Future<void> setFollowedLabelers(List<String> labelerDids) async {
7676+ if (_isLoading) await _loadSettings();
7777+ _followedLabelers = List<String>.from(labelerDids);
7878+ await _prefs?.setStringList(_followedLabelersKey, _followedLabelers);
7979+ notifyListeners();
8080+ }
8181+8282+ Future<void> addFollowedLabeler(String labelerDid) async {
8383+ if (_isLoading) await _loadSettings();
8484+ if (!_followedLabelers.contains(labelerDid)) {
8585+ _followedLabelers.add(labelerDid);
8686+ await _prefs?.setStringList(_followedLabelersKey, _followedLabelers);
8787+ notifyListeners();
8888+ }
8989+ }
9090+9191+ Future<void> removeFollowedLabeler(String labelerDid) async {
9292+ if (_isLoading) await _loadSettings();
9393+ if (_followedLabelers.contains(labelerDid)) {
9494+ _followedLabelers.remove(labelerDid);
9595+ await _prefs?.setStringList(_followedLabelersKey, _followedLabelers);
9696+9797+ // Also remove preferences for this labeler
9898+ _labelPreferences.remove(labelerDid);
9999+ await _saveLabelPreferences();
100100+101101+ notifyListeners();
102102+ }
103103+ }
104104+105105+ /// Saves label preferences to SharedPreferences
106106+ Future<void> _saveLabelPreferences() async {
107107+ if (_prefs == null) return;
108108+109109+ final jsonStr = jsonEncode(_labelPreferences);
110110+ await _prefs!.setString(_labelerPreferencesKey, jsonStr);
111111+ }
112112+113113+ /// Sets a preference for a specific label from a labeler
114114+ Future<void> setLabelPreference(
115115+ String labelerDid,
116116+ String labelValue,
117117+ LabelPreference preference
118118+ ) async {
119119+ if (_isLoading) await _loadSettings();
120120+121121+ // Ensure the labeler is initialized in the map
122122+ _labelPreferences[labelerDid] ??= {};
123123+124124+ // Set the preference
125125+ _labelPreferences[labelerDid]![labelValue] = preference.name;
126126+127127+ // Save to SharedPreferences
128128+ await _saveLabelPreferences();
129129+ notifyListeners();
130130+ }
131131+132132+ /// Removes a preference for a specific label, reverting to the default
133133+ Future<void> removeLabelPreference(
134134+ String labelerDid,
135135+ String labelValue
136136+ ) async {
137137+ if (_isLoading) await _loadSettings();
138138+139139+ // Check if the labeler and preference exist
140140+ if (_labelPreferences.containsKey(labelerDid)) {
141141+ // Remove the specific preference
142142+ _labelPreferences[labelerDid]?.remove(labelValue);
143143+144144+ // Save to SharedPreferences
145145+ await _saveLabelPreferences();
146146+ notifyListeners();
147147+ }
148148+ }
149149+150150+ /// Gets the preference for a specific label from a labeler
151151+ /// Returns null if no preference is defined
152152+ LabelPreference? getLabelPreference(String labelerDid, String labelValue) {
153153+ if (_isLoading || !_labelPreferences.containsKey(labelerDid)) {
154154+ return null;
155155+ }
156156+157157+ final prefValue = _labelPreferences[labelerDid]?[labelValue];
158158+ if (prefValue == null) return null;
159159+160160+ return LabelPreference.values.firstWhere(
161161+ (e) => e.name == prefValue,
162162+ orElse: () => LabelPreference.warn // default
163163+ );
164164+ }
165165+166166+ /// Gets the preference for a specific label, or returns the default setting from the label definition
167167+ LabelPreference getLabelPreferenceOrDefault(
168168+ String labelerDid,
169169+ String labelValue,
170170+ Map<String, dynamic>? labelDefinition
171171+ ) {
172172+ // First try to get user's explicit preference
173173+ final userPreference = getLabelPreference(labelerDid, labelValue);
174174+ if (userPreference != null) {
175175+ return userPreference;
176176+ }
177177+178178+ // If no user preference and we have a label definition with defaultSetting
179179+ if (labelDefinition != null && labelDefinition.containsKey('defaultSetting')) {
180180+ final defaultSetting = labelDefinition['defaultSetting'] as String;
181181+182182+ // Map the defaultSetting string to LabelPreference
183183+ switch (defaultSetting) {
184184+ case 'show':
185185+ return LabelPreference.show;
186186+ case 'hide':
187187+ return LabelPreference.hide;
188188+ case 'warn':
189189+ return LabelPreference.warn;
190190+ default:
191191+ return LabelPreference.warn; // Fallback default
192192+ }
193193+ }
194194+195195+ // Final fallback
196196+ return LabelPreference.warn;
197197+ }
198198+199199+ /// Sets preferences in bulk for all labels from a labeler
200200+ Future<void> setLabelerPreferences(
201201+ String labelerDid,
202202+ Map<String, LabelPreference> preferences
203203+ ) async {
204204+ if (_isLoading) await _loadSettings();
205205+206206+ // Convert the map of enums to strings
207207+ final stringPrefs = preferences.map(
208208+ (key, value) => MapEntry(key, value.name)
209209+ );
210210+211211+ _labelPreferences[labelerDid] = stringPrefs;
212212+ await _saveLabelPreferences();
213213+ notifyListeners();
214214+ }
215215+216216+ /// Removes all preferences for a specific labeler
217217+ Future<void> clearLabelerPreferences(String labelerDid) async {
218218+ if (_isLoading) await _loadSettings();
219219+220220+ _labelPreferences.remove(labelerDid);
221221+ await _saveLabelPreferences();
29222 notifyListeners();
30223 }
31224}