<?php
namespace XF\Entity;
use XF\Mvc\Entity\Entity;
use XF\Mvc\Entity\Structure;
use function count, is_array, is_int;
/**
* COLUMNS
* @property int|null $thread_id
* @property int $node_id
* @property string $title
* @property int $reply_count
* @property int $view_count
* @property int $user_id
* @property string $username
* @property int $post_date
* @property bool $sticky
* @property string $discussion_state
* @property bool $discussion_open
* @property string $discussion_type
* @property array $type_data_
* @property int $first_post_id
* @property int $last_post_date
* @property int $last_post_id
* @property int $last_post_user_id
* @property string $last_post_username
* @property int $first_post_reaction_score
* @property array|null $first_post_reactions
* @property int $prefix_id
* @property array $custom_fields_
* @property array $tags
* @property int $vote_score
* @property int $vote_count
*
* GETTERS
* @property \XF\Draft $draft_reply
* @property array $post_ids
* @property array $last_post_cache
* @property \XF\CustomField\Set $custom_fields
* @property string|null $cover_image
* @property \XF\ThreadType\AbstractHandler|null $TypeHandler
* @property array $type_data
* @property mixed $vote_score_short
*
* RELATIONS
* @property \XF\Entity\Forum $Forum
* @property \XF\Entity\User $User
* @property \XF\Entity\Post $FirstPost
* @property \XF\Entity\Post $LastPost
* @property \XF\Entity\User $LastPoster
* @property \XF\Entity\ThreadPrefix $Prefix
* @property \XF\Mvc\Entity\AbstractCollection|\XF\Entity\ThreadRead[] $Read
* @property \XF\Mvc\Entity\AbstractCollection|\XF\Entity\ThreadWatch[] $Watch
* @property \XF\Mvc\Entity\AbstractCollection|\XF\Entity\ThreadUserPost[] $UserPosts
* @property \XF\Entity\DeletionLog $DeletionLog
* @property \XF\Mvc\Entity\AbstractCollection|\XF\Entity\Draft[] $DraftReplies
* @property \XF\Entity\ApprovalQueue $ApprovalQueue
* @property \XF\Entity\ThreadRedirect $Redirect
* @property \XF\Mvc\Entity\AbstractCollection|\XF\Entity\ThreadReplyBan[] $ReplyBans
* @property \XF\Entity\Poll $Poll
* @property \XF\Mvc\Entity\AbstractCollection|\XF\Entity\ThreadFieldValue[] $CustomFields
* @property \XF\Entity\ThreadQuestion $Question
* @property \XF\Mvc\Entity\AbstractCollection|\XF\Entity\TagContent[] $Tags
* @property \XF\Mvc\Entity\AbstractCollection|\XF\Entity\ContentVote[] $ContentVotes
*/
class Thread extends Entity implements LinkableInterface
{
use ContentVoteTrait;
public function canView(&$error = null)
{
if (!$this->Forum || !$this->Forum->canView())
{
return false;
}
$visitor = \XF::visitor();
$nodeId = $this->node_id;
if (!$visitor->hasNodePermission($nodeId, 'view'))
{
return false;
}
if (!$visitor->hasNodePermission($nodeId, 'viewOthers') && $visitor->user_id != $this->user_id)
{
return false;
}
if (!$visitor->hasNodePermission($nodeId, 'viewContent'))
{
return false;
}
if ($this->discussion_state == 'moderated')
{
if (
!$visitor->hasNodePermission($nodeId, 'viewModerated')
&& (!$visitor->user_id || $visitor->user_id != $this->user_id)
)
{
$error = \XF::phraseDeferred('requested_thread_not_found');
return false;
}
}
else if ($this->discussion_state == 'deleted')
{
if (!$visitor->hasNodePermission($nodeId, 'viewDeleted'))
{
$error = \XF::phraseDeferred('requested_thread_not_found');
return false;
}
}
return true;
}
public function canPreview(&$error = null)
{
// assumes view check has already been run
$visitor = \XF::visitor();
$nodeId = $this->node_id;
return (
$this->discussion_type != 'redirect'
&& $this->first_post_id
&& $this->app()->options()->discussionPreview
&& $visitor->hasNodePermission($nodeId, 'viewContent')
);
}
public function canEdit(&$error = null)
{
$visitor = \XF::visitor();
if (!$visitor->user_id)
{
return false;
}
$nodeId = $this->node_id;
if ($visitor->hasNodePermission($nodeId, 'manageAnyThread'))
{
return true;
}
if (!$this->discussion_open && !$this->canLockUnlock())
{
$error = \XF::phraseDeferred('you_may_not_perform_this_action_because_discussion_is_closed');
return false;
}
if ($this->user_id == $visitor->user_id && $visitor->hasNodePermission($nodeId, 'editOwnPost'))
{
$editLimit = $visitor->hasNodePermission($nodeId, 'editOwnPostTimeLimit');
if ($editLimit != -1 && (!$editLimit || $this->post_date < \XF::$time - 60 * $editLimit))
{
$error = \XF::phraseDeferred('message_edit_time_limit_expired', ['minutes' => $editLimit]);
return false;
}
if (!$this->Forum || !$this->Forum->allow_posting)
{
$error = \XF::phraseDeferred('you_may_not_perform_this_action_because_forum_does_not_allow_posting');
return false;
}
return $visitor->hasNodePermission($nodeId, 'editOwnThreadTitle');
}
return false;
}
/**
* Returns true if the visitor can edit moderator-level fields. These are fields/options that regular users
* usually can't control, even on their own threads. Only applies to fields that don't have other dedicated
* permissions or checks (such as thread locking/stickying).
*
* @param mixed $error Returned error message if a specific value is requested
*
* @return bool
*/
public function canEditModeratorFields(&$error = null): bool
{
$visitor = \XF::visitor();
return $visitor->user_id && $visitor->hasNodePermission($this->node_id, 'manageAnyThread');
}
public function canCleanSpam()
{
return (\XF::visitor()->canCleanSpam() && $this->User && $this->User->isPossibleSpammer());
}
public function isPrefixEditable()
{
$prefixId = $this->prefix_id;
if (!$prefixId || !$this->Forum->isPrefixValid($prefixId))
{
return true;
}
return $this->Forum->isPrefixUsable($prefixId);
}
public function canCreatePoll(&$error = null)
{
if ($this->discussion_type != \XF\ThreadType\AbstractHandler::BASIC_THREAD_TYPE)
{
return false;
}
if (!$this->Forum->canCreatePoll())
{
return false;
}
$visitor = \XF::visitor();
if (!$visitor->user_id)
{
return false;
}
if (!$this->discussion_open && !$this->canLockUnlock())
{
$error = \XF::phraseDeferred('you_may_not_perform_this_action_because_discussion_is_closed');
return false;
}
$nodeId = $this->node_id;
if ($visitor->hasNodePermission($nodeId, 'manageAnyThread'))
{
return true;
}
if ($this->user_id == $visitor->user_id && $visitor->hasNodePermission($nodeId, 'editOwnPost'))
{
$editLimit = $visitor->hasNodePermission($nodeId, 'editOwnPostTimeLimit');
if ($editLimit != -1 && (!$editLimit || $this->post_date < \XF::$time - 60 * $editLimit))
{
$error = \XF::phraseDeferred('message_edit_time_limit_expired', ['minutes' => $editLimit]);
return false;
}
if (!$this->Forum || !$this->Forum->allow_posting)
{
$error = \XF::phraseDeferred('you_may_not_perform_this_action_because_forum_does_not_allow_posting');
return false;
}
return true;
}
return false;
}
public function isContentVotingSupported(): bool
{
return $this->TypeHandler->isThreadVotingSupported($this);
}
public function isContentDownvoteSupported(): bool
{
return $this->TypeHandler->isThreadDownvoteSupported($this);
}
protected function canVoteOnContentInternal(&$error = null): bool
{
if (!$this->isVisible())
{
return false;
}
return $this->TypeHandler->canVoteOnThread($this, $error);
}
public function canDownvoteContent(&$error = null): bool
{
return $this->TypeHandler->canDownvoteThread($this, $error);
}
public function canReply(&$error = null)
{
if ($this->discussion_type == 'redirect' || $this->discussion_state == 'deleted')
{
return false;
}
if (!$this->discussion_open && !$this->canLockUnlock())
{
$error = \XF::phraseDeferred('you_may_not_perform_this_action_because_discussion_is_closed');
return false;
}
if (!$this->Forum || !$this->Forum->allow_posting)
{
$error = \XF::phraseDeferred('you_may_not_perform_this_action_because_forum_does_not_allow_posting');
return false;
}
$visitor = \XF::visitor();
$nodeId = $this->node_id;
if (!$visitor->hasNodePermission($nodeId, 'postReply'))
{
return false;
}
if ($visitor->user_id)
{
$replyBans = $this->ReplyBans;
if ($replyBans)
{
if (isset($replyBans[$visitor->user_id]))
{
$replyBan = $replyBans[$visitor->user_id];
$isBanned = ($replyBan && (!$replyBan->expiry_date || $replyBan->expiry_date > time()));
if ($isBanned)
{
return false;
}
}
}
}
return true;
}
public function canReplyPreReg()
{
if (\XF::visitor()->user_id || $this->canReply())
{
// quick bypass with the user ID check, then ensure that this can only return true if the visitor
// can't take the "normal" action
return false;
}
return \XF::canPerformPreRegAction(
function() { return $this->canReply(); }
);
}
public function canEditTags(&$error = null)
{
/** @var Forum $forum */
$forum = $this->Forum;
return $forum ? $forum->canEditTags($this, $error) : false;
}
public function canUseInlineModeration(&$error = null)
{
$visitor = \XF::visitor();
return ($visitor->user_id && $visitor->hasNodePermission($this->node_id, 'inlineMod'));
}
public function canDelete($type = 'soft', &$error = null)
{
$visitor = \XF::visitor();
if (!$visitor->user_id)
{
return false;
}
$nodeId = $this->node_id;
if ($type != 'soft' && !$visitor->hasNodePermission($nodeId, 'hardDeleteAnyThread'))
{
return false;
}
if (!$this->discussion_open && !$this->canLockUnlock())
{
$error = \XF::phraseDeferred('you_may_not_perform_this_action_because_discussion_is_closed');
return false;
}
if ($visitor->hasNodePermission($nodeId, 'deleteAnyThread'))
{
return true;
}
if ($this->user_id == $visitor->user_id && $visitor->hasNodePermission($nodeId, 'deleteOwnThread'))
{
$editLimit = $visitor->hasNodePermission($nodeId, 'editOwnPostTimeLimit');
if ($editLimit != -1 && (!$editLimit || $this->post_date < \XF::$time - 60 * $editLimit))
{
$error = \XF::phraseDeferred('message_edit_time_limit_expired', ['minutes' => $editLimit]);
return false;
}
if (!$this->Forum || !$this->Forum->allow_posting)
{
$error = \XF::phraseDeferred('you_may_not_perform_this_action_because_forum_does_not_allow_posting');
return false;
}
return true;
}
return false;
}
public function canUndelete(&$error = null)
{
$visitor = \XF::visitor();
return $visitor->user_id && $visitor->hasNodePermission($this->node_id, 'undelete');
}
public function canLockUnlock(&$error = null)
{
$visitor = \XF::visitor();
return ($visitor->user_id && $visitor->hasNodePermission($this->node_id, 'lockUnlockThread'));
}
public function canViewDeletedPosts()
{
return \XF::visitor()->hasNodePermission($this->node_id, 'viewDeleted');
}
public function canViewModeratedPosts()
{
return \XF::visitor()->hasNodePermission($this->node_id, 'viewModerated');
}
public function canApproveUnapprove(&$error = null)
{
$visitor = \XF::visitor();
return $visitor->user_id && $visitor->hasNodePermission($this->node_id, 'approveUnapprove');
}
public function canStickUnstick(&$error = null)
{
$visitor = \XF::visitor();
return $visitor->user_id && $visitor->hasNodePermission($this->node_id, 'stickUnstickThread');
}
public function canMove(&$error = null)
{
$visitor = \XF::visitor();
return $visitor->user_id && $visitor->hasNodePermission($this->node_id, 'manageAnyThread');
}
public function canCopy(&$error = null)
{
$visitor = \XF::visitor();
return $visitor->user_id && $visitor->hasNodePermission($this->node_id, 'manageAnyThread');
}
public function canMerge(&$error = null)
{
$visitor = \XF::visitor();
return $visitor->user_id && $visitor->hasNodePermission($this->node_id, 'manageAnyThread');
}
public function canChangeType(&$error = null): bool
{
$visitor = \XF::visitor();
if (!$visitor->user_id || !$visitor->hasNodePermission($this->node_id, 'manageAnyThread'))
{
return false;
}
if (!$this->TypeHandler->canThreadTypeBeChanged($this))
{
$error = \XF::phraseDeferred('threads_type_not_changeable');
return false;
}
return true;
}
public function canViewAttachments(&$error = null)
{
return \XF::visitor()->hasNodePermission($this->node_id, 'viewAttachment');
}
public function canWatch(&$error = null)
{
return \XF::visitor()->user_id ? true : false;
}
public function canReplyBan(&$error = null)
{
if (!$this->discussion_open)
{
$error = \XF::phraseDeferred('you_may_not_perform_this_action_because_discussion_is_closed');
return false;
}
$visitor = \XF::visitor();
return $visitor->user_id && $visitor->hasNodePermission($this->node_id, 'threadReplyBan');
}
public function canSendModeratorActionAlert()
{
return $this->FirstPost && $this->FirstPost->canSendModeratorActionAlert();
}
public function canViewModeratorLogs(&$error = null)
{
$visitor = \XF::visitor();
return $visitor->user_id && $visitor->hasNodePermission($this->node_id, 'manageAnyThread');
}
public function isVisible()
{
return ($this->discussion_state == 'visible');
}
public function isSearchEngineIndexable(): bool
{
$forum = $this->Forum;
if (!$forum)
{
return false;
}
if ($forum->allow_index == 'criteria')
{
$criteria = $forum->index_criteria;
if (
!empty($criteria['max_days_post']) &&
$this->post_date < \XF::$time - $criteria['max_days_post'] * 86400
)
{
return false;
}
if (
!empty($criteria['max_days_last_post']) &&
$this->last_post_date < \XF::$time - $criteria['max_days_last_post'] * 86400
)
{
return false;
}
if (
!empty($criteria['min_replies']) &&
$this->reply_count < $criteria['min_replies']
)
{
return false;
}
if (
isset($criteria['min_reaction_score']) &&
$this->first_post_reaction_score < $criteria['min_reaction_score']
)
{
return false;
}
return true;
}
return ($forum->allow_index == 'allow');
}
public function isUnread()
{
if ($this->discussion_state == 'deleted')
{
return false;
}
if ($this->discussion_type == 'redirect')
{
return false;
}
$readDate = $this->getVisitorReadDate();
if ($readDate === null)
{
return false;
}
return $readDate < $this->last_post_date;
}
public function isIgnored()
{
return \XF::visitor()->isIgnoring($this->user_id);
}
public function isWatched()
{
return isset($this->Watch[\XF::visitor()->user_id]);
}
public function getUserPostCount($user = null)
{
if ($user === null)
{
$userId = \XF::visitor()->user_id;
}
else if (is_int($user))
{
$userId = $user;
}
else if ($user instanceof User)
{
$userId = $user->user_id;
}
else
{
throw new \InvalidArgumentException("User must be provided as null, ID, or entity");
}
if (!$userId)
{
return 0;
}
return isset($this->UserPosts[$userId]) ? $this->UserPosts[$userId]->post_count : 0;
}
public function getUserReadDate(\XF\Entity\User $user)
{
$threadRead = $this->Read[$user->user_id];
$forumRead = $this->Forum ? $this->Forum->Read[$user->user_id] : null;
$dates = [\XF::$time - $this->app()->options()->readMarkingDataLifetime * 86400];
if ($threadRead)
{
$dates[] = $threadRead->thread_read_date;
}
if ($forumRead)
{
$dates[] = $forumRead->forum_read_date;
}
return max($dates);
}
public function getVisitorReadDate()
{
$visitor = \XF::visitor();
if (!$visitor->user_id)
{
return null;
}
return $this->getUserReadDate($visitor);
}
/**
* @return \XF\Draft
*/
public function getDraftReply()
{
return \XF\Draft::createFromEntity($this, 'DraftReplies');
}
/**
* @return string|null
*/
public function getCoverImage()
{
$firstPost = $this->FirstPost;
if (!$firstPost)
{
return null;
}
if ($firstPost->attach_count)
{
$attachments = $firstPost->Attachments;
}
else
{
$attachments = $this->_em->getEmptyCollection();
}
$canViewAttachments = $this->canViewAttachments();
$attachments = $attachments->filter(function(Attachment $attachment) use ($canViewAttachments)
{
if ($attachment->type_grouping != 'image')
{
return false;
}
return $canViewAttachments || $attachment->hasThumbnail();
});
$embeddedAttachmentIds = $firstPost->embed_metadata['attachments'] ?? [];
$embeddedAttachments = $attachments->sortByList(array_keys($embeddedAttachmentIds));
$coverImageUrl = null;
if ($embeddedAttachments->count())
{
foreach ($embeddedAttachments AS $attachment)
{
/** @var Attachment $attachment */
$coverImageUrl = $canViewAttachments
? $attachment->getDirectUrl(true)
: $attachment->getThumbnailUrlFull();
if ($coverImageUrl)
{
break;
}
}
}
else if (preg_match('#\[img.*\](https?://.+)\[/img]#iU', $this->FirstPost->message, $match))
{
$url = $match[1];
$strFormatter = $this->app()->stringFormatter();
$linkInfo = $strFormatter->getLinkClassTarget($url);
if ($linkInfo['local'])
{
$coverImageUrl = $url;
}
else
{
$coverImageUrl = $this->app()->stringFormatter()->getProxiedUrlIfActive('image', $url);
if (!$coverImageUrl)
{
$coverImageUrl = $url;
}
}
}
else
{
foreach ($attachments AS $attachment)
{
/** @var Attachment $attachment */
if (isset($embeddedAttachments[$attachment->attachment_id]))
{
continue;
}
$coverImageUrl = $canViewAttachments
? $attachment->getDirectUrl(true)
: $attachment->getThumbnailUrlFull();
if ($coverImageUrl)
{
break;
}
}
}
return $coverImageUrl ?: null;
}
public function getNewPost()
{
$post = $this->_em->create('XF:Post');
$post->thread_id = $this->_getDeferredValue(function()
{
return $this->thread_id;
}, 'save');
return $post;
}
public function getFieldEditMode($allowPreReg = false)
{
$visitor = \XF::visitor();
$isSelf = ($visitor->user_id == $this->user_id || !$this->thread_id);
$isMod = ($visitor->user_id && $visitor->hasNodePermission($this->node_id, 'manageAnyThread'));
if ($isMod || !$isSelf)
{
return $isSelf ? 'moderator_user' : 'moderator';
}
else
{
if ($allowPreReg && !$this->thread_id && $this->Forum->canCreateThreadPreReg())
{
// creating a new thread and in a pre-reg action situation so apply the correct group limits
return 'user_pre_reg';
}
return 'user';
}
}
/**
* @return \XF\CustomField\Set
*/
public function getCustomFields()
{
$class = 'XF\CustomField\Set';
$class = $this->app()->extendClass($class);
$fieldDefinitions = $this->app()->container('customFields.threads');
return new $class($fieldDefinitions, $this);
}
/**
* @param bool $current If true, gets the current value; else gets the previous value
* @param bool $fallback If true, falls back to the basic thread type if the type is invalid
*
* @return \XF\ThreadType\AbstractHandler|null Null if getting the previous type on an insert
*/
public function getTypeHandler(bool $current = true, bool $fallback = true)
{
if (!$current && $this->isInsert())
{
return null;
}
if ($current)
{
$type = $this->discussion_type;
if (!$type && $this->Forum)
{
// This would generally only happen if called before explicitly setting the type.
// preSave will resolve that and most common cases will setup the type early.
$forum = $this->Forum;
$type = $forum->TypeHandler->getDefaultThreadType($forum);
}
}
else
{
$type = $this->getExistingValue('discussion_type');
// Existing threads should essentially always have a type. We accept a basic type fallback
// if something has gone wrong.
}
$handler = $this->app()->threadType($type, false);
if (!$handler && $fallback)
{
$handler = $this->app()->threadType(\XF\ThreadType\AbstractHandler::BASIC_THREAD_TYPE);
}
return $handler;
}
/**
* @param bool $current If true, gets the current value; else gets the previous value
*
* @return array
*/
public function getTypeData($current = true)
{
if ($current)
{
$defaultData = $this->TypeHandler->getDefaultTypeData();
$data = $this->getValue('type_data');
}
else
{
$previousTypeHandler = $this->getTypeHandler(false, false);
if (!$previousTypeHandler)
{
// if we don't have a previous type, just ignore the data
return [];
}
$defaultData = $previousTypeHandler->getDefaultTypeData();
$data = $this->getExistingValue('type_data');
}
return array_replace($defaultData, $data);
}
/**
* @return array
*/
public function getPostIds()
{
return $this->db()->fetchAllColumn("
SELECT post_id
FROM xf_post
WHERE thread_id = ?
ORDER BY post_date
", $this->thread_id);
}
/**
* @return array
*/
public function getLastPostCache()
{
return [
'post_id' => $this->last_post_id,
'user_id' => $this->last_post_user_id,
'username' => $this->last_post_username,
'post_date' => $this->last_post_date
];
}
public function getBreadcrumbs($includeSelf = true)
{
$breadcrumbs = $this->Forum ? $this->Forum->getBreadcrumbs() : [];
if ($includeSelf)
{
$breadcrumbs[] = [
'href' => $this->app()->router('public')->buildLink('threads', $this),
'value' => $this->title
];
}
return $breadcrumbs;
}
public function getLdStructuredData(Post $firstDisplayedPost, int $page = 1, array $extraData = [])
{
$output = $this->TypeHandler->getLdStructuredData($this, $firstDisplayedPost, $page, $extraData);
if (is_array($output))
{
$filterNull = function(array $input) use (&$filterNull)
{
foreach ($input AS $k => &$value)
{
if (is_array($value))
{
$value = $filterNull($value);
if (!count($value))
{
$value = null;
}
}
if ($value === null)
{
unset($input[$k]);
}
}
return $input;
};
$output = $filterNull($output);
}
return $output;
}
public function rebuildCounters()
{
$this->rebuildFirstPostInfo();
$this->rebuildLastPostInfo();
$this->rebuildReplyCount();
$this->TypeHandler->onThreadRebuildCounters($this);
}
public function rebuildFirstPostInfo()
{
$firstPost = $this->db()->fetchRow("
SELECT post_id, post_date, user_id, username, reaction_score, reactions
FROM xf_post USE INDEX (thread_id_post_date)
WHERE thread_id = ?
ORDER BY post_date
LIMIT 1
", $this->thread_id);
if (!$firstPost)
{
return false;
}
// TODO: sanity check first post to make sure it's visible and force it? Might break other counters though
$this->first_post_id = $firstPost['post_id'];
$this->post_date = $firstPost['post_date'];
$this->user_id = $firstPost['user_id'];
$this->username = $firstPost['username'] ?: '-';
$this->first_post_reaction_score = $firstPost['reaction_score'];
$this->first_post_reactions = json_decode($firstPost['reactions'], true) ?: [];
return true;
}
public function rebuildLastPostInfo()
{
$lastPost = $this->db()->fetchRow("
SELECT post_id, post_date, user_id, username
FROM xf_post USE INDEX (thread_id_post_date)
WHERE thread_id = ?
AND message_state = 'visible'
ORDER BY post_date DESC
LIMIT 1
", $this->thread_id);
if (!$lastPost)
{
return false;
}
$this->last_post_id = $lastPost['post_id'];
$this->last_post_date = $lastPost['post_date'];
$this->last_post_user_id = $lastPost['user_id'];
$this->last_post_username = $lastPost['username'] ?: '-';
return true;
}
public function rebuildReplyCount()
{
$visiblePosts = $this->db()->fetchOne("
SELECT COUNT(*)
FROM xf_post
WHERE thread_id = ?
AND message_state = 'visible'
", $this->thread_id);
$this->reply_count = max(0, $visiblePosts - 1);
return $this->reply_count;
}
public function postAdded(Post $post)
{
if (!$this->first_post_id)
{
$this->first_post_id = $post->post_id;
}
else
{
$this->reply_count++;
}
if ($post->post_date >= $this->last_post_date)
{
$this->last_post_date = $post->post_date;
$this->last_post_id = $post->post_id;
$this->last_post_user_id = $post->user_id;
$this->last_post_username = $post->username;
}
unset($this->_getterCache['post_ids']);
$this->TypeHandler->onVisiblePostAdded($this, $post);
}
public function postRemoved(Post $post)
{
$this->reply_count--;
if ($post->post_id == $this->first_post_id)
{
$this->rebuildFirstPostInfo();
}
if ($post->post_id == $this->last_post_id)
{
$this->rebuildLastPostInfo();
}
unset($this->_getterCache['post_ids']);
$this->TypeHandler->onVisiblePostRemoved($this, $post);
}
protected function verifyDiscussionType(&$value)
{
if ($value === '')
{
$forum = $this->Forum;
if ($forum)
{
$value = $forum->TypeHandler->getDefaultThreadType($forum);
}
else
{
$value = \XF\ThreadType\AbstractHandler::BASIC_THREAD_TYPE;
}
}
if ($value !== $this->getExistingValue('discussion_type'))
{
// type has been changed so wipe out the old data
$this->type_data = [];
}
return true;
}
protected function _preSave()
{
$forum = $this->Forum;
if ($forum)
{
if ($this->prefix_id && ($this->isChanged(['prefix_id', 'node_id'])))
{
if (!$forum->isPrefixValid($this->prefix_id))
{
$this->prefix_id = 0;
}
}
$forumTypeHandler = $forum ? $forum->TypeHandler : null;
if (!$this->discussion_type)
{
// never been explicitly set, so go with the default
$this->discussion_type = $forumTypeHandler->getDefaultThreadType($forum);
}
else if ($this->isUpdate() && $this->isChanged('node_id') && !$this->isChanged('discussion_type'))
{
// Forum move with implicit type change checks
if (!$forumTypeHandler->isThreadTypeAllowed($this->discussion_type, $forum))
{
$this->discussion_type = $forumTypeHandler->getDefaultThreadType($forum);
}
}
else if ($this->isChanged('discussion_type'))
{
// Explicit type change, just validate that the type is allowed
if (!$forumTypeHandler->isThreadTypeAllowed($this->discussion_type, $forum))
{
$this->discussion_type = $forumTypeHandler->getDefaultThreadType($forum);
}
}
}
if ($this->isChanged('discussion_type') && !$this->app()->threadType($this->discussion_type, false))
{
// account for the type not being known, but only if changing it
$this->error(\XF::phrase('please_select_valid_thread_type'), 'discussion_type');
}
$isTypeEntered = ($this->isInsert() || $this->isChanged('discussion_type'));
$this->TypeHandler->onThreadPreSave($this, $isTypeEntered);
}
protected function _postSave()
{
$visibilityChange = $this->isStateChanged('discussion_state', 'visible');
$approvalChange = $this->isStateChanged('discussion_state', 'moderated');
$deletionChange = $this->isStateChanged('discussion_state', 'deleted');
if ($this->isUpdate())
{
if ($visibilityChange == 'enter')
{
$this->threadMadeVisible();
if ($approvalChange)
{
$this->submitHamData();
}
}
else if ($visibilityChange == 'leave')
{
$this->threadHidden();
}
if ($this->isChanged('node_id'))
{
$oldForum = $this->getExistingRelation('Forum');
if ($oldForum && $this->Forum)
{
$this->threadMoved($oldForum, $this->Forum);
}
}
if ($deletionChange == 'leave' && $this->DeletionLog)
{
$this->DeletionLog->delete();
}
if ($approvalChange == 'leave' && $this->ApprovalQueue)
{
$this->ApprovalQueue->delete();
}
}
if ($approvalChange == 'enter')
{
$approvalQueue = $this->getRelationOrDefault('ApprovalQueue', false);
$approvalQueue->content_date = $this->post_date;
$approvalQueue->save();
}
else if ($deletionChange == 'enter' && !$this->DeletionLog)
{
$delLog = $this->getRelationOrDefault('DeletionLog', false);
$delLog->setFromVisitor();
$delLog->save();
}
$this->updateForumRecord();
$isTypeEntered = ($this->isInsert() || $this->isChanged('discussion_type'));
if ($isTypeEntered)
{
if ($this->isUpdate())
{
$oldTypeHandler = $this->getTypeHandler(false, false);
$oldTypeData = $oldTypeHandler ? $this->getTypeData(false) : [];
$this->TypeHandler->onThreadEnterType($this, $this->type_data, $oldTypeHandler, $oldTypeData);
if ($oldTypeHandler)
{
$oldTypeHandler->onThreadLeaveType($this, $oldTypeData, false);
}
}
else
{
$this->TypeHandler->onThreadEnterType($this, $this->type_data);
}
}
$this->TypeHandler->onThreadSave($this, $isTypeEntered);
if ($this->isUpdate() && $this->getOption('log_moderator'))
{
$this->app()->logger()->logModeratorChanges('thread', $this);
}
}
protected function threadMadeVisible()
{
// TODO: this may need a different process with big threads
$this->adjustUserMessageCountIfNeeded(1);
/** @var \XF\Repository\Reaction $reactionRepo */
$reactionRepo = $this->repository('XF:Reaction');
$reactionRepo->recalculateReactionIsCounted('post', $this->post_ids);
$this->TypeHandler->onThreadMadeVisible($this);
}
protected function threadHidden($hardDelete = false)
{
$this->adjustUserMessageCountIfNeeded(-1);
if (!$hardDelete)
{
// hard delete will remove the reactions, so skip that here
/** @var \XF\Repository\Reaction $reactionRepo */
$reactionRepo = $this->repository('XF:Reaction');
$reactionRepo->fastUpdateReactionIsCounted('post', $this->post_ids, false);
}
/** @var \XF\Repository\UserAlert $alertRepo */
$alertRepo = $this->repository('XF:UserAlert');
$alertRepo->fastDeleteAlertsForContent('post', $this->post_ids);
if ($hardDelete)
{
$alertRepo->fastDeleteAlertsForContent('thread', $this->thread_id);
}
if ($this->discussion_type != 'redirect')
{
/** @var \XF\Repository\ThreadRedirect $redirectRepo */
$redirectRepo = $this->repository('XF:ThreadRedirect');
$redirectRepo->deleteRedirectsToThread($this);
}
$this->TypeHandler->onThreadHidden($this, $hardDelete);
}
protected function submitHamData()
{
/** @var \XF\Spam\ContentChecker $submitter */
$submitter = $this->app()->container('spam.contentHamSubmitter');
$submitter->submitHam('thread', $this->thread_id);
}
protected function threadMoved(Forum $from, Forum $to)
{
if (!$this->isStateChanged('discussion_state', 'visible'))
{
$newCounts = $to->count_messages;
$oldCounts = $from->count_messages;
if ($newCounts != $oldCounts)
{
$this->adjustUserMessageCountIfNeeded($newCounts ? 1 : -1, true);
}
}
/** @var \XF\Repository\ThreadRedirect $redirectRepo */
$redirectRepo = $this->repository('XF:ThreadRedirect');
if ($this->discussion_type == 'redirect')
{
$redirectRepo->rebuildThreadRedirectKey($this);
}
else
{
if ($this->Forum)
{
$redirectRepo->deleteRedirectsToThreadInForum($this, $to);
}
}
}
protected function adjustUserMessageCountIfNeeded($direction, $forceChange = false)
{
if ($this->discussion_type == 'redirect')
{
return;
}
if ($forceChange || !empty($this->Forum->count_messages))
{
$updates = $this->db()->fetchPairs("
SELECT user_id, COUNT(*)
FROM xf_post
WHERE thread_id = ?
AND user_id > 0
AND message_state = 'visible'
GROUP BY user_id
", $this->thread_id);
$operator = $direction > 0 ? '+' : '-';
foreach ($updates AS $userId => $adjust)
{
$this->db()->query("
UPDATE xf_user
SET message_count = GREATEST(0, CAST(message_count AS SIGNED) {$operator} ?)
WHERE user_id = ?
", [$adjust, $userId]);
/** @var \XF\Entity\User $userEntity */
$userEntity = $this->em()->findCached('XF:User', $userId);
if ($userEntity)
{
$userEntity->setAsSaved('message_count', max(0, $userEntity->message_count + ($direction > 0 ? $adjust : -$adjust) ));
}
}
}
}
protected function updateForumRecord()
{
if (!$this->Forum)
{
return;
}
/** @var \XF\Entity\Forum $forum */
$forum = $this->Forum;
if ($this->isUpdate() && $this->isChanged('node_id'))
{
// thread moved, trumps the rest
if ($this->discussion_state == 'visible')
{
$forum->threadAdded($this);
$forum->save();
}
if ($this->getExistingValue('discussion_state') == 'visible')
{
/** @var Forum $oldForum */
$oldForum = $this->getExistingRelation('Forum');
if ($oldForum)
{
$oldForum->threadRemoved($this);
$oldForum->save();
}
}
return;
}
// check for thread entering/leaving visible
$visibilityChange = $this->isStateChanged('discussion_state', 'visible');
if ($visibilityChange == 'enter' && $this->Forum)
{
$forum->threadAdded($this);
$forum->save();
return;
}
else if ($visibilityChange == 'leave' && $this->Forum)
{
$forum->threadRemoved($this);
$forum->save();
return;
}
// general data changes
if ($this->discussion_state == 'visible'
&& $this->isChanged(['last_post_date', 'reply_count', 'title', 'discussion_type'])
)
{
$forum->threadDataChanged($this);
$forum->save();
}
}
protected function _postDelete()
{
if ($this->discussion_state == 'visible')
{
$this->threadHidden(true);
}
if ($this->Forum && $this->discussion_state == 'visible')
{
$this->Forum->threadRemoved($this);
$this->Forum->save();
}
if ($this->discussion_state == 'deleted' && $this->DeletionLog)
{
$this->DeletionLog->delete();
}
if ($this->discussion_state == 'moderated' && $this->ApprovalQueue)
{
$this->ApprovalQueue->delete();
}
$this->TypeHandler->onThreadLeaveType($this, $this->type_data, true);
$this->TypeHandler->onThreadDelete($this);
if ($this->getOption('log_moderator'))
{
$this->app()->logger()->logModeratorAction('thread', $this, 'delete_hard');
}
$db = $this->db();
$postIds = $this->post_ids;
if ($postIds)
{
$this->_postDeletePosts($postIds);
}
$db->delete('xf_thread_read', 'thread_id = ?', $this->thread_id);
$db->delete('xf_thread_view', 'thread_id = ?', $this->thread_id);
$db->delete('xf_thread_watch', 'thread_id = ?', $this->thread_id);
$db->delete('xf_thread_reply_ban', 'thread_id = ?', $this->thread_id);
$db->delete('xf_thread_user_post', 'thread_id = ?', $this->thread_id);
$db->delete('xf_thread_field_value', 'thread_id = ?', $this->thread_id);
}
protected function _postDeletePosts(array $postIds)
{
$db = $this->db();
/** @var \XF\Repository\Attachment $attachRepo */
$attachRepo = $this->repository('XF:Attachment');
$attachRepo->fastDeleteContentAttachments('post', $postIds);
/** @var \XF\Repository\Reaction $reactionRepo */
$reactionRepo = $this->repository('XF:Reaction');
$reactionRepo->fastDeleteReactions('post', $postIds);
$db->delete('xf_post', 'post_id IN (' . $db->quote($postIds) . ')');
$db->delete('xf_approval_queue', 'content_id IN (' . $db->quote($postIds) . ') AND content_type = ?', 'post');
$db->delete('xf_deletion_log', 'content_id IN (' . $db->quote($postIds) . ') AND content_type = ?', 'post');
$db->delete('xf_edit_history', 'content_id IN (' . $db->quote($postIds) . ') AND content_type = ?', 'post');
$db->delete('xf_news_feed', 'content_id IN (' . $db->quote($postIds) . ') AND content_type = ?', 'post');
}
public function softDelete($reason = '', User $byUser = null)
{
$byUser = $byUser ?: \XF::visitor();
$db = $this->db();
$db->beginTransaction();
$rawThread = $db->fetchRow("
SELECT *
FROM xf_thread
WHERE thread_id = ?
FOR UPDATE
", $this->thread_id);
if ($rawThread['discussion_state'] == 'deleted')
{
$db->commit();
return false;
}
$this->discussion_state = 'deleted';
/** @var \XF\Entity\DeletionLog $deletionLog */
$deletionLog = $this->getRelationOrDefault('DeletionLog');
$deletionLog->setFromUser($byUser);
$deletionLog->delete_reason = $reason;
$this->save(true, false);
$db->commit();
return true;
}
public function rebuildThreadFieldValuesCache()
{
$this->repository('XF:ThreadField')->rebuildThreadFieldValuesCache($this->thread_id);
}
/**
* @param \XF\Api\Result\EntityResult $result
* @param int $verbosity
* @param array $options
*
* @api-out str $username
* @api-out bool $is_watching <cond> If accessing as a user, true if they are watching this thread
* @api-out int $visitor_post_count <cond> If accessing as a user, the number of posts they have made in this thread
* @api-out bool $is_unread <cond> If accessing as a user, true if this thread is unread
* @api-out object $custom_fields Key-value pairs of custom field values for this thread
* @api-out array $tags
* @api-out str $prefix <cond> Present if this thread has a prefix. Printable name of the prefix.
* @api-out bool $can_edit
* @api-out bool $can_edit_tags
* @api-out bool $can_reply
* @api-out bool $can_soft_delete
* @api-out bool $can_hard_delete
* @api-out bool $can_view_attachments
* @api-out string $view_url
* @api-out bool $is_first_post_pinned
* @api-out array $highlighted_post_ids
* @api-out Node $Forum <cond> If requested by context, the forum this thread was posted in.
* @api-see XF\Entity\ContentVoteTrait::addContentVoteToApiResult
*/
protected function setupApiResultData(
\XF\Api\Result\EntityResult $result, $verbosity = self::VERBOSITY_NORMAL, array $options = []
)
{
$result->username = $this->User ? $this->User->username : $this->username;
$visitor = \XF::visitor();
if ($visitor->user_id)
{
$result->is_watching = isset($this->Watch[$visitor->user_id]);
$result->visitor_post_count = $this->getUserPostCount();
$result->is_unread = $this->isUnread();
}
if (!empty($options['skip_forum']))
{
$result->skipRelation('Forum');
}
// TODO: option for first and last post? last poster?
$result->custom_fields = (object)$this->custom_fields->getNamedFieldValues($this->Forum->field_cache);
$result->tags = array_column($this->tags, 'tag');
if ($this->prefix_id)
{
$result->prefix = \XF::phrase('thread_prefix.' . $this->prefix_id);
}
$this->TypeHandler->addTypeDataToApiResult($this, $result, $verbosity, $options);
$this->addContentVoteToApiResult($result);
$result->can_edit = $this->canEdit();
$result->can_edit_tags = $this->canEditTags();
$result->can_reply = $this->canReply();
$result->can_soft_delete = $this->canDelete();
$result->can_hard_delete = $this->canDelete('hard');
$result->can_view_attachments = $this->canViewAttachments();
$result->view_url = $this->getContentUrl(true);
$result->is_first_post_pinned = $this->TypeHandler->isFirstPostPinned($this);
$result->highlighted_post_ids = $this->TypeHandler->getHighlightedPostIds($this);
}
public function getContentUrl(bool $canonical = false, array $extraParams = [], $hash = null)
{
$route = $canonical ? 'canonical:threads' : 'threads';
return $this->app()->router('public')->buildLink($route, $this, $extraParams, $hash);
}
public function getContentPublicRoute()
{
return 'threads';
}
public function getContentTitle(string $context = '')
{
return \XF::phrase('thread_x', ['title' => $this->title]);
}
public static function getStructure(Structure $structure)
{
$structure->table = 'xf_thread';
$structure->shortName = 'XF:Thread';
$structure->contentType = 'thread';
$structure->primaryKey = 'thread_id';
$structure->columns = [
'thread_id' => ['type' => self::UINT, 'autoIncrement' => true, 'nullable' => true],
'node_id' => ['type' => self::UINT, 'required' => true, 'api' => true],
'title' => ['type' => self::STR, 'maxLength' => 150,
'required' => 'please_enter_valid_title',
'censor' => true,
'api' => true
],
'reply_count' => ['type' => self::UINT, 'forced' => true, 'default' => 0, 'api' => true],
'view_count' => ['type' => self::UINT, 'forced' => true, 'default' => 0, 'api' => true],
'user_id' => ['type' => self::UINT, 'required' => true, 'api' => true],
'username' => ['type' => self::STR, 'maxLength' => 50, 'required' => true, 'api' => true],
'post_date' => ['type' => self::UINT, 'default' => 0, 'api' => true],
'sticky' => ['type' => self::BOOL, 'default' => false, 'api' => true],
'discussion_state' => ['type' => self::STR, 'default' => 'visible',
'allowedValues' => ['visible', 'moderated', 'deleted'], 'api' => true
],
'discussion_open' => ['type' => self::BOOL, 'default' => true, 'api' => true],
'discussion_type' => ['type' => self::STR, 'maxLength' => 50, 'default' => '', 'api' => true],
'type_data' => ['type' => self::JSON_ARRAY, 'default' => []],
'first_post_id' => ['type' => self::UINT, 'default' => 0, 'api' => true],
'last_post_date' => ['type' => self::UINT, 'default' => 0, 'api' => true],
'last_post_id' => ['type' => self::UINT, 'default' => 0, 'api' => true],
'last_post_user_id' => ['type' => self::UINT, 'default' => 0, 'api' => true],
'last_post_username' => ['type' => self::STR, 'maxLength' => 50, 'default' => '', 'api' => true],
'first_post_reaction_score' => ['type' => self::INT, 'default' => 0, 'api' => true],
'first_post_reactions' => ['type' => self::JSON_ARRAY, 'default' => [], 'nullable' => true],
'prefix_id' => ['type' => self::UINT, 'default' => 0, 'api' => true],
'custom_fields' => ['type' => self::JSON_ARRAY, 'default' => []],
'tags' => ['type' => self::JSON_ARRAY, 'default' => []]
];
$structure->behaviors = [
'XF:ContentVotable' => ['stateField' => 'discussion_state'],
'XF:ContentVotableContainer' => [
'childContentType' => 'post',
'childIds' => function($thread) { return $thread->post_ids; },
'stateField' => 'discussion_state'
],
'XF:Taggable' => ['stateField' => 'discussion_state'],
'XF:Indexable' => [
'checkForUpdates' => ['title', 'node_id', 'user_id', 'prefix_id', 'tags', 'discussion_state', 'first_post_id']
],
'XF:IndexableContainer' => [
'childContentType' => 'post',
'childIds' => function($thread) { return $thread->post_ids; },
'checkForUpdates' => ['node_id', 'discussion_state', 'prefix_id']
],
'XF:NewsFeedPublishable' => [
'usernameField' => 'username',
'dateField' => 'post_date'
],
'XF:CustomFieldsHolder' => [
'valueTable' => 'xf_thread_field_value',
'checkForUpdates' => ['node_id'],
'getAllowedFields' => function($thread) { return $thread->Forum ? $thread->Forum->field_cache : []; }
]
];
$structure->getters = [
'draft_reply' => true,
'post_ids' => true,
'last_post_cache' => true,
'custom_fields' => true,
'cover_image' => true,
'TypeHandler' => [
'cache' => true,
'getter' => 'getTypeHandler',
'invalidate' => ['discussion_type', 'node_id']
],
'type_data' => [
'cache' => true,
'getter' => 'getTypeData',
'invalidate' => ['discussion_type', 'node_id']
]
];
$structure->relations = [
'Forum' => [
'entity' => 'XF:Forum',
'type' => self::TO_ONE,
'conditions' => 'node_id',
'primary' => true,
'with' => 'Node',
'api' => true
],
'User' => [
'entity' => 'XF:User',
'type' => self::TO_ONE,
'conditions' => 'user_id',
'primary' => true,
'api' => true
],
'FirstPost' => [
'entity' => 'XF:Post',
'type' => self::TO_ONE,
'conditions' => [['post_id', '=', '$first_post_id']],
'primary' => true
],
'LastPost' => [
'entity' => 'XF:Post',
'type' => self::TO_ONE,
'conditions' => [['post_id', '=', '$last_post_id']],
'primary' => true
],
'LastPoster' => [
'entity' => 'XF:User',
'type' => self::TO_ONE,
'conditions' => [['user_id', '=', '$last_post_user_id']],
'primary' => true
],
'Prefix' => [
'entity' => 'XF:ThreadPrefix',
'type' => self::TO_ONE,
'conditions' => 'prefix_id',
'primary' => true
],
'Read' => [
'entity' => 'XF:ThreadRead',
'type' => self::TO_MANY,
'conditions' => 'thread_id',
'key' => 'user_id'
],
'Watch' => [
'entity' => 'XF:ThreadWatch',
'type' => self::TO_MANY,
'conditions' => 'thread_id',
'key' => 'user_id'
],
'UserPosts' => [
'entity' => 'XF:ThreadUserPost',
'type' => self::TO_MANY,
'conditions' => 'thread_id',
'key' => 'user_id'
],
'DeletionLog' => [
'entity' => 'XF:DeletionLog',
'type' => self::TO_ONE,
'conditions' => [
['content_type', '=', 'thread'],
['content_id', '=', '$thread_id']
],
'primary' => true
],
'DraftReplies' => [
'entity' => 'XF:Draft',
'type' => self::TO_MANY,
'conditions' => [
['draft_key', '=', 'thread-', '$thread_id']
],
'key' => 'user_id'
],
'ApprovalQueue' => [
'entity' => 'XF:ApprovalQueue',
'type' => self::TO_ONE,
'conditions' => [
['content_type', '=', 'thread'],
['content_id', '=', '$thread_id']
],
'primary' => true
],
'Redirect' => [
'entity' => 'XF:ThreadRedirect',
'type' => self::TO_ONE,
'conditions' => 'thread_id',
'primary' => true
],
'ReplyBans' => [
'entity' => 'XF:ThreadReplyBan',
'type' => self::TO_MANY,
'conditions' => 'thread_id',
'key' => 'user_id'
],
'Poll' => [
'entity' => 'XF:Poll',
'type' => self::TO_ONE,
'conditions' => [
['content_type', '=', 'thread'],
['content_id', '=', '$thread_id']
]
],
'CustomFields' => [
'entity' => 'XF:ThreadFieldValue',
'type' => self::TO_MANY,
'conditions' => 'thread_id',
'key' => 'field_id'
],
'Question' => [
'entity' => 'XF:ThreadQuestion',
'type' => self::TO_ONE,
'conditions' => 'thread_id',
'primary' => true
],
'Tags' => [
'entity' => 'XF:TagContent',
'type' => self::TO_MANY,
'conditions' => [
['content_type', '=', 'thread'],
['content_id', '=', '$thread_id']
],
'key' => 'tag_id'
]
];
$structure->columnAliases = [
'first_post_likes' => 'first_post_reaction_score'
];
$structure->options = [
'log_moderator' => true
];
$structure->withAliases = [
'full' => [
'User',
'LastPoster',
function()
{
$userId = \XF::visitor()->user_id;
if ($userId)
{
return [
'Read|' . $userId,
'UserPosts|' . $userId,
'Watch|' . $userId
];
}
return null;
}
],
'fullForum' => [
'full',
function()
{
$with = ['Forum', 'Forum.Node'];
$userId = \XF::visitor()->user_id;
if ($userId)
{
$with[] = 'Forum.Read|' . $userId;
$with[] = 'Forum.Watch|' . $userId;
}
return $with;
}
],
'api' => [
'Forum.api',
'User.api',
function()
{
$userId = \XF::visitor()->user_id;
if ($userId)
{
return [
'Read|' . $userId,
'Forum.Read|' . $userId,
'UserPosts|' . $userId,
'Watch|' . $userId,
'ReplyBans|' . $userId,
'ContentVotes|' . $userId,
];
}
return null;
}
]
];
self::addVotableStructureElements($structure);
return $structure;
}
}