Commit e4086cda authored by Emma's avatar Emma 🦉

Merge branch 'pagination-on-user-overview' into 'master'

Pagination on user overview

See merge request edgyemma/Postmill!47
parents 572f2ad7 4ba1bd8f
Pipeline #21758349 passed with stage
in 5 minutes and 38 seconds
......@@ -40,12 +40,23 @@ final class UserController extends AbstractController {
* Show the user's profile page.
*
* @param User $user
* @param UserRepository $repository
* @param Request $request
* @param UserRepository $users
*
* @return Response
*/
public function userPage(User $user, UserRepository $repository) {
$contributions = $repository->findLatestContributions($user);
public function userPage(User $user, Request $request, UserRepository $users) {
$nextUnixTime = $request->query->getInt('next_timestamp');
if ($nextUnixTime) {
$nextTimestamp = new \DateTime('@'.$nextUnixTime);
}
$contributions = $users->findContributions($user, $nextTimestamp ?? null);
if ($nextUnixTime && !\count($contributions)) {
throw $this->createNotFoundException('No such page');
}
return $this->render('user/user.html.twig', [
'contributions' => $contributions,
......
......@@ -38,6 +38,7 @@ final class UserSettingsType extends AbstractType {
'required' => false,
])
->add('show_custom_stylesheets', CheckboxType::class, [
'label' => 'label.let_forums_override_preferred_theme',
'required' => false,
])
->add('preferred_theme', ThemeSelectorType::class, [
......
......@@ -5,9 +5,8 @@ namespace App\Repository;
use App\Entity\Comment;
use App\Entity\Submission;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Persistence\ManagerRegistry;
use Pagerfanta\Adapter\DoctrineSelectableAdapter;
use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
......@@ -34,7 +33,7 @@ class CommentRepository extends ServiceEntityRepository {
$comment = $this->findOneBy(['submission' => $submission, 'id' => $id]);
if (!$comment) {
if (!$comment instanceof Comment) {
throw new NotFoundHttpException('No such comment');
}
......@@ -48,14 +47,66 @@ class CommentRepository extends ServiceEntityRepository {
* @return Pagerfanta|Comment[]
*/
public function findRecentPaginated(int $page, int $maxPerPage = 25) {
$criteria = Criteria::create()
->where(Criteria::expr()->eq('softDeleted', false))
->orderBy(['timestamp' => 'DESC']);
$query = $this->createQueryBuilder('c')
->where('c.softDeleted = FALSE')
->orderBy('c.id', 'DESC');
$pager = new Pagerfanta(new DoctrineSelectableAdapter($this, $criteria));
$pager = new Pagerfanta(new DoctrineORMAdapter($query, false, false));
$pager->setMaxPerPage($maxPerPage);
$pager->setCurrentPage($page);
$this->hydrateComments(\iterator_to_array($pager));
return $pager;
}
private function hydrateComments(array $comments): void {
// hydrate parent and parent's author
$this->createQueryBuilder('c')
->select('PARTIAL c.{id}')
->addSelect('p')
->addSelect('pu')
->leftJoin('c.parent', 'p')
->leftJoin('p.user', 'pu')
->where('c IN (?1)')
->setParameter(1, $comments)
->getQuery()
->execute();
// hydrate comment author/submission/submission author/forum
$this->createQueryBuilder('c')
->select('PARTIAL c.{id}')
->addSelect('cu')
->addSelect('s')
->addSelect('su')
->addSelect('f')
->join('c.user', 'cu')
->join('c.submission', 's')
->join('s.user', 'su')
->join('s.forum', 'f')
->where('c IN (?1)')
->setParameter(1, $comments)
->getQuery()
->execute();
// hydrate votes
$this->createQueryBuilder('c')
->select('PARTIAL c.{id}')
->addSelect('cv')
->leftJoin('c.votes', 'cv')
->where('c IN (?1)')
->setParameter(1, $comments)
->getQuery()
->execute();
// hydrate children (for count only)
$this->createQueryBuilder('c')
->select('PARTIAL c.{id}')
->addSelect('PARTIAL r.{id}')
->leftJoin('c.children', 'r')
->where('c IN (?1)')
->setParameter(1, $comments)
->getQuery()
->execute();
}
}
......@@ -9,7 +9,7 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Persistence\ManagerRegistry;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\DBAL\Types\Type;
use Pagerfanta\Adapter\DoctrineSelectableAdapter;
use Pagerfanta\Pagerfanta;
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
......@@ -44,9 +44,11 @@ class UserRepository extends ServiceEntityRepository implements UserLoaderInterf
}
/**
* {@inheritdoc}
* @param string|null $username
*
* @return User|null
*/
public function loadUserByUsername($username) {
public function loadUserByUsername($username): ?User {
if ($username === null) {
return null;
}
......@@ -98,68 +100,87 @@ class UserRepository extends ServiceEntityRepository implements UserLoaderInterf
}
/**
* Find the latest comments and submissions for a user, combined.
* Find the combined contributions (comments and submissions) of a user.
*
* This takes 1-3 queries to complete. If there is a better way of
* performing this, I'm unaware of it.
* This has the potential of skipping some contributions if they were posted
* at the same second, and if they were to appear on separate pages. This is
* an edge case, so we don't really care.
*
* @param User $user
* @param int $limit
* @param User $user
* @param \DateTime|null $nextTimestamp
*
* @return array
* @return object
*/
public function findLatestContributions(User $user, int $limit = 25) {
$sql = <<<EOSQL
SELECT JSON_AGG(id) AS ids, type FROM (
SELECT id, timestamp, 'comment'::TEXT AS type FROM comments WHERE user_id = :user_id
UNION ALL
SELECT id, timestamp, 'submission'::TEXT AS type FROM submissions WHERE user_id = :user_id
ORDER BY timestamp DESC
LIMIT :limit
) q
GROUP BY type
EOSQL;
$rsm = new ResultSetMapping();
$rsm->addScalarResult('ids', 'ids', 'json_array'); // not really scalar
$rsm->addIndexByScalar('type');
$contributions = $this->_em->createNativeQuery($sql, $rsm)
->setParameter(':user_id', $user->getId())
->setParameter(':limit', $limit, 'integer')
->execute();
if (!empty($contributions['comment']['ids'])) {
$comments = $this->_em->createQueryBuilder()
->select('c AS comment')
->addSelect('c.timestamp AS timestamp')
->addSelect("'comment' AS type")
->from(Comment::class, 'c')
->where('c.id IN (?1)')
->getQuery()
->setParameter(1, $contributions['comment']['ids'])
->execute();
public function findContributions(User $user, ?\DateTime $nextTimestamp) {
if (!$nextTimestamp) {
$nextTimestamp = new \DateTime('@'.time());
}
if (!empty($contributions['submission']['ids'])) {
$submissions = $this->_em->createQueryBuilder()
->select('s AS submission')
->addSelect('s.timestamp AS timestamp')
->addSelect("'submission' AS type")
->from(Submission::class, 's')
->where('s.id IN (?1)')
->getQuery()
->setParameter(1, $contributions['submission']['ids'])
->execute();
}
$submissions = $this->_em->createQueryBuilder()
->select('s')
->from(Submission::class, 's')
->where('s.user = ?1')
->andWhere('s.timestamp <= ?2')
->setParameter(1, $user)
->setParameter(2, $nextTimestamp, Type::DATETIMETZ)
->orderBy('s.timestamp', 'DESC')
->setMaxResults(26)
->getQuery()
->execute();
$combined = array_merge($comments ?? [], $submissions ?? []);
$comments = $this->_em->createQueryBuilder()
->select('c')
->from(Comment::class, 'c')
->where('c.softDeleted = FALSE')
->andWhere('c.user = ?1')
->andWhere('c.timestamp <= ?2')
->setParameter(1, $user)
->setParameter(2, $nextTimestamp, Type::DATETIMETZ)
->orderBy('c.timestamp', 'DESC')
->setMaxResults(26)
->getQuery()
->execute();
$combined = \array_merge($submissions, $comments);
usort($combined, function ($a, $b) {
return $b['timestamp'] <=> $a['timestamp'];
\usort($combined, function ($a, $b) {
return $b->getTimestamp() <=> $a->getTimestamp();
});
return $combined;
$contributions = \array_map(function ($element) {
$type = $element instanceof Submission ? 'submission' : 'comment';
return ['type' => $type, $type => $element];
}, \array_slice($combined, 0, 25));
// 26th element of $combined determines if there is a 'next' button
return new class($contributions, $combined[25] ?? null) implements \IteratorAggregate, \Countable {
private $contributions;
private $pagerEntity;
public function __construct(array $contributions, $pagerEntity) {
$this->contributions = $contributions;
$this->pagerEntity = $pagerEntity;
}
public function hasNextPage(): bool {
return isset($this->pagerEntity);
}
public function getNextPageParams() {
$unixTime = $this->pagerEntity->getTimestamp()->getTimestamp();
return ['next_timestamp' => $unixTime];
}
public function count(): int {
return \count($this->contributions);
}
public function getIterator() {
return new \ArrayIterator($this->contributions);
}
};
}
/**
......
{% extends 'base.html.twig' %}
{% from _self import timestamp %}
{% block sidebar %}
<section class="sidebar__section user-bio">
<h1 class="sidebar__title user-bio__title">
<a href="{{ path('user', {username: user.username}) }}" class="user-bio__user-link">{{ user.username }}</a>
</h1>
<p class="user-bio__registered">{{ 'user.registered'|trans({
'%timestamp%': timestamp(user.created)
})|raw }}</p>
{% if user.biography is not empty %}
<div class="user-bio__biography">{{ user.biography|cached_markdown(markdown_context())|raw }}</div>
{% endif %}
</section>
{% set toolbox_items = [] %}
{% if is_granted('ROLE_USER') and user is not same as(app.user) %}
{% if is_granted('message', user) %}
{% set item %}
<li><a href="{{ path('compose_message', {username: user.username}) }}">{{ 'user.message'|trans }}</a></li>
{% endset %}
{% set toolbox_items = toolbox_items|merge([item]) %}
{% endif %}
{% if not app.user.isBlocking(user) %}
{% set item %}
<li><a href="{{ path('block_user', {username: user.username}) }}">{{ 'nav.block_user'|trans }}</a></li>
{% endset %}
{% set toolbox_items = toolbox_items|merge([item]) %}
{% endif %}
{% if is_granted('ROLE_ADMIN') %}
{% set item %}
{% if not user.banned %}
<li><a href="{{ path('ban_user', {username: user.username}) }}">{{ 'action.ban'|trans }}</a></li>
{% else %}
<li><a href="{{ path('unban_user', {username: user.username}) }}">{{ 'action.unban'|trans }}</a></li>
{% endif %}
{% endset %}
{% set toolbox_items = toolbox_items|merge([item]) %}
{% set item %}
<li><a href="{{ path('user_forum_bans', {username: user.username}) }}">{{ 'nav.forum_bans'|trans }}</a></li>
{% endset %}
{% set toolbox_items = toolbox_items|merge([item]) %}
{% endif %}
{% endif %}
{% if user is same as(app.user) %}
{% set item %}
<li><a href="{{ path('edit_biography', {username: user.username}) }}">{{ 'nav.edit_biography'|trans }}</a></li>
{% endset %}
{% set toolbox_items = toolbox_items|merge([item]) %}
{% endif %}
{% if toolbox_items %}
<section class="sidebar__section sidebar__section--user-toolbox">
<h1 class="sidebar__title">{{ 'label.toolbox'|trans }}</h1>
<ul>
{% for item in toolbox_items %}
{{ item }}
{% endfor %}
</ul>
</section>
{% endif %}
{% if is_granted('ROLE_ADMIN') %}
<section class="sidebar__section sidebar__section--user-roles">
<h1 class="sidebar__title">{{ 'heading.user_roles'|trans }}</h1>
<form action="{{ path('mark_user_trusted', {id: user.id, trusted: not user.trusted ? 1 : 0}) }}" method="post" class="form">
<input type="hidden" name="token" value="{{ csrf_token('mark_trusted') }}">
<div class="form__row">
<button class="button">{{ (not user.trusted ? 'action.mark_as_trusted' : 'action.mark_as_untrusted')|trans }}</button>
</div>
</form>
</section>
{% endif %}
{% if user.moderatorTokens|length > 0 %}
<section class="sidebar__section sidebar__section--user-moderator-info">
<h1 class="sidebar__title">{{ 'user.moderates'|trans({'%username%': user.username}) }}</h1>
<ul>
{% for token in user.moderatorTokens %}
<li><a href="{{ path('forum', {forum_name: token.forum.name}) }}">{{ token.forum.name }}</a></li>
{% endfor %}
</ul>
</section>
{% endif %}
{% endblock %}
{%- macro timestamp(timestamp) -%}
{%- set date = timestamp|localizeddate('long', 'short') -%}
<time datetime="{{ timestamp|date('c') }}" class="relative-time" title="{{ date }}">
{{- 'time.on_timestamp'|trans({'%timestamp%': date}) -}}
</time>
{%- endmacro -%}
{% extends 'user/user.html.twig' %}
{% extends 'user/base.html.twig' %}
{% from 'comment/_macros.html.twig' import comment %}
{% block head %}
......
{% extends 'user/user.html.twig' %}
{% extends 'user/base.html.twig' %}
{% block title 'title.editing_biography_for_user'|trans({'%user%': user.username}) %}
......
......@@ -30,9 +30,9 @@
{{ form_row(form.preferred_theme, {attr: {class: 'select2'}}) }}
{{ form_row(form.night_mode) }}
{{ form_row(form.show_custom_stylesheets) }}
{{ form_row(form.night_mode) }}
</fieldset>
{{ form_end(form) }}
{% endblock %}
{% extends 'user/user.html.twig' %}
{% extends 'user/base.html.twig' %}
{% from 'submission/_macros.html.twig' import submission %}
{% block head %}
......
{% extends 'base.html.twig' %}
{% extends 'user/base.html.twig' %}
{% from 'submission/_macros.html.twig' import submission %}
{% from 'comment/_macros.html.twig' import comment %}
{% from _self import timestamp %}
{% block title user.username %}
{% block page_classes 'user-page' %}
{% block head %}
{{ include('_includes/meta_pagination.html.twig', {pager: contributions}, with_context=false) }}
{% endblock %}
{% block body %}
{{ include('user/_nav.html.twig', {current: 'user', user: user}, with_context=false) }}
......@@ -20,102 +22,6 @@
{{ comment(contribution.comment, {show_context: true}) }}
{% endif %}
{% endfor %}
{% endblock %}
{% block sidebar %}
<section class="sidebar__section user-bio">
<h1 class="sidebar__title user-bio__title">
<a href="{{ path('user', {username: user.username}) }}" class="user-bio__user-link">{{ user.username }}</a>
</h1>
<p class="user-bio__registered">{{ 'user.registered'|trans({
'%timestamp%': timestamp(user.created)
})|raw }}</p>
{% if user.biography is not empty %}
<div class="user-bio__biography">{{ user.biography|cached_markdown(markdown_context())|raw }}</div>
{% endif %}
</section>
{% set toolbox_items = [] %}
{% if is_granted('ROLE_USER') and user is not same as(app.user) %}
{% if is_granted('message', user) %}
{% set item %}
<li><a href="{{ path('compose_message', {username: user.username}) }}">{{ 'user.message'|trans }}</a></li>
{% endset %}
{% set toolbox_items = toolbox_items|merge([item]) %}
{% endif %}
{% if not app.user.isBlocking(user) %}
{% set item %}
<li><a href="{{ path('block_user', {username: user.username}) }}">{{ 'nav.block_user'|trans }}</a></li>
{% endset %}
{% set toolbox_items = toolbox_items|merge([item]) %}
{% endif %}
{% if is_granted('ROLE_ADMIN') %}
{% set item %}
{% if not user.banned %}
<li><a href="{{ path('ban_user', {username: user.username}) }}">{{ 'action.ban'|trans }}</a></li>
{% else %}
<li><a href="{{ path('unban_user', {username: user.username}) }}">{{ 'action.unban'|trans }}</a></li>
{% endif %}
{% endset %}
{% set toolbox_items = toolbox_items|merge([item]) %}
{% set item %}
<li><a href="{{ path('user_forum_bans', {username: user.username}) }}">{{ 'nav.forum_bans'|trans }}</a></li>
{% endset %}
{% set toolbox_items = toolbox_items|merge([item]) %}
{% endif %}
{% endif %}
{% if user is same as(app.user) %}
{% set item %}
<li><a href="{{ path('edit_biography', {username: user.username}) }}">{{ 'nav.edit_biography'|trans }}</a></li>
{% endset %}
{% set toolbox_items = toolbox_items|merge([item]) %}
{% endif %}
{% if toolbox_items %}
<section class="sidebar__section sidebar__section--user-toolbox">
<h1 class="sidebar__title">{{ 'label.toolbox'|trans }}</h1>
<ul>
{% for item in toolbox_items %}
{{ item }}
{% endfor %}
</ul>
</section>
{% endif %}
{% if is_granted('ROLE_ADMIN') %}
<section class="sidebar__section sidebar__section--user-roles">
<h1 class="sidebar__title">{{ 'heading.user_roles'|trans }}</h1>
<form action="{{ path('mark_user_trusted', {id: user.id, trusted: not user.trusted ? 1 : 0}) }}" method="post" class="form">
<input type="hidden" name="token" value="{{ csrf_token('mark_trusted') }}">
<div class="form__row">
<button class="button">{{ (not user.trusted ? 'action.mark_as_trusted' : 'action.mark_as_untrusted')|trans }}</button>
</div>
</form>
</section>
{% endif %}
{% if user.moderatorTokens|length > 0 %}
<section class="sidebar__section sidebar__section--user-moderator-info">
<h1 class="sidebar__title">{{ 'user.moderates'|trans({'%username%': user.username}) }}</h1>
<ul>
{% for token in user.moderatorTokens %}
<li><a href="{{ path('forum', {forum_name: token.forum.name}) }}">{{ token.forum.name }}</a></li>
{% endfor %}
</ul>
</section>
{% endif %}
{{ include('_includes/pagination.html.twig', {pager: contributions}, with_context=false) }}
{% endblock %}
{%- macro timestamp(timestamp) -%}
{%- set date = timestamp|localizeddate('long', 'short') -%}
<time datetime="{{ timestamp|date('c') }}" class="relative-time" title="{{ date }}">
{{- 'time.on_timestamp'|trans({'%timestamp%': date}) -}}
</time>
{%- endmacro -%}
......@@ -440,7 +440,6 @@ user_settings:
user_settings_form:
locale: Γλώσσα
night_mode: Νυχτερινή λειτουργία
show_custom_stylesheets: Εμφάνιση προσαρτημένων φύλλων στυλ
save: Αποθήκευσε τις αλλαγές
wiki:
......
......@@ -292,6 +292,7 @@ label:
show_post_previews: Show post previews
reason_for_banning: Reason for banning
user_associated_with_ip: User associated with IP
let_forums_override_preferred_theme: Let forums override preferred theme
log:
comment_deletion: '%user% deleted comment by %author% in "%submission%"'
......@@ -507,7 +508,6 @@ user_settings:
user_settings_form:
locale: Language
night_mode: Night mode
show_custom_stylesheets: Show custom stylesheets
save: Save changes
wiki:
......
......@@ -487,7 +487,6 @@ user_settings:
user_settings_form:
locale: Idioma
night_mode: Modo nocturno
show_custom_stylesheets: Mostrar hojas de estilos personalizadas
save: Guardar cambios
wiki:
......
......@@ -371,7 +371,6 @@ user_settings:
user_settings_form:
locale: Langue
night_mode: Mode nuit
show_custom_stylesheets: Afficher des feuilles de style personnalisées
save: Sauvegarder les modifications
wiki:
......
......@@ -426,7 +426,6 @@ user_settings:
user_settings_form:
locale: Idioma
night_mode: Modo noturno
show_custom_stylesheets: Mostrar estilos customizados
save: Salvar alterções
wiki:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment