Compare commits

...

4 Commits

8 changed files with 189 additions and 168 deletions

View File

@ -5,12 +5,16 @@ declare(strict_types = 1);
namespace Component\Collection\Util\Controller;
use App\Core\Controller;
use App\Entity\Actor;
use App\Util\Common;
use Component\Feed\Feed;
class Collection extends Controller
{
public function query(string $query, ?string $language = null, ?Actor $actor = null)
{
$actor ??= Common::actor();
$language ??= $actor->getTopLanguage()->getLocale();
return Feed::query($query, $this->int('page') ?? 1, $language, $actor);
}
}

View File

@ -27,11 +27,17 @@ declare(strict_types = 1);
namespace Component\Conversation\Controller;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\NoLoggedInUser;
use App\Util\Exception\NoSuchNoteException;
use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException;
use Component\Collection\Util\Controller\FeedController;
use Component\Conversation\Entity\ConversationMute;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@ -50,17 +56,35 @@ class Conversation extends FeedController
*/
public function showConversation(Request $request, int $conversation_id): array
{
$data = $this->query(query: "note-conversation:{$conversation_id}");
$notes = $data['notes'];
return [
'_template' => 'collection/notes.html.twig',
'notes' => $notes,
'notes' => $this->query(query: "note-conversation:{$conversation_id}")['notes'] ?? [],
'should_format' => false,
'page_title' => _m('Conversation'),
];
}
/**
* Controller for the note reply non-JS page
*
* Leverages the `PostingModifyData` event to add the `reply_to_id` field from the GET variable 'reply_to_id'
*
* @throws ClientException
* @throws NoLoggedInUser
* @throws NoSuchNoteException
* @throws ServerException
*
* @return array
*/
public function addReply(Request $request)
{
$user = Common::ensureLoggedIn();
$note_id = $this->int('reply_to_id', new ClientException(_m('Malformed query.')));
$note = Note::ensureCanInteract(Note::getByPK($note_id), $user);
$conversation_id = $note->getConversationId();
return $this->showConversation($request, $conversation_id);
}
/**
* Creates form view for Muting Conversation extra action.
*
@ -72,17 +96,23 @@ class Conversation extends FeedController
*
* @return array Array containing templating where the form is to be rendered, and the form itself
*/
public function muteConversation(Request $request, int $conversation_id): array
public function muteConversation(Request $request, int $conversation_id)
{
$user = Common::ensureLoggedIn();
$form = Form::create([
['mute_conversation', SubmitType::class, ['label' => _m('Mute conversation')]],
$user = Common::ensureLoggedIn();
$is_muted = ConversationMute::isMuted($conversation_id, $user);
$form = Form::create([
['mute_conversation', SubmitType::class, ['label' => $is_muted ? _m('Mute conversation') : _m('Unmute conversation')]],
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
DB::persist(ConversationMute::create(['conversation_id' => $conversation_id, 'actor_id' => $user->getId()]));
if ($is_muted) {
DB::persist(ConversationMute::create(['conversation_id' => $conversation_id, 'actor_id' => $user->getId()]));
} else {
DB::removeBy('conversation_mute', ['conversation_id' => $conversation_id, 'actor_id' => $user->getId()]);
}
DB::flush();
Cache::delete(ConversationMute::cacheKeys($conversation_id, $user->getId())['mute']);
throw new RedirectException();
}

View File

@ -1,71 +0,0 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
/**
* @author Hugo Sales <hugo@hsal.es>
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
namespace Component\Conversation\Controller;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\NoLoggedInUser;
use App\Util\Exception\NoSuchNoteException;
use App\Util\Exception\ServerException;
use Component\Collection\Util\Controller\FeedController;
use Component\Feed\Feed;
use Symfony\Component\HttpFoundation\Request;
use function App\Core\I18n\_m;
class Reply extends FeedController
{
/**
* Controller for the note reply non-JS page
*
* @throws ClientException
* @throws NoLoggedInUser
* @throws NoSuchNoteException
* @throws ServerException
*
* @return array
*/
public function addReply(Request $request, int $note_id)
{
$user = Common::ensureLoggedIn();
$note = Note::getByPK($note_id);
if (\is_null($note) || !$note->isVisibleTo($user)) {
throw new NoSuchNoteException();
}
$conversation_id = $note->getConversationId();
$data = $this->query(query: "note-conversation:{$conversation_id}");
$notes = $data['notes'];
return [
'_template' => 'collection/notes.html.twig',
'notes' => $notes,
'should_format' => false,
'page_title' => _m('Conversation'),
];
}
}

View File

@ -34,12 +34,22 @@ use App\Entity\Activity;
use App\Entity\Actor;
use App\Entity\Note;
use App\Util\Common;
use Component\Conversation\Controller\Reply as ReplyController;
use Component\Conversation\Entity\Conversation as ConversationEntity;
use Component\Conversation\Entity\ConversationMute;
use Functional as F;
use Symfony\Component\HttpFoundation\Request;
class Conversation extends Component
{
public function onAddRoute(RouteLoader $r): bool
{
$r->connect('conversation', '/conversation/{conversation_id<\d+>}', [Controller\Conversation::class, 'showConversation']);
$r->connect('conversation_mute', '/conversation/{conversation_id<\d+>}/mute', [Controller\Conversation::class, 'muteConversation']);
$r->connect('conversation_reply_to', '/conversation/reply', [Controller\Conversation::class, 'addReply']);
return Event::next;
}
/**
* **Assigns** the given local Note it's corresponding **Conversation**.
*
@ -96,14 +106,18 @@ class Conversation extends Component
return Event::next;
}
// Generating URL for reply action route
$args = ['note_id' => $note->getId()];
$type = Router::ABSOLUTE_PATH;
$reply_action_url = Router::url('conversation_reply_to', $args, $type);
$from = $request->query->has('from')
? $request->query->get('from')
: $request->getPathInfo();
$query_string = $request->getQueryString();
// Concatenating get parameter to redirect the user to where he came from
$reply_action_url .= '?from=' . urlencode($request->getRequestUri()) . '#note-anchor-' . $note->getId();
$reply_action_url = Router::url(
'conversation_reply_to',
[
'reply_to_id' => $note->getId(),
'from' => $from . '#note-anchor-' . $note->getId(),
],
Router::ABSOLUTE_PATH,
);
$reply_action = [
'url' => $reply_action_url,
@ -117,10 +131,18 @@ class Conversation extends Component
return Event::next;
}
public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args): bool
/**
* Posting event to add extra info to a note
*/
public function onPostingModifyData(Request $request, Actor $actor, array &$data): bool
{
$extra_args['reply_to'] = 'conversation_reply_to' === $request->get('_route') ? (int) $request->get('note_id') : null;
$data['reply_to_id'] = $request->get('_route') === 'conversation_reply_to' && $request->query->has('reply_to_id')
? $request->query->getInt('reply_to_id')
: null;
if (!\is_null($data['reply_to_id'])) {
Note::ensureCanInteract(Note::getById($data['reply_to_id']), $actor);
}
return Event::next;
}
@ -132,23 +154,17 @@ class Conversation extends Component
*/
public function onAppendCardNote(array $vars, array &$result): bool
{
// If note is the original and user isn't the one who repeated, append on end "user repeated this"
// If user is the one who repeated, append on end "you repeated this, remove repeat?"
$check_user = !\is_null(Common::user());
// The current Note being rendered
$note = $vars['note'];
// Will have actors array, and action string
// Actors are the subjects, action is the verb (in the final phrase)
$reply_actors = [];
$note_replies = $note->getReplies();
$reply_actors = F\map(
$note->getReplies(),
fn (Note $reply) => Actor::getByPK($reply->getActorId()),
);
// Get actors who repeated the note
foreach ($note_replies as $reply) {
$reply_actors[] = Actor::getByPK($reply->getActorId());
}
if (\count($reply_actors) < 1) {
if (empty($reply_actors)) {
return Event::next;
}
@ -159,20 +175,6 @@ class Conversation extends Component
return Event::next;
}
/**
* Connects the various Conversation related routes to their respective controllers.
*
* @return bool EventHook
*/
public function onAddRoute(RouteLoader $r): bool
{
$r->connect('conversation_reply_to', '/conversation/reply?reply_to_note={note_id<\d+>}', [ReplyController::class, 'addReply']);
$r->connect('conversation', '/conversation/{conversation_id<\d+>}', [Controller\Conversation::class, 'showConversation']);
$r->connect('conversation_mute', '/conversation/{conversation_id<\d+>}/mute', [Controller\Conversation::class, 'muteConversation']);
return Event::next;
}
/**
* Informs **\App\Component\Posting::onAppendRightPostingBlock**, of the **current page context** in which the given
* Actor is in. This is valuable when posting within a group route, allowing \App\Component\Posting to create a
@ -181,15 +183,14 @@ class Conversation extends Component
* @param \App\Entity\Actor $actor The Actor currently attempting to post a Note
* @param null|\App\Entity\Actor $context_actor The 'owner' of the current route (e.g. Group or Actor), used to target it
*/
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor &$context_actor): bool
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor &$context_actor)
{
// TODO: check if actor is posting in group, changing the context actor to that group
/*$to_query = $request->get('actor_id');
if (!\is_null($to_query)) {
$to_note_id = $request->query->get('reply_to_id');
if (!\is_null($to_note_id)) {
// Getting the actor itself
$context_actor = Actor::getById((int) $to_query);
$context_actor = Actor::getById(Note::getById((int) $to_note_id)->getActorId());
return Event::stop;
}*/
}
return Event::next;
}
@ -202,14 +203,10 @@ class Conversation extends Component
*/
public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
{
Cache::delete("note-replies-{$note->getId()}");
DB::wrapInTransaction(function () use ($note) {
foreach ($note->getReplies() as $reply) {
$reply->setReplyTo(null);
}
});
Cache::delete("note-replies-{$note->getId()}");
// Ensure we have the most up to date replies
Cache::delete(Note::cacheKeys($note->getId())['replies']);
DB::wrapInTransaction(fn () => F\each($note->getReplies(), fn (Note $note) => $note->setReplyTo(null)));
Cache::delete(Note::cacheKeys($note->getId())['replies']);
return Event::next;
}
@ -225,12 +222,12 @@ class Conversation extends Component
*/
public function onAddExtraNoteActions(Request $request, Note $note, array &$actions)
{
if (\is_null($actor = Common::actor())) {
if (\is_null($user = Common::user())) {
return Event::next;
}
$actions[] = [
'title' => _m('Mute conversation'),
'title' => ConversationMute::isMuted($note, $user) ? _m('Mute conversation') : _m('Unmute conversation'),
'classes' => '',
'url' => Router::url('conversation_mute', ['conversation_id' => $note->getConversationId()]),
];
@ -240,21 +237,9 @@ class Conversation extends Component
public function onNewNotificationShould(Activity $activity, Actor $actor)
{
if ('note' === $activity->getObjectType()) {
$is_blocked = !empty(DB::dql(
<<<'EOQ'
SELECT 1
FROM note AS n
JOIN conversation_mute AS cm WITH n.conversation_id = cm.conversation_id
WHERE n.id = :object_id
EOQ,
['object_id' => $activity->getObjectId()],
));
if ($is_blocked) {
return Event::stop;
} else {
return Event::next;
}
if ($activity->getObjectType() === 'note' && ConversationMute::isMuted($activity, $actor)) {
return Event::stop;
}
return Event::next;
}
}

View File

@ -23,7 +23,13 @@ declare(strict_types = 1);
namespace Component\Conversation\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Entity\Activity;
use App\Entity\Actor;
use App\Entity\LocalUser;
use App\Entity\Note;
use DateTimeInterface;
/**
@ -80,6 +86,33 @@ class ConversationMute extends Entity
// @codeCoverageIgnoreEnd
// }}} Autocode
public static function cacheKeys(int $conversation_id, int $actor_id): array
{
return [
'mute' => "conversation-mute-{$conversation_id}-{$actor_id}",
];
}
/**
* Check if a conversation referenced by $object is muted form $actor
*/
public static function isMuted(Activity|Note|int $object, Actor|LocalUser $actor): bool
{
$conversation_id = null;
if (\is_int($object)) {
$conversation_id = $object;
} elseif ($object instanceof Note) {
$conversation_id = $object->getConversationId();
} elseif ($object instanceof Activity) {
$conversation_id = Note::getById($object->getObjectId())->getConversationId();
}
return Cache::get(
self::cacheKeys($conversation_id, $actor->getId())['mute'],
fn () => (bool) DB::count('conversation_mute', ['conversation_id' => $conversation_id, 'actor_id' => $actor->getId()]),
);
}
public static function schemaDef(): array
{
return [

View File

@ -55,6 +55,7 @@ use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\HttpFoundation\File\Exception\FormSizeFileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Validator\Constraints\Length;
class Posting extends Component
@ -129,6 +130,8 @@ class Posting extends Component
try {
if ($form->isValid()) {
$data = $form->getData();
Event::handle('PostingModifyData', [$request, $actor, &$data, $form_params, $form]);
if (empty($data['content']) && empty($data['attachments'])) {
// TODO Display error: At least one of `content` and `attachments` must be provided
throw new ClientException(_m('You must enter content or provide at least one attachment to post a note.'));
@ -148,15 +151,28 @@ class Posting extends Component
content_type: $content_type,
language: $data['language'],
scope: VisibilityScope::from($data['visibility']),
target: $data['in'] ?? null,
target: $data['in'] ?? $context_actor,
reply_to_id: $data['reply_to_id'],
attachments: $data['attachments'],
process_note_content_extra_args: $extra_args,
);
try {
if ($request->query->has('from')) {
$from = $request->query->get('from');
if (str_contains($from, '#')) {
[$from, $fragment] = explode('#', $from);
}
Router::match($from);
throw new RedirectException(url: $from . (isset($fragment) ? '#' . $fragment : ''));
}
} catch (ResourceNotFoundException $e) {
// continue
}
throw new RedirectException();
}
} catch (FormSizeFileException $sizeFileException) {
throw new FormSizeFileException();
} catch (FormSizeFileException $e) {
throw new ClientException(_m('Invalid file size given'), previous: $e);
}
}
@ -185,7 +201,8 @@ class Posting extends Component
string $content_type,
?string $language = null,
?VisibilityScope $scope = null,
?string $target = null,
null|int|Actor $target = null,
?int $reply_to_id = null,
array $attachments = [],
array $processed_attachments = [],
array $process_note_content_extra_args = [],
@ -206,6 +223,7 @@ class Posting extends Component
'language_id' => !\is_null($language) ? Language::getByLocale($language)->getId() : null,
'is_local' => true,
'scope' => $scope,
'reply_to' => $reply_to_id,
]);
/** @var UploadedFile[] $attachments */
@ -222,11 +240,6 @@ class Posting extends Component
DB::persist($note);
// 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);
// Need file and note ids for the next step
$note->setUrl(Router::url('note_view', ['id' => $note->getId()], Router::ABSOLUTE_URL));
if (!empty($content)) {
@ -242,6 +255,8 @@ class Posting extends Component
}
}
Conversation::assignLocalConversation($note, $reply_to_id);
$activity = Activity::create([
'actor_id' => $actor->getId(),
'verb' => 'create',
@ -269,7 +284,7 @@ class Posting extends Component
DB::flush();
if ($notify) {
Event::handle('NewNotification', [$actor, $activity, ['object' => $mention_ids], _m('{nickname} created a note {note_id}.', ['nickname' => $actor->getNickname(), 'note_id' => $activity->getObjectId()])]);
Event::handle('NewNotification', [$actor, $activity, ['object' => $mention_ids], _m('{nickname} created a note {note_id}.', ['{nickname}' => $actor->getNickname(), '{note_id}' => $activity->getObjectId()])]);
}
return $note;

View File

@ -56,9 +56,9 @@ use Symfony\Component\Validator\Exception\ValidatorException;
use Throwable;
/**
* @method ?int int(string $param)
* @method ?bool bool(string $param)
* @method ?string string(string $param)
* @method ?int int(string $param, ?\Throwable $throw = null)
* @method ?bool bool(string $param, ?\Throwable $throw = null)
* @method ?string string(string $param, ?\Throwable $throw = null)
* @method ?string params(string $param)
* @method mixed handle(Request $request, mixed ...$extra)
*/
@ -257,7 +257,6 @@ abstract class Controller extends AbstractController implements EventSubscriberI
} else {
return null;
}
break;
case 'params':
return $this->request->query->all();
default:

View File

@ -30,10 +30,13 @@ use App\Core\Event;
use App\Core\Log;
use App\Core\Router\Router;
use App\Core\VisibilityScope;
use App\Util\Exception\ClientException;
use App\Util\Exception\NoSuchNoteException;
use App\Util\Formatting;
use Component\Avatar\Avatar;
use Component\Conversation\Entity\Conversation;
use Component\Language\Entity\Language;
use function App\Core\I18n\_m;
/**
* Entity for notices
@ -221,6 +224,18 @@ class Note extends Entity
// @codeCoverageIgnoreEnd
// }}} Autocode
public static function cacheKeys(int $note_id)
{
return [
'note' => "note-{$note_id}",
'attachments' => "note-attachments-{$note_id}",
'attachments-title' => "note-attachments-with-title-{$note_id}",
'links' => "note-links-{$note_id}",
'tags' => "note-tags-{$note_id}",
'replies' => "note-replies-{$note_id}",
];
}
public function getConversation(): Conversation
{
return Conversation::getByPK(['id' => $this->getConversationId()]);
@ -258,17 +273,17 @@ class Note extends Entity
public static function getById(int $note_id): self
{
return Cache::get("note-{$note_id}", fn () => DB::findOneBy('note', ['id' => $note_id]));
return Cache::get(self::cacheKeys($note_id)['note'], fn () => DB::findOneBy('note', ['id' => $note_id]));
}
public function getNoteLanguageShortDisplay(): ?string
{
return !\is_null($this->language_id) ? Language::getById($this->language_id)->getShortDisplay() : null;
return !\is_null($this->getLanguageId()) ? Language::getById($this->getLanguageId())->getShortDisplay() : null;
}
public function getLanguageLocale(): ?string
{
return !\is_null($this->language_id) ? Language::getById($this->language_id)->getLocale() : null;
return !\is_null($this->getLanguageId()) ? Language::getById($this->getLanguageId())->getLocale() : null;
}
public static function getAllNotesByActor(Actor $actor): array
@ -279,7 +294,7 @@ class Note extends Entity
public function getAttachments(): array
{
return Cache::getList('note-attachments-' . $this->id, function () {
return Cache::getList(self::cacheKeys($this->getId())['attachments'], function () {
return DB::dql(
<<<'EOF'
select att from attachment att
@ -293,7 +308,7 @@ class Note extends Entity
public function getAttachmentsWithTitle(): array
{
return Cache::getList('note-attachments-with-title-' . $this->id, function () {
return Cache::getList(self::cacheKeys($this->getId())['attachments-title'], function () {
$from_db = DB::dql(
<<<'EOF'
select att, atn.title
@ -313,7 +328,7 @@ class Note extends Entity
public function getLinks(): array
{
return Cache::getList('note-links-' . $this->id, function () {
return Cache::getList(self::cacheKeys($this->getId())['links'], function () {
return DB::dql(
<<<'EOF'
select l from link l
@ -327,7 +342,7 @@ class Note extends Entity
public function getTags(): array
{
return Cache::getList('note-tags-' . $this->getId(), fn () => DB::findBy('note_tag', ['note_id' => $this->getId()]));
return Cache::getList(self::cacheKeys($this->getId())['tags'], fn () => DB::findBy('note_tag', ['note_id' => $this->getId()]));
}
/**
@ -352,7 +367,7 @@ class Note extends Entity
*/
public function getReplies(): array
{
return DB::findBy('note', ['reply_to' => $this->getId()], order_by: ['created' => 'DESC', 'id' => 'DESC']);
return Cache::getList(self::cacheKeys($this->getId())['replies'], fn () => DB::findBy('note', ['reply_to' => $this->getId()], order_by: ['created' => 'DESC', 'id' => 'DESC']));
}
/**
@ -492,6 +507,17 @@ class Note extends Entity
return $activity;
}
public static function ensureCanInteract(?Note $note, LocalUser|Actor $actor): Note
{
if (\is_null($note)) {
throw new NoSuchNoteException();
} elseif (!$note->isVisibleTo($actor)) {
throw new ClientException(_m('You don\'t have permissions to view this note.'), 401);
} else {
return $note;
}
}
public static function schemaDef(): array
{
return [