Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @since 10 Nov 2016 */ namespace IPS\Content; /* To prevent PHP errors (extending class does not exist) revealing path */ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * Reaction Trait */ trait Reactable { /** * Reaction type * * @return string */ public static function reactionType() { throw new \BadMethodCallException; } /** * React * * @param \IPS\core\Reaction THe reaction * @param \IPS\Member The member reacting, or NULL * @return void * @throws \DomainException */ public function react( \IPS\Content\Reaction $reaction, \IPS\Member $member = NULL ) { /* Did we pass a member? */ $member = $member ?: \IPS\Member::loggedIn(); /* Figure out the owner of this - if it is content, it will be the author. If it is a node, then it will be the person who created it */ if ( $this instanceof \IPS\Content ) { $owner = $this->author(); } else if ( $this instanceof \IPS\Node\Model ) { $owner = $this->owner(); } /* Can we react? */ if ( $this->canReact( $member ) === FALSE ) { throw new \DomainException( 'cannot_react' ); } /* Have we hit our limit? Also, why 999 for unlimited? */ if ( $member->group['g_rep_max_positive'] !== -1 ) { $count = \IPS\Db::i()->select( 'COUNT(*)', 'core_reputation_index', array( 'member_id=? AND rep_date>?', $member->member_id, \IPS\DateTime::create()->sub( new \DateInterval( 'P1D' ) )->getTimestamp() ) )->first(); if ( $count >= $member->group['g_rep_max_positive'] ) { throw new \DomainException( \IPS\Member::loggedIn()->language()->addToStack( 'react_daily_exceeded', FALSE, array( 'sprintf' => array( $member->group['g_rep_max_positive'] ) ) ) ); } } /* Figure out our app - we do it this way as content items and nodes will always have a lowercase namespace for the app, so if the match below fails, then 'core' can be assumed */ $app = explode( '\\', get_class( $this ) ); if ( \strtolower( $app[1] ) === $app[1] ) { $app = $app[1]; } else { $app = 'core'; } /* If this is a comment, we need the parent items ID */ $itemId = 0; if ( $this instanceof \IPS\Content\Comment ) { $item = $this->item(); $itemIdColumn = $item::$databaseColumnId; $itemId = $item->$itemIdColumn; } /* Have we already reacted? */ if ( $this->reacted( $member ) ) { $this->removeReaction( $member ); } /* Actually insert it */ $idColumn = static::$databaseColumnId; \IPS\Db::i()->insert( 'core_reputation_index', array( 'member_id' => $member->member_id, 'app' => $app, 'type' => static::reactionType(), 'type_id' => $this->$idColumn, 'rep_date' => \IPS\DateTime::create()->getTimestamp(), 'rep_rating' => $reaction->value, 'member_received' => $owner->member_id, 'rep_class' => get_class( $this ), 'class_type_id_hash' => md5( get_class( $this ) . ':' . $this->$idColumn ), 'item_id' => $itemId, 'reaction' => $reaction->id ) ); /* Send the notification */ if ( $this->author()->member_id AND $this->author() != \IPS\Member::loggedIn() AND $this->canView( $owner ) ) { $notification = new \IPS\Notification( \IPS\Application::load('core'), 'new_likes', $this, array( $this, $member ) ); $notification->recipients->attach( $owner ); $notification->send(); } if ( $owner->member_id ) { $owner->pp_reputation_points += $reaction->value; $owner->save(); } } /** * Remove Reaction * * @param \IPS\Member|NULL The member, or NULL * @return void */ public function removeReaction( \IPS\Member $member = NULL ) { $member = $member ?: \IPS\Member::loggedIn(); try { try { $idColumn = static::$databaseColumnId; $rep = \IPS\Db::i()->select( '*', 'core_reputation_index', array( "member_id=? AND rep_class=? AND type=? AND type_id=?", $member->member_id, get_class( $this ), static::reactionType(), $this->$idColumn ) )->first(); } catch( \UnderflowException $e ) { throw new \OutOfRangeException; } $memberReceived = \IPS\Member::load( $rep['member_received'] ); $reaction = \IPS\Content\Reaction::load( $rep['reaction'] ); } catch( \OutOfRangeException $e ) { throw new \DomainException; } if ( $memberReceived->member_id ) { $memberReceived->pp_reputation_points = $memberReceived->pp_reputation_points - $reaction->value; $memberReceived->save(); } \IPS\Db::i()->delete( 'core_reputation_index', array( "id=?", $rep['id'] ) ); } /** * Can React * * @param \IPS\Member|NULL The member, or NULL for currently logged in * @return bool */ public function canReact( \IPS\Member $member = NULL ) { $member = $member ?: \IPS\Member::loggedIn(); if ( $this instanceof \IPS\Content ) { $owner = $this->author(); } else if ( $this instanceof \IPS\Node\Model ) { $owner = $this->owner(); } /* Only members can react */ if ( !$member->member_id ) { return FALSE; } if ( !$owner->member_id ) { return FALSE; } /* Protected Groups */ if ( $owner->inGroup( explode( ',', \IPS\Settings::i()->reputation_protected_groups ) ) ) { return FALSE; } /* Reactions per day */ if ( $member->group['g_rep_max_positive'] == 0 ) { return FALSE; } /* React to own content */ if ( !\IPS\Settings::i()->reputation_can_self_vote AND $this->author()->member_id == $member->member_id ) { return FALSE; } /* Still here? All good. */ return TRUE; } /** * @brief Reactions Cache */ protected $_reactions = NULL; /** * Reactions * * @param array|NULL $mixInData If the data is already know, it can be passed here to be manually set * @return array */ public function reactions() { if ( $this->_reactionCount === NULL ) { $this->_reactionCount = 0; } if ( $this->_reactions === NULL ) { $idColumn = static::$databaseColumnId; $this->_reactions = array(); if ( is_array( $this->reputation ) ) { foreach( $this->reputation AS $memberId => $reactionId ) { try { $this->_reactionCount += \IPS\Content\Reaction::load( $reactionId )->value; $this->_reactions[ $memberId ][] = $reactionId; } catch( \OutOfRangeException $e ) { } } } else { foreach( \IPS\Db::i()->select( '*', 'core_reputation_index', $this->getReactionWhereClause() )->join( 'core_reactions', 'reaction=reaction_id' ) AS $reaction ) { $this->_reactions[ $reaction['member_id'] ][] = $reaction['reaction']; $this->_reactionCount += $reaction['rep_rating']; } } } return $this->_reactions; } /** * @brief Reaction Count */ protected $_reactionCount = NULL; /** * Reaction Count * * @return int */ public function reactionCount() { $this->reactions(); return $this->_reactionCount; } /** * Reaction Where Clause * * @param \IPS\Content\Reaction|array|int|NULL This can be any one of the following: An \IPS\Content\Reaction object, an array of \IPS\Content\Reaction objects, and integer, or an array of integers, or NULL * @return array */ public function getReactionWhereClause( $reactions = NULL ) { $idColumn = static::$databaseColumnId; $where = array( array( 'rep_class=? AND type=? AND type_id=? AND reaction_enabled=?', get_class( $this ), static::reactionType(), $this->$idColumn, 1 ) ); if ( $reactions !== NULL ) { if ( !is_array( $reactions ) ) { $reactions = array( $reactions ); } $in = array(); foreach( $reactions AS $reaction ) { if ( $reaction instanceof \IPS\Content\Reaction ) { $in[] = $reaction->id; } else { $in[] = $reaction; } } if ( count( $in ) ) { $where[] = array( \IPS\Db::i()->in( 'reaction', $in ) ); } } return $where; } /** * Reaction Table * * @return \IPS\Helpers\Table\Db */ public function reactionTable( $reaction=NULL ) { if ( !\IPS\Member::loggedIn()->group['gbw_view_reps'] ) { throw new \DomainException; } $idColumn = static::$databaseColumnId; $table = new \IPS\Helpers\Table\Db( 'core_reputation_index', $this->url('showReactions'), $this->getReactionWhereClause( $reaction ) ); $table->sortBy = 'rep_date'; $table->sortDirection = 'desc'; $table->tableTemplate = array( \IPS\Theme::i()->getTemplate( 'global', 'core', 'front' ), 'reactionLogTable' ); $table->rowsTemplate = array( \IPS\Theme::i()->getTemplate( 'global', 'core', 'front' ), 'reactionLog' ); $table->joins = array( array( 'from' => 'core_reactions', 'where' => 'reaction=reaction_id' ) ); $table->rowButtons = function( $row ) { return array( 'delete' => array( 'icon' => 'times-circle', 'title' => 'delete', 'link' => $this->url( 'unreact' )->csrf()->setQueryString( array( 'member' => $row['member_id'] ) ), 'data' => array( 'confirm' => TRUE ) ) ); }; return $table; } /** * Has reacted? * * @param \IPS\Member|NULL The member, or NULL for currently logged in * @return \IPS\Content\Reaction|FALSE */ public function reacted( \IPS\Member $member = NULL ) { $member = $member ?: \IPS\Member::loggedIn(); $idColumn = static::$databaseColumnId; try { if ( is_array( $this->reputation ) ) { if ( isset( $this->reputation[ $member->member_id ] ) ) { return \IPS\Content\Reaction::load( $this->reputation[ $member->member_id ] ); } else { return FALSE; } } else { return \IPS\Content\Reaction::load( \IPS\Db::i()->select( 'reaction', 'core_reputation_index', array( "member_id=? AND rep_class=? AND type=? AND type_id=?", $member->member_id, get_class( $this ), static::reactionType(), $this->$idColumn ) )->first() ); } } catch( \UnderflowException $e ) { return FALSE; } } /** * @brief Cached React Blurb */ public $reactBlurb = NULL; /** * React Blurb * * @return string */ public function reactBlurb() { if ( $this->reactBlurb === NULL ) { $this->reactBlurb = array(); if ( count( $this->reactions() ) ) { $idColumn = static::$databaseColumnId; /* Figure out our app - we do it this way as content items and nodes will always have a lowercase namespace for the app, so if the match below fails, then 'core' can be assumed */ $app = explode( '\\', get_class( $this ) ); if ( \strtolower( $app[1] ) === $app[1] ) { $app = $app[1]; } else { $app = 'core'; } foreach( \IPS\Db::i()->select( 'reaction', 'core_reputation_index', array( "app=? AND rep_class=? AND type=? AND type_id=?", $app, get_class( $this ), static::reactionType(), $this->$idColumn ) ) AS $rep ) { if ( !isset( $this->reactBlurb[ $rep ] ) ) { $this->reactBlurb[ $rep ] = 0; } $this->reactBlurb[ $rep ]++; } /* Error suppressor for https://bugs.php.net/bug.php?id=50688 */ @uksort( $this->reactBlurb, function( $a, $b ) { try { $a = \IPS\Content\Reaction::load( $a ); $b = \IPS\Content\Reaction::load( $b ); } /* One of the reactions does not exist */ catch( \OutOfRangeException $e ) { return 1; } if ( $a->position < $b->position ) { return -1; } elseif ( $a->position == $b->position ) { return 0; } else { return 1; } } ); } else { $this->reactBlurb = array(); } } return $this->reactBlurb; } /** * @brief Cached like blurb */ public $likeBlurb = NULL; /** * Who Reacted * * @param bool|NULL Use like text instead? NULL to automatically determine * @return string */ public function whoReacted( $isLike = NULL ) { if ( $isLike === NULL ) { $isLike = \IPS\Content\Reaction::isLikeMode(); } if( $this->likeBlurb === NULL ) { $langPrefix = 'react_'; if ( $isLike ) { $langPrefix = 'like_'; } /* Did anyone like it? */ $numberOfLikes = $this->reactionCount(); # int if ( $numberOfLikes ) { /* Is it just us? */ $userLiked = ( $this->reacted() ); if ( $userLiked and $numberOfLikes < 2 ) { $this->likeBlurb = \IPS\Member::loggedIn()->language()->addToStack("{$langPrefix}blurb_just_you"); } /* Nope, we need to display a number... */ else { $peopleToDisplayInMainView = array(); $andXOthers = $numberOfLikes; /* If the user liked, we always show "You" first */ if ( $userLiked ) { $peopleToDisplayInMainView[] = \IPS\Member::loggedIn()->language()->addToStack("{$langPrefix}blurb_you_and_others"); $andXOthers--; } $peopleToDisplayInSecondaryView = array(); /* Some random names */ $idColumn = static::$databaseColumnId; $i = 0; $peopleToDisplayInSecondaryView = array(); /* Figure out our app - we do it this way as content items and nodes will always have a lowercase namespace for the app, so if the match below fails, then 'core' can be assumed */ $app = explode( '\\', get_class( $this ) ); if ( \strtolower( $app[1] ) === $app[1] ) { $app = $app[1]; } else { $app = 'core'; } foreach ( \IPS\Db::i()->select( '*', 'core_reputation_index', array( 'app=? AND type=? AND type_id=? AND member_id!=?', $app, static::reactionType(), $this->$idColumn, ( \IPS\Member::loggedIn()->member_id ) ?: 0 ), 'RAND()', $userLiked ? 17 : 18 ) as $rep ) { if ( $i < ( $userLiked ? 2 : 3 ) ) { $peopleToDisplayInMainView[] = htmlspecialchars( \IPS\Member::load( $rep['member_id'] )->name, ENT_QUOTES | \IPS\HTMLENTITIES, 'UTF-8', FALSE ); $andXOthers--; } else { $peopleToDisplayInSecondaryView[] = htmlspecialchars( \IPS\Member::load( $rep['member_id'] )->name, ENT_QUOTES | \IPS\HTMLENTITIES, 'UTF-8', FALSE ); } $i++; } /* If there's people to display in the secondary view, add that */ if ( $peopleToDisplayInSecondaryView ) { if ( count( $peopleToDisplayInSecondaryView ) < $andXOthers ) { $peopleToDisplayInSecondaryView[] = \IPS\Member::loggedIn()->language()->addToStack( "{$langPrefix}blurb_others_secondary", FALSE, array( 'pluralize' => array( $andXOthers - count( $peopleToDisplayInSecondaryView ) ) ) ); } $peopleToDisplayInMainView[] = \IPS\Theme::i()->getTemplate( 'global', 'core', 'front' )->reputationOthers( $this->url( 'showReactions' ), \IPS\Member::loggedIn()->language()->addToStack( "{$langPrefix}blurb_others", FALSE, array( 'pluralize' => array( $andXOthers ) ) ), json_encode( $peopleToDisplayInSecondaryView ) ); } /* Put it all together */ $this->likeBlurb = \IPS\Member::loggedIn()->language()->addToStack( "{$langPrefix}blurb", FALSE, array( 'pluralize' => array( $numberOfLikes ), 'htmlsprintf' => array( \IPS\Member::loggedIn()->language()->formatList( $peopleToDisplayInMainView ) ) ) ); } } /* Nobody liked it - show nothing */ else { $this->likeBlurb = ''; } } return $this->likeBlurb; } }