diff --git a/components/Posting/Controller/Post.php b/components/Posting/Controller/Post.php index 5bf96d4a65..7f19ffabbe 100644 --- a/components/Posting/Controller/Post.php +++ b/components/Posting/Controller/Post.php @@ -19,27 +19,74 @@ namespace Component\Posting\Controller; +use App\Core\DB\DB; use App\Core\Form; -use App\Core\Module; +use function App\Core\I18n\_m; +use App\Entity\FileToNote; +use App\Entity\Note; +use App\Util\Common; +use App\Util\Exception\ClientException; use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\HttpFoundation\Request; -use function App\Core\I18n\_m; -class Post extends Module { - public function reply(Request $r) { +class Post +{ + public function reply(Request $request, string $reply_to) + { + $note = DB::find('note', ['id' => $reply_to]); + if ($note == null) { + throw new ClientException(_m('No such note')); + } + + $actor_id = Common::ensureLoggedIn()->getActor()->getId(); $form = Form::create([ - ['avatar', FileType::class, ['label' => _m('Avatar'), 'help' => _m('You can upload your personal avatar. The maximum file size is 2MB.')]], - ['reply_to', HiddenType::class, []], - ['save', SubmitType::class, ['label' => _m('Submit')]], + ['reply_to', HiddenType::class, ['data' => (int) $reply_to]], + ['content', TextareaType::class, ['label' => ' ']], + ['attachments', FileType::class, ['label' => ' ', 'multiple' => true, 'required' => false]], + ['save', SubmitType::class, ['label' => _m('Submit')]], ]); $form->handleRequest($request); - if ($form->isSubmitted() && $form->isValid()) { - $data = $form->getData(); - $sfile = null; + if ($form->isSubmitted()) { + $data = $form->getData(); + if ($form->isValid()) { + self::storeNote($actor_id, $data['content'], $data['attachments'], $is_local = true, $data['reply_to']); + } else { + // TODO display errors + } + } + + return [ + '_template' => 'note/reply.html.twig', + 'note' => $note, + 'reply' => $form->createView(), + ]; } -} \ No newline at end of file + + public static function storeNote(int $actor_id, string $content, array $attachments, bool $is_local, ?int $reply_to = null) + { + $note = Note::create(['gsactor_id' => $actor_id, 'content' => $content, 'is_local' => $is_local, 'reply_to' => $reply_to]); + $files = []; + foreach ($attachments as $f) { + $nf = Media::validateAndStoreFile($f, Common::config('attachments', 'dir'), + Security::sanitize($title = $f->getClientOriginalName()), + $is_local = true, $actor_id); + $files[] = $nf; + DB::persist($nf); + } + DB::persist($note); + // Need file and note ids for the next step + DB::flush(); + if ($attachments != []) { + foreach ($files as $f) { + DB::persist(FileToNote::create(['file_id' => $f->getId(), 'note_id' => $note->getId()])); + } + DB::flush(); + } + } +} diff --git a/components/Posting/Posting.php b/components/Posting/Posting.php index a5ad9724b3..53de5b75dd 100644 --- a/components/Posting/Posting.php +++ b/components/Posting/Posting.php @@ -24,11 +24,7 @@ use App\Core\Event; use App\Core\Form; use function App\Core\I18n\_m; use App\Core\Module; -use App\Core\Security; -use App\Entity\FileToNote; -use App\Entity\Note; use App\Util\Common; -use Component\Media\Media; use Component\Posting\Controller as C; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\FileType; @@ -39,8 +35,7 @@ class Posting extends Module { public function onAddRoute($r) { - $r->connect('note_new', '/note/new/{reply_to<\d*>}', - [C\Post::class, 'note'], []); + $r->connect('note_reply', '/note/reply/{reply_to<\d*>}', [C\Post::class, 'reply']); } public function onStartTwigPopulateVars(array &$vars) @@ -49,60 +44,30 @@ class Posting extends Module return; } - $to_options = ['public' => _m('public'), 'instance' => _m('instance'), 'private' => _m('private')]; - - $id = Common::actor()->getId(); - $to_tags = []; - foreach (DB::dql('select c.tag from App\Entity\GSActorCircle c where c.tagger = :tagger', ['tagger' => $id]) as $t) { + $actor_id = Common::actor()->getId(); + $to_tags = []; + foreach (DB::dql('select c.tag from App\Entity\GSActorCircle c where c.tagger = :tagger', ['tagger' => $actor_id]) as $t) { $t = $t['tag']; $to_tags[$t] = $t; } - $empty_string = ['How are you feeling?...', 'Something to share?...', 'How was your day?...']; - $rand_keys = array_rand($empty_string, 1); + $placeholder_string = ['How are you feeling?', 'Have something to share?', 'How was your day?']; + $rand_key = array_rand($placeholder_string); $request = $vars['request']; $form = Form::create([ - ['content', TextareaType::class, [ - 'label' => ' ', - 'data' => _m($empty_string[$rand_keys]), - ]], - ['attachments', FileType::class, ['label' => _m(' '), 'multiple' => true, 'required' => false]], - ['visibility', ChoiceType::class, [ - 'label' => 'Visibility:', - 'expanded' => true, - 'choices' => $to_options, - ]], - ['to', ChoiceType::class, [ - 'label' => 'To:', - 'multiple' => true, - 'expanded' => true, - 'choices' => $to_tags, - ]], - ['send', SubmitType::class, ['label' => _m('Send')]], + ['content', TextareaType::class, ['label' => ' ', 'data' => '', 'attr' => ['placeholder' => _m($placeholder_string[$rand_key])]]], + ['attachments', FileType::class, ['label' => ' ', 'data' => null, 'multiple' => true, 'required' => false]], + ['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'expanded' => true, 'choices' => [_m('Public') => 'public', _m('Instance') => 'instance', _m('Private') => 'private']]], + ['to', ChoiceType::class, ['label' => _m('To:'), 'multiple' => true, 'expanded' => true, 'choices' => $to_tags]], + ['send', SubmitType::class, ['label' => _m('Send')]], ]); $form->handleRequest($request); if ($form->isSubmitted()) { $data = $form->getData(); if ($form->isValid()) { - $content = $data['content']; - $note = Note::create(['gsactor_id' => $id, 'content' => $content]); - $files = []; - foreach ($data['attachments'] as $f) { - $nf = Media::validateAndStoreFile($f, Common::config('attachments', 'dir'), - Security::sanitize($title = $f->getClientOriginalName()), - $is_local = true, $actor_id = $id); - $files[] = $nf; - DB::persist($nf); - } - DB::persist($note); - // Need file and note ids for the next step - DB::flush(); - foreach ($files as $f) { - DB::persist(FileToNote::create(['file_id' => $f->getId(), 'note_id' => $note->getId()])); - } - DB::flush(); + Post::storeNote($actor_id, $data['content'], $data['attachments'], $is_local = true); } else { // TODO Display error } diff --git a/public/assets/css/network/public.css b/public/assets/css/network/public.css index 9bf7054ab2..802fcb5989 100644 --- a/public/assets/css/network/public.css +++ b/public/assets/css/network/public.css @@ -97,13 +97,13 @@ font-size: var(--unit-size); } -.notice { +.note { display: flex; flex-wrap: wrap; border: solid 2px var(--accent-low); border-radius: var(--small-size); } -.notice-info { +.note-info { background-color: var(--bg1); box-sizing: border-box; padding: 5px; @@ -114,14 +114,14 @@ flex-shrink: 0; width: 100%; } -.notice-info b { +.note-info b { margin-left: var(--small-size); } -.notice-info a img { +.note-info a img { width: calc(2 * var(--unit-size)); height: auto; } -.notice-content { +.note-content { word-break: break-word; padding: var(--small-size); border-radius: 0 0 var(--small-size) var(--small-size); @@ -236,7 +236,7 @@ } /* options */ -.notice-options { +.note-options { order: 3; display: inline-flex; justify-content: flex-start; @@ -246,4 +246,4 @@ } .send { margin-left: auto; -} \ No newline at end of file +} diff --git a/public/assets/css/network/public_mid.css b/public/assets/css/network/public_mid.css index 70edb8045e..4a4ee3f422 100644 --- a/public/assets/css/network/public_mid.css +++ b/public/assets/css/network/public_mid.css @@ -64,7 +64,7 @@ transition: all 0.8s ease; } -/* notices */ +/* notes */ .notes { display: flex; flex-wrap: wrap; @@ -97,13 +97,13 @@ font-size: var(--unit-size); } -.notice { +.note { display: flex; flex-wrap: wrap; border: solid 2px var(--accent-low); border-radius: var(--small-size); } -.notice-info { +.note-info { background-color: var(--bg1); box-sizing: border-box; padding: 5px; @@ -114,11 +114,11 @@ flex-shrink: 0; width: 100%; } -.notice-info a img { +.note-info a img { width: calc(2 * var(--unit-size)); height: auto; } -.notice-content { +.note-content { word-break: break-word; padding: var(--small-size); border-radius: 0 0 var(--small-size) var(--small-size); @@ -129,7 +129,7 @@ display: flex; flex: max-content; } -.create-notice { +.create-note { display: flex; flex-wrap: wrap; flex: max-content; @@ -233,7 +233,7 @@ } /* options */ -.notice-options { +.note-options { order: 3; display: inline-flex; justify-content: flex-start; @@ -243,4 +243,4 @@ } .send { margin-left: auto; -} \ No newline at end of file +} diff --git a/public/assets/css/network/public_small.css b/public/assets/css/network/public_small.css index 4fc2593dad..203557d40d 100644 --- a/public/assets/css/network/public_small.css +++ b/public/assets/css/network/public_small.css @@ -64,7 +64,7 @@ transition: all 0.8s ease; } -/* notices */ +/* notes */ .notes { display: flex; flex-wrap: wrap; @@ -97,13 +97,13 @@ font-size: var(--unit-size); } -.notice { +.note { display: flex; flex-wrap: wrap; border: solid 2px var(--accent-low); border-radius: var(--small-size); } -.notice-info { +.note-info { background-color: var(--bg1); box-sizing: border-box; padding: 5px; @@ -114,11 +114,11 @@ flex-shrink: 0; width: 100%; } -.notice-info a img { +.note-info a img { width: calc(2 * var(--unit-size)); height: auto; } -.notice-content { +.note-content { word-break: break-word; padding: var(--small-size); border-radius: 0 0 var(--small-size) var(--small-size); @@ -129,7 +129,7 @@ display: flex; flex: max-content; } -.create-notice { +.create-note { display: flex; flex-wrap: wrap; flex: max-content; @@ -233,7 +233,7 @@ } /* options */ -.notice-options { +.note-options { order: 3; display: inline-flex; justify-content: flex-start; @@ -243,4 +243,4 @@ } .send { margin-left: auto; -} \ No newline at end of file +} diff --git a/src/Core/DB/DB.php b/src/Core/DB/DB.php index c0ea9fb712..977772cb64 100644 --- a/src/Core/DB/DB.php +++ b/src/Core/DB/DB.php @@ -45,7 +45,7 @@ abstract class DB self::$em = $m; } - public static function dql(string $query, ?array $params) + public static function dql(string $query, array $params = []) { $q = new Query(self::$em); $q->setDQL($query); diff --git a/src/Core/Entity.php b/src/Core/Entity.php index f2346b45e6..d221881427 100644 --- a/src/Core/Entity.php +++ b/src/Core/Entity.php @@ -33,7 +33,7 @@ class Entity $obj = $obj ?: new $class(); $args['created'] = $args['modified'] = new DateTime(); foreach ($args as $prop => $val) { - if (property_exists($class, $prop)) { + if (property_exists($class, $prop) && $val != null) { $set = 'set' . Formatting::snakeCaseToCamelCase($prop); $obj->{$set}($val); } else { diff --git a/src/Entity/Note.php b/src/Entity/Note.php index b2733f797f..7c48635ecb 100644 --- a/src/Entity/Note.php +++ b/src/Entity/Note.php @@ -21,6 +21,7 @@ namespace App\Entity; +use App\Core\Cache; use App\Core\DB\DB; use App\Core\Entity; use App\Core\Event; @@ -187,6 +188,23 @@ class Note extends Entity return $url; } + public function getAttachments(): array + { + return Cache::get('note-attachments-' . $this->id, function () { + return DB::dql( + 'select f from App\Entity\File f ' . + 'join App\Entity\FileToNote ftn with ftn.file_id = f.id ' . + 'where ftn.note_id = :note_id', + ['note_id' => $this->id] + ); + }); + } + + public function getReplies(): array + { + return Cache::getList('note-replies-' . $this->id, function () { return DB::dql('select n from App\Entity\Note n where n.reply_to = :id', ['id' => $this->id]); }); + } + public static function schemaDef(): array { return [ diff --git a/src/Routes/Main.php b/src/Routes/Main.php index cbaa2db082..03c9dda0fb 100644 --- a/src/Routes/Main.php +++ b/src/Routes/Main.php @@ -47,8 +47,8 @@ abstract class Main $r->connect('register', '/register', [C\Security::class, 'register']); $r->connect('root', '/', RedirectController::class, ['defaults' => ['route' => 'main_all']]); - $r->connect('main_all', '/main/all', [C\NetworkPublic::class, 'public']); - $r->connect('home_all', '/{nickname<' . Nickname::DISPLAY_FMT . '>}/all', [C\NetworkPublic::class, 'home']); + $r->connect('main_all', '/main/all', [C\Network::class, 'public']); + $r->connect('home_all', '/{nickname<' . Nickname::DISPLAY_FMT . '>}/all', [C\Network::class, 'home']); $r->connect('panel', '/panel', [C\AdminPanel::class, 'site']); $r->connect('panel_site', '/panel/site', [C\AdminPanel::class, 'site']); diff --git a/src/Util/Common.php b/src/Util/Common.php index 67bc42cdf3..af0bf3dc98 100644 --- a/src/Util/Common.php +++ b/src/Util/Common.php @@ -81,6 +81,16 @@ abstract class Common return self::user()->getActor(); } + public static function ensureLoggedIn(): LocalUser + { + if (($user = self::user()) == null) { + throw new NoLoggedInUser(); + // TODO Maybe redirect to login page and back + } else { + return $user; + } + } + /** * Is the given string identical to a system path or route? * This could probably be put in some other class, but at diff --git a/src/Util/Exception/NoLoggedInUser.php b/src/Util/Exception/NoLoggedInUser.php new file mode 100644 index 0000000000..492b8df070 --- /dev/null +++ b/src/Util/Exception/NoLoggedInUser.php @@ -0,0 +1,34 @@ +. +// }}} + +namespace App\Util\Exception; + +/** + * No user logged in + * + * @category Exception + * @package GNUsocial + * + * @author Hugo Sales + * @copyright 2020 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ +class NoLoggedInUser extends NicknameInvalidException +{ +} diff --git a/templates/network/public.html.twig b/templates/network/public.html.twig index 9a35b5fcc8..dc2c6abb55 100644 --- a/templates/network/public.html.twig +++ b/templates/network/public.html.twig @@ -72,9 +72,11 @@
  • Public
  • -
  • + {% if user_nickname is defined %} +
  • Home -
  • + + {% endif %}
  • Network
  • @@ -84,25 +86,8 @@
    {% if notes is defined and notes is not empty %} {% for note in notes %} -
    -
    - - {{ note.getActorNickname() }}'s avatar - - {{ note.getActorNickname() }} -
    -
    - {{ note.getContent() }} - {% set id = note.getId() - 1 %} - {% for attachment in attachments[id] %} -
    - {{ attachment.getTitle() }} -
    - {% endfor %} -
    -
    -
    -
    + {% set id = note.getId() - 1 %} + {% include '/note/view.html.twig' with {'note': note} only %} {% endfor %} {% else %}

    {% trans %}No notes here.{% endtrans %}

    diff --git a/templates/note/reply.html.twig b/templates/note/reply.html.twig new file mode 100644 index 0000000000..564f6ecc8d --- /dev/null +++ b/templates/note/reply.html.twig @@ -0,0 +1,10 @@ +{% extends '/base.html.twig' %} + +{% block body %} +
    +
    + {% include '/note/view.html.twig' with {'note': note} only %} + {{ form(reply) }} +
    +
    +{% endblock body %} diff --git a/templates/note/view.html.twig b/templates/note/view.html.twig new file mode 100644 index 0000000000..b9f9bdcc9d --- /dev/null +++ b/templates/note/view.html.twig @@ -0,0 +1,28 @@ +
    +
    + + {{ note.getActorNickname() }}'s avatar + + {{ note.getActorNickname() }} +
    +
    + {{ note.getContent() }} + {% for attachment in note.getAttachments() %} +
    + {{ attachment.getTitle() }} +
    + {% endfor %} +
    + +
    + {% for reply in note.getReplies() %} + {% include '/note/view.html.twig' with {'note': reply} only %} + {% endfor %} +
    +