From 11d2cfb9eda9ccc2d43243e3b54372c38a5b95be Mon Sep 17 00:00:00 2001 From: Hugo Sales Date: Fri, 26 Nov 2021 23:31:53 +0000 Subject: [PATCH] [UI][FEEDS][ENTITY][Feed] Add way to customize the feeds that are displayed in the left panel. The user can add, delete, reoder and rename them --- public/assets/icons/edit.svg.twig | 9 ++ src/Controller/Feeds.php | 119 ++++++++++++++ src/Controller/Security.php | 10 +- src/Entity/Feed.php | 179 ++++++++++++++++++++++ src/Routes/Main.php | 2 +- src/Twig/Extension.php | 1 + src/Twig/Runtime.php | 11 +- templates/cards/navigation/view.html.twig | 8 +- templates/feeds/edit_feeds.html.twig | 7 + 9 files changed, 335 insertions(+), 11 deletions(-) create mode 100644 public/assets/icons/edit.svg.twig create mode 100644 src/Entity/Feed.php create mode 100644 templates/feeds/edit_feeds.html.twig diff --git a/public/assets/icons/edit.svg.twig b/public/assets/icons/edit.svg.twig new file mode 100644 index 0000000000..83b207dda0 --- /dev/null +++ b/public/assets/icons/edit.svg.twig @@ -0,0 +1,9 @@ + + + + + + + edit + + diff --git a/src/Controller/Feeds.php b/src/Controller/Feeds.php index 9ad70e3769..b19ceda874 100644 --- a/src/Controller/Feeds.php +++ b/src/Controller/Feeds.php @@ -35,17 +35,27 @@ declare(strict_types = 1); namespace App\Controller; +use App\Core\Cache; use App\Core\Controller; use App\Core\DB\DB; use App\Core\Event; +use App\Core\Form; use function App\Core\I18n\_m; +use App\Core\Router\Router; use App\Core\VisibilityScope; +use App\Entity\Feed; use App\Entity\Note; use App\Util\Common; use App\Util\Exception\ClientException; use App\Util\Exception\NotFoundException; use App\Util\Exception\NotImplementedException; +use App\Util\Exception\RedirectException; +use Functional as F; +use Symfony\Component\Form\Extension\Core\Type\IntegerType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; class Feeds extends Controller { @@ -128,6 +138,115 @@ class Feeds extends Controller ]; } + public function edit_feeds(Request $request) + { + $user = Common::ensureLoggedIn(); + $key = Feed::cacheKey($user); + $feeds = Feed::getFeeds($user); + + $form_definitions = []; + foreach ($feeds as $feed) { + $md5 = md5($feed->getUrl()); + $form_definitions[] = [$md5 . '-url', TextType::class, ['data' => $feed->getUrl(), 'label' => ' ']]; + $form_definitions[] = [$md5 . '-order', IntegerType::class, ['data' => $feed->getOrdering(), 'label' => ' ']]; + $form_definitions[] = [$md5 . '-title', TextType::class, ['data' => $feed->getTitle(), 'label' => ' ']]; + $form_definitions[] = [$md5 . '-remove', SubmitType::class, ['label' => _m('Remove')]]; + } + + $form_definitions[] = ['url', TextType::class, ['label' => _m('New feed'), 'required' => false]]; + $form_definitions[] = ['order', IntegerType::class, ['label' => _m('Order'), 'data' => (\count($form_definitions) / 4) + 1]]; + $form_definitions[] = ['title', TextType::class, ['label' => _m('Title'), 'required' => false]]; + $form_definitions[] = ['add', SubmitType::class, ['label' => _m('Add')]]; + $form_definitions[] = ['update_exisiting', SubmitType::class, ['label' => _m('Update existing')]]; + $form_definitions[] = ['reset', SubmitType::class, ['label' => _m('Reset to default values')]]; + + $form = Form::create($form_definitions); + + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + array_pop($form_definitions); + array_pop($form_definitions); + array_pop($form_definitions); + array_pop($form_definitions); + array_pop($form_definitions); + + $data = $form->getData(); + + if ($form->get('update_exisiting')->isClicked()) { + // Each feed has a URL, an order and a title + $feeds_data = array_chunk($data, 3, preserve_keys: true); + // The last three would be the new one + array_pop($feeds_data); + // Sort by the order + usort($feeds_data, fn ($fd_l, $fd_r) => next($fd_l) <=> next($fd_r)); + // Make the order sequential + $order = 1; + foreach ($feeds_data as $i => $fd) { + next($fd); + $feeds_data[$i][key($fd)] = $order++; + } + // Update the fields in the corresponding feed + foreach ($feeds_data as $fd) { + $md5 = str_replace('-url', '', array_key_first($fd)); + $feed = F\first($feeds, fn ($f) => md5($f->getUrl()) === $md5); + $feed->setUrl($fd[$md5 . '-url']); + $feed->setOrdering($fd[$md5 . '-order']); + $feed->setTitle($fd[$md5 . '-title']); + DB::merge($feed); + } + DB::flush(); + Cache::delete($key); + throw new RedirectException(); + } + + // Remove feed + foreach ($form_definitions as [$field, $type, $opts]) { + if (str_ends_with($field, '-url')) { + $remove_id = str_replace('-url', '-remove', $field); + if ($form->get($remove_id)->isClicked()) { + DB::remove(DB::getReference('feed', ['actor_id' => $user->getId(), 'url' => $opts['data']])); + DB::flush(); + Cache::delete($key); + throw new RedirectException(); + } + } + } + + if ($form->get('reset')->isClicked()) { + F\map(DB::findBy('feed', ['actor_id' => $user->getId()]), fn ($f) => DB::remove($f)); + DB::flush(); + Cache::delete($key); + Feed::createDefaultFeeds($user->getId(), $user); + DB::flush(); + throw new RedirectException(); + } + + // Add feed + try { + $match = Router::match($data['url']); + $route = $match['_route']; + DB::persist(Feed::create([ + 'actor_id' => $user->getId(), + 'url' => $data['url'], + 'route' => $route, + 'title' => $data['title'], + 'ordering' => $data['order'], + ])); + DB::flush(); + Cache::delete($key); + throw new RedirectException(); + } catch (ResourceNotFoundException) { + // throw new ClientException(_m('Invalid route')); + // continue bellow + } + } + + return [ + '_template' => 'feeds/edit_feeds.html.twig', + 'form' => $form->createView(), + ]; + } + public function replies(Request $request) { // TODO replies diff --git a/src/Controller/Security.php b/src/Controller/Security.php index 1d150c624b..a4426e7d8b 100644 --- a/src/Controller/Security.php +++ b/src/Controller/Security.php @@ -8,8 +8,10 @@ use App\Core\Controller; use App\Core\DB\DB; use App\Core\Event; use App\Core\Form; +use function App\Core\I18n\_m; use App\Core\Log; use App\Entity\Actor; +use App\Entity\Feed; use App\Entity\LocalUser; use App\Entity\Subscription; use App\Security\Authenticator; @@ -39,7 +41,6 @@ use Symfony\Component\Security\Guard\GuardAuthenticatorHandler; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\NotBlank; -use function App\Core\I18n\_m; class Security extends Controller { @@ -155,8 +156,11 @@ class Security extends Controller DB::persistWithSameId( $actor, $user, - // Self subscription - fn (int $id) => DB::persist(Subscription::create(['subscriber' => $id, 'subscribed' => $id])), + function (int $id) use ($user) { + // Self subscription + DB::persist(Subscription::create(['subscriber' => $id, 'subscribed' => $id])); + Feed::createDefaultFeeds($id, $user); + }, ); Event::handle('SuccessfulLocalUserRegistration', [$actor, $user]); diff --git a/src/Entity/Feed.php b/src/Entity/Feed.php new file mode 100644 index 0000000000..f5c8914cc4 --- /dev/null +++ b/src/Entity/Feed.php @@ -0,0 +1,179 @@ +. +// }}} + +namespace App\Entity; + +use App\Core\Cache; +use App\Core\DB\DB; +use App\Core\Entity; +use function App\Core\I18n\_m; +use DateTimeInterface; + +/** + * Entity for feeds a user follows + * + * @category DB + * @package GNUsocial + * + * @author Hugo Sales + * @copyright 2021 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ +class Feed extends Entity +{ + // {{{ Autocode + // @codeCoverageIgnoreStart + private int $actor_id; + private string $url; + private string $title; + private string $route; + private int $ordering; + private DateTimeInterface $created; + private DateTimeInterface $modified; + + public function setActorId(int $actor_id): self + { + $this->actor_id = $actor_id; + return $this; + } + + public function getActorId(): int + { + return $this->actor_id; + } + + public function setUrl(string $url): self + { + $this->url = $url; + return $this; + } + + public function getUrl(): string + { + return $this->url; + } + + public function setTitle(string $title): self + { + $this->title = $title; + return $this; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setRoute(string $route): self + { + $this->route = $route; + return $this; + } + + public function getRoute(): string + { + return $this->route; + } + + public function setOrdering(int $ordering): self + { + $this->ordering = $ordering; + return $this; + } + + public function getOrdering(): int + { + return $this->ordering; + } + + public function setCreated(DateTimeInterface $created): self + { + $this->created = $created; + return $this; + } + + public function getCreated(): DateTimeInterface + { + return $this->created; + } + + public function setModified(DateTimeInterface $modified): self + { + $this->modified = $modified; + return $this; + } + + public function getModified(): DateTimeInterface + { + return $this->modified; + } + + // @codeCoverageIgnoreEnd + // }}} Autocode + + public static function cacheKey(LocalUser|Actor $user_actor): string + { + return 'feeds-' . $user_actor->getId(); + } + + /** + * @return self[] + */ + public static function getFeeds(LocalUser|Actor $user_actor): array + { + return Cache::getList(self::cacheKey($user_actor), fn () => DB::findBy('feed', ['actor_id' => $user_actor->getId()], order_by: ['ordering' => 'ASC'])); + } + + /** + * This is called in the register function, in `DB::persistWithSameId`, + * so we don't have the $user with an id yet, hence the awkward + * arguments + */ + public static function createDefaultFeeds(int $actor_id, LocalUser $user): void + { + DB::persist(self::create(['actor_id' => $actor_id, 'url' => '/main/public', + 'route' => 'main_public', 'title' => _m('Public'), 'ordering' => 1, ])); + DB::persist(self::create(['actor_id' => $actor_id, 'url' => '/main/all', + 'route' => 'main_all', 'title' => _m('Network'), 'ordering' => 2, ])); + DB::persist(self::create(['actor_id' => $actor_id, 'url' => '/@' . $user->getNickname() . '/all', + 'route' => 'home_all', 'title' => _m('Home'), 'ordering' => 3, ])); + } + + public static function schemaDef(): array + { + return [ + 'name' => 'feed', + 'fields' => [ + 'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'many to one', 'not null' => true, 'description' => 'foreign key to actor table'], + 'url' => ['type' => 'text', 'not null' => true], + 'title' => ['type' => 'text', 'not null' => true], + 'route' => ['type' => 'text', 'not null' => true], + 'ordering' => ['type' => 'int', 'not null' => true], + 'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'], + 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], + ], + 'primary key' => ['actor_id', 'url'], + 'indexes' => [ + 'feed_actor_id_idx' => ['actor_id'], + ], + ]; + } +} diff --git a/src/Routes/Main.php b/src/Routes/Main.php index ab3d70f0dd..23533ca329 100644 --- a/src/Routes/Main.php +++ b/src/Routes/Main.php @@ -59,7 +59,7 @@ abstract class Main $r->connect('main_all', '/main/all', [C\Feeds::class, 'network']); $r->connect('home_all', '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/all', [C\Feeds::class, 'home']); $r->connect('replies', '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/replies', [C\Feeds::class, 'replies']); - $r->connect('edit_feeds', '/edit-feeds', [C\Feeds::class, 'replies']); + $r->connect('edit_feeds', '/edit-feeds', [C\Feeds::class, 'edit_feeds']); $r->connect('panel', '/panel', [C\AdminPanel::class, 'site']); $r->connect('panel_site', '/panel/site', [C\AdminPanel::class, 'site']); diff --git a/src/Twig/Extension.php b/src/Twig/Extension.php index c437861f65..e3b44ecd89 100644 --- a/src/Twig/Extension.php +++ b/src/Twig/Extension.php @@ -73,6 +73,7 @@ class Extension extends AbstractExtension new TwigFunction('handle_override_stylesheet', [Runtime::class, 'handleOverrideStylesheet']), new TwigFunction('open_details', [Runtime::class, 'openDetails']), + new TwigFunction('get_feeds', [Runtime::class, 'getFeeds']), ]; } } diff --git a/src/Twig/Runtime.php b/src/Twig/Runtime.php index 0253652127..9040e40c6e 100644 --- a/src/Twig/Runtime.php +++ b/src/Twig/Runtime.php @@ -33,6 +33,8 @@ declare(strict_types = 1); namespace App\Twig; use App\Core\Event; +use App\Entity\Actor; +use App\Entity\Feed; use App\Entity\Note; use App\Util\Common; use App\Util\Formatting; @@ -143,9 +145,14 @@ class Runtime implements RuntimeExtensionInterface, EventSubscriberInterface return $result; } - public function openDetails(?string $query, array $ids) + public function openDetails(?string $query, array $ids): string { - return in_array($query, $ids) ? 'open=""' : ''; + return \in_array($query, $ids) ? 'open=""' : ''; + } + + public function getFeeds(Actor $actor): array + { + return Feed::getFeeds($actor); } // ---------------------------------------------------------- diff --git a/templates/cards/navigation/view.html.twig b/templates/cards/navigation/view.html.twig index 678dd115f7..134c632324 100644 --- a/templates/cards/navigation/view.html.twig +++ b/templates/cards/navigation/view.html.twig @@ -16,13 +16,11 @@ {% else %} {# User custom feeds #} - {{ icon('edit', 'icon') }} + {{ icon('edit', 'icon') | raw }} diff --git a/templates/feeds/edit_feeds.html.twig b/templates/feeds/edit_feeds.html.twig new file mode 100644 index 0000000000..2d021b55d6 --- /dev/null +++ b/templates/feeds/edit_feeds.html.twig @@ -0,0 +1,7 @@ +{% extends 'base.html.twig' %} + +{% block body %} +
+ {{ form(form) }} +
+{% endblock %}