[COMPONENTS][Conversation] Local Conversations done

[COMPONENTS][Posting] Call Conversation::assignLocalConversation upon
creating a new note

By using the AddExtraArgsToNoteContent event upon posting a Note, an
extra argument ('reply_to') is added before storing the aforementioned Note.
When storeLocalNote eventually creates the Note, the corresponding
Conversation is assigned.
This commit is contained in:
Eliseu Amaro 2021-12-19 17:43:43 +00:00 committed by Diogo Peralta Cordeiro
parent 3ca7a35158
commit 48b2c8c04e
Signed by: diogo
GPG Key ID: 18D2D35001FBFAB0
7 changed files with 123 additions and 160 deletions

View File

@ -26,46 +26,31 @@ declare(strict_types = 1);
namespace Component\Conversation\Controller; namespace Component\Conversation\Controller;
use _PHPStan_76800bfb5\Nette\NotImplementedException;
use App\Core\Controller\FeedController; use App\Core\Controller\FeedController;
use App\Core\DB\DB; use App\Core\DB\DB;
use App\Core\Form;
use App\Util\Exception\DuplicateFoundException;
use App\Util\Exception\NoLoggedInUser;
use App\Util\Exception\ServerException;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\InvalidFormException;
use App\Util\Exception\NoSuchNoteException;
use App\Util\Exception\RedirectException;
use App\Util\Form\FormFields;
use Component\Posting\Posting;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class Conversation extends FeedController class Conversation extends FeedController
{ {
// if note is root -> just link /**
// if note is a reply -> link from above plus anchor * Render conversation page
public function ConversationShow(Request $request) *
* @return array
*/
public function showConversation(Request $request, int $conversation_id)
{ {
throw new NotImplementedException(); // TODO:
$actor_id = Common::ensureLoggedIn()->getId(); // if note is root -> just link
$notes = DB::dql('select n from App\Entity\Note n ' // if note is a reply -> link from above plus anchor
. 'where n.reply_to is not null and n.actor_id = :id '
. 'order by n.created DESC', ['id' => $actor_id], ); $notes = DB::dql('select n from App\Entity\Note n '
. 'on n.conversation_id = :id '
. 'order by n.created DESC', ['id' => $conversation_id], );
return [ return [
'_template' => 'feeds/feed.html.twig', '_template' => 'feeds/feed.html.twig',
'notes' => $notes, 'notes' => $notes,
'should_format' => false, 'should_format' => false,
'page_title' => 'Replies feed', 'page_title' => 'Conversation',
]; ];
} }
} }

View File

