Compare commits

...

31 Commits

Author SHA1 Message Date
ricoberger
046479071b Prepare v1.2.0 Release 2023-11-26 17:51:09 +01:00
Rico Berger
ffbed73669 [medium] Remove Spam (#79)
When using Medium as a source it is possible that the a lot of items
might be spam when following a tag. To reduce the spam, we filter these
items based on a word list and an calculated score. The score is
calculated by the number of words which are included in the title from
our word list.
2023-11-26 17:03:54 +01:00
Rico Berger
03371cf645 [reddit] Remove Tables from Description (#78)
When a Reddit post contained an image it was always rendered as table,
where the image was displayed in the left column and the author in the
right column. This looked very ugly in the app, so that we are now
removing all tables from the description of an Reddit post, so that the
image is rendered as large as possible and the author is displayed below
the image.
2023-11-26 16:55:17 +01:00
Rico Berger
eb606b5f6c [core] Improve Subtitle in Details View (#77)
This commit improves the displayed subtitle in the details view of an
item. For that we have adjusted the `ItemSubtitle` widget to diplay next
to the source title, author and publishing time an corresponding icon.
We also increased the space between the items and we are using a `|`
instead of a `/` as seperator.
2023-11-26 16:09:48 +01:00
Rico Berger
df927516b1 [core] Improve Icon Handling (#76)
Instead of defining the icons for a source only within the `SourceIcon`
widget, the icons are now defined as extension for the `FDSourceType`
enum. The background and foreground colors are also defined within the
enum now. This allows us to access the icon of a source outside of the
`SourceIcon` widget and we only have to touch the `source.dart` file
when adding a new source type.
2023-11-26 14:52:15 +01:00
Rico Berger
fa23e095e5 [core] Fix getMedia Function (#75)
In the `getMedia` function for Medium, Nitter, RSS and Tumblr we checked
the content and/or description of an RSS feed entry for a media file,
but we didn't pass the string to the `unescape` function first, so that
we might missed some media files, because our regular expression were
not able to find an image.

Now we are using the `unescape` function before using our regular
expression to find the image, similar to how we are also applying the
`unescape` function before we save the item description.
2023-11-25 18:24:22 +01:00
Rico Berger
2298176c3b [pinterest] Add Support for Pinterest (#74)
This commit adds a new source type "pinterest", which can be used to
follow the post of an user or a board on Pinterest. To use the new
source type a user can select the "Pinterest" item in the add source
modal. In the form a user can provide the username or board he wants to
follow via FeedDeck.

In the corresponding Supabase function we then convert the input
provided by the user to an valid RSS feed url for Pinterest. This means
that we have to add `/feed.rss` for users and `.rss` for boards to the
Pinterest url.

Then we generate the source and items as for the other sources and reuse
the existing components to render the preview and details item. We had
to adjust the rendering logic for these items, to ignore empty values,
from which also other sources will benefit.
2023-11-25 18:04:19 +01:00
Rico Berger
3f7caf4ad4 [core] Fix Decoding of Special Characters (#73)
We have to add the "charset" parameter to the "Content-Type" header when
we return json from one of the Supabase functions, so that special
characters are properly decoded in Flutter.
2023-11-25 00:00:22 +01:00
Rico Berger
db9363e7af [rss] Allow Users to Provide a Website URL (#72)
Users can now provide the URL of a website instead of the url of a RSS
feed via the input field for the RSS source.

This is possible because we are now trying to get and parse the RSS feed
for the provided url as usual, but if this operation fails, we try to
parse the text as html, so that we can check if it contains a
"<link type="application/rss+xml" href="RSS_FEED_URL">" tag. If this is
the case we are using this value to try to get and parse the RSS feed
again. If it fails again we are returning an error as before.
2023-11-24 23:11:50 +01:00
Rico Berger
ce36761c64 [core] Add GitHub Action for iOS and Android (#71)
This commit adds 2 new jobs to the existing "Continuous Delivery" GitHub
Action to test the build of the iOS and Android app.

For this we also had to adjust the "build.gradle" file for the Android
app, so that the test build in the GitHub Action is signed with dummy
credentials, since we do not provide the keystore properties.
2023-11-24 17:37:24 +01:00
Rico Berger
4e38cfdb5c [core] Update Flutter to Version 3.16.0 (#70)
Update the used Flutter version to 3.16.0 and all Flutter packages to
their latest version.

This commit also fixes all of the newly added analysis options and the
layout changes introduced with the new Flutter version (e.g. we have to
set the "tabAlignment" property in the "TabBar" widget).
2023-11-24 17:12:08 +01:00
Rico Berger
1c7c88a9ca [core] Submit "Add Source" Forms on Enter (#69)
All our forms were submitable via enter except the forms used to add a
new source to a column. This is now changed so that also these forms can
be submitted by pressing enter in a text field.
2023-11-07 23:12:46 +01:00
dependabot[bot]
c7d208de23 Bump the pub group in /app with 8 updates (#68)
* Bump the pub group in /app with 8 updates

Bumps the pub group in /app with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [flutter_lints](https://github.com/flutter/packages/tree/main/packages) | `2.0.3` | `3.0.1` |
| [flutter_native_splash](https://github.com/jonbhanson/flutter_native_splash) | `2.3.3` | `2.3.5` |
| [msix](https://github.com/YehudaKremer/msix) | `3.16.4` | `3.16.6` |
| [package_info_plus](https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus) | `4.1.0` | `4.2.0` |
| [purchases_flutter](https://github.com/RevenueCat/purchases-flutter) | `6.0.0` | `6.2.0` |
| [supabase_flutter](https://github.com/supabase/supabase-flutter/tree/main/packages) | `1.10.22` | `1.10.24` |
| [timeago](https://github.com/andresaraujo/timeago.dart) | `3.5.0` | `3.6.0` |
| [url_launcher](https://github.com/flutter/packages/tree/main/packages/url_launcher) | `6.1.14` | `6.2.1` |


Updates `flutter_lints` from 2.0.3 to 3.0.1
- [Release notes](https://github.com/flutter/packages/releases)
- [Commits](https://github.com/flutter/packages/commits/pigeon-v3.0.1/packages)

Updates `flutter_native_splash` from 2.3.3 to 2.3.5
- [Release notes](https://github.com/jonbhanson/flutter_native_splash/releases)
- [Changelog](https://github.com/jonbhanson/flutter_native_splash/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jonbhanson/flutter_native_splash/commits/v2.3.5)

Updates `msix` from 3.16.4 to 3.16.6
- [Release notes](https://github.com/YehudaKremer/msix/releases)
- [Changelog](https://github.com/YehudaKremer/msix/blob/main/CHANGELOG.md)
- [Commits](https://github.com/YehudaKremer/msix/compare/v3.16.4...v3.16.6)

Updates `package_info_plus` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/fluttercommunity/plus_plugins/releases)
- [Commits](https://github.com/fluttercommunity/plus_plugins/commits/package_info_plus-v4.2.0/packages/package_info_plus)

Updates `purchases_flutter` from 6.0.0 to 6.2.0
- [Release notes](https://github.com/RevenueCat/purchases-flutter/releases)
- [Changelog](https://github.com/RevenueCat/purchases-flutter/blob/main/CHANGELOG.md)
- [Commits](https://github.com/RevenueCat/purchases-flutter/compare/6.0.0...6.2.0)

Updates `supabase_flutter` from 1.10.22 to 1.10.24
- [Changelog](https://github.com/supabase/supabase-flutter/blob/main/CHANGELOG.md)
- [Commits](https://github.com/supabase/supabase-flutter/commits/supabase_flutter-v1.10.24/packages)

Updates `timeago` from 3.5.0 to 3.6.0
- [Commits](https://github.com/andresaraujo/timeago.dart/commits)

Updates `url_launcher` from 6.1.14 to 6.2.1
- [Release notes](https://github.com/flutter/packages/releases)
- [Commits](https://github.com/flutter/packages/commits/url_launcher-v6.2.1/packages/url_launcher)

---
updated-dependencies:
- dependency-name: flutter_lints
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: pub
- dependency-name: flutter_native_splash
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pub
- dependency-name: msix
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pub
- dependency-name: package_info_plus
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pub
- dependency-name: purchases_flutter
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pub
- dependency-name: supabase_flutter
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pub
- dependency-name: timeago
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pub
- dependency-name: url_launcher
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pub
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update Flutter Packages

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ricoberger <mail@ricoberger.de>
2023-11-05 23:33:01 +01:00
dependabot[bot]
a7639344a1 Bump the npm-email-templates group in /supabase/email-templates with 1 update (#61)
* Bump the npm-email-templates group

Bumps the npm-email-templates group in /supabase/email-templates with 1 update: [@react-email/components](https://github.com/resendlabs/react-email/tree/HEAD/packages/components).

- [Release notes](https://github.com/resendlabs/react-email/releases)
- [Changelog](https://github.com/resendlabs/react-email/blob/main/docs/changelog.mdx)
- [Commits](https://github.com/resendlabs/react-email/commits/HEAD/packages/components)

---
updated-dependencies:
- dependency-name: "@react-email/components"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-email-templates
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update Email Templates

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ricoberger <mail@ricoberger.de>
2023-11-05 23:07:07 +01:00
dependabot[bot]
ff76531962 Bump the npm-landing group in /landing with 7 updates (#60)
Bumps the npm-landing group in /landing with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `20.8.5` | `20.8.10` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `18.2.28` | `18.2.33` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `18.2.13` | `18.2.14` |
| [eslint](https://github.com/eslint/eslint) | `8.51.0` | `8.52.0` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `13.5.4` | `14.0.1` |
| [next](https://github.com/vercel/next.js) | `13.5.4` | `14.0.1` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss) | `3.3.3` | `3.3.5` |


Updates `@types/node` from 20.8.5 to 20.8.10
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@types/react` from 18.2.28 to 18.2.33
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `@types/react-dom` from 18.2.13 to 18.2.14
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `eslint` from 8.51.0 to 8.52.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.51.0...v8.52.0)

Updates `eslint-config-next` from 13.5.4 to 14.0.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v14.0.1/packages/eslint-config-next)

Updates `next` from 13.5.4 to 14.0.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v13.5.4...v14.0.1)

Updates `tailwindcss` from 3.3.3 to 3.3.5
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/compare/v3.3.3...v3.3.5)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-landing
- dependency-name: "@types/react"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-landing
- dependency-name: "@types/react-dom"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-landing
- dependency-name: eslint
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-landing
- dependency-name: eslint-config-next
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-landing
- dependency-name: next
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-landing
- dependency-name: tailwindcss
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-landing
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-05 23:04:13 +01:00
dependabot[bot]
dc77f16a34 Bump the github-actions group with 1 update (#62)
* Bump the github-actions group with 1 update

Bumps the github-actions group with 1 update: [actions/setup-node](https://github.com/actions/setup-node).

- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update Node Version

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ricoberger <mail@ricoberger.de>
2023-11-05 22:59:13 +01:00
Rico Berger
bcd03e7e60 [core] Add Custom Cache Manager (#67)
This commit adds a custom cache manager "CustomCacheManager" which is
used in the "CachedNetworkImage" widget. The custom cache manager is
required, so that we can adjust the stale periode of cached images. By
default the package used 30 days as stale periode, but for our use case
7 days should be enough and we can reduce the storage used by the app.

Note: We also fixed the "run.sh" script to work with devices where the
name contains a space.
2023-11-05 17:35:37 +01:00
Rico Berger
92bea5d715 [core] Improve Media Handling (#66)
This commit improves / simplifies the media handling within the app.
Until now we always had to provide the type of the media file (item
media / source icon) when we wanted to display it. This is now not
necessary anymore. Instead we always display the image from it's
original path when the url starts with "http://" or "https://".
Additionally we also check the platform to proxy the image request on
the web. If the image doesn't start with "http://" or "https://" we
always try to display the image from the Supabase storage.

For this we also adjusted the corresponding Deno function, so that we
check if the image starts with "http://" or "https://" before we upload
it to the Supabase storage. If this is the case we upload the source
icon to the Supabase storage. If the upload fails, we will use the
original path of the icon.

Last but not least this commit also introduces our own
"CachedNetworkImage" widget, which wraps the original
"CachedNetworkImage" widget. Our own widget will ensure that we use the
correct url for the image request, so that we do not have to use this
function all over the app anymore. Later this widget can also be used to
introduce our own cache manager.
2023-11-04 18:27:17 +01:00
Rico Berger
37cd4dff6f [core] Run "deno fmt" (#65)
Format the Deno code via "deno fmt" to use the defined code style from
the "deno.json" file.
2023-11-04 15:45:42 +01:00
Rico Berger
d62bf10eaf [github] Fix Icons in Item Preview (#64)
The icons for GitHub items were not shown, because we did not set the
correct "sourceIconType", which was required for the "ItemSource"
widget.
2023-11-04 11:48:12 +01:00
Rico Berger
3afbe5674b [core] Add Development Setup for Neovim (#63)
Add some stuff to make the development with Neovim easier:

- Add a "deno.json" file, which is required by the Deno language server,
  when working in Neovim to detect the "import_map.json" file.
- Add a "run.sh" script to easily run the Flutter app from the command
  line.
- Add the "run.sh" script and an example for Neovim to the contributing
  guide.
2023-11-04 11:47:53 +01:00
Rico Berger
6d5a699db6 [core] Add Missing Divider to Video Quality Selection (#58)
In the modal bottom sheet where a user can select the video quality the
dividers between the different qualities were missing. This commit adds
the missing divider, so that the modal bottom sheet looks similar to the
other modal bottom sheets we are using (e.g. sign out, account
settings).
2023-10-30 19:49:21 +01:00
Rico Berger
e19885a594 [core] Fix Modal Bottom Sheet Size for Images (#57)
The size of the modal bottom sheet to display images had always a max
width of 640px on large screens. This wasn't intended an the modal
bottom sheet should fill the whole screen. This is now fixed, so that
when a user clicks on an image in the details view of an item, the whole
screen is used to display the image.
2023-10-30 12:08:57 +01:00
Rico Berger
6e27eb751c [youtube] Add Desktop Support (#56)
It is now pissible to play YouTube videos on the native desktop clients.
To achieve this we removed the "youtube_player_iframe" package which was
used before to play YouTube video, but which only supported web, iOS and
Android as target platforms.

On the web we are now using our own implementation to render an iframe
with for the YouTube video.

On all other platforms we are now using the "youtube_explode_dart"
package to fetch the video urls for a YouTube video and then we display
them within our own video player (the "ItemVideoPlayer" widget which was
added in #51). We also decided to switch the package for the iOS and
Android implementation which already worked before, because we are now
able to play YouTube videos in fullscreen and we only have to maintain
an exception for the web implementation.

The "ItemVideoPlayer" widget now also supports multiple qualities of an
video via the "qualities" paramter, which allows a user to switch
between the different video qualities which are available for a YouTube
video. Last but not least the widget now uses our primary color for the
seek bar.
2023-10-29 22:48:54 +01:00
Rico Berger
8e3586f315 [github] Fix Notification Links for PRs (#55)
If a notification was related to a pull request the link we added to the
item didn't work. This should now be fixed, so that when a user clicks
on the item he is redirected to the correct pull request which is
associated with the item.

This commit also removes an unnecessary "console.log" statement.
2023-10-29 13:49:06 +01:00
Rico Berger
e994ab6214 [core] Sign Out from Current Device (#54)
Until now a user was always signed out from all devices when he clicked
on the "Sign Out" button in the settings. Now we will display a actions
modal bottom sheet where a user can select if he wants to sign out from
the current device or from all devices.

For this we pass the [SignOutScope] to the "signOut" function of
Supabase. if the scope is "local" the user will be signed out from the
current device. If the scope is "global" the user will be signed out
from all devices.
2023-10-29 10:39:28 +01:00
Rico Berger
4906f9dc27 [core] Improve Tabs Handling in Small Deck Layout (#53)
This commit improves the handling of tabs in the samll deck layout. We
are now saving the selected tabs index in the newly added
"LayoutRepository" so that we can reuse the selected tab when a user
switches between the small and large layout. We can now also set the tab
which should be initially selected in the large layout when a user
selects a column in the navigation rail. Last but not least we can also
reset the initial tab index when a user selects a new deck in the
settings, so that we always display the first column instead of the
column with the same index as it was selected in the previous deck.
2023-10-28 17:36:01 +02:00
Rico Berger
55c6da07d9 [rss] Improve Rendering of Items (#52)
The items of an RSS feed are now rendered better, to achieve this we did
the following changes:

- Remove leading and trailing whitespaces from the item description
  which should be rendered.
- Check if the media file of an item is an SVG image. If this is the
  case we will not add it to the "media" field in the database, because
  currently the CachedNetworkImage widget can not render SVGs. If we
  want to render them, we run into serious performance issue so we skip
  them completly.
- Always assume that the content of an RSS feed contains HTML and render
  them as plain text in the preview and as markdown in the details.
  Since we also render images from the description now, we check if the
  "item.media" image should be rendered. If the description contains an
  image we do not render our own image. If the description doesn't
  contain a image we render it.
2023-10-28 11:37:00 +02:00
Rico Berger
8dc83a5d5a [mastodon] Add Support for Videos (#51)
It is now possible to play videos from toots within FeedDeck. For that
we are using the "madia_kit" package, which is already used for the
Podcast player on Windows and Linux.

The videos from a toot are saved within the "options.videos" field of an
item next to the "options.media" field. In the "ItemDetailsMastodon"
widget we are then checking if this field is present and contains a list
of video urls. These urls can then be played via the "ItemVideos"
widget.
2023-10-27 15:22:27 +02:00
ricoberger
94d5732f6a Fix App IDs used in Landing Page 2023-10-22 23:09:37 +02:00
ricoberger
f4a9f84061 Fix app.feeddeck.feeddeck.metainfo.xml 2023-10-22 20:28:10 +02:00
128 changed files with 5384 additions and 2980 deletions

View File

@@ -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 }}

View File

@@ -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
View File

@@ -1,6 +1,9 @@
# Visual Studio Code Launch Configurations
.vscode/launch.json
# Neovim
.nvim.lua
# Environment Variables
/supabase/.env.local
/supabase/.env.dev

View File

@@ -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

View File

@@ -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
}
}
}
}

View File

@@ -0,0 +1 @@
extensions:

Binary file not shown.

View File

@@ -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

View File

@@ -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()),
],

View File

@@ -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,

View 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;
}

View File

@@ -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 =

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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),
),
),

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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,
),
],
);
}

View 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,
),
],
);
}
}

View File

@@ -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,

View File

@@ -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,
),
],
);

View File

@@ -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,
),
);
}
}

View File

@@ -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,

View File

@@ -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(),
),

View File

@@ -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(),
),

View File

@@ -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(),
),
),
);

View File

@@ -12,6 +12,10 @@ class ItemTitle extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (itemTitle.isEmpty) {
return Container();
}
return SelectableText(
itemTitle,
textAlign: TextAlign.left,

View 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,
),
),
),
),
);
},
);
}
}

View File

@@ -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);
}

View File

@@ -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,
);

View File

@@ -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',
);

View File

@@ -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,
);

View File

@@ -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,

View File

@@ -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,
),

View File

@@ -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()

View 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,
),
],
);
}
}

View File

@@ -41,7 +41,7 @@ class ItemPreviewRSS extends StatelessWidget {
),
ItemDescription(
itemDescription: item.description,
sourceFormat: DescriptionFormat.plain,
sourceFormat: DescriptionFormat.html,
tagetFormat: DescriptionFormat.plain,
),
],

View File

@@ -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,

View File

@@ -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(),
),

View File

@@ -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(),
);

View File

@@ -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,
),
),

View File

@@ -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;

View File

@@ -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,
),
),
),
],
),
);
}
}

View File

@@ -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();

View File

@@ -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,
),
),
],

View File

@@ -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),
],

View File

@@ -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>(

View File

@@ -118,6 +118,7 @@ class _AddSourceMastodonState extends State<AddSourceMastodon> {
border: OutlineInputBorder(),
labelText: 'Username, Hashtag, or Url',
),
onFieldSubmitted: (value) => _addSource(),
),
],
),

View File

@@ -123,6 +123,7 @@ class _AddSourceMediumState extends State<AddSourceMedium> {
border: OutlineInputBorder(),
labelText: 'Medium Url, Author or Tag',
),
onFieldSubmitted: (value) => _addSource(),
),
],
),

View File

@@ -119,6 +119,7 @@ class _AddSourceNitterState extends State<AddSourceNitter> {
border: OutlineInputBorder(),
labelText: 'Username or Search Term',
),
onFieldSubmitted: (value) => _addSource(),
),
],
),

View 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(),
),
],
),
),
);
}
}

View File

@@ -116,6 +116,7 @@ class _AddSourcePodcastState extends State<AddSourcePodcast> {
border: OutlineInputBorder(),
labelText: 'Podcast Url',
),
onFieldSubmitted: (value) => _addSource(),
),
],
),

View File

@@ -120,6 +120,7 @@ class _AddSourceRedditState extends State<AddSourceReddit> {
border: OutlineInputBorder(),
labelText: 'Reddit Url, Subreddit or User',
),
onFieldSubmitted: (value) => _addSource(),
),
],
),

View File

@@ -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(),
),
],
),

View File

@@ -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>(

View File

@@ -117,6 +117,7 @@ class _AddSourceTumblrState extends State<AddSourceTumblr> {
border: OutlineInputBorder(),
labelText: 'Blog',
),
onFieldSubmitted: (value) => _addSource(),
),
],
),

View File

@@ -110,6 +110,7 @@ class _AddSourceXState extends State<AddSourceX> {
border: OutlineInputBorder(),
labelText: 'Username',
),
onFieldSubmitted: (value) => _addSource(),
),
],
),

View File

@@ -115,6 +115,7 @@ class _AddSourceYouTubeState extends State<AddSourceYouTube> {
border: OutlineInputBorder(),
labelText: 'Channel Url',
),
onFieldSubmitted: (value) => _addSource(),
),
],
),

View File

@@ -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),
),

View 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),
),
);
}

View File

@@ -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>

View File

@@ -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);

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
media_kit_libs_linux
media_kit_video
screen_retriever
url_launcher_linux
window_manager

View File

@@ -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"))
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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
View 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}

View File

@@ -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"
]
}
]
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 4096 4096" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="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

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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",
},

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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=

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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);
}
};

View File

@@ -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() });
}
};

View File

@@ -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 };
};

View File

@@ -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() });
}
};

View File

@@ -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');
}
};

View File

@@ -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'
}`,
};
}

View File

@@ -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;
}

View File

@@ -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)];

View File

@@ -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];
}
}

View File

@@ -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]);
}
}

View 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;
};

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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];
}
}

View File

@@ -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