mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 218 lines 6.6 kB view raw
1import 'dart:convert'; 2import 'dart:io'; 3 4import 'package:bluesky_text/bluesky_text.dart'; 5import 'package:http/http.dart' as http; 6 7class LinkPreviewData { 8 const LinkPreviewData({required this.uri, required this.title, required this.description, this.thumbnailUrl}); 9 10 final String uri; 11 final String title; 12 final String description; 13 final String? thumbnailUrl; 14 15 Map<String, dynamic> toExternalEmbedJson() { 16 return { 17 r'$type': 'app.bsky.embed.external', 18 'external': {'uri': uri, 'title': title, 'description': description}, 19 }; 20 } 21} 22 23class LinkPreviewService { 24 LinkPreviewService({http.Client? httpClient}) : _httpClient = httpClient ?? http.Client(); 25 26 final http.Client _httpClient; 27 28 static String? firstLink(String text) { 29 final entities = BlueskyText(text, enableMarkdown: false).entities; 30 for (final entity in entities) { 31 if (!entity.isLink) { 32 continue; 33 } 34 final parsed = _normalizeUri(entity.value); 35 if (parsed != null) { 36 return parsed.toString(); 37 } 38 } 39 return null; 40 } 41 42 Future<LinkPreviewData?> fetch(String rawUrl) async { 43 final uri = _normalizeUri(rawUrl); 44 if (uri == null) { 45 return null; 46 } 47 48 final response = await _httpClient 49 .get( 50 uri, 51 headers: const { 52 'User-Agent': 53 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko)', 54 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', 55 }, 56 ) 57 .timeout(const Duration(seconds: 4)); 58 if (response.statusCode < 200 || response.statusCode >= 300) { 59 throw HttpException('Link preview request failed (${response.statusCode})', uri: uri); 60 } 61 62 final html = _decodeHtml(response.bodyBytes); 63 final title = 64 _readMetaContent(html, 'property', 'og:title') ?? 65 _readMetaContent(html, 'name', 'twitter:title') ?? 66 _readHtmlTag(html, 'title') ?? 67 ExternalLinkHost.host(uri.toString()); 68 final description = 69 _readMetaContent(html, 'property', 'og:description') ?? 70 _readMetaContent(html, 'name', 'description') ?? 71 _readMetaContent(html, 'name', 'twitter:description') ?? 72 ''; 73 final image = _readMetaContent(html, 'property', 'og:image') ?? _readMetaContent(html, 'name', 'twitter:image'); 74 75 return LinkPreviewData( 76 uri: uri.toString(), 77 title: _truncate(title, 300), 78 description: _truncate(description, 1000), 79 thumbnailUrl: _normalizeImageUrl(image, base: uri), 80 ); 81 } 82 83 Future<({List<int> bytes, String mimeType})?> fetchThumbnail(String rawUrl) async { 84 final uri = _normalizeUri(rawUrl); 85 if (uri == null) { 86 return null; 87 } 88 89 final response = await _httpClient 90 .get( 91 uri, 92 headers: const { 93 'User-Agent': 94 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko)', 95 'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8', 96 }, 97 ) 98 .timeout(const Duration(seconds: 4)); 99 if (response.statusCode < 200 || response.statusCode >= 300) { 100 return null; 101 } 102 103 final mime = _normalizeMimeType(response.headers['content-type']); 104 if (mime == null || !mime.startsWith('image/')) { 105 return null; 106 } 107 108 final bytes = response.bodyBytes; 109 const maxThumbBytes = 1 * 1024 * 1024; 110 if (bytes.isEmpty || bytes.length > maxThumbBytes) { 111 return null; 112 } 113 114 return (bytes: bytes, mimeType: mime); 115 } 116 117 static String _decodeHtml(List<int> bodyBytes) { 118 try { 119 return utf8.decode(bodyBytes, allowMalformed: true); 120 } catch (_) { 121 return latin1.decode(bodyBytes, allowInvalid: true); 122 } 123 } 124 125 static Uri? _normalizeUri(String raw) { 126 final trimmed = raw.trim(); 127 if (trimmed.isEmpty) { 128 return null; 129 } 130 131 final parsed = Uri.tryParse(trimmed); 132 if (parsed != null && parsed.hasScheme) { 133 if ((parsed.scheme == 'http' || parsed.scheme == 'https') && parsed.host.isNotEmpty) { 134 return parsed; 135 } 136 return null; 137 } 138 139 final withScheme = Uri.tryParse('https://$trimmed'); 140 if (withScheme != null && withScheme.host.isNotEmpty) { 141 return withScheme; 142 } 143 144 return null; 145 } 146 147 static String? _readMetaContent(String html, String keyAttr, String keyValue) { 148 final normalizedKey = RegExp.escape(keyValue); 149 final regex = RegExp( 150 '<meta\\s+[^>]*$keyAttr\\s*=\\s*["\']$normalizedKey["\'][^>]*content\\s*=\\s*["\']([^"\']*)["\'][^>]*>', 151 caseSensitive: false, 152 dotAll: true, 153 ); 154 final match = regex.firstMatch(html); 155 return match == null ? null : _collapseWhitespace(_decodeHtmlEntities(match.group(1)!)); 156 } 157 158 static String? _readHtmlTag(String html, String tag) { 159 final regex = RegExp('<$tag[^>]*>(.*?)</$tag>', caseSensitive: false, dotAll: true); 160 final match = regex.firstMatch(html); 161 return match == null ? null : _collapseWhitespace(_decodeHtmlEntities(match.group(1)!)); 162 } 163 164 static String _decodeHtmlEntities(String input) { 165 return input 166 .replaceAll('&amp;', '&') 167 .replaceAll('&lt;', '<') 168 .replaceAll('&gt;', '>') 169 .replaceAll('&quot;', '"') 170 .replaceAll('&#39;', "'"); 171 } 172 173 static String _collapseWhitespace(String input) { 174 return input.replaceAll(RegExp(r'\s+'), ' ').trim(); 175 } 176 177 static String _truncate(String text, int maxChars) { 178 if (text.length <= maxChars) { 179 return text; 180 } 181 return text.substring(0, maxChars).trimRight(); 182 } 183 184 static String? _normalizeImageUrl(String? rawImageUrl, {required Uri base}) { 185 if (rawImageUrl == null || rawImageUrl.trim().isEmpty) { 186 return null; 187 } 188 189 final trimmed = rawImageUrl.trim(); 190 final parsed = Uri.tryParse(trimmed); 191 if (parsed == null) { 192 return null; 193 } 194 if (parsed.hasScheme) { 195 return parsed.toString(); 196 } 197 return base.resolveUri(parsed).toString(); 198 } 199 200 static String? _normalizeMimeType(String? rawContentType) { 201 if (rawContentType == null || rawContentType.trim().isEmpty) { 202 return null; 203 } 204 final normalized = rawContentType.split(';').first.trim().toLowerCase(); 205 return normalized.isEmpty ? null : normalized; 206 } 207} 208 209class ExternalLinkHost { 210 static String host(String rawUri) { 211 final uri = Uri.tryParse(rawUri); 212 final host = uri?.host.trim(); 213 if (host == null || host.isEmpty) { 214 return rawUri; 215 } 216 return host; 217 } 218}