@ -28,25 +28,12 @@ namespace Component\Conversation\Controller;
use App\Core\Controller\FeedController; use App\Core\Controller\FeedController;
use App\Core\DB\DB; use App\Core\DB\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Entity\Note; use App\Entity\Note;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\ClientException; use App\Util\Exception\ClientException;
use App\Util\Exception\DuplicateFoundException;
use App\Util\Exception\InvalidFormException;
use App\Util\Exception\NoLoggedInUser; use App\Util\Exception\NoLoggedInUser;
use App\Util\Exception\NoSuchNoteException; use App\Util\Exception\NoSuchNoteException;
use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException; use App\Util\Exception\ServerException;
use App\Util\Form\FormFields;
use Component\Posting\Posting;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class Reply extends FeedController class Reply extends FeedController
@ -55,110 +42,45 @@ class Reply extends FeedController
* Controller for the note reply non-JS page * Controller for the note reply non-JS page
* *
* @throws ClientException * @throws ClientException
* @throws DuplicateFoundException
* @throws InvalidFormException
* @throws NoLoggedInUser * @throws NoLoggedInUser
* @throws NoSuchNoteException * @throws NoSuchNoteException
* @throws RedirectException
* @throws ServerException * @throws ServerException
* *
* @return array * @return array
*/ */
public function replyAddNote(Request $request, int $id) public function addReply(Request $request, int $note_id, int $actor_id)
{ {
$user = Common::ensureLoggedIn(); $user = Common::ensureLoggedIn();
$actor_id = $user->getId();
$note = Note::getByPK($id); $note = Note::getByPK($note_id);
if (\is_null($note) || !$note->isVisibleTo($user)) { if (\is_null($note) || !$note->isVisibleTo($user)) {
throw new NoSuchNoteException(); throw new NoSuchNoteException();
} }
/*
* TODO shouldn't this be the posting form?
* Posting needs to be improved to do that. Currently, if it was used here,
* there are only slow ways to retrieve the resulting note.
* Not only is it part of a right panel event, there's an immediate redirect exception
* after submitting it.
* That event needs to be extended to allow this component to automagically fill the To: field and get the
* resulting note
*/
$form = Form::create([
['content', TextareaType::class, ['label' => _m('Reply'), 'label_attr' => ['class' => 'section-form-label'], 'help' => _m('Please input your reply.')]],
FormFields::language(
$user->getActor(),
context_actor: $note->getActor(),
label: _m('Note language'),
),
['attachments', FileType::class, ['label' => ' ', 'multiple' => true, 'required' => false]],
['replyform', SubmitType::class, ['label' => _m('Submit')]],
]);
$form->handleRequest($request);
if ($form->isSubmitted()) {
$data = $form->getData();
if ($form->isValid()) {
// Create a new note with the same content as the original
$reply = Posting::storeLocalNote(
actor: Actor::getByPK($actor_id),
content: $data['content'],
content_type: 'text/plain', // TODO
language: $data['language'],
attachments: $data['attachments'],
);
// Update DB
DB::persist($reply);
DB::flush();
// Find the id of the note we just created
$reply_id = $reply->getId();
$parent_id = $note->getId();
$resulting_note = Note::getByPK($reply_id);
$resulting_note->setReplyTo($parent_id);
// Update DB one last time
DB::merge($note);
DB::flush();
// Redirect user to where they came from
// Prevent open redirect
if (!\is_null($from = $this->string('from'))) {
if (Router::isAbsolute($from)) {
Log::warning("Actor {$actor_id} attempted to reply to a note and then get redirected to another host, or the URL was invalid ({$from})");
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
} else {
// TODO anchor on element id
throw new RedirectException($from);
}
} else {
// If we don't have a URL to return to, go to the instance root
throw new RedirectException('root');
}
} else {
throw new InvalidFormException();
}
}
return [ return [
'_template' => 'reply/add_reply.html.twig', '_template' => 'reply/add_reply.html.twig',
'note' => $note, 'note' => $note,
'add_reply' => $form->createView(),
]; ];
} }
public function replies(Request $request) /**
* Render actor replies page
*
* @throws NoLoggedInUser
*
* @return array
*/
public function showReplies(Request $request)
{ {
$actor_id = Common::ensureLoggedIn()->getId(); $actor_id = Common::ensureLoggedIn()->getId();
$notes = DB::dql('select n from App\Entity\Note n ' $notes = DB::dql('select n from App\Entity\Note n '
. 'where n.reply_to is not null and n.actor_id = :id ' . 'where n.reply_to is not null and n.actor_id = :id '
. 'order by n.created DESC', ['id' => $actor_id], ); . 'order by n.created DESC', ['id' => $actor_id], );
return [ return [
'_template' => 'feeds/feed.html.twig', '_template' => 'feeds/feed.html.twig',
'notes' => $notes, 'notes' => $notes,
'should_format' => false, 'should_format' => false,
'page_title' => 'Replies feed', 'page_title' => 'Replies feed',
]; ];
} }
} }

View File

