mobile bluesky app made with flutter
lazurite.stormlightlabs.org/
mobile
bluesky
flutter
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('&', '&')
167 .replaceAll('<', '<')
168 .replaceAll('>', '>')
169 .replaceAll('"', '"')
170 .replaceAll(''', "'");
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}