~emersion/goguma

cb3bed78506c213a11ef0ad6801fef29652903b4 — Simon Ser a month ago c7bef2a image-preview
wip: image previews
3 files changed, 102 insertions(+), 2 deletions(-)

A lib/link_preview.dart
M lib/linkify.dart
M lib/page/buffer.dart
A lib/link_preview.dart => lib/link_preview.dart +58 -0
@@ 0,0 1,58 @@
import 'dart:io';

import 'package:linkify/linkify.dart' as lnk;

import 'linkify.dart';

const maxPhotoSize = 10 * 1024 * 1024;

class LinkPreviewer {
	final HttpClient client = HttpClient();

	void dispose() {
		client.close();
	}

	Future<PhotoPreview?> previewUrl(Uri url) async {
		if (url.scheme != 'https') {
			return null;
		}

		var req = await client.headUrl(url);
		var resp = await req.close();
		if (resp.statusCode ~/ 100 != 2) {
			throw Exception('HTTP error fetching $url: ${resp.statusCode}');
		}

		if (resp.headers.contentType?.primaryType != 'image') {
			return null;
		}
		if (resp.headers.contentLength > maxPhotoSize) {
			return null;
		}

		return PhotoPreview(url);
	}

	Future<List<PhotoPreview>> previewText(String text) async {
		var links = extractLinks(text);

		List<PhotoPreview> previews = [];
		await Future.wait(links.map((link) async {
			if (link is lnk.UrlElement) {
				var preview = await previewUrl(Uri.parse(link.url));
				if (preview != null) {
					previews.add(preview);
				}
			}
		}));

		return previews;
	}
}

class PhotoPreview {
	final Uri url;

	PhotoPreview(this.url);
}

M lib/linkify.dart => lib/linkify.dart +6 -2
@@ 3,11 3,15 @@ import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:linkify/linkify.dart' as lnk;
import 'package:url_launcher/url_launcher.dart';

TextSpan linkify(String text, { required TextStyle textStyle, required TextStyle linkStyle }) {
	var elements = lnk.linkify(text, options: lnk.LinkifyOptions(
List<LinkifyElement> extractLinks(String text) {
	return lnk.linkify(text, options: lnk.LinkifyOptions(
		humanize: false,
		defaultToHttps: true,
	));
}

TextSpan linkify(String text, { required TextStyle textStyle, required TextStyle linkStyle }) {
	var elements = extractLinks(text);
	return buildTextSpan(
		elements,
		onOpen: (link) async {

M lib/page/buffer.dart => lib/page/buffer.dart +38 -0
@@ 3,6 3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';

import '../ansi.dart';
import '../client.dart';


@@ 10,6 11,7 @@ import '../client_controller.dart';
import '../database.dart';
import '../irc.dart';
import '../linkify.dart';
import '../link_preview.dart';
import '../models.dart';
import '../notification_controller.dart';
import '../prefs.dart';


@@ 636,6 638,8 @@ class _CompactMessageItem extends StatelessWidget {
	}
}

var linkPreviewer = LinkPreviewer();

class _MessageItem extends StatelessWidget {
	final MessageModel msg;
	final MessageModel? prevMsg, nextMsg;


@@ 651,6 655,36 @@ class _MessageItem extends StatelessWidget {
		this.onSwipe
	}) : super(key: key);

	Widget _buildLinkPreview(String text) {
		return FutureBuilder<List<PhotoPreview>>(
			future: linkPreviewer.previewText(text),
			builder: (context, snapshot) {
				var previews = snapshot.data;
				if (previews == null || previews.isEmpty) {
					return Container();
				}
				var preview = previews.first;
				return Container(
					margin: EdgeInsets.all(10),
					child: ClipRRect(
						borderRadius: BorderRadius.circular(10),
						child: InkWell(
							onTap: () {
								launchUrl(preview.url, mode: LaunchMode.externalApplication);
							},
							child: Image.network(
								preview.url.toString(),
								width: 250,
								height: 250,
								fit: BoxFit.cover,
							),
						),
					),
				);
			},
		);
	}

	@override
	Widget build(BuildContext context) {
		var client = context.read<Client>();


@@ 717,6 751,7 @@ class _MessageItem extends StatelessWidget {
		var linkStyle = textStyle.apply(decoration: TextDecoration.underline);

		List<InlineSpan> content;
		Widget? linkPreview;
		if (isAction) {
			// isAction can only ever be true if we have a ctcp
			var actionText = stripAnsiFormatting(ctcp!.param ?? '');


@@ 744,6 779,8 @@ class _MessageItem extends StatelessWidget {
				if (isFirstInGroup) TextSpan(text: '\n'),
				linkify(body, textStyle: textStyle, linkStyle: linkStyle),
			];

			linkPreview = _buildLinkPreview(body);
		}

		Widget inner = RichText(text: TextSpan(


@@ 829,6 866,7 @@ class _MessageItem extends StatelessWidget {
				margin: EdgeInsets.only(left: margin, right: margin, top: marginTop, bottom: marginBottom),
				child: decoratedMessage,
			),
			if (linkPreview != null) linkPreview,
		]);
	}
}