mirror of
https://github.com/feeddeck/feeddeck.git
synced 2026-03-09 23:22:02 -05:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
046479071b | ||
|
|
ffbed73669 | ||
|
|
03371cf645 | ||
|
|
eb606b5f6c | ||
|
|
df927516b1 | ||
|
|
fa23e095e5 | ||
|
|
2298176c3b | ||
|
|
3f7caf4ad4 | ||
|
|
db9363e7af | ||
|
|
ce36761c64 | ||
|
|
4e38cfdb5c | ||
|
|
1c7c88a9ca | ||
|
|
c7d208de23 | ||
|
|
a7639344a1 | ||
|
|
ff76531962 | ||
|
|
dc77f16a34 | ||
|
|
bcd03e7e60 | ||
|
|
92bea5d715 | ||
|
|
37cd4dff6f | ||
|
|
d62bf10eaf | ||
|
|
3afbe5674b | ||
|
|
6d5a699db6 | ||
|
|
e19885a594 | ||
|
|
6e27eb751c | ||
|
|
8e3586f315 | ||
|
|
e994ab6214 | ||
|
|
4906f9dc27 | ||
|
|
55c6da07d9 | ||
|
|
8dc83a5d5a | ||
|
|
94d5732f6a | ||
|
|
f4a9f84061 |
72
.github/workflows/continuous-delivery.yaml
vendored
72
.github/workflows/continuous-delivery.yaml
vendored
@@ -166,7 +166,7 @@ jobs:
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.13.7'
|
||||
flutter-version: '3.16.0'
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:'
|
||||
@@ -209,7 +209,7 @@ jobs:
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.13.7'
|
||||
flutter-version: '3.16.0'
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:'
|
||||
@@ -271,7 +271,7 @@ jobs:
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.13.7'
|
||||
flutter-version: '3.16.0'
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:'
|
||||
@@ -329,7 +329,7 @@ jobs:
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.13.7'
|
||||
flutter-version: '3.16.0'
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:'
|
||||
@@ -364,3 +364,67 @@ jobs:
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: app/build/feeddeck-windows-x86_64.zip
|
||||
|
||||
# The "iOS" job builds the Flutter iOS app on every pull request. This is only used to test that the build of the iOS
|
||||
# app works. The artifact of the build isn't uploaded / used.
|
||||
ios:
|
||||
name: iOS
|
||||
runs-on: macos-latest
|
||||
if: github.event_name == 'pull_request' || (github.event_name == 'release' && github.event.action == 'published')
|
||||
defaults:
|
||||
run:
|
||||
working-directory: "app"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.16.0'
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:'
|
||||
cache-path: '${{ runner.tool_cache }}/flutter/:channel:-:version:-:arch:'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
flutter pub get
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
flutter config --enable-ios
|
||||
flutter build ipa --no-codesign --release --dart-define SUPABASE_URL=${{ secrets.SUPABASE_PROD_URL }} --dart-define SUPABASE_ANON_KEY=${{ secrets.SUPABASE_PROD_ANON_KEY }} --dart-define SUPABASE_SITE_URL=${{ secrets.SUPABASE_PROD_SITE_URL }} --dart-define GOOGLE_CLIENT_ID=${{ secrets.SUPABASE_PROD_GOOGLE_CLIENT_ID }}
|
||||
|
||||
# The "Android" job builds the Flutter Android app on every pull request. This is only used to test that the build of
|
||||
# the Android app works. The artifact of the build isn't uploaded / used.
|
||||
android:
|
||||
name: Android
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' || (github.event_name == 'release' && github.event.action == 'published')
|
||||
defaults:
|
||||
run:
|
||||
working-directory: "app"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.16.0'
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:'
|
||||
cache-path: '${{ runner.tool_cache }}/flutter/:channel:-:version:-:arch:'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
flutter pub get
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
flutter config --enable-android
|
||||
flutter build appbundle --release --dart-define SUPABASE_URL=${{ secrets.SUPABASE_PROD_URL }} --dart-define SUPABASE_ANON_KEY=${{ secrets.SUPABASE_PROD_ANON_KEY }} --dart-define SUPABASE_SITE_URL=${{ secrets.SUPABASE_PROD_SITE_URL }} --dart-define GOOGLE_CLIENT_ID=${{ secrets.SUPABASE_PROD_GOOGLE_CLIENT_ID }}
|
||||
|
||||
4
.github/workflows/release.yaml
vendored
4
.github/workflows/release.yaml
vendored
@@ -40,9 +40,9 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "16"
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: landing/package-lock.json
|
||||
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,9 @@
|
||||
# Visual Studio Code Launch Configurations
|
||||
.vscode/launch.json
|
||||
|
||||
# Neovim
|
||||
.nvim.lua
|
||||
|
||||
# Environment Variables
|
||||
/supabase/.env.local
|
||||
/supabase/.env.dev
|
||||
|
||||
@@ -57,13 +57,13 @@ check your installed version:
|
||||
```sh
|
||||
$ flutter --version
|
||||
|
||||
Flutter 3.13.7 • channel stable • https://github.com/flutter/flutter.git
|
||||
Framework • revision 2f708eb839 (4 days ago) • 2023-10-09 09:58:08 -0500
|
||||
Engine • revision a794cf2681
|
||||
Tools • Dart 3.1.3 • DevTools 2.25.0
|
||||
|
||||
Flutter 3.16.0 • channel stable • https://github.com/flutter/flutter.git
|
||||
Framework • revision db7ef5bf9f (9 days ago) • 2023-11-15 11:25:44 -0800
|
||||
Engine • revision 74d16627b9
|
||||
Tools • Dart 3.2.0 • DevTools 2.28.2
|
||||
|
||||
$ deno --version
|
||||
|
||||
deno 1.36.4 (release, aarch64-apple-darwin)
|
||||
v8 11.6.189.12
|
||||
typescript 5.1.6
|
||||
@@ -71,19 +71,24 @@ typescript 5.1.6
|
||||
|
||||
### Working with Flutter
|
||||
|
||||
We are recommending to use the
|
||||
[Visual Studio Code](https://docs.flutter.dev/development/tools/vs-code)
|
||||
extensions for development.
|
||||
To run the app you can use the [`run.sh`](./app/run.sh) script, which will
|
||||
automatically load the `.env` file from the Supabase project and passes the
|
||||
required variables to the `flutter run` command:
|
||||
|
||||
The easiest way to run the Flutter app within Visual Studio Code is to create a
|
||||
`.vscode/launch.json` file. Within the different configurations you have to
|
||||
provide the following arguments: `--dart-define SUPABASE_URL=<SUPABASE_URL>`,
|
||||
```sh
|
||||
./run.sh --device="chrome" --environment="local"
|
||||
```
|
||||
|
||||
Alternative you can also run the project from Visual Studio Code or Neovim
|
||||
with the following configuration files. Within the different configurations you
|
||||
have to provide the following arguments:
|
||||
`--dart-define SUPABASE_URL=<SUPABASE_URL>`,
|
||||
`--dart-define SUPABASE_ANON_KEY=<SUPABASE_ANON_KEY>`,
|
||||
`--dart-define SUPABASE_SITE_URL=<SUPABASE_SITE_URL>` and
|
||||
`--dart-define GOOGLE_CLIENT_ID=<GOOGLE_CLIENT_ID>`.
|
||||
|
||||
<details>
|
||||
<summary>Example: `.vscode/launch.json`</summary>
|
||||
<summary>Visual Studio Code: `.vscode/launch.json`</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -152,6 +157,53 @@ provide the following arguments: `--dart-define SUPABASE_URL=<SUPABASE_URL>`,
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Neovim: `.nvim.lua`</summary>
|
||||
|
||||
|
||||
```lua
|
||||
require('flutter-tools').setup_project({
|
||||
{
|
||||
name = 'Local - Chrome',
|
||||
flavor = 'debug',
|
||||
target = 'lib/main.dart',
|
||||
device = 'chrome',
|
||||
dart_define = {
|
||||
SUPABASE_URL = 'http://localhost:54321',
|
||||
SUPABASE_ANON_KEY = '<SUPABASE_ANON_KEY'
|
||||
SUPABASE_SITE_URL = '<SUPABASE_SITE_URL>',
|
||||
GOOGLE_CLIENT_ID = '<GOOGLE_CLIENT_ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
name = 'Local - iOS Simulator',
|
||||
flavor = 'debug',
|
||||
target = 'lib/main.dart',
|
||||
device = 'iPhone 14 Pro Max',
|
||||
dart_define = {
|
||||
SUPABASE_URL = 'http://localhost:54321',
|
||||
SUPABASE_ANON_KEY = '<SUPABASE_ANON_KEY'
|
||||
SUPABASE_SITE_URL = '<SUPABASE_SITE_URL>',
|
||||
GOOGLE_CLIENT_ID = '<GOOGLE_CLIENT_ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
name = 'Local - Chrome',
|
||||
flavor = 'debug',
|
||||
target = 'lib/main.dart',
|
||||
device = 'macOS',
|
||||
dart_define = {
|
||||
SUPABASE_URL = 'http://localhost:54321',
|
||||
SUPABASE_ANON_KEY = '<SUPABASE_ANON_KEY'
|
||||
SUPABASE_SITE_URL = '<SUPABASE_SITE_URL>',
|
||||
GOOGLE_CLIENT_ID = '<GOOGLE_CLIENT_ID',
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
#### Sort all Imports
|
||||
|
||||
To sort all imports in the Dart code in a uniformly way you have to run the
|
||||
|
||||
@@ -49,7 +49,6 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "app.feeddeck.feeddeck"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
|
||||
@@ -72,7 +71,12 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
if (project.hasProperty("keyStoreFile")) {
|
||||
signingConfig signingConfigs.release
|
||||
} else {
|
||||
// For testing purposes we sign with dummy credentials if no key properties are given.
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
app/devtools_options.yaml
Normal file
1
app/devtools_options.yaml
Normal file
@@ -0,0 +1 @@
|
||||
extensions:
|
||||
Binary file not shown.
@@ -13,19 +13,25 @@ PODS:
|
||||
- FMDB/standard (2.7.5)
|
||||
- just_audio (0.0.1):
|
||||
- Flutter
|
||||
- media_kit_libs_ios_video (1.0.4):
|
||||
- Flutter
|
||||
- media_kit_native_event_loop (1.0.0):
|
||||
- Flutter
|
||||
- media_kit_video (0.0.1):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- purchases_flutter (6.0.0):
|
||||
- purchases_flutter (6.4.0):
|
||||
- Flutter
|
||||
- PurchasesHybridCommon (= 8.0.0)
|
||||
- PurchasesHybridCommon (8.0.0):
|
||||
- RevenueCat (= 4.30.5)
|
||||
- RevenueCat (4.30.5)
|
||||
- screen_brightness_ios (0.1.0):
|
||||
- Flutter
|
||||
- PurchasesHybridCommon (= 7.0.0)
|
||||
- PurchasesHybridCommon (7.0.0):
|
||||
- RevenueCat (= 4.27.0)
|
||||
- RevenueCat (4.27.0)
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -36,6 +42,10 @@ PODS:
|
||||
- FMDB (>= 2.7.5)
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- volume_controller (0.0.1):
|
||||
- Flutter
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
- webview_flutter_wkwebview (0.0.1):
|
||||
- Flutter
|
||||
|
||||
@@ -46,14 +56,19 @@ DEPENDENCIES:
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||
- just_audio (from `.symlinks/plugins/just_audio/ios`)
|
||||
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
|
||||
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
|
||||
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- purchases_flutter (from `.symlinks/plugins/purchases_flutter/ios`)
|
||||
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`)
|
||||
- sqflite (from `.symlinks/plugins/sqflite/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- volume_controller (from `.symlinks/plugins/volume_controller/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
@@ -75,14 +90,20 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||
just_audio:
|
||||
:path: ".symlinks/plugins/just_audio/ios"
|
||||
media_kit_libs_ios_video:
|
||||
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
|
||||
media_kit_native_event_loop:
|
||||
:path: ".symlinks/plugins/media_kit_native_event_loop/ios"
|
||||
media_kit_video:
|
||||
:path: ".symlinks/plugins/media_kit_video/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
purchases_flutter:
|
||||
:path: ".symlinks/plugins/purchases_flutter/ios"
|
||||
screen_brightness_ios:
|
||||
:path: ".symlinks/plugins/screen_brightness_ios/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sign_in_with_apple:
|
||||
@@ -91,6 +112,10 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/sqflite/ios"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
volume_controller:
|
||||
:path: ".symlinks/plugins/volume_controller/ios"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
webview_flutter_wkwebview:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
|
||||
|
||||
@@ -102,18 +127,23 @@ SPEC CHECKSUMS:
|
||||
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
just_audio: baa7252489dbcf47a4c7cc9ca663e9661c99aafa
|
||||
media_kit_native_event_loop: f1ee9f941ec0af371b245969a3e010901c375480
|
||||
package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
|
||||
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
|
||||
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
|
||||
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
|
||||
package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85
|
||||
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
||||
purchases_flutter: 549ccfbbaf5e7cd195043c714b69a35e278c00f1
|
||||
PurchasesHybridCommon: af3b2413f9cb999bc1fdca44770bdaf39dfb89fa
|
||||
RevenueCat: 84fbe2eb9bbf63e1abf346ccd3ff9ee45d633e3b
|
||||
purchases_flutter: a428f3e8ac54dfb499ff190efa99d6701094bc32
|
||||
PurchasesHybridCommon: 80262c5ffe6621e3cf3812e6103170f6d7fbcb79
|
||||
RevenueCat: c1e33f4e1f1fd239ba461652f02928e220becc31
|
||||
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
|
||||
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
|
||||
sign_in_with_apple: f3bf75217ea4c2c8b91823f225d70230119b8440
|
||||
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
|
||||
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
|
||||
url_launcher_ios: bf5ce03e0e2088bad9cc378ea97fa0ed5b49673b
|
||||
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
|
||||
wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47
|
||||
webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a
|
||||
|
||||
PODFILE CHECKSUM: ec83c31511fbc978a9918c6fda235238118483f5
|
||||
|
||||
COCOAPODS: 1.13.0
|
||||
COCOAPODS: 1.14.2
|
||||
|
||||
@@ -7,10 +7,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||
import 'package:just_audio_background/just_audio_background.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'package:feeddeck/repositories/app_repository.dart';
|
||||
import 'package:feeddeck/repositories/layout_repository.dart';
|
||||
import 'package:feeddeck/repositories/profile_repository.dart';
|
||||
import 'package:feeddeck/repositories/settings_repository.dart';
|
||||
import 'package:feeddeck/utils/constants.dart';
|
||||
@@ -45,13 +47,23 @@ void main() async {
|
||||
});
|
||||
}
|
||||
|
||||
/// Initialize the [media_kit] packages, so that we can play audio and video
|
||||
/// files.
|
||||
MediaKit.ensureInitialized();
|
||||
|
||||
/// Initialize the [just_audio_background] package, so that we can play audio
|
||||
/// files in the background.
|
||||
await JustAudioBackground.init(
|
||||
androidNotificationChannelId: 'com.ryanheise.bg_demo.channel.audio',
|
||||
androidNotificationChannelName: 'Audio playback',
|
||||
androidNotificationOngoing: true,
|
||||
);
|
||||
///
|
||||
/// We can not initialize the [just_audio_background] package on Windows and
|
||||
/// Linux, because then the returned duration in the `_player.durationStream`
|
||||
/// isn't working correctly in the [ItemAudioPlayer] widget.
|
||||
if (kIsWeb || Platform.isAndroid || Platform.isIOS || Platform.isIOS) {
|
||||
await JustAudioBackground.init(
|
||||
androidNotificationChannelId: 'com.ryanheise.bg_demo.channel.audio',
|
||||
androidNotificationChannelName: 'Audio playback',
|
||||
androidNotificationOngoing: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// For the ewb we have to use the path url strategy, so that the redirect
|
||||
/// within Supabase is working in all cases. On all other platforms this is a
|
||||
@@ -124,6 +136,7 @@ class FeedDeckApp extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => LayoutRepository()),
|
||||
ChangeNotifierProvider(create: (_) => AppRepository()),
|
||||
ChangeNotifierProvider(create: (_) => ProfileRepository()),
|
||||
],
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:feeddeck/models/sources/github.dart';
|
||||
import 'package:feeddeck/models/sources/googlenews.dart';
|
||||
import 'package:feeddeck/models/sources/stackoverflow.dart';
|
||||
import 'package:feeddeck/utils/constants.dart';
|
||||
import 'package:feeddeck/utils/fd_icons.dart';
|
||||
|
||||
/// [FDSourceType] is a enum value which defines the source type. A source can
|
||||
/// have one of the following types:
|
||||
@@ -12,6 +13,7 @@ import 'package:feeddeck/utils/constants.dart';
|
||||
/// - [mastodon]
|
||||
/// - [medium]
|
||||
/// - [nitter]
|
||||
/// - [pinterest]
|
||||
/// - [podcast]
|
||||
/// - [reddit]
|
||||
/// - [rss]
|
||||
@@ -30,6 +32,7 @@ enum FDSourceType {
|
||||
mastodon,
|
||||
medium,
|
||||
nitter,
|
||||
pinterest,
|
||||
podcast,
|
||||
reddit,
|
||||
rss,
|
||||
@@ -62,6 +65,8 @@ extension FDSourceTypeExtension on FDSourceType {
|
||||
return 'Medium';
|
||||
case FDSourceType.nitter:
|
||||
return 'Nitter';
|
||||
case FDSourceType.pinterest:
|
||||
return 'Pinterest';
|
||||
case FDSourceType.podcast:
|
||||
return 'Podcast';
|
||||
case FDSourceType.reddit:
|
||||
@@ -81,9 +86,42 @@ extension FDSourceTypeExtension on FDSourceType {
|
||||
}
|
||||
}
|
||||
|
||||
/// [color] returns the brand color for a source type, which can be used as
|
||||
/// background color for the icon of a source type.
|
||||
Color get color {
|
||||
/// [icon] returns the icon for a source.
|
||||
IconData get icon {
|
||||
switch (this) {
|
||||
case FDSourceType.github:
|
||||
return FDIcons.github;
|
||||
case FDSourceType.googlenews:
|
||||
return FDIcons.googlenews;
|
||||
case FDSourceType.mastodon:
|
||||
return FDIcons.mastodon;
|
||||
case FDSourceType.medium:
|
||||
return FDIcons.medium;
|
||||
case FDSourceType.nitter:
|
||||
return FDIcons.nitter;
|
||||
case FDSourceType.pinterest:
|
||||
return FDIcons.pinterest;
|
||||
case FDSourceType.podcast:
|
||||
return Icons.podcasts;
|
||||
case FDSourceType.reddit:
|
||||
return FDIcons.reddit;
|
||||
case FDSourceType.rss:
|
||||
return FDIcons.rss;
|
||||
case FDSourceType.stackoverflow:
|
||||
return FDIcons.stackoverflow;
|
||||
case FDSourceType.tumblr:
|
||||
return FDIcons.tumblr;
|
||||
// case FDSourceType.x:
|
||||
// return FDIcons.x;
|
||||
case FDSourceType.youtube:
|
||||
return FDIcons.youtube;
|
||||
default:
|
||||
return FDIcons.feeddeck;
|
||||
}
|
||||
}
|
||||
|
||||
/// [bgColor] returns the background color for the source icon.
|
||||
Color get bgColor {
|
||||
switch (this) {
|
||||
case FDSourceType.github:
|
||||
return const Color(0xff000000);
|
||||
@@ -95,6 +133,8 @@ extension FDSourceTypeExtension on FDSourceType {
|
||||
return const Color(0xff000000);
|
||||
case FDSourceType.nitter:
|
||||
return const Color(0xffff6c60);
|
||||
case FDSourceType.pinterest:
|
||||
return const Color(0xffe60023);
|
||||
case FDSourceType.podcast:
|
||||
return const Color(0xff872ec4);
|
||||
case FDSourceType.reddit:
|
||||
@@ -113,6 +153,41 @@ extension FDSourceTypeExtension on FDSourceType {
|
||||
return Constants.primary;
|
||||
}
|
||||
}
|
||||
|
||||
/// [fgColor] returns the forground color for the source icon. This should be
|
||||
/// used toether with the [bgColor].
|
||||
Color get fgColor {
|
||||
switch (this) {
|
||||
case FDSourceType.github:
|
||||
return const Color(0xffffffff);
|
||||
case FDSourceType.googlenews:
|
||||
return const Color(0xffffffff);
|
||||
case FDSourceType.mastodon:
|
||||
return const Color(0xffffffff);
|
||||
case FDSourceType.medium:
|
||||
return const Color(0xffffffff);
|
||||
case FDSourceType.nitter:
|
||||
return const Color(0xffffffff);
|
||||
case FDSourceType.pinterest:
|
||||
return const Color(0xffffffff);
|
||||
case FDSourceType.podcast:
|
||||
return const Color(0xffffffff);
|
||||
case FDSourceType.reddit:
|
||||
return const Color(0xffffffff);
|
||||
case FDSourceType.rss:
|
||||
return const Color(0xffffffff);
|
||||
case FDSourceType.stackoverflow:
|
||||
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:
|
||||
return Constants.onPrimary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [getSourceTypeFromString] returns the [FDSourceType] from his string
|
||||
@@ -173,7 +248,7 @@ class FDSource {
|
||||
'id': id,
|
||||
'type': type.toShortString(),
|
||||
'title': title,
|
||||
'options': options != null ? options!.toJson() : null,
|
||||
'options': options?.toJson(),
|
||||
'link': link,
|
||||
'icon': icon,
|
||||
};
|
||||
@@ -188,6 +263,7 @@ class FDSourceOptions {
|
||||
String? mastodon;
|
||||
String? medium;
|
||||
String? nitter;
|
||||
String? pinterest;
|
||||
String? podcast;
|
||||
String? reddit;
|
||||
String? rss;
|
||||
@@ -202,6 +278,7 @@ class FDSourceOptions {
|
||||
this.mastodon,
|
||||
this.medium,
|
||||
this.nitter,
|
||||
this.pinterest,
|
||||
this.podcast,
|
||||
this.reddit,
|
||||
this.rss,
|
||||
@@ -233,6 +310,10 @@ class FDSourceOptions {
|
||||
responseData.containsKey('nitter') && responseData['nitter'] != null
|
||||
? responseData['nitter']
|
||||
: null,
|
||||
pinterest: responseData.containsKey('pinterest') &&
|
||||
responseData['pinterest'] != null
|
||||
? responseData['pinterest']
|
||||
: null,
|
||||
podcast:
|
||||
responseData.containsKey('podcast') && responseData['podcast'] != null
|
||||
? responseData['podcast']
|
||||
@@ -269,6 +350,7 @@ class FDSourceOptions {
|
||||
'mastodon': mastodon,
|
||||
'medium': medium,
|
||||
'nitter': nitter,
|
||||
'pinterest': pinterest,
|
||||
'podcast': podcast,
|
||||
'reddit': reddit,
|
||||
'rss': rss,
|
||||
|
||||
12
app/lib/repositories/layout_repository.dart
Normal file
12
app/lib/repositories/layout_repository.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// The [LayoutRepository] is used to store several layout information of the
|
||||
/// app, which can be modifed or should be modifed within different locations.
|
||||
class LayoutRepository with ChangeNotifier {
|
||||
/// [_deckLayoutSmallInitialTabIndex] stores the selected tab index of the
|
||||
/// [DeckLayoutSmall] widget. This is used that we display the same tab when
|
||||
/// a user switches between the small and large layout (e.g. portrait and
|
||||
/// landscape mode on mobile devices) and that we can reset the tab index when
|
||||
/// a user selects a new deck.
|
||||
int deckLayoutSmallInitialTabIndex = 0;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
/// Flutter icons FDIcons
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Flutter icons [FDIcons]
|
||||
/// Copyright (C) 2023 by original authors @ fluttericon.com, fontello.com
|
||||
/// This font was generated by FlutterIcon.com, which is derived from Fontello.
|
||||
///
|
||||
@@ -11,11 +13,6 @@
|
||||
/// fonts:
|
||||
/// - asset: fonts/FDIcons.ttf
|
||||
///
|
||||
///
|
||||
///
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class FDIcons {
|
||||
FDIcons._();
|
||||
|
||||
@@ -32,6 +29,8 @@ class FDIcons {
|
||||
IconData(0xe803, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData googlenews =
|
||||
IconData(0xe804, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData pinterest =
|
||||
IconData(0xe805, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData medium =
|
||||
IconData(0xe806, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData nitter =
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import 'package:feeddeck/repositories/settings_repository.dart';
|
||||
|
||||
/// [FDImageType] is a enum value which defines the image type. An image can be
|
||||
/// related to an item or a source.
|
||||
enum FDImageType {
|
||||
item,
|
||||
source,
|
||||
}
|
||||
|
||||
/// [getImageUrl] returns the correct image url to use for the provided image
|
||||
/// url:
|
||||
/// - If the [imageType] is [FDImageType.source] the image is always requested
|
||||
/// from the Supabase storage.
|
||||
/// - If the [imageType] is [FDImageType.item] and the app runs on the web, the
|
||||
/// image is proxied through the Supabase functions.
|
||||
/// - If the [imageType] is [FDImageType.item] and the app runs on a mobile or
|
||||
/// desktop device, the image is requested directly from the provided url.
|
||||
String getImageUrl(FDImageType imageType, String imageUrl) {
|
||||
if (imageType == FDImageType.source) {
|
||||
return '${SettingsRepository().supabaseUrl}/storage/v1/object/public/sources/$imageUrl';
|
||||
}
|
||||
|
||||
if (kIsWeb) {
|
||||
return '${Supabase.instance.client.functionsUrl}/image-proxy-v1?media=${Uri.encodeQueryComponent(imageUrl)}';
|
||||
}
|
||||
|
||||
return imageUrl;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:scroll_to_index/scroll_to_index.dart';
|
||||
|
||||
import 'package:feeddeck/models/source.dart';
|
||||
import 'package:feeddeck/repositories/app_repository.dart';
|
||||
import 'package:feeddeck/repositories/layout_repository.dart';
|
||||
import 'package:feeddeck/utils/constants.dart';
|
||||
import 'package:feeddeck/widgets/column/column_layout.dart';
|
||||
import 'package:feeddeck/widgets/column/create/create_column.dart';
|
||||
@@ -224,10 +225,20 @@ class _DeckLayoutLargeState extends State<DeckLayoutLarge> {
|
||||
backgroundColor: Constants.background,
|
||||
selectedIndex: null,
|
||||
|
||||
/// When a user selects a destination in the navigation rail
|
||||
/// we scroll to the corresponding column by using the
|
||||
/// `scroll_to_index` package.
|
||||
/// When a user selects a destination in the navigation
|
||||
/// rail we scroll to the corresponding column by using
|
||||
/// the `scroll_to_index` package.
|
||||
onDestinationSelected: (int index) {
|
||||
/// Before we scroll to the corresponding column, we
|
||||
/// also update the [deckLayoutSmallInitialTabIndex] in
|
||||
/// the [LayoutRepository] so that the correct tab is
|
||||
/// also selected when a user switches to the small
|
||||
/// layout.
|
||||
Provider.of<LayoutRepository>(
|
||||
context,
|
||||
listen: false,
|
||||
).deckLayoutSmallInitialTabIndex = index;
|
||||
|
||||
_scrollController.scrollToIndex(
|
||||
index,
|
||||
preferPosition: AutoScrollPosition.end,
|
||||
@@ -244,8 +255,8 @@ class _DeckLayoutLargeState extends State<DeckLayoutLarge> {
|
||||
|
||||
/// We add two additional items to the navigation rail via
|
||||
/// the trailing property. These items are used to allow a
|
||||
/// user to create a new column and to go to the settings of
|
||||
/// the app.
|
||||
/// user to create a new column and to go to the settings
|
||||
/// of the app.
|
||||
trailing: Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:feeddeck/models/source.dart';
|
||||
import 'package:feeddeck/repositories/app_repository.dart';
|
||||
import 'package:feeddeck/repositories/layout_repository.dart';
|
||||
import 'package:feeddeck/utils/constants.dart';
|
||||
import 'package:feeddeck/widgets/column/column_layout.dart';
|
||||
import 'package:feeddeck/widgets/column/create/create_column.dart';
|
||||
@@ -20,6 +21,30 @@ import 'package:feeddeck/widgets/source/source_icon.dart';
|
||||
class DeckLayoutSmall extends StatelessWidget {
|
||||
const DeckLayoutSmall({super.key});
|
||||
|
||||
/// [_getInitialIndex] returns the initial index for the
|
||||
/// [DefaultTabController]. The intial index is saved in the
|
||||
/// [LayoutRepository] so that we can use it when a user switches between the
|
||||
/// small and large layout or between decks. To not run into errors we have to
|
||||
/// check that a column which should be used was not already deleted by a
|
||||
/// user before returning the index. If a column was deleted we reset the
|
||||
/// index to 0.
|
||||
int _getInitialIndex(BuildContext context, int columnsLength) {
|
||||
final deckLayoutSmallInitialTabIndex = Provider.of<LayoutRepository>(
|
||||
context,
|
||||
listen: false,
|
||||
).deckLayoutSmallInitialTabIndex;
|
||||
|
||||
if (deckLayoutSmallInitialTabIndex >= columnsLength) {
|
||||
Provider.of<LayoutRepository>(
|
||||
context,
|
||||
listen: false,
|
||||
).deckLayoutSmallInitialTabIndex = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
return deckLayoutSmallInitialTabIndex;
|
||||
}
|
||||
|
||||
/// [_buildTabs] returns all items for the tab bar. The items are generated
|
||||
/// by looping thorugh the `columns` defined in the [AppRepository].
|
||||
///
|
||||
@@ -124,6 +149,7 @@ class DeckLayoutSmall extends StatelessWidget {
|
||||
AppRepository app = Provider.of<AppRepository>(context, listen: true);
|
||||
|
||||
return DefaultTabController(
|
||||
initialIndex: _getInitialIndex(context, app.columns.length),
|
||||
length: app.columns.length,
|
||||
child: Scaffold(
|
||||
bottomNavigationBar: SafeArea(
|
||||
@@ -148,6 +174,17 @@ class DeckLayoutSmall extends StatelessWidget {
|
||||
),
|
||||
child: TabBar(
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
onTap: (int index) {
|
||||
/// When the user clicks on a tab we update the index in
|
||||
/// the [LayoutRepository] so that we can use it as
|
||||
/// initial index when the widget is rebuild (e.g. when
|
||||
/// a user switches between the large and small layout).
|
||||
Provider.of<LayoutRepository>(
|
||||
context,
|
||||
listen: false,
|
||||
).deckLayoutSmallInitialTabIndex = index;
|
||||
},
|
||||
tabs: _buildTabs(context),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -24,7 +24,7 @@ import 'package:flutter/material.dart';
|
||||
/// ),
|
||||
/// )
|
||||
class ElevatedButtonProgressIndicator extends StatelessWidget {
|
||||
const ElevatedButtonProgressIndicator({Key? key}) : super(key: key);
|
||||
const ElevatedButtonProgressIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:feeddeck/utils/openurl.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';
|
||||
import 'package:feeddeck/widgets/item/details/item_details_pinterest.dart';
|
||||
import 'package:feeddeck/widgets/item/details/item_details_podcast.dart';
|
||||
import 'package:feeddeck/widgets/item/details/item_details_reddit.dart';
|
||||
import 'package:feeddeck/widgets/item/details/item_details_rss.dart';
|
||||
@@ -85,6 +86,11 @@ class ItemDetails extends StatelessWidget {
|
||||
item: item,
|
||||
source: source,
|
||||
);
|
||||
case FDSourceType.pinterest:
|
||||
return ItemDetailsPinterest(
|
||||
item: item,
|
||||
source: source,
|
||||
);
|
||||
case FDSourceType.podcast:
|
||||
return ItemDetailsPodcast(
|
||||
item: item,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
import 'package:feeddeck/widgets/item/details/utils/item_videos.dart';
|
||||
|
||||
class ItemDetailsMastodon extends StatelessWidget {
|
||||
const ItemDetailsMastodon({
|
||||
@@ -36,12 +37,23 @@ class ItemDetailsMastodon extends StatelessWidget {
|
||||
height: Constants.spacingExtraSmall,
|
||||
),
|
||||
ItemMediaGallery(
|
||||
itemMedias: item.options != null && item.options!.containsKey('media')
|
||||
itemMedias: item.options != null &&
|
||||
item.options!.containsKey('media') &&
|
||||
item.options!['media'] != null
|
||||
? (item.options!['media'] as List)
|
||||
.map((item) => item as String)
|
||||
.toList()
|
||||
: null,
|
||||
),
|
||||
ItemVideos(
|
||||
videos: item.options != null &&
|
||||
item.options!.containsKey('videos') &&
|
||||
item.options!['videos'] != null
|
||||
? (item.options!['videos'] as List)
|
||||
.map((item) => item as String)
|
||||
.toList()
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
40
app/lib/widgets/item/details/item_details_pinterest.dart
Normal file
40
app/lib/widgets/item/details/item_details_pinterest.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:feeddeck/models/item.dart';
|
||||
import 'package:feeddeck/models/source.dart';
|
||||
import 'package:feeddeck/widgets/item/details/utils/item_description.dart';
|
||||
import 'package:feeddeck/widgets/item/details/utils/item_subtitle.dart';
|
||||
import 'package:feeddeck/widgets/item/details/utils/item_title.dart';
|
||||
|
||||
class ItemDetailsPinterest extends StatelessWidget {
|
||||
const ItemDetailsPinterest({
|
||||
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: [
|
||||
ItemTitle(
|
||||
itemTitle: item.title,
|
||||
),
|
||||
ItemSubtitle(
|
||||
item: item,
|
||||
source: source,
|
||||
),
|
||||
ItemDescription(
|
||||
itemDescription: item.description,
|
||||
sourceFormat: DescriptionFormat.html,
|
||||
tagetFormat: DescriptionFormat.markdown,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
import 'package:feeddeck/models/item.dart';
|
||||
import 'package:feeddeck/models/source.dart';
|
||||
import 'package:feeddeck/utils/constants.dart';
|
||||
import 'package:feeddeck/utils/image_url.dart';
|
||||
import 'package:feeddeck/widgets/item/details/utils/item_audio_palyer/item_audio_player.dart';
|
||||
import 'package:feeddeck/widgets/item/details/utils/item_description.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/utils/cached_network_image.dart';
|
||||
|
||||
class ItemDetailsPodcast extends StatelessWidget {
|
||||
const ItemDetailsPodcast({
|
||||
@@ -21,14 +19,6 @@ class ItemDetailsPodcast extends StatelessWidget {
|
||||
final FDItem item;
|
||||
final FDSource source;
|
||||
|
||||
String? _buildImageUrl() {
|
||||
if (source.icon != null && source.icon != '') {
|
||||
return getImageUrl(FDImageType.source, source.icon!);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// [_buildImage] returns an image which is displayed above the
|
||||
/// [ItemAudioPlayer]. For this image we are using the [source.icon] if it is
|
||||
/// available. If the [source.icon] is not available, we are not displaying
|
||||
@@ -55,8 +45,6 @@ class ItemDetailsPodcast extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final imageUrl = _buildImageUrl();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
@@ -74,7 +62,7 @@ class ItemDetailsPodcast extends StatelessWidget {
|
||||
/// directly listen to the podcast episode.
|
||||
Column(
|
||||
children: [
|
||||
_buildImage(imageUrl),
|
||||
_buildImage(source.icon),
|
||||
const SizedBox(
|
||||
height: Constants.spacingMiddle,
|
||||
),
|
||||
@@ -83,7 +71,7 @@ class ItemDetailsPodcast extends StatelessWidget {
|
||||
audioFile: item.media!,
|
||||
audioTitle: item.title,
|
||||
audioArtist: source.title,
|
||||
audioArt: imageUrl,
|
||||
audioArt: getImageUrl(source.icon!),
|
||||
),
|
||||
const SizedBox(
|
||||
height: Constants.spacingMiddle,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:html/parser.dart' show parse;
|
||||
|
||||
import 'package:feeddeck/models/item.dart';
|
||||
import 'package:feeddeck/models/source.dart';
|
||||
import 'package:feeddeck/widgets/item/details/utils/item_description.dart';
|
||||
@@ -17,8 +19,26 @@ class ItemDetailsRSS extends StatelessWidget {
|
||||
final FDItem item;
|
||||
final FDSource source;
|
||||
|
||||
/// [_buildImage] renders the [item.media] when the [shouldBeRendered] is
|
||||
/// `true`. If it is `false` an empty container is returned.
|
||||
Widget _buildImage(bool shouldBeRendered) {
|
||||
if (!shouldBeRendered) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
return ItemMedia(
|
||||
itemMedia: item.media,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
/// Check if the description of the RSS feed contains an image. If this is
|
||||
/// the case we do not render the image from the [item.media] because the
|
||||
/// image is already rendered in the [ItemDescription] widget.
|
||||
final descriptionContainImage =
|
||||
parse(item.description).querySelectorAll('img').isNotEmpty;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
@@ -30,13 +50,11 @@ class ItemDetailsRSS extends StatelessWidget {
|
||||
item: item,
|
||||
source: source,
|
||||
),
|
||||
ItemMedia(
|
||||
itemMedia: item.media,
|
||||
),
|
||||
_buildImage(!descriptionContainImage),
|
||||
ItemDescription(
|
||||
itemDescription: item.description,
|
||||
sourceFormat: DescriptionFormat.plain,
|
||||
tagetFormat: DescriptionFormat.plain,
|
||||
sourceFormat: DescriptionFormat.html,
|
||||
tagetFormat: DescriptionFormat.markdown,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:youtube_player_iframe/youtube_player_iframe.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_youtube/item_youtube_video.dart';
|
||||
|
||||
class ItemDetailsYoutube extends StatelessWidget {
|
||||
const ItemDetailsYoutube({
|
||||
@@ -23,22 +17,6 @@ class ItemDetailsYoutube extends StatelessWidget {
|
||||
final FDItem item;
|
||||
final FDSource source;
|
||||
|
||||
/// [_buildMedia] returns the media element for the item. On the web we are
|
||||
/// using the [YoutubeVideo] widget to render the video, so that a user can
|
||||
/// directly play the YouTube video. On all other platforms we display the
|
||||
/// thumbnail of the video.
|
||||
Widget _buildMedia() {
|
||||
if (kIsWeb || Platform.isAndroid || Platform.isIOS) {
|
||||
return YoutubeVideo(
|
||||
videoUrl: item.link,
|
||||
);
|
||||
}
|
||||
|
||||
return ItemMedia(
|
||||
itemMedia: item.media,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
@@ -52,7 +30,10 @@ class ItemDetailsYoutube extends StatelessWidget {
|
||||
item: item,
|
||||
source: source,
|
||||
),
|
||||
_buildMedia(),
|
||||
ItemYoutubeVideo(
|
||||
item.media,
|
||||
item.link,
|
||||
),
|
||||
ItemDescription(
|
||||
itemDescription: item.description,
|
||||
sourceFormat: DescriptionFormat.plain,
|
||||
@@ -62,51 +43,3 @@ class ItemDetailsYoutube extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class YoutubeVideo extends StatefulWidget {
|
||||
const YoutubeVideo({
|
||||
super.key,
|
||||
required this.videoUrl,
|
||||
});
|
||||
|
||||
final String videoUrl;
|
||||
|
||||
@override
|
||||
State<YoutubeVideo> createState() => _YoutubeVideoState();
|
||||
}
|
||||
|
||||
class _YoutubeVideoState extends State<YoutubeVideo> {
|
||||
late YoutubePlayerController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = YoutubePlayerController(
|
||||
params: const YoutubePlayerParams(
|
||||
showControls: true,
|
||||
showFullscreenButton: false,
|
||||
),
|
||||
);
|
||||
|
||||
_controller.cueVideoByUrl(mediaContentUrl: widget.videoUrl);
|
||||
_controller.cueVideoById(
|
||||
videoId: widget.videoUrl.replaceFirst(
|
||||
'https://www.youtube.com/watch?v=',
|
||||
'',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Constants.spacingMiddle,
|
||||
),
|
||||
child: YoutubePlayer(
|
||||
controller: _controller,
|
||||
aspectRatio: 16 / 9,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:html2md/html2md.dart' as html2md;
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import 'package:feeddeck/utils/constants.dart';
|
||||
import 'package:feeddeck/utils/font.dart';
|
||||
import 'package:feeddeck/utils/openurl.dart';
|
||||
import 'package:feeddeck/widgets/utils/cached_network_image.dart';
|
||||
|
||||
/// The [DescriptionFormat] enum defines the source and target format of a
|
||||
/// description.
|
||||
@@ -47,7 +45,7 @@ class ItemDescription extends StatelessWidget {
|
||||
Widget _buildMarkdown(BuildContext context, String content) {
|
||||
return MarkdownBody(
|
||||
selectable: true,
|
||||
data: content,
|
||||
data: content.trim(),
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
code: TextStyle(
|
||||
fontFamily: getMonospaceFontFamily(),
|
||||
@@ -67,12 +65,6 @@ class ItemDescription extends StatelessWidget {
|
||||
return Container();
|
||||
}
|
||||
|
||||
String imageUrl = uri.toString();
|
||||
if (kIsWeb) {
|
||||
imageUrl =
|
||||
'${Supabase.instance.client.functionsUrl}/image-proxy-v1?media=${Uri.encodeQueryComponent(imageUrl)}';
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Constants.spacingMiddle,
|
||||
@@ -87,6 +79,9 @@ class ItemDescription extends StatelessWidget {
|
||||
isDismissible: true,
|
||||
useSafeArea: true,
|
||||
backgroundColor: Colors.black,
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: double.infinity,
|
||||
),
|
||||
builder: (BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
@@ -95,7 +90,7 @@ class ItemDescription extends StatelessWidget {
|
||||
Center(
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.contain,
|
||||
imageUrl: imageUrl,
|
||||
imageUrl: uri.toString(),
|
||||
placeholder: (context, url) => Container(),
|
||||
errorWidget: (context, url, error) => Container(),
|
||||
),
|
||||
@@ -121,7 +116,7 @@ class ItemDescription extends StatelessWidget {
|
||||
child: CachedNetworkImage(
|
||||
width: double.infinity,
|
||||
fit: BoxFit.contain,
|
||||
imageUrl: imageUrl,
|
||||
imageUrl: uri.toString(),
|
||||
placeholder: (context, url) => Container(),
|
||||
errorWidget: (context, url, error) => Container(),
|
||||
),
|
||||
@@ -135,7 +130,7 @@ class ItemDescription extends StatelessWidget {
|
||||
/// [_buildPlain] renders the provided [content] as plain text.
|
||||
Widget _buildPlain(String content) {
|
||||
return SelectableText(
|
||||
content,
|
||||
content.trim(),
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.normal,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
import 'package:feeddeck/utils/constants.dart';
|
||||
import 'package:feeddeck/utils/image_url.dart';
|
||||
import 'package:feeddeck/widgets/utils/cached_network_image.dart';
|
||||
|
||||
/// The [ItemMedia] widget displays the media of an item. Based on the provided
|
||||
/// [itemMedia] value the media is displayed from the Supabase storage or
|
||||
@@ -22,8 +20,6 @@ class ItemMedia extends StatelessWidget {
|
||||
return Container();
|
||||
}
|
||||
|
||||
final imageUrl = getImageUrl(FDImageType.item, itemMedia!);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Constants.spacingMiddle,
|
||||
@@ -38,6 +34,9 @@ class ItemMedia extends StatelessWidget {
|
||||
isDismissible: true,
|
||||
useSafeArea: true,
|
||||
backgroundColor: Colors.black,
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: double.infinity,
|
||||
),
|
||||
builder: (BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
@@ -46,7 +45,7 @@ class ItemMedia extends StatelessWidget {
|
||||
Center(
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.contain,
|
||||
imageUrl: imageUrl,
|
||||
imageUrl: itemMedia!,
|
||||
placeholder: (context, url) => Container(),
|
||||
errorWidget: (context, url, error) => Container(),
|
||||
),
|
||||
@@ -72,7 +71,7 @@ class ItemMedia extends StatelessWidget {
|
||||
child: CachedNetworkImage(
|
||||
width: double.infinity,
|
||||
fit: BoxFit.contain,
|
||||
imageUrl: imageUrl,
|
||||
imageUrl: itemMedia!,
|
||||
placeholder: (context, url) => Container(),
|
||||
errorWidget: (context, url, error) => Container(),
|
||||
),
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:carousel_slider/carousel_slider.dart';
|
||||
|
||||
import 'package:feeddeck/utils/constants.dart';
|
||||
import 'package:feeddeck/utils/image_url.dart';
|
||||
import 'package:feeddeck/widgets/item/details/utils/item_media.dart';
|
||||
import 'package:feeddeck/widgets/utils/cached_network_image.dart';
|
||||
|
||||
/// The [ItemMediaGallery] widget can be used to display multiple media files in
|
||||
/// a gallery. Similar to the [ItemMedia] widget the provided [itemMedias]
|
||||
/// values can be displayed from the Supabase storage or directly from the
|
||||
/// a gallery. Similar to the [ItemMedia] widget the provided [itemMedias]
|
||||
/// provided url.
|
||||
class ItemMediaGallery extends StatelessWidget {
|
||||
const ItemMediaGallery({
|
||||
@@ -22,8 +21,6 @@ class ItemMediaGallery extends StatelessWidget {
|
||||
/// [_buildSingleMedia] displays a single media file in the gallery. If the
|
||||
/// app runs on the web, we proxy the url through the Supabase functions.
|
||||
Widget _buildSingleMedia(BuildContext context, int itemMediaIndex) {
|
||||
final imageUrl = getImageUrl(FDImageType.item, itemMedias![itemMediaIndex]);
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
@@ -34,6 +31,9 @@ class ItemMediaGallery extends StatelessWidget {
|
||||
isDismissible: true,
|
||||
useSafeArea: true,
|
||||
backgroundColor: Colors.black,
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: double.infinity,
|
||||
),
|
||||
builder: (BuildContext context) {
|
||||
return ItemMediaGalleryModal(
|
||||
initialItemMediaIndex: itemMediaIndex,
|
||||
@@ -45,7 +45,7 @@ class ItemMediaGallery extends StatelessWidget {
|
||||
child: CachedNetworkImage(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: imageUrl,
|
||||
imageUrl: itemMedias![itemMediaIndex],
|
||||
placeholder: (context, url) => Container(),
|
||||
errorWidget: (context, url, error) => Container(),
|
||||
),
|
||||
@@ -179,7 +179,7 @@ class ItemMediaGalleryModal extends StatelessWidget {
|
||||
(itemMedia) => Center(
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.contain,
|
||||
imageUrl: getImageUrl(FDImageType.item, itemMedia),
|
||||
imageUrl: itemMedia,
|
||||
placeholder: (context, url) => Container(),
|
||||
errorWidget: (context, url, error) => Container(),
|
||||
),
|
||||
|
||||
@@ -20,26 +20,94 @@ class ItemSubtitle extends StatelessWidget {
|
||||
final FDItem item;
|
||||
final FDSource source;
|
||||
|
||||
/// [_buildChildren] is used to build the children of the [RichText] widget.
|
||||
/// Depending on the provided information we will display the source title,
|
||||
/// the author and the publishing time of the item. We also include an icon
|
||||
/// for each information.
|
||||
List<InlineSpan> _buildChildren() {
|
||||
final List<InlineSpan> children = [];
|
||||
|
||||
if (source.title.trim().isNotEmpty) {
|
||||
children.addAll([
|
||||
WidgetSpan(
|
||||
child: Icon(
|
||||
source.type.icon,
|
||||
size: 12,
|
||||
color: Constants.secondaryTextColor,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ${source.title.trim()}',
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
children.addAll([
|
||||
WidgetSpan(
|
||||
child: Icon(
|
||||
source.type.icon,
|
||||
size: 12,
|
||||
color: Constants.secondaryTextColor,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ${source.type.toLocalizedString()}',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
if (item.author != null && item.author!.trim().isNotEmpty) {
|
||||
children.addAll([
|
||||
const TextSpan(
|
||||
text: ' | ',
|
||||
),
|
||||
const WidgetSpan(
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: 12,
|
||||
color: Constants.secondaryTextColor,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ${item.author!.trim()}',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
children.addAll([
|
||||
const TextSpan(
|
||||
text: ' | ',
|
||||
),
|
||||
const WidgetSpan(
|
||||
child: Icon(
|
||||
Icons.schedule,
|
||||
size: 12,
|
||||
color: Constants.secondaryTextColor,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text:
|
||||
' ${DateFormat.yMMMMd().add_Hm().format(DateTime.fromMillisecondsSinceEpoch(item.publishedAt * 1000))}',
|
||||
),
|
||||
]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sourceType = source.type.toLocalizedString();
|
||||
final sourceTitle = source.title != '' ? ' / ${source.title}' : '';
|
||||
final author =
|
||||
item.author != null && item.author != '' ? ' / ${item.author}' : '';
|
||||
final publishedAt =
|
||||
' / ${DateFormat.yMMMMd().add_Hm().format(DateTime.fromMillisecondsSinceEpoch(item.publishedAt * 1000))}';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Constants.spacingMiddle,
|
||||
),
|
||||
child: SelectableText(
|
||||
'$sourceType$sourceTitle$author$publishedAt',
|
||||
child: RichText(
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
color: Constants.secondaryTextColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
text: TextSpan(
|
||||
style: const TextStyle(
|
||||
color: Constants.secondaryTextColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
),
|
||||
children: _buildChildren(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -12,6 +12,10 @@ class ItemTitle extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (itemTitle.isEmpty) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
return SelectableText(
|
||||
itemTitle,
|
||||
textAlign: TextAlign.left,
|
||||
|
||||
256
app/lib/widgets/item/details/utils/item_videos.dart
Normal file
256
app/lib/widgets/item/details/utils/item_videos.dart
Normal file
@@ -0,0 +1,256 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import 'package:feeddeck/utils/constants.dart';
|
||||
|
||||
/// The [ItemVideos] widget is used to display a list of videos, which can be
|
||||
/// played by the user. If the [videos] list is empty or null, the widget will
|
||||
/// not be displayed.
|
||||
class ItemVideos extends StatelessWidget {
|
||||
const ItemVideos({
|
||||
super.key,
|
||||
required this.videos,
|
||||
});
|
||||
|
||||
final List<String>? videos;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (videos == null || videos!.isEmpty) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Constants.spacingMiddle,
|
||||
),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
separatorBuilder: (context, index) {
|
||||
return const SizedBox(
|
||||
height: Constants.spacingMiddle,
|
||||
);
|
||||
},
|
||||
itemCount: videos!.length,
|
||||
itemBuilder: (context, index) {
|
||||
return ItemVideoPlayer(video: videos![index]);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The [ItemVideoQuality] class is used to store the different qualities of a
|
||||
/// video. It is used in combination with the [ItemVideoPlayer] widget.
|
||||
class ItemVideoQuality {
|
||||
const ItemVideoQuality({
|
||||
required this.quality,
|
||||
required this.video,
|
||||
});
|
||||
|
||||
final String quality;
|
||||
final String video;
|
||||
}
|
||||
|
||||
/// The [ItemVideoPlayer] widget is used to display a video, which can be played
|
||||
/// by the user. It should be used in combination with the [ItemVideos] widget
|
||||
/// and is responsible for the actual implementation of the video player.
|
||||
///
|
||||
/// The optional [qualities] parameter can be used to display a list of
|
||||
/// different qualities for the video, so that a user can select a lower quality
|
||||
/// if the video is not loading fast enough.
|
||||
class ItemVideoPlayer extends StatefulWidget {
|
||||
const ItemVideoPlayer({
|
||||
super.key,
|
||||
required this.video,
|
||||
this.qualities,
|
||||
});
|
||||
|
||||
final String video;
|
||||
final List<ItemVideoQuality>? qualities;
|
||||
|
||||
@override
|
||||
State<ItemVideoPlayer> createState() => _ItemVideoPlayerState();
|
||||
}
|
||||
|
||||
class _ItemVideoPlayerState extends State<ItemVideoPlayer> {
|
||||
late final player = Player();
|
||||
late final controller = VideoController(player);
|
||||
|
||||
/// [_buildQualityButton] returns a button which can be used to display a list
|
||||
/// of different qualities for the video, so that a user can select a lower
|
||||
/// quality if the video is not loading fast enough.
|
||||
Widget _buildQualityButton() {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: true,
|
||||
useSafeArea: true,
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: Constants.centeredFormMaxWidth,
|
||||
),
|
||||
builder: (BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(
|
||||
Constants.spacingMiddle,
|
||||
),
|
||||
padding: const EdgeInsets.only(
|
||||
left: Constants.spacingMiddle,
|
||||
right: Constants.spacingMiddle,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: Constants.background,
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(Constants.spacingMiddle),
|
||||
),
|
||||
),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
separatorBuilder: (context, index) {
|
||||
return const Divider(
|
||||
color: Constants.dividerColor,
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
);
|
||||
},
|
||||
itemCount: widget.qualities!.length,
|
||||
itemBuilder: (context, index) {
|
||||
return ListTile(
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
player.open(
|
||||
Media(widget.qualities![index].video),
|
||||
play: true,
|
||||
);
|
||||
},
|
||||
title: Text(widget.qualities![index].quality),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.tune),
|
||||
);
|
||||
}
|
||||
|
||||
/// [_buildBottomButtonBar] returns the list of buttons which are displayed in
|
||||
/// the bottom button bar of the video player. If the [qualities] parameter is
|
||||
/// not null, a button to select the quality of the video is added to the
|
||||
/// bottom button bar. If the [isMobile] parameter is true, the bottom button
|
||||
/// bar contains the default buttons from the [MaterialVideoControlsThemeData]
|
||||
/// theme, if it is false it contains the default buttons from the
|
||||
/// [MaterialDesktopVideoControlsThemeData] theme.
|
||||
List<Widget> _buildBottomButtonBar(bool isMobile) {
|
||||
if (isMobile) {
|
||||
if (widget.qualities != null) {
|
||||
return [
|
||||
const MaterialPositionIndicator(),
|
||||
const Spacer(),
|
||||
_buildQualityButton(),
|
||||
const MaterialFullscreenButton(),
|
||||
];
|
||||
}
|
||||
|
||||
return const [
|
||||
MaterialPositionIndicator(),
|
||||
Spacer(),
|
||||
MaterialFullscreenButton(),
|
||||
];
|
||||
}
|
||||
|
||||
if (widget.qualities != null) {
|
||||
return [
|
||||
const MaterialDesktopSkipPreviousButton(),
|
||||
const MaterialDesktopPlayOrPauseButton(),
|
||||
const MaterialDesktopSkipNextButton(),
|
||||
const MaterialDesktopVolumeButton(),
|
||||
const MaterialDesktopPositionIndicator(),
|
||||
const Spacer(),
|
||||
_buildQualityButton(),
|
||||
const MaterialDesktopFullscreenButton(),
|
||||
];
|
||||
}
|
||||
|
||||
return const [
|
||||
MaterialDesktopSkipPreviousButton(),
|
||||
MaterialDesktopPlayOrPauseButton(),
|
||||
MaterialDesktopSkipNextButton(),
|
||||
MaterialDesktopVolumeButton(),
|
||||
MaterialDesktopPositionIndicator(),
|
||||
Spacer(),
|
||||
MaterialDesktopFullscreenButton(),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
player.open(
|
||||
Media(widget.video),
|
||||
play: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: constraints.maxWidth,
|
||||
height: constraints.maxWidth * 9.0 / 16.0,
|
||||
child: MaterialDesktopVideoControlsTheme(
|
||||
normal: MaterialDesktopVideoControlsThemeData(
|
||||
bottomButtonBar: _buildBottomButtonBar(false),
|
||||
seekBarPositionColor: Constants.primary,
|
||||
seekBarThumbColor: Constants.primary,
|
||||
),
|
||||
fullscreen: const MaterialDesktopVideoControlsThemeData(
|
||||
seekBarPositionColor: Constants.primary,
|
||||
seekBarThumbColor: Constants.primary,
|
||||
),
|
||||
child: MaterialVideoControlsTheme(
|
||||
normal: MaterialVideoControlsThemeData(
|
||||
bottomButtonBar: _buildBottomButtonBar(true),
|
||||
seekBarPositionColor: Constants.primary,
|
||||
seekBarThumbColor: Constants.primary,
|
||||
),
|
||||
fullscreen: const MaterialVideoControlsThemeData(
|
||||
seekBarPositionColor: Constants.primary,
|
||||
seekBarThumbColor: Constants.primary,
|
||||
),
|
||||
child: Video(
|
||||
controller: controller,
|
||||
controls: kIsWeb ||
|
||||
Platform.isLinux ||
|
||||
Platform.isMacOS ||
|
||||
Platform.isWindows
|
||||
? MaterialDesktopVideoControls
|
||||
: MaterialVideoControls,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
library item_youtube_video;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'item_youtube_video_stub.dart'
|
||||
if (dart.library.io) 'item_youtube_video_native.dart'
|
||||
if (dart.library.html) 'item_youtube_video_web.dart';
|
||||
|
||||
/// The [ItemYoutubeVideo] class implements a widget that displays a video from
|
||||
/// YouTube.
|
||||
///
|
||||
/// This is required because we are using different implementations for the web
|
||||
/// and for all other target platforms (Android, iOS, macOS, Windows, Linux). On
|
||||
/// the web we display the YouTube video via an `iframe` element. On all other
|
||||
/// platforms we are using the [youtube_explode_dart] package to fetch the url
|
||||
/// of the YouTube video, which can then be displayed via our [ItemVideoPlayer]
|
||||
/// widget.
|
||||
///
|
||||
/// We decided for this implementation, because on the web we would have to
|
||||
/// proxy the calls from the [youtube_explode_dart] because of CORS errors.
|
||||
/// Further with the former implementation via the [youtube_player_iframe]
|
||||
/// package we were not able to display the video on macOS, Windows and Linux
|
||||
/// and we were not able to display the video in fullscreen.
|
||||
abstract class ItemYoutubeVideo implements StatefulWidget {
|
||||
factory ItemYoutubeVideo(String? imageUrl, String videoUrl) =>
|
||||
getItemYoutubeVideo(imageUrl, videoUrl);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
import 'package:feeddeck/utils/constants.dart';
|
||||
import 'package:feeddeck/widgets/item/details/utils/item_media.dart';
|
||||
import 'package:feeddeck/widgets/item/details/utils/item_videos.dart';
|
||||
import 'item_youtube_video.dart';
|
||||
|
||||
class ItemYoutubeVideoNative extends StatefulWidget
|
||||
implements ItemYoutubeVideo {
|
||||
const ItemYoutubeVideoNative({
|
||||
super.key,
|
||||
required this.imageUrl,
|
||||
required this.videoUrl,
|
||||
});
|
||||
|
||||
final String? imageUrl;
|
||||
final String videoUrl;
|
||||
|
||||
@override
|
||||
State<ItemYoutubeVideoNative> createState() => _ItemYoutubeVideoNativeState();
|
||||
}
|
||||
|
||||
class _ItemYoutubeVideoNativeState extends State<ItemYoutubeVideoNative> {
|
||||
final yt = YoutubeExplode();
|
||||
late Future<List<ItemVideoQuality>> _futureFetchVideoUrls;
|
||||
|
||||
Future<List<ItemVideoQuality>> _fetchVideoUrls() async {
|
||||
final streamManifest = await yt.videos.streamsClient.getManifest(
|
||||
widget.videoUrl,
|
||||
);
|
||||
return streamManifest.muxed
|
||||
.sortByVideoQuality()
|
||||
.map(
|
||||
(element) => ItemVideoQuality(
|
||||
quality: element.qualityLabel,
|
||||
video: element.url.toString(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
setState(() {
|
||||
_futureFetchVideoUrls = _fetchVideoUrls();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
yt.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: _futureFetchVideoUrls,
|
||||
builder: (
|
||||
BuildContext context,
|
||||
AsyncSnapshot<List<ItemVideoQuality>> snapshot,
|
||||
) {
|
||||
if (snapshot.connectionState == ConnectionState.none ||
|
||||
snapshot.connectionState == ConnectionState.waiting ||
|
||||
snapshot.hasError ||
|
||||
snapshot.data == null) {
|
||||
return ItemMedia(itemMedia: widget.imageUrl);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Constants.spacingMiddle,
|
||||
),
|
||||
child: ItemVideoPlayer(
|
||||
video: snapshot.data!.first.video,
|
||||
qualities: snapshot.data,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ItemYoutubeVideo getItemYoutubeVideo(String? imageUrl, String videoUrl) =>
|
||||
ItemYoutubeVideoNative(
|
||||
imageUrl: imageUrl,
|
||||
videoUrl: videoUrl,
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'item_youtube_video.dart';
|
||||
|
||||
ItemYoutubeVideo getItemYoutubeVideo(String? imageUrl, String videoUrl) =>
|
||||
throw UnsupportedError(
|
||||
'Can not ItemYoutubeVideo without the packages dart:html or dart:io',
|
||||
);
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'dart:html'; // ignore: avoid_web_libraries_in_flutter
|
||||
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:feeddeck/utils/constants.dart';
|
||||
import 'item_youtube_video.dart';
|
||||
|
||||
class ItemYoutubeVideoWeb extends StatefulWidget implements ItemYoutubeVideo {
|
||||
const ItemYoutubeVideoWeb({
|
||||
super.key,
|
||||
required this.imageUrl,
|
||||
required this.videoUrl,
|
||||
});
|
||||
|
||||
final String? imageUrl;
|
||||
final String videoUrl;
|
||||
|
||||
@override
|
||||
State<ItemYoutubeVideoWeb> createState() => _ItemYoutubeVideoWebState();
|
||||
}
|
||||
|
||||
class _ItemYoutubeVideoWebState extends State<ItemYoutubeVideoWeb> {
|
||||
final IFrameElement _iframeElement = IFrameElement();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_iframeElement.src =
|
||||
'https://www.youtube-nocookie.com/embed/${widget.videoUrl.replaceFirst(
|
||||
'https://www.youtube.com/watch?v=',
|
||||
'',
|
||||
)}';
|
||||
_iframeElement.style.border = 'none';
|
||||
_iframeElement.allowFullscreen = true;
|
||||
|
||||
// ignore: undefined_prefixed_name
|
||||
ui.platformViewRegistry.registerViewFactory(
|
||||
widget.videoUrl,
|
||||
(int viewId) => _iframeElement,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Constants.spacingMiddle,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: constraints.maxWidth,
|
||||
height: constraints.maxWidth * 9.0 / 16.0,
|
||||
child: HtmlElementView(
|
||||
key: Key(widget.videoUrl),
|
||||
viewType: widget.videoUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ItemYoutubeVideo getItemYoutubeVideo(String? imageUrl, String videoUrl) =>
|
||||
ItemYoutubeVideoWeb(
|
||||
imageUrl: imageUrl,
|
||||
videoUrl: videoUrl,
|
||||
);
|
||||
@@ -10,6 +10,7 @@ import 'package:feeddeck/widgets/item/preview/item_preview_googlenews.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';
|
||||
import 'package:feeddeck/widgets/item/preview/item_preview_pinterest.dart';
|
||||
import 'package:feeddeck/widgets/item/preview/item_preview_podcast.dart';
|
||||
import 'package:feeddeck/widgets/item/preview/item_preview_reddit.dart';
|
||||
import 'package:feeddeck/widgets/item/preview/item_preview_rss.dart';
|
||||
@@ -67,6 +68,11 @@ class ItemPreview extends StatelessWidget {
|
||||
item: item,
|
||||
source: source,
|
||||
);
|
||||
case FDSourceType.pinterest:
|
||||
return ItemPreviewPinterest(
|
||||
item: item,
|
||||
source: source,
|
||||
);
|
||||
case FDSourceType.podcast:
|
||||
return ItemPreviewPodcast(
|
||||
item: item,
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:feeddeck/models/item.dart';
|
||||
import 'package:feeddeck/models/source.dart';
|
||||
import 'package:feeddeck/utils/image_url.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';
|
||||
@@ -30,7 +29,6 @@ class ItemPreviewGooglenews extends StatelessWidget {
|
||||
sourceSubtitle: '${source.type.toLocalizedString()}: ${source.title}',
|
||||
sourceType: source.type,
|
||||
sourceIcon: item.media,
|
||||
sourceIconType: FDImageType.item,
|
||||
itemPublishedAt: item.publishedAt,
|
||||
itemIsRead: item.isRead,
|
||||
),
|
||||
|
||||
@@ -38,7 +38,9 @@ class ItemPreviewMastodon extends StatelessWidget {
|
||||
tagetFormat: DescriptionFormat.markdown,
|
||||
),
|
||||
ItemMediaGallery(
|
||||
itemMedias: item.options != null && item.options!.containsKey('media')
|
||||
itemMedias: item.options != null &&
|
||||
item.options!.containsKey('media') &&
|
||||
item.options!['media'] != null
|
||||
? (item.options!['media'] as List)
|
||||
.map((item) => item as String)
|
||||
.toList()
|
||||
|
||||
50
app/lib/widgets/item/preview/item_preview_pinterest.dart
Normal file
50
app/lib/widgets/item/preview/item_preview_pinterest.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
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 ItemPreviewPinterest extends StatelessWidget {
|
||||
const ItemPreviewPinterest({
|
||||
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: source.title,
|
||||
sourceSubtitle: source.type.toLocalizedString(),
|
||||
sourceType: source.type,
|
||||
sourceIcon: source.icon,
|
||||
itemPublishedAt: item.publishedAt,
|
||||
itemIsRead: item.isRead,
|
||||
),
|
||||
ItemTitle(
|
||||
itemTitle: item.title,
|
||||
),
|
||||
ItemMedia(
|
||||
itemMedia: item.media,
|
||||
),
|
||||
ItemDescription(
|
||||
itemDescription: item.description,
|
||||
sourceFormat: DescriptionFormat.html,
|
||||
tagetFormat: DescriptionFormat.plain,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ class ItemPreviewRSS extends StatelessWidget {
|
||||
),
|
||||
ItemDescription(
|
||||
itemDescription: item.description,
|
||||
sourceFormat: DescriptionFormat.plain,
|
||||
sourceFormat: DescriptionFormat.html,
|
||||
tagetFormat: DescriptionFormat.plain,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:html2md/html2md.dart' as html2md;
|
||||
|
||||
import 'package:feeddeck/utils/constants.dart';
|
||||
import 'package:feeddeck/utils/font.dart';
|
||||
import 'package:feeddeck/utils/openurl.dart';
|
||||
import 'package:feeddeck/widgets/utils/cached_network_image.dart';
|
||||
|
||||
/// The [DescriptionFormat] enum defines the source and target format of a
|
||||
/// description.
|
||||
@@ -46,7 +46,7 @@ class ItemDescription extends StatelessWidget {
|
||||
),
|
||||
child: MarkdownBody(
|
||||
selectable: false,
|
||||
data: content,
|
||||
data: content.trim(),
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
code: TextStyle(
|
||||
fontFamily: getMonospaceFontFamily(),
|
||||
@@ -82,12 +82,16 @@ class ItemDescription extends StatelessWidget {
|
||||
|
||||
/// [_buildPlain] renders the provided [content] as plain text.
|
||||
Widget _buildPlain(String content) {
|
||||
if (content == '') {
|
||||
return Container();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Constants.spacingExtraSmall,
|
||||
),
|
||||
child: Text(
|
||||
content,
|
||||
content.trim(),
|
||||
maxLines: 5,
|
||||
style: const TextStyle(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
import 'package:feeddeck/utils/constants.dart';
|
||||
import 'package:feeddeck/utils/image_url.dart';
|
||||
import 'package:feeddeck/widgets/utils/cached_network_image.dart';
|
||||
|
||||
/// The [ItemMedia] widget displays the media of an item. Based on the provided
|
||||
/// [itemMedia] value the media is displayed from the Supabase storage or
|
||||
@@ -30,7 +28,7 @@ class ItemMedia extends StatelessWidget {
|
||||
width: double.infinity,
|
||||
height: 200,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: getImageUrl(FDImageType.item, itemMedia!),
|
||||
imageUrl: itemMedia!,
|
||||
placeholder: (context, url) => Container(),
|
||||
errorWidget: (context, url, error) => Container(),
|
||||
),
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
import 'package:feeddeck/utils/constants.dart';
|
||||
import 'package:feeddeck/utils/image_url.dart';
|
||||
import 'package:feeddeck/widgets/item/details/utils/item_media.dart';
|
||||
import 'package:feeddeck/widgets/utils/cached_network_image.dart';
|
||||
|
||||
/// The [ItemMediaGallery] widget can be used to display multiple media files in
|
||||
/// a gallery. Similar to the [ItemMedia] widget the provided [itemMedias]
|
||||
@@ -24,12 +22,10 @@ class ItemMediaGallery extends StatelessWidget {
|
||||
/// [CachedNetworkImage] widget. If the app is running in the web, the url is
|
||||
/// proxied through the Supabase functions.
|
||||
Widget _buildSingleMedia(BuildContext context, String itemMedia) {
|
||||
final imageUrl = getImageUrl(FDImageType.item, itemMedia);
|
||||
|
||||
return CachedNetworkImage(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: imageUrl,
|
||||
imageUrl: itemMedia,
|
||||
placeholder: (context, url) => Container(),
|
||||
errorWidget: (context, url, error) => Container(),
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:timeago/timeago.dart' as timeago;
|
||||
|
||||
import 'package:feeddeck/models/source.dart';
|
||||
import 'package:feeddeck/utils/constants.dart';
|
||||
import 'package:feeddeck/utils/image_url.dart';
|
||||
import 'package:feeddeck/widgets/source/source_icon.dart';
|
||||
|
||||
/// The [ItemSource] widget is used to display the source of an item above the
|
||||
@@ -16,7 +15,6 @@ class ItemSource extends StatelessWidget {
|
||||
required this.sourceSubtitle,
|
||||
required this.sourceType,
|
||||
required this.sourceIcon,
|
||||
this.sourceIconType = FDImageType.source,
|
||||
required this.itemPublishedAt,
|
||||
required this.itemIsRead,
|
||||
});
|
||||
@@ -25,7 +23,6 @@ class ItemSource extends StatelessWidget {
|
||||
final String sourceSubtitle;
|
||||
final FDSourceType sourceType;
|
||||
final String? sourceIcon;
|
||||
final FDImageType sourceIconType;
|
||||
final int itemPublishedAt;
|
||||
final bool itemIsRead;
|
||||
|
||||
@@ -72,7 +69,6 @@ class ItemSource extends StatelessWidget {
|
||||
child: SourceIcon(
|
||||
type: sourceType,
|
||||
icon: sourceIcon,
|
||||
iconType: sourceIconType,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:feeddeck/repositories/app_repository.dart';
|
||||
import 'package:feeddeck/repositories/items_repository.dart';
|
||||
import 'package:feeddeck/repositories/layout_repository.dart';
|
||||
import 'package:feeddeck/utils/constants.dart';
|
||||
|
||||
/// The [SettingsDecksSelect] widget shows a list of the users decks, when the
|
||||
@@ -20,13 +21,18 @@ class _SettingsDecksSelectState extends State<SettingsDecksSelect> {
|
||||
/// [_selectDeck] sets the provided [deckId] as the active deck. The active
|
||||
/// deck is updated via the [selectDeck] method of the [AppRepository]. When
|
||||
/// the active deck is updated the user is redirected to the decks view.
|
||||
///
|
||||
/// Before the active deck is changed the [ItemsRepositoryStore] is cleared,
|
||||
/// to trigger a reload of the items once the deck is loaded.
|
||||
Future<void> _selectDeck(String deckId) async {
|
||||
try {
|
||||
/// Before the active deck is changed the [ItemsRepositoryStore] is
|
||||
/// cleared, to trigger a reload of the items once the deck is loaded.
|
||||
ItemsRepositoryStore().clear();
|
||||
|
||||
/// We also have to reset the tab index when the user selects a new deck,
|
||||
/// so that the first tab is selected instead of the tab with the same
|
||||
/// index as in the previously selected deck.
|
||||
Provider.of<LayoutRepository>(context, listen: false)
|
||||
.deckLayoutSmallInitialTabIndex = 0;
|
||||
|
||||
await Provider.of<AppRepository>(context, listen: false)
|
||||
.selectDeck(deckId);
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -20,18 +20,21 @@ class _SettingsProfileSignOutState extends State<SettingsProfileSignOut> {
|
||||
bool _isLoading = false;
|
||||
|
||||
/// [_signOut] signs out the currently authenticated user and redirects him
|
||||
/// to the [SignIn] screen. This will sign out the user from all devices.
|
||||
/// to the [SignIn] screen. If the provided scope is
|
||||
/// [supabase.SignOutScope.local] the user will be signed out from the current
|
||||
/// device. If the scope in [supabase.SignOutScope.global] the user will be
|
||||
/// signed out from all devices.
|
||||
///
|
||||
/// Before the user is signed out the [ItemsRepositoryStore] is cleared, to
|
||||
/// trigger a reload of the items once the user is signed in again.
|
||||
Future<void> _signOut() async {
|
||||
Future<void> _signOut(supabase.SignOutScope scope) async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
ItemsRepositoryStore().clear();
|
||||
await supabase.Supabase.instance.client.auth.signOut();
|
||||
await supabase.Supabase.instance.client.auth.signOut(scope: scope);
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
@@ -64,7 +67,27 @@ class _SettingsProfileSignOutState extends State<SettingsProfileSignOut> {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () => _signOut(),
|
||||
onTap: () {
|
||||
/// Show a modal bottom sheet with the [SettingsProfileSignOutActions]
|
||||
/// widget, where the user can select the scope of the sign out
|
||||
/// action.
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: true,
|
||||
useSafeArea: true,
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: Constants.centeredFormMaxWidth,
|
||||
),
|
||||
builder: (BuildContext context) {
|
||||
return SettingsProfileSignOutActions(
|
||||
signOut: _signOut,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: Card(
|
||||
color: Constants.secondary,
|
||||
margin: const EdgeInsets.only(
|
||||
@@ -115,3 +138,75 @@ class _SettingsProfileSignOutState extends State<SettingsProfileSignOut> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The [SettingsProfileSignOutActions] widget displays a list of actions which
|
||||
/// can be used to sign out the user from the current device or from all
|
||||
/// devices.
|
||||
class SettingsProfileSignOutActions extends StatelessWidget {
|
||||
const SettingsProfileSignOutActions({
|
||||
super.key,
|
||||
required this.signOut,
|
||||
});
|
||||
|
||||
final Future<void> Function(supabase.SignOutScope scope) signOut;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(
|
||||
Constants.spacingMiddle,
|
||||
),
|
||||
padding: const EdgeInsets.only(
|
||||
left: Constants.spacingMiddle,
|
||||
right: Constants.spacingMiddle,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: Constants.background,
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(Constants.spacingMiddle),
|
||||
),
|
||||
),
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
ListTile(
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
signOut(supabase.SignOutScope.local);
|
||||
},
|
||||
leading: const Icon(
|
||||
Icons.logout,
|
||||
),
|
||||
title: const Text(
|
||||
'From current device',
|
||||
),
|
||||
),
|
||||
const Divider(
|
||||
color: Constants.dividerColor,
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
),
|
||||
ListTile(
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
signOut(supabase.SignOutScope.global);
|
||||
},
|
||||
leading: const Icon(
|
||||
Icons.logout,
|
||||
color: Constants.error,
|
||||
),
|
||||
title: const Text(
|
||||
'From all devices',
|
||||
style: TextStyle(
|
||||
color: Constants.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import 'package:feeddeck/utils/openurl.dart';
|
||||
/// Here the user can find information the version of the app and the links to
|
||||
/// the website, the GitHub repository and the X account.
|
||||
class SettingsInfo extends StatefulWidget {
|
||||
const SettingsInfo({Key? key}) : super(key: key);
|
||||
const SettingsInfo({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsInfo> createState() => _SettingsInfoState();
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:feeddeck/widgets/source/add/add_source_googlenews.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';
|
||||
import 'package:feeddeck/widgets/source/add/add_source_pinterest.dart';
|
||||
import 'package:feeddeck/widgets/source/add/add_source_podcast.dart';
|
||||
import 'package:feeddeck/widgets/source/add/add_source_reddit.dart';
|
||||
import 'package:feeddeck/widgets/source/add/add_source_rss.dart';
|
||||
@@ -63,6 +64,10 @@ class _AddSourceState extends State<AddSource> {
|
||||
return AddSourceNitter(column: widget.column);
|
||||
}
|
||||
|
||||
if (_sourceType == FDSourceType.pinterest) {
|
||||
return AddSourcePinterst(column: widget.column);
|
||||
}
|
||||
|
||||
if (_sourceType == FDSourceType.podcast) {
|
||||
return AddSourcePodcast(column: widget.column);
|
||||
}
|
||||
@@ -75,6 +80,10 @@ class _AddSourceState extends State<AddSource> {
|
||||
return AddSourceRSS(column: widget.column);
|
||||
}
|
||||
|
||||
if (_sourceType == FDSourceType.stackoverflow) {
|
||||
return AddSourceStackOverflow(column: widget.column);
|
||||
}
|
||||
|
||||
if (_sourceType == FDSourceType.tumblr) {
|
||||
return AddSourceTumblr(column: widget.column);
|
||||
}
|
||||
@@ -83,10 +92,6 @@ class _AddSourceState extends State<AddSource> {
|
||||
// return AddSourceX(column: widget.column);
|
||||
// }
|
||||
|
||||
if (_sourceType == FDSourceType.stackoverflow) {
|
||||
return AddSourceStackOverflow(column: widget.column);
|
||||
}
|
||||
|
||||
if (_sourceType == FDSourceType.youtube) {
|
||||
return AddSourceYouTube(column: widget.column);
|
||||
}
|
||||
@@ -115,7 +120,7 @@ class _AddSourceState extends State<AddSource> {
|
||||
/// If we decide later to use a generic color as background
|
||||
/// the following line can be used:
|
||||
/// color: Constants.secondary,
|
||||
color: FDSourceType.values[index].color,
|
||||
color: FDSourceType.values[index].bgColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Column(
|
||||
@@ -132,14 +137,14 @@ class _AddSourceState extends State<AddSource> {
|
||||
),
|
||||
Text(
|
||||
FDSourceType.values[index].toLocalizedString(),
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
/// Since we are using the brand color as background
|
||||
/// color, we are using the same color as for the icon
|
||||
/// as text color (source_icon.dart). If we decide later
|
||||
/// to use a generic color as background the following
|
||||
/// line can be used:
|
||||
/// color: Constants.onSecondary,
|
||||
color: Color(0xffffffff),
|
||||
color: FDSourceType.values[index].fgColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -143,6 +143,7 @@ class _AddSourceGitHubState extends State<AddSourceGitHub> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Repository',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
const SizedBox(height: Constants.spacingMiddle),
|
||||
],
|
||||
@@ -162,6 +163,7 @@ class _AddSourceGitHubState extends State<AddSourceGitHub> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Query Name',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
const SizedBox(height: Constants.spacingMiddle),
|
||||
TextFormField(
|
||||
@@ -174,6 +176,7 @@ class _AddSourceGitHubState extends State<AddSourceGitHub> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Query',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
const SizedBox(height: Constants.spacingMiddle),
|
||||
],
|
||||
@@ -193,6 +196,7 @@ class _AddSourceGitHubState extends State<AddSourceGitHub> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'User',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
const SizedBox(height: Constants.spacingMiddle),
|
||||
],
|
||||
@@ -212,6 +216,7 @@ class _AddSourceGitHubState extends State<AddSourceGitHub> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Repository',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
const SizedBox(height: Constants.spacingMiddle),
|
||||
],
|
||||
@@ -232,6 +237,7 @@ class _AddSourceGitHubState extends State<AddSourceGitHub> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Organization',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
const SizedBox(height: Constants.spacingMiddle),
|
||||
],
|
||||
|
||||
@@ -109,6 +109,7 @@ class _AddSourceGoogleNewsState extends State<AddSourceGoogleNews> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Url',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
const SizedBox(height: Constants.spacingMiddle),
|
||||
];
|
||||
@@ -126,6 +127,7 @@ class _AddSourceGoogleNewsState extends State<AddSourceGoogleNews> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Search',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
const SizedBox(height: Constants.spacingMiddle),
|
||||
DropdownButton<GoogleNewsCode>(
|
||||
|
||||
@@ -118,6 +118,7 @@ class _AddSourceMastodonState extends State<AddSourceMastodon> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Username, Hashtag, or Url',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -123,6 +123,7 @@ class _AddSourceMediumState extends State<AddSourceMedium> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Medium Url, Author or Tag',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -119,6 +119,7 @@ class _AddSourceNitterState extends State<AddSourceNitter> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Username or Search Term',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
128
app/lib/widgets/source/add/add_source_pinterest.dart
Normal file
128
app/lib/widgets/source/add/add_source_pinterest.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
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 Pinterest source can be used to follow your favorite Pinterest users or
|
||||
boards.
|
||||
|
||||
- **User**: `@username` or `https://www.pinterest.com/username/`
|
||||
- **Board**: `@username/board` or `https://www.pinterest.com/username/board/`
|
||||
''';
|
||||
|
||||
/// The [AddSourcePinterest] widget is used to display the form to add a new
|
||||
/// Pinterest source.
|
||||
class AddSourcePinterst extends StatefulWidget {
|
||||
const AddSourcePinterst({
|
||||
super.key,
|
||||
required this.column,
|
||||
});
|
||||
|
||||
final FDColumn column;
|
||||
|
||||
@override
|
||||
State<AddSourcePinterst> createState() => _AddSourcePinterstState();
|
||||
}
|
||||
|
||||
class _AddSourcePinterstState extends State<AddSourcePinterst> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _pinterestController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
String _error = '';
|
||||
|
||||
/// [_addSource] adds a new Pinterst source. To add a new Pinterest source the
|
||||
/// user must provide the URL of an user / a board or an url via the
|
||||
/// [_pinterestController].
|
||||
Future<void> _addSource() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = '';
|
||||
});
|
||||
|
||||
try {
|
||||
AppRepository app = Provider.of<AppRepository>(context, listen: false);
|
||||
await app.addSource(
|
||||
widget.column.id,
|
||||
FDSourceType.pinterest,
|
||||
FDSourceOptions(
|
||||
pinterest: _pinterestController.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() {
|
||||
_pinterestController.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: _pinterestController,
|
||||
keyboardType: TextInputType.text,
|
||||
autocorrect: false,
|
||||
enableSuggestions: true,
|
||||
maxLines: 1,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Pinterest Url, Username or Board',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,7 @@ class _AddSourcePodcastState extends State<AddSourcePodcast> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Podcast Url',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -120,6 +120,7 @@ class _AddSourceRedditState extends State<AddSourceReddit> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Reddit Url, Subreddit or User',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -14,6 +14,10 @@ import 'package:feeddeck/widgets/source/add/add_source_form.dart';
|
||||
const _helpText = '''
|
||||
The RSS source can be used to follow all your RSS feeds. You have to provide
|
||||
the url for the RSS feed, e.g. `https://www.tagesschau.de/xml/rss2/`.
|
||||
|
||||
If you do not know the url of the RSS feed you can also provide the url of the
|
||||
website you want to add and we try to find a RSS feed for you, e.g.
|
||||
`https://www.tagesschau.de`.
|
||||
''';
|
||||
|
||||
/// The [AddSourceRSS] widget is used to display the form to add a new RSS
|
||||
@@ -118,6 +122,7 @@ class _AddSourceRSSState extends State<AddSourceRSS> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'RSS Feed',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -103,6 +103,7 @@ class _AddSourceStackOverflowState extends State<AddSourceStackOverflow> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Url',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
const SizedBox(height: Constants.spacingMiddle),
|
||||
];
|
||||
@@ -120,6 +121,7 @@ class _AddSourceStackOverflowState extends State<AddSourceStackOverflow> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Tag',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
const SizedBox(height: Constants.spacingMiddle),
|
||||
DropdownButton<FDStackOverflowSort>(
|
||||
|
||||
@@ -117,6 +117,7 @@ class _AddSourceTumblrState extends State<AddSourceTumblr> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Blog',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -110,6 +110,7 @@ class _AddSourceXState extends State<AddSourceX> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Username',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -115,6 +115,7 @@ class _AddSourceYouTubeState extends State<AddSourceYouTube> {
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Channel Url',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
import 'package:feeddeck/models/source.dart';
|
||||
import 'package:feeddeck/utils/constants.dart';
|
||||
import 'package:feeddeck/utils/fd_icons.dart';
|
||||
import 'package:feeddeck/utils/image_url.dart';
|
||||
import 'package:feeddeck/widgets/utils/cached_network_image.dart';
|
||||
|
||||
/// [SourceIcon] can be used to show the image for a source. For that the [icon]
|
||||
/// of the source must be provided. The size of the image can be adjusted via
|
||||
@@ -18,14 +16,12 @@ class SourceIcon extends StatelessWidget {
|
||||
super.key,
|
||||
required this.type,
|
||||
required this.icon,
|
||||
this.iconType = FDImageType.source,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
final FDSourceType type;
|
||||
final String? icon;
|
||||
final double size;
|
||||
final FDImageType iconType;
|
||||
|
||||
/// buildIcon returns the provided [icon] with the provided [backgroundColor].
|
||||
Widget buildIcon(
|
||||
@@ -47,99 +43,21 @@ class SourceIcon extends StatelessWidget {
|
||||
|
||||
/// [buildDefaultIcon] returns an icon based on the provided source [type].
|
||||
Widget buildDefaultIcon(double iconSize) {
|
||||
switch (type) {
|
||||
case FDSourceType.github:
|
||||
return buildIcon(
|
||||
FDIcons.github,
|
||||
iconSize,
|
||||
type.color,
|
||||
const Color(0xffffffff),
|
||||
);
|
||||
case FDSourceType.googlenews:
|
||||
return buildIcon(
|
||||
FDIcons.googlenews,
|
||||
iconSize,
|
||||
type.color,
|
||||
const Color(0xffffffff),
|
||||
);
|
||||
case FDSourceType.mastodon:
|
||||
return buildIcon(
|
||||
FDIcons.mastodon,
|
||||
iconSize,
|
||||
type.color,
|
||||
const Color(0xffffffff),
|
||||
);
|
||||
case FDSourceType.medium:
|
||||
return buildIcon(
|
||||
FDIcons.medium,
|
||||
iconSize,
|
||||
type.color,
|
||||
const Color(0xffffffff),
|
||||
);
|
||||
case FDSourceType.nitter:
|
||||
return buildIcon(
|
||||
FDIcons.nitter,
|
||||
iconSize,
|
||||
type.color,
|
||||
const Color(0xffffffff),
|
||||
);
|
||||
case FDSourceType.podcast:
|
||||
return buildIcon(
|
||||
Icons.podcasts,
|
||||
iconSize,
|
||||
type.color,
|
||||
const Color(0xffffffff),
|
||||
);
|
||||
case FDSourceType.reddit:
|
||||
return buildIcon(
|
||||
FDIcons.reddit,
|
||||
iconSize,
|
||||
type.color,
|
||||
const Color(0xffffffff),
|
||||
);
|
||||
case FDSourceType.rss:
|
||||
return buildIcon(
|
||||
FDIcons.rss,
|
||||
iconSize,
|
||||
type.color,
|
||||
const Color(0xffffffff),
|
||||
);
|
||||
case FDSourceType.stackoverflow:
|
||||
return buildIcon(
|
||||
FDIcons.stackoverflow,
|
||||
iconSize,
|
||||
type.color,
|
||||
const Color(0xffffffff),
|
||||
);
|
||||
case FDSourceType.tumblr:
|
||||
return buildIcon(
|
||||
FDIcons.tumblr,
|
||||
iconSize,
|
||||
type.color,
|
||||
const Color(0xffffffff),
|
||||
);
|
||||
// case FDSourceType.x:
|
||||
// return buildIcon(
|
||||
// FDIcons.x,
|
||||
// iconSize,
|
||||
// type.color,
|
||||
// const Color(0xffffffff),
|
||||
// );
|
||||
case FDSourceType.youtube:
|
||||
return buildIcon(
|
||||
FDIcons.youtube,
|
||||
iconSize,
|
||||
type.color,
|
||||
const Color(0xffffffff),
|
||||
);
|
||||
default:
|
||||
return buildIcon(
|
||||
FDIcons.feeddeck,
|
||||
iconSize,
|
||||
Constants.primary,
|
||||
Constants.onPrimary,
|
||||
);
|
||||
if (FDSourceType.values.contains(type)) {
|
||||
return buildIcon(
|
||||
type.icon,
|
||||
iconSize,
|
||||
type.bgColor,
|
||||
type.fgColor,
|
||||
);
|
||||
}
|
||||
|
||||
return buildIcon(
|
||||
FDIcons.feeddeck,
|
||||
iconSize,
|
||||
Constants.primary,
|
||||
Constants.onPrimary,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -155,7 +73,7 @@ class SourceIcon extends StatelessWidget {
|
||||
height: size,
|
||||
width: size,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: getImageUrl(iconType, icon!),
|
||||
imageUrl: icon!,
|
||||
placeholder: (context, url) => buildDefaultIcon(size),
|
||||
errorWidget: (context, url, error) => buildDefaultIcon(size),
|
||||
),
|
||||
|
||||
81
app/lib/widgets/utils/cached_network_image.dart
Normal file
81
app/lib/widgets/utils/cached_network_image.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart' as cni;
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import 'package:feeddeck/repositories/settings_repository.dart';
|
||||
|
||||
/// [getImageUrl] returns the "correct" image url for the provided [imageUrl].
|
||||
/// "Correct" means that depending on the provided [imageUrl] and the current
|
||||
/// platform, the image url will be pointed to the Supabase storage or will be
|
||||
/// proxied via the "image-proxy-v1" Supabase function.
|
||||
String getImageUrl(String imageUrl) {
|
||||
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
|
||||
if (kIsWeb) {
|
||||
return '${Supabase.instance.client.functionsUrl}/image-proxy-v1?media=${Uri.encodeQueryComponent(imageUrl)}';
|
||||
}
|
||||
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
return '${SettingsRepository().supabaseUrl}/storage/v1/object/public/sources/$imageUrl';
|
||||
}
|
||||
|
||||
/// The [CachedNetworkImage] is a wrapper around the [cni.CachedNetworkImage]
|
||||
/// widget, which will automatically use the correct url to display the image,
|
||||
/// via the [getImageUrl] function.
|
||||
class CachedNetworkImage extends StatelessWidget {
|
||||
final String imageUrl;
|
||||
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit? fit;
|
||||
final Widget Function(BuildContext, String)? placeholder;
|
||||
final Widget Function(BuildContext, String, Object)? errorWidget;
|
||||
const CachedNetworkImage({
|
||||
super.key,
|
||||
required this.imageUrl,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit,
|
||||
this.placeholder,
|
||||
this.errorWidget,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return cni.CachedNetworkImage(
|
||||
cacheManager: CustomCacheManager(),
|
||||
imageUrl: getImageUrl(imageUrl),
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
placeholder: placeholder,
|
||||
errorWidget: errorWidget,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [CustomCacheManager] is a custom [CacheManager] which is used by the
|
||||
/// [CachedNetworkImage] widget to cache the images. This is required to adjust
|
||||
/// the `stalePeriod` to 7 days, instead of the default 30 days. 7 days should
|
||||
/// be enough for our use case and will reduce the storage usage.
|
||||
class CustomCacheManager extends CacheManager with ImageCacheManager {
|
||||
static const key = 'libCachedImageData';
|
||||
|
||||
static final CustomCacheManager _instance = CustomCacheManager._internal();
|
||||
|
||||
factory CustomCacheManager() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
CustomCacheManager._internal()
|
||||
: super(
|
||||
Config(
|
||||
key,
|
||||
stalePeriod: const Duration(days: 7),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,51 @@
|
||||
</categories>
|
||||
|
||||
<releases>
|
||||
<release version="v1.2.0" date="2023-11-26">
|
||||
<description>
|
||||
<p>Added</p>
|
||||
<ul>
|
||||
<li>#74: [pinterest] Add Support for Pinterest @ricoberger</li>
|
||||
<li>#72: [rss] Allow Users to Provide a Website URL @ricoberger</li>
|
||||
<li>#71: [core] Add GitHub Action for iOS and Android @ricoberger</li>
|
||||
<li>#63: [core] Add Development Setup for Neovim @ricoberger</li>
|
||||
<li>#56: [youtube] Add Desktop Support @ricoberger</li>
|
||||
<li>#54: [core] Sign Out from Current Device @ricoberger</li>
|
||||
<li>#51: [mastodon] Add Support for Videos @ricoberger</li>
|
||||
</ul>
|
||||
|
||||
<p>Fixed</p>
|
||||
<li>#75: [core] Fix getMedia Function @ricoberger</li>
|
||||
<li>#73: [core] Fix Decoding of Special Characters @ricoberger</li>
|
||||
<li>#64: [github] Fix Icons in Item Preview @ricoberger</li>
|
||||
<li>#58: [core] Add Missing Divider to Video Quality Selection @ricoberger</li>
|
||||
<li>#57: [core] Fix Modal Bottom Sheet Size for Images @ricoberger</li>
|
||||
<li>#55: [github] Fix Notification Links for PRs @ricoberger</li>
|
||||
<ul>
|
||||
</ul>
|
||||
|
||||
<p>Changed</p>
|
||||
<ul>
|
||||
<li>#79: [medium] Remove Spam @ricoberger</li>
|
||||
<li>#78: [reddit] Remove Tables from Description @ricoberger</li>
|
||||
<li>#77: [core] Improve Subtitle in Details View @ricoberger</li>
|
||||
<li>#76: [core] Improve Icon Handling @ricoberger</li>
|
||||
<li>#70: [core] Update Flutter to Version 3.16.0 @ricoberger</li>
|
||||
<li>#69: [core] Submit "Add Source" Forms on Enter @ricoberger</li>
|
||||
<li>#68: Bump the pub group in /app with 8 updates @dependabot</li>
|
||||
<li>#61: Bump the npm-email-templates group in /supabase/email-templates with 1 update @dependabot</li>
|
||||
<li>#60: Bump the npm-landing group in /landing with 7 updates @dependabot</li>
|
||||
<li>#62: Bump the github-actions group with 1 update @dependabot</li>
|
||||
<li>#67: [core] Add Custom Cache Manager @ricoberger</li>
|
||||
<li>#66: [core] Improve Media Handling @ricoberger</li>
|
||||
<li>#65: [core] Run "deno fmt" @ricoberger</li>
|
||||
<li>#53: [core] Improve Tabs Handling in Small Deck Layout @ricoberger</li>
|
||||
<li>#52: [rss] Improve Rendering of Items @ricoberger</li>
|
||||
</ul>
|
||||
</description>
|
||||
|
||||
<url>https://github.com/feeddeck/feeddeck/releases/tag/v1.2.0</url>
|
||||
</release>
|
||||
<release version="v1.1.1" date="2023-10-21">
|
||||
<description>
|
||||
<p>Fixed</p>
|
||||
@@ -66,7 +111,7 @@
|
||||
<ul>
|
||||
<li>#48: [core] Improve Android App Icons @ricoberger</li>
|
||||
<li>#47: [core] Clear Cached Items @ricoberger</li>
|
||||
<li>#39: [core] Add Privacy Policy and Terms & Conditions @ricoberger</li>
|
||||
<li>#39: [core] Add Privacy Policy and Terms and Conditions @ricoberger</li>
|
||||
<li>#38: [core] Enable In-App Purchases for Android @ricoberger</li>
|
||||
</ul>
|
||||
</description>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
|
||||
#include <media_kit_video/media_kit_video_plugin.h>
|
||||
#include <screen_retriever/screen_retriever_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
@@ -15,6 +16,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
|
||||
media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) media_kit_video_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin");
|
||||
media_kit_video_plugin_register_with_registrar(media_kit_video_registrar);
|
||||
g_autoptr(FlPluginRegistrar) screen_retriever_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin");
|
||||
screen_retriever_plugin_register_with_registrar(screen_retriever_registrar);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
media_kit_libs_linux
|
||||
media_kit_video
|
||||
screen_retriever
|
||||
url_launcher_linux
|
||||
window_manager
|
||||
|
||||
@@ -9,14 +9,18 @@ import app_links
|
||||
import audio_service
|
||||
import audio_session
|
||||
import just_audio
|
||||
import media_kit_libs_macos_video
|
||||
import media_kit_video
|
||||
import package_info_plus
|
||||
import path_provider_foundation
|
||||
import purchases_flutter
|
||||
import screen_brightness_macos
|
||||
import screen_retriever
|
||||
import shared_preferences_foundation
|
||||
import sign_in_with_apple
|
||||
import sqflite
|
||||
import url_launcher_macos
|
||||
import wakelock_plus
|
||||
import window_manager
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
@@ -24,13 +28,17 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin"))
|
||||
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
||||
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
||||
FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin"))
|
||||
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
|
||||
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
PurchasesFlutterPlugin.register(with: registry.registrar(forPlugin: "PurchasesFlutterPlugin"))
|
||||
ScreenBrightnessMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenBrightnessMacosPlugin"))
|
||||
ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
||||
}
|
||||
|
||||
@@ -11,19 +11,25 @@ PODS:
|
||||
- FMDB/standard (2.7.5)
|
||||
- just_audio (0.0.1):
|
||||
- FlutterMacOS
|
||||
- media_kit_libs_macos_video (1.0.4):
|
||||
- FlutterMacOS
|
||||
- media_kit_native_event_loop (1.0.0):
|
||||
- FlutterMacOS
|
||||
- media_kit_video (0.0.1):
|
||||
- FlutterMacOS
|
||||
- package_info_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- purchases_flutter (6.0.0):
|
||||
- purchases_flutter (6.4.0):
|
||||
- FlutterMacOS
|
||||
- PurchasesHybridCommon (= 8.0.0)
|
||||
- PurchasesHybridCommon (8.0.0):
|
||||
- RevenueCat (= 4.30.5)
|
||||
- RevenueCat (4.30.5)
|
||||
- screen_brightness_macos (0.1.0):
|
||||
- FlutterMacOS
|
||||
- PurchasesHybridCommon (= 7.0.0)
|
||||
- PurchasesHybridCommon (7.0.0):
|
||||
- RevenueCat (= 4.27.0)
|
||||
- RevenueCat (4.27.0)
|
||||
- screen_retriever (0.0.1):
|
||||
- FlutterMacOS
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
@@ -36,6 +42,8 @@ PODS:
|
||||
- FMDB (>= 2.7.5)
|
||||
- url_launcher_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- wakelock_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- window_manager (0.2.0):
|
||||
- FlutterMacOS
|
||||
|
||||
@@ -45,15 +53,19 @@ DEPENDENCIES:
|
||||
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/macos`)
|
||||
- media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`)
|
||||
- media_kit_native_event_loop (from `Flutter/ephemeral/.symlinks/plugins/media_kit_native_event_loop/macos`)
|
||||
- media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`)
|
||||
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
|
||||
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- purchases_flutter (from `Flutter/ephemeral/.symlinks/plugins/purchases_flutter/macos`)
|
||||
- screen_brightness_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_brightness_macos/macos`)
|
||||
- screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`)
|
||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sign_in_with_apple (from `Flutter/ephemeral/.symlinks/plugins/sign_in_with_apple/macos`)
|
||||
- sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/macos`)
|
||||
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
|
||||
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
|
||||
- window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`)
|
||||
|
||||
SPEC REPOS:
|
||||
@@ -73,14 +85,20 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral
|
||||
just_audio:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/just_audio/macos
|
||||
media_kit_libs_macos_video:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos
|
||||
media_kit_native_event_loop:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/media_kit_native_event_loop/macos
|
||||
media_kit_video:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos
|
||||
package_info_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
|
||||
path_provider_foundation:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
|
||||
purchases_flutter:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/purchases_flutter/macos
|
||||
screen_brightness_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/screen_brightness_macos/macos
|
||||
screen_retriever:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos
|
||||
shared_preferences_foundation:
|
||||
@@ -91,6 +109,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/sqflite/macos
|
||||
url_launcher_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
|
||||
wakelock_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
|
||||
window_manager:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
||||
|
||||
@@ -101,19 +121,23 @@ SPEC CHECKSUMS:
|
||||
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
just_audio: 9b67ca7b97c61cfc9784ea23cd8cc55eb226d489
|
||||
media_kit_native_event_loop: d20622d35dd6d06fe71223976bd70a2bcf595dce
|
||||
media_kit_libs_macos_video: b3e2bbec2eef97c285f2b1baa7963c67c753fb82
|
||||
media_kit_native_event_loop: 81fd5b45192b72f8b5b69eaf5b540f45777eb8d5
|
||||
media_kit_video: c75b07f14d59706c775778e4dd47dd027de8d1e5
|
||||
package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce
|
||||
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
||||
purchases_flutter: 9aad80bf27960c38fdeafc27ab066cb55615aed5
|
||||
PurchasesHybridCommon: af3b2413f9cb999bc1fdca44770bdaf39dfb89fa
|
||||
RevenueCat: 84fbe2eb9bbf63e1abf346ccd3ff9ee45d633e3b
|
||||
purchases_flutter: dd2e2b2d7fda0e64ee50ad426487dfa6dbf1bdd8
|
||||
PurchasesHybridCommon: 80262c5ffe6621e3cf3812e6103170f6d7fbcb79
|
||||
RevenueCat: c1e33f4e1f1fd239ba461652f02928e220becc31
|
||||
screen_brightness_macos: 2d6d3af2165592d9a55ffcd95b7550970e41ebda
|
||||
screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38
|
||||
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
|
||||
sign_in_with_apple: a9e97e744e8edc36aefc2723111f652102a7a727
|
||||
sqflite: a5789cceda41d54d23f31d6de539d65bb14100ea
|
||||
url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95
|
||||
wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269
|
||||
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
|
||||
|
||||
PODFILE CHECKSUM: 8d40c19d3cbdb380d870685c3a564c989f1efa52
|
||||
|
||||
COCOAPODS: 1.13.0
|
||||
COCOAPODS: 1.14.2
|
||||
|
||||
355
app/pubspec.lock
355
app/pubspec.lock
@@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
ansicolor:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ansicolor
|
||||
sha256: "8bf17a8ff6ea17499e40a2d2542c2f481cd7615760c6d34065cb22bfd22e6880"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
app_links:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -13,10 +21,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: "7e0d52067d05f2e0324268097ba723b71cb41ac8a6a2b24d1edf9c536b987b03"
|
||||
sha256: "7b875fd4a20b165a3084bd2d210439b22ebc653f21cea4842729c0c30c82596b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.6"
|
||||
version: "3.4.9"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -61,10 +69,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audio_session
|
||||
sha256: "8a2bc5e30520e18f3fb0e366793d78057fb64cd5287862c76af0c8771f2a52ad"
|
||||
sha256: "6fdf255ed3af86535c96452c33ecff1245990bb25a605bfb1958661ccc3d467f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.16"
|
||||
version: "0.1.18"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -141,10 +149,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: collection
|
||||
sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687
|
||||
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.2"
|
||||
version: "1.18.0"
|
||||
console:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -185,6 +193,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.6"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -215,7 +231,7 @@ packages:
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_cache_manager:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_cache_manager
|
||||
sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba"
|
||||
@@ -234,26 +250,26 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04
|
||||
sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
version: "3.0.1"
|
||||
flutter_markdown:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_markdown
|
||||
sha256: "8afc9a6aa6d8e8063523192ba837149dbf3d377a37c0b0fc579149a1fbd4a619"
|
||||
sha256: "35108526a233cc0755664d445f8a6b4b61e6f8fe993b3658b80b4a26827fc196"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.18"
|
||||
version: "0.6.18+2"
|
||||
flutter_native_splash:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_native_splash
|
||||
sha256: "91004565166dbbc7a85e7e99b84124a287839830ca957cfe45004793fe6fe69f"
|
||||
sha256: c4d899312b36e7454bedfd0a4740275837b99e532d81c8477579d8183db1de6c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.3"
|
||||
version: "2.3.6"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -292,10 +308,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: gotrue
|
||||
sha256: "15359f3b3824dbc8feab3b79d06daefe6f7163afb727e83602385e2d4b809902"
|
||||
sha256: f3a47cdbc59e543f453a1ef150050cd7650fe756254ac1fcac1d2a2f6f2b5a21
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.4"
|
||||
version: "1.12.6"
|
||||
hive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -313,7 +329,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
html:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: html
|
||||
sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a"
|
||||
@@ -388,42 +404,43 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: just_audio
|
||||
sha256: "5ed0cd723e17dfd8cd4b0253726221e67f6546841ea4553635cf895061fc335b"
|
||||
sha256: b607cd1a43bac03d85c3aaee00448ff4a589ef2a77104e3d409889ff079bf823
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.35"
|
||||
version: "0.9.36"
|
||||
just_audio_background:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: just_audio_background
|
||||
sha256: d290c9c450083aee40cc481e2cb4c088dcbca35961598970ea1b6a6f6c68ae13
|
||||
sha256: "3454ffc97edfa1282b7f42759bfa8aa13d9114a24465f4101e0d3ae58a9327fb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.1-beta.10"
|
||||
version: "0.0.1-beta.11"
|
||||
just_audio_media_kit:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: just_audio_media_kit
|
||||
sha256: d6288e898bc5ed499a938c3cf1ea99eeca4264f9b6ef7bdf92ace3e8b804e259
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: "5980ceac7cc385baf2269eda2dcb024d3e5203cc"
|
||||
url: "https://github.com/feeddeck/just_audio_media_kit.git"
|
||||
source: git
|
||||
version: "1.0.0"
|
||||
just_audio_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: just_audio_platform_interface
|
||||
sha256: d8409da198bbc59426cd45d4c92fca522a2ec269b576ce29459d6d6fcaeb44df
|
||||
sha256: c3dee0014248c97c91fe6299edb73dc4d6c6930a2f4f713579cd692d9e47f4a1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.1"
|
||||
version: "4.2.2"
|
||||
just_audio_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: just_audio_web
|
||||
sha256: ff62f733f437b25a0ff590f0e295fa5441dcb465f1edbdb33b3dea264705bc13
|
||||
sha256: "134356b0fe3d898293102b33b5fd618831ffdc72bb7a1b726140abdf22772b70"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.8"
|
||||
version: "0.4.9"
|
||||
jwt_decode:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -436,10 +453,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452"
|
||||
sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
version: "3.0.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -473,13 +490,29 @@ packages:
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
media_kit:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: media_kit
|
||||
sha256: "3289062540e3b8b9746e5c50d95bd78a9289826b7227e253dff806d002b9e67a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.10+1"
|
||||
media_kit_libs_android_video:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: media_kit_libs_android_video
|
||||
sha256: "9dd8012572e4aff47516e55f2597998f0a378e3d588d0fad0ca1f11a53ae090c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.6"
|
||||
media_kit_libs_ios_video:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: media_kit_libs_ios_video
|
||||
sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.4"
|
||||
media_kit_libs_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -488,11 +521,27 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
media_kit_libs_windows_audio:
|
||||
media_kit_libs_macos_video:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: media_kit_libs_windows_audio
|
||||
sha256: c2fd558cc87b9d89a801141fcdffe02e338a3b21a41a18fbd63d5b221a1b8e53
|
||||
name: media_kit_libs_macos_video
|
||||
sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.4"
|
||||
media_kit_libs_video:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: media_kit_libs_video
|
||||
sha256: "3688e0c31482074578652bf038ce6301a5d21e1eda6b54fc3117ffeb4bdba067"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
media_kit_libs_windows_video:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: media_kit_libs_windows_video
|
||||
sha256: "7bace5f35d9afcc7f9b5cdadb7541d2191a66bb3fc71bfa11c1395b3360f6122"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
@@ -504,14 +553,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
media_kit_video:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: media_kit_video
|
||||
sha256: c048d11a19e379aebbe810647636e3fc6d18374637e2ae12def4ff8a4b99a882
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.4"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3"
|
||||
sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
version: "1.10.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -524,10 +581,10 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: msix
|
||||
sha256: "6e76e2491d5c809d784ce2b68e6c3426097fb5c68e61fe121c8c3341ab89bf46"
|
||||
sha256: "957d04eee260e4bd15bec1fdb988dfc73718285e201cf89d97ef01ef38e66d4c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.16.4"
|
||||
version: "3.16.6"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -556,10 +613,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "6ff267fcd9d48cb61c8df74a82680e8b82e940231bb5f68356672fde0397334a"
|
||||
sha256: "88bc797f44a94814f2213db1c9bd5badebafdfb8290ca9f78d4b9ee2a3db4d79"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
version: "5.0.1"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -588,10 +645,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "6b8b19bd80da4f11ce91b2d1fb931f3006911477cec227cce23d3253d80df3f1"
|
||||
sha256: e595b98692943b4881b219f0a9e3945118d3c16bd7e2813f98ec6e532d905f72
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
version: "2.2.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -628,10 +685,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750
|
||||
sha256: eeb2d1428ee7f4170e2bd498827296a18d4e7fc462b71727d111c0ac7707cfa6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.4.0"
|
||||
version: "6.0.1"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -644,10 +701,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d
|
||||
sha256: f4f88d4a900933e7267e2b353594774fc0d07fb072b47eedcd5b54e1ea3269f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.6"
|
||||
version: "2.1.7"
|
||||
pointycastle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -660,18 +717,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: postgrest
|
||||
sha256: "87e35d3a59e327188321befbfbfcc5a7a2e71f0d0a13d975cbc7d169387ec712"
|
||||
sha256: f190eddc5779842dfa529fa239ec4b1025f6f968c18052ba6fffc0aecac93e6b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.1"
|
||||
version: "1.5.2"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: provider
|
||||
sha256: cdbe7530b12ecd9eb455bdaa2fcb8d4dad22e80b8afb4798b41479d5ce26847f
|
||||
sha256: "9a96a0a19b594dbc5bf0f1f27d2bc67d5f95957359b461cd9feb44ed6ae75096"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.5"
|
||||
version: "6.1.1"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -684,18 +741,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: purchases_flutter
|
||||
sha256: "3fb05df9d4ec901547c447a27830ce24d0f8b90e8f751513429479091385233d"
|
||||
sha256: d7b830b637da01c586076b2acd463d7e96e594f40c96615616d2b8306b07dd90
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
version: "6.4.0"
|
||||
realtime_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: realtime_client
|
||||
sha256: d93f99b6ee42a7b7af3e15ef2965576172ff196426aabca24b91842fb27df116
|
||||
sha256: "2027358cdbe65d5f1770c3f768aa9adecd394de486c5dbbd2cfe19d5c6dbbc4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
version: "1.4.0"
|
||||
retry:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -720,6 +777,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
screen_brightness:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_brightness
|
||||
sha256: ed8da4a4511e79422fc1aa88138e920e4008cd312b72cdaa15ccb426c0faaedd
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2+1"
|
||||
screen_brightness_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_brightness_android
|
||||
sha256: "3df10961e3a9e968a5e076fe27e7f4741fa8a1d3950bdeb48cf121ed529d0caf"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0+2"
|
||||
screen_brightness_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_brightness_ios
|
||||
sha256: "99adc3ca5490b8294284aad5fcc87f061ad685050e03cf45d3d018fe398fd9a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
screen_brightness_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_brightness_macos
|
||||
sha256: "64b34e7e3f4900d7687c8e8fb514246845a73ecec05ab53483ed025bd4a899fd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0+1"
|
||||
screen_brightness_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_brightness_platform_interface
|
||||
sha256: b211d07f0c96637a15fb06f6168617e18030d5d74ad03795dd8547a52717c171
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
screen_brightness_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_brightness_windows
|
||||
sha256: "9261bf33d0fc2707d8cf16339ce25768100a65e70af0fcabaf032fc12408ba86"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3"
|
||||
screen_retriever:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -780,10 +885,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf
|
||||
sha256: "7b15ffb9387ea3e237bb7a66b8a23d2147663d391cafc5c8f37b2e7b4bde5d21"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
version: "2.2.2"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -849,34 +954,34 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "1b92f368f44b0dee2425bb861cfa17b6f6cf3961f762ff6f941d20b33355660a"
|
||||
sha256: bb4738f15b23352822f4c42a531677e5c6f522e079461fd240ead29d8d8a54a6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.0"
|
||||
version: "2.5.0+2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5
|
||||
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.11.0"
|
||||
version: "1.11.1"
|
||||
storage_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: storage_client
|
||||
sha256: "7860281c718983a7cd388b2a87b45af495174701a0230cce2111b81a38352422"
|
||||
sha256: f02d4d8967bec77767dcaf9daf24ca5b8d5a9f1cc093f14dffb77930b52589a3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.3"
|
||||
version: "1.5.4"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8"
|
||||
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
version: "2.1.2"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -889,18 +994,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: supabase
|
||||
sha256: "3d70f8a5d7a09916e1f8aa85d6bf548f8b674e18378498d79fecbfe09e825372"
|
||||
sha256: "1434bb9375f88f51802dadf7b99568117c434f6a9af7f8a55e5be94c8b4da7c9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.11.9"
|
||||
version: "1.11.11"
|
||||
supabase_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: supabase_flutter
|
||||
sha256: "8794dd3b292ebed40ec920f6ef303cb2d78f927a9cff00eebd776c9fa9862153"
|
||||
sha256: "8d68a4fa3215bc23811469fc3499c3895ebb35a2363d6edcfffaa426d5effd84"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.22"
|
||||
version: "1.10.25"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -921,18 +1026,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8"
|
||||
sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
version: "0.6.1"
|
||||
timeago:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: timeago
|
||||
sha256: "4addcda362e51f23cf7ae2357fccd053f29d59b4ddd17fb07fc3e7febb47a456"
|
||||
sha256: c44b80cbc6b44627c00d76960f2af571f6f50e5dbedef4d9215d455e4335165b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.5.0"
|
||||
version: "3.6.0"
|
||||
tint:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -977,74 +1082,74 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: "47e208a6711459d813ba18af120d9663c20bdf6985d6ad39fe165d2538378d27"
|
||||
sha256: b1c9e98774adf8820c96fbc7ae3601231d324a7d5ebd8babe27b6dfac91357ba
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.14"
|
||||
version: "6.2.1"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: b04af59516ab45762b2ca6da40fa830d72d0f6045cd97744450b73493fa76330
|
||||
sha256: "31222ffb0063171b526d3e569079cf1f8b294075ba323443fdc690842bfd4def"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
version: "6.2.0"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "7c65021d5dee51813d652357bc65b8dd4a6177082a9966bc8ba6ee477baa795f"
|
||||
sha256: bba3373219b7abb6b5e0d071b0fe66dfbe005d07517a68e38d4fc3638f35c6d3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5"
|
||||
version: "6.2.1"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: b651aad005e0cb06a01dbd84b428a301916dc75f0e7ea6165f80057fee2d8e8e
|
||||
sha256: "9f2d390e096fdbe1e6e6256f97851e51afc2d9c423d3432f1d6a02a8a9a8b9fd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.6"
|
||||
version: "3.1.0"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: b55486791f666e62e0e8ff825e58a023fd6b1f71c49926483f1128d3bbd8fe88
|
||||
sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
version: "3.1.0"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "95465b39f83bfe95fcb9d174829d6476216f2d548b79c38ab2506e0458787618"
|
||||
sha256: "980e8d9af422f477be6948bdfb68df8433be71f5743a188968b0c1b887807e50"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
version: "2.2.0"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "2942294a500b4fa0b918685aff406773ba0a4cd34b7f42198742a94083020ce5"
|
||||
sha256: "138bd45b3a456dcfafc46d1a146787424f8d2edfbf2809c9324361e58f851cf7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.20"
|
||||
version: "2.2.1"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "95fef3129dc7cfaba2bc3d5ba2e16063bb561fc6d78e63eee16162bc70029069"
|
||||
sha256: "7754a1ad30ee896b265f8d14078b0513a4dba28d358eabb9d5f339886f4a1adc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.8"
|
||||
version: "3.1.0"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: b715b8d3858b6fa9f68f87d20d98830283628014750c2b09b6f516c1da4af2a7
|
||||
sha256: df5a4d8f22ee4ccd77f8839ac7cb274ebc11ef9adcce8b92be14b797fe889921
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
version: "4.2.1"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1053,14 +1158,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
volume_controller:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: volume_controller
|
||||
sha256: "189bdc7a554f476b412e4c8b2f474562b09d74bc458c23667356bce3ca1d48c9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.7"
|
||||
wakelock_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wakelock_plus
|
||||
sha256: "268e56b9c63f850406f54e9acb2a7d2ddf83c26c8ff9e7a125a96c3a513bf65f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
wakelock_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wakelock_plus_platform_interface
|
||||
sha256: "40fabed5da06caff0796dc638e1f07ee395fb18801fbff3255a2372db2d80385"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10
|
||||
sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.4-beta"
|
||||
version: "0.3.0"
|
||||
web_socket_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1073,42 +1202,42 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter
|
||||
sha256: c1ab9b81090705c6069197d9fdc1625e587b52b8d70cdde2339d177ad0dbb98e
|
||||
sha256: "42393b4492e629aa3a88618530a4a00de8bb46e50e7b3993fedbfdc5352f0dbf"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.4.1"
|
||||
version: "4.4.2"
|
||||
webview_flutter_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_android
|
||||
sha256: b0cd33dd7d3dd8e5f664e11a19e17ba12c352647269921a3b568406b001f1dff
|
||||
sha256: "8326ee235f87605a2bfc444a4abc897f4abc78d83f054ba7d3d1074ce82b4fbf"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.12.0"
|
||||
version: "3.12.1"
|
||||
webview_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_platform_interface
|
||||
sha256: "6d9213c65f1060116757a7c473247c60f3f7f332cac33dc417c9e362a9a13e4f"
|
||||
sha256: adb8c03c2be231bea5a8ed0e9039e9d18dbb049603376beaefa15393ede468a5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
version: "2.7.0"
|
||||
webview_flutter_wkwebview:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_wkwebview
|
||||
sha256: "30b9af6bdd457b44c08748b9190d23208b5165357cc2eb57914fee1366c42974"
|
||||
sha256: accdaaa49a2aca2dc3c3230907988954cdd23fed0a19525d6c9789d380f4dc76
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.9.1"
|
||||
version: "3.9.4"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: "350a11abd2d1d97e0cc7a28a81b781c08002aa2864d9e3f192ca0ffa18b06ed3"
|
||||
sha256: "7c99c0e1e2fa190b48d25c81ca5e42036d5cac81430ef249027d97b0935c553f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.9"
|
||||
version: "5.1.0"
|
||||
window_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1129,10 +1258,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84"
|
||||
sha256: af5e77e9b83f2f4adc5d3f0a4ece1c7f45a2467b695c2540381bac793e34e556
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
version: "6.4.2"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1149,22 +1278,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
youtube_player_iframe:
|
||||
youtube_explode_dart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: youtube_player_iframe
|
||||
sha256: d7aec9083430db4e5da83a3b5d7b7fcbb93cfa027d9f680ce3c7e7cd20724305
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.4"
|
||||
youtube_player_iframe_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: youtube_player_iframe_web
|
||||
sha256: c7020816031600349b56d2729d4e8be011fcb723ff7dc2dd0cdf72096a0e5ff4
|
||||
name: youtube_explode_dart
|
||||
sha256: "98fd11b51adbbca76cbdb17f560168f1d7a9835cecceea965f49eb1e5eed155c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
sdks:
|
||||
dart: ">=3.1.3 <4.0.0"
|
||||
flutter: ">=3.13.0"
|
||||
dart: ">=3.2.0 <4.0.0"
|
||||
flutter: ">=3.16.0"
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.1.1+7
|
||||
version: 1.2.0+8
|
||||
|
||||
environment:
|
||||
sdk: '>=3.1.3 <4.0.0'
|
||||
@@ -41,24 +41,35 @@ dependencies:
|
||||
cached_network_image: ^3.2.3
|
||||
carousel_slider: ^4.2.1
|
||||
collection: ^1.17.0
|
||||
flutter_cache_manager: ^3.3.1
|
||||
flutter_markdown: ^0.6.14
|
||||
flutter_native_splash: ^2.2.19
|
||||
flutter_native_splash: ^2.3.5
|
||||
html: ^0.15.4
|
||||
html2md: ^1.2.6
|
||||
intl: ^0.18.1
|
||||
just_audio: ^0.9.32
|
||||
just_audio_background: ^0.0.1-beta.10
|
||||
just_audio_media_kit: ^1.0.0
|
||||
package_info_plus: ^4.1.0
|
||||
# We use our own fork of the "just_audio_media_kit" package, where we
|
||||
# replaced the "media_kit_libs_windows_audio" with
|
||||
# "media_kit_libs_windows_video" package, so that we can play video files on
|
||||
# Windows via the "media_kit" package.
|
||||
just_audio_media_kit:
|
||||
git:
|
||||
url: https://github.com/feeddeck/just_audio_media_kit.git
|
||||
media_kit: ^1.1.10+1
|
||||
media_kit_video: ^1.2.4
|
||||
media_kit_libs_video: ^1.0.4
|
||||
package_info_plus: ^5.0.1
|
||||
provider: ^6.0.4
|
||||
purchases_flutter: ^6.0.0
|
||||
purchases_flutter: ^6.2.0
|
||||
rxdart: ^0.27.7
|
||||
scroll_to_index: ^3.0.1
|
||||
shared_preferences: ^2.1.0
|
||||
supabase_flutter: ^1.10.6
|
||||
timeago: ^3.4.0
|
||||
url_launcher: ^6.1.10
|
||||
supabase_flutter: ^1.10.24
|
||||
timeago: ^3.6.0
|
||||
url_launcher: ^6.2.1
|
||||
window_manager: ^0.3.4
|
||||
youtube_player_iframe: ^4.0.4
|
||||
youtube_explode_dart: ^2.0.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -69,11 +80,11 @@ dev_dependencies:
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^2.0.0
|
||||
flutter_lints: ^3.0.1
|
||||
|
||||
flutter_launcher_icons: ^0.13.1
|
||||
import_sorter: ^4.6.0
|
||||
msix: ^3.16.1
|
||||
msix: ^3.16.6
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
@@ -160,7 +171,7 @@ msix_config:
|
||||
publisher_display_name: Rico Berger
|
||||
identity_name: 26077RicoBerger.FeedDeck
|
||||
publisher: CN=7740451A-C179-450A-B346-7231CA231332
|
||||
msix_version: 1.1.1.0
|
||||
msix_version: 1.2.0.0
|
||||
logo_path: templates/app-icon/windows.png
|
||||
languages: en-us
|
||||
capabilities: internetClient
|
||||
|
||||
58
app/run.sh
Executable file
58
app/run.sh
Executable file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ "$#" -lt 1 ]]; then
|
||||
echo "$(basename "$0") -- program to run the app
|
||||
|
||||
where:
|
||||
-e|--environment set the environment on which the app should be run, e.g. -e=\"local\"
|
||||
-d|--device set the device on which the app should be run, e.g. -d=\"chrome\""
|
||||
exit
|
||||
fi
|
||||
|
||||
for arg in "$@"
|
||||
do
|
||||
case ${arg} in
|
||||
-e=*|--environment=*)
|
||||
environment="${arg#*=}"
|
||||
shift
|
||||
;;
|
||||
-d=*|--device=*)
|
||||
device="${arg#*=}"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "${environment}" ] || [ -z "${device}" ]; then
|
||||
echo "You have to provide an environment and a device"
|
||||
echo " Example: $0 -e=\"local\" -d=\"chrome\""
|
||||
echo " Provided: $0 -e=\"${environment}\" -d=\"${device}\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Run: $0 -e=\"${environment}\" -d=\"${device}\""
|
||||
|
||||
# Load the environment variables from the corresponding ".env" file in the
|
||||
# "supabase" directory.
|
||||
set -o allexport && source "../supabase/.env.${environment}" && set +o allexport
|
||||
|
||||
# When the local environment is used, we have to adjust the Supabase URL to the
|
||||
# local Supabase instance, since the environment variable set by the .env file
|
||||
# uses the Docker address of the Supabase instance.
|
||||
supabase_url=$FEEDDECK_SUPABASE_URL
|
||||
if [ "${environment}" == "local" ]; then
|
||||
supabase_url="http://localhost:54321"
|
||||
fi
|
||||
|
||||
# If the selected device is "chrome" we have to add some additional flags to the
|
||||
# "flutter run" command, since we want to run the app always on the same port
|
||||
# and we want to disable the web security.
|
||||
additional_flags=""
|
||||
if [ "${device}" == "chrome" ]; then
|
||||
additional_flags="--web-port 3000 --web-browser-flag=--disable-web-security"
|
||||
fi
|
||||
|
||||
# Run the app on the provided device and environment.
|
||||
flutter run -d "${device}" ${additional_flags} --dart-define SUPABASE_URL=${supabase_url} --dart-define SUPABASE_ANON_KEY=${FEEDDECK_SUPABASE_ANON_KEY} --dart-define SUPABASE_SITE_URL=${FEEDDECK_SUPABASE_SITE_URL} --dart-define GOOGLE_CLIENT_ID=${FEEDDECK_GOOGLE_CLIENT_ID}
|
||||
@@ -299,6 +299,20 @@
|
||||
"search": [
|
||||
"mastodon"
|
||||
]
|
||||
},
|
||||
{
|
||||
"uid": "84fb197462be8b1518105fbfaf61716f",
|
||||
"css": "pinterest",
|
||||
"code": 59397,
|
||||
"src": "custom_icons",
|
||||
"selected": true,
|
||||
"svg": {
|
||||
"path": "M524 0C318.5 0 115.3 137 115.3 358.8 115.3 499.9 194.7 580 242.7 580 262.6 580 274 524.7 274 509.1 274 490.5 226.5 450.8 226.5 373.3 226.5 212.2 349.1 98 507.8 98 644.3 98 745.2 175.5 745.2 318 745.2 424.4 702.6 623.9 564.3 623.9 514.4 623.9 471.7 587.8 471.7 536.2 471.7 460.4 524.6 387.1 524.6 309 524.6 176.3 336.5 200.4 336.5 360.6 336.5 394.3 340.7 431.6 355.7 462.2 328.1 581.2 271.6 758.6 271.6 881.2 271.6 919 277 956.3 280.6 994.2 287.4 1001.8 284 1001 294.4 997.2 395.4 858.9 391.8 831.9 437.5 651 462.1 697.8 525.8 723.1 576.3 723.1 789.1 723.1 884.7 515.7 884.7 328.8 884.7 129.8 712.8 0 524 0Z",
|
||||
"width": 1000
|
||||
},
|
||||
"search": [
|
||||
"pinterest"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
7
app/templates/iconfont/pinterest.svg
Normal file
7
app/templates/iconfont/pinterest.svg
Normal 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="Pinterest" transform="matrix(8.20665,0,0,8.20665,472.324,-53.3432)">
|
||||
<path d="M204,6.5C101.4,6.5 0,74.9 0,185.6C0,256 39.6,296 63.6,296C73.5,296 79.2,268.4 79.2,260.6C79.2,251.3 55.5,231.5 55.5,192.8C55.5,112.4 116.7,55.4 195.9,55.4C264,55.4 314.4,94.1 314.4,165.2C314.4,218.3 293.1,317.9 224.1,317.9C199.2,317.9 177.9,299.9 177.9,274.1C177.9,236.3 204.3,199.7 204.3,160.7C204.3,94.5 110.4,106.5 110.4,186.5C110.4,203.3 112.5,221.9 120,237.2C106.2,296.6 78,385.1 78,446.3C78,465.2 80.7,483.8 82.5,502.7C85.9,506.5 84.2,506.1 89.4,504.2C139.8,435.2 138,421.7 160.8,331.4C173.1,354.8 204.9,367.4 230.1,367.4C336.3,367.4 384,263.9 384,170.6C384,71.3 298.2,6.5 204,6.5Z" style="fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -7,7 +7,9 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <app_links/app_links_plugin_c_api.h>
|
||||
#include <media_kit_libs_windows_audio/media_kit_libs_windows_audio_plugin_c_api.h>
|
||||
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
|
||||
#include <media_kit_video/media_kit_video_plugin_c_api.h>
|
||||
#include <screen_brightness_windows/screen_brightness_windows_plugin.h>
|
||||
#include <screen_retriever/screen_retriever_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
@@ -15,8 +17,12 @@
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AppLinksPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
|
||||
MediaKitLibsWindowsAudioPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("MediaKitLibsWindowsAudioPluginCApi"));
|
||||
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
|
||||
MediaKitVideoPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
|
||||
ScreenBrightnessWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("ScreenBrightnessWindowsPlugin"));
|
||||
ScreenRetrieverPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("ScreenRetrieverPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
app_links
|
||||
media_kit_libs_windows_audio
|
||||
media_kit_libs_windows_video
|
||||
media_kit_video
|
||||
screen_brightness_windows
|
||||
screen_retriever
|
||||
url_launcher_windows
|
||||
window_manager
|
||||
|
||||
@@ -67,6 +67,7 @@ export default function Home() {
|
||||
<Mastodon />
|
||||
<Medium />
|
||||
<Nitter />
|
||||
<Pinterest />
|
||||
<Reddit />
|
||||
<RSS />
|
||||
<StackOverflow />
|
||||
@@ -252,6 +253,24 @@ const Nitter = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
const Pinterest = () => (
|
||||
<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="Pinterest" transform="matrix(8.20665,0,0,8.20665,472.324,-53.3432)">
|
||||
<path d="M204,6.5C101.4,6.5 0,74.9 0,185.6C0,256 39.6,296 63.6,296C73.5,296 79.2,268.4 79.2,260.6C79.2,251.3 55.5,231.5 55.5,192.8C55.5,112.4 116.7,55.4 195.9,55.4C264,55.4 314.4,94.1 314.4,165.2C314.4,218.3 293.1,317.9 224.1,317.9C199.2,317.9 177.9,299.9 177.9,274.1C177.9,236.3 204.3,199.7 204.3,160.7C204.3,94.5 110.4,106.5 110.4,186.5C110.4,203.3 112.5,221.9 120,237.2C106.2,296.6 78,385.1 78,446.3C78,465.2 80.7,483.8 82.5,502.7C85.9,506.5 84.2,506.1 89.4,504.2C139.8,435.2 138,421.7 160.8,331.4C173.1,354.8 204.9,367.4 230.1,367.4C336.3,367.4 384,263.9 384,170.6C384,71.3 298.2,6.5 204,6.5Z" />
|
||||
</g>
|
||||
</svg>
|
||||
<div className="pt-4">Pinterest</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Reddit = () => (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<svg
|
||||
|
||||
@@ -104,10 +104,10 @@ export const generalMetadata: Metadata = {
|
||||
},
|
||||
},
|
||||
itunes: {
|
||||
appId: "1494512160",
|
||||
appId: "6451055362",
|
||||
},
|
||||
other: {
|
||||
"google-play-app": "app-id=io.feeddeck.feeddeck",
|
||||
"google-play-app": "app-id=app.feeddeck.feeddeck",
|
||||
"msApplication-ID": "26077RicoBerger.FeedDeck",
|
||||
"msApplication-PackageFamilyName": "26077RicoBerger.FeedDeck_2w82je6nmmv2c",
|
||||
},
|
||||
|
||||
188
landing/package-lock.json
generated
188
landing/package-lock.json
generated
@@ -9,17 +9,17 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/react": "^18.2.13",
|
||||
"@types/react-dom": "^18.2.6",
|
||||
"@types/node": "^20.8.10",
|
||||
"@types/react": "^18.2.33",
|
||||
"@types/react-dom": "^18.2.14",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.43.0",
|
||||
"eslint-config-next": "^13.4.6",
|
||||
"next": "^13.4.6",
|
||||
"eslint": "^8.52.0",
|
||||
"eslint-config-next": "^14.0.1",
|
||||
"next": "^14.0.1",
|
||||
"postcss": "^8.4.24",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"typescript": "^5.1.3"
|
||||
}
|
||||
},
|
||||
@@ -98,9 +98,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.51.0.tgz",
|
||||
"integrity": "sha512-HxjQ8Qn+4SI3/AFv6sOrDB+g6PpUTDwSJiQqOrnneEk8L71161srI9gjzzZvYVbzHiVg/BvcH95+cK/zfIt4pg==",
|
||||
"version": "8.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.52.0.tgz",
|
||||
"integrity": "sha512-mjZVbpaeMZludF2fsWLD0Z9gCref1Tk4i9+wddjRvpUNqqcndPkBD09N/Mapey0b3jaXbLm2kICwFv2E64QinA==",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
@@ -121,11 +121,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.11.11",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.11.tgz",
|
||||
"integrity": "sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==",
|
||||
"version": "0.11.13",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
|
||||
"integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==",
|
||||
"dependencies": {
|
||||
"@humanwhocodes/object-schema": "^1.2.1",
|
||||
"@humanwhocodes/object-schema": "^2.0.1",
|
||||
"debug": "^4.1.1",
|
||||
"minimatch": "^3.0.5"
|
||||
},
|
||||
@@ -146,9 +146,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/object-schema": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
|
||||
"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA=="
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz",
|
||||
"integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw=="
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.3",
|
||||
@@ -194,22 +194,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "13.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-13.5.4.tgz",
|
||||
"integrity": "sha512-LGegJkMvRNw90WWphGJ3RMHMVplYcOfRWf2Be3td3sUa+1AaxmsYyANsA+znrGCBjXJNi4XAQlSoEfUxs/4kIQ=="
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.0.1.tgz",
|
||||
"integrity": "sha512-Ms8ZswqY65/YfcjrlcIwMPD7Rg/dVjdLapMcSHG26W6O67EJDF435ShW4H4LXi1xKO1oRc97tLXUpx8jpLe86A=="
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "13.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-13.5.4.tgz",
|
||||
"integrity": "sha512-vI94U+D7RNgX6XypSyjeFrOzxGlZyxOplU0dVE5norIfZGn/LDjJYPHdvdsR5vN1eRtl6PDAsOHmycFEOljK5A==",
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.0.1.tgz",
|
||||
"integrity": "sha512-bLjJMwXdzvhnQOnxvHoTTUh/+PYk6FF/DCgHi4BXwXCINer+o1ZYfL9aVeezj/oI7wqGJOqwGIXrlBvPbAId3w==",
|
||||
"dependencies": {
|
||||
"glob": "7.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "13.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.5.4.tgz",
|
||||
"integrity": "sha512-Df8SHuXgF1p+aonBMcDPEsaahNo2TCwuie7VXED4FVyECvdXfRT9unapm54NssV9tF3OQFKBFOdlje4T43VO0w==",
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.1.tgz",
|
||||
"integrity": "sha512-JyxnGCS4qT67hdOKQ0CkgFTp+PXub5W1wsGvIq98TNbF3YEIN7iDekYhYsZzc8Ov0pWEsghQt+tANdidITCLaw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -222,9 +222,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "13.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.4.tgz",
|
||||
"integrity": "sha512-siPuUwO45PnNRMeZnSa8n/Lye5ZX93IJom9wQRB5DEOdFrw0JjOMu1GINB8jAEdwa7Vdyn1oJ2xGNaQpdQQ9Pw==",
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.1.tgz",
|
||||
"integrity": "sha512-625Z7bb5AyIzswF9hvfZWa+HTwFZw+Jn3lOBNZB87lUS0iuCYDHqk3ujuHCkiyPtSC0xFBtYDLcrZ11mF/ap3w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -237,9 +237,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "13.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.4.tgz",
|
||||
"integrity": "sha512-l/k/fvRP/zmB2jkFMfefmFkyZbDkYW0mRM/LB+tH5u9pB98WsHXC0WvDHlGCYp3CH/jlkJPL7gN8nkTQVrQ/2w==",
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.1.tgz",
|
||||
"integrity": "sha512-iVpn3KG3DprFXzVHM09kvb//4CNNXBQ9NB/pTm8LO+vnnnaObnzFdS5KM+w1okwa32xH0g8EvZIhoB3fI3mS1g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -252,9 +252,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "13.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.4.tgz",
|
||||
"integrity": "sha512-YYGb7SlLkI+XqfQa8VPErljb7k9nUnhhRrVaOdfJNCaQnHBcvbT7cx/UjDQLdleJcfyg1Hkn5YSSIeVfjgmkTg==",
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.1.tgz",
|
||||
"integrity": "sha512-mVsGyMxTLWZXyD5sen6kGOTYVOO67lZjLApIj/JsTEEohDDt1im2nkspzfV5MvhfS7diDw6Rp/xvAQaWZTv1Ww==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -267,9 +267,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "13.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.5.4.tgz",
|
||||
"integrity": "sha512-uE61vyUSClnCH18YHjA8tE1prr/PBFlBFhxBZis4XBRJoR+txAky5d7gGNUIbQ8sZZ7LVkSVgm/5Fc7mwXmRAg==",
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.1.tgz",
|
||||
"integrity": "sha512-wMqf90uDWN001NqCM/auRl3+qVVeKfjJdT9XW+RMIOf+rhUzadmYJu++tp2y+hUbb6GTRhT+VjQzcgg/QTD9NQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -282,9 +282,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "13.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.5.4.tgz",
|
||||
"integrity": "sha512-qVEKFYML/GvJSy9CfYqAdUexA6M5AklYcQCW+8JECmkQHGoPxCf04iMh7CPR7wkHyWWK+XLt4Ja7hhsPJtSnhg==",
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.1.tgz",
|
||||
"integrity": "sha512-ol1X1e24w4j4QwdeNjfX0f+Nza25n+ymY0T2frTyalVczUmzkVD7QGgPTZMHfR1aLrO69hBs0G3QBYaj22J5GQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -297,9 +297,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "13.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.4.tgz",
|
||||
"integrity": "sha512-mDSQfqxAlfpeZOLPxLymZkX0hYF3juN57W6vFHTvwKlnHfmh12Pt7hPIRLYIShk8uYRsKPtMTth/EzpwRI+u8w==",
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.1.tgz",
|
||||
"integrity": "sha512-WEmTEeWs6yRUEnUlahTgvZteh5RJc4sEjCQIodJlZZ5/VJwVP8p2L7l6VhzQhT4h7KvLx/Ed4UViBdne6zpIsw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -312,9 +312,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-ia32-msvc": {
|
||||
"version": "13.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.4.tgz",
|
||||
"integrity": "sha512-aoqAT2XIekIWoriwzOmGFAvTtVY5O7JjV21giozBTP5c6uZhpvTWRbmHXbmsjZqY4HnEZQRXWkSAppsIBweKqw==",
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.1.tgz",
|
||||
"integrity": "sha512-oFpHphN4ygAgZUKjzga7SoH2VGbEJXZa/KL8bHCAwCjDWle6R1SpiGOdUdA8EJ9YsG1TYWpzY6FTbUA+iAJeww==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -327,9 +327,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "13.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.4.tgz",
|
||||
"integrity": "sha512-cyRvlAxwlddlqeB9xtPSfNSCRy8BOa4wtMo0IuI9P7Y0XT2qpDrpFKRyZ7kUngZis59mPVla5k8X1oOJ8RxDYg==",
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.1.tgz",
|
||||
"integrity": "sha512-FFp3nOJ/5qSpeWT0BZQ+YE1pSMk4IMpkME/1DwKBwhg4mJLB9L+6EXuJi4JEwaJdl5iN+UUlmUD3IsR1kx5fAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -392,11 +392,11 @@
|
||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.8.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.5.tgz",
|
||||
"integrity": "sha512-SPlobFgbidfIeOYlzXiEjSYeIJiOCthv+9tSQVpvk4PAdIIc+2SmjNVzWXk9t0Y7dl73Zdf+OgXKHX9XtkqUpw==",
|
||||
"version": "20.8.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.10.tgz",
|
||||
"integrity": "sha512-TlgT8JntpcbmKUFzjhsyhGfP2fsiz1Mv56im6enJ905xG1DAYesxJaeSbGqQmAw8OWPdhyJGhGSQGKRNJ45u9w==",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.25.1"
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
@@ -405,9 +405,9 @@
|
||||
"integrity": "sha512-kMpQpfZKSCBqltAJwskgePRaYRFukDkm1oItcAbC3gNELR20XIBcN9VRgg4+m8DKsTfkWeA4m4Imp4DDuWy7FQ=="
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.28.tgz",
|
||||
"integrity": "sha512-ad4aa/RaaJS3hyGz0BGegdnSRXQBkd1CCYDCdNjBPg90UUpLgo+WlJqb9fMYUxtehmzF3PJaTWqRZjko6BRzBg==",
|
||||
"version": "18.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.33.tgz",
|
||||
"integrity": "sha512-v+I7S+hu3PIBoVkKGpSYYpiBT1ijqEzWpzQD62/jm4K74hPpSP7FF9BnKG6+fg2+62weJYkkBWDJlZt5JO/9hg==",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"@types/scheduler": "*",
|
||||
@@ -415,9 +415,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.13.tgz",
|
||||
"integrity": "sha512-eJIUv7rPP+EC45uNYp/ThhSpE16k22VJUknt5OLoH9tbXoi8bMhwLf5xRuWMywamNbWzhrSmU7IBJfPup1+3fw==",
|
||||
"version": "18.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.14.tgz",
|
||||
"integrity": "sha512-V835xgdSVmyQmI1KLV2BEIUgqEuinxp9O4G6g3FqO/SqLac049E53aysv0oEFD2kHfejeKU+ZqL2bcFWj9gLAQ==",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
@@ -524,6 +524,11 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@ungap/structured-clone": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
|
||||
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.10.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz",
|
||||
@@ -1297,17 +1302,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.51.0.tgz",
|
||||
"integrity": "sha512-2WuxRZBrlwnXi+/vFSJyjMqrNjtJqiasMzehF0shoLaW7DzS3/9Yvrmq5JiT66+pNjiX4UBnLDiKHcWAr/OInA==",
|
||||
"version": "8.52.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.52.0.tgz",
|
||||
"integrity": "sha512-zh/JHnaixqHZsolRB/w9/02akBk9EPrOs9JwcTP2ek7yL5bVvXuRariiaAjjoJ5DvuwQ1WAE/HsMz+w17YgBCg==",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
"@eslint/eslintrc": "^2.1.2",
|
||||
"@eslint/js": "8.51.0",
|
||||
"@humanwhocodes/config-array": "^0.11.11",
|
||||
"@eslint/js": "8.52.0",
|
||||
"@humanwhocodes/config-array": "^0.11.13",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@nodelib/fs.walk": "^1.2.8",
|
||||
"@ungap/structured-clone": "^1.2.0",
|
||||
"ajv": "^6.12.4",
|
||||
"chalk": "^4.0.0",
|
||||
"cross-spawn": "^7.0.2",
|
||||
@@ -1350,11 +1356,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "13.5.4",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-13.5.4.tgz",
|
||||
"integrity": "sha512-FzQGIj4UEszRX7fcRSJK6L1LrDiVZvDFW320VVntVKh3BSU8Fb9kpaoxQx0cdFgf3MQXdeSbrCXJ/5Z/NndDkQ==",
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.0.1.tgz",
|
||||
"integrity": "sha512-QfIFK2WD39H4WOespjgf6PLv9Bpsd7KGGelCtmq4l67nGvnlsGpuvj0hIT+aIy6p5gKH+lAChYILsyDlxP52yg==",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "13.5.4",
|
||||
"@next/eslint-plugin-next": "14.0.1",
|
||||
"@rushstack/eslint-patch": "^1.3.3",
|
||||
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0",
|
||||
"eslint-import-resolver-node": "^0.3.6",
|
||||
@@ -2686,11 +2692,11 @@
|
||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "13.5.4",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-13.5.4.tgz",
|
||||
"integrity": "sha512-+93un5S779gho8y9ASQhb/bTkQF17FNQOtXLKAj3lsNgltEcF0C5PMLLncDmH+8X1EnJH1kbqAERa29nRXqhjA==",
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-14.0.1.tgz",
|
||||
"integrity": "sha512-s4YaLpE4b0gmb3ggtmpmV+wt+lPRuGtANzojMQ2+gmBpgX9w5fTbjsy6dXByBuENsdCX5pukZH/GxdFgO62+pA==",
|
||||
"dependencies": {
|
||||
"@next/env": "13.5.4",
|
||||
"@next/env": "14.0.1",
|
||||
"@swc/helpers": "0.5.2",
|
||||
"busboy": "1.6.0",
|
||||
"caniuse-lite": "^1.0.30001406",
|
||||
@@ -2702,18 +2708,18 @@
|
||||
"next": "dist/bin/next"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14.0"
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "13.5.4",
|
||||
"@next/swc-darwin-x64": "13.5.4",
|
||||
"@next/swc-linux-arm64-gnu": "13.5.4",
|
||||
"@next/swc-linux-arm64-musl": "13.5.4",
|
||||
"@next/swc-linux-x64-gnu": "13.5.4",
|
||||
"@next/swc-linux-x64-musl": "13.5.4",
|
||||
"@next/swc-win32-arm64-msvc": "13.5.4",
|
||||
"@next/swc-win32-ia32-msvc": "13.5.4",
|
||||
"@next/swc-win32-x64-msvc": "13.5.4"
|
||||
"@next/swc-darwin-arm64": "14.0.1",
|
||||
"@next/swc-darwin-x64": "14.0.1",
|
||||
"@next/swc-linux-arm64-gnu": "14.0.1",
|
||||
"@next/swc-linux-arm64-musl": "14.0.1",
|
||||
"@next/swc-linux-x64-gnu": "14.0.1",
|
||||
"@next/swc-linux-x64-musl": "14.0.1",
|
||||
"@next/swc-win32-arm64-msvc": "14.0.1",
|
||||
"@next/swc-win32-ia32-msvc": "14.0.1",
|
||||
"@next/swc-win32-x64-msvc": "14.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
@@ -3630,19 +3636,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.3.tgz",
|
||||
"integrity": "sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==",
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.5.tgz",
|
||||
"integrity": "sha512-5SEZU4J7pxZgSkv7FP1zY8i2TIAOooNZ1e/OGtxIEv6GltpoiXUqWvLy89+a10qYTB1N5Ifkuw9lqQkN9sscvA==",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
"chokidar": "^3.5.3",
|
||||
"didyoumean": "^1.2.2",
|
||||
"dlv": "^1.1.3",
|
||||
"fast-glob": "^3.2.12",
|
||||
"fast-glob": "^3.3.0",
|
||||
"glob-parent": "^6.0.2",
|
||||
"is-glob": "^4.0.3",
|
||||
"jiti": "^1.18.2",
|
||||
"jiti": "^1.19.1",
|
||||
"lilconfig": "^2.1.0",
|
||||
"micromatch": "^4.0.5",
|
||||
"normalize-path": "^3.0.0",
|
||||
@@ -3850,9 +3856,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.25.3",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz",
|
||||
"integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA=="
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.0.13",
|
||||
|
||||
@@ -10,17 +10,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/react": "^18.2.13",
|
||||
"@types/react-dom": "^18.2.6",
|
||||
"@types/node": "^20.8.10",
|
||||
"@types/react": "^18.2.33",
|
||||
"@types/react-dom": "^18.2.14",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.43.0",
|
||||
"eslint-config-next": "^13.4.6",
|
||||
"next": "^13.4.6",
|
||||
"eslint": "^8.52.0",
|
||||
"eslint-config-next": "^14.0.1",
|
||||
"next": "^14.0.1",
|
||||
"postcss": "^8.4.24",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"typescript": "^5.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ FEEDDECK_SUPABASE_SITE_URL=
|
||||
FEEDDECK_SUPABASE_URL=
|
||||
FEEDDECK_SUPABASE_ANON_KEY=
|
||||
FEEDDECK_SUPABASE_SERVICE_ROLE_KEY=
|
||||
FEEDDECK_GOOGLE_CLIENT_ID=
|
||||
|
||||
# Encryption Configuration for Accounts
|
||||
FEEDDECK_ENCRYPTION_KEY=
|
||||
|
||||
@@ -34,9 +34,8 @@ const ChangeEmailAddress = () => {
|
||||
</Text>
|
||||
<Section className="text-center mt-[32px] mb-[32px]">
|
||||
<Button
|
||||
pX={20}
|
||||
pY={12}
|
||||
className="bg-[#49d3b4] rounded text-[#1f2229] text-xs font-semibold no-underline text-center"
|
||||
style={{ padding: "12px 20px" }}
|
||||
href="{{ .SiteURL }}/confirmation?template=change-email-address&confirmation_url={{ .ConfirmationURL }}"
|
||||
>
|
||||
Confirm Change of Email Address
|
||||
|
||||
@@ -42,9 +42,8 @@ const ConfirmSignup = () => {
|
||||
</Text>
|
||||
<Section className="text-center mt-[32px] mb-[32px]">
|
||||
<Button
|
||||
pX={20}
|
||||
pY={12}
|
||||
className="bg-[#49d3b4] rounded text-[#1f2229] text-xs font-semibold no-underline text-center"
|
||||
style={{ padding: "12px 20px" }}
|
||||
href="{{ .SiteURL }}/confirmation?template=confirm-signup&confirmation_url={{ .ConfirmationURL }}"
|
||||
>
|
||||
Confirm Sign Up
|
||||
|
||||
@@ -31,9 +31,8 @@ const InviteUser = () => {
|
||||
</Text>
|
||||
<Section className="text-center mt-[32px] mb-[32px]">
|
||||
<Button
|
||||
pX={20}
|
||||
pY={12}
|
||||
className="bg-[#49d3b4] rounded text-[#1f2229] text-xs font-semibold no-underline text-center"
|
||||
style={{ padding: "12px 20px" }}
|
||||
href="{{ .SiteURL }}/confirmation?template=invite-user&confirmation_url={{ .ConfirmationURL }}"
|
||||
>
|
||||
Accept Invite
|
||||
|
||||
@@ -31,9 +31,8 @@ const InviteUser = () => {
|
||||
</Text>
|
||||
<Section className="text-center mt-[32px] mb-[32px]">
|
||||
<Button
|
||||
pX={20}
|
||||
pY={12}
|
||||
className="bg-[#49d3b4] rounded text-[#1f2229] text-xs font-semibold no-underline text-center"
|
||||
style={{ padding: "12px 20px" }}
|
||||
href="{{ .SiteURL }}/confirmation?template=magic-link&confirmation_url={{ .ConfirmationURL }}"
|
||||
>
|
||||
Sign In
|
||||
|
||||
@@ -32,9 +32,8 @@ const ResetPassword = () => {
|
||||
</Text>
|
||||
<Section className="text-center mt-[32px] mb-[32px]">
|
||||
<Button
|
||||
pX={20}
|
||||
pY={12}
|
||||
className="bg-[#49d3b4] rounded text-[#1f2229] text-xs font-semibold no-underline text-center"
|
||||
style={{ padding: "12px 20px" }}
|
||||
href="{{ .SiteURL }}/confirmation?template=reset-password&confirmation_url={{ .ConfirmationURL }}"
|
||||
>
|
||||
Reset Password
|
||||
|
||||
1486
supabase/email-templates/package-lock.json
generated
1486
supabase/email-templates/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@
|
||||
"export": "email export"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "^0.0.7",
|
||||
"@react-email/components": "^0.0.10",
|
||||
"react-email": "^1.9.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +1,76 @@
|
||||
import { generateKey } from "../_shared/utils/encrypt.ts";
|
||||
import { log } from "../_shared/utils/log.ts";
|
||||
import { runScheduler } from "./scheduler/scheduler.ts";
|
||||
import { runWorker } from "./worker/worker.ts";
|
||||
import { generateAppleSecretKey } from "./tools/tools.ts";
|
||||
import { generateKey } from '../_shared/utils/encrypt.ts';
|
||||
import { log } from '../_shared/utils/log.ts';
|
||||
import { runScheduler } from './scheduler/scheduler.ts';
|
||||
import { runWorker } from './worker/worker.ts';
|
||||
import { generateAppleSecretKey } from './tools/tools.ts';
|
||||
|
||||
/**
|
||||
* Next to the Supabase Edge functions we also have to create an command which can be run inside of a Docker container.
|
||||
* This command is used to start the scheduler or worker, to refetch the feeds for all user sources.
|
||||
* Next to the Supabase Edge functions we also have to create an command which
|
||||
* can be run inside of a Docker container. This command is used to start the
|
||||
* scheduler or worker, to refetch the feeds for all user sources.
|
||||
*/
|
||||
const main = (args: string[]) => {
|
||||
if (args.length === 1 && args[0] === "scheduler") {
|
||||
log("info", "Start scheduler...");
|
||||
if (args.length === 1 && args[0] === 'scheduler') {
|
||||
log('info', 'Start scheduler...');
|
||||
runScheduler().then(() => {
|
||||
Deno.exit(0);
|
||||
}).catch((err) => {
|
||||
log("error", "Scheduler crashed", { error: err.toString() });
|
||||
log('error', 'Scheduler crashed', { error: err.toString() });
|
||||
Deno.exit(1);
|
||||
});
|
||||
} else if (args.length === 1 && args[0] === "worker") {
|
||||
log("info", "Start worker...");
|
||||
} else if (args.length === 1 && args[0] === 'worker') {
|
||||
log('info', 'Start worker...');
|
||||
runWorker().then(() => {
|
||||
Deno.exit(0);
|
||||
}).catch((err) => {
|
||||
log("error", "Worker crashed", { error: err.toString() });
|
||||
log('error', 'Worker crashed', { error: err.toString() });
|
||||
Deno.exit(1);
|
||||
});
|
||||
} else if (
|
||||
args.length === 2 && args[0] === "tools" &&
|
||||
args[1] === "generate-key"
|
||||
args.length === 2 && args[0] === 'tools' &&
|
||||
args[1] === 'generate-key'
|
||||
) {
|
||||
/**
|
||||
* The "tools generate-key" command can be invoked via the following command:
|
||||
* deno run --allow-net --allow-env --import-map=./supabase/functions/import_map.json ./supabase/functions/_cmd/cmd.ts tools generate-key
|
||||
* The "tools generate-key" command can be invoked via the following
|
||||
* command:
|
||||
* deno run --allow-net --allow-env --import-map=./supabase/functions/import_map.json ./supabase/functions/_cmd/cmd.ts tools generate-key
|
||||
*/
|
||||
generateKey().then((data) => {
|
||||
log("info", "Encryption key was generated", {
|
||||
log('info', 'Encryption key was generated', {
|
||||
key: data.rawKey,
|
||||
iv: data.iv,
|
||||
});
|
||||
Deno.exit(0);
|
||||
}).catch((err) => {
|
||||
log("error", "Failed to generate encryption key", {
|
||||
log('error', 'Failed to generate encryption key', {
|
||||
error: err.toString(),
|
||||
});
|
||||
Deno.exit(1);
|
||||
});
|
||||
} else if (
|
||||
args.length === 6 && args[0] === "tools" &&
|
||||
args[1] === "generate-apple-secret-key"
|
||||
args.length === 6 && args[0] === 'tools' &&
|
||||
args[1] === 'generate-apple-secret-key'
|
||||
) {
|
||||
/**
|
||||
* The "tools generate-key" command can be invoked via the following command:
|
||||
* deno run --allow-env --allow-read --import-map=./supabase/functions/import_map.json ./supabase/functions/_cmd/cmd.ts tools generate-apple-secret-key <KEY-ID> <TEAM-ID> <SERVICE-ID> <FILE>
|
||||
* The "tools generate-key" command can be invoked via the following
|
||||
* command:
|
||||
* deno run --allow-env --allow-read --import-map=./supabase/functions/import_map.json ./supabase/functions/_cmd/cmd.ts tools generate-apple-secret-key <KEY-ID> <TEAM-ID> <SERVICE-ID> <FILE>
|
||||
*/
|
||||
generateAppleSecretKey(args[2], args[3], args[4], args[5]).then((data) => {
|
||||
log("info", "Encryption key was generated", {
|
||||
log('info', 'Encryption key was generated', {
|
||||
kid: data.kid,
|
||||
exp: new Date(data.exp * 1000).toString(),
|
||||
jwt: data.jwt,
|
||||
});
|
||||
Deno.exit(0);
|
||||
}).catch((err) => {
|
||||
log("error", "Failed to generate encryption key", {
|
||||
log('error', 'Failed to generate encryption key', {
|
||||
error: err.toString(),
|
||||
});
|
||||
Deno.exit(1);
|
||||
});
|
||||
} else {
|
||||
log("error", "Invalid command-line arguments", { args: args });
|
||||
log('error', 'Invalid command-line arguments', { args: args });
|
||||
Deno.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { connect, Redis } from "redis";
|
||||
import { createClient, SupabaseClient } from "@supabase/supabase-js";
|
||||
import { connect, Redis } from 'redis';
|
||||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { log } from "../../_shared/utils/log.ts";
|
||||
import { log } from '../../_shared/utils/log.ts';
|
||||
import {
|
||||
FEEDDECK_REDIS_HOSTNAME,
|
||||
FEEDDECK_REDIS_PASSWORD,
|
||||
@@ -9,15 +9,16 @@ import {
|
||||
FEEDDECK_REDIS_USERNAME,
|
||||
FEEDDECK_SUPABASE_SERVICE_ROLE_KEY,
|
||||
FEEDDECK_SUPABASE_URL,
|
||||
} from "../../_shared/utils/constants.ts";
|
||||
} from '../../_shared/utils/constants.ts';
|
||||
|
||||
/**
|
||||
* `runScheduler` starts the scheduler which is responsible for fetching all sources from the Supabase database and
|
||||
* schedule them for the worker.
|
||||
* `runScheduler` starts the scheduler which is responsible for fetching all
|
||||
* sources from the Supabase database and schedule them for the worker.
|
||||
*/
|
||||
export const runScheduler = async () => {
|
||||
/**
|
||||
* Create a new Supabase client which is used to fetch all user profiles and sources from the Supabase database.
|
||||
* Create a new Supabase client which is used to fetch all user profiles and
|
||||
* sources from the Supabase database.
|
||||
*/
|
||||
const adminSupabaseClient = createClient(
|
||||
FEEDDECK_SUPABASE_URL,
|
||||
@@ -31,8 +32,9 @@ export const runScheduler = async () => {
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a new Redis client which is used to schedule all sources for the worker and for caching data when needed,
|
||||
* e.g. the media urls for the Google News provider.
|
||||
* Create a new Redis client which is used to schedule all sources for the
|
||||
* worker and for caching data when needed, e.g. the media urls for the Google
|
||||
* News provider.
|
||||
*/
|
||||
const redisClient = await connect({
|
||||
hostname: FEEDDECK_REDIS_HOSTNAME,
|
||||
@@ -42,7 +44,8 @@ export const runScheduler = async () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Run the `scheduleSources` function in an endless loop and wait 15 minutes between each run.
|
||||
* Run the `scheduleSources` function in an endless loop and wait 15 minutes
|
||||
* between each run.
|
||||
*/
|
||||
while (true) {
|
||||
await scheduleSources(adminSupabaseClient, redisClient);
|
||||
@@ -51,14 +54,16 @@ export const runScheduler = async () => {
|
||||
};
|
||||
|
||||
/**
|
||||
* `sleep` is a helper function which can be used to wait for a specific amount of time.
|
||||
* `sleep` is a helper function which can be used to wait for a specific amount
|
||||
* of time.
|
||||
*/
|
||||
const sleep = (ms: number) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
/**
|
||||
* `scheduleSources` fetches all users and their sources from the Supabase database and schedules them for the worker.
|
||||
* `scheduleSources` fetches all users and their sources from the Supabase
|
||||
* database and schedules them for the worker.
|
||||
*/
|
||||
const scheduleSources = async (
|
||||
supabaseClient: SupabaseClient,
|
||||
@@ -66,23 +71,26 @@ const scheduleSources = async (
|
||||
) => {
|
||||
try {
|
||||
/**
|
||||
* The `profileCreatedAt` is used to fetch all user profiles which are newer than 7 days. This is used to only
|
||||
* fetch users which are having an active subscription or which are new to FeedDeck.
|
||||
* The `profileCreatedAt` is used to fetch all user profiles which are newer
|
||||
* than 7 days. This is used to only fetch users which are having an active
|
||||
* subscription or which are new to FeedDeck.
|
||||
*
|
||||
* The `sourcesUpdatedAt` is used to fetch all sources which where not updated in the last hour.
|
||||
* The `sourcesUpdatedAt` is used to fetch all sources which where not
|
||||
* updated in the last hour.
|
||||
*/
|
||||
const profileCreatedAt = Math.floor(new Date().getTime() / 1000) -
|
||||
(60 * 60 * 24 * 7);
|
||||
const sourcesUpdatedAt = Math.floor(new Date().getTime() / 1000) -
|
||||
(60 * 60);
|
||||
log("info", "Schedule sources", {
|
||||
log('info', 'Schedule sources', {
|
||||
sourcesUpdatedAt: sourcesUpdatedAt,
|
||||
profileCreatedAt: profileCreatedAt,
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch all user profiles which are newer than 7 days and which are having an active subscription. We fetch the
|
||||
* profiles in batches of 1000 to avoid fetching all profiles at once.
|
||||
* Fetch all user profiles which are newer than 7 days and which are having
|
||||
* an active subscription. We fetch the profiles in batches of 1000 to avoid
|
||||
* fetching all profiles at once.
|
||||
*/
|
||||
// deno-lint-ignore no-explicit-any
|
||||
const profiles: any[] = [];
|
||||
@@ -90,19 +98,19 @@ const scheduleSources = async (
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
log("debug", "Fetching profiles", { offset: offset });
|
||||
log('debug', 'Fetching profiles', { offset: offset });
|
||||
|
||||
const { data: tmpProfiles, error: profilesError } = await supabaseClient
|
||||
.from(
|
||||
"profiles",
|
||||
).select("*").or(`tier.eq.premium,createdAt.gt.${profileCreatedAt}`)
|
||||
'profiles',
|
||||
).select('*').or(`tier.eq.premium,createdAt.gt.${profileCreatedAt}`)
|
||||
.order(
|
||||
"createdAt",
|
||||
'createdAt',
|
||||
)
|
||||
.range(offset, offset + batchSize);
|
||||
if (profilesError) {
|
||||
log("error", "Failed to get user profiles", {
|
||||
"error": profilesError,
|
||||
log('error', 'Failed to get user profiles', {
|
||||
'error': profilesError,
|
||||
});
|
||||
} else {
|
||||
profiles.push(...tmpProfiles);
|
||||
@@ -115,41 +123,42 @@ const scheduleSources = async (
|
||||
}
|
||||
}
|
||||
|
||||
log("info", "Fetched profiles", { profilesCount: profiles.length });
|
||||
log('info', 'Fetched profiles', { profilesCount: profiles.length });
|
||||
for (const profile of profiles) {
|
||||
/**
|
||||
* Fetch all sources for the current user profile which where not updated in the last hour.
|
||||
*/
|
||||
const { data: sources, error: sourcesError } = await supabaseClient
|
||||
.from(
|
||||
"sources",
|
||||
).select("*").eq("userId", profile.id).lt(
|
||||
"updatedAt",
|
||||
'sources',
|
||||
).select('*').eq('userId', profile.id).lt(
|
||||
'updatedAt',
|
||||
sourcesUpdatedAt,
|
||||
);
|
||||
if (sourcesError) {
|
||||
log("error", "Failed to get user sources", {
|
||||
"profile": profile.id,
|
||||
"error": sourcesError,
|
||||
log('error', 'Failed to get user sources', {
|
||||
'profile': profile.id,
|
||||
'error': sourcesError,
|
||||
});
|
||||
} else {
|
||||
log("info", "Fetched sources", {
|
||||
"profile": profile.id,
|
||||
log('info', 'Fetched sources', {
|
||||
'profile': profile.id,
|
||||
sourcesCount: sources.length,
|
||||
});
|
||||
for (const source of sources) {
|
||||
/**
|
||||
* Schedule the current source for the worker. The scheduled "job" contains the source and the users profile
|
||||
* since it is possible that we need the users account information to fetch the sources data, e.g. the users
|
||||
* GitHub token.
|
||||
* Schedule the current source for the worker. The scheduled "job"
|
||||
* contains the source and the users profile since it is possible that
|
||||
* we need the users account information to fetch the sources data,
|
||||
* e.g. the users GitHub token.
|
||||
*/
|
||||
log("info", "Scheduling source", {
|
||||
"source": source.id,
|
||||
"profile": profile.id,
|
||||
log('info', 'Scheduling source', {
|
||||
'source': source.id,
|
||||
'profile': profile.id,
|
||||
});
|
||||
await redisClient.rpush(
|
||||
// source.id,
|
||||
"sources",
|
||||
'sources',
|
||||
JSON.stringify({
|
||||
source: source,
|
||||
profile: profile,
|
||||
@@ -159,6 +168,6 @@ const scheduleSources = async (
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log("error", "Failed to schedule sources...", { error: err.toString() });
|
||||
log('error', 'Failed to schedule sources...', { error: err.toString() });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const base64URL = (value: string) => {
|
||||
return globalThis.btoa(value).replace(/[=]/g, "").replace(/[+]/g, "-")
|
||||
.replace(/[\/]/g, "_");
|
||||
return globalThis.btoa(value).replace(/[=]/g, '').replace(/[+]/g, '-')
|
||||
.replace(/[\/]/g, '_');
|
||||
};
|
||||
|
||||
const stringToArrayBuffer = (value: string): ArrayBuffer => {
|
||||
@@ -34,46 +34,46 @@ export const generateAppleSecretKey = async (
|
||||
|
||||
// remove PEM headers and spaces
|
||||
const pkcs8 = stringToArrayBuffer(
|
||||
globalThis.atob(contents.replace(/-+[^-]+-+/g, "").replace(/\s+/g, "")),
|
||||
globalThis.atob(contents.replace(/-+[^-]+-+/g, '').replace(/\s+/g, '')),
|
||||
);
|
||||
|
||||
const privateKey = await globalThis.crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
'pkcs8',
|
||||
pkcs8,
|
||||
{
|
||||
name: "ECDSA",
|
||||
namedCurve: "P-256",
|
||||
name: 'ECDSA',
|
||||
namedCurve: 'P-256',
|
||||
},
|
||||
true,
|
||||
["sign"],
|
||||
['sign'],
|
||||
);
|
||||
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + 180 * 24 * 60 * 60;
|
||||
|
||||
const jwt = [
|
||||
base64URL(JSON.stringify({ typ: "JWT", kid, alg: "ES256" })),
|
||||
base64URL(JSON.stringify({ typ: 'JWT', kid, alg: 'ES256' })),
|
||||
base64URL(
|
||||
JSON.stringify({
|
||||
iss,
|
||||
sub,
|
||||
iat,
|
||||
exp,
|
||||
aud: "https://appleid.apple.com",
|
||||
aud: 'https://appleid.apple.com',
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
const signature = await globalThis.crypto.subtle.sign(
|
||||
{
|
||||
name: "ECDSA",
|
||||
hash: "SHA-256",
|
||||
name: 'ECDSA',
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
privateKey,
|
||||
stringToArrayBuffer(jwt.join(".")),
|
||||
stringToArrayBuffer(jwt.join('.')),
|
||||
);
|
||||
|
||||
jwt.push(base64URL(arrayBufferToString(signature)));
|
||||
|
||||
return { kid, jwt: jwt.join("."), exp };
|
||||
return { kid, jwt: jwt.join('.'), exp };
|
||||
};
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { connect, Redis } from "redis";
|
||||
import { createClient, SupabaseClient } from "@supabase/supabase-js";
|
||||
import { connect, Redis } from 'redis';
|
||||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { log } from "../../_shared/utils/log.ts";
|
||||
import { getFeed } from "../../_shared/feed/feed.ts";
|
||||
import { log } from '../../_shared/utils/log.ts';
|
||||
import { getFeed } from '../../_shared/feed/feed.ts';
|
||||
import {
|
||||
FEEDDECK_REDIS_HOSTNAME,
|
||||
FEEDDECK_REDIS_PASSWORD,
|
||||
FEEDDECK_REDIS_PORT,
|
||||
FEEDDECK_REDIS_USERNAME,
|
||||
FEEDDECK_SUPABASE_URL,
|
||||
} from "../../_shared/utils/constants.ts";
|
||||
import { FEEDDECK_SUPABASE_SERVICE_ROLE_KEY } from "../../_shared/utils/constants.ts";
|
||||
} from '../../_shared/utils/constants.ts';
|
||||
import { FEEDDECK_SUPABASE_SERVICE_ROLE_KEY } from '../../_shared/utils/constants.ts';
|
||||
|
||||
/**
|
||||
* `runWorker` starts the worker which is responsible for fetching the feeds for all scheduled sources. For this a
|
||||
* worker will listen for new "jobs" in the Redis queue and fetch the feed for the source and updates the database
|
||||
* `runWorker` starts the worker which is responsible for fetching the feeds for
|
||||
* all scheduled sources. For this a worker will listen for new "jobs" in the
|
||||
* Redis queue and fetch the feed for the source and updates the database
|
||||
* entries.
|
||||
*/
|
||||
export const runWorker = async () => {
|
||||
/**
|
||||
* Create a new Supabase client which is used to update all sources and items in the Supabase database.
|
||||
* Create a new Supabase client which is used to update all sources and items
|
||||
* in the Supabase database.
|
||||
*/
|
||||
const adminSupabaseClient = createClient(
|
||||
FEEDDECK_SUPABASE_URL,
|
||||
@@ -33,8 +35,9 @@ export const runWorker = async () => {
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a new Redis client which is used to listen for new sources which should be fetched and for caching data when
|
||||
* needed, e.g. the media urls for the Google News provider.
|
||||
* Create a new Redis client which is used to listen for new sources which
|
||||
* should be fetched and for caching data when needed, e.g. the media urls for
|
||||
* the Google News provider.
|
||||
*/
|
||||
const redisClient = await connect({
|
||||
hostname: FEEDDECK_REDIS_HOSTNAME,
|
||||
@@ -57,22 +60,22 @@ const listenForSources = async (
|
||||
) => {
|
||||
try {
|
||||
/**
|
||||
* Listen for new sources in the Redis queue. Once a valid source is received we get the source and profile from the
|
||||
* Redis data.
|
||||
* Listen for new sources in the Redis queue. Once a valid source is
|
||||
* received we get the source and profile from the Redis data.
|
||||
*/
|
||||
const data = await redisClient.blpop(1000 * 60, "sources");
|
||||
if (data && data[0] === "sources") {
|
||||
const data = await redisClient.blpop(1000 * 60, 'sources');
|
||||
if (data && data[0] === 'sources') {
|
||||
const { source: redisSource, profile: redisProfile } = JSON.parse(
|
||||
data[1],
|
||||
);
|
||||
log("info", "Received source", {
|
||||
"source": redisSource.id,
|
||||
"profile": redisProfile.id,
|
||||
log('info', 'Received source', {
|
||||
'source': redisSource.id,
|
||||
'profile': redisProfile.id,
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch the feed for the source using the created Supabase client, Redis client and the source and profile data
|
||||
* from Redis.
|
||||
* Fetch the feed for the source using the created Supabase client, Redis
|
||||
* client and the source and profile data from Redis.
|
||||
*/
|
||||
const { source, items } = await getFeed(
|
||||
supabaseClient,
|
||||
@@ -82,33 +85,35 @@ const listenForSources = async (
|
||||
);
|
||||
|
||||
/**
|
||||
* Update the source and items in the Supabase database, when the returned list of items contains at least one
|
||||
* item. We have to use `upsert` instead of `update` to update do bulk "updates" for all sources and items.
|
||||
* Update the source and items in the Supabase database, when the
|
||||
* returned list of items contains at least one item. We have to use
|
||||
* `upsert` instead of `update` to update do bulk "updates" for all
|
||||
* sources and items.
|
||||
*/
|
||||
if (items.length > 0) {
|
||||
const { error: sourceError } = await supabaseClient.from("sources")
|
||||
const { error: sourceError } = await supabaseClient.from('sources')
|
||||
.upsert(
|
||||
source,
|
||||
);
|
||||
if (sourceError) {
|
||||
log("error", "Failed to save sources", { "error": sourceError });
|
||||
log('error', 'Failed to save sources', { 'error': sourceError });
|
||||
}
|
||||
|
||||
const { error: itemsError } = await supabaseClient.from("items")
|
||||
const { error: itemsError } = await supabaseClient.from('items')
|
||||
.upsert(
|
||||
items,
|
||||
);
|
||||
if (itemsError) {
|
||||
log("error", "Failed to save items", { "error": itemsError });
|
||||
log('error', 'Failed to save items', { 'error': itemsError });
|
||||
}
|
||||
|
||||
log("info", "Updated source", {
|
||||
"source": redisSource.id,
|
||||
"profile": redisProfile.id,
|
||||
log('info', 'Updated source', {
|
||||
'source': redisSource.id,
|
||||
'profile': redisProfile.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log("error", "Failed to listen for sources", { "error": err.toString() });
|
||||
log('error', 'Failed to listen for sources', { 'error': err.toString() });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { Redis } from "redis";
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { Redis } from 'redis';
|
||||
|
||||
import { IItem } from "../models/item.ts";
|
||||
import { ISource } from "../models/source.ts";
|
||||
import { getMediumFeed, isMediumUrl } from "./medium.ts";
|
||||
import { getRSSFeed } from "./rss.ts";
|
||||
import { getPodcastFeed } from "./podcast.ts";
|
||||
import { getTumblrFeed, isTumblrUrl } from "./tumblr.ts";
|
||||
import { getStackoverflowFeed } from "./stackoverflow.ts";
|
||||
import { getGooglenewsFeed } from "./googlenews.ts";
|
||||
import { getYoutubeFeed, isYoutubeUrl } from "./youtube.ts";
|
||||
import { getRedditFeed, isRedditUrl } from "./reddit.ts";
|
||||
import { getGithubFeed } from "./github.ts";
|
||||
import { IProfile } from "../models/profile.ts";
|
||||
import { getNitterFeed } from "./nitter.ts";
|
||||
import { getMastodonFeed } from "./mastodon.ts";
|
||||
import { getXFeed } from "./x.ts";
|
||||
import { IItem } from '../models/item.ts';
|
||||
import { ISource } from '../models/source.ts';
|
||||
import { getMediumFeed, isMediumUrl } from './medium.ts';
|
||||
import { getPinterestFeed, isPinterestUrl } from './pinterest.ts';
|
||||
import { getRSSFeed } from './rss.ts';
|
||||
import { getPodcastFeed } from './podcast.ts';
|
||||
import { getTumblrFeed, isTumblrUrl } from './tumblr.ts';
|
||||
import { getStackoverflowFeed } from './stackoverflow.ts';
|
||||
import { getGooglenewsFeed } from './googlenews.ts';
|
||||
import { getYoutubeFeed, isYoutubeUrl } from './youtube.ts';
|
||||
import { getRedditFeed, isRedditUrl } from './reddit.ts';
|
||||
import { getGithubFeed } from './github.ts';
|
||||
import { IProfile } from '../models/profile.ts';
|
||||
import { getNitterFeed } from './nitter.ts';
|
||||
import { getMastodonFeed } from './mastodon.ts';
|
||||
// import { getXFeed } from './x.ts';
|
||||
|
||||
/**
|
||||
* `getFeed` returns a feed which consist of a source and a list of items for the provided `source`. Based on the `type`
|
||||
* of the provided source it will use the appropriate function to get the feed.
|
||||
* `getFeed` returns a feed which consist of a source and a list of items for
|
||||
* the provided `source`. Based on the `type` of the provided source it will use
|
||||
* the appropriate function to get the feed.
|
||||
*/
|
||||
export const getFeed = async (
|
||||
supabaseClient: SupabaseClient,
|
||||
@@ -28,31 +30,38 @@ export const getFeed = async (
|
||||
source: ISource,
|
||||
): Promise<{ source: ISource; items: IItem[] }> => {
|
||||
switch (source.type) {
|
||||
case "github":
|
||||
case 'github':
|
||||
return await getGithubFeed(supabaseClient, redisClient, profile, source);
|
||||
case "googlenews":
|
||||
case 'googlenews':
|
||||
return await getGooglenewsFeed(
|
||||
supabaseClient,
|
||||
redisClient,
|
||||
profile,
|
||||
source,
|
||||
);
|
||||
case "mastodon":
|
||||
case 'mastodon':
|
||||
return await getMastodonFeed(
|
||||
supabaseClient,
|
||||
redisClient,
|
||||
profile,
|
||||
source,
|
||||
);
|
||||
case "medium":
|
||||
case 'medium':
|
||||
return await getMediumFeed(supabaseClient, redisClient, profile, source);
|
||||
case "nitter":
|
||||
case 'nitter':
|
||||
return await getNitterFeed(supabaseClient, redisClient, profile, source);
|
||||
case "podcast":
|
||||
case 'pinterest':
|
||||
return await getPinterestFeed(
|
||||
supabaseClient,
|
||||
redisClient,
|
||||
profile,
|
||||
source,
|
||||
);
|
||||
case 'podcast':
|
||||
return await getPodcastFeed(supabaseClient, redisClient, profile, source);
|
||||
case "reddit":
|
||||
case 'reddit':
|
||||
return await getRedditFeed(supabaseClient, redisClient, profile, source);
|
||||
case "rss":
|
||||
case 'rss':
|
||||
try {
|
||||
if (source.options?.rss && isMediumUrl(source.options.rss)) {
|
||||
return await getMediumFeed(supabaseClient, redisClient, profile, {
|
||||
@@ -61,6 +70,13 @@ export const getFeed = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (source.options?.rss && isPinterestUrl(source.options.rss)) {
|
||||
return await getPinterestFeed(supabaseClient, redisClient, profile, {
|
||||
...source,
|
||||
options: { pinterest: source.options.rss },
|
||||
});
|
||||
}
|
||||
|
||||
if (source.options?.rss && isRedditUrl(source.options.rss)) {
|
||||
return await getTumblrFeed(supabaseClient, redisClient, profile, {
|
||||
...source,
|
||||
@@ -83,25 +99,26 @@ export const getFeed = async (
|
||||
}
|
||||
} catch (_) {
|
||||
/**
|
||||
* We ignore any errors at this point and try to use the general `getRSSFeed` function instead.
|
||||
* We ignore any errors at this point and try to use the general
|
||||
* `getRSSFeed` function instead.
|
||||
*/
|
||||
}
|
||||
|
||||
return await getRSSFeed(supabaseClient, redisClient, profile, source);
|
||||
case "stackoverflow":
|
||||
case 'stackoverflow':
|
||||
return await getStackoverflowFeed(
|
||||
supabaseClient,
|
||||
redisClient,
|
||||
profile,
|
||||
source,
|
||||
);
|
||||
case "tumblr":
|
||||
case 'tumblr':
|
||||
return await getTumblrFeed(supabaseClient, redisClient, profile, source);
|
||||
// case "x":
|
||||
// return await getXFeed(supabaseClient, redisClient, profile, source);
|
||||
case "youtube":
|
||||
case 'youtube':
|
||||
return await getYoutubeFeed(supabaseClient, redisClient, profile, source);
|
||||
default:
|
||||
throw new Error("Invalid source type");
|
||||
throw new Error('Invalid source type');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { Md5 } from "std/md5";
|
||||
import { Redis } from "redis";
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { Md5 } from 'std/md5';
|
||||
import { Redis } from 'redis';
|
||||
|
||||
import { ISource } from "../models/source.ts";
|
||||
import { IItem } from "../models/item.ts";
|
||||
import { IProfile } from "../models/profile.ts";
|
||||
import { decrypt } from "../utils/encrypt.ts";
|
||||
import { fetchWithTimeout } from "../utils/fetchWithTimeout.ts";
|
||||
import { uploadSourceIcon } from "./utils/uploadFile.ts";
|
||||
import { ISource } from '../models/source.ts';
|
||||
import { IItem } from '../models/item.ts';
|
||||
import { IProfile } from '../models/profile.ts';
|
||||
import { decrypt } from '../utils/encrypt.ts';
|
||||
import { fetchWithTimeout } from '../utils/fetchWithTimeout.ts';
|
||||
import { uploadSourceIcon } from './utils/uploadFile.ts';
|
||||
|
||||
export const getGithubFeed = async (
|
||||
supabaseClient: SupabaseClient,
|
||||
@@ -16,22 +16,23 @@ export const getGithubFeed = async (
|
||||
source: ISource,
|
||||
): Promise<{ source: ISource; items: IItem[] }> => {
|
||||
if (!source.options?.github || !source.options?.github.type) {
|
||||
throw new Error("Invalid source options");
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
|
||||
if (!profile.accountGithub?.token) {
|
||||
throw new Error("GitHub token is missing");
|
||||
throw new Error('GitHub token is missing');
|
||||
}
|
||||
const token = await decrypt(profile.accountGithub.token);
|
||||
|
||||
if (
|
||||
source.options.github.type === "notifications" ||
|
||||
source.options.github.type === "repositorynotifications"
|
||||
source.options.github.type === 'notifications' ||
|
||||
source.options.github.type === 'repositorynotifications'
|
||||
) {
|
||||
/**
|
||||
* With `notifications` and `repositorynotifications` type users can add there GitHub notifications or repository
|
||||
* notifications as source to FeedDeck. The notifications are retrieved from the GitHub API via the list
|
||||
* notifications endpoint.
|
||||
* With `notifications` and `repositorynotifications` type users can add
|
||||
* there GitHub notifications or repository notifications as source to
|
||||
* FeedDeck. The notifications are retrieved from the GitHub API via the
|
||||
* list notifications endpoint.
|
||||
*
|
||||
* - https://docs.github.com/en/rest/activity/notifications?apiVersion=2022-11-28#list-notifications-for-the-authenticated-user
|
||||
* - https://docs.github.com/en/rest/activity/notifications?apiVersion=2022-11-28#list-repository-notifications-for-the-authenticated-user
|
||||
@@ -39,18 +40,18 @@ export const getGithubFeed = async (
|
||||
*/
|
||||
const notifications = [];
|
||||
|
||||
if (source.options.github.type === "notifications") {
|
||||
const tmpNotifications = await request("/notifications", {
|
||||
if (source.options.github.type === 'notifications') {
|
||||
const tmpNotifications = await request('/notifications', {
|
||||
token: token,
|
||||
params: {
|
||||
all: "true",
|
||||
participating: source.options.github.participating ? "true" : "false",
|
||||
page: "1",
|
||||
per_page: "50",
|
||||
all: 'true',
|
||||
participating: source.options.github.participating ? 'true' : 'false',
|
||||
page: '1',
|
||||
per_page: '50',
|
||||
},
|
||||
});
|
||||
|
||||
const user = await request("/user", {
|
||||
const user = await request('/user', {
|
||||
token: token,
|
||||
});
|
||||
|
||||
@@ -61,21 +62,21 @@ export const getGithubFeed = async (
|
||||
source.icon = await uploadSourceIcon(supabaseClient, source);
|
||||
notifications.push(...tmpNotifications);
|
||||
} else if (
|
||||
source.options.github.type === "repositorynotifications" &&
|
||||
source.options.github.type === 'repositorynotifications' &&
|
||||
source.options.github.repository
|
||||
) {
|
||||
const [owner, repo] = source.options.github.repository.split("/");
|
||||
const [owner, repo] = source.options.github.repository.split('/');
|
||||
const tmpNotifications = await request(
|
||||
`/repos/${owner}/${repo}/notifications`,
|
||||
{
|
||||
token: token,
|
||||
params: {
|
||||
all: "true",
|
||||
all: 'true',
|
||||
participating: source.options.github.participating
|
||||
? "true"
|
||||
: "false",
|
||||
page: "1",
|
||||
per_page: "50",
|
||||
? 'true'
|
||||
: 'false',
|
||||
page: '1',
|
||||
per_page: '50',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -95,11 +96,11 @@ export const getGithubFeed = async (
|
||||
}
|
||||
notifications.push(...tmpNotifications);
|
||||
} else {
|
||||
throw new Error("Invalid source options");
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
|
||||
source.type = "github";
|
||||
source.link = "https://github.com/notifications";
|
||||
source.type = 'github';
|
||||
source.link = 'https://github.com/notifications';
|
||||
|
||||
const items: IItem[] = [];
|
||||
|
||||
@@ -109,13 +110,8 @@ export const getGithubFeed = async (
|
||||
userId: source.userId,
|
||||
columnId: source.columnId,
|
||||
sourceId: source.id,
|
||||
title: notification.subject?.title ?? "Notification",
|
||||
link: notification.subject?.url
|
||||
? `https://github.com/${notification.subject?.url.replace(
|
||||
"https://api.github.com/repos/",
|
||||
"",
|
||||
)}`
|
||||
: "",
|
||||
title: notification.subject?.title ?? 'Notification',
|
||||
link: getLinkFromApiUrl(notification.subject?.url),
|
||||
media: notification.repository.owner.avatar_url,
|
||||
description: formatDescription(notification),
|
||||
author: notification.repository?.full_name,
|
||||
@@ -127,17 +123,20 @@ export const getGithubFeed = async (
|
||||
|
||||
return { source, items };
|
||||
} else if (
|
||||
source.options.github.type === "useractivities" ||
|
||||
source.options.github.type === "repositoryactivities" ||
|
||||
source.options.github.type === "organizationactivitiespublic" ||
|
||||
source.options.github.type === "organizationactivitiesprivate"
|
||||
source.options.github.type === 'useractivities' ||
|
||||
source.options.github.type === 'repositoryactivities' ||
|
||||
source.options.github.type === 'organizationactivitiespublic' ||
|
||||
source.options.github.type === 'organizationactivitiesprivate'
|
||||
) {
|
||||
/**
|
||||
* `useractivities`, `repositoryactivities`, `organizationactivitiespublic` and `organizationactivitiesprivate` lets
|
||||
* a user add the user, repository or organization notifications as source. We are using the corresponding events
|
||||
* endpoint to get the notifications and add the id, title, icon and link to the source. Then we are going though
|
||||
* all the events and adding the actor of an event as author. The title, description and link is different for each
|
||||
* notification type and generated via the `formatEvent` function.
|
||||
* `useractivities`, `repositoryactivities`, `organizationactivitiespublic`
|
||||
* and `organizationactivitiesprivate` lets a user add the user, repository
|
||||
* or organization notifications as source. We are using the corresponding
|
||||
* events endpoint to get the notifications and add the id, title, icon and
|
||||
* link to the source. Then we are going though all the events and adding
|
||||
* the actor of an event as author. The title, description and link is
|
||||
* different for each notification type and generated via the `formatEvent`
|
||||
* function.
|
||||
*
|
||||
* - https://docs.github.com/en/developers/webhooks-and-events/events/github-event-types
|
||||
* - GitHubTypeUserActivities: https://docs.github.com/en/rest/activity/events?apiVersion=2022-11-28#list-events-received-by-the-authenticated-user
|
||||
@@ -148,7 +147,7 @@ export const getGithubFeed = async (
|
||||
const events = [];
|
||||
|
||||
if (
|
||||
source.options.github.type === "useractivities" &&
|
||||
source.options.github.type === 'useractivities' &&
|
||||
source.options.github.user
|
||||
) {
|
||||
const tmpEvents = await request(
|
||||
@@ -156,8 +155,8 @@ export const getGithubFeed = async (
|
||||
{
|
||||
token: token,
|
||||
params: {
|
||||
page: "1",
|
||||
per_page: "100",
|
||||
page: '1',
|
||||
per_page: '100',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -175,17 +174,17 @@ export const getGithubFeed = async (
|
||||
|
||||
events.push(...tmpEvents);
|
||||
} else if (
|
||||
source.options.github.type === "repositoryactivities" &&
|
||||
source.options.github.type === 'repositoryactivities' &&
|
||||
source.options.github.repository
|
||||
) {
|
||||
const [owner, repo] = source.options.github.repository.split("/");
|
||||
const [owner, repo] = source.options.github.repository.split('/');
|
||||
const tmpEvents = await request(
|
||||
`/repos/${owner}/${repo}/events`,
|
||||
{
|
||||
token: token,
|
||||
params: {
|
||||
page: "1",
|
||||
per_page: "100",
|
||||
page: '1',
|
||||
per_page: '100',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -203,7 +202,7 @@ export const getGithubFeed = async (
|
||||
|
||||
events.push(...tmpEvents);
|
||||
} else if (
|
||||
source.options.github.type === "organizationactivitiespublic" &&
|
||||
source.options.github.type === 'organizationactivitiespublic' &&
|
||||
source.options.github.organization
|
||||
) {
|
||||
const tmpEvents = await request(
|
||||
@@ -211,8 +210,8 @@ export const getGithubFeed = async (
|
||||
{
|
||||
token: token,
|
||||
params: {
|
||||
page: "1",
|
||||
per_page: "100",
|
||||
page: '1',
|
||||
per_page: '100',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -233,10 +232,10 @@ export const getGithubFeed = async (
|
||||
|
||||
events.push(...tmpEvents);
|
||||
} else if (
|
||||
source.options.github.type === "organizationactivitiesprivate" &&
|
||||
source.options.github.type === 'organizationactivitiesprivate' &&
|
||||
source.options.github.organization
|
||||
) {
|
||||
const user = await request("/user", {
|
||||
const user = await request('/user', {
|
||||
token: token,
|
||||
});
|
||||
|
||||
@@ -245,8 +244,8 @@ export const getGithubFeed = async (
|
||||
{
|
||||
token: token,
|
||||
params: {
|
||||
page: "1",
|
||||
per_page: "100",
|
||||
page: '1',
|
||||
per_page: '100',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -267,10 +266,10 @@ export const getGithubFeed = async (
|
||||
|
||||
events.push(...tmpEvents);
|
||||
} else {
|
||||
throw new Error("Invalid source options");
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
|
||||
source.type = "github";
|
||||
source.type = 'github';
|
||||
|
||||
const items: IItem[] = [];
|
||||
|
||||
@@ -282,8 +281,8 @@ export const getGithubFeed = async (
|
||||
userId: source.userId,
|
||||
columnId: source.columnId,
|
||||
sourceId: source.id,
|
||||
title: eventDetails.title ?? "",
|
||||
link: eventDetails.link ?? "",
|
||||
title: eventDetails.title ?? '',
|
||||
link: eventDetails.link ?? '',
|
||||
media: event.actor?.avatar_url
|
||||
? event.actor?.avatar_url
|
||||
: event.actor?.login
|
||||
@@ -300,15 +299,18 @@ export const getGithubFeed = async (
|
||||
|
||||
return { source, items };
|
||||
} else if (
|
||||
source.options.github.type === "searchissuesandpullrequests" &&
|
||||
source.options.github.type === 'searchissuesandpullrequests' &&
|
||||
source.options.github.query
|
||||
) {
|
||||
/**
|
||||
* The `searchissuesandpullrequests` let a user add a seach query as source. With this type it is possible to follow
|
||||
* all issues and pull requests of an user, repository or organization. Since we allow a custom query we do not add
|
||||
* a icon and link to the source. The id of the source is generated based on a hash of the query. Then we are going
|
||||
* through all the returned issues and generating an item for each issue, where we are using the user as author (we
|
||||
* were also thinking about using the repository, but decided against it).
|
||||
* The `searchissuesandpullrequests` let a user add a seach query as source.
|
||||
* With this type it is possible to follow all issues and pull requests of
|
||||
* an user, repository or organization. Since we allow a custom query we do
|
||||
* not add a icon and link to the source. The id of the source is generated
|
||||
* based on a hash of the query. Then we are going through all the returned
|
||||
* issues and generating an item for each issue, where we are using the user
|
||||
* as author (we were also thinking about using the repository, but decided
|
||||
* against it).
|
||||
*
|
||||
* - https://docs.github.com/en/rest/search?apiVersion=2022-11-28#search-issues-and-pull-requests
|
||||
*/
|
||||
@@ -318,10 +320,10 @@ export const getGithubFeed = async (
|
||||
token: token,
|
||||
params: {
|
||||
q: source.options.github.query,
|
||||
sort: "created",
|
||||
direction: "desc",
|
||||
page: "1",
|
||||
per_page: "100",
|
||||
sort: 'created',
|
||||
direction: 'desc',
|
||||
page: '1',
|
||||
per_page: '100',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -330,8 +332,8 @@ export const getGithubFeed = async (
|
||||
`github-${source.userId}-${source.columnId}-${source.options.github.type}-${
|
||||
new Md5().update(source.options.github.query).toString()
|
||||
}`;
|
||||
source.type = "github";
|
||||
source.title = source.options.github.queryName || "Search";
|
||||
source.type = 'github';
|
||||
source.title = source.options.github.queryName || 'Search';
|
||||
source.icon = undefined;
|
||||
source.link = undefined;
|
||||
|
||||
@@ -352,8 +354,8 @@ export const getGithubFeed = async (
|
||||
: undefined,
|
||||
description: `${
|
||||
issue.repository_url.replace(
|
||||
"https://api.github.com/repos/",
|
||||
"",
|
||||
'https://api.github.com/repos/',
|
||||
'',
|
||||
)
|
||||
} #${issue.number}`,
|
||||
author: issue.user.login,
|
||||
@@ -366,7 +368,7 @@ export const getGithubFeed = async (
|
||||
return { source, items };
|
||||
}
|
||||
|
||||
throw new Error("Invalid source options");
|
||||
throw new Error('Invalid source options');
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -378,14 +380,14 @@ const request = async (
|
||||
) => {
|
||||
const res = await fetchWithTimeout(
|
||||
`https://api.github.com${url}${
|
||||
options.params ? `?${new URLSearchParams(options.params).toString()}` : ""
|
||||
options.params ? `?${new URLSearchParams(options.params).toString()}` : ''
|
||||
}`,
|
||||
{
|
||||
method: "GET",
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${options.token}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
},
|
||||
5000,
|
||||
@@ -393,51 +395,73 @@ const request = async (
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error?.message ?? "Unknown error");
|
||||
throw new Error(error?.message ?? 'Unknown error');
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* `formatDescription` formats the description for a notification based on the notification reason.
|
||||
* `getLinkFromApiUrl` returns a link which can be clicked by a user based on
|
||||
* the given GitHub API url. If there is no url given we return the default
|
||||
* GitHub notifications link.
|
||||
*/
|
||||
const getLinkFromApiUrl = (url?: string): string => {
|
||||
if (!url) {
|
||||
return 'https://github.com/notifications';
|
||||
}
|
||||
|
||||
if (/^https:\/\/api.github.com\/repos\/.*\/.*\/pulls\/\d+$/.test(url)) {
|
||||
const n = url.lastIndexOf('pulls');
|
||||
url = url.slice(0, n) + url.slice(n).replace('pulls', 'pull');
|
||||
}
|
||||
|
||||
return `https://github.com/${
|
||||
url.replace('https://api.github.com/repos/', '')
|
||||
}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* `formatDescription` formats the description for a notification based on the
|
||||
* notification reason.
|
||||
* See: https://docs.github.com/en/rest/activity/notifications?apiVersion=2022-11-28#about-notification-reasons
|
||||
*/
|
||||
// deno-lint-ignore no-explicit-any
|
||||
const formatDescription = (notification: any): string | undefined => {
|
||||
switch (notification.reason) {
|
||||
case "assign":
|
||||
return "You were assigned to the issue.";
|
||||
case "author":
|
||||
return "You created the thread.";
|
||||
case "comment":
|
||||
return "You commented on the thread.";
|
||||
case "ci_activity":
|
||||
return "A GitHub Actions workflow run that you triggered was completed.";
|
||||
case "invitation":
|
||||
return "You accepted an invitation to contribute to the repository.";
|
||||
case "manual":
|
||||
return "You subscribed to the thread (via an issue or pull request).";
|
||||
case "mention":
|
||||
return "You were specifically @mentioned in the content.";
|
||||
case "review_requested":
|
||||
return "You, or a team you're a member of, were requested to review a pull request.";
|
||||
case "security_alert":
|
||||
return "GitHub discovered a security vulnerability in your repository.";
|
||||
case "state_change":
|
||||
return "You changed the thread state (for example, closing an issue or merging a pull request).";
|
||||
case "subscribed":
|
||||
return "You're watching the repository.";
|
||||
case "team_mention":
|
||||
return "You were on a team that was mentioned.";
|
||||
case 'assign':
|
||||
return 'You were assigned to the issue.';
|
||||
case 'author':
|
||||
return 'You created the thread.';
|
||||
case 'comment':
|
||||
return 'You commented on the thread.';
|
||||
case 'ci_activity':
|
||||
return 'A GitHub Actions workflow run that you triggered was completed.';
|
||||
case 'invitation':
|
||||
return 'You accepted an invitation to contribute to the repository.';
|
||||
case 'manual':
|
||||
return 'You subscribed to the thread (via an issue or pull request).';
|
||||
case 'mention':
|
||||
return 'You were specifically @mentioned in the content.';
|
||||
case 'review_requested':
|
||||
return 'You, or a team you\'re a member of, were requested to review a pull request.';
|
||||
case 'security_alert':
|
||||
return 'GitHub discovered a security vulnerability in your repository.';
|
||||
case 'state_change':
|
||||
return 'You changed the thread state (for example, closing an issue or merging a pull request).';
|
||||
case 'subscribed':
|
||||
return 'You\'re watching the repository.';
|
||||
case 'team_mention':
|
||||
return 'You were on a team that was mentioned.';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* `formatEvent` formats the given event by returning a proper title, link and description for a item as we save it in
|
||||
* our database. If the event type is not supported or a required field is missing we return an error.
|
||||
* `formatEvent` formats the given event by returning a proper title, link and
|
||||
* description for a item as we save it in our database. If the event type is
|
||||
* not supported or a required field is missing we return an error.
|
||||
*/
|
||||
const formatEvent = (
|
||||
// deno-lint-ignore no-explicit-any
|
||||
@@ -448,85 +472,85 @@ const formatEvent = (
|
||||
description: string | undefined;
|
||||
} | undefined => {
|
||||
switch (event.type) {
|
||||
case "CommitCommentEvent":
|
||||
case 'CommitCommentEvent':
|
||||
if (event.payload?.comment?.html_url) {
|
||||
return {
|
||||
title: "",
|
||||
title: '',
|
||||
link: event.payload.comment.html_url,
|
||||
description: "Added a comment to a commit",
|
||||
description: 'Added a comment to a commit',
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
|
||||
case "CreateEvent":
|
||||
case 'CreateEvent':
|
||||
if (event.payload?.ref_type) {
|
||||
let description = "";
|
||||
let description = '';
|
||||
switch (event.payload?.ref_type) {
|
||||
case "repository":
|
||||
description = "Created a new repository";
|
||||
case 'repository':
|
||||
description = 'Created a new repository';
|
||||
break;
|
||||
case "branch":
|
||||
description = "Created a new branch";
|
||||
case 'branch':
|
||||
description = 'Created a new branch';
|
||||
break;
|
||||
case "tag":
|
||||
description = "Created a new tag";
|
||||
case 'tag':
|
||||
description = 'Created a new tag';
|
||||
break;
|
||||
default:
|
||||
description = "Created something";
|
||||
description = 'Created something';
|
||||
break;
|
||||
}
|
||||
return {
|
||||
title: "",
|
||||
title: '',
|
||||
link: event.repo.html_url,
|
||||
description: description,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
|
||||
case "DeleteEvent":
|
||||
case 'DeleteEvent':
|
||||
if (event.payload?.ref_type) {
|
||||
let description = "";
|
||||
let description = '';
|
||||
switch (event.payload?.ref_type) {
|
||||
case "repository":
|
||||
description = "Deleted a repository";
|
||||
case 'repository':
|
||||
description = 'Deleted a repository';
|
||||
break;
|
||||
case "branch":
|
||||
description = "Deleted a branch";
|
||||
case 'branch':
|
||||
description = 'Deleted a branch';
|
||||
break;
|
||||
case "tag":
|
||||
description = "Deleted a tag";
|
||||
case 'tag':
|
||||
description = 'Deleted a tag';
|
||||
break;
|
||||
default:
|
||||
description = "Deleted something";
|
||||
description = 'Deleted something';
|
||||
break;
|
||||
}
|
||||
return {
|
||||
title: "",
|
||||
title: '',
|
||||
link: event.repo.html_url,
|
||||
description: description,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
|
||||
case "ForkEvent":
|
||||
case 'ForkEvent':
|
||||
if (event.payload?.forkee) {
|
||||
return {
|
||||
title: event.payload.forkee.name,
|
||||
link: event.payload.forkee.html_url,
|
||||
description: "Forked a repository",
|
||||
description: 'Forked a repository',
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
|
||||
case "GollumEvent":
|
||||
case 'GollumEvent':
|
||||
if (event.payload?.pages && event.payload?.pages.length > 0) {
|
||||
let description = "";
|
||||
let description = '';
|
||||
switch (event.payload?.pages[0].action) {
|
||||
case "created":
|
||||
description = "Created a new wiki page";
|
||||
case 'created':
|
||||
description = 'Created a new wiki page';
|
||||
break;
|
||||
default:
|
||||
description = "Updated a wiki page";
|
||||
description = 'Updated a wiki page';
|
||||
break;
|
||||
}
|
||||
return {
|
||||
@@ -537,66 +561,66 @@ const formatEvent = (
|
||||
}
|
||||
return undefined;
|
||||
|
||||
case "IssueCommentEvent":
|
||||
case 'IssueCommentEvent':
|
||||
if (event.payload?.issue && event.payload?.comment) {
|
||||
return {
|
||||
title: event.payload.issue.title,
|
||||
link: event.payload.comment.html_url,
|
||||
description: "Added a comment",
|
||||
description: 'Added a comment',
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
|
||||
case "IssuesEvent":
|
||||
case 'IssuesEvent':
|
||||
if (event.payload?.action && event.payload?.issue) {
|
||||
let description = "";
|
||||
let description = '';
|
||||
switch (event.payload?.action) {
|
||||
case "assigned":
|
||||
description = "Assigned";
|
||||
case 'assigned':
|
||||
description = 'Assigned';
|
||||
break;
|
||||
case "unassigned":
|
||||
description = "Unassigned";
|
||||
case 'unassigned':
|
||||
description = 'Unassigned';
|
||||
break;
|
||||
case "review_requested":
|
||||
description = "Requested a review";
|
||||
case 'review_requested':
|
||||
description = 'Requested a review';
|
||||
break;
|
||||
case "review_request_removed":
|
||||
description = "Removed a requested review";
|
||||
case 'review_request_removed':
|
||||
description = 'Removed a requested review';
|
||||
break;
|
||||
case "labeled":
|
||||
description = "Added a label";
|
||||
case 'labeled':
|
||||
description = 'Added a label';
|
||||
break;
|
||||
case "unlabeled":
|
||||
description = "Removed a label";
|
||||
case 'unlabeled':
|
||||
description = 'Removed a label';
|
||||
break;
|
||||
case "opened":
|
||||
description = "Opened an issue";
|
||||
case 'opened':
|
||||
description = 'Opened an issue';
|
||||
if (event.payload.issue.pull_request) {
|
||||
description = "Opened a pull request";
|
||||
description = 'Opened a pull request';
|
||||
}
|
||||
break;
|
||||
case "closed":
|
||||
description = "Closed an issue";
|
||||
case 'closed':
|
||||
description = 'Closed an issue';
|
||||
if (event.payload.issue.pull_request) {
|
||||
description = "Closed a pull request";
|
||||
description = 'Closed a pull request';
|
||||
}
|
||||
break;
|
||||
case "reopened":
|
||||
description = "Reopened an issue";
|
||||
case 'reopened':
|
||||
description = 'Reopened an issue';
|
||||
if (event.payload.issue.pull_request) {
|
||||
description = "Reopened a pull request";
|
||||
description = 'Reopened a pull request';
|
||||
}
|
||||
break;
|
||||
case "synchronize":
|
||||
description = "Synchronized an issue";
|
||||
case 'synchronize':
|
||||
description = 'Synchronized an issue';
|
||||
if (event.payload.issue.pull_request) {
|
||||
description = "Synchronized a pull request";
|
||||
description = 'Synchronized a pull request';
|
||||
}
|
||||
break;
|
||||
case "edited":
|
||||
description = "Edited an issue";
|
||||
case 'edited':
|
||||
description = 'Edited an issue';
|
||||
if (event.payload.issue.pull_request) {
|
||||
description = "Edited a pull request";
|
||||
description = 'Edited a pull request';
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -608,12 +632,12 @@ const formatEvent = (
|
||||
}
|
||||
return undefined;
|
||||
|
||||
case "MemberEvent":
|
||||
case 'MemberEvent':
|
||||
if (event.payload?.action && event.payload?.member) {
|
||||
let description = "";
|
||||
let description = '';
|
||||
switch (event.payload?.action) {
|
||||
case "added":
|
||||
description = "Was added as member";
|
||||
case 'added':
|
||||
description = 'Was added as member';
|
||||
break;
|
||||
}
|
||||
return {
|
||||
@@ -624,50 +648,50 @@ const formatEvent = (
|
||||
}
|
||||
return undefined;
|
||||
|
||||
case "PullRequestEvent":
|
||||
case 'PullRequestEvent':
|
||||
if (event.payload?.action && event.payload?.pull_request) {
|
||||
let description = "";
|
||||
let description = '';
|
||||
switch (event.payload?.action) {
|
||||
case "assigned":
|
||||
description = "Assigned";
|
||||
case 'assigned':
|
||||
description = 'Assigned';
|
||||
break;
|
||||
case "unassigned":
|
||||
description = "Unassigned";
|
||||
case 'unassigned':
|
||||
description = 'Unassigned';
|
||||
break;
|
||||
case "review_requested":
|
||||
description = "Requested a review";
|
||||
case 'review_requested':
|
||||
description = 'Requested a review';
|
||||
break;
|
||||
case "review_request_removed":
|
||||
description = "Removed a requested review";
|
||||
case 'review_request_removed':
|
||||
description = 'Removed a requested review';
|
||||
break;
|
||||
case "labeled":
|
||||
description = "Added a label";
|
||||
case 'labeled':
|
||||
description = 'Added a label';
|
||||
break;
|
||||
case "unlabeled":
|
||||
description = "Removed a label";
|
||||
case 'unlabeled':
|
||||
description = 'Removed a label';
|
||||
break;
|
||||
case "opened":
|
||||
description = "Opened";
|
||||
case 'opened':
|
||||
description = 'Opened';
|
||||
break;
|
||||
case "closed":
|
||||
case 'closed':
|
||||
if (
|
||||
event.payload?.pull_request.merged &&
|
||||
event.payload?.pull_request.merged == true
|
||||
) {
|
||||
description = "Merged";
|
||||
description = 'Merged';
|
||||
break;
|
||||
} else {
|
||||
description = "Closed";
|
||||
description = 'Closed';
|
||||
break;
|
||||
}
|
||||
case "reopened":
|
||||
description = "Reopened";
|
||||
case 'reopened':
|
||||
description = 'Reopened';
|
||||
break;
|
||||
case "synchronize":
|
||||
description = "Synchronized";
|
||||
case 'synchronize':
|
||||
description = 'Synchronized';
|
||||
break;
|
||||
case "edited":
|
||||
description = "Edited";
|
||||
case 'edited':
|
||||
description = 'Edited';
|
||||
break;
|
||||
}
|
||||
return {
|
||||
@@ -678,7 +702,7 @@ const formatEvent = (
|
||||
}
|
||||
return undefined;
|
||||
|
||||
case "PullRequestReviewEvent":
|
||||
case 'PullRequestReviewEvent':
|
||||
if (
|
||||
event.payload?.pull_request &&
|
||||
event.payload?.review
|
||||
@@ -686,27 +710,27 @@ const formatEvent = (
|
||||
return {
|
||||
title: event.payload.pull_request.title,
|
||||
link: event.payload.review.html_url,
|
||||
description: "Added a review",
|
||||
description: 'Added a review',
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
|
||||
case "PullRequestReviewCommentEvent":
|
||||
case 'PullRequestReviewCommentEvent':
|
||||
if (
|
||||
event.payload?.action &&
|
||||
event.payload?.pull_request &&
|
||||
event.payload?.comment
|
||||
) {
|
||||
let description = "";
|
||||
let description = '';
|
||||
switch (event.payload?.action) {
|
||||
case "created":
|
||||
description = "Added a review comment";
|
||||
case 'created':
|
||||
description = 'Added a review comment';
|
||||
break;
|
||||
case "edited":
|
||||
description = "Updated a review comment";
|
||||
case 'edited':
|
||||
description = 'Updated a review comment';
|
||||
break;
|
||||
case "deleted":
|
||||
description = "Deleted a review comment";
|
||||
case 'deleted':
|
||||
description = 'Deleted a review comment';
|
||||
break;
|
||||
}
|
||||
return {
|
||||
@@ -717,44 +741,44 @@ const formatEvent = (
|
||||
}
|
||||
return undefined;
|
||||
|
||||
case "PushEvent":
|
||||
case 'PushEvent':
|
||||
if (event.payload?.repository) {
|
||||
return {
|
||||
title: "",
|
||||
title: '',
|
||||
link: event.payload.repository.html_url,
|
||||
description: event.payload.commits
|
||||
? event.payload.commits.length === 1
|
||||
? `Pushed ${event.payload.commits.length} commit`
|
||||
: `Pushed ${event.payload.commits.length} commits`
|
||||
: "",
|
||||
: '',
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
|
||||
case "ReleaseEvent":
|
||||
case 'ReleaseEvent':
|
||||
if (event.payload?.action && event.payload?.release) {
|
||||
let description = "";
|
||||
let description = '';
|
||||
switch (event.payload?.action) {
|
||||
case "created":
|
||||
description = "Release was created";
|
||||
case 'created':
|
||||
description = 'Release was created';
|
||||
break;
|
||||
case "deleted":
|
||||
description = "Release was created";
|
||||
case 'deleted':
|
||||
description = 'Release was created';
|
||||
break;
|
||||
case "edited":
|
||||
description = "Release was updated";
|
||||
case 'edited':
|
||||
description = 'Release was updated';
|
||||
break;
|
||||
case "prereleased":
|
||||
description = "Prerelease was created";
|
||||
case 'prereleased':
|
||||
description = 'Prerelease was created';
|
||||
break;
|
||||
case "published":
|
||||
description = "Release was published";
|
||||
case 'published':
|
||||
description = 'Release was published';
|
||||
break;
|
||||
case "released":
|
||||
description = "Release was released";
|
||||
case 'released':
|
||||
description = 'Release was released';
|
||||
break;
|
||||
case "unpublished":
|
||||
description = "Release was unpublished";
|
||||
case 'unpublished':
|
||||
description = 'Release was unpublished';
|
||||
break;
|
||||
}
|
||||
return {
|
||||
@@ -765,13 +789,13 @@ const formatEvent = (
|
||||
}
|
||||
return undefined;
|
||||
|
||||
case "WatchEvent":
|
||||
case 'WatchEvent':
|
||||
if (event.actor) {
|
||||
return {
|
||||
title: "",
|
||||
title: '',
|
||||
link: `https://github.com/${event.actor.login}`,
|
||||
description: `${event.actor.login} starred ${
|
||||
event.payload?.repository?.name || "the repository"
|
||||
event.payload?.repository?.name || 'the repository'
|
||||
}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { parseFeed } from "rss";
|
||||
import { Md5 } from "std/md5";
|
||||
import { FeedEntry } from "rss/types";
|
||||
import { unescape } from "lodash";
|
||||
import { Redis } from "redis";
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { parseFeed } from 'rss';
|
||||
import { Md5 } from 'std/md5';
|
||||
import { FeedEntry } from 'rss/types';
|
||||
import { unescape } from 'lodash';
|
||||
import { Redis } from 'redis';
|
||||
|
||||
import { ISource } from "../models/source.ts";
|
||||
import { IItem } from "../models/item.ts";
|
||||
import { getFavicon } from "./utils/getFavicon.ts";
|
||||
import { IProfile } from "../models/profile.ts";
|
||||
import { fetchWithTimeout } from "../utils/fetchWithTimeout.ts";
|
||||
import { log } from "../utils/log.ts";
|
||||
import { ISource } from '../models/source.ts';
|
||||
import { IItem } from '../models/item.ts';
|
||||
import { getFavicon } from './utils/getFavicon.ts';
|
||||
import { IProfile } from '../models/profile.ts';
|
||||
import { fetchWithTimeout } from '../utils/fetchWithTimeout.ts';
|
||||
import { log } from '../utils/log.ts';
|
||||
|
||||
export const getGooglenewsFeed = async (
|
||||
_supabaseClient: SupabaseClient,
|
||||
@@ -19,74 +19,78 @@ export const getGooglenewsFeed = async (
|
||||
source: ISource,
|
||||
): Promise<{ source: ISource; items: IItem[] }> => {
|
||||
if (!source.options?.googlenews || !source.options?.googlenews?.type) {
|
||||
throw new Error("Invalid source options");
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
|
||||
if (
|
||||
source.options.googlenews.type === "url" && source.options.googlenews.url
|
||||
source.options.googlenews.type === 'url' && source.options.googlenews.url
|
||||
) {
|
||||
/**
|
||||
* If the user selected type is url, we check if the url already points to the Google News RSS feed, if not we
|
||||
* convert the url to the RSS feed url and use it later.
|
||||
* If the user selected type is url, we check if the url already points to
|
||||
* the Google News RSS feed, if not we convert the url to the RSS feed url
|
||||
* and use it later.
|
||||
*/
|
||||
if (
|
||||
source.options.googlenews.url.startsWith("https://news.google.com/rss")
|
||||
source.options.googlenews.url.startsWith('https://news.google.com/rss')
|
||||
) {
|
||||
/**
|
||||
* Do nothing, since the user already provided an url to the Google News RSS feed.
|
||||
* Do nothing, since the user already provided an url to the Google News
|
||||
* RSS feed.
|
||||
*/
|
||||
} else if (
|
||||
source.options.googlenews.url.startsWith("https://news.google.com")
|
||||
source.options.googlenews.url.startsWith('https://news.google.com')
|
||||
) {
|
||||
source.options.googlenews.url = `https://news.google.com/rss${
|
||||
source.options.googlenews.url.replace("https://news.google.com", "")
|
||||
source.options.googlenews.url.replace('https://news.google.com', '')
|
||||
}`;
|
||||
}
|
||||
} else if (
|
||||
source.options.googlenews.type === "search" &&
|
||||
source.options.googlenews.type === 'search' &&
|
||||
source.options.googlenews.search && source.options.googlenews.ceid &&
|
||||
source.options.googlenews.gl && source.options.googlenews.hl
|
||||
) {
|
||||
/**
|
||||
* If the user selected type is earch we construct the RSS feed url with the search term and the user selected
|
||||
* language and region.
|
||||
* If the user selected type is earch we construct the RSS feed url with the
|
||||
* search term and the user selected language and region.
|
||||
*/
|
||||
source.options.googlenews.url =
|
||||
`https://news.google.com/rss/search?q=${source.options.googlenews.search}&hl=${source.options.googlenews.hl}&gl=${source.options.googlenews.gl}&ceid=${source.options.googlenews.ceid}`;
|
||||
} else {
|
||||
throw new Error("Invalid source options");
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the RSS for the provided `googlenews` url and parse it. If a feed doesn't contains an item we return an error.
|
||||
* Get the RSS for the provided `googlenews` url and parse it. If a feed
|
||||
* doesn't contains an item we return an error.
|
||||
*/
|
||||
const response = await fetchWithTimeout(source.options.googlenews.url, {
|
||||
method: "get",
|
||||
method: 'get',
|
||||
}, 5000);
|
||||
const xml = await response.text();
|
||||
log("debug", "Add source", {
|
||||
sourceType: "googlenews",
|
||||
log('debug', 'Add source', {
|
||||
sourceType: 'googlenews',
|
||||
requestUrl: source.options.googlenews.url,
|
||||
responseStatus: response.status,
|
||||
});
|
||||
const feed = await parseFeed(xml);
|
||||
|
||||
if (!feed.title.value) {
|
||||
throw new Error("Invalid feed");
|
||||
throw new Error('Invalid feed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a source id based on the user id, column id and the normalized `stackoverflow` url. Besides that we also
|
||||
* set the source type to `stackoverflow` and set the title and link for the source.
|
||||
* Generate a source id based on the user id, column id and the normalized
|
||||
* `stackoverflow` url. Besides that we also set the source type to
|
||||
* `stackoverflow` and set the title and link for the source.
|
||||
*/
|
||||
if (source.id === "") {
|
||||
if (source.id === '') {
|
||||
source.id = generateSourceId(
|
||||
source.userId,
|
||||
source.columnId,
|
||||
source.options.googlenews.url,
|
||||
);
|
||||
}
|
||||
source.type = "googlenews";
|
||||
source.type = 'googlenews';
|
||||
source.title = feed.title.value;
|
||||
if (feed.links.length > 0) {
|
||||
source.link = feed.links[0];
|
||||
@@ -94,8 +98,8 @@ export const getGooglenewsFeed = async (
|
||||
source.icon = undefined;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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[] = [];
|
||||
|
||||
@@ -139,12 +143,14 @@ export const getGooglenewsFeed = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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.
|
||||
* - the published date of the entry is older than the last update date of the
|
||||
* source minus 10 seconds.
|
||||
*/
|
||||
const skipEntry = (
|
||||
index: number,
|
||||
@@ -170,8 +176,9 @@ const skipEntry = (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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,
|
||||
@@ -184,20 +191,23 @@ const generateSourceId = (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* `getMedia` returns the icon of the source if it exists. The icon can then be used within the `item.media` field. If
|
||||
* we are not able to get an icon for the source we return `undefined`.
|
||||
* `getMedia` returns the icon of the source if it exists. The icon can then be
|
||||
* used within the `item.media` field. If we are not able to get an icon for the
|
||||
* source we return `undefined`.
|
||||
*
|
||||
* To get the item we have to use the `getFavicon` function against the `source.url` of the `entry`. Since this function
|
||||
* is very expensive we cache the result in Redis and check if an icon for the `source.url` is already cached before we
|
||||
* call the `getFavicon` function.
|
||||
* To get the item we have to use the `getFavicon` function against the
|
||||
* `source.url` of the `entry`. Since this function is very expensive we cache
|
||||
* the result in Redis and check if an icon for the `source.url` is already
|
||||
* cached before we call the `getFavicon` function.
|
||||
*/
|
||||
const getMedia = async (
|
||||
redisClient: Redis,
|
||||
@@ -213,7 +223,7 @@ const getMedia = async (
|
||||
}
|
||||
|
||||
const favicon = await getFavicon(entry.source?.url);
|
||||
if (favicon && favicon.url.startsWith("https://")) {
|
||||
if (favicon && favicon.url.startsWith('https://')) {
|
||||
await redisClient.set(cacheKey, favicon.url);
|
||||
return favicon.url;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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 { 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 { uploadSourceIcon } from "./utils/uploadFile.ts";
|
||||
import { IProfile } from "../models/profile.ts";
|
||||
import { fetchWithTimeout } from "../utils/fetchWithTimeout.ts";
|
||||
import { log } from "../utils/log.ts";
|
||||
import { IItem } from '../models/item.ts';
|
||||
import { ISource } from '../models/source.ts';
|
||||
import { uploadSourceIcon } from './utils/uploadFile.ts';
|
||||
import { IProfile } from '../models/profile.ts';
|
||||
import { fetchWithTimeout } from '../utils/fetchWithTimeout.ts';
|
||||
import { log } from '../utils/log.ts';
|
||||
|
||||
export const getMastodonFeed = async (
|
||||
supabaseClient: SupabaseClient,
|
||||
@@ -19,25 +19,25 @@ export const getMastodonFeed = async (
|
||||
source: ISource,
|
||||
): Promise<{ source: ISource; items: IItem[] }> => {
|
||||
if (!source.options?.mastodon || source.options.mastodon.length === 0) {
|
||||
throw new Error("Invalid source options");
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
|
||||
if (source.options.mastodon[0] === "@") {
|
||||
const lastIndex = source.options.mastodon.lastIndexOf("@");
|
||||
if (source.options.mastodon[0] === '@') {
|
||||
const lastIndex = source.options.mastodon.lastIndexOf('@');
|
||||
const username = source.options.mastodon.slice(0, lastIndex);
|
||||
const instance = source.options.mastodon.slice(lastIndex + 1);
|
||||
source.options.mastodon = `https://${instance}/${username}.rss`;
|
||||
} else if (source.options.mastodon[0] === "#") {
|
||||
} else if (source.options.mastodon[0] === '#') {
|
||||
source.options.mastodon = `https://${getInstance()}/tags/${
|
||||
source.options.mastodon.slice(1)
|
||||
}.rss`;
|
||||
} else if (
|
||||
source.options.mastodon.startsWith("https://") &&
|
||||
!source.options.mastodon.endsWith(".rss")
|
||||
source.options.mastodon.startsWith('https://') &&
|
||||
!source.options.mastodon.endsWith('.rss')
|
||||
) {
|
||||
source.options.mastodon = `${source.options.mastodon}.rss`;
|
||||
} else {
|
||||
throw new Error("Invalid source options");
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,42 +45,46 @@ export const getMastodonFeed = async (
|
||||
*/
|
||||
const response = await fetchWithTimeout(
|
||||
source.options.mastodon,
|
||||
{ method: "get" },
|
||||
{ method: 'get' },
|
||||
5000,
|
||||
);
|
||||
const xml = await response.text();
|
||||
log("debug", "Add source", {
|
||||
sourceType: "mastodon",
|
||||
log('debug', 'Add source', {
|
||||
sourceType: 'mastodon',
|
||||
requestUrl: source.options.mastodon,
|
||||
responseStatus: response.status,
|
||||
});
|
||||
const feed = await parseFeed(xml);
|
||||
|
||||
if (!feed.title.value) {
|
||||
throw new Error("Invalid feed");
|
||||
throw new Error('Invalid feed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a source id based on the user id, column id and the normalized `mastodon` options. Besides that we also
|
||||
* set the source type to `mastodon` 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.
|
||||
* Generate a source id based on the user id, column id and the normalized
|
||||
* `mastodon` options. Besides that we also set the source type to `mastodon`
|
||||
* 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 === "") {
|
||||
if (source.id === '') {
|
||||
source.id = generateSourceId(
|
||||
source.userId,
|
||||
source.columnId,
|
||||
source.options.mastodon,
|
||||
);
|
||||
}
|
||||
source.type = "mastodon";
|
||||
source.type = 'mastodon';
|
||||
source.title = feed.title.value;
|
||||
if (feed.links.length > 0) {
|
||||
source.link = feed.links[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* When the source doesn't has an icon yet and the feed contains an image we add an image to the source. We also
|
||||
* upload the image to our CDN and set the `source.icon` to the path of the uploaded image.
|
||||
* When the source doesn't has an icon yet and the feed contains an image we
|
||||
* add an image to the source. We also
|
||||
* upload the image to our CDN and set the `source.icon` to the path of the
|
||||
* uploaded image.
|
||||
*/
|
||||
if (!source.icon && feed.image?.url) {
|
||||
source.icon = feed.image.url;
|
||||
@@ -88,8 +92,8 @@ export const getMastodonFeed = async (
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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[] = [];
|
||||
|
||||
@@ -99,12 +103,13 @@ export const getMastodonFeed = async (
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 or if the entry does not have an id we use the link of the first link of the
|
||||
* entry.
|
||||
* 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 or if the entry does not have an id we use the link of the first
|
||||
* link of the entry.
|
||||
*/
|
||||
let itemId = "";
|
||||
if (entry.id != "") {
|
||||
let itemId = '';
|
||||
if (entry.id != '') {
|
||||
itemId = generateItemId(source.id, entry.id);
|
||||
} else if (entry.links.length > 0 && entry.links[0].href) {
|
||||
itemId = generateItemId(source.id, entry.links[0].href);
|
||||
@@ -113,20 +118,34 @@ export const getMastodonFeed = async (
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the item object and add it to the `items` array. Before the item is created we also try to get a list of
|
||||
* media fils (images) and add it to the options. Since there could be multiple media files we add it to the options
|
||||
* and not to the media field.
|
||||
* Create the item object and add it to the `items` array. Before the item
|
||||
* is created we also try to get a list of media fils (images) and videos
|
||||
* which will then be added to the `options`. Since there could be multiple
|
||||
* media files we add it to the options and not to the media field.
|
||||
*
|
||||
* The implementation to generate the options field is not ideal, but is
|
||||
* required to be compatible with older clients, where we just check if the
|
||||
* options are defined and if it contains a media field, but we do not check
|
||||
* if the media field is null.
|
||||
*/
|
||||
const options: { media?: string[]; videos?: string[] } = {};
|
||||
const media = getMedia(entry);
|
||||
if (media && media.length > 0) {
|
||||
options['media'] = media;
|
||||
}
|
||||
const videos = getVideos(entry);
|
||||
if (videos && videos.length > 0) {
|
||||
options['videos'] = videos;
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: itemId,
|
||||
userId: source.userId,
|
||||
columnId: source.columnId,
|
||||
sourceId: source.id,
|
||||
title: "",
|
||||
title: '',
|
||||
link: entry.links[0].href!,
|
||||
options: media && media.length > 0 ? { media: media } : undefined,
|
||||
options: Object.keys(options).length === 0 ? undefined : options,
|
||||
description: entry.description?.value
|
||||
? unescape(entry.description.value)
|
||||
: undefined,
|
||||
@@ -139,12 +158,14 @@ export const getMastodonFeed = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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 link or a published date.
|
||||
* - the published date of the entry is older than the last update date of the source minus 10 seconds.
|
||||
* - the published date of the entry is older than the last update date of the
|
||||
* source minus 10 seconds.
|
||||
*/
|
||||
const skipEntry = (
|
||||
index: number,
|
||||
@@ -167,8 +188,9 @@ const skipEntry = (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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,
|
||||
@@ -179,22 +201,23 @@ const generateSourceId = (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* `getMedia` returns an image for the provided feed entry from it's description. If we could not get an image from the
|
||||
* description we return `undefined`.
|
||||
* `getMedia` returns all images for the provided feed entry from it's
|
||||
* `media:content` field. If we could not get an image we return `undefined`.
|
||||
*/
|
||||
const getMedia = (entry: FeedEntry): string[] | undefined => {
|
||||
if (entry["media:content"]) {
|
||||
if (entry['media:content']) {
|
||||
const images = [];
|
||||
for (const media of entry["media:content"]) {
|
||||
if (media.medium === "image" && media.url) {
|
||||
for (const media of entry['media:content']) {
|
||||
if (media.medium === 'image' && media.url) {
|
||||
images.push(media.url);
|
||||
}
|
||||
}
|
||||
@@ -206,11 +229,31 @@ const getMedia = (entry: FeedEntry): string[] | undefined => {
|
||||
};
|
||||
|
||||
/**
|
||||
* `getAuthor` returns the author for the provided feed entry based on the link to the entry.
|
||||
* `getVideos` returns all videos for the provided feed entry from it's
|
||||
* `media:content` field. If we could not get a video we return `undefined`.
|
||||
*/
|
||||
const getVideos = (entry: FeedEntry): string[] | undefined => {
|
||||
if (entry['media:content']) {
|
||||
const videos = [];
|
||||
for (const media of entry['media:content']) {
|
||||
if (media.medium === 'video' && media.url) {
|
||||
videos.push(media.url);
|
||||
}
|
||||
}
|
||||
|
||||
return videos;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* `getAuthor` returns the author for the provided feed entry based on the link
|
||||
* to the entry.
|
||||
*/
|
||||
const getAuthor = (entry: FeedEntry): string | undefined => {
|
||||
if (entry.links.length > 0 && entry.links[0].href) {
|
||||
const urlParts = entry.links[0].href.replace("https://", "").split("/");
|
||||
const urlParts = entry.links[0].href.replace('https://', '').split('/');
|
||||
if (urlParts.length === 3) {
|
||||
return `${urlParts[1]}@${urlParts[0]}`;
|
||||
}
|
||||
@@ -221,16 +264,16 @@ const getAuthor = (entry: FeedEntry): string | undefined => {
|
||||
|
||||
const getInstance = (): string => {
|
||||
const instances = [
|
||||
"mastodon.social",
|
||||
"fediscience.org",
|
||||
"fosstodon.org",
|
||||
"hachyderm.io",
|
||||
"hci.social",
|
||||
"indieweb.social",
|
||||
"ioc.exchange",
|
||||
"mindly.social",
|
||||
"techhub.social",
|
||||
"universeodon.com",
|
||||
'mastodon.social',
|
||||
'fediscience.org',
|
||||
'fosstodon.org',
|
||||
'hachyderm.io',
|
||||
'hci.social',
|
||||
'indieweb.social',
|
||||
'ioc.exchange',
|
||||
'mindly.social',
|
||||
'techhub.social',
|
||||
'universeodon.com',
|
||||
];
|
||||
|
||||
return instances[Math.floor(Math.random() * instances.length)];
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
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 { 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 { Favicon, getFavicon } from "./utils/getFavicon.ts";
|
||||
import { uploadSourceIcon } from "./utils/uploadFile.ts";
|
||||
import { IProfile } from "../models/profile.ts";
|
||||
import { fetchWithTimeout } from "../utils/fetchWithTimeout.ts";
|
||||
import { log } from "../utils/log.ts";
|
||||
import { IItem } from '../models/item.ts';
|
||||
import { ISource } from '../models/source.ts';
|
||||
import { Favicon, getFavicon } from './utils/getFavicon.ts';
|
||||
import { uploadSourceIcon } from './utils/uploadFile.ts';
|
||||
import { IProfile } from '../models/profile.ts';
|
||||
import { fetchWithTimeout } from '../utils/fetchWithTimeout.ts';
|
||||
import { log } from '../utils/log.ts';
|
||||
|
||||
/**
|
||||
* `isMediumUrl` checks if the provided `url` is a valid Medium url. A url is considered valid if the hostname starts
|
||||
* with `medium.com`.
|
||||
* `isMediumUrl` checks if the provided `url` is a valid Medium url. A url is
|
||||
* considered valid if the hostname starts with `medium.com`.
|
||||
*/
|
||||
export const isMediumUrl = (url: string): boolean => {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.hostname.endsWith("medium.com");
|
||||
return parsedUrl.hostname.endsWith('medium.com');
|
||||
};
|
||||
|
||||
export const getMediumFeed = async (
|
||||
@@ -29,96 +29,100 @@ export const getMediumFeed = async (
|
||||
source: ISource,
|
||||
): Promise<{ source: ISource; items: IItem[] }> => {
|
||||
/**
|
||||
* Since the `medium` option supports multiple input format we need to normalize it to a valid Medium feed url. If
|
||||
* this is not possible we consider the provided option as invalid.
|
||||
* Since the `medium` option supports multiple input format we need to
|
||||
* normalize it to a valid Medium feed url. If this is not possible we
|
||||
* consider the provided option as invalid.
|
||||
*/
|
||||
if (source.options?.medium) {
|
||||
const input = source.options.medium;
|
||||
if (input.length > 1 && input[0] === "#") {
|
||||
if (input.length > 1 && input[0] === '#') {
|
||||
source.options.medium = `https://medium.com/feed/tag/${input.slice(1)}`;
|
||||
} else if (input.length > 1 && input[0] === "@") {
|
||||
} else if (input.length > 1 && input[0] === '@') {
|
||||
source.options.medium = `https://medium.com/feed/${input}`;
|
||||
} else {
|
||||
const parsedUrl = new URL(input);
|
||||
const parsedHostname = parsedUrl.hostname.split(".");
|
||||
const parsedHostname = parsedUrl.hostname.split('.');
|
||||
if (
|
||||
parsedHostname.length === 2 && parsedHostname[0] === "medium" &&
|
||||
parsedHostname[1] === "com"
|
||||
parsedHostname.length === 2 && parsedHostname[0] === 'medium' &&
|
||||
parsedHostname[1] === 'com'
|
||||
) {
|
||||
source.options.medium = `https://medium.com/feed/${
|
||||
input.replace("https://medium.com/", "").replace("feed/", "")
|
||||
input.replace('https://medium.com/', '').replace('feed/', '')
|
||||
}`;
|
||||
} else if (
|
||||
parsedHostname.length === 3 && parsedHostname[1] === "medium" &&
|
||||
parsedHostname[2] === "com"
|
||||
parsedHostname.length === 3 && parsedHostname[1] === 'medium' &&
|
||||
parsedHostname[2] === 'com'
|
||||
) {
|
||||
source.options.medium = `https://${parsedHostname[0]}.medium.com/feed`;
|
||||
} else {
|
||||
throw new Error("Invalid source options");
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error("Invalid source options");
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the RSS for the provided `medium` url and parse it. If a feed doesn't contains an item we return an error.
|
||||
* Get the RSS for the provided `medium` url and parse it. If a feed doesn't
|
||||
* contains an item we return an error.
|
||||
*/
|
||||
const response = await fetchWithTimeout(source.options.medium, {
|
||||
method: "get",
|
||||
method: 'get',
|
||||
}, 5000);
|
||||
const xml = await response.text();
|
||||
log("debug", "Add source", {
|
||||
sourceType: "medium",
|
||||
log('debug', 'Add source', {
|
||||
sourceType: 'medium',
|
||||
requestUrl: source.options.medium,
|
||||
responseStatus: response.status,
|
||||
});
|
||||
const feed = await parseFeed(xml);
|
||||
|
||||
if (!feed.title.value) {
|
||||
throw new Error("Invalid feed");
|
||||
throw new Error('Invalid feed');
|
||||
}
|
||||
|
||||
/**
|
||||
* When the source doesn't has an id yet we try to get an favicon from the feed for the source. We check if the source
|
||||
* has an id because we only want to try to get the favicon when the source is created the first time.
|
||||
* When the source doesn't has an id yet we try to get an favicon from the
|
||||
* feed for the source. We check if the source has an id because we only want
|
||||
* to try to get the favicon when the source is created the first time.
|
||||
*/
|
||||
if (source.id === "" && feed.links.length > 0) {
|
||||
if (source.id === '' && feed.links.length > 0) {
|
||||
const favicon = await getFavicon(
|
||||
feed.links[0],
|
||||
(favicons: Favicon[]): Favicon[] => {
|
||||
return favicons.filter((favicon) => {
|
||||
return favicon.url.startsWith("https://cdn-images");
|
||||
return favicon.url.startsWith('https://cdn-images');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (favicon && favicon.url.startsWith("https://")) {
|
||||
if (favicon && favicon.url.startsWith('https://')) {
|
||||
source.icon = favicon.url;
|
||||
source.icon = await uploadSourceIcon(supabaseClient, source);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a source id based on the user id, column id and the normalized `medium` url. Besides that we also set the
|
||||
* source type to `medium` and set the title and link for the source.
|
||||
* Generate a source id based on the user id, column id and the normalized
|
||||
* `medium` url. Besides that we also set the source type to `medium` and set
|
||||
* the title and link for the source.
|
||||
*/
|
||||
if (source.id === "") {
|
||||
if (source.id === '') {
|
||||
source.id = generateSourceId(
|
||||
source.userId,
|
||||
source.columnId,
|
||||
source.options.medium,
|
||||
);
|
||||
}
|
||||
source.type = "medium";
|
||||
source.type = 'medium';
|
||||
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.
|
||||
* 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[] = [];
|
||||
|
||||
@@ -128,12 +132,13 @@ export const getMediumFeed = async (
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 or if the entry does not have an id we use the link of the first link of the
|
||||
* entry.
|
||||
* 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 or if the entry does not have an id we use the link of the first
|
||||
* link of the entry.
|
||||
*/
|
||||
let itemId = "";
|
||||
if (entry.id != "") {
|
||||
let itemId = '';
|
||||
if (entry.id != '') {
|
||||
itemId = generateItemId(source.id, entry.id);
|
||||
} else if (entry.links.length > 0 && entry.links[0].href) {
|
||||
itemId = generateItemId(source.id, entry.links[0].href);
|
||||
@@ -153,7 +158,7 @@ export const getMediumFeed = async (
|
||||
link: entry.links[0].href!,
|
||||
media: getMedia(entry),
|
||||
description: getItemDescription(entry),
|
||||
author: entry["dc:creator"]?.join(", "),
|
||||
author: entry['dc:creator']?.join(', '),
|
||||
publishedAt: Math.floor(entry.published!.getTime() / 1000),
|
||||
});
|
||||
}
|
||||
@@ -162,12 +167,15 @@ export const getMediumFeed = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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
|
||||
* `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.
|
||||
* - the published date of the entry is older than the last update date of the
|
||||
* source minus 10 seconds.
|
||||
*/
|
||||
const skipEntry = (
|
||||
index: number,
|
||||
@@ -189,12 +197,48 @@ const skipEntry = (
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip entries which might be spam. To detect possible spam, we check the
|
||||
* title of the entry against a list of words, when the title contains 3 or
|
||||
* more of these words we consider the entry as spam.
|
||||
*/
|
||||
const filterWords = [
|
||||
'cash',
|
||||
'loan',
|
||||
'customer',
|
||||
'care',
|
||||
'helpline',
|
||||
'number',
|
||||
'patti',
|
||||
'toll',
|
||||
'free',
|
||||
'paisa',
|
||||
'call',
|
||||
'kup',
|
||||
'niewykrywalnych',
|
||||
'fałszywych',
|
||||
'pieniędzy',
|
||||
'whatsapp',
|
||||
];
|
||||
const title = entry.title.value.toLowerCase();
|
||||
let score = 0;
|
||||
|
||||
for (const word of filterWords) {
|
||||
if (title.includes(word)) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
if (score >= 3) {
|
||||
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.
|
||||
* `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,
|
||||
@@ -205,16 +249,18 @@ const generateSourceId = (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* `getItemDescription` returns the description of the item. If the item has a `content` property we use that as our
|
||||
* description, otherwise we use the `description` property.
|
||||
* `getItemDescription` returns the description of the item. If the item has a
|
||||
* `content` property we use that as our description, otherwise we use the
|
||||
* `description` property.
|
||||
*/
|
||||
const getItemDescription = (entry: FeedEntry): string | undefined => {
|
||||
if (entry.content?.value) {
|
||||
@@ -229,24 +275,25 @@ const getItemDescription = (entry: FeedEntry): string | undefined => {
|
||||
};
|
||||
|
||||
/**
|
||||
* `getMedia` returns an image for the provided feed entry from it's content or description. If we could not get an
|
||||
* image from the content or description we return `undefined`.
|
||||
* `getMedia` returns an image for the provided feed entry from it's content or
|
||||
* description. If we could not get an image from the content or description we
|
||||
* return `undefined`.
|
||||
*/
|
||||
const getMedia = (entry: FeedEntry): string | undefined => {
|
||||
if (entry.content?.value) {
|
||||
const matches = /<img[^>]+\bsrc=["']([^"']+)["']/.exec(
|
||||
entry.content?.value,
|
||||
unescape(entry.content.value),
|
||||
);
|
||||
if (matches && matches.length == 2 && matches[1].startsWith("https://")) {
|
||||
if (matches && matches.length == 2 && matches[1].startsWith('https://')) {
|
||||
return matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.description?.value) {
|
||||
const matches = /<img[^>]+\bsrc=["']([^"']+)["']/.exec(
|
||||
entry.description?.value,
|
||||
unescape(entry.description.value),
|
||||
);
|
||||
if (matches && matches.length == 2 && matches[1].startsWith("https://")) {
|
||||
if (matches && matches.length == 2 && matches[1].startsWith('https://')) {
|
||||
return matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
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 { 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 { uploadSourceIcon } from "./utils/uploadFile.ts";
|
||||
import { IProfile } from "../models/profile.ts";
|
||||
import { fetchWithTimeout } from "../utils/fetchWithTimeout.ts";
|
||||
import { IItem } from '../models/item.ts';
|
||||
import { ISource } from '../models/source.ts';
|
||||
import { uploadSourceIcon } from './utils/uploadFile.ts';
|
||||
import { IProfile } from '../models/profile.ts';
|
||||
import { fetchWithTimeout } from '../utils/fetchWithTimeout.ts';
|
||||
import {
|
||||
FEEDDECK_SOURCE_NITTER_BASIC_AUTH,
|
||||
FEEDDECK_SOURCE_NITTER_INSTANCE,
|
||||
} from "../utils/constants.ts";
|
||||
import { log } from "../utils/log.ts";
|
||||
} from '../utils/constants.ts';
|
||||
import { log } from '../utils/log.ts';
|
||||
|
||||
export const getNitterFeed = async (
|
||||
supabaseClient: SupabaseClient,
|
||||
@@ -23,58 +23,60 @@ export const getNitterFeed = async (
|
||||
source: ISource,
|
||||
): Promise<{ source: ISource; items: IItem[] }> => {
|
||||
if (!source.options?.nitter || source.options.nitter.length === 0) {
|
||||
throw new Error("Invalid source options");
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
|
||||
const nitterOptions = parseNitterOptions(source.options.nitter);
|
||||
|
||||
/**
|
||||
* Get the RSS for the provided `nitter` username or search term. If a feed doesn't contains an item we return an
|
||||
* error.
|
||||
* Get the RSS for the provided `nitter` username or search term. If a feed
|
||||
* doesn't contains an item we return an error.
|
||||
*/
|
||||
const response = await fetchWithTimeout(
|
||||
nitterOptions.feedUrl,
|
||||
{
|
||||
headers: nitterOptions.isCustomInstance ? undefined : {
|
||||
"Authorization": `Basic ${FEEDDECK_SOURCE_NITTER_BASIC_AUTH}`,
|
||||
'Authorization': `Basic ${FEEDDECK_SOURCE_NITTER_BASIC_AUTH}`,
|
||||
},
|
||||
method: "get",
|
||||
method: 'get',
|
||||
},
|
||||
5000,
|
||||
);
|
||||
const xml = await response.text();
|
||||
log("debug", "Add source", {
|
||||
sourceType: "nitter",
|
||||
log('debug', 'Add source', {
|
||||
sourceType: 'nitter',
|
||||
requestUrl: nitterOptions.feedUrl,
|
||||
responseStatus: response.status,
|
||||
});
|
||||
const feed = await parseFeed(xml);
|
||||
|
||||
if (!feed.title.value) {
|
||||
throw new Error("Invalid feed");
|
||||
throw new Error('Invalid feed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a source id based on the user id, column id and the normalized `nitter` options. Besides that we also set
|
||||
* the source type to `nitter` 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.
|
||||
* Generate a source id based on the user id, column id and the normalized
|
||||
* `nitter` options. Besides that we also set the source type to `nitter` 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 === "") {
|
||||
if (source.id === '') {
|
||||
source.id = generateSourceId(
|
||||
source.userId,
|
||||
source.columnId,
|
||||
source.options.nitter,
|
||||
);
|
||||
}
|
||||
source.type = "nitter";
|
||||
source.type = 'nitter';
|
||||
source.title = nitterOptions.sourceTitle;
|
||||
if (feed.links.length > 0) {
|
||||
source.link = feed.links[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
if (!source.icon && nitterOptions.isUsername && feed.image?.url) {
|
||||
source.icon = feed.image.url;
|
||||
@@ -82,8 +84,8 @@ export const getNitterFeed = async (
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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[] = [];
|
||||
|
||||
@@ -93,12 +95,13 @@ export const getNitterFeed = async (
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 or if the entry does not have an id we use the link of the first link of the
|
||||
* entry.
|
||||
* 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 or if the entry does not have an id we use the link of the first
|
||||
* link of the entry.
|
||||
*/
|
||||
let itemId = "";
|
||||
if (entry.id != "") {
|
||||
let itemId = '';
|
||||
if (entry.id != '') {
|
||||
itemId = generateItemId(source.id, entry.id);
|
||||
} else if (entry.links.length > 0 && entry.links[0].href) {
|
||||
itemId = generateItemId(source.id, entry.links[0].href);
|
||||
@@ -107,9 +110,10 @@ export const getNitterFeed = async (
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the item object and add it to the `items` array. Before the item is created we also try to get a list of
|
||||
* media fils (images) and add it to the options. Since there could be multiple media files we add it to the options
|
||||
* and not to the media field.
|
||||
* Create the item object and add it to the `items` array. Before the item
|
||||
* is created we also try to get a list of media fils (images) and add it to
|
||||
* the options. Since there could be multiple media files we add it to the
|
||||
* options and not to the media field.
|
||||
*/
|
||||
const media = getMedia(entry);
|
||||
|
||||
@@ -124,7 +128,7 @@ export const getNitterFeed = async (
|
||||
description: entry.description?.value
|
||||
? unescape(entry.description.value)
|
||||
: undefined,
|
||||
author: entry["dc:creator"]?.join(", "),
|
||||
author: entry['dc:creator']?.join(', '),
|
||||
publishedAt: Math.floor(entry.published!.getTime() / 1000),
|
||||
});
|
||||
}
|
||||
@@ -133,12 +137,14 @@ export const getNitterFeed = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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.
|
||||
* - the published date of the entry is older than the last update date of the
|
||||
* source minus 10 seconds.
|
||||
*/
|
||||
const skipEntry = (
|
||||
index: number,
|
||||
@@ -164,11 +170,13 @@ const skipEntry = (
|
||||
};
|
||||
|
||||
/**
|
||||
* `parseNitterOptions` parsed the Nitter options and returns an object with all the required data to get the feed and
|
||||
* to create the database entry for the source.
|
||||
* `parseNitterOptions` parsed the Nitter options and returns an object with all
|
||||
* the required data to get the feed and to create the database entry for the
|
||||
* source.
|
||||
*
|
||||
* This is required, because a user can provide the RSS feed of his own Nitter instance or a username or search term,
|
||||
* where we have to use our own Nitter instance.
|
||||
* This is required, because a user can provide the RSS feed of his own Nitter
|
||||
* instance or a username or search term, where we have to use our own Nitter
|
||||
* instance.
|
||||
*/
|
||||
const parseNitterOptions = (
|
||||
options: string,
|
||||
@@ -178,14 +186,14 @@ const parseNitterOptions = (
|
||||
isUsername: boolean;
|
||||
isCustomInstance: boolean;
|
||||
} => {
|
||||
if (options.startsWith("http://") || options.startsWith("https://")) {
|
||||
if (options.endsWith("/rss")) {
|
||||
if (options.startsWith('http://') || options.startsWith('https://')) {
|
||||
if (options.endsWith('/rss')) {
|
||||
return {
|
||||
feedUrl: options,
|
||||
sourceTitle: `@${
|
||||
options.slice(
|
||||
options.replace("/rss", "").lastIndexOf("/") + 1,
|
||||
options.replace("/rss", "").length,
|
||||
options.replace('/rss', '').lastIndexOf('/') + 1,
|
||||
options.replace('/rss', '').length,
|
||||
)
|
||||
}`,
|
||||
isUsername: true,
|
||||
@@ -196,13 +204,13 @@ const parseNitterOptions = (
|
||||
const url = new URL(options);
|
||||
return {
|
||||
feedUrl: options,
|
||||
sourceTitle: url.searchParams.get("q") || options,
|
||||
sourceTitle: url.searchParams.get('q') || options,
|
||||
isUsername: false,
|
||||
isCustomInstance: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (options[0] === "@") {
|
||||
if (options[0] === '@') {
|
||||
return {
|
||||
feedUrl: `${FEEDDECK_SOURCE_NITTER_INSTANCE}/${options.slice(1)}/rss`,
|
||||
sourceTitle: options,
|
||||
@@ -222,8 +230,9 @@ const parseNitterOptions = (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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,
|
||||
@@ -234,16 +243,18 @@ const generateSourceId = (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* `getMedia` returns an image for the provided feed entry from it's description. If we could not get an image from the
|
||||
* description we return `undefined`.
|
||||
* `getMedia` returns an image for the provided feed entry from it's
|
||||
* description. If we could not get an image from the description we return
|
||||
* `undefined`.
|
||||
*/
|
||||
const getMedia = (entry: FeedEntry): string[] | undefined => {
|
||||
const images = [];
|
||||
@@ -253,13 +264,13 @@ const getMedia = (entry: FeedEntry): string[] | undefined => {
|
||||
let matches;
|
||||
|
||||
do {
|
||||
matches = re.exec(entry.description?.value);
|
||||
matches = re.exec(unescape(entry.description.value));
|
||||
if (
|
||||
matches && matches.length == 2
|
||||
) {
|
||||
if (matches[1].startsWith("http://")) {
|
||||
images.push(matches[1].replace("http://", "https://"));
|
||||
} else if (matches[1].startsWith("https://")) {
|
||||
if (matches[1].startsWith('http://')) {
|
||||
images.push(matches[1].replace('http://', 'https://'));
|
||||
} else if (matches[1].startsWith('https://')) {
|
||||
images.push(matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
309
supabase/functions/_shared/feed/pinterest.ts
Normal file
309
supabase/functions/_shared/feed/pinterest.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
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';
|
||||
|
||||
const pinterestUrls = [
|
||||
'pinterest.at',
|
||||
'pinterest.ca',
|
||||
'pinterest.ch',
|
||||
'pinterest.cl',
|
||||
'pinterest.co.kr',
|
||||
'pinterest.com',
|
||||
'pinterest.com.au',
|
||||
'pinterest.com.mx',
|
||||
'pinterest.co.uk',
|
||||
'pinterest.de',
|
||||
'pinterest.dk',
|
||||
'pinterest.es',
|
||||
'pinterest.fr',
|
||||
'pinterest.ie',
|
||||
'pinterest.info',
|
||||
'pinterest.it',
|
||||
'pinterest.jp',
|
||||
'pinterest.net',
|
||||
'pinterest.nz',
|
||||
'pinterest.ph',
|
||||
'pinterest.pt',
|
||||
'pinterest.ru',
|
||||
'pinterest.se',
|
||||
];
|
||||
|
||||
/**
|
||||
* `isPinterestUrl` checks if the provided `url` is a valid Pinterest url.
|
||||
*/
|
||||
export const isPinterestUrl = (url: string): boolean => {
|
||||
if (!url.startsWith('https://www.')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
for (const pinterestUrl of pinterestUrls) {
|
||||
if (parsedUrl.hostname.endsWith(pinterestUrl)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getPinterestFeed = async (
|
||||
_supabaseClient: SupabaseClient,
|
||||
_redisClient: Redis | undefined,
|
||||
_profile: IProfile,
|
||||
source: ISource,
|
||||
): Promise<{ source: ISource; items: IItem[] }> => {
|
||||
/**
|
||||
* Since the `pinterest` option supports multiple input format we need to
|
||||
* normalize it to a valid Pinterest feed url. If this is not possible we
|
||||
* consider the provided option as invalid.
|
||||
*/
|
||||
if (source.options?.pinterest) {
|
||||
const input = source.options.pinterest;
|
||||
/**
|
||||
* If the input starts with `@` we assume that a username or board was
|
||||
* provided in the form of `@username` or `@username/board`. We then use
|
||||
* the provided username or board to generate a valid Pinterest feed url.
|
||||
*/
|
||||
if (input.length > 1 && input[0] === '@') {
|
||||
if (input.includes('/')) {
|
||||
source.options.pinterest = `https://www.pinterest.com/${
|
||||
input.substring(1)
|
||||
}.rss`;
|
||||
} else {
|
||||
source.options.pinterest = `https://www.pinterest.com/${
|
||||
input.substring(1)
|
||||
}/feed.rss`;
|
||||
}
|
||||
} else {
|
||||
/**
|
||||
* If the input does not start with `@` we assume that a valid Pinterest
|
||||
* url was provided and replace the domain with `pinterest.com`.
|
||||
*
|
||||
* If the url ends with `.rss` or `/feed.rss` we consider that already a
|
||||
* Pinterest RSS feed url was provided and use it as is.
|
||||
*
|
||||
* If the url does not end with `.rss` or `/feed.rss` we have to generate
|
||||
* the feed url, by appending `.rss` or `/feed.rss` to the url, depending
|
||||
* on if the url contains a `/` or not.
|
||||
*/
|
||||
if (isPinterestUrl(input)) {
|
||||
const pinterestDotComUrl = replaceDomain(input);
|
||||
if (
|
||||
pinterestDotComUrl.endsWith('.rss') ||
|
||||
pinterestDotComUrl.endsWith('/feed.rss')
|
||||
) {
|
||||
source.options.pinterest = pinterestDotComUrl;
|
||||
} else {
|
||||
const urlParameters = pinterestDotComUrl.replace(
|
||||
'https://www.pinterest.com/',
|
||||
'',
|
||||
).replace(/\/$/, '');
|
||||
if (urlParameters.includes('/')) {
|
||||
source.options.pinterest =
|
||||
`https://www.pinterest.com/${urlParameters}.rss`;
|
||||
} else {
|
||||
source.options.pinterest =
|
||||
`https://www.pinterest.com/${urlParameters}/feed.rss`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the RSS for the provided `pinterest` url and parse it. If a feed
|
||||
* doesn't contains an item we return an error.
|
||||
*/
|
||||
const response = await fetchWithTimeout(source.options.pinterest, {
|
||||
method: 'get',
|
||||
}, 5000);
|
||||
const xml = await response.text();
|
||||
log('debug', 'Add source', {
|
||||
sourceType: 'pinterest',
|
||||
requestUrl: source.options.pinterest,
|
||||
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
|
||||
* `pinterest` url. Besides that we also set the source type to `pinterest`and
|
||||
* set the title and link for the source.
|
||||
*/
|
||||
if (source.id === '') {
|
||||
source.id = generateSourceId(
|
||||
source.userId,
|
||||
source.columnId,
|
||||
source.options.pinterest,
|
||||
);
|
||||
}
|
||||
source.type = 'pinterest';
|
||||
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 or if the entry does not have an id we use the link of the first
|
||||
* link of the entry.
|
||||
*/
|
||||
let itemId = '';
|
||||
if (entry.id != '') {
|
||||
itemId = generateItemId(source.id, entry.id);
|
||||
} else if (entry.links.length > 0 && entry.links[0].href) {
|
||||
itemId = generateItemId(source.id, entry.links[0].href);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.links[0].href!,
|
||||
media: getMedia(entry),
|
||||
description: getItemDescription(entry),
|
||||
author: `@${
|
||||
source.options.pinterest.replace('https://www.pinterest.com/', '')
|
||||
.replace('.rss', '').replace('/feed.rss', '').split('/')[0]
|
||||
}`,
|
||||
publishedAt: Math.floor(entry.published!.getTime() / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
return { source, items };
|
||||
};
|
||||
|
||||
/**
|
||||
* `replaceDomain` replaces the domain of the Pinterest url with
|
||||
* `pinterest.com`.
|
||||
*/
|
||||
const replaceDomain = (url: string): string => {
|
||||
let finalUrl = url;
|
||||
|
||||
for (const pinterestUrl of pinterestUrls) {
|
||||
if (pinterestUrl !== 'pinterest.com') {
|
||||
finalUrl = finalUrl.replace(pinterestUrl, 'pinterest.com');
|
||||
}
|
||||
}
|
||||
return finalUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.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 `pinterest-${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()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* `getItemDescription` returns the description of the item. If the item has a
|
||||
* `content` property we use that as our description, otherwise we use the
|
||||
* `description` property.
|
||||
*/
|
||||
const getItemDescription = (entry: FeedEntry): string | undefined => {
|
||||
if (entry.description?.value) {
|
||||
return unescape(entry.description.value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* `getMedia` returns an image for the provided feed entry from it's content or
|
||||
* description. If we could not get an image from the content or description we
|
||||
* return `undefined`.
|
||||
*/
|
||||
const getMedia = (entry: FeedEntry): string | undefined => {
|
||||
if (entry.description?.value) {
|
||||
const matches = /<img[^>]+\bsrc=["']([^"']+)["']/.exec(
|
||||
unescape(entry.description.value),
|
||||
);
|
||||
if (matches && matches.length == 2 && matches[1].startsWith('https://')) {
|
||||
return matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -1,16 +1,16 @@
|
||||
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 { 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 { ISource } from "../models/source.ts";
|
||||
import { IItem } from "../models/item.ts";
|
||||
import { uploadSourceIcon } from "./utils/uploadFile.ts";
|
||||
import { IProfile } from "../models/profile.ts";
|
||||
import { fetchWithTimeout } from "../utils/fetchWithTimeout.ts";
|
||||
import { log } from "../utils/log.ts";
|
||||
import { ISource } from '../models/source.ts';
|
||||
import { IItem } from '../models/item.ts';
|
||||
import { uploadSourceIcon } from './utils/uploadFile.ts';
|
||||
import { IProfile } from '../models/profile.ts';
|
||||
import { fetchWithTimeout } from '../utils/fetchWithTimeout.ts';
|
||||
import { log } from '../utils/log.ts';
|
||||
|
||||
export const getPodcastFeed = async (
|
||||
supabaseClient: SupabaseClient,
|
||||
@@ -19,13 +19,14 @@ export const getPodcastFeed = async (
|
||||
source: ISource,
|
||||
): Promise<{ source: ISource; items: IItem[] }> => {
|
||||
if (!source.options?.podcast) {
|
||||
throw new Error("Invalid source options");
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
|
||||
/**
|
||||
* If the `podcast` url is an Apple Podcast url we try to get the RSS feed url from it.
|
||||
* If the `podcast` url is an Apple Podcast url we try to get the RSS feed url
|
||||
* from it.
|
||||
*/
|
||||
if (source.options.podcast.startsWith("https://podcasts.apple.com")) {
|
||||
if (source.options.podcast.startsWith('https://podcasts.apple.com')) {
|
||||
const matches = /[^w]+\/id(\d+)/.exec(source.options?.podcast);
|
||||
if (matches && matches.length === 2) {
|
||||
const feedUrl = await getRSSFeedFromApplePodcast(matches[1]);
|
||||
@@ -34,27 +35,30 @@ export const getPodcastFeed = async (
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the RSS for the provided `podcast` url and parse it. If a feed doesn't contains an item we return an error.
|
||||
* Get the RSS for the provided `podcast` url and parse it. If a feed doesn't
|
||||
* contains an item we return an error.
|
||||
*/
|
||||
const response = await fetchWithTimeout(source.options.podcast, {
|
||||
method: "get",
|
||||
method: 'get',
|
||||
}, 5000);
|
||||
const xml = await response.text();
|
||||
log("debug", "Add source", {
|
||||
sourceType: "podcast",
|
||||
log('debug', 'Add source', {
|
||||
sourceType: 'podcast',
|
||||
requestUrl: source.options.podcast,
|
||||
responseStatus: response.status,
|
||||
});
|
||||
const feed = await parseFeed(xml);
|
||||
|
||||
if (!feed.title.value) {
|
||||
throw new Error("Invalid feed");
|
||||
throw new Error('Invalid feed');
|
||||
}
|
||||
|
||||
/**
|
||||
* If the source doesn't have an id yet we generate one using the `generateSourceId` function. We also set the type,
|
||||
* title and link of the source. If the feed contains an image we set it as the icon of the source. We also upload the
|
||||
* icon to our CDN and set the icon of the source to the CDN url.
|
||||
* If the source doesn't have an id yet we generate one using the
|
||||
* `generateSourceId` function. We also set the type, title and link of the
|
||||
* source. If the feed contains an image we set it as the icon of the source.
|
||||
* We also upload the icon to our CDN and set the icon of the source to the
|
||||
* CDN url.
|
||||
*/
|
||||
if (!source.id) {
|
||||
source.id = generateSourceId(
|
||||
@@ -63,7 +67,7 @@ export const getPodcastFeed = async (
|
||||
source.options.podcast,
|
||||
);
|
||||
}
|
||||
source.type = "podcast";
|
||||
source.type = 'podcast';
|
||||
source.title = feed.title.value;
|
||||
if (feed.links.length > 0) {
|
||||
source.link = feed.links[0];
|
||||
@@ -73,17 +77,18 @@ export const getPodcastFeed = async (
|
||||
source.icon = feed.image.url;
|
||||
source.icon = await uploadSourceIcon(supabaseClient, source);
|
||||
// deno-lint-ignore no-explicit-any
|
||||
} else if ((feed as any)["itunes:image"]?.href) {
|
||||
} else if ((feed as any)['itunes:image']?.href) {
|
||||
// deno-lint-ignore no-explicit-any
|
||||
source.icon = (feed as any)["itunes:image"].href;
|
||||
source.icon = (feed as any)['itunes:image'].href;
|
||||
source.icon = await 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.
|
||||
* 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[] = [];
|
||||
|
||||
@@ -98,12 +103,13 @@ export const getPodcastFeed = async (
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 or if the entry does not have an id we use the link of the first link of the
|
||||
* entry.
|
||||
* 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 or if the entry does not have an id we use the link of the first
|
||||
* link of the entry.
|
||||
*/
|
||||
let itemId = "";
|
||||
if (entry.id != "") {
|
||||
let itemId = '';
|
||||
if (entry.id != '') {
|
||||
itemId = generateItemId(source.id, entry.id);
|
||||
} else if (entry.links && entry.links.length > 0 && entry.links[0].href) {
|
||||
itemId = generateItemId(source.id, entry.links[0].href);
|
||||
@@ -124,7 +130,7 @@ export const getPodcastFeed = async (
|
||||
description: entry.description?.value
|
||||
? unescape(entry.description.value)
|
||||
: undefined,
|
||||
author: entry["dc:creator"]?.join(", "),
|
||||
author: entry['dc:creator']?.join(', '),
|
||||
publishedAt: Math.floor(entry.published!.getTime() / 1000),
|
||||
});
|
||||
}
|
||||
@@ -133,12 +139,14 @@ export const getPodcastFeed = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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 or a published date.
|
||||
* - the published date of the entry is older than the last update date of the source minus 10 seconds.
|
||||
* - the published date of the entry is older than the last update date of the
|
||||
* source minus 10 seconds.
|
||||
*/
|
||||
const skipEntry = (
|
||||
index: number,
|
||||
@@ -161,12 +169,13 @@ const skipEntry = (
|
||||
};
|
||||
|
||||
/**
|
||||
* `getRSSFeedFromApplePodcast` returns the RSS feed url for the provided Apple Podcast id.
|
||||
* `getRSSFeedFromApplePodcast` returns the RSS feed url for the provided Apple
|
||||
* Podcast id.
|
||||
*/
|
||||
const getRSSFeedFromApplePodcast = async (id: string): Promise<string> => {
|
||||
const resp = await fetchWithTimeout(
|
||||
`https://itunes.apple.com/lookup?id=${id}&entity=podcast`,
|
||||
{ method: "get" },
|
||||
{ method: 'get' },
|
||||
5000,
|
||||
);
|
||||
const podcast = await resp.json();
|
||||
@@ -175,15 +184,16 @@ const getRSSFeedFromApplePodcast = async (id: string): Promise<string> => {
|
||||
!podcast || !podcast.results || podcast.results.length !== 1 ||
|
||||
!podcast.results[0].feedUrl
|
||||
) {
|
||||
throw new Error("Failed to get Apple Podcast");
|
||||
throw new Error('Failed to get Apple Podcast');
|
||||
}
|
||||
|
||||
return podcast.results[0].feedUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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,
|
||||
@@ -194,16 +204,17 @@ const generateSourceId = (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* `getMedia` returns the mp3 file for the podcast episode. For podcast rss feeds the file should be available in the
|
||||
* attachments field.
|
||||
* `getMedia` returns the mp3 file for the podcast episode. For podcast rss
|
||||
* feeds the file should be available in the attachments field.
|
||||
*/
|
||||
const getMedia = (entry: FeedEntry): string | undefined => {
|
||||
if (entry.attachments && entry.attachments.length > 0) {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
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 { 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";
|
||||
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';
|
||||
|
||||
/**
|
||||
* `isRedditUrl` checks if the provided `url` is a valid Reddit url. A url is considered valid if the hostname starts
|
||||
* with `reddit.com`.
|
||||
* `isRedditUrl` checks if the provided `url` is a valid Reddit url. A url is
|
||||
* considered valid if the hostname starts with `reddit.com`.
|
||||
*/
|
||||
export const isRedditUrl = (url: string): boolean => {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.hostname.endsWith("reddit.com");
|
||||
return parsedUrl.hostname.endsWith('reddit.com');
|
||||
};
|
||||
|
||||
export const getRedditFeed = async (
|
||||
@@ -27,59 +27,61 @@ export const getRedditFeed = async (
|
||||
source: ISource,
|
||||
): Promise<{ source: ISource; items: IItem[] }> => {
|
||||
if (!source.options?.reddit) {
|
||||
throw new Error("Invalid source options");
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
|
||||
if (
|
||||
source.options.reddit.startsWith("/r/") ||
|
||||
source.options.reddit.startsWith("/u/")
|
||||
source.options.reddit.startsWith('/r/') ||
|
||||
source.options.reddit.startsWith('/u/')
|
||||
) {
|
||||
source.options.reddit =
|
||||
`https://www.reddit.com${source.options.reddit}.rss`;
|
||||
} else if (isRedditUrl(source.options.reddit)) {
|
||||
if (!source.options.reddit.endsWith(".rss")) {
|
||||
if (!source.options.reddit.endsWith('.rss')) {
|
||||
source.options.reddit = `${source.options.reddit}.rss`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the RSS for the provided `youtube` url and parse it. If a feed doesn't contains an item we return an error.
|
||||
* Get the RSS for the provided `youtube` url and parse it. If a feed doesn't
|
||||
* contains an item we return an error.
|
||||
*/
|
||||
const response = await fetchWithTimeout(source.options.reddit, {
|
||||
method: "get",
|
||||
method: 'get',
|
||||
}, 5000);
|
||||
const xml = await response.text();
|
||||
log("debug", "Add source", {
|
||||
sourceType: "reddit",
|
||||
log('debug', 'Add source', {
|
||||
sourceType: 'reddit',
|
||||
requestUrl: source.options.reddit,
|
||||
responseStatus: response.status,
|
||||
});
|
||||
const feed = await parseFeed(xml);
|
||||
|
||||
if (!feed.title.value) {
|
||||
throw new Error("Invalid feed");
|
||||
throw new Error('Invalid feed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a source id based on the user id, column id and the normalized `youtube` url. Besides that we also set the
|
||||
* source type to `youtube` and set the title and link for the source.
|
||||
* Generate a source id based on the user id, column id and the normalized
|
||||
* `youtube` url. Besides that we also set the source type to `youtube` and
|
||||
* set the title and link for the source.
|
||||
*/
|
||||
if (source.id === "") {
|
||||
if (source.id === '') {
|
||||
source.id = generateSourceId(
|
||||
source.userId,
|
||||
source.columnId,
|
||||
source.options.reddit,
|
||||
);
|
||||
}
|
||||
source.type = "reddit";
|
||||
source.type = 'reddit';
|
||||
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.
|
||||
* 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[] = [];
|
||||
|
||||
@@ -89,12 +91,13 @@ export const getRedditFeed = async (
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 or if the entry does not have an id we use the link of the first link of the
|
||||
* entry.
|
||||
* 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 or if the entry does not have an id we use the link of the first
|
||||
* link of the entry.
|
||||
*/
|
||||
let itemId = "";
|
||||
if (entry.id != "") {
|
||||
let itemId = '';
|
||||
if (entry.id != '') {
|
||||
itemId = generateItemId(source.id, entry.id);
|
||||
} else if (entry.links.length > 0 && entry.links[0].href) {
|
||||
itemId = generateItemId(source.id, entry.links[0].href);
|
||||
@@ -113,9 +116,7 @@ export const getRedditFeed = async (
|
||||
title: entry.title!.value!,
|
||||
link: entry.links[0].href!,
|
||||
media: getMedia(entry),
|
||||
description: entry.content?.value
|
||||
? unescape(entry.content.value)
|
||||
: undefined,
|
||||
description: getDescription(entry),
|
||||
author: entry.author?.name,
|
||||
publishedAt: Math.floor(entry.published!.getTime() / 1000),
|
||||
});
|
||||
@@ -125,12 +126,14 @@ export const getRedditFeed = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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.
|
||||
* - the published date of the entry is older than the last update date of the
|
||||
* source minus 10 seconds.
|
||||
*/
|
||||
const skipEntry = (
|
||||
index: number,
|
||||
@@ -156,8 +159,9 @@ const skipEntry = (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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,
|
||||
@@ -168,24 +172,47 @@ const generateSourceId = (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* `getMedia` returns the media for a feed entry. If the entry does not contain a media we return `undefined`. Some
|
||||
* Reddit feed items are containing a thumbnail, which we can use as media.
|
||||
* `getDescription` returns the description for a feed entry. If the entry does
|
||||
* not contain a description we return `undefined`. Some Reddit feed items are
|
||||
* containing a table, which we have to remove from the description, to improve
|
||||
* the rendering in the Flutter app.
|
||||
*/
|
||||
const getMedia = (entry: FeedEntry): string | undefined => {
|
||||
if (
|
||||
// deno-lint-ignore no-explicit-any
|
||||
(entry as any)["media:thumbnail"] && (entry as any)["media:thumbnail"].url
|
||||
) {
|
||||
// deno-lint-ignore no-explicit-any
|
||||
return (entry as any)["media:thumbnail"].url;
|
||||
const getDescription = (entry: FeedEntry): string | undefined => {
|
||||
if (entry.content?.value) {
|
||||
const content = unescape(entry.content.value);
|
||||
return content.replaceAll('<table>', '').replaceAll('<tr>', '').replaceAll(
|
||||
'<td>',
|
||||
'',
|
||||
).replaceAll('</table>', '').replaceAll('</tr>', '').replaceAll(
|
||||
'</td>',
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* `getMedia` returns the media for a feed entry. If the entry does not contain
|
||||
* a media we return `undefined`. Some Reddit feed items are containing a
|
||||
* thumbnail, which we can use as media.
|
||||
*/
|
||||
const getMedia = (entry: FeedEntry): string | undefined => {
|
||||
if (
|
||||
// deno-lint-ignore no-explicit-any
|
||||
(entry as any)['media:thumbnail'] && (entry as any)['media:thumbnail'].url
|
||||
) {
|
||||
// deno-lint-ignore no-explicit-any
|
||||
return (entry as any)['media:thumbnail'].url;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
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 { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { Feed, parseFeed } from 'rss';
|
||||
import { Md5 } from 'std/md5';
|
||||
import { FeedEntry } from 'rss/types';
|
||||
import { Redis } from 'redis';
|
||||
import { unescape } from 'lodash';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
import { IItem } from "../models/item.ts";
|
||||
import { ISource } from "../models/source.ts";
|
||||
import { getFavicon } from "./utils/getFavicon.ts";
|
||||
import { uploadSourceIcon } from "./utils/uploadFile.ts";
|
||||
import { IProfile } from "../models/profile.ts";
|
||||
import { fetchWithTimeout } from "../utils/fetchWithTimeout.ts";
|
||||
import { log } from "../utils/log.ts";
|
||||
import { IItem } from '../models/item.ts';
|
||||
import { ISource } from '../models/source.ts';
|
||||
import { getFavicon } from './utils/getFavicon.ts';
|
||||
import { uploadSourceIcon } from './utils/uploadFile.ts';
|
||||
import { IProfile } from '../models/profile.ts';
|
||||
import { fetchWithTimeout } from '../utils/fetchWithTimeout.ts';
|
||||
import { log } from '../utils/log.ts';
|
||||
|
||||
export const getRSSFeed = async (
|
||||
supabaseClient: SupabaseClient,
|
||||
@@ -20,75 +21,81 @@ export const getRSSFeed = async (
|
||||
source: ISource,
|
||||
): Promise<{ source: ISource; items: IItem[] }> => {
|
||||
/**
|
||||
* To get a RSS feed the `source` must have a `rss` option. This option is then passed to the `parseFeed` function of
|
||||
* the `rss` package to get the feed.
|
||||
* To get a RSS feed the `source` must have a `rss` option. This option is
|
||||
* then passed to the `parseFeed` function of the `rss` package to get the
|
||||
* feed.
|
||||
*/
|
||||
if (!source.options?.rss) {
|
||||
throw new Error("Invalid source options");
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
|
||||
const response = await fetchWithTimeout(
|
||||
source.options.rss,
|
||||
{ method: "get" },
|
||||
5000,
|
||||
);
|
||||
const xml = await response.text();
|
||||
log("debug", "Add source", {
|
||||
sourceType: "rss",
|
||||
requestUrl: source.options.rss,
|
||||
responseStatus: response.status,
|
||||
});
|
||||
const feed = await parseFeed(xml);
|
||||
let feed = await getFeed(source);
|
||||
if (!feed) {
|
||||
log(
|
||||
'debug',
|
||||
'Failed to get RSS feed, try to get RSS feed from website',
|
||||
{ requestUrl: source.options.rss },
|
||||
);
|
||||
feed = await getFeedFromWebsite(source);
|
||||
if (!feed) {
|
||||
throw new Error('Failed to get RSS feed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the feed does not have a title we consider it invalid and throw an error.
|
||||
* If the feed does not have a title we consider it invalid and throw an
|
||||
* error.
|
||||
*/
|
||||
if (!feed.title.value) {
|
||||
throw new Error("Invalid feed");
|
||||
throw new Error('Invalid feed');
|
||||
}
|
||||
|
||||
/**
|
||||
* If the provided source does not already have an id we generate one using the `generateSourceId` function. The id of
|
||||
* a source is a combination of the user id, the column id and the link of the RSS feed. We also set the type of the
|
||||
* source to `rss` and the title to the title of the feed.
|
||||
* If the provided source does not already have an id we generate one using
|
||||
* the `generateSourceId` function. The id of a source is a combination of the
|
||||
* user id, the column id and the link of the RSS feed. We also set the type
|
||||
* of the source to `rss` and the title to the title of the feed.
|
||||
*/
|
||||
if (source.id === "") {
|
||||
if (source.id === '') {
|
||||
source.id = generateSourceId(
|
||||
source.userId,
|
||||
source.columnId,
|
||||
source.options.rss,
|
||||
);
|
||||
}
|
||||
source.type = "rss";
|
||||
source.type = 'rss';
|
||||
source.title = feed.title.value;
|
||||
|
||||
/**
|
||||
* If the feed contains a list of links we are using the first one as the link for our source.
|
||||
* If the feed contains a list of links we are using the first one as the link
|
||||
* for our source.
|
||||
*/
|
||||
if (feed.links.length > 0) {
|
||||
source.link = feed.links[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* If the source doesn't already contain an icon, we try to get an icon via the `source.link` via our `getFavicon`
|
||||
* function. If that fails we try to use the icon or image of the feed. If we are able to get an icon we upload it to
|
||||
* our CDN and set the `source.icon` to the URL of the uploaded icon.
|
||||
* If the source doesn't already contain an icon, we try to get an icon via
|
||||
* the `source.link` via our `getFavicon` function. If that fails we try to
|
||||
* use the icon or image of the feed. If we are able to get an icon we upload
|
||||
* it to our CDN and set the `source.icon` to the URL of the uploaded icon.
|
||||
*
|
||||
* Note: We try to use the `getFavicon` function first, because the most RSS feeds do not contain a proper icon so
|
||||
* that a favicon looks better than the feed icon / image within the UI.
|
||||
* Note: We try to use the `getFavicon` function first, because the most RSS
|
||||
* feeds do not contain a proper icon so that a favicon looks better than the
|
||||
* feed icon / image within the UI.
|
||||
*/
|
||||
if (!source.icon) {
|
||||
if (source.link) {
|
||||
const favicon = await getFavicon(source.link);
|
||||
if (favicon && favicon.url.startsWith("https://")) {
|
||||
if (favicon && favicon.url.startsWith('https://')) {
|
||||
source.icon = favicon.url;
|
||||
}
|
||||
}
|
||||
|
||||
if (!source.icon) {
|
||||
if (feed.icon && feed.icon.startsWith("https://")) {
|
||||
if (feed.icon && feed.icon.startsWith('https://')) {
|
||||
source.icon = feed.icon;
|
||||
} else if (feed.image?.url && feed.image.url.startsWith("https://")) {
|
||||
} else if (feed.image?.url && feed.image.url.startsWith('https://')) {
|
||||
source.icon = feed.image?.url;
|
||||
}
|
||||
}
|
||||
@@ -97,7 +104,8 @@ export const getRSSFeed = async (
|
||||
}
|
||||
|
||||
/**
|
||||
* Now that the source contains all the required fields we can loop through all the items and add them for the source.
|
||||
* Now that the source contains all the required fields we can loop through
|
||||
* all the items and add them for the source.
|
||||
*/
|
||||
const items: IItem[] = [];
|
||||
|
||||
@@ -107,11 +115,12 @@ export const getRSSFeed = async (
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 or if the entry does not have an id we use the link of the first link of the
|
||||
* entry.
|
||||
* 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 or if the entry does not have an id we use the link of the first
|
||||
* link of the entry.
|
||||
*/
|
||||
let itemId = "";
|
||||
let itemId = '';
|
||||
if (entry.id) {
|
||||
itemId = generateItemId(source.id, entry.id);
|
||||
} else {
|
||||
@@ -143,12 +152,77 @@ export const getRSSFeed = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `getFeed` is a helper function to get a RSS feed for a source. It returns
|
||||
* the feed or undefined if the request failed or the returned response could
|
||||
* not be parsed as a feed.
|
||||
*/
|
||||
const getFeed = async (source: ISource): Promise<Feed | undefined> => {
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
source.options!.rss!,
|
||||
{ method: 'get' },
|
||||
5000,
|
||||
);
|
||||
const xml = await response.text();
|
||||
log('debug', 'Add source', {
|
||||
sourceType: 'rss',
|
||||
requestUrl: source.options!.rss!,
|
||||
responseStatus: response.status,
|
||||
});
|
||||
const feed = await parseFeed(xml);
|
||||
return feed;
|
||||
} catch (_) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* `getFeedFromWebsite` is a helper function to get a RSS feed from a website.
|
||||
* This function can be used to get the RSS feed after the call to `getFeed`
|
||||
* failed. This could happen when a user provided an url to a website instead of
|
||||
* a RSS feed.
|
||||
*
|
||||
* In the function we are checking if there is a
|
||||
* `<link rel="alternate" type="application/rss+xml" href="RSS_FEED_URL">` tag
|
||||
* on the website. If this is the case we are using the `href` attribute and try
|
||||
* to get the RSS feed from that url via the `getFeed` function.
|
||||
*
|
||||
* When we construct the RSS feed url we have to ensure, that the url is
|
||||
* absolute.
|
||||
*/
|
||||
const getFeedFromWebsite = async (
|
||||
source: ISource,
|
||||
): Promise<Feed | undefined> => {
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
source.options!.rss!,
|
||||
{ method: 'get' },
|
||||
5000,
|
||||
);
|
||||
const html = await response.text();
|
||||
|
||||
const $ = cheerio.load(html);
|
||||
const rssLink = $('link[type="application/rss+xml"]').attr('href');
|
||||
if (!rssLink) {
|
||||
return undefined;
|
||||
}
|
||||
source.options!.rss = new URL(rssLink, source.options!.rss!).href;
|
||||
|
||||
return getFeed(source);
|
||||
} catch (_) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* `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 / updated date.
|
||||
* - the published / updated date of the entry is older than the last update date of the source minus 10 seconds.
|
||||
* - the published / updated date of the entry is older than the last update
|
||||
* date of the source minus 10 seconds.
|
||||
*/
|
||||
const skipEntry = (
|
||||
index: number,
|
||||
@@ -183,8 +257,9 @@ const skipEntry = (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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,
|
||||
@@ -195,44 +270,48 @@ const generateSourceId = (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* `getItemDescription` returns the description of an item based on the provided description and content. In the first
|
||||
* step we try to use the description of the items as our description. If that is not available, we try to use the
|
||||
* content. If that is not available, we return undefined. We also remove all HTML tags from the description and content
|
||||
* before returning it.
|
||||
* `getItemDescription` returns the description of an item based on the provided
|
||||
* description and content. In the first step we try to use the description of
|
||||
* the items as our description. If that is not available, we try to use the
|
||||
* content. If that is not available, we return undefined. We also remove all
|
||||
* HTML tags from the description and content before returning it.
|
||||
*/
|
||||
const getItemDescription = (entry: FeedEntry): string | undefined => {
|
||||
if (entry.description?.value) {
|
||||
return unescape(entry.description?.value.replace(/(<([^>]+)>)/ig, ""));
|
||||
return unescape(entry.description?.value.replace(/(<([^>]+)>)/ig, ''));
|
||||
}
|
||||
|
||||
if (entry.content?.value) {
|
||||
return unescape(entry.content?.value.replace(/(<([^>]+)>)/ig, ""));
|
||||
return unescape(entry.content?.value.replace(/(<([^>]+)>)/ig, ''));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* `getMedia` returns a media url for the provided feed `entry` (item). To get the media we check all the different
|
||||
* media tags that are available in the feed. If we find a media tag with a medium of `image` we return the url of that
|
||||
* tag. If we don't find any media tags with a medium of `image` we check the attachements of the feed entry. If we do
|
||||
* not find an image there we finally check if the description or content contains an `img` tag to use it for the media
|
||||
* field.
|
||||
* `getMedia` returns a media url for the provided feed `entry` (item). To get
|
||||
* the media we check all the different media tags that are available in the
|
||||
* feed. If we find a media tag with a medium of `image` we return the url of
|
||||
* that tag. If we don't find any media tags with a medium of `image` we check
|
||||
* the attachements of the feed entry. If we do not find an image there we
|
||||
* finally check if the description or content contains an `img` tag to use it
|
||||
* for the media field.
|
||||
*/
|
||||
const getMedia = (entry: FeedEntry): string | undefined => {
|
||||
if (entry["media:content"] && entry["media:content"].length > 0) {
|
||||
for (const media of entry["media:content"]) {
|
||||
if (entry['media:content'] && entry['media:content'].length > 0) {
|
||||
for (const media of entry['media:content']) {
|
||||
if (
|
||||
media.medium && media.medium === "image" && media.url &&
|
||||
media.url.startsWith("https://")
|
||||
media.medium && media.medium === 'image' && media.url &&
|
||||
media.url.startsWith('https://') && !media.url.endsWith('.svg')
|
||||
) {
|
||||
return media.url;
|
||||
}
|
||||
@@ -240,20 +319,21 @@ const getMedia = (entry: FeedEntry): string | undefined => {
|
||||
}
|
||||
|
||||
if (
|
||||
entry["media:thumbnails"] && entry["media:thumbnails"].url &&
|
||||
entry["media:thumbnails"].url.startsWith("https://")
|
||||
entry['media:thumbnails'] && entry['media:thumbnails'].url &&
|
||||
entry['media:thumbnails'].url.startsWith('https://')
|
||||
) {
|
||||
return entry["media:thumbnails"].url;
|
||||
return entry['media:thumbnails'].url;
|
||||
}
|
||||
|
||||
if (entry["media:group"] && entry["media:group"].length > 0) {
|
||||
for (const mediaGroup of entry["media:group"]) {
|
||||
if (mediaGroup["media:content"]) {
|
||||
for (const mediaContent of mediaGroup["media:content"]) {
|
||||
if (entry['media:group'] && entry['media:group'].length > 0) {
|
||||
for (const mediaGroup of entry['media:group']) {
|
||||
if (mediaGroup['media:content']) {
|
||||
for (const mediaContent of mediaGroup['media:content']) {
|
||||
if (
|
||||
mediaContent.medium && mediaContent.medium === "image" &&
|
||||
mediaContent.medium && mediaContent.medium === 'image' &&
|
||||
mediaContent.url &&
|
||||
mediaContent.url.startsWith("https://")
|
||||
mediaContent.url.startsWith('https://') &&
|
||||
!mediaContent.url.endsWith('.svg')
|
||||
) {
|
||||
return mediaContent.url;
|
||||
}
|
||||
@@ -265,9 +345,10 @@ const getMedia = (entry: FeedEntry): string | undefined => {
|
||||
if (entry.attachments && entry.attachments.length > 0) {
|
||||
for (const attachment of entry.attachments) {
|
||||
if (
|
||||
attachment.mimeType && attachment.mimeType.startsWith("image/") &&
|
||||
attachment.mimeType && attachment.mimeType.startsWith('image/') &&
|
||||
attachment.url &&
|
||||
attachment.url.startsWith("https://")
|
||||
attachment.url.startsWith('https://') &&
|
||||
!attachment.url.endsWith('.svg')
|
||||
) {
|
||||
return attachment.url;
|
||||
}
|
||||
@@ -276,18 +357,24 @@ const getMedia = (entry: FeedEntry): string | undefined => {
|
||||
|
||||
if (entry.description?.value) {
|
||||
const matches = /<img[^>]+\bsrc=["']([^"']+)["']/.exec(
|
||||
entry.description?.value,
|
||||
unescape(entry.description.value),
|
||||
);
|
||||
if (matches && matches.length == 2 && matches[1].startsWith("https://")) {
|
||||
if (
|
||||
matches && matches.length == 2 && matches[1].startsWith('https://') &&
|
||||
!matches[1].endsWith('.svg')
|
||||
) {
|
||||
return matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.content?.value) {
|
||||
const matches = /<img[^>]+\bsrc=["']([^"']+)["']/.exec(
|
||||
entry.content?.value,
|
||||
unescape(entry.content.value),
|
||||
);
|
||||
if (matches && matches.length == 2 && matches[1].startsWith("https://")) {
|
||||
if (
|
||||
matches && matches.length == 2 && matches[1].startsWith('https://') &&
|
||||
!matches[1].endsWith('.svg')
|
||||
) {
|
||||
return matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { parseFeed } from "rss";
|
||||
import { FeedEntry } from "rss/types";
|
||||
import { Md5 } from "std/md5";
|
||||
import { Redis } from "redis";
|
||||
import { unescape } from "lodash";
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { parseFeed } from 'rss';
|
||||
import { FeedEntry } from 'rss/types';
|
||||
import { Md5 } from 'std/md5';
|
||||
import { Redis } from 'redis';
|
||||
import { unescape } from 'lodash';
|
||||
|
||||
import { ISource } from "../models/source.ts";
|
||||
import { IItem } from "../models/item.ts";
|
||||
import { IProfile } from "../models/profile.ts";
|
||||
import { fetchWithTimeout } from "../utils/fetchWithTimeout.ts";
|
||||
import { log } from "../utils/log.ts";
|
||||
import { ISource } from '../models/source.ts';
|
||||
import { IItem } from '../models/item.ts';
|
||||
import { IProfile } from '../models/profile.ts';
|
||||
import { fetchWithTimeout } from '../utils/fetchWithTimeout.ts';
|
||||
import { log } from '../utils/log.ts';
|
||||
|
||||
export const getStackoverflowFeed = async (
|
||||
_supabaseClient: SupabaseClient,
|
||||
@@ -18,48 +18,50 @@ export const getStackoverflowFeed = async (
|
||||
source: ISource,
|
||||
): Promise<{ source: ISource; items: IItem[] }> => {
|
||||
if (!source.options?.stackoverflow || !source.options?.stackoverflow?.type) {
|
||||
throw new Error("Invalid source options");
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
|
||||
if (source.options.stackoverflow.type === "tag") {
|
||||
if (source.options.stackoverflow.type === 'tag') {
|
||||
source.options.stackoverflow.url =
|
||||
`https://stackoverflow.com/feeds/tag?tagnames=${source.options.stackoverflow.tag}&sort=${source.options.stackoverflow.sort}`;
|
||||
}
|
||||
|
||||
if (!source.options?.stackoverflow.url) {
|
||||
throw new Error("Invalid source options");
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the RSS for the provided `stackoverflow` url and parse it. If a feed doesn't contains an item we return an error.
|
||||
* Get the RSS for the provided `stackoverflow` url and parse it. If a feed
|
||||
* doesn't contains an item we return an error.
|
||||
*/
|
||||
const response = await fetchWithTimeout(source.options.stackoverflow.url, {
|
||||
method: "get",
|
||||
method: 'get',
|
||||
}, 5000);
|
||||
const xml = await response.text();
|
||||
log("debug", "Add source", {
|
||||
sourceType: "stackoverflow",
|
||||
log('debug', 'Add source', {
|
||||
sourceType: 'stackoverflow',
|
||||
requestUrl: source.options.stackoverflow.url,
|
||||
responseStatus: response.status,
|
||||
});
|
||||
const feed = await parseFeed(xml);
|
||||
|
||||
if (!feed.title.value) {
|
||||
throw new Error("Invalid feed");
|
||||
throw new Error('Invalid feed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a source id based on the user id, column id and the normalized `stackoverflow` url. Besides that we also
|
||||
* set the source type to `stackoverflow` and set the title and link for the source.
|
||||
* Generate a source id based on the user id, column id and the normalized
|
||||
* `stackoverflow` url. Besides that we also set the source type to
|
||||
* `stackoverflow` and set the title and link for the source.
|
||||
*/
|
||||
if (source.id === "") {
|
||||
if (source.id === '') {
|
||||
source.id = generateSourceId(
|
||||
source.userId,
|
||||
source.columnId,
|
||||
source.options.stackoverflow.url,
|
||||
);
|
||||
}
|
||||
source.type = "stackoverflow";
|
||||
source.type = 'stackoverflow';
|
||||
source.title = feed.title.value;
|
||||
if (feed.links.length > 0) {
|
||||
source.link = feed.links[0];
|
||||
@@ -67,8 +69,8 @@ export const getStackoverflowFeed = async (
|
||||
source.icon = undefined;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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[] = [];
|
||||
|
||||
@@ -100,12 +102,14 @@ export const getStackoverflowFeed = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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.
|
||||
* - the published date of the entry is older than the last update date of the
|
||||
* source minus 10 seconds.
|
||||
*/
|
||||
const skipEntry = (
|
||||
index: number,
|
||||
@@ -131,8 +135,9 @@ const skipEntry = (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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,
|
||||
@@ -145,8 +150,9 @@ const generateSourceId = (
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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()}`;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user