diff --git a/locales/fr_FR/LC_MESSAGES/main.mo b/locales/fr_FR/LC_MESSAGES/main.mo index 5b19d971..7a70c580 100644 Binary files a/locales/fr_FR/LC_MESSAGES/main.mo and b/locales/fr_FR/LC_MESSAGES/main.mo differ diff --git a/locales/fr_FR/LC_MESSAGES/main.po b/locales/fr_FR/LC_MESSAGES/main.po index 2b650b05..ed0b5386 100644 --- a/locales/fr_FR/LC_MESSAGES/main.po +++ b/locales/fr_FR/LC_MESSAGES/main.po @@ -1,8 +1,8 @@ msgid "" msgstr "" "Project-Id-Version: Flus\n" -"POT-Creation-Date: 2026-06-22 08:36+0200\n" -"PO-Revision-Date: 2026-06-22 08:36+0200\n" +"POT-Creation-Date: 2026-06-26 10:35+0200\n" +"PO-Revision-Date: 2026-06-26 10:35+0200\n" "Last-Translator: Marien Fressinaud \n" "Language-Team: \n" "Language: fr_FR\n" @@ -320,21 +320,21 @@ msgstr "[%s] Réinitialisation de votre mot de passe" msgid "[%s] Your account will be deleted soon due to inactivity" msgstr "[%s] Votre compte sera bientôt supprimé pour cause d'inactivité" -#: models/Collection.php:41 models/Group.php:30 +#: models/Collection.php:41 models/Group.php:30 models/Stream.php:32 msgid "The name is required." msgstr "Le nom est obligatoire." -#: models/Collection.php:45 models/Group.php:34 +#: models/Collection.php:45 models/Group.php:34 models/Stream.php:36 msgid "The name must be less than {max} characters." msgstr "Le nom ne doit pas faire plus de {max} caractères." #: models/Collection.php:139 models/Collection.php:226 -#: navigations/ReadingNavigation.php:35 +#: models/ReadLaterSource.php:20 navigations/ReadingNavigation.php:32 msgid "To read" msgstr "À lire" -#: models/Collection.php:150 models/Collection.php:228 -#: navigations/ReadingNavigation.php:42 +#: models/Collection.php:150 models/Collection.php:228 models/ReadSource.php:20 +#: navigations/ReadingNavigation.php:39 msgid "Links read" msgstr "Liens lus" @@ -343,7 +343,7 @@ msgid "Links never to read" msgstr "Liens à ne jamais lire" #: models/Collection.php:172 models/Collection.php:230 -#: navigations/ReadingNavigation.php:28 +#: navigations/ReadingNavigation.php:25 msgid "News" msgstr "Journal" @@ -375,6 +375,14 @@ msgstr "L’origine ne doit pas faire plus de {max} caractères." msgid "The content is required." msgstr "Le contenu est obligatoire." +#: models/ReadLaterSource.php:25 +msgid "Place here the links you want to consult later on." +msgstr "Placez ici les liens que vous souhaitez consulter plus tard." + +#: models/ReadSource.php:25 +msgid "Find here all the links you’ve marked as read." +msgstr "Retrouvez ici tous les liens que vous avez marqués comme lu." + #: models/Topic.php:31 msgid "The label is required." msgstr "Le nom est obligatoire." @@ -441,35 +449,35 @@ msgstr "" msgid "Account & data menu" msgstr "Menu compte & données" -#: navigations/AccountNavigation.php:31 +#: navigations/AccountNavigation.php:28 msgid "Overview" msgstr "Aperçu" -#: navigations/AccountNavigation.php:39 +#: navigations/AccountNavigation.php:36 msgid "Account validation" msgstr "Validation du compte" -#: navigations/AccountNavigation.php:48 +#: navigations/AccountNavigation.php:45 msgid "Mastodon" msgstr "Mastodon" -#: navigations/AccountNavigation.php:57 +#: navigations/AccountNavigation.php:54 msgid "OPML import" msgstr "Importation OPML" -#: navigations/AccountNavigation.php:65 +#: navigations/AccountNavigation.php:62 msgid "Data export" msgstr "Exportation des données" -#: navigations/AccountNavigation.php:72 +#: navigations/AccountNavigation.php:69 msgid "Credentials" msgstr "Identifiants" -#: navigations/AccountNavigation.php:79 +#: navigations/AccountNavigation.php:76 msgid "Sessions" msgstr "Sessions" -#: navigations/AccountNavigation.php:86 +#: navigations/AccountNavigation.php:83 msgid "Account deletion" msgstr "Suppression du compte" @@ -489,10 +497,22 @@ msgstr "Sécurité" msgid "Reading menu" msgstr "Menu lecture" -#: navigations/ReadingNavigation.php:51 +#: navigations/ReadingNavigation.php:48 msgid "Explore" msgstr "Explorer" +#: navigations/ReadingNavigation.php:57 +msgid "New stream" +msgstr "Nouveau fil" + +#: navigations/ReadingNavigation.php:74 +msgid "Create a stream to get started." +msgstr "Créez un fil pour commencer." + +#: navigations/ReadingNavigation.php:79 +msgid "Streams" +msgstr "Fils" + #: seeds.php:12 msgid "Business" msgstr "Business" @@ -604,10 +624,6 @@ msgstr "Info" msgid "Success" msgstr "Succès" -#: views/bookmarks/index.html.twig:30 -msgid "Place here the links you want to consult later on." -msgstr "Placez ici les liens que vous souhaitez consulter plus tard." - #: views/bookmarks/index.html.twig:45 views/collections/show.html.twig:309 msgid "Add a link" msgstr "Ajouter un lien" @@ -637,7 +653,7 @@ msgstr "aucun lien" #: views/collections/_collection.html.twig:26 #: views/collections/_collection_follow.html.twig:18 #: views/links/_pagination_count.html.twig:7 views/news/index.html.twig:26 -#: views/news/index.html.twig:132 +#: views/news/index.html.twig:132 views/streams/show.html.twig:101 #, php-format msgid "%s link" msgid_plural "%s links" @@ -657,7 +673,9 @@ msgstr "privée" #: views/collections/_collection.html.twig:52 #: views/collections/_collection_follow.html.twig:24 -#: views/collections/show.html.twig:96 +#: views/collections/show.html.twig:96 views/streams/show.html.twig:34 +#: views/streams/sources/_source.html.twig:34 +#: views/streams/sources/edit.html.twig:48 msgid "Publication frequency" msgstr "Fréquence de publication" @@ -690,7 +708,7 @@ msgstr "Comment voulez-vous nommer cette collection ?" #: views/collections/_form.html.twig:14 #: views/collections/_selector.html.twig:48 #: views/collections/groups/edit.html.twig:23 views/groups/edit.html.twig:14 -#: views/mastodon/show.html.twig:56 +#: views/mastodon/show.html.twig:56 views/streams/_form.html.twig:14 #, php-format msgid "(max. %d characters)" msgstr "(max. %d caractères)" @@ -723,10 +741,11 @@ msgstr "(max. %d caractères)" #: views/my/security/confirmation.html.twig:40 #: views/my/security/show.html.twig:31 views/my/security/show.html.twig:59 #: views/notes/edit.html.twig:24 views/passwords/edit.html.twig:31 -#: views/passwords/forgot.html.twig:43 views/registrations/new.html.twig:51 -#: views/registrations/new.html.twig:79 views/registrations/new.html.twig:113 -#: views/registrations/new.html.twig:151 views/sessions/new.html.twig:32 -#: views/sessions/new.html.twig:67 views/support/show.html.twig:41 +#: views/passwords/forgot.html.twig:43 views/registrations/new.html.twig:52 +#: views/registrations/new.html.twig:80 views/registrations/new.html.twig:114 +#: views/registrations/new.html.twig:152 views/sessions/new.html.twig:32 +#: views/sessions/new.html.twig:67 views/streams/_form.html.twig:20 +#: views/streams/_form.html.twig:51 views/support/show.html.twig:41 #: views/support/show.html.twig:67 msgid "Error:" msgstr "Erreur :" @@ -737,11 +756,11 @@ msgstr "Quelle est la raison d’être de cette collection ?" #: views/collections/_form.html.twig:45 #: views/links/collections/_new_note_form.html.twig:5 -#: views/my/security/show.html.twig:53 +#: views/my/security/show.html.twig:53 views/streams/_form.html.twig:45 msgid "(optional)" msgstr "(optionnel)" -#: views/collections/_form.html.twig:69 +#: views/collections/_form.html.twig:69 views/streams/_form.html.twig:69 msgid "You can format the description in Markdown." msgstr "Vous pouvez formater la description en Markdown." @@ -1003,6 +1022,7 @@ msgid "Change the illustration" msgstr "Changer l’illustration" #: views/collections/show.html.twig:88 +#: views/streams/sources/_source.html.twig:18 #, php-format msgid "Feed of %s website" msgstr "Flux du site %s" @@ -2015,14 +2035,14 @@ msgstr "" "Vous êtes sur le point de supprimer votre compte : vos données seront alors " "perdues. Cette action ne peut pas être annulée !" -#: views/my/account/deletion.html.twig:33 views/registrations/new.html.twig:105 +#: views/my/account/deletion.html.twig:33 views/registrations/new.html.twig:106 msgid "Your password" msgstr "Votre mot de passe" #: views/my/account/deletion.html.twig:63 #: views/my/security/confirmation.html.twig:65 #: views/my/security/show.html.twig:84 views/passwords/edit.html.twig:56 -#: views/registrations/new.html.twig:139 views/sessions/new.html.twig:95 +#: views/registrations/new.html.twig:140 views/sessions/new.html.twig:95 msgid "" "Show password as plain text. Note: this will visually expose your password." msgstr "" @@ -2132,7 +2152,7 @@ msgstr "" msgid "Your name" msgstr "Votre nom" -#: views/my/profile/edit.html.twig:14 views/registrations/new.html.twig:45 +#: views/my/profile/edit.html.twig:14 views/registrations/new.html.twig:46 #, php-format msgid "(public, max. %d characters)" msgstr "(public, max. %d caractères)" @@ -2174,7 +2194,7 @@ msgstr "Confirmer votre mot de passe" msgid "You can change your login details here." msgstr "Vous pouvez changer vos identifiants de connexion ici." -#: views/my/security/show.html.twig:26 views/registrations/new.html.twig:74 +#: views/my/security/show.html.twig:26 views/registrations/new.html.twig:75 msgid "Your email address" msgstr "Votre adresse courriel" @@ -2715,7 +2735,7 @@ msgstr "Vous êtes en train de modifier le mot de passe de %s." msgid "Your new password" msgstr "Votre nouveau mot de passe" -#: views/passwords/edit.html.twig:25 views/registrations/new.html.twig:107 +#: views/passwords/edit.html.twig:25 views/registrations/new.html.twig:108 msgid "(recommended at least 8 characters)" msgstr "(recommandé au moins 8 caractères)" @@ -2820,33 +2840,29 @@ msgstr "Vous n’avez encore partagé aucun lien." msgid "This user didn’t share any links yet." msgstr "Cet·te utilisateur·ice n’a pas encore partagé de lien." -#: views/read/index.html.twig:30 -msgid "Find here all the links you’ve marked as read." -msgstr "Retrouvez ici tous les liens que vous avez marqués comme lu." - #: views/registrations/new.html.twig:3 views/registrations/new.html.twig:11 -#: views/registrations/new.html.twig:23 +#: views/registrations/new.html.twig:24 msgid "Registration" msgstr "Inscription" -#: views/registrations/new.html.twig:27 +#: views/registrations/new.html.twig:28 #, php-format msgid "First month is free. Learn more about pricing." msgstr "" "Le premier mois est gratuit. En savoir plus sur les tarifs." -#: views/registrations/new.html.twig:43 +#: views/registrations/new.html.twig:44 msgid "What’s your name?" msgstr "Quel est votre nom ?" -#: views/registrations/new.html.twig:99 +#: views/registrations/new.html.twig:100 msgid "We only send emails necessary for the operation of the service." msgstr "" "Nous n’y enverrons que des courriels nécessaires au fonctionnement du " "service." -#: views/registrations/new.html.twig:157 +#: views/registrations/new.html.twig:158 #, php-format msgid "" "Please read the general terms and " @@ -2856,7 +2872,7 @@ msgstr "" "générales de services et d’utilisation pour vous assurer que celles-ci " "vous conviennent." -#: views/registrations/new.html.twig:173 +#: views/registrations/new.html.twig:174 msgid "" "I acknowledge that I have read and accepted the general terms and conditions " "of service and use" @@ -2864,11 +2880,11 @@ msgstr "" "Je reconnais avoir lu et accepté les conditions générales de services et " "d’utilisation" -#: views/registrations/new.html.twig:185 +#: views/registrations/new.html.twig:186 msgid "Sign up" msgstr "S’inscrire" -#: views/registrations/new.html.twig:193 +#: views/registrations/new.html.twig:194 msgid "Already an account?" msgstr "Déjà un compte ?" @@ -2889,6 +2905,62 @@ msgstr "Mot de passe oublié ?" msgid "No account yet?" msgstr "Pas encore de compte ?" +#: views/streams/_form.html.twig:12 +msgid "What’s the name of the stream?" +msgstr "Quelle est le nom du fil ?" + +#: views/streams/_form.html.twig:43 +msgid "What’s the purpose of the stream?" +msgstr "Quelle est la raison d’être du fil ?" + +#: views/streams/new.html.twig:9 +msgid "Create the stream" +msgstr "Créer le fil" + +#: views/streams/show.html.twig:41 views/streams/sources/_source.html.twig:22 +msgid "By" +msgstr "Par" + +#: views/streams/show.html.twig:59 +msgid "Manage sources" +msgstr "Gérer les sources" + +#: views/streams/sources/_source.html.twig:45 +msgid "Add" +msgstr "Ajouter" + +#: views/streams/sources/_source.html.twig:53 +msgid "Remove" +msgstr "Retirer" + +#: views/streams/sources/edit.html.twig:3 +#, php-format +msgid "Sources of %s" +msgstr "Sources de %s" + +#: views/streams/sources/edit.html.twig:20 +msgid "Add or remove sources to customise your stream." +msgstr "Ajoutez ou retirez des sources pour personnaliser votre fil." + +#: views/streams/sources/edit.html.twig:27 +msgid "Sources to add" +msgstr "Sources à ajouter" + +#: views/streams/sources/edit.html.twig:41 +msgid "Sources of the stream" +msgstr "Sources du fil" + +#: views/streams/sources/edit.html.twig:45 +#, php-format +msgid "%d source" +msgid_plural "%d sources" +msgstr[0] "%d source" +msgstr[1] "%d sources" + +#: views/streams/sources/edit.html.twig:67 +msgid "Continue to the stream" +msgstr "Continuer vers le fil" + #: views/support/show.html.twig:18 msgid "" "If you have a question, want to make suggestions, or if you’re facing an " diff --git a/src/Router.php b/src/Router.php index baebd3db..24db9c9a 100644 --- a/src/Router.php +++ b/src/Router.php @@ -214,6 +214,24 @@ public static function load(): \Minz\Router $router->addRoute('GET', '/feeds.xsl', 'Feeds#xsl', 'feeds xsl'); + // Streams + $router->addRoute('GET', '/streams/new', 'Streams#new', 'new stream'); + $router->addRoute('POST', '/streams/new', 'Streams#create', 'create stream'); + $router->addRoute('GET', '/streams/:id', 'Streams#show', 'stream'); + $router->addRoute('GET', '/streams/:id/sources/edit', 'streams/Sources#edit', 'edit stream sources'); + $router->addRoute( + 'POST', + '/streams/:id/sources/:source_id/add', + 'streams/Sources#add', + 'add stream source', + ); + $router->addRoute( + 'POST', + '/streams/:id/sources/:source_id/remove', + 'streams/Sources#remove', + 'remove stream source', + ); + // Explore $router->addRoute('GET', '/explore', 'Explore#show', 'explore'); $router->addRoute('GET', '/discovery', 'Explore#discovery', 'discovery'); diff --git a/src/assets/stylesheets/application.css b/src/assets/stylesheets/application.css index 1dbbe966..8e4d6680 100644 --- a/src/assets/stylesheets/application.css +++ b/src/assets/stylesheets/application.css @@ -12,6 +12,7 @@ @import url('./components/links.css'); @import url('./components/notepads.css'); @import url('./components/shares.css'); +@import url('./components/sources.css'); @import url('./components/threads.css'); @import url('./components/topics.css'); @import url('./components/turbo.css'); diff --git a/src/assets/stylesheets/components/sources.css b/src/assets/stylesheets/components/sources.css new file mode 100644 index 00000000..0dda00b2 --- /dev/null +++ b/src/assets/stylesheets/components/sources.css @@ -0,0 +1,56 @@ +.sources { + position: relative; +} + +.sources__selector { + position: relative; +} + +.sources__selector-header { + position: sticky; + top: 0; + + padding-top: var(--space-smaller); + padding-bottom: var(--space-smaller); + + background-color: var(--color-background); +} + +.sources__submit { + position: sticky; + right: 0; + bottom: calc(var(--layout-header-height) + var(--space-smaller)); + left: 0; + + max-width: var(--width-large); + margin-right: auto; + margin-left: auto; + padding: var(--space-medium); + + text-align: right; + + background-color: var(--color-accent-bg-subtle); + border-radius: var(--border-radius); +} + +@media (min-width: 800px) { + .sources__submit { + bottom: var(--space-smaller); + } +} + +.source { + padding: var(--space-medium); + + background-color: var(--color-base); + border: 1px solid var(--color-grey-line); + border-radius: var(--border-radius); +} + +.source__image { + width: 64px; + max-width: none; + + background-color: var(--color-base); + border-radius: var(--border-radius); +} diff --git a/src/auth/Access.php b/src/auth/Access.php index 98d881f8..d1bc9da3 100644 --- a/src/auth/Access.php +++ b/src/auth/Access.php @@ -39,6 +39,8 @@ public static function can(?models\User $user, string $action, object $subject): $access_class = ImportationsAccess::class; } elseif ($subject instanceof models\Session) { $access_class = SessionsAccess::class; + } elseif ($subject instanceof models\Stream) { + $access_class = StreamsAccess::class; } else { throw new \InvalidArgumentException("{$subject_class} subject is not supported"); } diff --git a/src/auth/StreamsAccess.php b/src/auth/StreamsAccess.php new file mode 100644 index 00000000..4f1cfe2b --- /dev/null +++ b/src/auth/StreamsAccess.php @@ -0,0 +1,27 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class StreamsAccess +{ + public static function canView(?models\User $user, models\Stream $stream): bool + { + return $user && $user->id === $stream->user_id; + } + + public static function canUpdate(?models\User $user, models\Stream $stream): bool + { + return $user && $user->id === $stream->user_id; + } + + public static function canDelete(?models\User $user, models\Stream $stream): bool + { + return $user && $user->id === $stream->user_id; + } +} diff --git a/src/controllers/Streams.php b/src/controllers/Streams.php new file mode 100644 index 00000000..6910650a --- /dev/null +++ b/src/controllers/Streams.php @@ -0,0 +1,94 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class Streams extends BaseController +{ + /** + * @response 200 + * + * @throws auth\MissingCurrentUserError + * If the user is not connected. + */ + public function new(): Response + { + auth\CurrentUser::require(); + + $form = new forms\streams\Stream(); + + return Response::ok('streams/new.html.twig', [ + 'form' => $form, + ]); + } + + /** + * @request_param string name + * @request_param string description + * @request_param string csrf_token + * + * @response 400 + * If at least one of the parameters is invalid. + * @response 302 /streams/:id/sources/edit + * On success. + * + * @throws auth\MissingCurrentUserError + * If the user is not connected. + */ + public function create(Request $request): Response + { + $user = auth\CurrentUser::require(); + + $stream = $user->initStream(); + $form = new forms\streams\Stream(model: $stream); + + $form->handleRequest($request); + + if (!$form->validate()) { + return Response::badRequest('streams/new.html.twig', [ + 'form' => $form, + ]); + } + + $stream = $form->model(); + $stream->save(); + + return Response::redirect('edit stream sources', ['id' => $stream->id]); + } + + /** + * @request_param string id + * + * @response 200 + * On success. + * + * @throws auth\MissingCurrentUserError + * If the user is not connected. + * @throws \Minz\Errors\MissingRecordError + * If the stream doesn't exist. + * @throws auth\AccessDeniedError + * If the user cannot view the stream. + */ + public function show(Request $request): Response + { + $user = auth\CurrentUser::require(); + $stream = models\Stream::requireFromRequest($request); + + auth\Access::require($user, 'view', $stream); + + $stream_view = models\StreamView::buildFromRequest($stream, $request); + + return Response::ok('streams/show.html.twig', [ + 'stream_view' => $stream_view, + ]); + } +} diff --git a/src/controllers/streams/Sources.php b/src/controllers/streams/Sources.php new file mode 100644 index 00000000..04dd7e13 --- /dev/null +++ b/src/controllers/streams/Sources.php @@ -0,0 +1,138 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class Sources extends BaseController +{ + /** + * @request_param string id + * + * @response 200 + * On success. + * + * @throws auth\MissingCurrentUserError + * If the user is not connected. + * @throws \Minz\Errors\MissingRecordError + * If the stream doesn't exist. + * @throws auth\AccessDeniedError + * If the user cannot update the stream. + */ + public function edit(Request $request): Response + { + $user = auth\CurrentUser::require(); + $stream = models\Stream::requireFromRequest($request); + + auth\Access::require($user, 'update', $stream); + + $suggested_sources = $user->followedCollections(['number_links']); + $suggested_sources = utils\Sorter::localeSort($suggested_sources, 'name'); + + $existing_sources = $stream->sources(); + + $suggested_sources = array_udiff( + $suggested_sources, + $existing_sources, + function (models\Collection $source1, models\Collection $source2): int { + return $source1->id <=> $source2->id; + }, + ); + + return Response::ok('streams/sources/edit.html.twig', [ + 'stream' => $stream, + 'suggested_sources' => $suggested_sources, + ]); + } + + /** + * @request_param string id + * @request_param string source_id + * @request_param string csrf_token + * + * @response 302 /streams/:id/sources/edit + * @flash notification.error + * If at least one of the parameters is invalid. + * @response 302 /streams/:id/sources/edit + * On success. + * + * @throws auth\MissingCurrentUserError + * If the user is not connected. + * @throws \Minz\Errors\MissingRecordError + * If the stream or the source don't exist. + * @throws auth\AccessDeniedError + * If the user cannot update the stream or cannot view the source. + */ + public function add(Request $request): Response + { + $user = auth\CurrentUser::require(); + + $stream = models\Stream::requireFromRequest($request); + $source = models\Collection::requireFromRequest($request, parameter: 'source_id'); + + auth\Access::require($user, 'update', $stream); + auth\Access::require($user, 'view', $source); + + $form = new forms\streams\AddSource(); + $form->handleRequest($request); + + if (!$form->validate()) { + utils\Notification::error($form->error('@base')); + return Response::redirect('edit stream sources', ['id' => $stream->id]); + } + + $stream->addSource($source); + + return Response::redirect('edit stream sources', ['id' => $stream->id]); + } + + /** + * @request_param string id + * @request_param string source_id + * @request_param string csrf_token + * + * @response 302 /streams/:id/sources/edit + * @flash notification.error + * If at least one of the parameters is invalid. + * @response 302 /streams/:id/sources/edit + * On success. + * + * @throws auth\MissingCurrentUserError + * If the user is not connected. + * @throws \Minz\Errors\MissingRecordError + * If the stream or the source don't exist. + * @throws auth\AccessDeniedError + * If the user cannot update the stream. + */ + public function remove(Request $request): Response + { + $user = auth\CurrentUser::require(); + + $stream = models\Stream::requireFromRequest($request); + $source = models\Collection::requireFromRequest($request, parameter: 'source_id'); + + auth\Access::require($user, 'update', $stream); + + $form = new forms\streams\RemoveSource(); + $form->handleRequest($request); + + if (!$form->validate()) { + utils\Notification::error($form->error('@base')); + return Response::redirect('edit stream sources', ['id' => $stream->id]); + } + + $stream->removeSource($source); + + return Response::redirect('edit stream sources', ['id' => $stream->id]); + } +} diff --git a/src/forms/streams/AddSource.php b/src/forms/streams/AddSource.php new file mode 100644 index 00000000..c74a6f4e --- /dev/null +++ b/src/forms/streams/AddSource.php @@ -0,0 +1,13 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class AddSource extends BaseForm +{ +} diff --git a/src/forms/streams/RemoveSource.php b/src/forms/streams/RemoveSource.php new file mode 100644 index 00000000..efceec85 --- /dev/null +++ b/src/forms/streams/RemoveSource.php @@ -0,0 +1,13 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class RemoveSource extends BaseForm +{ +} diff --git a/src/forms/streams/Stream.php b/src/forms/streams/Stream.php new file mode 100644 index 00000000..b79918db --- /dev/null +++ b/src/forms/streams/Stream.php @@ -0,0 +1,26 @@ + + * + * @author Marien Fressinaud + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class Stream extends BaseForm +{ + #[Form\Field(transform: 'trim')] + public string $name = ''; + + #[Form\Field(transform: 'trim')] + public string $description = ''; + + public int $stream_name_max_length = models\Stream::NAME_MAX_LENGTH; +} diff --git a/src/migrations/Migration202606240001CreateStreams.php b/src/migrations/Migration202606240001CreateStreams.php new file mode 100644 index 00000000..a2910b04 --- /dev/null +++ b/src/migrations/Migration202606240001CreateStreams.php @@ -0,0 +1,59 @@ +exec(<<<'SQL' + CREATE TABLE streams ( + id TEXT PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + is_public BOOLEAN NOT NULL DEFAULT false, + image_filename TEXT, + + user_id TEXT REFERENCES users ON DELETE CASCADE ON UPDATE CASCADE + ); + + CREATE INDEX idx_streams_user_id ON streams(user_id); + CREATE INDEX idx_streams_image_filename ON streams(image_filename) WHERE image_filename IS NOT NULL; + + CREATE TABLE streams_to_follows ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + stream_id TEXT REFERENCES streams ON DELETE CASCADE ON UPDATE CASCADE, + follow_id BIGINT REFERENCES followed_collections ON DELETE CASCADE ON UPDATE CASCADE + ); + + CREATE UNIQUE INDEX idx_streams_to_follows ON streams_to_follows(stream_id, follow_id); + CREATE INDEX idx_streams_to_follows_follow_id ON streams_to_follows(follow_id); + SQL); + + return true; + } + + public function rollback(): bool + { + $database = \Minz\Database::get(); + + $database->exec(<<<'SQL' + DROP INDEX idx_streams_to_follows; + DROP INDEX idx_streams_to_follows_follow_id; + + DROP TABLE streams_to_follows; + + DROP INDEX idx_streams_user_id; + DROP INDEX idx_streams_image_filename; + + DROP TABLE streams; + SQL); + + return true; + } +} diff --git a/src/models/FollowedCollection.php b/src/models/FollowedCollection.php index 381e8960..b02716b1 100644 --- a/src/models/FollowedCollection.php +++ b/src/models/FollowedCollection.php @@ -51,4 +51,14 @@ public function __construct(string $user_id, string $collection_id) $this->user_id = $user_id; $this->collection_id = $collection_id; } + + public static function findOrCreate(User $user, Collection $collection): self + { + return self::findOrCreateBy([ + 'user_id' => $user->id, + 'collection_id' => $collection->id, + ], [ + 'time_filter' => 'normal', + ]); + } } diff --git a/src/models/Stream.php b/src/models/Stream.php new file mode 100644 index 00000000..3c8d2aea --- /dev/null +++ b/src/models/Stream.php @@ -0,0 +1,157 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +#[Database\Table(name: 'streams')] +class Stream +{ + use Database\Recordable; + use Database\Resource; + use Validable; + use utils\Memoizer; + + public const NAME_MAX_LENGTH = 100; + + #[Database\Column] + public string $id; + + #[Database\Column] + public \DateTimeImmutable $created_at; + + #[Database\Column] + #[Validable\Presence( + message: new Translatable('The name is required.'), + )] + #[Validable\Length( + max: self::NAME_MAX_LENGTH, + message: new Translatable('The name must be less than {max} characters.'), + )] + public string $name = ''; + + #[Database\Column] + public string $description = ''; + + #[Database\Column] + public bool $is_public = false; + + #[Database\Column] + public ?string $image_filename = null; + + #[Database\Column] + public string $user_id; + + public function __construct(User $user) + { + $this->id = \Minz\Random::timebased(); + $this->setOwner($user); + } + + public function name(): string + { + return $this->name; + } + + public function description(): string + { + return $this->description; + } + + /** + * Return the description as HTML (from Markdown). + */ + public function descriptionAsHtml(): string + { + $markdown = new utils\MiniMarkdown(context_user: $this->owner()); + return $markdown->text($this->description()); + } + + public function url(): string + { + return \Minz\Url::absoluteFor('stream', ['id' => $this->id]); + } + + public function owner(): User + { + return $this->memoize('owner', function (): User { + return User::require($this->user_id); + }); + } + + public function setOwner(User $user): void + { + $this->user_id = $user->id; + $this->memoizeValue('owner', $user); + } + + /** + * @return Collection[] + */ + public function sources(): array + { + return $this->memoize('sources', function (): array { + $collections = Collection::listByStream($this); + return utils\Sorter::localeSort($collections, 'name'); + }); + } + + public function hasSource(Collection $source): bool + { + return StreamToFollow::find($this, $source) !== null; + } + + public function addSource(Collection $source): void + { + StreamToFollow::findOrCreate($this, $source); + $this->unmemoize('sources'); + } + + public function removeSource(Collection $source): void + { + $stream_to_follow = StreamToFollow::find($this, $source); + if ($stream_to_follow) { + $stream_to_follow->remove(); + } + $this->unmemoize('sources'); + } + + public function publicationFrequencyPerYear(): int + { + $sources = $this->sources(); + return array_sum(array_column($sources, 'publication_frequency_per_year')); + } + + /** + * @param array{ + * context_user?: ?User, + * at?: \DateTimeImmutable, + * days?: int, + * } $options + * + * @return Link[] + */ + public function links(array $options = []): array + { + return Link::listByStream($this, $options); + } + + /** + * Return a tag URI that can be used as Atom id + * + * @see https://www.rfc-editor.org/rfc/rfc4151.txt + */ + public function tagUri(): string + { + $host = \App\Configuration::$url_options['host']; + $date = $this->created_at->format('Y-m-d'); + return "tag:{$host},{$date}:streams/{$this->id}"; + } +} diff --git a/src/models/StreamToFollow.php b/src/models/StreamToFollow.php new file mode 100644 index 00000000..a4c1e5aa --- /dev/null +++ b/src/models/StreamToFollow.php @@ -0,0 +1,68 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +#[Database\Table(name: 'streams_to_follows')] +class StreamToFollow +{ + use Database\Recordable; + + #[Database\Column] + public int $id; + + #[Database\Column] + public \DateTimeImmutable $created_at; + + #[Database\Column] + public string $stream_id; + + #[Database\Column] + public int $follow_id; + + public static function find(Stream $stream, Collection $source): ?self + { + $sql = << $stream->owner()->id, + 'source_id' => $source->id, + 'stream_id' => $stream->id, + ]; + + $database = \Minz\Database::get(); + $statement = $database->prepare($sql); + $statement->execute($parameters); + + $result = $statement->fetch(); + if (is_array($result)) { + return self::fromDatabaseRow($result); + } else { + return null; + } + } + + public static function findOrCreate(Stream $stream, Collection $source): self + { + $follow = FollowedCollection::findOrCreate($stream->owner(), $source); + + return self::findOrCreateBy([ + 'stream_id' => $stream->id, + 'follow_id' => $follow->id, + ]); + } +} diff --git a/src/models/StreamView.php b/src/models/StreamView.php new file mode 100644 index 00000000..5ad92bf9 --- /dev/null +++ b/src/models/StreamView.php @@ -0,0 +1,40 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class StreamView +{ + public function __construct( + public readonly Stream $stream, + public readonly \DateTimeImmutable $at, + public readonly int $days, + ) { + } + + public static function buildFromRequest(Stream $stream, Request $request): self + { + $today = \Minz\Time::now(); + $at = $request->parameters->getDatetime('at', $today, 'Y-m-d'); + $days = $request->parameters->getInteger('days', 1); + + return new self($stream, $at, $days); + } + + public function linksTimeline(?User $context_user = null): utils\LinksTimeline + { + $links = $this->stream->links([ + 'context_user' => $context_user, + 'at' => $this->at, + 'days' => $this->days, + ]); + + return new utils\LinksTimeline($links); + } +} diff --git a/src/models/User.php b/src/models/User.php index ca450816..a1b63a20 100644 --- a/src/models/User.php +++ b/src/models/User.php @@ -206,6 +206,11 @@ public function isBetaEnabled(): bool return FeatureFlag::isEnabled('beta', $this->id); } + public function isAlphaEnabled(): bool + { + return FeatureFlag::isEnabled('alpha', $this->id); + } + public function isOwning(Link|Collection $object): bool { return $object->user_id === $this->id; @@ -790,6 +795,27 @@ private function unmemoizeUrlStatusesOfLinks(array $links): void } } + public function initStream(): Stream + { + return new Stream($this); + } + + /** + * Return the list of streams owned by the user. + * + * @return Stream[] + */ + public function streams(): array + { + return $this->memoize('streams', function (): array { + $streams = Stream::listBy([ + 'user_id' => $this->id, + ]); + + return utils\Sorter::localeSort($streams, 'name'); + }); + } + /** * Set the user password. */ diff --git a/src/models/dao/Collection.php b/src/models/dao/Collection.php index 4abc7b6e..b58d713c 100644 --- a/src/models/dao/Collection.php +++ b/src/models/dao/Collection.php @@ -2,6 +2,7 @@ namespace App\models\dao; +use App\models; use Minz\Database; /** @@ -395,6 +396,33 @@ public static function listComputedSharedByUserIdTo( return self::fromDatabaseRows($statement->fetchAll()); } + /** + * List the collections present in the given stream. + * + * @return self[] + */ + public static function listByStream(models\Stream $stream): array + { + $parameters = [ + ':stream_id' => $stream->id, + ]; + + $sql = <<prepare($sql); + $statement->execute($parameters); + + return self::fromDatabaseRows($statement->fetchAll()); + } + /** * Return whether the link is in a collection owned by the given user or not. */ diff --git a/src/models/dao/Link.php b/src/models/dao/Link.php index cf08d4ef..1e4d9f17 100644 --- a/src/models/dao/Link.php +++ b/src/models/dao/Link.php @@ -636,6 +636,89 @@ public static function countRead(models\User $user): int return intval($statement->fetchColumn()); } + /** + * Return the list of links of the given stream. + * + * @param array{ + * context_user?: ?models\User, + * at?: \DateTimeImmutable, + * days?: int, + * } $options + * + * @return models\Link[] + */ + public static function listByStream(models\Stream $stream, array $options): array + { + $default_options = [ + 'context_user' => null, + 'at' => \Minz\Time::now(), + 'days' => 1, + ]; + $options = array_merge($default_options, $options); + + $parameters = [ + ':stream_id' => $stream->id, + ]; + + // Calculate the time span interval to get the links. + $start = $options['at']->modify('00:00:00'); + $end = $start->modify('23:59:59'); + + $days = min(7, max(1, $options['days'])); + $days = $days - 1; // the actual interval is already of 1 day. + if ($days > 0) { + $start = $start->modify("-{$days} days"); + } + + $parameters[':at_start'] = $start->format(Database\Column::DATETIME_FORMAT); + $parameters[':at_end'] = $end->format(Database\Column::DATETIME_FORMAT); + + // Create the visibility clause, adapted if a context user is passed. + $visibility_clause = 'AND (l.is_hidden = false AND c.is_public = true)'; + + if ($options['context_user']) { + $parameters[':user_id'] = $options['context_user']->id; + + $visibility_clause = <<= :at_start AND lc.created_at <= :at_end + + {$visibility_clause} + + ORDER BY published_at DESC, l.id + SQL; + + $database = Database::get(); + $statement = $database->prepare($sql); + $statement->execute($parameters); + + return self::fromDatabaseRows($statement->fetchAll()); + } + public function numberCollectionsForUser(\App\models\User $user): int { $sql = <<isValidated()) { $account_items[] = new Item( - 'account validation', - \Minz\Url::for('account validation'), - 'check', - TwigExtension::translate('Account validation'), + label: TwigExtension::translate('Account validation'), + key: 'account validation', + url: \Minz\Url::for('account validation'), + icon: 'check', ); } if (!$current_user->isBlocked()) { $account_items[] = new Item( - 'mastodon', - \Minz\Url::for('mastodon'), - 'mastodon', - TwigExtension::translate('Mastodon'), + label: TwigExtension::translate('Mastodon'), + key: 'mastodon', + url: \Minz\Url::for('mastodon'), + icon: 'mastodon', ); } if (!$current_user->isBlocked()) { $data_items[] = new Item( - 'opml', - \Minz\Url::for('opml'), - 'upload', - TwigExtension::translate('OPML import'), + label: TwigExtension::translate('OPML import'), + key: 'opml', + url: \Minz\Url::for('opml'), + icon: 'upload', ); } $data_items[] = new Item( - 'exportation', - \Minz\Url::for('exportation'), - 'cloud-download', - TwigExtension::translate('Data export'), + label: TwigExtension::translate('Data export'), + key: 'exportation', + url: \Minz\Url::for('exportation'), + icon: 'cloud-download', ); $security_items[] = new Item( - 'security', - \Minz\Url::for('security'), - 'key', - TwigExtension::translate('Credentials'), + label: TwigExtension::translate('Credentials'), + key: 'security', + url: \Minz\Url::for('security'), + icon: 'key', ); $security_items[] = new Item( - 'sessions', - \Minz\Url::for('sessions'), - 'session', - TwigExtension::translate('Sessions'), + label: TwigExtension::translate('Sessions'), + key: 'sessions', + url: \Minz\Url::for('sessions'), + icon: 'session', ); $security_items[] = new Item( - 'account deletion', - \Minz\Url::for('account deletion'), - 'trash', - TwigExtension::translate('Account deletion'), + label: TwigExtension::translate('Account deletion'), + key: 'account deletion', + url: \Minz\Url::for('account deletion'), + icon: 'trash', ); return [ diff --git a/src/navigations/BaseNavigation.php b/src/navigations/BaseNavigation.php index da608d17..35c4a553 100644 --- a/src/navigations/BaseNavigation.php +++ b/src/navigations/BaseNavigation.php @@ -27,7 +27,7 @@ public function currentLabel(): string } foreach ($items as $item) { - if ($this->isCurrent($item)) { + if ($item instanceof Item && $this->isCurrent($item)) { return $item->label; } } diff --git a/src/navigations/Item.php b/src/navigations/Item.php index 582dca2a..a7f7a73c 100644 --- a/src/navigations/Item.php +++ b/src/navigations/Item.php @@ -9,10 +9,10 @@ class Item extends Element { public function __construct( + public readonly string $label, public readonly string $key, public readonly string $url, - public readonly string $icon, - public readonly string $label, + public readonly string $icon = '', ) { } diff --git a/src/navigations/ItemAction.php b/src/navigations/ItemAction.php new file mode 100644 index 00000000..84e24fd6 --- /dev/null +++ b/src/navigations/ItemAction.php @@ -0,0 +1,22 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class ItemAction extends Element +{ + public function __construct( + public readonly string $label, + public readonly string $url, + public readonly string $icon, + ) { + } + + public function is(string $type): bool + { + return $type === 'action'; + } +} diff --git a/src/navigations/ItemGroup.php b/src/navigations/ItemGroup.php index a291bc08..883624b6 100644 --- a/src/navigations/ItemGroup.php +++ b/src/navigations/ItemGroup.php @@ -10,8 +10,9 @@ class ItemGroup extends Element { public function __construct( public readonly string $label, - /** @var Item[] */ + /** @var array */ public readonly array $items, + public readonly ?ItemAction $action = null, ) { } diff --git a/src/navigations/ItemPlaceholder.php b/src/navigations/ItemPlaceholder.php new file mode 100644 index 00000000..2297f311 --- /dev/null +++ b/src/navigations/ItemPlaceholder.php @@ -0,0 +1,20 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class ItemPlaceholder extends Element +{ + public function __construct( + public readonly string $label, + ) { + } + + public function is(string $type): bool + { + return $type === 'placeholder'; + } +} diff --git a/src/navigations/ReadingNavigation.php b/src/navigations/ReadingNavigation.php index 4ec820f5..1fb91f06 100644 --- a/src/navigations/ReadingNavigation.php +++ b/src/navigations/ReadingNavigation.php @@ -22,33 +22,63 @@ public function elements(): array $elements = [ new Item( - 'news', - \Minz\Url::for('news'), - 'news', - TwigExtension::translate('News'), + label: TwigExtension::translate('News'), + key: 'news', + url: \Minz\Url::for('news'), + icon: 'news', ), new Item( - 'bookmarks', - \Minz\Url::for('bookmarks'), - 'bookmark', - TwigExtension::translate('To read'), + label: TwigExtension::translate('To read'), + key: 'bookmarks', + url: \Minz\Url::for('bookmarks'), + icon: 'bookmark', ), new Item( - 'read', - \Minz\Url::for('read list'), - 'check', - TwigExtension::translate('Links read'), + label: TwigExtension::translate('Links read'), + key: 'read', + url: \Minz\Url::for('read list'), + icon: 'check', ), ]; if ($current_user->isBetaEnabled()) { $elements[] = new Item( - 'explore', - \Minz\Url::for('explore'), - 'compass', - TwigExtension::translate('Explore'), + label: TwigExtension::translate('Explore'), + key: 'explore', + url: \Minz\Url::for('explore'), + icon: 'compass', + ); + } + + if ($current_user->isAlphaEnabled()) { + $new_stream_action = new ItemAction( + label: TwigExtension::translate('New stream'), + url: \Minz\Url::for('new stream'), + icon: 'plus', + ); + + $stream_items = []; + + foreach ($current_user->streams() as $stream) { + $stream_items[] = new Item( + label: $stream->name, + key: $stream->id, + url: \Minz\Url::for('stream', ['id' => $stream->id]), + ); + } + + if (count($stream_items) === 0) { + $stream_items[] = new ItemPlaceholder( + TwigExtension::translate('Create a stream to get started.'), + ); + } + + $elements[] = new ItemGroup( + label: TwigExtension::translate('Streams'), + items: $stream_items, + action: $new_stream_action, ); } diff --git a/src/schema.sql b/src/schema.sql index c39b5509..fa513b05 100644 --- a/src/schema.sql +++ b/src/schema.sql @@ -232,6 +232,31 @@ CREATE TABLE followed_collections ( CREATE UNIQUE INDEX idx_followed_collections ON followed_collections(user_id, collection_id); +CREATE TABLE streams ( + id TEXT PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + is_public BOOLEAN NOT NULL DEFAULT false, + image_filename TEXT, + + user_id TEXT REFERENCES users ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX idx_streams_user_id ON streams(user_id); +CREATE INDEX idx_streams_image_filename ON streams(image_filename) WHERE image_filename IS NOT NULL; + +CREATE TABLE streams_to_follows ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + stream_id TEXT REFERENCES streams ON DELETE CASCADE ON UPDATE CASCADE, + follow_id BIGINT REFERENCES followed_collections ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX idx_streams_to_follows ON streams_to_follows(stream_id, follow_id); +CREATE INDEX idx_streams_to_follows_follow_id ON streams_to_follows(follow_id); + CREATE TABLE collection_shares ( id BIGSERIAL PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL, diff --git a/src/views/layouts/_sidenav_element.html.twig b/src/views/layouts/_sidenav_element.html.twig index 26f8ea3e..197ace01 100644 --- a/src/views/layouts/_sidenav_element.html.twig +++ b/src/views/layouts/_sidenav_element.html.twig @@ -1,18 +1,44 @@ {% if element.is('group') %}
-

{{ element.label }}

+ {% if element.action %} +
+

{{ element.label }}

+ + +
+ {% else %} +

{{ element.label }}

+ {% endif %} {% for item in element.items %} {{ include('layouts/_sidenav_element.html.twig', { element: item }) }} {% endfor %}
+{% elseif element.is('placeholder') %} +

+ {{ element.label }} +

{% else %} - {{ icon(element.icon) }} + {% if element.icon %} + {{ icon(element.icon) }} + {% endif %} + {{ element.label }} {% endif %} diff --git a/src/views/streams/_form.html.twig b/src/views/streams/_form.html.twig new file mode 100644 index 00000000..504a6c0a --- /dev/null +++ b/src/views/streams/_form.html.twig @@ -0,0 +1,80 @@ +
+ {{ include('alerts/_error.html.twig', { message: form.error('@base') }) }} + +
+ + + {% if form.isInvalid('name') %} +

+ {{ t('Error:') }} + {{ form.error('name') }} +

+ {% endif %} + + +
+ +
+ + + {% if form.isInvalid('description') %} +

+ {{ t('Error:') }} + {{ form.error('description') }} +

+ {% endif %} + + + +

+ {{ t('You can format the description in Markdown.') }} +

+
+ +
+ +
+ + +
diff --git a/src/views/streams/new.html.twig b/src/views/streams/new.html.twig new file mode 100644 index 00000000..99a6fd0d --- /dev/null +++ b/src/views/streams/new.html.twig @@ -0,0 +1,11 @@ +{% extends 'layouts/modal.html.twig' %} + +{% block title %}{{ t('New stream') }}{% endblock %} + +{% block body %} + {{ include('streams/_form.html.twig', { + form: form, + action: url('create stream'), + submit_label: t('Create the stream'), + }) }} +{% endblock %} diff --git a/src/views/streams/show.html.twig b/src/views/streams/show.html.twig new file mode 100644 index 00000000..34ea8333 --- /dev/null +++ b/src/views/streams/show.html.twig @@ -0,0 +1,123 @@ +{% extends 'layouts/application.html.twig' %} + +{% set stream = stream_view.stream %} + +{% block title %}{{ stream.name }}{% endblock %} +{% block canonical %}{{ url_full('stream', { id: stream.id }) }}{% endblock %} + +{% block layout_header %} + {{ include('layouts/_header.html.twig', { page: 'stream', tab: 'reading' }) }} +{% endblock %} + +{% block layout_sidenav %} + {{ include('layouts/_sidenav.html.twig', { navigation: reading_navigation(current: stream.id) }) }} +{% endblock %} + +{% block back %} + {{ include('layouts/_back.html.twig', { title: stream.name, reset: true }) }} +{% endblock %} + +{% block body %} +
+
+ +
+ +
+

{{ stream.name }}

+ +

+ + {{ icon('frequency') }} + {{ stream.publicationFrequencyPerYear | format_publication_frequency }} + +

+ +

+ {{ t('By') }} + + + {{- stream.owner.username -}} + +

+ + {% if stream.description %} +
+ {{ stream.descriptionAsHtml | raw }} +
+ {% endif %} +
+
+ + {% if can(app.user, 'update', stream) %} + + {% endif %} + + {% set links_timeline = stream_view.linksTimeline(context_user: app.user) %} + + {% if not links_timeline.empty %} +
+ {% for date_group in links_timeline.datesGroups %} +
+
+

+ {{ d(date_group.date, 'dd MMMM') }} +

+ +
+
+ +
+ {% set links = date_group.links %} + + {% if links %} +
+ {% for link in date_group.links %} + {{ include('links/_link.html.twig', { + link: link, + display_read_later: 'auto', + display_mark_as_read: 'auto', + storing_must_mark_as_read: true, + })}} + {% endfor %} +
+ {% endif %} + + {% for source_group in date_group.sourceGroups %} +
+
+

+ {{ source_group.source.name() }} +

+ + + {{ t('%s link', '%s links', source_group.links | length, [source_group.links | length]) }} + +
+ +
+ {% for link in source_group.links %} + {{ include('links/_link.html.twig', { + link: link, + display_read_later: 'auto', + display_mark_as_read: 'auto', + storing_must_mark_as_read: true, + }) }} + {% endfor %} +
+
+ {% endfor %} +
+
+ {% endfor %} +
+ {% endif %} +{% endblock %} diff --git a/src/views/streams/sources/_source.html.twig b/src/views/streams/sources/_source.html.twig new file mode 100644 index 00000000..0b73e35e --- /dev/null +++ b/src/views/streams/sources/_source.html.twig @@ -0,0 +1,59 @@ +
+
+ + +
+

+ + {{ source.name }} + +

+ +

+ {% if source.isFeed %} + + {{ source.feedWebsiteHost }} + + {% else %} + {{ t('By') }} + {%- for user in source.publishers -%} + {% if not loop.first %}, {% endif %} + + + {{- user.username -}} + + {%- endfor -%} + {% endif %} +

+ +

+ + {{ icon('frequency') }} + {{ source.publication_frequency_per_year | format_publication_frequency }} + +

+
+
+ + {% if action === 'add' %} +
+ + + +
+ {% elseif action === 'remove' %} +
+ + + +
+ {% endif %} +
diff --git a/src/views/streams/sources/edit.html.twig b/src/views/streams/sources/edit.html.twig new file mode 100644 index 00000000..6a379e78 --- /dev/null +++ b/src/views/streams/sources/edit.html.twig @@ -0,0 +1,72 @@ +{% extends 'layouts/application.html.twig' %} + +{% set title = t('Sources of %s', [stream.name]) %} + +{% block title %}{{ title }}{% endblock %} +{% block canonical %}{{ url_full('edit stream sources', { id: stream.id }) }}{% endblock %} + +{% block layout_header %} + {{ include('layouts/_header.html.twig', { page: 'stream sources', tab: 'reading' }) }} +{% endblock %} + +{% block back %} + {{ include('layouts/_back.html.twig', { title: title }) }} +{% endblock %} + +{% block body %} +

