[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;
use _PHPStan_76800bfb5\Nette\NotImplementedException;
use App\Core\Controller\FeedController;
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;
class Conversation extends FeedController
{
// if note is root -> just link
// if note is a reply -> link from above plus anchor
public function ConversationShow(Request $request)
/**
* Render conversation page
*
* @return array
*/
public function showConversation(Request $request, int $conversation_id)
{
throw new NotImplementedException();
$actor_id = Common::ensureLoggedIn()->getId();
$notes = DB::dql('select n from App\Entity\Note n '
. 'where n.reply_to is not null and n.actor_id = :id '
. 'order by n.created DESC', ['id' => $actor_id], );
// TODO:
// if note is root -> just link
// if note is a reply -> link from above plus anchor
$notes = DB::dql('select n from App\Entity\Note n '
. 'on n.conversation_id = :id '
. 'order by n.created DESC', ['id' => $conversation_id], );
return [
'_template' => 'feeds/feed.html.twig',
'notes' => $notes,
'_template' => 'feeds/feed.html.twig',
'notes' => $notes,
'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\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\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\DuplicateFoundException;
use App\Util\Exception\InvalidFormException;
use App\Util\Exception\NoLoggedInUser;
use App\Util\Exception\NoSuchNoteException;
use App\Util\Exception\RedirectException;
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;
class Reply extends FeedController
@ -55,110 +42,45 @@ class Reply extends FeedController
* Controller for the note reply non-JS page
*
* @throws ClientException
* @throws DuplicateFoundException
* @throws InvalidFormException
* @throws NoLoggedInUser
* @throws NoSuchNoteException
* @throws RedirectException
* @throws ServerException
*
* @return array
*/
public function replyAddNote(Request $request, int $id)
public function addReply(Request $request, int $note_id, int $actor_id)
{
$user = Common::ensureLoggedIn();
$actor_id = $user->getId();
$user = Common::ensureLoggedIn();
$note = Note::getByPK($id);
$note = Note::getByPK($note_id);
if (\is_null($note) || !$note->isVisibleTo($user)) {
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 [
'_template' => 'reply/add_reply.html.twig',
'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();
$notes = DB::dql('select n from App\Entity\Note n '
. 'where n.reply_to is not null and n.actor_id = :id '
. 'order by n.created DESC', ['id' => $actor_id], );
. 'where n.reply_to is not null and n.actor_id = :id '
. 'order by n.created DESC', ['id' => $actor_id], );
return [
'_template' => 'feeds/feed.html.twig',
'notes' => $notes,
'_template' => 'feeds/feed.html.twig',
'notes' => $notes,
'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\Nickname;
use Component\Conversation\Controller\Reply as ReplyController;
use Component\Conversation\Entity\Conversation as ConversationEntity;
use const SORT_REGULAR;
use Symfony\Component\HttpFoundation\Request;
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
* action, if a user is logged in
@ -52,14 +91,15 @@ class Conversation extends Component
return Event::next;
}
// Generating URL for repeat action route
$args = ['id' => $note->getId()];
// Generating URL for reply action route
$args = ['note_id' => $note->getId(), 'actor_id' => $note->getActor()->getId()];
$type = Router::ABSOLUTE_PATH;
$reply_action_url = Router::url('reply_add', $args, $type);
$query_string = $request->getQueryString();
// 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 = [
'url' => $reply_action_url,
@ -72,6 +112,14 @@ class Conversation extends Component
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
*/
@ -95,7 +143,7 @@ class Conversation extends Component
}
// 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
foreach ($reply_actor as $actor) {
@ -125,27 +173,16 @@ class Conversation extends Component
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"
// metadata, let's try to find an inline reply_to-reference.
// TODO: preg match any reply_to reference and handle reply to funky business (see Link component)
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']);
$r->connect('reply_add', '/object/note/new?to={actor_id<\d+>}&reply_to={note_id<\d+>}', [ReplyController::class, 'addReply']);
$r->connect('replies', '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/replies', [ReplyController::class, 'showReplies']);
$r->connect('conversation', '/conversation/{conversation_id<\d+>}', [Controller\Conversation::class, 'showConversation']);
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([
'actor_id' => $actor_id,

View File

@ -23,9 +23,7 @@ declare(strict_types = 1);
namespace Component\Conversation\Entity;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Entity\Note;
/**
* Entity class for Conversations
@ -83,7 +81,6 @@ class Conversation extends Entity
return $this->initial_note_id;
}
// @codeCoverageIgnoreEnd
// }}} Autocode
@ -92,11 +89,11 @@ class Conversation extends Entity
return [
'name' => 'conversation',
'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'],
'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'],
'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'],
'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' => [
'conversation_uri_uniq' => ['uri'],
],

View File

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

View File

@ -25,6 +25,7 @@ namespace Component\Posting;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Core\Event;
use App\Core\Form;
use App\Core\GSFile;
@ -39,6 +40,7 @@ use App\Entity\Language;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\DuplicateFoundException;
use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException;
use App\Util\Form\FormFields;
@ -46,6 +48,7 @@ use App\Util\Formatting;
use Component\Attachment\Entity\ActorToAttachment;
use Component\Attachment\Entity\Attachment;
use Component\Attachment\Entity\AttachmentToNote;
use Component\Conversation\Conversation;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@ -67,7 +70,7 @@ class Posting extends Component
*/
public function onAppendRightPostingBlock(Request $request, array &$res): bool
{
if (($user = Common::user()) === null) {
if (\is_null($user = Common::user())) {
return Event::next;
}
@ -95,8 +98,22 @@ class Posting extends Component
];
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
$form_params = [
// TODO: this needs work
// 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]],
['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')])]]],
@ -131,7 +148,7 @@ class Posting extends Component
$content_type = $data['content_type'] ?? $available_content_types[array_key_first($available_content_types)];
$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(
$user->getActor(),
@ -141,6 +158,7 @@ class Posting extends Component
$data['attachments'],
process_note_content_extra_args: $extra_args,
);
throw new RedirectException();
}
} 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 $process_note_content_extra_args Extra arguments for the event ProcessNoteContent
*
* @throws \App\Util\Exception\DuplicateFoundException
* @throws ClientException
* @throws DuplicateFoundException
* @throws ServerException
*
* @return \App\Core\Entity|mixed
* @return Entity|mixed
*/
public static function storeLocalNote(
Actor $actor,
@ -212,6 +230,11 @@ class Posting extends Component
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 !== []) {
foreach ($processed_attachments as [$a, $fname]) {
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;
}
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'])) {
throw new ClientException;