forked from thunder-app/thunder
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathlinks.dart
366 lines (325 loc) · 12.4 KB
/
links.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:html/parser.dart' as parser;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:link_preview_generator/link_preview_generator.dart';
import 'package:share_plus/share_plus.dart';
import 'package:swipeable_page_route/swipeable_page_route.dart';
import 'package:thunder/core/enums/browser_mode.dart';
import 'package:thunder/instances.dart';
import 'package:thunder/shared/pages/loading_page.dart';
import 'package:thunder/shared/webview.dart';
import 'package:thunder/utils/bottom_sheet_list_picker.dart';
import 'package:thunder/utils/media/image.dart';
import 'package:url_launcher/url_launcher.dart' as url_launcher;
import 'package:flutter_custom_tabs/flutter_custom_tabs.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:thunder/thunder/bloc/thunder_bloc.dart';
import 'package:thunder/account/models/account.dart';
import 'package:thunder/core/auth/helpers/fetch_account.dart';
import 'package:thunder/core/singletons/lemmy_client.dart';
import 'package:thunder/feed/utils/utils.dart';
import 'package:thunder/feed/view/feed_page.dart';
import 'package:thunder/post/utils/post.dart';
import 'package:thunder/utils/instance.dart';
import 'package:thunder/comment/utils/navigate_comment.dart';
import 'package:thunder/post/utils/navigate_post.dart';
class LinkInfo {
String? imageURL;
String? title;
LinkInfo({this.imageURL, this.title});
}
Future<LinkInfo> getLinkInfo(String url) async {
try {
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
final document = parser.parse(response.body);
final metatags = document.getElementsByTagName('meta');
String imageURL = '';
String title = '';
for (final metatag in metatags) {
final property = metatag.attributes['property'];
final content = metatag.attributes['content'];
if (property == 'og:image') {
imageURL = content ?? '';
} else if (property == 'og:title') {
title = content ?? '';
}
}
return LinkInfo(imageURL: imageURL, title: title);
} else {
throw Exception('Unable to fetch link information');
}
} catch (e) {
return LinkInfo();
}
}
void _openLink(BuildContext context, {required String url}) async {
ThunderState state = context.read<ThunderBloc>().state;
if (state.browserMode == BrowserMode.external || (!kIsWeb && !Platform.isAndroid && !Platform.isIOS)) {
hideLoadingPage(context, delay: true);
url_launcher.launchUrl(Uri.parse(url), mode: url_launcher.LaunchMode.externalApplication);
} else if (state.browserMode == BrowserMode.customTabs) {
hideLoadingPage(context, delay: true);
launchUrl(
Uri.parse(url),
customTabsOptions: CustomTabsOptions(
browser: const CustomTabsBrowserConfiguration(
prefersDefaultBrowser: true,
),
colorSchemes: CustomTabsColorSchemes(
defaultPrams: CustomTabsColorSchemeParams(
toolbarColor: Theme.of(context).canvasColor,
),
),
shareState: CustomTabsShareState.browserDefault,
urlBarHidingEnabled: true,
showTitle: true,
instantAppsEnabled: true,
),
safariVCOptions: SafariViewControllerOptions(
preferredBarTintColor: Theme.of(context).canvasColor,
preferredControlTintColor: Theme.of(context).textTheme.titleLarge?.color ?? Theme.of(context).primaryColor,
barCollapsingEnabled: true,
entersReaderIfAvailable: state.openInReaderMode,
),
);
} else if (state.browserMode == BrowserMode.inApp) {
// Check if the scheme is not https, in which case the in-app browser can't handle it
Uri? uri = Uri.tryParse(url);
if (uri != null && uri.scheme != 'https') {
// Although a non-https scheme is an indication that this link is intended for another app,
// we actually have to change it back to https in order for the intent to be properly passed to another app.
hideLoadingPage(context, delay: true);
url_launcher.launchUrl(uri, mode: url_launcher.LaunchMode.externalApplication);
} else {
final bool reduceAnimations = state.reduceAnimations;
SwipeablePageRoute route = SwipeablePageRoute(
transitionDuration: isLoadingPageShown
? Duration.zero
: reduceAnimations
? const Duration(milliseconds: 100)
: null,
reverseTransitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : const Duration(milliseconds: 500),
backGestureDetectionWidth: 45,
canOnlySwipeFromEdge: true,
builder: (context) => WebView(url: url),
);
pushOnTopOfLoadingPage(context, route);
}
}
}
/// A universal way of handling links in Thunder.
/// Attempts to perform in-app navigtion to communities, users, posts, and comments
/// Before falling back to opening in the browser (either Custom Tabs or system browser, as specified by the user).
void handleLink(BuildContext context, {required String url}) async {
LemmyApiV3 lemmy = LemmyClient.instance.lemmyApiV3;
Account? account = await fetchActiveProfileAccount();
// Try navigating to community
String? communityName = await getLemmyCommunity(url);
if (communityName != null && (!context.mounted || await _testValidCommunity(context, url, communityName, communityName.split('@')[1]))) {
try {
if (context.mounted) {
await navigateToFeedPage(context, feedType: FeedType.community, communityName: communityName);
return;
}
} catch (e) {
// Ignore exception, if it's not a valid community we'll perform the next fallback
}
}
// Try navigating to user
String? username = await getLemmyUser(url);
if (username != null && (!context.mounted || await _testValidUser(context, url, username, username.split('@')[1]))) {
try {
if (context.mounted) {
await navigateToFeedPage(context, feedType: FeedType.user, username: username);
return;
}
} catch (e) {
// Ignore exception, if it's not a valid user, we'll perform the next fallback
}
}
// Try navigating to post
int? postId = await getLemmyPostId(url);
if (postId != null) {
try {
GetPostResponse post = await lemmy.run(GetPost(
id: postId,
auth: account?.jwt,
));
if (context.mounted) {
navigateToPost(context, postViewMedia: (await parsePostViews([post.postView])).first);
return;
}
} catch (e) {
// Ignore exception, if it's not a valid post, we'll perform the next fallback
}
}
// Try navigating to comment
int? commentId = await getLemmyCommentId(url);
if (commentId != null) {
try {
CommentResponse fullCommentView = await lemmy.run(GetComment(
id: commentId,
auth: account?.jwt,
));
if (context.mounted) {
navigateToComment(context, fullCommentView.commentView);
return;
}
} catch (e) {
// Ignore exception, if it's not a valid comment, we'll perform the next fallback
}
}
// Try opening it as an image
try {
if (isImageUrl(url) && context.mounted) {
showImageViewer(context, url: url);
return;
}
} catch (e) {
// Ignore the exception and fall back.
}
// Fallback: open link in browser
if (context.mounted) {
_openLink(context, url: url);
}
}
/// This is a helper method which helps [handleLink] determine whether a link refers to a valid Lemmy community.
/// If the passed in link is not a valid URI, then there's no point in doing any fallback, so assume it passes.
/// If the passed in [instance] is a known Lemmy instance, then it passes.
/// If we can retrieve the passed in object, then it passes.
/// Otherwise it fails.
Future<bool> _testValidCommunity(BuildContext context, String link, String communityName, String instance) async {
Uri? uri = Uri.tryParse(link);
if (uri == null || !uri.hasScheme) {
return true;
}
if (instances.contains(instance)) {
return true;
}
try {
// Since this may take a while, show a loading page.
showLoadingPage(context);
Account? account = await fetchActiveProfileAccount();
await LemmyClient.instance.lemmyApiV3.run(GetCommunity(name: communityName, auth: account?.jwt));
return true;
} catch (e) {
// Ignore and return false below.
}
return false;
}
/// This is a helper method which helps [handleLink] determine whether a link refers to a valid Lemmy user.
/// If the passed in link is not a valid URI, then there's no point in doing any fallback, so assume it passes.
/// If the passed in [instance] is a known Lemmy instance, then it passes.
/// If we can retrieve the passed in object, then it passes.
/// Otherwise it fails.
Future<bool> _testValidUser(BuildContext context, String link, String userName, String instance) async {
Uri? uri = Uri.tryParse(link);
if (uri == null || !uri.hasScheme) {
return true;
}
if (instances.contains(instance)) {
return true;
}
try {
// Since this may take a while, show a loading page.
showLoadingPage(context);
Account? account = await fetchActiveProfileAccount();
await LemmyClient.instance.lemmyApiV3.run(GetPersonDetails(username: userName, auth: account?.jwt));
return true;
} catch (e) {
// Ignore and return false below.
}
return false;
}
void handleLinkLongPress(BuildContext context, ThunderState state, String text, String? url) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
HapticFeedback.mediumImpact();
showModalBottomSheet(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (ctx) {
bool isValidUrl = url?.startsWith('http') ?? false;
return BottomSheetListPicker(
title: l10n.linkActions,
heading: Column(
children: [
if (isValidUrl) ...[
LinkPreviewGenerator(
link: url!,
placeholderWidget: const CircularProgressIndicator(),
linkPreviewStyle: LinkPreviewStyle.large,
cacheDuration: Duration.zero,
onTap: null,
bodyTextOverflow: TextOverflow.fade,
graphicFit: BoxFit.scaleDown,
removeElevation: true,
backgroundColor: theme.dividerColor.withOpacity(0.25),
borderRadius: 10,
useDefaultOnTap: false,
),
const SizedBox(height: 10),
],
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
decoration: BoxDecoration(
color: theme.dividerColor.withOpacity(0.25),
borderRadius: BorderRadius.circular(10),
),
child: Padding(
padding: const EdgeInsets.all(5),
child: Text(url!),
),
),
],
),
],
),
items: [
ListPickerItem(label: l10n.open, payload: 'open', icon: Icons.language),
ListPickerItem(label: l10n.copy, payload: 'copy', icon: Icons.copy_rounded),
ListPickerItem(label: l10n.share, payload: 'share', icon: Icons.share_rounded),
],
onSelect: (value) {
switch (value.payload) {
case 'open':
handleLinkTap(context, state, text, url);
break;
case 'copy':
Clipboard.setData(ClipboardData(text: url));
break;
case 'share':
Share.share(url);
break;
}
},
);
},
);
}
Future<void> handleLinkTap(BuildContext context, ThunderState state, String text, String? url) async {
Uri? parsedUri = Uri.tryParse(text);
String parsedUrl = text;
if (parsedUri != null && parsedUri.host.isNotEmpty) {
parsedUrl = parsedUri.toString();
} else {
parsedUrl = url ?? '';
}
// The markdown link processor treats URLs with @ as emails and prepends "mailto:".
// If the URL contains that, but the text doesn't, we can remove it.
if (parsedUrl.startsWith('mailto:') && !text.startsWith('mailto:')) {
parsedUrl = parsedUrl.replaceFirst('mailto:', '');
}
if (context.mounted) {
handleLink(context, url: parsedUrl);
}
}