{{ title }}

+ +

+ {{ t('Add or remove sources to customise your stream.') }} +

+ +
+
+
+
+

{{ t('Sources to add') }}

+
+ +
+ {% for source in suggested_sources %} + {{ include('streams/sources/_source.html.twig', { action: 'add' }) }} + {% endfor %} +
+
+ +
+ {% set sources = stream.sources %} + +
+

{{ t('Sources of the stream') }}

+ +

+ + {{ t('%d source', '%d sources', sources | length, [sources | length]) }} + + + + {{ icon('frequency') }} + {{ stream.publicationFrequencyPerYear | format_publication_frequency }} + +

+
+ + {% if sources %} +
+ {% for source in sources %} + {{ include('streams/sources/_source.html.twig', { action: 'remove' }) }} + {% endfor %} +
+ {% endif %} +
+
+ +

+ + {{ t('Continue to the stream') }} + {{ icon('arrow-right') }} + +

+
+{% endblock %} diff --git a/tests/controllers/StreamsTest.php b/tests/controllers/StreamsTest.php new file mode 100644 index 00000000..0ccd596a --- /dev/null +++ b/tests/controllers/StreamsTest.php @@ -0,0 +1,196 @@ +login(); + + $response = $this->appRun('GET', '/streams/new'); + + $this->assertResponseCode($response, 200); + $this->assertResponseContains($response, 'New stream'); + $this->assertResponseTemplateName($response, 'streams/new.html.twig'); + } + + public function testNewRedirectsIfNotConnected(): void + { + $response = $this->appRun('GET', '/streams/new'); + + $this->assertResponseCode($response, 302, '/login?redirect_to=%2Fstreams%2Fnew'); + } + + public function testCreateCreatesStreamAndRedirects(): void + { + $user = $this->login(); + /** @var string */ + $name = $this->fake('words', 3, true); + /** @var string */ + $description = $this->fake('sentence'); + + $this->assertSame(0, models\Stream::count()); + + $response = $this->appRun('POST', '/streams/new', [ + 'csrf_token' => $this->csrfToken(forms\streams\Stream::class), + 'name' => $name, + 'description' => $description, + ]); + + $this->assertSame(1, models\Stream::count()); + $stream = models\Stream::take(); + $this->assertNotNull($stream); + $this->assertResponseCode($response, 302, "/streams/{$stream->id}/sources/edit"); + $this->assertSame($name, $stream->name); + $this->assertSame($description, $stream->description); + $this->assertFalse($stream->is_public); + } + + public function testCreateRedirectsIfNotConnected(): void + { + /** @var string */ + $name = $this->fake('words', 3, true); + /** @var string */ + $description = $this->fake('sentence'); + + $response = $this->appRun('POST', '/streams/new', [ + 'csrf_token' => $this->csrfToken(forms\streams\Stream::class), + 'name' => $name, + 'description' => $description, + ]); + + $this->assertResponseCode($response, 302, '/login?redirect_to=%2Fstreams%2Fnew'); + $this->assertSame(0, models\Stream::count()); + } + + public function testCreateFailsIfCsrfIsInvalid(): void + { + $user = $this->login(); + /** @var string */ + $name = $this->fake('words', 3, true); + /** @var string */ + $description = $this->fake('sentence'); + + $response = $this->appRun('POST', '/streams/new', [ + 'csrf_token' => 'not the token', + 'name' => $name, + 'description' => $description, + ]); + + $this->assertResponseCode($response, 400); + $this->assertResponseContains($response, 'A security verification failed'); + $this->assertSame(0, models\Stream::count()); + } + + public function testCreateFailsIfNameIsInvalid(): void + { + $user = $this->login(); + /** @var string */ + $name = $this->fake('words', 100, true); + /** @var string */ + $description = $this->fake('sentence'); + + $response = $this->appRun('POST', '/streams/new', [ + 'csrf_token' => $this->csrfToken(forms\streams\Stream::class), + 'name' => $name, + 'description' => $description, + ]); + + $this->assertResponseCode($response, 400); + $this->assertResponseContains($response, 'The name must be less than 100 characters'); + $this->assertSame(0, models\Stream::count()); + } + + public function testCreateFailsIfNameIsMissing(): void + { + $user = $this->login(); + /** @var string */ + $description = $this->fake('sentence'); + + $response = $this->appRun('POST', '/streams/new', [ + 'csrf_token' => $this->csrfToken(forms\streams\Stream::class), + 'description' => $description, + ]); + + $this->assertResponseCode($response, 400); + $this->assertResponseContains($response, 'The name is required'); + $this->assertSame(0, models\Stream::count()); + } + + public function testShowRendersCorrectly(): void + { + $user = $this->login(); + /** @var string */ + $link_title = $this->fake('words', 3, true); + $feed = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $link = LinkFactory::create([ + 'user_id' => $feed->user_id, + 'title' => $link_title, + 'is_hidden' => false, + ]); + $feed->addLinks([$link], at: \Minz\Time::now()); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $stream->addSource($feed); + + $response = $this->appRun('GET', "/streams/{$stream->id}"); + + $this->assertResponseCode($response, 200); + $this->assertResponseContains($response, $link_title); + $this->assertResponseTemplateName($response, 'streams/show.html.twig'); + } + + public function testShowHidesHiddenLinksInPublicCollections(): void + { + $user = $this->login(); + /** @var string */ + $link_title = $this->fake('words', 3, true); + $feed = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $link = LinkFactory::create([ + 'user_id' => $feed->user_id, + 'title' => $link_title, + 'is_hidden' => true, + ]); + $feed->addLinks([$link], at: \Minz\Time::now()); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + + $response = $this->appRun('GET', "/streams/{$stream->id}"); + + $this->assertResponseCode($response, 200); + $this->assertResponseNotContains($response, $link_title); + } + + public function testShowFailsIfStreamDoesNotExist(): void + { + $this->login(); + + $response = $this->appRun('GET', '/streams/unknown'); + + $this->assertResponseCode($response, 404); + } +} diff --git a/tests/controllers/streams/SourcesTest.php b/tests/controllers/streams/SourcesTest.php new file mode 100644 index 00000000..d32aa987 --- /dev/null +++ b/tests/controllers/streams/SourcesTest.php @@ -0,0 +1,405 @@ +login(); + /** @var string */ + $stream_name = $this->fake('words', 3, true); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + 'name' => $stream_name, + ]); + /** @var string */ + $source_name = $this->fake('words', 3, true); + /** @var string */ + $feed_url = $this->fake('url'); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'name' => $source_name, + 'feed_site_url' => $feed_url, + 'is_public' => true, + ]); + $user->follow($source->id); + + $response = $this->appRun('GET', "/streams/{$stream->id}/sources/edit"); + + $this->assertResponseCode($response, 200); + $this->assertResponseContains($response, "Sources of {$stream_name}"); + $this->assertResponseContains($response, $source_name); + $this->assertResponseTemplateName($response, 'streams/sources/edit.html.twig'); + } + + public function testEditRedirectsIfNotConnected(): void + { + $user = UserFactory::create(); + /** @var string */ + $stream_name = $this->fake('words', 3, true); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + 'name' => $stream_name, + ]); + /** @var string */ + $source_name = $this->fake('words', 3, true); + /** @var string */ + $feed_url = $this->fake('url'); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'name' => $source_name, + 'feed_site_url' => $feed_url, + 'is_public' => true, + ]); + $user->follow($source->id); + + $response = $this->appRun('GET', "/streams/{$stream->id}/sources/edit"); + + $this->assertResponseCode($response, 302, "/login?redirect_to=%2Fstreams%2F{$stream->id}%2Fsources%2Fedit"); + } + + public function testEditFailsIfTheStreamDoesNotExist(): void + { + $user = $this->login(); + + $response = $this->appRun('GET', '/streams/unknown/sources/edit'); + + $this->assertResponseCode($response, 404); + } + + public function testEditFailsIfTheUserCannotUpdateTheStream(): void + { + $user = $this->login(); + $other_user = UserFactory::create(); + /** @var string */ + $stream_name = $this->fake('words', 3, true); + $stream = StreamFactory::create([ + 'user_id' => $other_user->id, + 'name' => $stream_name, + ]); + /** @var string */ + $source_name = $this->fake('words', 3, true); + /** @var string */ + $feed_url = $this->fake('url'); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'name' => $source_name, + 'feed_site_url' => $feed_url, + 'is_public' => true, + ]); + $other_user->follow($source->id); + + $response = $this->appRun('GET', "/streams/{$stream->id}/sources/edit"); + + $this->assertResponseCode($response, 403); + } + + public function testAddAddsTheSourceToTheStream(): void + { + $user = $this->login(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $user->follow($source->id); + + $this->assertFalse($stream->hasSource($source)); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/add", [ + 'csrf_token' => $this->csrfToken(forms\streams\AddSource::class), + ]); + + $this->assertResponseCode($response, 302, "/streams/{$stream->id}/sources/edit"); + $this->assertTrue($stream->hasSource($source)); + } + + public function testAddWorksIfTheSourceIsNotFollowedYet(): void + { + $user = $this->login(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + + $this->assertFalse($stream->hasSource($source)); + $this->assertFalse($user->isFollowing($source->id)); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/add", [ + 'csrf_token' => $this->csrfToken(forms\streams\AddSource::class), + ]); + + $this->assertResponseCode($response, 302, "/streams/{$stream->id}/sources/edit"); + $this->assertTrue($stream->hasSource($source)); + $this->assertTrue($user->isFollowing($source->id)); + } + + public function testAddRedirectsIfNotConnected(): void + { + $user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $user->follow($source->id); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/add", [ + 'csrf_token' => $this->csrfToken(forms\streams\AddSource::class), + ]); + + $this->assertResponseCode($response, 302, '/login?redirect_to=%2F'); + $this->assertFalse($stream->hasSource($source)); + } + + public function testAddFailsIfTheStreamDoesNotExist(): void + { + $user = $this->login(); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $user->follow($source->id); + + $response = $this->appRun('POST', "/streams/unknown/sources/{$source->id}/add", [ + 'csrf_token' => $this->csrfToken(forms\streams\AddSource::class), + ]); + + $this->assertResponseCode($response, 404); + } + + public function testAddFailsIfTheSourceDoesNotExist(): void + { + $user = $this->login(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/unknown/add", [ + 'csrf_token' => $this->csrfToken(forms\streams\AddSource::class), + ]); + + $this->assertResponseCode($response, 404); + } + + public function testAddFailsIfTheUserCannotUpdateTheStream(): void + { + $user = $this->login(); + $other_user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $other_user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/add", [ + 'csrf_token' => $this->csrfToken(forms\streams\AddSource::class), + ]); + + $this->assertResponseCode($response, 403); + $this->assertFalse($stream->hasSource($source)); + } + + public function testAddFailsIfTheUserCannotViewTheSource(): void + { + $user = $this->login(); + $other_user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'collection', + 'is_public' => false, + 'user_id' => $other_user->id, + ]); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/add", [ + 'csrf_token' => $this->csrfToken(forms\streams\AddSource::class), + ]); + + $this->assertResponseCode($response, 403); + $this->assertFalse($stream->hasSource($source)); + } + + public function testAddFailsIfCsrfIsInvalid(): void + { + $user = $this->login(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $user->follow($source->id); + + $this->assertFalse($stream->hasSource($source)); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/add", [ + 'csrf_token' => 'not the token', + ]); + + $this->assertResponseCode($response, 302, "/streams/{$stream->id}/sources/edit"); + $this->assertStringContainsString('A security verification failed', utils\Notification::popError()); + $this->assertFalse($stream->hasSource($source)); + } + + public function testRemoveRemovesTheSourceFromTheStream(): void + { + $user = $this->login(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $stream->addSource($source); + + $this->assertTrue($stream->hasSource($source)); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/remove", [ + 'csrf_token' => $this->csrfToken(forms\streams\RemoveSource::class), + ]); + + $this->assertResponseCode($response, 302, "/streams/{$stream->id}/sources/edit"); + $this->assertFalse($stream->hasSource($source)); + } + + public function testRemoveDoesNotFailsIfTheSourceIsNotInTheStream(): void + { + $user = $this->login(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + + $this->assertFalse($stream->hasSource($source)); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/remove", [ + 'csrf_token' => $this->csrfToken(forms\streams\RemoveSource::class), + ]); + + $this->assertResponseCode($response, 302, "/streams/{$stream->id}/sources/edit"); + $this->assertFalse($stream->hasSource($source)); + } + + public function testRemoveRedirectsIfNotConnected(): void + { + $user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $stream->addSource($source); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/remove", [ + 'csrf_token' => $this->csrfToken(forms\streams\RemoveSource::class), + ]); + + $this->assertResponseCode($response, 302, '/login?redirect_to=%2F'); + $this->assertTrue($stream->hasSource($source)); + } + + public function testRemoveFailsIfTheStreamDoesNotExist(): void + { + $user = $this->login(); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + + $response = $this->appRun('POST', "/streams/unknown/sources/{$source->id}/remove", [ + 'csrf_token' => $this->csrfToken(forms\streams\RemoveSource::class), + ]); + + $this->assertResponseCode($response, 404); + } + + public function testRemoveFailsIfTheSourceDoesNotExist(): void + { + $user = $this->login(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/unknown/remove", [ + 'csrf_token' => $this->csrfToken(forms\streams\RemoveSource::class), + ]); + + $this->assertResponseCode($response, 404); + } + + public function testRemoveFailsIfTheUserCannotUpdateTheStream(): void + { + $user = $this->login(); + $other_user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $other_user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $stream->addSource($source); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/remove", [ + 'csrf_token' => $this->csrfToken(forms\streams\RemoveSource::class), + ]); + + $this->assertResponseCode($response, 403); + $this->assertTrue($stream->hasSource($source)); + } + + public function testRemoveFailsIfCsrfIsInvalid(): void + { + $user = $this->login(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $stream->addSource($source); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/remove", [ + 'csrf_token' => 'not the token', + ]); + + $this->assertResponseCode($response, 302, "/streams/{$stream->id}/sources/edit"); + $this->assertStringContainsString('A security verification failed', utils\Notification::popError()); + $this->assertTrue($stream->hasSource($source)); + } +} diff --git a/tests/factories/StreamFactory.php b/tests/factories/StreamFactory.php new file mode 100644 index 00000000..a8fce059 --- /dev/null +++ b/tests/factories/StreamFactory.php @@ -0,0 +1,47 @@ + + * + * @author Marien Fressinaud + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class StreamFactory extends Database\Factory +{ + public static function model(): string + { + return models\Stream::class; + } + + public static function values(): array + { + $faker = \Faker\Factory::create(); + + return [ + 'id' => function (): string { + return \Minz\Random::timebased(); + }, + + 'created_at' => function () use ($faker) { + return $faker->dateTime; + }, + + 'name' => function () use ($faker) { + return $faker->words(3, true); + }, + + 'is_public' => function () use ($faker) { + return $faker->boolean; + }, + + 'user_id' => function () { + return UserFactory::create()->id; + }, + ]; + } +} diff --git a/tests/models/StreamViewTest.php b/tests/models/StreamViewTest.php new file mode 100644 index 00000000..ea420e0d --- /dev/null +++ b/tests/models/StreamViewTest.php @@ -0,0 +1,333 @@ +fake('date'); + /** @var int */ + $days = $this->fake('numberBetween', 1, 7); + $request = new \Minz\Request('GET', '/stream', [ + 'at' => $date, + 'days' => $days, + ]); + $stream = StreamFactory::create(); + + $stream_view = StreamView::buildFromRequest($stream, $request); + + $this->assertSame($date, $stream_view->at->format('Y-m-d')); + $this->assertSame($days, $stream_view->days); + } + + public function testBuildFromRequestHasDefaultValues(): void + { + $now = \Minz\Time::now(); + $request = new \Minz\Request('GET', '/stream', []); + $stream = StreamFactory::create(); + + $stream_view = StreamView::buildFromRequest($stream, $request); + + $this->assertSame($now->format('Y-m-d'), $stream_view->at->format('Y-m-d')); + $this->assertSame(1, $stream_view->days); + } + + public function testLinksTimelineListsLinksOfSelectedDay(): void + { + /** @var \DateTimeImmutable */ + $date_1 = $this->fake('dateTime'); + $date_2 = $date_1->modify('-1 day'); + $date_3 = $date_1->modify('-2 days'); + $request = new \Minz\Request('GET', '/stream', [ + 'at' => $date_1->format('Y-m-d'), + 'days' => 1, + ]); + $user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $link_1 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $link_2 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $link_3 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $source->addLinks([$link_1], at: $date_1); + $source->addLinks([$link_2], at: $date_2); + $source->addLinks([$link_3], at: $date_3); + $stream->addSource($source); + $stream_view = StreamView::buildFromRequest($stream, $request); + + $links_timeline = $stream_view->linksTimeline(); + + $date_groups = $links_timeline->datesGroups(); + $this->assertSame(1, count($date_groups)); + $date_group = array_shift($date_groups); + $this->assertNotNull($date_group); + $source_groups = $date_group->sourceGroups(); + $this->assertSame(1, count($source_groups)); + $source_group = $source_groups[0]; + $this->assertSame($source->id, $source_group->source->id); + $this->assertSame(1, count($source_group->links)); + $this->assertSame($link_1->id, $source_group->links[0]->id); + } + + public function testLinksTimelineListsLinksOfSelectedDayPlusDays(): void + { + /** @var \DateTimeImmutable */ + $date_1 = $this->fake('dateTime'); + $date_2 = $date_1->modify('-1 day'); + $date_3 = $date_1->modify('-2 days'); + $request = new \Minz\Request('GET', '/stream', [ + 'at' => $date_1->format('Y-m-d'), + 'days' => 2, + ]); + $user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $link_1 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $link_2 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $link_3 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $source->addLinks([$link_1], at: $date_1); + $source->addLinks([$link_2], at: $date_2); + $source->addLinks([$link_3], at: $date_3); + $stream->addSource($source); + $stream_view = StreamView::buildFromRequest($stream, $request); + + $links_timeline = $stream_view->linksTimeline(); + + $date_groups = $links_timeline->datesGroups(); + $this->assertSame(2, count($date_groups)); + + $date_group_1 = array_shift($date_groups); + $this->assertNotNull($date_group_1); + $source_groups_1 = $date_group_1->sourceGroups(); + $this->assertSame(1, count($source_groups_1)); + $source_group_1 = $source_groups_1[0]; + $this->assertSame($source->id, $source_group_1->source->id); + $this->assertSame(1, count($source_group_1->links)); + $this->assertSame($link_1->id, $source_group_1->links[0]->id); + + $date_group_2 = array_shift($date_groups); + $this->assertNotNull($date_group_2); + $source_groups_2 = $date_group_2->sourceGroups(); + $this->assertSame(1, count($source_groups_2)); + $source_group_2 = $source_groups_2[0]; + $this->assertSame($source->id, $source_group_2->source->id); + $this->assertSame(1, count($source_group_2->links)); + $this->assertSame($link_2->id, $source_group_2->links[0]->id); + } + + public function testLinksTimelineCanListLinksFromPrivateSourceIfTheUserOwnsTheSource(): void + { + /** @var \DateTimeImmutable */ + $date = $this->fake('dateTime'); + $request = new \Minz\Request('GET', '/stream', [ + 'at' => $date->format('Y-m-d'), + 'days' => 1, + ]); + $user = UserFactory::create(); + $other_user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source_1 = CollectionFactory::create([ + 'type' => 'collection', + 'is_public' => false, + 'user_id' => $user->id, + ]); + $source_2 = CollectionFactory::create([ + 'type' => 'collection', + 'is_public' => false, + 'user_id' => $other_user->id, + ]); + $link_1 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $link_2 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $source_1->addLinks([$link_1], at: $date); + $source_2->addLinks([$link_2], at: $date); + $stream->addSource($source_1); + $stream->addSource($source_2); + $stream_view = StreamView::buildFromRequest($stream, $request); + + $links_timeline = $stream_view->linksTimeline($user); + + $date_groups = $links_timeline->datesGroups(); + $this->assertSame(1, count($date_groups)); + $date_group = array_shift($date_groups); + $this->assertNotNull($date_group); + $source_groups = $date_group->sourceGroups(); + $this->assertSame(1, count($source_groups)); + $source_group = $source_groups[0]; + $this->assertSame($source_1->id, $source_group->source->id); + $this->assertSame(1, count($source_group->links)); + $this->assertSame($link_1->id, $source_group->links[0]->id); + } + + public function testLinksTimelineCanListLinksFromPrivateSourceIfTheUserHasAccessToTheSource(): void + { + /** @var \DateTimeImmutable */ + $date = $this->fake('dateTime'); + $request = new \Minz\Request('GET', '/stream', [ + 'at' => $date->format('Y-m-d'), + 'days' => 1, + ]); + $user = UserFactory::create(); + $other_user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source_1 = CollectionFactory::create([ + 'type' => 'collection', + 'is_public' => false, + 'user_id' => $other_user->id, + ]); + $source_2 = CollectionFactory::create([ + 'type' => 'collection', + 'is_public' => false, + 'user_id' => $other_user->id, + ]); + $link_1 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $link_2 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $source_1->addLinks([$link_1], at: $date); + $source_2->addLinks([$link_2], at: $date); + $stream->addSource($source_1); + $stream->addSource($source_2); + $source_1->shareWith($user, 'read'); + $stream_view = StreamView::buildFromRequest($stream, $request); + + $links_timeline = $stream_view->linksTimeline($user); + + $date_groups = $links_timeline->datesGroups(); + $this->assertSame(1, count($date_groups)); + $date_group = array_shift($date_groups); + $this->assertNotNull($date_group); + $source_groups = $date_group->sourceGroups(); + $this->assertSame(1, count($source_groups)); + $source_group = $source_groups[0]; + $this->assertSame($source_1->id, $source_group->source->id); + $this->assertSame(1, count($source_group->links)); + $this->assertSame($link_1->id, $source_group->links[0]->id); + } + + public function testLinksTimelineExcludesHiddenLinks(): void + { + /** @var \DateTimeImmutable */ + $date = $this->fake('dateTime'); + $request = new \Minz\Request('GET', '/stream', [ + 'at' => $date->format('Y-m-d'), + 'days' => 1, + ]); + $user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'collection', + 'is_public' => true, + ]); + $link_1 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $link_2 = LinkFactory::create([ + 'is_hidden' => true, + ]); + $source->addLinks([$link_1, $link_2], at: $date); + $stream->addSource($source); + $stream_view = StreamView::buildFromRequest($stream, $request); + + $links_timeline = $stream_view->linksTimeline(); + + $date_groups = $links_timeline->datesGroups(); + $this->assertSame(1, count($date_groups)); + $date_group = array_shift($date_groups); + $this->assertNotNull($date_group); + $source_groups = $date_group->sourceGroups(); + $this->assertSame(1, count($source_groups)); + $source_group = $source_groups[0]; + $this->assertSame($source->id, $source_group->source->id); + $this->assertSame(1, count($source_group->links)); + $this->assertSame($link_1->id, $source_group->links[0]->id); + } + + public function testLinksTimelineExcludesPrivateSources(): void + { + /** @var \DateTimeImmutable */ + $date = $this->fake('dateTime'); + $request = new \Minz\Request('GET', '/stream', [ + 'at' => $date->format('Y-m-d'), + 'days' => 1, + ]); + $user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source_1 = CollectionFactory::create([ + 'type' => 'collection', + 'is_public' => true, + ]); + $source_2 = CollectionFactory::create([ + 'type' => 'collection', + 'is_public' => false, + ]); + $link_1 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $link_2 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $source_1->addLinks([$link_1], at: $date); + $source_2->addLinks([$link_2], at: $date); + $stream->addSource($source_1); + $stream->addSource($source_2); + $stream_view = StreamView::buildFromRequest($stream, $request); + + $links_timeline = $stream_view->linksTimeline(); + + $date_groups = $links_timeline->datesGroups(); + $this->assertSame(1, count($date_groups)); + $date_group = array_shift($date_groups); + $this->assertNotNull($date_group); + $source_groups = $date_group->sourceGroups(); + $this->assertSame(1, count($source_groups)); + $source_group = $source_groups[0]; + $this->assertSame($source_1->id, $source_group->source->id); + $this->assertSame(1, count($source_group->links)); + $this->assertSame($link_1->id, $source_group->links[0]->id); + } +}