diff --git a/plugins/PinnedNotes/Controller/PinnedNotes.php b/plugins/PinnedNotes/Controller/PinnedNotes.php new file mode 100644 index 0000000000..60a3eefe91 --- /dev/null +++ b/plugins/PinnedNotes/Controller/PinnedNotes.php @@ -0,0 +1,91 @@ +. + +// }}} + +namespace Plugin\PinnedNotes\Controller; + +use App\Core\DB\DB; +use App\Core\Form; +use function App\Core\I18n\_m; +use App\Core\Router\Router; +use App\Util\Common; +use App\Util\Exception\ClientException; +use App\Util\Exception\NoSuchNoteException; +use App\Util\Exception\RedirectException; +use Component\Collection\Util\Controller\FeedController; +use Plugin\PinnedNotes\Entity as E; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\HttpFoundation\Request; + +class PinnedNotes extends FeedController +{ + public function togglePin(Request $request, int $id) + { + $user = Common::ensureLoggedIn(); + $note = DB::findOneBy('note', ['id' => $id]); + if ($user->getId() !== $note?->getActorId()) { + throw new NoSuchNoteException(); + } + + $opts = ['note_id' => $id, 'actor_id' => $user->getId()]; + $is_pinned = !\is_null(DB::findOneBy(E\PinnedNotes::class, $opts, return_null: true)); + + $form = Form::create([ + ['toggle_pin', SubmitType::class, [ + 'label' => _m(($is_pinned ? 'Unpin' : 'Pin') . ' this note'), + 'attr' => [ + 'title' => _m(($is_pinned ? 'Unpin' : 'Pin') . ' this note'), + ], + ]], + ]); + $form->handleRequest($request); + if ($form->isSubmitted()) { + $opts = ['note_id' => $id, 'actor_id' => $user->getId()]; + if ($is_pinned) { + $pinned = DB::findOneBy(E\PinnedNotes::class, $opts); + DB::remove($pinned); + } else { + DB::persist(E\PinnedNotes::create($opts)); + } + DB::flush(); + + // redirect user to where they came from, but prevent open redirect + if (!\is_null($from = $this->string('from'))) { + if (Router::isAbsolute($from)) { + throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive) + } else { + throw new RedirectException(url: $from); + } + } else { + // If we don't have a URL to return to, go to the instance root + throw new RedirectException('root'); + } + } + + return [ + '_template' => 'PinnedNotes/toggle.html.twig', + 'note' => $note, + 'title' => _m($is_pinned ? 'Unpin note' : 'Pin note'), + 'toggle_form' => $form->createView(), + ]; + } +} diff --git a/plugins/PinnedNotes/Entity/PinnedNotes.php b/plugins/PinnedNotes/Entity/PinnedNotes.php new file mode 100644 index 0000000000..ae121f09e1 --- /dev/null +++ b/plugins/PinnedNotes/Entity/PinnedNotes.php @@ -0,0 +1,57 @@ +id; + } + public function setId($id) + { + $this->id = $id; + return $this; + } + + public function getActorId() + { + return $this->actor_id; + } + public function setActorId($actor_id) + { + $this->actor_id = $actor_id; + return $this; + } + + public function getNoteId() + { + return $this->note_id; + } + public function setNoteId($note_id) + { + $this->note_id = $note_id; + return $this; + } + + public static function schemaDef() + { + return [ + 'name' => 'pinned_notes', + 'fields' => [ + 'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'], + 'actor_id' => ['type' => 'int', 'not null' => true, 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'many to one', 'description' => 'Actor who pinned the note'], + 'note_id' => ['type' => 'int', 'not null' => true, 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'many to one', 'description' => 'Pinned note'], + ], + 'primary key' => ['id'], + ]; + } +} diff --git a/plugins/PinnedNotes/PinnedNotes.php b/plugins/PinnedNotes/PinnedNotes.php new file mode 100644 index 0000000000..912d674f50 --- /dev/null +++ b/plugins/PinnedNotes/PinnedNotes.php @@ -0,0 +1,132 @@ +. +// }}} +/** + * WebMonetization for GNU social + * + * @package GNUsocial + * @category Plugin + * + * @author Phablulo + * @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ + +namespace Plugin\PinnedNotes; + +use App\Core\DB\DB; +use App\Core\Event; +use App\Core\Modules\Plugin; +use App\Core\Router\RouteLoader; +use App\Core\Router\Router; +use App\Entity\Actor; +use App\Entity\LocalUser; +use App\Entity\Note; +use App\Util\Common; +use App\Util\Formatting; +use Component\Collection\Collection; +use Doctrine\Common\Collections\ExpressionBuilder; +use Doctrine\ORM\Query\Expr; + +use Doctrine\ORM\QueryBuilder; +use Plugin\PinnedNotes\Controller as C; +use Plugin\PinnedNotes\Entity as E; + +use Symfony\Component\HttpFoundation\Request; + +class PinnedNotes extends Plugin +{ + public function onAddRoute(RouteLoader $r): bool + { + // Pin and unpin notes + $r->connect(id: 'toggle_note_pin', uri_path: '/object/note/{id<\d+>}/pin', target: [C\PinnedNotes::class, 'togglePin']); + return Event::next; + } + + public function onBeforeFeed(Request $request, &$res): bool + { + $path = $request->attributes->get('_route'); + if ($path === 'actor_view_nickname') { + $actor = LocalUser::getByNickname($request->attributes->get('nickname'))->getActor(); + } elseif ($path === 'actor_view_id') { + $actor = DB::findOneBy(Actor::class, ['id' => $request->attributes->get('id')]); + } else { + return Event::next; + } + + $locale = Common::currentLanguage()->getLocale(); + $notes = Collection::query('pinned:true actor:' . $actor->getId(), 1, $locale, $actor); + + $res[] = Formatting::twigRenderFile('PinnedNotes/notes.html.twig', ['pinnednotes' => $notes['notes']]); + + return Event::next; + } + + public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool + { + $note_qb->leftJoin(E\PinnedNotes::class, 'pinned', Expr\Join::WITH, 'note.id = pinned.note_id'); + return Event::next; + } + + public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr): bool + { + if ($term === 'pinned:true') { + $note_expr = $eb->neq('pinned', null); + return Event::stop; + } + if (str_starts_with($term, 'actor:')) { + $actor_id = (int) mb_substr($term, 6); + $actor_expr = $eb->eq('actor.id', $actor_id); + return Event::stop; + } + return Event::next; + } + + public function onAddNoteActions(Request $request, Note $note, array &$actions): bool + { + $user = Common::user(); + if ($user->getId() !== $note->getActorId()) { + return Event::next; + } + + $opts = ['note_id' => $note->getId(), 'actor_id' => $user->getId()]; + $is_pinned = !\is_null(DB::findOneBy(E\PinnedNotes::class, $opts, return_null: true)); + + $router_args = ['id' => $note->getId()]; + $router_type = Router::ABSOLUTE_PATH; + $action_url = Router::url('toggle_note_pin', $router_args, $router_type); + $action_url .= '?from=' . urlencode($request->getRequestUri()); // so we can go back + + $actions[] = [ + 'url' => $action_url, + 'title' => ($is_pinned ? 'Unpin' : 'Pin') . ' this note', + 'classes' => 'button-container pin-button-container ' . ($is_pinned ? 'pinned' : ''), + 'id' => 'pin-button-container-' . $note->getId(), + ]; + + return Event::next; + } + + public function onEndShowStyles(array &$styles, string $route): bool + { + $styles[] = 'plugins/PinnedNotes/assets/css/pinned-notes.css'; + return Event::next; + } +} diff --git a/plugins/PinnedNotes/templates/PinnedNotes/notes.html.twig b/plugins/PinnedNotes/templates/PinnedNotes/notes.html.twig new file mode 100644 index 0000000000..c4a6e9db29 --- /dev/null +++ b/plugins/PinnedNotes/templates/PinnedNotes/notes.html.twig @@ -0,0 +1,20 @@ +{% import '/cards/note/view.html.twig' as noteView %} + +{# Backwards compatibility with hAtom 0.1 #} +{% if pinnednotes is not empty %} +
+

