Commit 6124028c authored by Emma's avatar Emma 🏳🌈

Feature: sort by last activity

parent a4b92d35
Pipeline #60560400 passed with stages
in 9 minutes and 12 seconds
......@@ -9,7 +9,7 @@ parameters:
fonts_config: "%env(json:file:resolve:APP_FONTS)%"
themes_config: "%env(json:file:resolve:APP_THEMES)%"
ratelimit_ip_whitelist: "%env(csv:RATELIMIT_WHITELIST)%"
submission_sort_modes: hot|new|top|controversial|most_commented
submission_sort_modes: active|hot|new|top|controversial|most_commented
user_forum_creation_interval: 1 day
number_regex: '[1-9][0-9]{0,17}'
uuid_regex: '[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}'
......
......@@ -211,7 +211,7 @@ final class CommentController extends AbstractController {
$this->validateCsrf('delete_comment', $request->request->get('token'));
if ($this->isGranted('delete_thread', $comment)) {
$em->refresh($comment);
$submission->removeComment($comment);
$em->remove($comment);
} elseif ($this->isGranted('softdelete', $comment)) {
$comment->softDelete();
......
......@@ -201,6 +201,7 @@ class Comment extends Votable {
$this->notify();
$this->notifications = new ArrayCollection();
$this->mentions = new ArrayCollection();
$submission->addComment($this);
}
public function getId(): ?int {
......@@ -317,10 +318,6 @@ class Comment extends Votable {
return $this->softDeleted;
}
public function setSoftDeleted(bool $softDeleted) {
$this->softDeleted = $softDeleted;
}
/**
* Delete a comment without deleting its replies.
*/
......
......@@ -6,6 +6,7 @@ use App\Entity\Exception\BannedFromForumException;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
......@@ -22,6 +23,7 @@ class Submission extends Votable {
public const FRONT_SUBSCRIBED = 'subscribed';
public const FRONT_ALL = 'all';
public const FRONT_MODERATED = 'moderated';
public const SORT_ACTIVE = 'active';
public const SORT_HOT = 'hot';
public const SORT_NEW = 'new';
public const SORT_TOP = 'top';
......@@ -41,6 +43,7 @@ class Submission extends Votable {
];
public const SORT_OPTIONS = [
self::SORT_ACTIVE,
self::SORT_HOT,
self::SORT_NEW,
self::SORT_TOP,
......@@ -104,8 +107,9 @@ class Submission extends Votable {
/**
* @ORM\OneToMany(targetEntity="Comment", mappedBy="submission",
* fetch="EXTRA_LAZY", cascade={"remove"})
* @ORM\OrderBy({"timestamp": "ASC"})
*
* @var Comment[]|Collection
* @var Comment[]|Collection|Selectable
*/
private $comments;
......@@ -118,6 +122,15 @@ class Submission extends Votable {
*/
private $timestamp;
/**
* @ORM\Column(type="datetimetz")
*
* @Groups({"submission:read"})
*
* @var \DateTime
*/
private $lastActive;
/**
* @ORM\JoinColumn(nullable=false)
* @ORM\ManyToOne(targetEntity="Forum", inversedBy="submissions")
......@@ -269,8 +282,9 @@ class Submission extends Votable {
$this->timestamp = $timestamp ?: new \DateTime('@'.time());
$this->comments = new ArrayCollection();
$this->votes = new ArrayCollection();
$this->vote($user, $ip, Votable::VOTE_UP);
$this->mentions = new ArrayCollection();
$this->vote($user, $ip, Votable::VOTE_UP);
$this->updateLastActive();
}
public function getId(): ?int {
......@@ -335,18 +349,52 @@ class Submission extends Votable {
return $comments;
}
public function addComment(Comment $comment) {
if (!$this->comments->contains($comment)) {
$this->comments->add($comment);
public function addComment(Comment ...$comments) {
foreach ($comments as $comment) {
if (!$this->comments->contains($comment)) {
$this->comments->add($comment);
}
}
$this->updateRanking();
$this->updateLastActive();
}
public function removeComment(Comment ...$comments) {
// hydrate the collection
$this->comments->get(-1);
foreach ($comments as $comment) {
$this->comments->removeElement($comment);
}
$this->updateRanking();
$this->updateLastActive();
}
public function getTimestamp(): \DateTime {
return $this->timestamp;
}
public function getLastActive(): \DateTime {
return $this->lastActive;
}
public function updateLastActive(): void {
$criteria = Criteria::create()
->where(Criteria::expr()->eq('softDeleted', false))
->orderBy(['timestamp' => 'DESC'])
->setMaxResults(1);
$lastComment = $this->comments->matching($criteria)->first();
if ($lastComment) {
$this->lastActive = clone $lastComment->getTimestamp();
} else {
$this->lastActive = clone $this->getTimestamp();
}
}
public function getForum(): Forum {
return $this->forum;
}
......@@ -429,13 +477,18 @@ class Submission extends Votable {
}
public function updateRanking() {
$criteria = Criteria::create()
->where(Criteria::expr()->eq('softDeleted', false));
$commentCount = \count($this->comments->matching($criteria));
$netScore = $this->getNetScore();
$netScoreAdvantage = $netScore * self::NETSCORE_MULTIPLIER;
if ($netScore > self::DOWNVOTED_CUTOFF) {
$commentAdvantage = count($this->comments) * self::COMMENT_MULTIPLIER;
$commentAdvantage = $commentCount * self::COMMENT_MULTIPLIER;
} else {
$commentAdvantage = count($this->comments) * self::COMMENT_DOWNVOTED_MULTIPLIER;
$commentAdvantage = $commentCount * self::COMMENT_DOWNVOTED_MULTIPLIER;
}
$advantage = max(min($netScoreAdvantage + $commentAdvantage, self::MAX_ADVANTAGE), -self::MAX_PENALTY);
......
......@@ -63,7 +63,7 @@ class UserData implements UserInterface {
private $frontPage;
/**
* @Assert\Choice({Submission::SORT_HOT, Submission::SORT_NEW}, groups={"settings"}, strict=true)
* @Assert\Choice({Submission::SORT_ACTIVE, Submission::SORT_HOT, Submission::SORT_NEW}, groups={"settings"}, strict=true)
* @Assert\NotBlank(groups={"settings"})
*/
private $frontPageSortMode;
......
......@@ -59,6 +59,7 @@ final class UserSettingsType extends AbstractType {
'choices' => [
'submissions.sort_by_hot' => Submission::SORT_HOT,
'submissions.sort_by_new' => Submission::SORT_NEW,
'submissions.sort_by_active' => Submission::SORT_ACTIVE,
],
'error_bubbling' => true,
'label' => 'label.sort_by',
......
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20190507133347 extends AbstractMigration {
public function getDescription(): string {
return 'Add column for last activity';
}
public function up(Schema $schema): void {
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE submissions ADD last_active TIMESTAMP(0) WITH TIME ZONE');
$this->addSql('UPDATE submissions s SET last_active = (SELECT COALESCE(MAX(c.timestamp), s.timestamp) FROM comments c WHERE c.submission_id = s.id)');
$this->addSql('ALTER TABLE submissions ALTER last_active SET NOT NULL');
}
public function down(Schema $schema): void {
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE submissions DROP last_active');
}
}
......@@ -33,7 +33,7 @@ class SubmissionPager implements \IteratorAggregate {
return [];
}
$params[$column] = $value;
$params[$column] = self::transformValue($type, $value);
}
// complete pager params
......@@ -60,6 +60,11 @@ class SubmissionPager implements \IteratorAggregate {
$accessor = $this->columnNameToAccessor($column);
$value = $submission->{$accessor}();
if ($value instanceof \DateTimeInterface) {
// ugly hack
$value = $value->format('c');
}
$this->nextPageParams['next_'.$column] = $value;
}
......@@ -89,12 +94,22 @@ class SubmissionPager implements \IteratorAggregate {
return $this->nextPageParams;
}
public function isEmpty(): bool {
return empty($this->submissions);
}
private function columnNameToAccessor(string $columnName): string {
return 'get'.str_replace('_', '', ucwords($columnName, '_'));
}
private static function valueIsOfType(string $type, string $value): bool {
switch ($type) {
case 'datetimetz':
try {
return (bool) new \DateTime($value);
} catch (\Exception $e) {
return false;
}
case 'integer':
return ctype_digit($value) && \is_int(+$value) &&
$value >= -0x80000000 && $value <= 0x7fffffff;
......@@ -107,7 +122,15 @@ class SubmissionPager implements \IteratorAggregate {
}
}
public function isEmpty(): bool {
return empty($this->submissions);
private static function transformValue(string $type, string $value) {
switch ($type) {
case 'datetimetz':
return new \DateTime($value);
case 'integer':
case 'bigint':
return +$value;
default:
throw new \InvalidArgumentException("Unexpected type '$type'");
}
}
}
......@@ -18,6 +18,7 @@ class SubmissionRepository extends ServiceEntityRepository {
* @var array[]
*/
public const SORT_COLUMN_MAP = [
Submission::SORT_ACTIVE => ['last_active', 'id'],
Submission::SORT_HOT => ['ranking', 'id'],
Submission::SORT_NEW => ['id'],
Submission::SORT_TOP => ['net_score', 'id'],
......@@ -26,6 +27,7 @@ class SubmissionRepository extends ServiceEntityRepository {
];
public const SORT_COLUMN_TYPES = [
'last_active' => 'datetimetz',
'ranking' => 'bigint',
'id' => 'bigint',
'net_score' => 'integer',
......@@ -101,6 +103,7 @@ class SubmissionRepository extends ServiceEntityRepository {
->setMaxResults($maxPerPage + 1);
switch ($sortBy) {
case Submission::SORT_ACTIVE:
case Submission::SORT_HOT:
case Submission::SORT_NEW:
break;
......
......@@ -39,7 +39,7 @@
{{ _tab_button(current_label, 'label.sort_by_mode', '%mode%', 'sort') }}
<ul class="dropdown__menu dropdown-card unlistify">
{{ _submission_sort_items(current, ['hot', 'new'], null) }}
{{ _submission_sort_items(current, ['hot', 'new', 'active'], null) }}
{{ _submission_sort_items(current, ['top', 'controversial', 'most_commented'], 'day') }}
</ul>
</li>
......@@ -90,7 +90,7 @@
{% if current not in times %}
{% set current = 'all' %}
{% endif %}
{% if app.request.query.has('t') or sort_by not in ['hot', 'new'] %}
{% if app.request.query.has('t') or sort_by not in ['active', 'hot', 'new'] %}
{% set attr = app.request.attributes %}
{% set current_label = 'submissions.time_%s'|format(current)|trans %}
<li class="dropdown">
......
......@@ -485,6 +485,7 @@ submissions:
edit: Edit
info_with_forum_name: Submitted by %submitter% %timestamp% in %forum%
info_without_forum_name: Submitted by %submitter% %timestamp%
sort_by_active: Active
sort_by_hot: Hot
sort_by_new: New
sort_by_top: Top
......
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