[lemmy] Add Support for Lemmy (#94)

This commit adds support to add Lemmy RSS feeds to FeedDeck. A user can
provide the url of an Lemmy instance, the url of a community or of an
user.

The special thing of the Lemmy source in opposite to the normal RSS
source is, that we parse the provided link form a feed item, to check if
it contains a image, video or YouTube url, to apply some special
formatting.
This commit is contained in:
Rico Berger
2023-12-02 15:52:50 +01:00
committed by GitHub
parent eb28a44cc8
commit 8c88ece3dc
17 changed files with 711 additions and 6 deletions

Binary file not shown.

View File

@@ -10,6 +10,7 @@ import 'package:feeddeck/utils/fd_icons.dart';
/// have one of the following types:
/// - [github]
/// - [googlenews]
/// - [lemmy]
/// - [mastodon]
/// - [medium]
/// - [nitter]
@@ -29,6 +30,7 @@ import 'package:feeddeck/utils/fd_icons.dart';
enum FDSourceType {
github,
googlenews,
lemmy,
mastodon,
medium,
nitter,
@@ -59,6 +61,8 @@ extension FDSourceTypeExtension on FDSourceType {
return 'GitHub';
case FDSourceType.googlenews:
return 'Google News';
case FDSourceType.lemmy:
return 'Lemmy';
case FDSourceType.mastodon:
return 'Mastodon';
case FDSourceType.medium:
@@ -93,6 +97,8 @@ extension FDSourceTypeExtension on FDSourceType {
return FDIcons.github;
case FDSourceType.googlenews:
return FDIcons.googlenews;
case FDSourceType.lemmy:
return FDIcons.lemmy;
case FDSourceType.mastodon:
return FDIcons.mastodon;
case FDSourceType.medium:
@@ -127,6 +133,8 @@ extension FDSourceTypeExtension on FDSourceType {
return const Color(0xff000000);
case FDSourceType.googlenews:
return const Color(0xff4285f4);
case FDSourceType.lemmy:
return const Color(0xff00bc8c);
case FDSourceType.mastodon:
return const Color(0xff6364ff);
case FDSourceType.medium:
@@ -162,6 +170,8 @@ extension FDSourceTypeExtension on FDSourceType {
return const Color(0xffffffff);
case FDSourceType.googlenews:
return const Color(0xffffffff);
case FDSourceType.lemmy:
return const Color(0xffffffff);
case FDSourceType.mastodon:
return const Color(0xffffffff);
case FDSourceType.medium:
@@ -260,6 +270,7 @@ class FDSource {
class FDSourceOptions {
FDGitHubOptions? github;
FDGoogleNewsOptions? googlenews;
String? lemmy;
String? mastodon;
String? medium;
String? nitter;
@@ -275,6 +286,7 @@ class FDSourceOptions {
FDSourceOptions({
this.github,
this.googlenews,
this.lemmy,
this.mastodon,
this.medium,
this.nitter,
@@ -298,6 +310,9 @@ class FDSourceOptions {
responseData['googlenews'] != null
? FDGoogleNewsOptions.fromJson(responseData['googlenews'])
: null,
lemmy: responseData.containsKey('lemmy') && responseData['lemmy'] != null
? responseData['lemmy']
: null,
mastodon: responseData.containsKey('mastodon') &&
responseData['mastodon'] != null
? responseData['mastodon']
@@ -347,6 +362,7 @@ class FDSourceOptions {
return {
'github': github?.toJson(),
'googlenews': googlenews?.toJson(),
'lemmy': lemmy,
'mastodon': mastodon,
'medium': medium,
'nitter': nitter,

View File

@@ -63,4 +63,6 @@ class FDIcons {
IconData(0xe814, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData mastodon =
IconData(0xe815, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData lemmy =
IconData(0xe816, fontFamily: _kFontFam, fontPackage: _kFontPkg);
}

View File

@@ -7,6 +7,7 @@ import 'package:feeddeck/models/source.dart';
import 'package:feeddeck/repositories/items_repository.dart';
import 'package:feeddeck/utils/constants.dart';
import 'package:feeddeck/utils/openurl.dart';
import 'package:feeddeck/widgets/item/details/item_details_lemmy.dart';
import 'package:feeddeck/widgets/item/details/item_details_mastodon.dart';
import 'package:feeddeck/widgets/item/details/item_details_medium.dart';
import 'package:feeddeck/widgets/item/details/item_details_nitter.dart';
@@ -71,6 +72,11 @@ class ItemDetails extends StatelessWidget {
/// corresponding preview item.
case FDSourceType.googlenews:
return Container();
case FDSourceType.lemmy:
return ItemDetailsLemmy(
item: item,
source: source,
);
case FDSourceType.mastodon:
return ItemDetailsMastodon(
item: item,

View File

@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:feeddeck/models/item.dart';
import 'package:feeddeck/models/source.dart';
import 'package:feeddeck/utils/constants.dart';
import 'package:feeddeck/widgets/item/details/utils/item_description.dart';
import 'package:feeddeck/widgets/item/details/utils/item_media.dart';
import 'package:feeddeck/widgets/item/details/utils/item_subtitle.dart';
import 'package:feeddeck/widgets/item/details/utils/item_title.dart';
import 'package:feeddeck/widgets/item/details/utils/item_videos.dart';
import 'package:feeddeck/widgets/item/details/utils/item_youtube/item_youtube_video.dart';
class ItemDetailsLemmy extends StatelessWidget {
const ItemDetailsLemmy({
super.key,
required this.item,
required this.source,
});
final FDItem item;
final FDSource source;
/// [_buildMedia] builds the media widget for the item. The media widget can
/// display an image, a video or y YouTube video.
///
/// See the `getMedia` function in the `lemmy.ts` file, for a list of
/// extension which are a image / video.
Widget _buildMedia() {
if (item.media != null && item.media! != '') {
final mediaUrl = Uri.parse(item.media!);
if (mediaUrl.path.endsWith('.jpg') ||
mediaUrl.path.endsWith('.jpeg') ||
mediaUrl.path.endsWith('.png') ||
mediaUrl.path.endsWith('.gif')) {
return ItemMedia(
itemMedia: item.media,
);
}
if (mediaUrl.path.endsWith('.mp4')) {
return Container(
padding: const EdgeInsets.only(
bottom: Constants.spacingMiddle,
),
child: ItemVideoPlayer(
video: item.media!,
),
);
}
if (item.media!.startsWith('https://youtu.be/') ||
item.media!.startsWith('https://www.youtube.com/watch?') ||
item.media!.startsWith('https://m.youtube.com/watch?')) {
return ItemYoutubeVideo(
null,
item.media!,
);
}
}
return Container();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.start,
children: [
ItemTitle(
itemTitle: item.title,
),
ItemSubtitle(
item: item,
source: source,
),
_buildMedia(),
ItemDescription(
itemDescription: item.description,
sourceFormat: DescriptionFormat.html,
tagetFormat: DescriptionFormat.markdown,
),
],
);
}
}

View File

@@ -7,6 +7,33 @@ import 'package:flutter/material.dart';
import 'package:feeddeck/utils/constants.dart';
import 'item_youtube_video.dart';
/// [_convertVideoUrl] converts the video url to a format that can be used to
/// embed the video in an iframe.
String _convertVideoUrl(String videoUrl) {
if (videoUrl.startsWith('https://youtu.be/')) {
return videoUrl.replaceFirst(
'https://youtu.be/',
'https://www.youtube-nocookie.com/embed/',
);
}
if (videoUrl.startsWith('https://www.youtube.com/watch?v=')) {
return 'https://www.youtube-nocookie.com/embed/${videoUrl.replaceFirst(
'https://www.youtube.com/watch?v=',
'',
)}';
}
if (videoUrl.startsWith('https://m.youtube.com/watch?v=')) {
return 'https://www.youtube-nocookie.com/embed/${videoUrl.replaceFirst(
'https://m.youtube.com/watch?v=',
'',
)}';
}
return videoUrl;
}
class ItemYoutubeVideoWeb extends StatefulWidget implements ItemYoutubeVideo {
const ItemYoutubeVideoWeb({
super.key,
@@ -27,11 +54,8 @@ class _ItemYoutubeVideoWebState extends State<ItemYoutubeVideoWeb> {
@override
void initState() {
super.initState();
_iframeElement.src =
'https://www.youtube-nocookie.com/embed/${widget.videoUrl.replaceFirst(
'https://www.youtube.com/watch?v=',
'',
)}';
_iframeElement.src = _convertVideoUrl(widget.videoUrl);
_iframeElement.style.border = 'none';
_iframeElement.allowFullscreen = true;

View File

@@ -7,6 +7,7 @@ import 'package:feeddeck/models/source.dart';
import 'package:feeddeck/repositories/items_repository.dart';
import 'package:feeddeck/widgets/item/preview/item_preview_github.dart';
import 'package:feeddeck/widgets/item/preview/item_preview_googlenews.dart';
import 'package:feeddeck/widgets/item/preview/item_preview_lemmy.dart';
import 'package:feeddeck/widgets/item/preview/item_preview_mastodon.dart';
import 'package:feeddeck/widgets/item/preview/item_preview_medium.dart';
import 'package:feeddeck/widgets/item/preview/item_preview_nitter.dart';
@@ -53,6 +54,11 @@ class ItemPreview extends StatelessWidget {
item: item,
source: source,
);
case FDSourceType.lemmy:
return ItemPreviewLemmy(
item: item,
source: source,
);
case FDSourceType.mastodon:
return ItemPreviewMastodon(
item: item,

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:feeddeck/models/item.dart';
import 'package:feeddeck/models/source.dart';
import 'package:feeddeck/widgets/item/preview/utils/details.dart';
import 'package:feeddeck/widgets/item/preview/utils/item_actions.dart';
import 'package:feeddeck/widgets/item/preview/utils/item_description.dart';
import 'package:feeddeck/widgets/item/preview/utils/item_media.dart';
import 'package:feeddeck/widgets/item/preview/utils/item_source.dart';
import 'package:feeddeck/widgets/item/preview/utils/item_title.dart';
class ItemPreviewLemmy extends StatelessWidget {
const ItemPreviewLemmy({
super.key,
required this.item,
required this.source,
});
final FDItem item;
final FDSource source;
/// [_buildMedia] returns the media of the item if the item has media file.
/// Since we save images and videos within the media property we have to
/// filter out all videos.
///
/// See the `getMedia` function in the `lemmy.ts` file, for a list of
/// extension which are a image / video.
Widget _buildMedia() {
if (item.media != null && item.media! != '') {
final mediaUrl = Uri.parse(item.media!);
if (mediaUrl.path.endsWith('.jpg') ||
mediaUrl.path.endsWith('.jpeg') ||
mediaUrl.path.endsWith('.png') ||
mediaUrl.path.endsWith('.gif')) {
return ItemMedia(
itemMedia: item.media,
);
}
}
return Container();
}
@override
Widget build(BuildContext context) {
return ItemActions(
item: item,
onTap: () => showDetails(context, item, source),
children: [
ItemSource(
sourceTitle: source.title,
sourceSubtitle: source.type.toLocalizedString(),
sourceType: source.type,
sourceIcon: source.icon,
itemPublishedAt: item.publishedAt,
itemIsRead: item.isRead,
),
ItemTitle(
itemTitle: item.title,
),
_buildMedia(),
ItemDescription(
itemDescription: item.description,
sourceFormat: DescriptionFormat.html,
tagetFormat: DescriptionFormat.plain,
),
],
);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:feeddeck/models/source.dart';
import 'package:feeddeck/utils/constants.dart';
import 'package:feeddeck/widgets/source/add/add_source_github.dart';
import 'package:feeddeck/widgets/source/add/add_source_googlenews.dart';
import 'package:feeddeck/widgets/source/add/add_source_lemmy.dart';
import 'package:feeddeck/widgets/source/add/add_source_mastodon.dart';
import 'package:feeddeck/widgets/source/add/add_source_medium.dart';
import 'package:feeddeck/widgets/source/add/add_source_nitter.dart';
@@ -52,6 +53,10 @@ class _AddSourceState extends State<AddSource> {
return AddSourceGoogleNews(column: widget.column);
}
if (_sourceType == FDSourceType.lemmy) {
return AddSourceLemmy(column: widget.column);
}
if (_sourceType == FDSourceType.mastodon) {
return AddSourceMastodon(column: widget.column);
}

View File

@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:provider/provider.dart';
import 'package:feeddeck/models/column.dart';
import 'package:feeddeck/models/source.dart';
import 'package:feeddeck/repositories/app_repository.dart';
import 'package:feeddeck/utils/api_exception.dart';
import 'package:feeddeck/utils/constants.dart';
import 'package:feeddeck/utils/openurl.dart';
import 'package:feeddeck/widgets/source/add/add_source_form.dart';
const _helpText = '''
The Lemmy source can be used to follow your favorite Lemmy communities.
- **Community**: Provide the url of the community you want to follow
(e.g. `https://lemmy.world/c/lemmyworld`).
- **User**: Provide the url of the user you want to follow
(e.g. `https://lemmy.world/u/lwCET`).
- **Lemmy Instance**: Provide the url of an Lemmy instance to follow all posts
of this instance (e.g. `https://lemmy.world`).
''';
/// The [AddSourceLemmy] widget is used to display the form to add a new Reddit
/// source.
class AddSourceLemmy extends StatefulWidget {
const AddSourceLemmy({
super.key,
required this.column,
});
final FDColumn column;
@override
State<AddSourceLemmy> createState() => _AddSourceLemmyState();
}
class _AddSourceLemmyState extends State<AddSourceLemmy> {
final _formKey = GlobalKey<FormState>();
final _lemmyController = TextEditingController();
bool _isLoading = false;
String _error = '';
/// [_addSource] adds a new Reddit source. The user can provide a subreddit or
/// a user. It is also possible to provide the complete RSS feed url.
Future<void> _addSource() async {
setState(() {
_isLoading = true;
_error = '';
});
try {
AppRepository app = Provider.of<AppRepository>(context, listen: false);
await app.addSource(
widget.column.id,
FDSourceType.lemmy,
FDSourceOptions(
lemmy: _lemmyController.text,
),
);
setState(() {
_isLoading = false;
_error = '';
});
if (mounted) {
Navigator.of(context).pop();
}
} on ApiException catch (err) {
setState(() {
_isLoading = false;
_error = 'Failed to add source: ${err.message}';
});
} catch (err) {
setState(() {
_isLoading = false;
_error = 'Failed to add source: ${err.toString()}';
});
}
}
@override
void dispose() {
_lemmyController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AddSourceForm(
onTap: _addSource,
isLoading: _isLoading,
error: _error,
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MarkdownBody(
selectable: true,
data: _helpText,
onTapLink: (text, href, title) {
try {
if (href != null) {
openUrl(href);
}
} catch (_) {}
},
),
const SizedBox(
height: Constants.spacingMiddle,
),
TextFormField(
controller: _lemmyController,
keyboardType: TextInputType.text,
autocorrect: false,
enableSuggestions: true,
maxLines: 1,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Lemmy Url',
),
onFieldSubmitted: (value) => _addSource(),
),
],
),
),
);
}
}

View File

@@ -313,6 +313,20 @@
"search": [
"pinterest"
]
},
{
"uid": "08d314902e3800cf1b17c4c1226f45d9",
"css": "lemmy",
"code": 59414,
"src": "custom_icons",
"selected": true,
"svg": {
"path": "M123.3 176C118.7 176 114.1 176.3 109.5 176.7 72.9 181 39.9 200.5 19.7 234.5-0.1 267.8-5 304.5 5.1 338.5 15.2 372.5 39.7 403.5 75.9 427.9 76.1 428.1 76.3 428.2 76.5 428.4 107.6 447.1 138.7 459.4 171.9 465.9 171 479.7 170.7 493.7 171.6 508.3 174.2 551.7 189.8 591.7 213.1 627L129.5 661.1C125.2 662.8 121.8 666.2 120 670.5 117.3 676.9 118.7 684.4 123.7 689.4 127 692.7 131.4 694.5 136 694.5 138.2 694.5 140.5 694.1 142.6 693.2L234.7 655.6C251.7 675.8 270.9 694.4 291.8 710.5 292.7 711.1 293.5 711.6 294.3 712.3L240.5 783.5C238.4 786.5 237.2 790 237.2 793.7 237.2 803.2 245.1 811 254.6 811 259.9 811 264.9 808.6 268.2 804.4L323 732C352.9 750.9 384.5 765 415.8 774.7 431.8 804.7 463.2 824.1 500 824.1 537.1 824.1 568.5 803.2 584.3 773.8 615.3 764 646.7 750 676.3 731L731.8 804.4C735.1 808.7 740.2 811.3 745.7 811.3 754.3 811.3 761.6 804.9 762.8 796.3 763.5 791.8 762.2 787.2 759.5 783.5L704.9 711.2C705.2 711 705.7 710.8 706 710.5 726.7 694.6 745.8 676.4 762.7 656.6L852.5 693.2C861.3 696.8 871.5 692.5 875.1 683.7 876 681.6 876.4 679.4 876.4 677.1 876.4 670.1 872.1 663.7 865.6 661.1L784.6 628.1C808.7 592.5 825 552.3 828.4 508.5 829.5 493.9 829.4 479.7 828.7 465.7 861.7 459.2 892.6 447 923.5 428.4 923.7 428.2 923.9 428 924.2 427.9 960.3 403.4 984.8 372.5 994.9 338.5 1005 304.5 1000.1 267.8 980.3 234.5 960.1 200.5 927.1 180.9 890.5 176.7 885.9 176.2 881.3 176 876.7 175.9 844 175.3 809.1 185.5 775.5 204.8 750.8 219.1 728.6 241 711.2 264.8 662.5 236.9 599 221.1 521 219.9 514 219.7 506.8 219.7 499.7 219.9 412.9 221.3 343.3 237.8 290.8 267.5 290.7 267.4 290.6 267.1 290.5 266.9 272.9 242.3 250 219.6 224.5 204.9 191 185.5 156 175.4 123.3 176L123.3 176ZM135.2 206.6C158.4 208.4 184 216.5 209.3 231.1 229.5 242.8 250 262.6 265.4 284 258.2 289.3 251.3 295 244.7 301 206.4 336.5 183.8 382.7 175.2 435.7 146.8 429.9 120.3 419.3 92.5 402.7 61.1 381.3 41.9 355.9 34.2 330 26.5 304 29.7 277 45.7 250 61.4 223.7 84.4 210.2 112.9 207 120.3 206.2 127.8 206 135.2 206.7L135.2 206.6ZM864.8 206.6C872.2 206 879.7 206.1 887.1 207 915.6 210.2 938.6 223.7 954.2 250 970.3 277 973.5 304 965.8 330 958.1 355.9 938.9 381.2 907.5 402.6 880 419.1 853.8 429.6 825.8 435.5 817.7 381.5 795.1 334.1 756.4 297.9 750.1 292.1 743.6 286.7 736.7 281.6 752 261.2 771.3 242.3 790.7 231.1 816 216.5 841.6 208.4 864.8 206.6L864.8 206.6ZM500.2 250.2C507 250 513.8 250 520.5 250.2 620.1 251.8 690.7 278.1 735.7 320.1 783.6 364.9 804.1 428.4 798.1 506.2 792.7 577.3 747.3 640.7 687.6 686.4 658.7 708.5 626.7 725.3 594.9 737.6 595 735.7 595.5 733.9 595.5 732 595.6 682.1 556.7 639.7 500 639.7 443.3 639.7 403.2 682 404.5 732.3 404.5 734.4 405.1 736.3 405.2 738.4 372.7 726.2 339.9 709.2 310.3 686.5 251 640.9 206.2 577.7 201.9 506.5 197.2 429.1 217.6 367.3 265.2 323.2 312.9 279.2 389.7 252 500.2 250.2ZM348.5 534.7C323.3 534.7 302.8 555.1 302.8 580.4 302.8 605.7 323.3 626.2 348.5 626.2 373.7 626.2 394.2 605.7 394.2 580.4 394.2 555.1 373.7 534.7 348.5 534.7ZM651.9 535.2C626.9 535.2 606.6 555.4 606.6 580.5 606.6 605.5 626.9 625.8 651.9 625.8 676.9 625.8 697.2 605.5 697.2 580.5 697.2 555.4 676.9 535.2 651.9 535.2ZM500 670.2C542.7 670.2 565.2 696.7 565.1 731.9 565.1 764.6 537.1 793.8 500 793.8 461.8 793.8 435.8 770.4 434.9 731.6 434 696.8 457.3 670.2 500 670.2Z",
"width": 1000
},
"search": [
"lemmy"
]
}
]
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 4096 4096" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="Lemmy" transform="matrix(170.666,0,0,170.666,-0.0042009,-0.0031286)">
<path d="M2.959,4.223C2.849,4.224 2.738,4.231 2.627,4.242C1.749,4.343 0.958,4.812 0.473,5.628C-0.002,6.428 -0.12,7.309 0.123,8.125C0.365,8.941 0.952,9.683 1.821,10.27C1.826,10.274 1.831,10.278 1.837,10.281C2.583,10.731 3.329,11.025 4.125,11.181C4.105,11.513 4.098,11.85 4.119,12.199C4.182,13.242 4.555,14.2 5.115,15.048L3.109,15.867C3.006,15.908 2.924,15.989 2.881,16.091C2.815,16.246 2.85,16.426 2.969,16.546C3.047,16.624 3.153,16.669 3.263,16.669C3.318,16.669 3.372,16.659 3.422,16.638L5.632,15.734C6.04,16.22 6.501,16.666 7.004,17.052C7.024,17.067 7.045,17.079 7.064,17.095L5.773,18.805C5.722,18.876 5.694,18.961 5.694,19.048C5.694,19.276 5.882,19.464 6.11,19.464C6.237,19.464 6.358,19.406 6.437,19.306L7.751,17.567C8.469,18.022 9.229,18.36 9.98,18.592C10.363,19.313 11.118,19.779 12,19.779C12.891,19.779 13.644,19.278 14.024,18.572C14.768,18.337 15.52,17.999 16.232,17.545L17.564,19.306C17.642,19.41 17.765,19.472 17.896,19.472C18.103,19.472 18.279,19.317 18.308,19.112C18.323,19.003 18.294,18.893 18.228,18.805L16.918,17.07C16.926,17.063 16.936,17.059 16.944,17.052C17.441,16.671 17.899,16.234 18.306,15.758L20.461,16.637C20.672,16.723 20.916,16.62 21.002,16.41C21.023,16.359 21.034,16.305 21.034,16.251C21.034,16.082 20.931,15.93 20.775,15.866L18.831,15.074C19.408,14.22 19.801,13.255 19.882,12.204C19.908,11.854 19.906,11.513 19.89,11.178C20.68,11.021 21.422,10.728 22.163,10.281C22.169,10.277 22.174,10.273 22.18,10.269C23.048,9.682 23.636,8.941 23.878,8.124C24.12,7.308 24.003,6.427 23.528,5.627C23.043,4.811 22.251,4.342 21.373,4.241C21.262,4.229 21.152,4.223 21.041,4.222C20.255,4.207 19.418,4.451 18.612,4.916C18.019,5.258 17.487,5.783 17.069,6.355C15.899,5.685 14.376,5.307 12.505,5.277C12.335,5.274 12.164,5.274 11.994,5.277C9.909,5.311 8.24,5.707 6.98,6.421C6.977,6.418 6.975,6.41 6.972,6.406C6.549,5.815 5.999,5.27 5.388,4.917C4.583,4.452 3.745,4.209 2.96,4.223L2.959,4.223ZM3.245,4.959C3.802,5.001 4.415,5.195 5.024,5.547C5.509,5.827 6,6.302 6.37,6.816C6.197,6.943 6.031,7.079 5.873,7.225C4.953,8.077 4.412,9.185 4.205,10.458C3.524,10.318 2.887,10.064 2.221,9.664C1.466,9.151 1.005,8.541 0.821,7.919C0.636,7.295 0.713,6.648 1.098,6C1.473,5.37 2.026,5.046 2.71,4.967C2.888,4.948 3.067,4.945 3.245,4.96L3.245,4.959ZM20.755,4.959C20.933,4.945 21.112,4.947 21.29,4.967C21.974,5.045 22.527,5.369 22.902,5.999C23.287,6.647 23.364,7.295 23.179,7.919C22.995,8.541 22.534,9.15 21.779,9.663C21.121,10.059 20.491,10.31 19.819,10.452C19.625,9.155 19.082,8.018 18.153,7.15C18.003,7.011 17.846,6.88 17.682,6.758C18.047,6.268 18.511,5.815 18.976,5.546C19.585,5.195 20.198,5.001 20.755,4.959L20.755,4.959ZM12.006,6.004C12.168,6.001 12.331,6.001 12.493,6.004C14.883,6.043 16.578,6.674 17.656,7.682C18.806,8.757 19.298,10.282 19.155,12.149C19.024,13.856 17.935,15.377 16.503,16.473C15.809,17.004 15.041,17.408 14.278,17.702C14.281,17.657 14.292,17.613 14.292,17.567C14.295,16.371 13.36,15.354 12,15.354C10.639,15.354 9.678,16.369 9.708,17.575C9.709,17.626 9.722,17.672 9.726,17.722C8.945,17.429 8.157,17.021 7.448,16.477C6.025,15.382 4.949,13.864 4.845,12.155C4.732,10.298 5.223,8.816 6.366,7.758C7.509,6.7 9.352,6.047 12.006,6.004ZM8.364,12.833C7.759,12.833 7.268,13.323 7.268,13.929C7.268,14.536 7.759,15.028 8.364,15.028C8.969,15.028 9.461,14.536 9.461,13.929C9.461,13.323 8.969,12.833 8.364,12.833ZM15.646,12.844C15.046,12.844 14.559,13.33 14.559,13.931C14.559,14.531 15.046,15.019 15.646,15.019C16.246,15.019 16.733,14.531 16.733,13.931C16.733,13.33 16.246,12.844 15.646,12.844ZM12,16.084C13.024,16.084 13.565,16.722 13.563,17.566C13.562,18.351 12.891,19.051 12,19.051C11.083,19.051 10.46,18.489 10.437,17.558C10.415,16.724 10.976,16.084 12,16.084Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -1,7 +1,9 @@
import 'package:feeddeck/widgets/item/preview/utils/item_title.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:feeddeck/widgets/item/preview/utils/item_title.dart';
void main() {
testWidgets('Should render title', (tester) async {
await tester.pumpWidget(

View File

@@ -3,6 +3,7 @@ import { Redis } from 'redis';
import { IItem } from '../models/item.ts';
import { ISource } from '../models/source.ts';
import { getLemmyFeed, isLemmyUrl } from './lemmy.ts';
import { getMediumFeed, isMediumUrl } from './medium.ts';
import { getPinterestFeed, isPinterestUrl } from './pinterest.ts';
import { getRSSFeed } from './rss.ts';
@@ -39,6 +40,8 @@ export const getFeed = async (
profile,
source,
);
case 'lemmy':
return await getLemmyFeed(supabaseClient, redisClient, profile, source);
case 'mastodon':
return await getMastodonFeed(
supabaseClient,
@@ -63,6 +66,13 @@ export const getFeed = async (
return await getRedditFeed(supabaseClient, redisClient, profile, source);
case 'rss':
try {
if (source.options?.rss && isLemmyUrl(source.options.rss)) {
return await getLemmyFeed(supabaseClient, redisClient, profile, {
...source,
options: { lemmy: source.options.rss },
});
}
if (source.options?.rss && isMediumUrl(source.options.rss)) {
return await getMediumFeed(supabaseClient, redisClient, profile, {
...source,

View File

@@ -0,0 +1,323 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { parseFeed } from 'rss';
import { Md5 } from 'std/md5';
import { FeedEntry } from 'rss/types';
import { Redis } from 'redis';
import { unescape } from 'lodash';
import { IItem } from '../models/item.ts';
import { ISource } from '../models/source.ts';
import { IProfile } from '../models/profile.ts';
import { fetchWithTimeout } from '../utils/fetchWithTimeout.ts';
import { log } from '../utils/log.ts';
/**
* `instances` contains a list of known Lemmy instances. This list is used to
* determin if a provided url is related to a Lemmy instance or not. The list
* is based on the list of instances from https://join-lemmy.org/instances.
*/
const instances = [
'lemmy.world',
'lemm.ee',
'programming.dev',
'sh.itjust.works',
'hexbear.net',
'feddit.de',
'lemmy.ca',
'beehaw.org',
'lemmy.dbzer0.com',
'lemmy.blahaj.zone',
'discuss.tchncs.de',
'lemmygrad.ml',
'sopuli.xyz',
'aussie.zone',
'lemmy.one',
'feddit.nl',
'feddit.uk',
'lemmy.zip',
'midwest.social',
'infosec.pub',
'jlai.lu',
'slrpnk.net',
'startrek.website',
'feddit.it',
'pawb.social',
'ttrpg.network',
'lemmings.world',
'lemmy.eco.br',
'mander.xyz',
'lemmy.today',
'lemdro.id',
'lemmy.nz',
'monero.town',
'feddit.dk',
'szmer.info',
'feddit.ch',
'yiffit.net',
'iusearchlinux.fyi',
'lemmus.org',
'lemmy.whynotdrs.org',
'ani.social',
'awful.systems',
'monyet.cc',
'feddit.cl',
'feddit.nu',
'mujico.org',
'lemmy.wtf',
'leminal.space',
'thelemmy.club',
'literature.cafe',
'fanaticus.social',
'r.nf',
'dormi.zone',
'pornlemmy.com',
'lemmy.cafe',
'lemmy.studio',
'lemmy.myserv.one',
'lemmy.kde.social',
'bookwormstory.social',
'sub.wetshaving.social',
'endlesstalk.org',
'lemmy.my.id',
'yall.theatl.social',
'toast.ooo',
'links.hackliberty.org',
'eviltoast.org',
'futurology.today',
'dmv.social',
'lemmy.fmhy.net',
'eslemmy.es',
'suppo.fi',
'lemmy.frozeninferno.xyz',
'lemmyf.uk',
'mtgzone.com',
'linux.community',
'lemmy.pt',
'lemmy.radio',
'feddit.ro',
'kyberpunk.social',
'alien.top',
'sffa.community',
'lemmy.tf',
'blendit.bsd.cafe',
'lemmy.cat',
'rblind.com',
'bbs.9tail.net',
'communick.news',
'talk.macstack.net',
];
/**
* `isLemmyUrl` checks if the provided `url` is related to a known Lemmy
* instance.
*/
export const isLemmyUrl = (url: string): boolean => {
const parsedUrl = new URL(url);
return instances.includes(parsedUrl.hostname);
};
export const getLemmyFeed = async (
_supabaseClient: SupabaseClient,
_redisClient: Redis | undefined,
_profile: IProfile,
source: ISource,
): Promise<{ source: ISource; items: IItem[] }> => {
if (!source.options?.lemmy) {
throw new Error('Invalid source options');
}
const parsedUrl = new URL(source.options.lemmy);
if (
parsedUrl.pathname.startsWith('/feeds/') &&
parsedUrl.pathname.endsWith('.xml')
) {
source.options.lemmy = `${parsedUrl.origin}${parsedUrl.pathname}?sort=New`;
} else if (
parsedUrl.pathname.startsWith('/c/') || parsedUrl.pathname.startsWith('/u/')
) {
source.options.lemmy =
`${parsedUrl.origin}/feeds${parsedUrl.pathname}.xml?sort=New`;
} else if (parsedUrl.pathname === '/feeds/all.xml') {
source.options.lemmy = `${parsedUrl.origin}/feeds/all.xml?sort=New`;
} else if (parsedUrl.pathname === '' || parsedUrl.pathname === '/') {
source.options.lemmy = `${parsedUrl.origin}/feeds/all.xml?sort=New`;
} else {
throw new Error('Invalid source options');
}
/**
* Get the RSS for the provided `lemmy` url and parse it. If a feed doesn't
* contains an item we return an error.
*/
const response = await fetchWithTimeout(source.options.lemmy, {
method: 'get',
}, 5000);
const xml = await response.text();
log('debug', 'Add source', {
sourceType: 'lemmy',
requestUrl: source.options.lemmy,
responseStatus: response.status,
});
const feed = await parseFeed(xml);
if (!feed.title.value) {
throw new Error('Invalid feed');
}
/**
* Generate a source id based on the user id, column id and the normalized
* `lemmy` url. Besides that we also set the source type to `lemmy` and
* set the title and link for the source.
*/
if (source.id === '') {
source.id = generateSourceId(
source.userId,
source.columnId,
source.options.lemmy,
);
}
source.type = 'lemmy';
source.title = feed.title.value;
if (feed.links.length > 0) {
source.link = feed.links[0];
}
/**
* Now that the source does contain all the required information we can start
* to generate the items for the source, by looping over all the feed entries.
*/
const items: IItem[] = [];
for (const [index, entry] of feed.entries.entries()) {
if (skipEntry(index, entry, source.updatedAt || 0)) {
continue;
}
/**
* Each item need a unique id which is generated using the `generateItemId`
* function. The id is a combination of the source id and the id of the
* entry.
*/
const itemId = generateItemId(source.id, entry.id);
/**
* Create the item object and add it to the `items` array.
*/
items.push({
id: itemId,
userId: source.userId,
columnId: source.columnId,
sourceId: source.id,
title: entry.title!.value!,
link: entry.id,
media: getMedia(entry),
description: getDescription(entry),
author: entry.author?.name,
publishedAt: Math.floor(entry.published!.getTime() / 1000),
});
}
return { source, items };
};
/**
* `skipEntry` is used to determin if an entry should be skipped or not. When a
* entry in the RSS feed is skipped it will not be added to the database. An
* entry will be skipped when
* - it is not within the first 50 entries of the feed, because we only keep the
* last 50 items of each source in our delete logic.
* - the entry does not contain a title, a link or a published date.
* - the published date of the entry is older than the last update date of the
* source minus 10 seconds.
*/
const skipEntry = (
index: number,
entry: FeedEntry,
sourceUpdatedAt: number,
): boolean => {
if (index === 50) {
return true;
}
if (
!entry.id || !entry.title?.value ||
(entry.links.length === 0 || !entry.links[0].href) || !entry.published
) {
return true;
}
if (Math.floor(entry.published.getTime() / 1000) <= (sourceUpdatedAt - 10)) {
return true;
}
return false;
};
/**
* `generateSourceId` generates a unique source id based on the user id, column
* id and the link of the RSS feed. We use the MD5 algorithm for the link to
* generate the id.
*/
const generateSourceId = (
userId: string,
columnId: string,
link: string,
): string => {
return `lemmy-${userId}-${columnId}-${new Md5().update(link).toString()}`;
};
/**
* `generateItemId` generates a unique item id based on the source id and the
* identifier of the item. We use the MD5 algorithm for the identifier, which
* can be the link of the item or the id of the item.
*/
const generateItemId = (sourceId: string, identifier: string): string => {
return `${sourceId}-${new Md5().update(identifier).toString()}`;
};
/**
* `getDescription` returns the description for a feed entry. If the entry does
* not contain a description we return `undefined`.
*/
const getDescription = (entry: FeedEntry): string | undefined => {
if (entry.description?.value) {
return unescape(entry.description.value);
}
return undefined;
};
/**
* `getMedia` returns the media for a feed entry. If the link of an entry ends
* with a image or video extension it is considered as media file. This is not
* always the case, since the link could also be a link to a website which is
* then not used as media.
*/
const getMedia = (entry: FeedEntry): string | undefined => {
if (entry.links && entry.links.length > 0 && entry.links[0].href) {
const parsedUrl = new URL(entry.links[0].href);
if (
/**
* Images
*/
parsedUrl.pathname.endsWith('.jpg') ||
parsedUrl.pathname.endsWith('.jpeg') ||
parsedUrl.pathname.endsWith('.png') ||
parsedUrl.pathname.endsWith('.gif') ||
/**
* Videos
*/
parsedUrl.pathname.endsWith('.mp4') ||
/**
* YouTube
*/
entry.links[0].href.startsWith('https://youtu.be/') ||
entry.links[0].href.startsWith('https://www.youtube.com/watch?') ||
entry.links[0].href.startsWith('https://m.youtube.com/watch?')
) {
return entry.links[0].href;
}
}
return undefined;
};

View File

@@ -5,6 +5,7 @@ import { ISourceOptionsStackOverflow } from './sources/stackoverflow.ts';
export type TSourceType =
| 'github'
| 'googlenews'
| 'lemmy'
| 'mastodon'
| 'medium'
| 'nitter'
@@ -33,6 +34,7 @@ export interface ISource {
export interface ISourceOptions {
github?: ISourceOptionsGithub;
googlenews?: ISourceOptionsGoogleNews;
lemmy?: string;
mastodon?: string;
medium?: string;
nitter?: string;