@ -38,10 +38,49 @@ use App\Util\Exception\ServerException;
use App\Util\Formatting; use App\Util\Formatting;
use App\Util\Nickname; use App\Util\Nickname;
use Component\Conversation\Controller\Reply as ReplyController; use Component\Conversation\Controller\Reply as ReplyController;
use Component\Conversation\Entity\Conversation as ConversationEntity;
use const SORT_REGULAR;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class Conversation extends Component class Conversation extends Component
{ {
/**
* **Assigns** the given local Note it's corresponding **Conversation**
*
* **If a _$parent_id_ is not given**, then the Actor is not attempting a reply,
* therefore, we can assume (for now) that we need to create a new Conversation and assign it
* to the newly created Note (please look at Component\Posting::storeLocalNote, where this function is used)
*
* **On the other hand**, given a _$parent_id_, the Actor is attempting to post a reply. Meaning that,
* this Note conversation_id should be same as the parent Note
*/
public static function assignLocalConversation(Note $current_note, ?int $parent_id): void
{
if (!$parent_id) {
// If none found, we don't know yet if it is a reply or root
// Let's assume for now that it's a new conversation and deal with stitching later
$conversation = ConversationEntity::create(['initial_note_id' => $current_note->getId()]);
// We need the Conversation id itself, so a persist is in order
DB::persist($conversation);
// Set the Uri and current_note's own conversation_id
$conversation->setUri(Router::url('conversation', ['conversation_id' => $conversation->getId()], Router::ABSOLUTE_URL));
$current_note->setConversationId($conversation->getId());
} else {
// It's a reply for sure
// Set reply_to property in newly created Note to parent's id
$current_note->setReplyTo($parent_id);
// Parent will have a conversation of its own, the reply should have the same one
$parent_note = Note::getById($parent_id);
$current_note->setConversationId($parent_note->getConversationId());
}
DB::merge($current_note);
DB::flush();
}
/** /**
* HTML rendering event that adds a reply link as a note * HTML rendering event that adds a reply link as a note
* action, if a user is logged in * action, if a user is logged in
@ -52,14 +91,15 @@ class Conversation extends Component
return Event::next; return Event::next;
} }
// Generating URL for repeat action route // Generating URL for reply action route
$args = ['id' => $note->getId()]; $args = ['note_id' => $note->getId(), 'actor_id' => $note->getActor()->getId()];
$type = Router::ABSOLUTE_PATH; $type = Router::ABSOLUTE_PATH;
$reply_action_url = Router::url('reply_add', $args, $type); $reply_action_url = Router::url('reply_add', $args, $type);
$query_string = $request->getQueryString(); $query_string = $request->getQueryString();
// Concatenating get parameter to redirect the user to where he came from // Concatenating get parameter to redirect the user to where he came from
$reply_action_url .= !\is_null($query_string) ? '?from=' . mb_substr($query_string, 2) : ''; $reply_action_url .= !\is_null($query_string) ? '?from=' : '&from=';
$reply_action_url .= mb_substr($query_string, 2);
$reply_action = [ $reply_action = [
'url' => $reply_action_url, 'url' => $reply_action_url,
@ -72,6 +112,14 @@ class Conversation extends Component
return Event::next; return Event::next;
} }
public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args): bool
{
// If Actor is adding a reply, get parent's Note id
// Else it's null
$extra_args['reply_to'] = $request->get('_route') === 'reply_add' ? (int) $request->get('note_id') : null;
return Event::next;
}
/** /**
* Append on note information about user actions * Append on note information about user actions
*/ */
@ -95,7 +143,7 @@ class Conversation extends Component
} }
// Filter out multiple replies from the same actor // Filter out multiple replies from the same actor
$reply_actor = array_unique($reply_actor, \SORT_REGULAR); $reply_actor = array_unique($reply_actor, SORT_REGULAR);
// Add to complementary info // Add to complementary info
foreach ($reply_actor as $actor) { foreach ($reply_actor as $actor) {
@ -125,27 +173,16 @@ class Conversation extends Component
return Event::next; return Event::next;
} }
public function onProcessNoteContent(Note $note, string $content): bool public function onAddRoute(RouteLoader $r): bool
{ {
// If the source lacks capability of sending the "reply_to" $r->connect('reply_add', '/object/note/new?to={actor_id<\d+>}&reply_to={note_id<\d+>}', [ReplyController::class, 'addReply']);
// metadata, let's try to find an inline reply_to-reference. $r->connect('replies', '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/replies', [ReplyController::class, 'showReplies']);
// TODO: preg match any reply_to reference and handle reply to funky business (see Link component) $r->connect('conversation', '/conversation/{conversation_id<\d+>}', [Controller\Conversation::class, 'showConversation']);
return Event::next;
}
/**
* @return bool
*/
public function onAddRoute(RouteLoader $r)
{
$r->connect('reply_add', '/object/note/{id<\d+>}/reply', [ReplyController::class, 'replyAddNote']);
$r->connect('replies', '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/replies', [ReplyController::class, 'replies']);
$r->connect('conversation', '/conversation/{id<\d+>}', [ReplyController::class, 'conversation']);
return Event::next; return Event::next;
} }
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering) public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering): bool
{ {
DB::persist(Feed::create([ DB::persist(Feed::create([
'actor_id' => $actor_id, 'actor_id' => $actor_id,

View File

@ -23,9 +23,7 @@ declare(strict_types = 1);
namespace Component\Conversation\Entity; namespace Component\Conversation\Entity;
use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use App\Entity\Note;
/** /**
* Entity class for Conversations * Entity class for Conversations
@ -83,7 +81,6 @@ class Conversation extends Entity
return $this->initial_note_id; return $this->initial_note_id;
} }
// @codeCoverageIgnoreEnd // @codeCoverageIgnoreEnd
// }}} Autocode // }}} Autocode
@ -92,11 +89,11 @@ class Conversation extends Entity
return [ return [
'name' => 'conversation', 'name' => 'conversation',
'fields' => [ 'fields' => [
'id' => ['type' => 'serial', 'not null' => true, 'description' => 'Serial identifier, since any additional meaning would require updating its value for every reply upon receiving a new aparent root'], 'id' => ['type' => 'serial', 'not null' => true, 'description' => 'Serial identifier, since any additional meaning would require updating its value for every reply upon receiving a new aparent root'],
'uri' => ['type' => 'varchar', 'not null' => true, 'length' => 191, 'description' => 'URI of the conversation'], 'uri' => ['type' => 'varchar', 'not null' => true, 'length' => 191, 'description' => 'URI of the conversation'],
'initial_note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'Initial note seen on this host for this conversation'], 'initial_note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'Initial note seen on this host for this conversation'],
], ],
'primary key' => ['id'], 'primary key' => ['id'],
'unique keys' => [ 'unique keys' => [
'conversation_uri_uniq' => ['uri'], 'conversation_uri_uniq' => ['uri'],
], ],

View File

@ -13,7 +13,6 @@
<div class="page"> <div class="page">
<div class="main"> <div class="main">
{{ noteView.macro_note_minimal(note) }} {{ noteView.macro_note_minimal(note) }}
{{ form(add_reply) }}
</div> </div>
</div> </div>
{% endblock body %} {% endblock body %}

View File

@ -25,6 +25,7 @@ namespace Component\Posting;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\DB; use App\Core\DB\DB;
use App\Core\Entity;
use App\Core\Event; use App\Core\Event;
use App\Core\Form; use App\Core\Form;
use App\Core\GSFile; use App\Core\GSFile;
@ -39,6 +40,7 @@ use App\Entity\Language;
use App\Entity\Note; use App\Entity\Note;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\ClientException; use App\Util\Exception\ClientException;
use App\Util\Exception\DuplicateFoundException;
use App\Util\Exception\RedirectException; use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException; use App\Util\Exception\ServerException;
use App\Util\Form\FormFields; use App\Util\Form\FormFields;
@ -46,6 +48,7 @@ use App\Util\Formatting;
use Component\Attachment\Entity\ActorToAttachment; use Component\Attachment\Entity\ActorToAttachment;
use Component\Attachment\Entity\Attachment; use Component\Attachment\Entity\Attachment;
use Component\Attachment\Entity\AttachmentToNote; use Component\Attachment\Entity\AttachmentToNote;
use Component\Conversation\Conversation;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@ -67,7 +70,7 @@ class Posting extends Component
*/ */
public function onAppendRightPostingBlock(Request $request, array &$res): bool public function onAppendRightPostingBlock(Request $request, array &$res): bool
{ {
if (($user = Common::user()) === null) { if (\is_null($user = Common::user())) {
return Event::next; return Event::next;
} }
@ -95,8 +98,22 @@ class Posting extends Component
]; ];
Event::handle('PostingAvailableContentTypes', [&$available_content_types]); Event::handle('PostingAvailableContentTypes', [&$available_content_types]);
$context_actor = null; // This is where we'd plug in the group in which the actor is posting, or whom they're replying to // TODO: this needs work
$form_params = [ // This is where we'd plug in the group in which the actor is posting, or whom they're replying to
// store local note needs to know what conversation it is
// Conversation adds the respective query string on route url, for groups it should be handled by an event
$to_query = $request->get('actor_id');
$context_actor = null;
// Actor is posting in a group?
if (!\is_null($to_query)) {
// Getting the actor itself
$context_actor = Actor::getById((int) $to_query);
// Adding it to the to_tags array TODO: this is wrong
$to_tags[] = $context_actor->getNickname();
}
$form_params = [
['to', ChoiceType::class, ['label' => _m('To:'), 'multiple' => false, 'expanded' => false, 'choices' => $to_tags]], ['to', ChoiceType::class, ['label' => _m('To:'), 'multiple' => false, 'expanded' => false, 'choices' => $to_tags]],
['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'multiple' => false, 'expanded' => false, 'data' => 'public', 'choices' => [_m('Public') => 'public', _m('Instance') => 'instance', _m('Private') => 'private']]], ['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'multiple' => false, 'expanded' => false, 'data' => 'public', 'choices' => [_m('Public') => 'public', _m('Instance') => 'instance', _m('Private') => 'private']]],
['content', TextareaType::class, ['label' => _m('Content:'), 'data' => $initial_content, 'attr' => ['placeholder' => _m($placeholder)], 'constraints' => [new Length(['max' => Common::config('site', 'text_limit')])]]], ['content', TextareaType::class, ['label' => _m('Content:'), 'data' => $initial_content, 'attr' => ['placeholder' => _m($placeholder)], 'constraints' => [new Length(['max' => Common::config('site', 'text_limit')])]]],
@ -131,7 +148,7 @@ class Posting extends Component
$content_type = $data['content_type'] ?? $available_content_types[array_key_first($available_content_types)]; $content_type = $data['content_type'] ?? $available_content_types[array_key_first($available_content_types)];
$extra_args = []; $extra_args = [];
Event::handle('PostingHandleForm', [$request, $actor, $data, &$extra_args, $form_params, $form]); Event::handle('AddExtraArgsToNoteContent', [$request, $actor, $data, &$extra_args, $form_params, $form]);
self::storeLocalNote( self::storeLocalNote(
$user->getActor(), $user->getActor(),
@ -141,6 +158,7 @@ class Posting extends Component
$data['attachments'], $data['attachments'],
process_note_content_extra_args: $extra_args, process_note_content_extra_args: $extra_args,
); );
throw new RedirectException(); throw new RedirectException();
} }
} catch (FormSizeFileException $sizeFileException) { } catch (FormSizeFileException $sizeFileException) {
@ -162,11 +180,11 @@ class Posting extends Component
* @param array $processed_attachments Array of [Attachment, Attachment's name] to be associated to this $actor and Note * @param array $processed_attachments Array of [Attachment, Attachment's name] to be associated to this $actor and Note
* @param array $process_note_content_extra_args Extra arguments for the event ProcessNoteContent * @param array $process_note_content_extra_args Extra arguments for the event ProcessNoteContent
* *
* @throws \App\Util\Exception\DuplicateFoundException
* @throws ClientException * @throws ClientException
* @throws DuplicateFoundException
* @throws ServerException * @throws ServerException
* *
* @return \App\Core\Entity|mixed * @return Entity|mixed
*/ */
public static function storeLocalNote( public static function storeLocalNote(
Actor $actor, Actor $actor,
@ -212,6 +230,11 @@ class Posting extends Component
Event::handle('ProcessNoteContent', [$note, $content, $content_type, $process_note_content_extra_args]); Event::handle('ProcessNoteContent', [$note, $content, $content_type, $process_note_content_extra_args]);
} }
// Assign conversation to this note
// AddExtraArgsToNoteContent already added the info we need
$reply_to = $process_note_content_extra_args['reply_to'];
Conversation::assignLocalConversation($note, $reply_to);
if ($processed_attachments !== []) { if ($processed_attachments !== []) {
foreach ($processed_attachments as [$a, $fname]) { foreach ($processed_attachments as [$a, $fname]) {
if (DB::count('actor_to_attachment', $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor->getId()]) === 0) { if (DB::count('actor_to_attachment', $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor->getId()]) === 0) {

View File

@ -184,7 +184,7 @@ class Tag extends Component
return Event::next; return Event::next;
} }
public function onPostingHandleForm(Request $request, Actor $actor, array $data, array &$extra_args) public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args)
{ {
if (!isset($data['tag_use_canonical'])) { if (!isset($data['tag_use_canonical'])) {
throw new ClientException; throw new ClientException;