Pinned Notes

+
+ {% for conversation in pinnednotes %} + {% block current_note %} + {% if conversation is instanceof('array') %} + {{ noteView.macro_note(conversation['note'], conversation['replies']) }} + {% else %} + {{ noteView.macro_note(conversation) }} + {% endif %} +
+ {% endblock current_note %} + {% endfor %} +
+
+{% endif %} diff --git a/plugins/PinnedNotes/templates/PinnedNotes/toggle.html.twig b/plugins/PinnedNotes/templates/PinnedNotes/toggle.html.twig new file mode 100644 index 0000000000..06f5268197 --- /dev/null +++ b/plugins/PinnedNotes/templates/PinnedNotes/toggle.html.twig @@ -0,0 +1,19 @@ +{% extends 'stdgrid.html.twig' %} +{% import "/cards/note/view.html.twig" as noteView %} + +{% block title %}{{ title }}{% endblock %} + +{% block stylesheets %} + {{ parent() }} + +{% endblock stylesheets %} + +{% block body %} + {{ parent() }} +
+
+ {{ noteView.macro_note_minimal(note) }} + {{ form(toggle_form) }} +
+
+{% endblock body %} diff --git a/public/plugins/PinnedNotes/assets/css/pinned-notes.css b/public/plugins/PinnedNotes/assets/css/pinned-notes.css new file mode 100644 index 0000000000..6599d9959f --- /dev/null +++ b/public/plugins/PinnedNotes/assets/css/pinned-notes.css @@ -0,0 +1,13 @@ +.pin-button-container { + -o-mask-image: url("/plugins/PinnedNotes/assets/icons/pin.svg"); + -moz-mask-image: url("/plugins/PinnedNotes/assets/icons/pin.svg"); + -webkit-mask-image: url("/plugins/PinnedNotes/assets/icons/pin.svg"); + mask-image: url("/plugins/PinnedNotes/assets/icons/pin.svg"); +} +.pin-button-container.pinned { + opacity: 1 !important; +} +.feed.pinned { + margin-top: 2em; + margin-bottom: 6em; +} diff --git a/public/plugins/PinnedNotes/assets/icons/pin.svg b/public/plugins/PinnedNotes/assets/icons/pin.svg new file mode 100644 index 0000000000..a812b34e91 --- /dev/null +++ b/public/plugins/PinnedNotes/assets/icons/pin.svg @@ -0,0 +1,4 @@ + + + +