[x] Remove X Source (#162)

X (Twitter) was never supported and the code was never used to add a
source. We kept it in the hope that it will be possible again to use X
within the app. Since this isn't the case it is time to remove the code.
This commit is contained in:
Rico Berger
2024-04-06 13:00:12 +02:00
committed by GitHub
parent eb9f84f650
commit 4c34d33ac0
12 changed files with 3 additions and 570 deletions

View File

@@ -21,7 +21,6 @@ import 'package:feeddeck/utils/fd_icons.dart';
/// - [rss]
/// - [stackoverflow]
/// - [tumblr]
/// - [x]
/// - [youtube]
///
/// The [none] value is not valid and just here as a fallback in case sth. odd
@@ -42,7 +41,6 @@ enum FDSourceType {
rss,
stackoverflow,
tumblr,
// x,
youtube,
none,
}
@@ -85,8 +83,6 @@ extension FDSourceTypeExtension on FDSourceType {
return 'StackOverflow';
case FDSourceType.tumblr:
return 'Tumblr';
// case FDSourceType.x:
// return 'X';
case FDSourceType.youtube:
return 'YouTube';
default:
@@ -123,8 +119,6 @@ extension FDSourceTypeExtension on FDSourceType {
return FDIcons.stackoverflow;
case FDSourceType.tumblr:
return FDIcons.tumblr;
// case FDSourceType.x:
// return FDIcons.x;
case FDSourceType.youtube:
return FDIcons.youtube;
default:
@@ -161,8 +155,6 @@ extension FDSourceTypeExtension on FDSourceType {
return const Color(0xffef8236);
case FDSourceType.tumblr:
return const Color(0xff34526f);
// case FDSourceType.x:
// return const Color(0xff000000);
case FDSourceType.youtube:
return const Color(0xffff0000);
default:
@@ -200,8 +192,6 @@ extension FDSourceTypeExtension on FDSourceType {
return const Color(0xffffffff);
case FDSourceType.tumblr:
return const Color(0xffffffff);
// case FDSourceType.x:
// return const Color(0xffffffff);
case FDSourceType.youtube:
return const Color(0xffffffff);
default:
@@ -291,7 +281,6 @@ class FDSourceOptions {
String? rss;
FDStackOverflowOptions? stackoverflow;
String? tumblr;
String? x;
String? youtube;
FDSourceOptions({
@@ -308,7 +297,6 @@ class FDSourceOptions {
this.rss,
this.stackoverflow,
this.tumblr,
this.x,
this.youtube,
});
@@ -364,9 +352,6 @@ class FDSourceOptions {
responseData.containsKey('tumblr') && responseData['tumblr'] != null
? responseData['tumblr']
: null,
x: responseData.containsKey('x') && responseData['x'] != null
? responseData['x']
: null,
youtube:
responseData.containsKey('youtube') && responseData['youtube'] != null
? responseData['youtube']
@@ -389,7 +374,6 @@ class FDSourceOptions {
'rss': rss,
'stackoverflow': stackoverflow?.toJson(),
'tumblr': tumblr,
'x': x,
'youtube': youtube,
};
}

View File

@@ -129,11 +129,6 @@ class ItemDetails extends StatelessWidget {
item: item,
source: source,
);
// case FDSourceType.x:
// return ItemDetailsX(
// item: item,
// source: source,
// );
case FDSourceType.youtube:
return ItemDetailsYoutube(
item: item,

View File

@@ -1,48 +0,0 @@
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_gallery.dart';
import 'package:feeddeck/widgets/item/details/utils/item_subtitle.dart';
class ItemDetailsX extends StatelessWidget {
const ItemDetailsX({
super.key,
required this.item,
required this.source,
});
final FDItem item;
final FDSource source;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.start,
children: [
ItemSubtitle(
item: item,
source: source,
),
ItemDescription(
itemDescription: item.description,
sourceFormat: DescriptionFormat.html,
tagetFormat: DescriptionFormat.markdown,
),
const SizedBox(
height: Constants.spacingExtraSmall,
),
ItemMediaGallery(
itemMedias: item.options != null && item.options!.containsKey('media')
? (item.options!['media'] as List)
.map((item) => item as String)
.toList()
: null,
),
],
);
}
}

View File

@@ -110,11 +110,6 @@ class ItemPreview extends StatelessWidget {
item: item,
source: source,
);
// case FDSourceType.x:
// return ItemPreviewX(
// item: item,
// source: source,
// );
case FDSourceType.youtube:
return ItemPreviewYoutube(
item: item,

View File

@@ -1,50 +0,0 @@
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_gallery.dart';
import 'package:feeddeck/widgets/item/preview/utils/item_source.dart';
class ItemPreviewX extends StatelessWidget {
const ItemPreviewX({
super.key,
required this.item,
required this.source,
});
final FDItem item;
final FDSource source;
@override
Widget build(BuildContext context) {
return ItemActions(
item: item,
onTap: () => showDetails(context, item, source),
children: [
ItemSource(
sourceTitle: item.author ?? '',
sourceSubtitle: '${source.type.toLocalizedString()}: ${source.title}',
sourceType: source.type,
sourceIcon: source.icon,
itemPublishedAt: item.publishedAt,
itemIsRead: item.isRead,
),
ItemDescription(
itemDescription: item.description,
sourceFormat: DescriptionFormat.html,
tagetFormat: DescriptionFormat.markdown,
),
ItemMediaGallery(
itemMedias: item.options != null && item.options!.containsKey('media')
? (item.options!['media'] as List)
.map((item) => item as String)
.toList()
: null,
),
],
);
}
}

View File

@@ -98,10 +98,6 @@ class _AddSourceState extends State<AddSource> {
return AddSourceTumblr(column: widget.column);
}
// if (_sourceType == FDSourceType.x) {
// return AddSourceX(column: widget.column);
// }
if (_sourceType == FDSourceType.youtube) {
return AddSourceYouTube(column: widget.column);
}

View File

@@ -22,7 +22,7 @@ The Lemmy source can be used to follow your favorite Lemmy communities.
of this instance (e.g. `https://lemmy.world`).
''';
/// The [AddSourceLemmy] widget is used to display the form to add a new Reddit
/// The [AddSourceLemmy] widget is used to display the form to add a new Lemmy
/// source.
class AddSourceLemmy extends StatefulWidget {
const AddSourceLemmy({
@@ -42,8 +42,8 @@ class _AddSourceLemmyState extends State<AddSourceLemmy> {
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.
/// [_addSource] adds a new Lemmy source. The user can provide a Lemmy url,
/// which could be be a community or user or the corresponding RSS feed.
Future<void> _addSource() async {
setState(() {
_isLoading = true;

View File

@@ -1,120 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:feeddeck/models/column.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 X (formerly Twitter) source can be used to follow a user on X, e.g. `@rico_berger`.
''';
/// The [AddSourceX] widget is used to display the form to add a new X
/// (formerly) source.
class AddSourceX extends StatefulWidget {
const AddSourceX({
super.key,
required this.column,
});
final FDColumn column;
@override
State<AddSourceX> createState() => _AddSourceXState();
}
class _AddSourceXState extends State<AddSourceX> {
final _formKey = GlobalKey<FormState>();
final _xController = TextEditingController();
bool _isLoading = false;
String _error = '';
/// [_addSource] adds a new X source where the user can provide the username
/// of a x user.
Future<void> _addSource() async {
setState(() {
_isLoading = true;
_error = '';
});
try {
// AppRepository app = Provider.of<AppRepository>(context, listen: false);
// await app.addSource(
// widget.column.id,
// FDSourceType.x,
// FDSourceOptions(
// x: _xController.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() {
_xController.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: _xController,
keyboardType: TextInputType.text,
autocorrect: false,
enableSuggestions: true,
maxLines: 1,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Username',
),
onFieldSubmitted: (value) => _addSource(),
),
],
),
),
);
}
}

View File

@@ -77,7 +77,6 @@ export default function Home() {
<RSS />
<StackOverflow />
<Tumblr />
<X />
<YouTube />
</div>
</div>
@@ -423,24 +422,6 @@ const Tumblr = () => (
</div>
);
const X = () => (
<div className="flex flex-col items-center justify-center">
<svg
width="32px"
height="32px"
viewBox="0 0 4096 4096"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
>
<g id="X" transform="matrix(8.90048,0,0,8.90048,-238.533,-230.522)">
<path d="M389.2,48L459.8,48L305.6,224.2L487,464L345,464L233.7,318.6L106.5,464L35.8,464L200.7,275.5L26.8,48L172.4,48L272.9,180.9L389.2,48ZM364.4,421.8L403.5,421.8L151.1,88L109.1,88L364.4,421.8Z" />
</g>
</svg>
<div className="pt-4">X</div>
</div>
);
const YouTube = () => (
<div className="flex flex-col items-center justify-center">
<svg

View File

@@ -19,7 +19,6 @@ import { IProfile } from '../models/profile.ts';
import { getNitterFeed } from './nitter.ts';
import { getMastodonFeed } from './mastodon.ts';
import { getFourChanFeed, isFourChanUrl } from './fourchan.ts';
// import { getXFeed } from './x.ts';
/**
* `getFeed` returns a feed which consist of a source and a list of items for
@@ -194,8 +193,6 @@ export const getFeed = async (
source,
feedData,
);
// case 'x':
// return await getXFeed(supabaseClient, redisClient, profile, source, data);
case 'youtube':
return await getYoutubeFeed(
supabaseClient,

View File

@@ -1,295 +0,0 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { Redis } from 'redis';
import { unescape } from 'lodash';
import { IItem } from '../models/item.ts';
import { ISource } from '../models/source.ts';
import { feedutils } from './utils/index.ts';
import { IProfile } from '../models/profile.ts';
import { utils } from '../utils/index.ts';
export const getXFeed = async (
supabaseClient: SupabaseClient,
_redisClient: Redis | undefined,
_profile: IProfile,
source: ISource,
_feedData: string | undefined,
): Promise<{ source: ISource; items: IItem[] }> => {
if (!source.options?.x || source.options.x.length === 0) {
throw new feedutils.FeedValidationError('Invalid source options');
}
if (source.options.x[0] !== '@') {
throw new feedutils.FeedValidationError('Invalid source options');
}
/**
* Get the feed for the provided X username, based on the content of the HTML
* returned for the syndication.twitter.com url.
*/
const response = await utils.fetchWithTimeout(
generateFeedUrl(source.options.x),
{ method: 'get' },
5000,
);
const html = await response.text();
const matches = html.match(
/script id="__NEXT_DATA__" type="application\/json">([^>]*)<\/script>/,
);
if (!matches || matches.length !== 2) {
throw new Error('Invalid feed');
}
const feed = JSON.parse(matches[1]) as Feed;
/**
* Generate a source id based on the user id, column id and the normalized
* `twitter` options. Besides that we also set the source type to `twitter`
* and the link for the source. In opposite to the other sources we do not use
* the title of the feed as the title for the source, instead we are using the
* user input as title.
*/
if (source.id === '') {
source.id = await generateSourceId(
source.userId,
source.columnId,
source.options.x,
);
}
source.type = 'x';
source.title = source.options.x;
source.link = `https://twitter.com/${source.options.x.slice(1)}`;
/**
* When the source doesn't has an icon yet and the user requested the feed of
* a user (string starts with `@`) we try to get an icon for the source from
* the first item in the returned feed.
*/
if (
!source.icon && source.options.x[0] === '@' &&
feed.props.pageProps.timeline.entries.length > 0 &&
feed.props.pageProps.timeline.entries[0].content.tweet.user
.profile_image_url_https
) {
source.icon = feed.props.pageProps.timeline.entries[0].content.tweet.user
.profile_image_url_https;
source.icon = await feedutils.uploadSourceIcon(supabaseClient, source);
}
/**
* 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.
* We only add the first 50 items from the feed, because we only keep the
* latest 50 items for each source in our deletion logic.
*/
const items: IItem[] = [];
for (
const [index, entry] of feed.props.pageProps.timeline.entries.entries()
) {
if (index === 50) {
break;
}
const media = getMedia(entry);
items.push({
id: await generateItemId(source.id, entry.content.tweet.id_str),
userId: source.userId,
columnId: source.columnId,
sourceId: source.id,
title: '',
link: `https://twitter.com${entry.content.tweet.permalink}`,
description: unescape(entry.content.tweet.full_text),
author: entry.content.tweet.user.screen_name,
options: media && media.length > 0 ? { media: media } : undefined,
publishedAt: Math.floor(
new Date(entry.content.tweet.created_at).getTime() / 1000,
),
});
}
return { source, items };
};
/**
* `generateFeedUrl` returns the url to get the Tweets of the provided username.
* Since we check before that the input must start with an `@` we do not need to
* check if the input is a valid username.
*/
const generateFeedUrl = (input: string): string => {
return `https://syndication.twitter.com/srv/timeline-profile/screen-name/${
input.slice(1)
}?showReplies=true`;
};
/**
* `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 = async (
userId: string,
columnId: string,
link: string,
): Promise<string> => {
return `x-${userId}-${columnId}-${await utils.md5(link)}`;
};
/**
* `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 = async (
sourceId: string,
identifier: string,
): Promise<string> => {
return `${sourceId}-${await utils.md5(identifier)}`;
};
/**
* `getMedia` returns an image for the provided feed entry. To get the images we
* have to check the `entities.media`, `retweeted_status.media` and
* `extended_entities.media` properties of the feed entry.
*/
const getMedia = (entry: Entry): string[] | undefined => {
if (
entry.content.tweet.retweeted_status?.extended_entities?.media &&
entry.content.tweet.retweeted_status.extended_entities.media.length > 0
) {
const images = [];
for (
const media of entry.content.tweet.retweeted_status.extended_entities
.media
) {
if (media.type === 'photo') {
images.push(media.media_url_https);
}
}
return images;
}
if (
entry.content.tweet.retweeted_status?.entities?.media &&
entry.content.tweet.retweeted_status?.entities.media.length > 0
) {
const images = [];
for (const media of entry.content.tweet.retweeted_status.entities.media) {
if (media.type === 'photo') {
images.push(media.media_url_https);
}
}
return images;
}
if (
entry.content.tweet.retweeted_status?.media &&
entry.content.tweet.retweeted_status.media.length > 0
) {
const images = [];
for (const media of entry.content.tweet.retweeted_status.media) {
if (media.type === 'photo') {
images.push(media.media_url_https);
}
}
return images;
}
if (
entry.content.tweet.extended_entities?.media &&
entry.content.tweet.extended_entities.media.length > 0
) {
const images = [];
for (const media of entry.content.tweet.extended_entities.media) {
if (media.type === 'photo') {
images.push(media.media_url_https);
}
}
return images;
}
if (
entry.content.tweet.entities.media &&
entry.content.tweet.entities.media.length > 0
) {
const images = [];
for (const media of entry.content.tweet.entities.media) {
if (media.type === 'photo') {
images.push(media.media_url_https);
}
}
return images;
}
return undefined;
};
/**
* `Feed` is the interface for the returned data from Twitter for a users
* timeline.
*/
export interface Feed {
props: {
pageProps: {
timeline: {
entries: Entry[];
};
};
};
}
export interface Entry {
content: {
tweet: {
created_at: string;
entities: {
media?: {
media_url_https: string;
type: string;
}[];
};
full_text: string;
id_str: string;
permalink: string;
user: {
screen_name: string;
profile_image_url_https: string;
};
retweeted_status?: {
entities?: {
media?: {
media_url_https: string;
type: string;
}[];
};
extended_entities?: {
media?: {
media_url_https: string;
type: string;
}[];
};
media?: {
media_url_https: string;
type: string;
}[];
};
extended_entities?: {
media?: {
media_url_https: string;
type: string;
}[];
};
};
};
}

View File

@@ -16,7 +16,6 @@ export type TSourceType =
| 'rss'
| 'stackoverflow'
| 'tumblr'
| 'x'
| 'youtube'
| 'none';
@@ -46,6 +45,5 @@ export interface ISourceOptions {
rss?: string;
stackoverflow?: ISourceOptionsStackOverflow;
tumblr?: string;
x?: string;
youtube?: string;
}