diff --git a/vanilla/applications/dashboard/models/class.activitymodel.php b/vanilla/applications/dashboard/models/class.activitymodel.php
new file mode 100644
index 0000000..4bafd8d
--- /dev/null
+++ b/vanilla/applications/dashboard/models/class.activitymodel.php
@@ -0,0 +1,1896 @@
+setPruneAfter(c('Garden.PruneActivityAfter', '2 months'));
+ } catch (Exception $ex) {
+ $this->setPruneAfter('2 months');
+ }
+ }
+
+ /**
+ * Build basis of common activity SQL query.
+ *
+ * @param bool $join
+ * @since 2.0.0
+ * @access public
+ */
+ public function activityQuery($join = true) {
+ $this->SQL
+ ->select('a.*')
+ ->select('t.FullHeadline, t.ProfileHeadline, t.AllowComments, t.ShowIcon, t.RouteCode')
+ ->select('t.Name', '', 'ActivityType')
+ ->from('Activity a')
+ ->join('ActivityType t', 'a.ActivityTypeID = t.ActivityTypeID');
+
+ if ($join) {
+ $this->SQL
+ ->select('au.Name', '', 'ActivityName')
+ ->select('au.Gender', '', 'ActivityGender')
+ ->select('au.Photo', '', 'ActivityPhoto')
+ ->select('au.Email', '', 'ActivityEmail')
+ ->select('ru.Name', '', 'RegardingName')
+ ->select('ru.Gender', '', 'RegardingGender')
+ ->select('ru.Email', '', 'RegardingEmail')
+ ->select('ru.Photo', '', 'RegardingPhoto')
+ ->join('User au', 'a.ActivityUserID = au.UserID')
+ ->join('User ru', 'a.RegardingUserID = ru.UserID', 'left');
+ }
+
+ $this->fireEvent('AfterActivityQuery');
+ }
+
+ /**
+ * Can the current user view the activity?
+ *
+ * @param array $activity
+ * @return bool
+ */
+ public function canView(array $activity): bool {
+ $result = false;
+
+ $userid = val('NotifyUserID', $activity);
+ switch ($userid) {
+ case ActivityModel::NOTIFY_PUBLIC:
+ $result = true;
+ break;
+ case ActivityModel::NOTIFY_MODS:
+ if (checkPermission('Garden.Moderation.Manage')) {
+ $result = true;
+ }
+ break;
+ case ActivityModel::NOTIFY_ADMINS:
+ if (checkPermission('Garden.Settings.Manage')) {
+ $result = true;
+ }
+ break;
+ default:
+ // Actual userid.
+ if (Gdn::session()->UserID === $userid || checkPermission('Garden.Community.Manage')) {
+ $result = true;
+ }
+ break;
+ }
+
+ return $result;
+ }
+
+ /**
+ *
+ *
+ * @param $data
+ */
+ public function calculateData(&$data) {
+ foreach ($data as &$row) {
+ $this->calculateRow($row);
+ }
+ }
+
+ /**
+ *
+ *
+ * @param $row
+ */
+ public function calculateRow(&$row) {
+ $activityType = self::getActivityType($row['ActivityTypeID']);
+ $row['ActivityType'] = val('Name', $activityType);
+ if (is_string($row['Data'])) {
+ $row['Data'] = dbdecode($row['Data']);
+ }
+
+ $row['PhotoUrl'] = url($row['Route'], true);
+ if (!$row['Photo']) {
+ if (isset($row['ActivityPhoto'])) {
+ $row['Photo'] = $row['ActivityPhoto'];
+ $row['PhotoUrl'] = userUrl($row, 'Activity');
+ } else {
+ $user = Gdn::userModel()->getID($row['ActivityUserID'], DATASET_TYPE_ARRAY);
+ if ($user) {
+ $photo = $user['Photo'];
+ $row['PhotoUrl'] = userUrl($user);
+ if (!$photo || stringBeginsWith($photo, 'http')) {
+ $row['Photo'] = $photo;
+ } else {
+ $row['Photo'] = Gdn_Upload::url(changeBasename($photo, 'n%s'));
+ }
+ }
+ }
+ }
+
+ $data = $row['Data'];
+ if (isset($data['ActivityUserIDs'])) {
+ $row['ActivityUserID'] = array_merge([$row['ActivityUserID']], $data['ActivityUserIDs']);
+ $row['ActivityUserID_Count'] = val('ActivityUserID_Count', $data);
+ }
+
+ if (isset($data['RegardingUserIDs'])) {
+ $row['RegardingUserID'] = array_merge([$row['RegardingUserID']], $data['RegardingUserIDs']);
+ $row['RegardingUserID_Count'] = val('RegardingUserID_Count', $data);
+ }
+
+
+ if (!empty($row['Route'])) {
+ $row['Url'] = externalUrl($row['Route']);
+ } else {
+ $id = $row['ActivityID'];
+ $row['Url'] = Gdn::request()->url("/activity/item/$id", true);
+ }
+
+ if ($row['HeadlineFormat']) {
+ $row['Headline'] = formatString($row['HeadlineFormat'], $row);
+ } else {
+ $row['Headline'] = Gdn_Format::activityHeadline($row);
+ }
+ }
+
+ /**
+ * Define a new activity type.
+ * @param string $name The string code of the activity type.
+ * @param array $activity The data that goes in the ActivityType table.
+ * @since 2.1
+ */
+ public function defineType($name, $activity = []) {
+ $this->SQL->replace('ActivityType', $activity, ['Name' => $name], true);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function delete($where = [], $options = []) {
+ if (is_numeric($where)) {
+ deprecated('ActivityModel->delete(int)', 'ActivityModel->deleteID(int)');
+
+ $result = $this->deleteID($where, $options);
+ return $result;
+ } elseif (count($where) === 1 && isset($where['ActivityID'])) {
+ return parent::delete($where, $options);
+ }
+
+ throw new \BadMethodCallException("ActivityModel->delete() is not supported.", 400);
+ }
+
+ /**
+ * Delete a particular activity item.
+ *
+ * @param int $activityID The unique ID of activity to be deleted.
+ * @param array $options Not used.
+ * @return bool Returns **true** if the activity was deleted or **false** otherwise.
+ */
+ public function deleteID($activityID, $options = []) {
+ // Get the activity first.
+ $activity = $this->getID($activityID);
+ if ($activity) {
+ // Log the deletion.
+ $log = val('Log', $options);
+ if ($log) {
+ LogModel::insert($log, 'Activity', $activity);
+ }
+
+ // Delete comments on the activity item
+ $this->SQL->delete('ActivityComment', ['ActivityID' => $activityID]);
+
+ // Delete the activity item
+ return parent::deleteID($activityID);
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Delete an activity comment.
+ *
+ * @since 2.1
+ *
+ * @param int $iD
+ * @return Gdn_DataSet
+ */
+ public function deleteComment($iD) {
+ return $this->SQL->delete('ActivityComment', ['ActivityCommentID' => $iD]);
+ }
+
+ /**
+ * Get the recent activities.
+ *
+ * @param array $where
+ * @param int $limit
+ * @param int $offset
+ * @return Gdn_DataSet
+ */
+ public function getWhereRecent($where, $limit = 0, $offset = 0) {
+ $result = $this->getWhere($where, '', '', $limit, $offset);
+ return $result;
+ }
+
+ /**
+ * Modifies standard Gdn_Model->GetWhere to use AcitivityQuery.
+ *
+ * Events: AfterGet.
+ *
+ * @param array $where A filter suitable for passing to Gdn_SQLDriver::where().
+ * @param string $orderFields A comma delimited string to order the data.
+ * @param string $orderDirection One of **asc** or **desc**.
+ * @param int|bool $limit The database limit.
+ * @param int|bool $offset The database offset.
+ * @return Gdn_DataSet SQL results.
+ */
+ public function getWhere($where = [], $orderFields = '', $orderDirection = '', $limit = false, $offset = false) {
+ if (is_string($where)) {
+ deprecated('ActivityModel->getWhere($key, $value)', 'ActivityModel->getWhere([$key => $value])');
+ $where = [$where => $orderFields];
+ $orderFields = '';
+ }
+ if (is_numeric($orderFields)) {
+ deprecated('ActivityModel->getWhere($where, $limit)');
+ $limit = $orderFields;
+ $orderFields = '';
+ }
+ if (is_numeric($orderDirection)) {
+ deprecated('ActivityModel->getWhere($where, $limit, $offset)');
+ $offset = $orderDirection;
+ $orderDirection = '';
+ }
+ $limit = $limit ?: 30;
+ $offset = $offset ?: 0;
+
+ $orderFields = $orderFields ?: 'a.DateUpdated';
+ $orderDirection = $orderDirection ?: 'desc';
+
+ // Add the basic activity query.
+ $this->SQL
+ ->select('a2.*')
+ ->select('t.FullHeadline, t.ProfileHeadline, t.AllowComments, t.ShowIcon, t.RouteCode')
+ ->select('t.Name', '', 'ActivityType')
+ ->from('Activity a')
+ ->join('Activity a2', 'a.ActivityID = a2.ActivityID')// self-join for index speed.
+ ->join('ActivityType t', 'a2.ActivityTypeID = t.ActivityTypeID');
+
+ // Add prefixes to the where.
+ foreach ($where as $key => $value) {
+ if (strpos($key, '.') === false) {
+ $where['a.'.$key] = $value;
+ unset($where[$key]);
+ }
+ }
+
+ $result = $this->SQL
+ ->where($where)
+ ->orderBy($orderFields, $orderDirection)
+ ->limit($limit, $offset)
+ ->get();
+
+ self::getUsers($result->resultArray());
+ Gdn::userModel()->joinUsers(
+ $result->resultArray(),
+ ['ActivityUserID', 'RegardingUserID'],
+ ['Join' => ['Name', 'Email', 'Gender', 'Photo']]
+ );
+ $this->calculateData($result->resultArray());
+
+ $this->EventArguments['Data'] =& $result;
+ $this->fireEvent('AfterGet');
+
+ return $result;
+ }
+
+ /**
+ *
+ *
+ * @param array &$activities
+ * @since 2.1
+ */
+ public function joinComments(&$activities) {
+ // Grab all of the activity IDs.
+ $activityIDs = [];
+ foreach ($activities as $activity) {
+ if ($iD = val('CommentActivityID', $activity['Data'])) {
+ // This activity shares its comments with another activity.
+ $activityIDs[] = $iD;
+ } else {
+ $activityIDs[] = $activity['ActivityID'];
+ }
+ }
+ $activityIDs = array_unique($activityIDs);
+
+ $comments = $this->getComments($activityIDs);
+ $comments = Gdn_DataSet::index($comments, ['ActivityID'], ['Unique' => false]);
+ foreach ($activities as &$activity) {
+ $iD = val('CommentActivityID', $activity['Data']);
+ if (!$iD) {
+ $iD = $activity['ActivityID'];
+ }
+
+ if (isset($comments[$iD])) {
+ $activity['Comments'] = $comments[$iD];
+ } else {
+ $activity['Comments'] = [];
+ }
+ }
+ }
+
+ /**
+ * Modifies standard Gdn_Model->Get to use AcitivityQuery.
+ *
+ * Events: BeforeGet, AfterGet.
+ *
+ * @param int|bool $notifyUserID Unique ID of user to gather activity for or one of the NOTIFY_* constants in this class.
+ * @param int $offset Number to skip.
+ * @param int $limit How many to return.
+ * @return Gdn_DataSet SQL results.
+ */
+ public function getByUser($notifyUserID = false, $offset = 0, $limit = 30) {
+ $offset = is_numeric($offset) ? $offset : 0;
+ if ($offset < 0) {
+ $offset = 0;
+ }
+
+ $limit = is_numeric($limit) ? $limit : 0;
+ if ($limit < 0) {
+ $limit = 30;
+ }
+
+ $this->activityQuery(false);
+
+ if ($notifyUserID === false || $notifyUserID === 0) {
+ $notifyUserID = self::NOTIFY_PUBLIC;
+ }
+ $this->SQL->whereIn('NotifyUserID', (array)$notifyUserID);
+
+ $this->fireEvent('BeforeGet');
+ $result = $this->SQL
+ ->orderBy('a.ActivityID', 'desc')
+ ->limit($limit, $offset)
+ ->get();
+
+ Gdn::userModel()->joinUsers($result, ['ActivityUserID', 'RegardingUserID'], ['Join' => ['Name', 'Photo', 'Email', 'Gender']]);
+
+ $this->EventArguments['Data'] =& $result;
+ $this->fireEvent('AfterGet');
+
+ return $result;
+ }
+
+ /**
+ *
+ *
+ * @param array &$data
+ */
+ public static function getUsers(&$data) {
+ $userIDs = [];
+
+ foreach ($data as &$row) {
+ if (is_string($row['Data'])) {
+ $row['Data'] = dbdecode($row['Data']);
+ }
+
+ $userIDs[$row['ActivityUserID']] = 1;
+ $userIDs[$row['RegardingUserID']] = 1;
+
+ if (isset($row['Data']['ActivityUserIDs'])) {
+ foreach ($row['Data']['ActivityUserIDs'] as $userID) {
+ $userIDs[$userID] = 1;
+ }
+ }
+
+ if (isset($row['Data']['RegardingUserIDs'])) {
+ foreach ($row['Data']['RegardingUserIDs'] as $userID) {
+ $userIDs[$userID] = 1;
+ }
+ }
+ }
+
+ Gdn::userModel()->getIDs(array_keys($userIDs));
+ }
+
+ /**
+ *
+ *
+ * @param $activityType
+ * @return bool
+ */
+ public static function getActivityType($activityType) {
+ if (self::$ActivityTypes === null) {
+ $data = Gdn::sql()->get('ActivityType')->resultArray();
+ foreach ($data as $row) {
+ self::$ActivityTypes[$row['Name']] = $row;
+ self::$ActivityTypes[$row['ActivityTypeID']] = $row;
+ }
+ }
+ if (isset(self::$ActivityTypes[$activityType])) {
+ return self::$ActivityTypes[$activityType];
+ }
+ return false;
+ }
+
+ /**
+ * Get number of activity related to a user.
+ *
+ * Events: BeforeGetCount.
+ *
+ * @since 2.0.0
+ * @access public
+ * @param string $userID Unique ID of user.
+ * @return int Number of activity items found.
+ */
+ public function getCount($userID = '') {
+ $this->SQL
+ ->select('a.ActivityID', 'count', 'ActivityCount')
+ ->from('Activity a')
+ ->join('ActivityType t', 'a.ActivityTypeID = t.ActivityTypeID');
+
+ if ($userID != '') {
+ $this->SQL
+ ->beginWhereGroup()
+ ->where('a.ActivityUserID', $userID)
+ ->orWhere('a.RegardingUserID', $userID)
+ ->endWhereGroup();
+ }
+
+ $session = Gdn::session();
+ if (!$session->isValid() || $session->UserID != $userID) {
+ $this->SQL->where('t.Public', '1');
+ }
+
+ $this->fireEvent('BeforeGetCount');
+ return $this->SQL
+ ->get()
+ ->firstRow()
+ ->ActivityCount;
+ }
+
+ /**
+ * Get activity related to a particular role.
+ *
+ * Events: AfterGet.
+ *
+ * @param string $roleID Unique ID of role.
+ * @param int $offset Number to skip.
+ * @param int $limit Max number to return.
+ * @return Gdn_DataSet SQL results.
+ * @since 2.0.18
+ */
+ public function getForRole($roleID = '', $offset = 0, $limit = 50) {
+ if (!is_array($roleID)) {
+ $roleID = [$roleID];
+ }
+
+ $offset = is_numeric($offset) ? $offset : 0;
+ if ($offset < 0) {
+ $offset = 0;
+ }
+
+ $limit = is_numeric($limit) ? $limit : 0;
+ if ($limit < 0) {
+ $limit = 0;
+ }
+
+ $this->activityQuery();
+ $result = $this->SQL
+ ->join('UserRole ur', 'a.ActivityUserID = ur.UserID')
+ ->whereIn('ur.RoleID', $roleID)
+ ->where('t.Public', '1')
+ ->orderBy('a.DateInserted', 'desc')
+ ->limit($limit, $offset)
+ ->get();
+
+ $this->EventArguments['Data'] =& $result;
+ $this->fireEvent('AfterGet');
+
+ return $result;
+ }
+
+ /**
+ * Get number of activity related to a particular role.
+ *
+ * @since 2.0.18
+ * @access public
+ * @param int|string $roleID Unique ID of role.
+ * @return int Number of activity items.
+ */
+ public function getCountForRole($roleID = '') {
+ if (!is_array($roleID)) {
+ $roleID = [$roleID];
+ }
+
+ return $this->SQL
+ ->select('a.ActivityID', 'count', 'ActivityCount')
+ ->from('Activity a')
+ ->join('ActivityType t', 'a.ActivityTypeID = t.ActivityTypeID')
+ ->join('UserRole ur', 'a.ActivityUserID = ur.UserID')
+ ->whereIn('ur.RoleID', $roleID)
+ ->where('t.Public', '1')
+ ->get()
+ ->firstRow()
+ ->ActivityCount;
+ }
+
+ /**
+ * Get a particular activity record.
+ *
+ * @param int $activityID Unique ID of activity item.
+ * @param bool|string $dataSetType The format of the resulting data.
+ * @param array $options Not used.
+ * @return array|object A single SQL result.
+ */
+ public function getID($activityID, $dataSetType = false, $options = []) {
+ $activity = parent::getID($activityID, $dataSetType);
+ if ($activity) {
+ $this->calculateRow($activity);
+ $activities = [$activity];
+ self::joinUsers($activities);
+ $activity = array_pop($activities);
+ }
+
+ return $activity;
+ }
+
+ /**
+ * Get notifications for a user.
+ *
+ * Events: BeforeGetNotifications.
+ *
+ * @param int $notifyUserID Unique ID of user.
+ * @param int $offset Number to skip.
+ * @param int $limit Max number to return.
+ * @return Gdn_DataSet SQL results.
+ * @since 2.0.0
+ */
+ public function getNotifications($notifyUserID, $offset = 0, $limit = 30) {
+ $this->activityQuery(false);
+ $this->fireEvent('BeforeGetNotifications');
+ $result = $this->SQL
+ ->where('NotifyUserID', $notifyUserID)
+ ->limit($limit, $offset)
+ ->orderBy('a.ActivityID', 'desc')
+ ->get();
+ $result->datasetType(DATASET_TYPE_ARRAY);
+
+ self::getUsers($result->resultArray());
+ Gdn::userModel()->joinUsers(
+ $result->resultArray(),
+ ['ActivityUserID', 'RegardingUserID'],
+ ['Join' => ['Name', 'Photo', 'Email', 'Gender']]
+ );
+ $this->calculateData($result->resultArray());
+
+ return $result;
+ }
+
+
+ /**
+ * @param $activity
+ * @return bool
+ */
+ public static function canDelete($activity) {
+ $session = Gdn::session();
+
+ $profileUserId = val('ActivityUserID', $activity);
+ $notifyUserId = val('NotifyUserID', $activity);
+
+ // User can delete any activity
+ if ($session->checkPermission('Garden.Activity.Delete')) {
+ return true;
+ }
+
+ $notifyUserIds = [ActivityModel::NOTIFY_PUBLIC];
+ if (Gdn::session()->checkPermission('Garden.Moderation.Manage')) {
+ $notifyUserIds[] = ActivityModel::NOTIFY_MODS;
+ }
+
+ // Is this a wall post?
+ if (!in_array(val('ActivityType', $activity), ['Status', 'WallPost']) || !in_array($notifyUserId, $notifyUserIds)) {
+ return false;
+ }
+ // Is this on the user's wall?
+ if ($profileUserId && $session->UserID == $profileUserId && $session->checkPermission('Garden.Profiles.Edit')) {
+ return true;
+ }
+
+ // The user inserted the activity --- may be added in later
+// $insertUserId = val('InsertUserID', $activity);
+// if ($insertUserId && $insertUserId == $session->UserID) {
+// return true;
+// }
+
+ return false;
+ }
+
+ /**
+ * Get notifications for a user since designated ActivityID.
+ *
+ * Events: BeforeGetNotificationsSince.
+ *
+ * @param int $userID Unique ID of user.
+ * @param int $lastActivityID ID of activity to start at.
+ * @param array|string $filterToActivityTypeIDs Limits returned activity to particular types.
+ * @param int $limit Max number to return.
+ * @return Gdn_DataSet SQL results.
+ * @since 2.0.18
+ */
+ public function getNotificationsSince($userID, $lastActivityID, $filterToActivityTypeIDs = '', $limit = 5) {
+ $this->activityQuery();
+ $this->fireEvent('BeforeGetNotificationsSince');
+ if (is_array($filterToActivityTypeIDs)) {
+ $this->SQL->whereIn('a.ActivityTypeID', $filterToActivityTypeIDs);
+ } else {
+ $this->SQL->where('t.Notify', '1');
+ }
+
+ $result = $this->SQL
+ ->where('RegardingUserID', $userID)
+ ->where('a.ActivityID >', $lastActivityID)
+ ->limit($limit, 0)
+ ->orderBy('a.ActivityID', 'desc')
+ ->get();
+
+ return $result;
+ }
+
+ /**
+ * @param int $iD
+ * @return array|false
+ */
+ public function getComment($iD) {
+ $activity = $this->SQL->getWhere('ActivityComment', ['ActivityCommentID' => $iD])->resultArray();
+ if ($activity) {
+ Gdn::userModel()->joinUsers($activity, ['InsertUserID'], ['Join' => ['Name', 'Photo', 'Email']]);
+ return array_shift($activity);
+ }
+ return false;
+ }
+
+ /**
+ * Get comments related to designated activity items.
+ *
+ * Events: BeforeGetComments.
+ *
+ * @param array $activityIDs IDs of activity items.
+ * @return Gdn_DataSet SQL results.
+ */
+ public function getComments($activityIDs) {
+ $result = $this->SQL
+ ->select('c.*')
+ ->from('ActivityComment c')
+ ->whereIn('c.ActivityID', $activityIDs)
+ ->orderBy('c.ActivityID, c.DateInserted')
+ ->get()->resultArray();
+ Gdn::userModel()->joinUsers($result, ['InsertUserID'], ['Join' => ['Name', 'Photo', 'Email']]);
+ return $result;
+ }
+
+ /**
+ * Add a new activity item.
+ *
+ * Getting reworked for 2.1 so I'm cheating and skipping params for now. -mlr
+ *
+ * @param int $activityUserID
+ * @param string $activityType
+ * @param string $story
+ * @param int|null $regardingUserID
+ * @param int $commentActivityID
+ * @param string $route
+ * @param string|bool $sendEmail
+ * @return int ActivityID of item created.
+ */
+ public function add($activityUserID, $activityType, $story = null, $regardingUserID = null, $commentActivityID = null, $route = null, $sendEmail = '') {
+ // Get the ActivityTypeID & see if this is a notification.
+ $activityTypeRow = self::getActivityType($activityType);
+ $notify = val('Notify', $activityTypeRow, false);
+
+ if ($activityTypeRow === false) {
+ trigger_error(
+ errorMessage(sprintf('Activity type could not be found: %s', $activityType), 'ActivityModel', 'Add'),
+ E_USER_ERROR
+ );
+ }
+
+ $activity = [
+ 'ActivityUserID' => $activityUserID,
+ 'ActivityType' => $activityType,
+ 'Story' => $story,
+ 'RegardingUserID' => $regardingUserID,
+ 'Route' => $route
+ ];
+
+
+ // Massage $SendEmail to allow for only sending an email.
+ if ($sendEmail === 'Only') {
+ $sendEmail = '';
+ } elseif ($sendEmail === 'QueueOnly') {
+ $sendEmail = '';
+ $notify = true;
+ }
+
+ // If $SendEmail was FALSE or TRUE, let it override the $Notify setting.
+ if ($sendEmail === false || $sendEmail === true) {
+ $notify = $sendEmail;
+ }
+
+ $preference = false;
+ if (($activityTypeRow['Notify'] || !$activityTypeRow['Public']) && !empty($regardingUserID)) {
+ $activity['NotifyUserID'] = $activity['RegardingUserID'];
+ $preference = $activityType;
+ } else {
+ $activity['NotifyUserID'] = self::NOTIFY_PUBLIC;
+ }
+
+ // Otherwise let the decision to email lie with the $Notify setting.
+ if ($sendEmail === 'Force' || $notify) {
+ $activity['Emailed'] = self::SENT_PENDING;
+ } elseif ($notify) {
+ $activity['Emailed'] = self::SENT_PENDING;
+ } elseif ($sendEmail === false) {
+ $activity['Emailed'] = self::SENT_ARCHIVE;
+ }
+
+ $activity = $this->save($activity, $preference);
+
+ return val('ActivityID', $activity);
+ }
+
+ /**
+ * Join the users to the activities.
+ *
+ * @param array|Gdn_DataSet &$activities The activities to join.
+ */
+ public static function joinUsers(&$activities) {
+ Gdn::userModel()->joinUsers(
+ $activities,
+ ['ActivityUserID', 'RegardingUserID'],
+ ['Join' => ['Name', 'Email', 'Gender', 'Photo']]
+ );
+ }
+
+ /**
+ * Get default notification preference for an activity type.
+ *
+ * @since 2.0.0
+ * @access public
+ * @param string $activityType
+ * @param array $preferences
+ * @param string $type One of the following:
+ * - Popup: Popup a notification.
+ * - Email: Email the notification.
+ * - NULL: True if either notification is true.
+ * - both: Return an array of (Popup, Email).
+ * @return bool|bool[]
+ */
+ public static function notificationPreference($activityType, $preferences, $type = null) {
+ if (is_numeric($preferences)) {
+ $user = Gdn::userModel()->getID($preferences);
+ if (!$user) {
+ return $type == 'both' ? [false, false] : false;
+ }
+ $preferences = val('Preferences', $user);
+ }
+
+ if ($type === null) {
+ $result = self::notificationPreference($activityType, $preferences, 'Email')
+ || self::notificationPreference($activityType, $preferences, 'Popup');
+
+ return $result;
+ } elseif ($type === 'both') {
+ $result = [
+ self::notificationPreference($activityType, $preferences, 'Popup'),
+ self::notificationPreference($activityType, $preferences, 'Email')
+ ];
+ return $result;
+ }
+
+ $configPreference = c("Preferences.$type.$activityType", '0');
+ if ((int)$configPreference === 2) {
+ $preference = true; // This preference is forced on.
+ } elseif ($configPreference !== false) {
+ $preference = val($type.'.'.$activityType, $preferences, $configPreference);
+ } else {
+ $preference = false;
+ }
+
+ return $preference;
+ }
+
+ /**
+ * Send notification.
+ *
+ * @since 2.0.17
+ * @access public
+ * @param int $ActivityID
+ * @param array|string $Story
+ * @param bool $Force
+ */
+ public function sendNotification($ActivityID, $Story = '', $Force = false) {
+ $Activity = $this->getID($ActivityID);
+ if (!$Activity) {
+ return;
+ }
+
+ $Activity = (object)$Activity;
+
+ $Story = Gdn_Format::text($Story == '' ? $Activity->Story : $Story, false);
+ // If this is a comment on another activity, fudge the activity a bit so that everything appears properly.
+ if (is_null($Activity->RegardingUserID) && $Activity->CommentActivityID > 0) {
+ $CommentActivity = $this->getID($Activity->CommentActivityID);
+ $Activity->RegardingUserID = $CommentActivity->RegardingUserID;
+ $Activity->Route = '/activity/item/'.$Activity->CommentActivityID;
+ }
+
+ $User = Gdn::userModel()->getID($Activity->RegardingUserID, DATASET_TYPE_OBJECT);
+
+ if ($User) {
+ if ($Force) {
+ $Preference = $Force;
+ } else {
+ $Preferences = $User->Preferences;
+ $Preference = val('Email.'.$Activity->ActivityType, $Preferences, Gdn::config('Preferences.Email.'.$Activity->ActivityType));
+ }
+ if ($Preference) {
+ $ActivityHeadline = Gdn_Format::text(Gdn_Format::activityHeadline($Activity, $Activity->ActivityUserID, $Activity->RegardingUserID), false);
+ $Email = new Gdn_Email();
+ $Email->subject($ActivityHeadline);
+ $Email->to($User);
+
+ $url = externalUrl(val('Route', $Activity) == '' ? '/' : val('Route', $Activity));
+ $emailTemplate = $Email->getEmailTemplate()
+ ->setButton($url, val('ActionText', $Activity, t('Check it out')))
+ ->setTitle($ActivityHeadline);
+
+ if ($message = $this->getEmailMessage($Activity)) {
+ $emailTemplate->setMessage($message, true);
+ }
+
+ $Email->setEmailTemplate($emailTemplate);
+
+ $Notification = ['ActivityID' => $ActivityID, 'User' => $User, 'Email' => $Email, 'Route' => $Activity->Route, 'Story' => $Story, 'Headline' => $ActivityHeadline, 'Activity' => $Activity];
+ $this->EventArguments = $Notification;
+ $this->fireEvent('BeforeSendNotification');
+ try {
+ // Only send if the user is not banned
+ if (!val('Banned', $User)) {
+ $Email->send();
+ $Emailed = self::SENT_OK;
+ } else {
+ $Emailed = self::SENT_SKIPPED;
+ }
+ } catch (phpmailerException $pex) {
+ if ($pex->getCode() == PHPMailer::STOP_CRITICAL && !$Email->PhpMailer->isServerError($pex)) {
+ $Emailed = self::SENT_FAIL;
+ } else {
+ $Emailed = self::SENT_ERROR;
+ }
+ } catch (Exception $ex) {
+ switch ($ex->getCode()) {
+ case Gdn_Email::ERR_SKIPPED:
+ $Emailed = self::SENT_SKIPPED;
+ break;
+ default:
+ $Emailed = self::SENT_FAIL; // similar to http 5xx
+ }
+ }
+ try {
+ $this->SQL->put('Activity', ['Emailed' => $Emailed], ['ActivityID' => $ActivityID]);
+ } catch (Exception $Ex) {
+ // We don't want a noisy error in a behind-the-scenes notification.
+ }
+ }
+ }
+ }
+
+
+ /**
+ * Takes an array representing an activity and builds the email message based on the activity's story and
+ * the contents of the global config Garden.Email.Prefix.
+ *
+ * @param array|object $activity The activity to build the email for.
+ * @return string The email message.
+ */
+ private function getEmailMessage($activity) {
+ $message = '';
+
+ if ($prefix = c('Garden.Email.Prefix', '')) {
+ $message = $prefix;
+ }
+
+ $isArray = is_array($activity);
+
+ $story = $isArray ? $activity['Story'] ?? null : $activity->Story ?? null;
+ $format = $isArray ? $activity['Format'] ?? null : $activity->Format ?? null;
+
+ if ($story && $format) {
+ $message .= Gdn_Format::to($story, $format);
+ }
+
+ return $message;
+ }
+
+ /**
+ *
+ *
+ * @param $activity
+ * @param array $options Options to modify the behavior of the emailing.
+ *
+ * - **NoDelete**: Don't delete an email-only activity once the email is sent.
+ * - **EmailSubject**: A custom subject for the email.
+ * @return bool
+ * @throws Exception
+ */
+ public function email(&$activity, $options = []) {
+ // The $options parameter used to be $noDelete bool, this is the backwards compat.
+ if (is_bool($options)) {
+ $options = ['NoDelete' => $options];
+ }
+ $options += [
+ 'NoDelete' => false,
+ 'EmailSubject' => '',
+ ];
+
+ if (is_numeric($activity)) {
+ $activityID = $activity;
+ $activity = $this->getID($activityID);
+ } else {
+ $activityID = val('ActivityID', $activity);
+ }
+
+ if (!$activity) {
+ return false;
+ }
+
+ $activity = (array)$activity;
+
+ $user = Gdn::userModel()->getID($activity['NotifyUserID'], DATASET_TYPE_ARRAY);
+ if (!$user) {
+ return false;
+ }
+
+ // Format the activity headline based on the user being emailed.
+ if (val('HeadlineFormat', $activity)) {
+ $sessionUserID = Gdn::session()->UserID;
+ Gdn::session()->UserID = $user['UserID'];
+ $activity['Headline'] = formatString($activity['HeadlineFormat'], $activity);
+ Gdn::session()->UserID = $sessionUserID;
+ } else {
+ if (!isset($activity['ActivityGender'])) {
+ $aT = self::getActivityType($activity['ActivityType']);
+
+ $data = [$activity];
+ self::joinUsers($data);
+ $activity = $data[0];
+ $activity['RouteCode'] = val('RouteCode', $aT);
+ $activity['FullHeadline'] = val('FullHeadline', $aT);
+ $activity['ProfileHeadline'] = val('ProfileHeadline', $aT);
+ }
+
+ $activity['Headline'] = Gdn_Format::activityHeadline($activity, '', $user['UserID']);
+ }
+
+ $subject = $options['EmailSubject'] ?: Gdn_Format::plainText($activity['Headline']);
+
+ // Build the email to send.
+ $email = new Gdn_Email();
+ // FIX: remove app title
+ $email->subject($subject);
+ $email->to($user);
+
+ $url = externalUrl(val('Route', $activity) == '' ? '/' : val('Route', $activity));
+
+ $emailTemplate = $email->getEmailTemplate()
+ ->setButton($url, val('ActionText', $activity, t('Check it out')))
+ ->setTitle($subject);
+
+ if ($message = $this->getEmailMessage($activity)) {
+ $emailTemplate->setMessage($message, true);
+ }
+
+ $email->setEmailTemplate($emailTemplate);
+
+ // Fire an event for the notification.
+ $notification = ['ActivityID' => $activityID, 'User' => $user, 'Email' => $email, 'Route' => $activity['Route'], 'Story' => $activity['Story'], 'Headline' => $activity['Headline'], 'Activity' => $activity];
+ $this->EventArguments = $notification;
+ $this->fireEvent('BeforeSendNotification');
+
+ // Send the email.
+ try {
+ // Only send if the user is not banned
+ if (!val('Banned', $user)) {
+ $email->send();
+ $emailed = self::SENT_OK;
+ } else {
+ $emailed = self::SENT_SKIPPED;
+ }
+
+ // Delete the activity now that it has been emailed.
+ if (!$options['NoDelete'] && !$activity['Notified']) {
+ if (val('ActivityID', $activity)) {
+ $this->delete($activity['ActivityID']);
+ } else {
+ $activity['_Delete'] = true;
+ }
+ }
+ } catch (phpmailerException $pex) {
+ if ($pex->getCode() == PHPMailer::STOP_CRITICAL && !$email->PhpMailer->isServerError($pex)) {
+ $emailed = self::SENT_FAIL;
+ } else {
+ $emailed = self::SENT_ERROR;
+ }
+ } catch (Exception $ex) {
+ switch ($ex->getCode()) {
+ case Gdn_Email::ERR_SKIPPED:
+ $emailed = self::SENT_SKIPPED;
+ break;
+ default:
+ $emailed = self::SENT_FAIL; // similar to http 5xx
+ }
+ }
+ $activity['Emailed'] = $emailed;
+ if ($activityID) {
+ // Save the emailed flag back to the activity.
+ $this->SQL->put('Activity', ['Emailed' => $emailed], ['ActivityID' => $activityID]);
+ }
+ return true;
+ }
+
+ /**
+ * @var array The Notification Queue is used to stack up notifications to users. Ensures
+ * that they only receive one notification about a single topic. For example:
+ * if someone comments on a discussion that they started and they have
+ * bookmarked, it will only notify them about one or the other, not both.
+ *
+ * This code makes the assumption that the queue is used for one user action
+ * at a time. For example: a comment being added to a discussion. The queue
+ * should be cleared before it is used, and sending the queue will clear it
+ * again.
+ */
+ private $_NotificationQueue = [];
+
+ /**
+ * Clear notification queue.
+ *
+ * @since 2.0.17
+ * @access public
+ */
+ public function clearNotificationQueue() {
+ unset($this->_NotificationQueue);
+ $this->_NotificationQueue = [];
+ }
+
+ /**
+ * Save a comment on an activity.
+ *
+ * @param array $comment
+ * @return int|bool|string
+ * @since 2.1
+ */
+ public function comment($comment) {
+ $comment['InsertUserID'] = Gdn::session()->UserID;
+ $comment['DateInserted'] = Gdn_Format::toDateTime();
+ $comment['InsertIPAddress'] = ipEncode(Gdn::request()->ipAddress());
+
+ $this->Validation->applyRule('ActivityID', 'Required');
+ $this->Validation->applyRule('Body', 'Required');
+ $this->Validation->applyRule('DateInserted', 'Required');
+ $this->Validation->applyRule('InsertUserID', 'Required');
+
+ $this->EventArguments['Comment'] = &$comment;
+ $this->fireEvent('BeforeSaveComment');
+
+ if ($this->validate($comment)) {
+ $activity = $this->getID($comment['ActivityID'], DATASET_TYPE_ARRAY);
+
+ $_ActivityID = $comment['ActivityID'];
+ // Check to see if this is a shared activity/notification.
+ if ($commentActivityID = val('CommentActivityID', $activity['Data'])) {
+ Gdn::controller()->json('CommentActivityID', $commentActivityID);
+ $comment['ActivityID'] = $commentActivityID;
+ }
+
+ $storageObject = FloodControlHelper::configure($this, 'Vanilla', 'ActivityComment');
+ if ($this->checkUserSpamming(Gdn::session()->User->UserID, $storageObject)) {
+ return false;
+ }
+
+ // Check for spam.
+ $spam = SpamModel::isSpam('ActivityComment', $comment);
+ if ($spam) {
+ return SPAM;
+ }
+
+ // Check for approval
+ $approvalRequired = checkRestriction('Vanilla.Approval.Require');
+ if ($approvalRequired && !val('Verified', Gdn::session()->User)) {
+ LogModel::insert('Pending', 'ActivityComment', $comment);
+ return UNAPPROVED;
+ }
+
+ $iD = $this->SQL->insert('ActivityComment', $comment);
+
+ if ($iD) {
+ // Check to see if this comment bumps the activity.
+ if ($activity && val('Bump', $activity['Data'])) {
+ $this->SQL->put('Activity', ['DateUpdated' => $comment['DateInserted']], ['ActivityID' => $activity['ActivityID']]);
+ if ($_ActivityID != $comment['ActivityID']) {
+ $this->SQL->put('Activity', ['DateUpdated' => $comment['DateInserted']], ['ActivityID' => $_ActivityID]);
+ }
+ }
+
+ // Send a notification to the original person.
+ if (val('ActivityType', $activity) === 'WallPost') {
+ $this->notifyWallComment($comment, $activity);
+ }
+ }
+
+ return $iD;
+ }
+ return false;
+ }
+
+ /**
+ * Send all notifications in the queue.
+ *
+ * @since 2.0.17
+ * @access public
+ */
+ public function sendNotificationQueue() {
+ foreach ($this->_NotificationQueue as $userID => $notifications) {
+ if (is_array($notifications)) {
+ // Only send out one notification per user.
+ $notification = $notifications[0];
+
+ /* @var Gdn_Email $Email */
+ $email = $notification['Email'];
+
+ if (is_object($email) && method_exists($email, 'send')) {
+ $this->EventArguments = $notification;
+ $this->fireEvent('BeforeSendNotification');
+
+ try {
+ // Only send if the user is not banned
+ $user = Gdn::userModel()->getID($userID);
+ if (!val('Banned', $user)) {
+ $email->send();
+ $emailed = self::SENT_OK;
+ } else {
+ $emailed = self::SENT_SKIPPED;
+ }
+ } catch (phpmailerException $pex) {
+ if ($pex->getCode() == PHPMailer::STOP_CRITICAL && !$email->PhpMailer->isServerError($pex)) {
+ $emailed = self::SENT_FAIL;
+ } else {
+ $emailed = self::SENT_ERROR;
+ }
+ } catch (Exception $ex) {
+ switch ($ex->getCode()) {
+ case Gdn_Email::ERR_SKIPPED:
+ $emailed = self::SENT_SKIPPED;
+ break;
+ default:
+ $emailed = self::SENT_FAIL;
+ }
+ }
+
+ try {
+ $this->SQL->put('Activity', ['Emailed' => $emailed], ['ActivityID' => $notification['ActivityID']]);
+ } catch (Exception $ex) {
+ // Ignore an exception in a behind-the-scenes notification.
+ }
+ }
+ }
+ }
+
+ // Clear out the queue
+ unset($this->_NotificationQueue);
+ $this->_NotificationQueue = [];
+ }
+
+ /**
+ * Get total unread notifications for a user.
+ *
+ * @param integer $userID
+ */
+ public function getUserTotalUnread($userID) {
+ $notifications = $this->SQL
+ ->select("ActivityID", "count", "total")
+ ->from($this->Name)
+ ->where("NotifyUserID", $userID)
+ ->where("Notified", self::SENT_PENDING)
+ ->get()
+ ->resultArray();
+ if (!is_array($notifications) || !isset($notifications[0])) {
+ return 0;
+ }
+ return $notifications[0]["total"] ?? 0;
+ }
+
+ /**
+ *
+ *
+ * @param $activityIDs
+ * @throws Exception
+ */
+ public function setNotified($activityIDs) {
+ if (!is_array($activityIDs) || count($activityIDs) == 0) {
+ return;
+ }
+
+ $this->SQL->update('Activity')
+ ->set('Notified', self::SENT_OK)
+ ->whereIn('ActivityID', $activityIDs)
+ ->put();
+ }
+
+ /**
+ *
+ *
+ * @param $activity
+ * @throws Exception
+ */
+ public function share(&$activity) {
+ // Massage the event for the user.
+ $this->EventArguments['RecordType'] = 'Activity';
+ $this->EventArguments['Activity'] =& $activity;
+
+ $this->fireEvent('Share');
+ }
+
+ /**
+ * Queue a notification for sending.
+ *
+ * @since 2.0.17
+ * @access public
+ * @param int $activityID
+ * @param string $story
+ * @param string $position
+ * @param bool $force
+ */
+ public function queueNotification($activityID, $story = '', $position = 'last', $force = false) {
+ $activity = $this->getID($activityID);
+ if (!is_object($activity)) {
+ return;
+ }
+
+ $story = Gdn_Format::text($story == '' ? $activity->Story : $story, false);
+ // If this is a comment on another activity, fudge the activity a bit so that everything appears properly.
+ if (is_null($activity->RegardingUserID) && $activity->CommentActivityID > 0) {
+ $commentActivity = $this->getID($activity->CommentActivityID);
+ $activity->RegardingUserID = $commentActivity->RegardingUserID;
+ $activity->Route = '/activity/item/'.$activity->CommentActivityID;
+ }
+ $user = Gdn::userModel()->getID($activity->RegardingUserID, DATASET_TYPE_OBJECT);
+
+ if ($user) {
+ if ($force) {
+ $preference = $force;
+ } else {
+ $configPreference = c('Preferences.Email.'.$activity->ActivityType, '0');
+ if ($configPreference !== false) {
+ $preference = val('Email.'.$activity->ActivityType, $user->Preferences, $configPreference);
+ } else {
+ $preference = false;
+ }
+ }
+
+ if ($preference) {
+ $activityHeadline = Gdn_Format::text(Gdn_Format::activityHeadline($activity, $activity->ActivityUserID, $activity->RegardingUserID), false);
+ $email = new Gdn_Email();
+ $email->subject($activityHeadline);
+ $email->to($user);
+ $url = externalUrl(val('Route', $activity) == '' ? '/' : val('Route', $activity));
+
+ $emailTemplate = $email->getEmailTemplate()
+ ->setButton($url, val('ActionText', $activity, t('Check it out')))
+ ->setTitle(Gdn_Format::plainText(val('Headline', $activity)));
+
+ if ($message = $this->getEmailMessage($activity)) {
+ $emailTemplate->setMessage($message, true);
+ }
+
+ $email->setEmailTemplate($emailTemplate);
+
+ if (!array_key_exists($user->UserID, $this->_NotificationQueue)) {
+ $this->_NotificationQueue[$user->UserID] = [];
+ }
+
+ $notification = ['ActivityID' => $activityID, 'User' => $user, 'Email' => $email, 'Route' => $activity->Route, 'Story' => $story, 'Headline' => $activityHeadline, 'Activity' => $activity];
+ if ($position == 'first') {
+ $this->_NotificationQueue[$user->UserID] = array_merge([$notification], $this->_NotificationQueue[$user->UserID]);
+ } else {
+ $this->_NotificationQueue[$user->UserID][] = $notification;
+ }
+ }
+ }
+ }
+
+ /**
+ * Queue an activity for saving later.
+ *
+ * @param array $data The data in the activity.
+ * @param string|bool $preference The name of the preference governing the activity.
+ * @param array $options Additional options for saving.
+ * @throws Exception
+ */
+ public function queue($data, $preference = false, $options = []) {
+ $this->_touch($data);
+ if (!isset($data['NotifyUserID']) || !isset($data['ActivityType'])) {
+ throw new Exception('Data missing NotifyUserID and/or ActivityType', 400);
+ }
+
+ if ($data['ActivityUserID'] == $data['NotifyUserID'] && !val('Force', $options)) {
+ return; // don't notify users of something they did.
+ }
+ $notified = $data['Notified'];
+ $emailed = $data['Emailed'];
+
+ if (isset(self::$Queue[$data['NotifyUserID']][$data['ActivityType']])) {
+ list($currentData, $currentOptions) = self::$Queue[$data['NotifyUserID']][$data['ActivityType']];
+
+ $notified = $notified ? $notified : $currentData['Notified'];
+ $emailed = $emailed ? $emailed : $currentData['Emailed'];
+
+ $reason = null;
+ if (isset($currentData['Data']['Reason']) && isset($data['Data']['Reason'])) {
+ $reason = array_merge((array)$currentData['Data']['Reason'], (array)$data['Data']['Reason']);
+ $reason = array_unique($reason);
+ }
+
+ $data = array_merge($currentData, $data);
+ $options = array_merge($currentOptions, $options);
+ if ($reason) {
+ $data['Data']['Reason'] = $reason;
+ }
+ }
+
+ $this->EventArguments['Preference'] = $preference;
+ $this->EventArguments['Options'] = $options;
+ $this->EventArguments['Data'] = &$data;
+ $this->fireEvent('BeforeCheckPreference');
+ if (!empty($preference)) {
+ list($popup, $email) = self::notificationPreference($preference, $data['NotifyUserID'], 'both');
+ if (!$popup && !$email) {
+ return; // don't queue if user doesn't want to be notified at all.
+ }
+ if ($popup) {
+ $notified = self::SENT_PENDING;
+ }
+ if ($email) {
+ $emailed = self::SENT_PENDING;
+ }
+ }
+ $data['Notified'] = $notified;
+ $data['Emailed'] = $emailed;
+
+ self::$Queue[$data['NotifyUserID']][$data['ActivityType']] = [$data, $options];
+ }
+
+ /**
+ *
+ *
+ * @param array $data
+ * @param bool $preference
+ * @param array $options
+ * @return array|bool|string|null
+ * @throws Exception
+ */
+ public function save($data, $preference = false, $options = []) {
+ trace('ActivityModel->save()');
+ $activity = $data;
+ $this->_touch($activity);
+
+ if ($activity['ActivityUserID'] == $activity['NotifyUserID'] && !val('Force', $options)) {
+ trace('Skipping activity because it would notify the user of something they did.');
+
+ return null; // don't notify users of something they did.
+ }
+
+ // Check the user's preference.
+ if ($preference) {
+ list($popup, $email) = self::notificationPreference($preference, $activity['NotifyUserID'], 'both');
+
+ if ($popup && !$activity['Notified']) {
+ $activity['Notified'] = self::SENT_PENDING;
+ }
+ if ($email && !$activity['Emailed']) {
+ $activity['Emailed'] = self::SENT_PENDING;
+ }
+
+ if (!$activity['Notified'] && !$activity['Emailed'] && !val('Force', $options)) {
+ trace("Skipping activity because the user has no preference set.");
+ return null;
+ }
+ }
+
+ $activityType = self::getActivityType($activity['ActivityType']);
+ $activityTypeID = val('ActivityTypeID', $activityType);
+ if (!$activityTypeID) {
+ trace("There is no $activityType activity type.", TRACE_WARNING);
+ $activityType = self::getActivityType('Default');
+ $activityTypeID = val('ActivityTypeID', $activityType);
+ }
+
+ $activity['ActivityTypeID'] = $activityTypeID;
+
+ $notificationInc = 0;
+ if ($activity['NotifyUserID'] > 0 && $activity['Notified']) {
+ $notificationInc = 1;
+ }
+
+ // Check to see if we are sharing this activity with another one.
+ if ($commentActivityID = val('CommentActivityID', $activity['Data'])) {
+ $commentActivity = $this->getID($commentActivityID);
+ $activity['Data']['CommentNotifyUserID'] = $commentActivity['NotifyUserID'];
+ }
+
+ // Make sure this activity isn't a duplicate.
+ if (val('CheckRecord', $options)) {
+ // Check to see if this record already notified so we don't notify multiple times.
+ $where = arrayTranslate($activity, ['NotifyUserID', 'RecordType', 'RecordID']);
+ $where['DateUpdated >'] = Gdn_Format::toDateTime(strtotime('-2 days')); // index hint
+
+ $checkActivity = $this->SQL->getWhere(
+ 'Activity',
+ $where
+ )->firstRow();
+
+ if ($checkActivity) {
+ return false;
+ }
+ }
+
+ // Check to share the activity.
+ if (val('Share', $options)) {
+ $this->share($activity);
+ }
+
+ // Group he activity.
+ if ($groupBy = val('GroupBy', $options)) {
+ $groupBy = (array)$groupBy;
+ $where = [];
+ foreach ($groupBy as $columnName) {
+ $where[$columnName] = $activity[$columnName];
+ }
+ $where['NotifyUserID'] = $activity['NotifyUserID'];
+ // Make sure to only group activities by day.
+ $where['DateInserted >'] = Gdn_Format::toDateTime(strtotime('-1 day'));
+
+ // See if there is another activity to group these into.
+ $groupActivity = $this->SQL->getWhere(
+ 'Activity',
+ $where
+ )->firstRow(DATASET_TYPE_ARRAY);
+
+ if ($groupActivity) {
+ $groupActivity['Data'] = dbdecode($groupActivity['Data']);
+ $activity = $this->mergeActivities($groupActivity, $activity);
+ $notificationInc = 0;
+ }
+ }
+
+ $delete = false;
+ if ($activity['Emailed'] == self::SENT_PENDING) {
+ $this->email($activity, $options);
+ $delete = val('_Delete', $activity);
+ }
+
+ $activityData = $activity['Data'];
+ if (isset($activity['Data']) && is_array($activity['Data'])) {
+ $activity['Data'] = dbencode($activity['Data']);
+ }
+
+ $this->defineSchema();
+ $activity = $this->filterSchema($activity);
+
+ $activityID = val('ActivityID', $activity);
+ if (!$activityID) {
+ if (!$delete) {
+ if (!val('DisableFloodControl', $options)) {
+ $storageObject = FloodControlHelper::configure($this, 'Vanilla', 'Activity');
+ if ($this->checkUserSpamming(Gdn::session()->UserID, $storageObject)) {
+ return false;
+ }
+ }
+
+ $this->addInsertFields($activity);
+ touchValue('DateUpdated', $activity, $activity['DateInserted']);
+
+ $this->EventArguments['Activity'] =& $activity;
+ $this->EventArguments['ActivityID'] = null;
+
+ $handled = false;
+ $this->EventArguments['Handled'] =& $handled;
+
+ $this->fireEvent('BeforeSave');
+
+ if (count($this->validationResults()) > 0) {
+ return false;
+ }
+
+ if ($handled) {
+ // A plugin handled this activity so don't save it.
+ return $activity;
+ }
+
+ if (val('CheckSpam', $options)) {
+ // Check for spam
+ $spam = SpamModel::isSpam('Activity', $activity);
+ if ($spam) {
+ return SPAM;
+ }
+
+ // Check for approval
+ $approvalRequired = checkRestriction('Vanilla.Approval.Require');
+ if ($approvalRequired && !val('Verified', Gdn::session()->User)) {
+ LogModel::insert('Pending', 'Activity', $activity);
+ return UNAPPROVED;
+ }
+ }
+
+ $activityID = $this->SQL->insert('Activity', $activity);
+ $activity['ActivityID'] = $activityID;
+
+ $this->prune();
+ }
+ } else {
+ $activity['DateUpdated'] = Gdn_Format::toDateTime();
+ unset($activity['ActivityID']);
+
+ $this->EventArguments['Activity'] =& $activity;
+ $this->EventArguments['ActivityID'] = $activityID;
+ $this->fireEvent('BeforeSave');
+
+ if (count($this->validationResults()) > 0) {
+ return false;
+ }
+
+ $this->SQL->put('Activity', $activity, ['ActivityID' => $activityID]);
+ $activity['ActivityID'] = $activityID;
+ }
+ $activity['Data'] = $activityData;
+
+ if (isset($commentActivity)) {
+ $commentActivity['Data']['SharedActivityID'] = $activity['ActivityID'];
+ $commentActivity['Data']['SharedNotifyUserID'] = $activity['NotifyUserID'];
+ $this->setField($commentActivity['ActivityID'], 'Data', $commentActivity['Data']);
+ }
+
+ if ($notificationInc > 0) {
+ $countNotifications = Gdn::userModel()->getID($activity['NotifyUserID'])->CountNotifications + $notificationInc;
+ Gdn::userModel()->setField($activity['NotifyUserID'], 'CountNotifications', $countNotifications);
+ }
+
+ // If this is a wall post then we need to notify on that.
+ if (val('Name', $activityType) == 'WallPost' && $activity['NotifyUserID'] == self::NOTIFY_PUBLIC) {
+ $this->notifyWallPost($activity);
+ }
+
+ return $activity;
+ }
+
+ /**
+ * Update a single activity's notification fields to reflect a read status.
+ *
+ * @param int $activityID
+ */
+ public function markSingleRead(int $activityID) {
+ $this->SQL->put(
+ "Activity",
+ ["Notified" => self::SENT_OK, "Emailed" => self::SENT_OK],
+ ["ActivityID" => $activityID]
+ );
+ }
+
+ /**
+ *
+ *
+ * @param $userID
+ */
+ public function markRead($userID) {
+ // Mark all of a user's unread activities read.
+ $this->SQL->put(
+ 'Activity',
+ ['Notified' => self::SENT_OK],
+ ['NotifyUserID' => $userID, 'Notified' => self::SENT_PENDING]
+ );
+
+ $user = Gdn::userModel()->getID($userID);
+ if (val('CountNotifications', $user) != 0) {
+ Gdn::userModel()->setField($userID, 'CountNotifications', 0);
+ }
+ }
+
+ /**
+ *
+ *
+ * @param $oldActivity
+ * @param $newActivity
+ * @param array $options
+ * @return array
+ */
+ public function mergeActivities($oldActivity, $newActivity, $options = []) {
+ // Group the two activities together.
+ $activityUserIDs = val('ActivityUserIDs', $oldActivity['Data'], []);
+ $activityUserCount = val('ActivityUserID_Count', $oldActivity['Data'], 0);
+ array_unshift($activityUserIDs, $oldActivity['ActivityUserID']);
+ if (($i = array_search($newActivity['ActivityUserID'], $activityUserIDs)) !== false) {
+ unset($activityUserIDs[$i]);
+ $activityUserIDs = array_values($activityUserIDs);
+ }
+ $activityUserIDs = array_unique($activityUserIDs);
+ if (count($activityUserIDs) > self::$MaxMergeCount) {
+ array_pop($activityUserIDs);
+ $activityUserCount++;
+ }
+
+ $regardingUserCount = 0;
+ if (val('RegardingUserID', $newActivity)) {
+ $regardingUserIDs = val('RegardingUserIDs', $oldActivity['Data'], []);
+ $regardingUserCount = val('RegardingUserID_Count', $oldActivity['Data'], 0);
+ array_unshift($regardingUserIDs, $oldActivity['RegardingUserID']);
+ if (($i = array_search($newActivity['RegardingUserID'], $regardingUserIDs)) !== false) {
+ unset($regardingUserIDs[$i]);
+ $regardingUserIDs = array_values($regardingUserIDs);
+ }
+ if (count($regardingUserIDs) > self::$MaxMergeCount) {
+ array_pop($regardingUserIDs);
+ $regardingUserCount++;
+ }
+ }
+
+ $recordIDs = [];
+ if ($oldActivity['RecordID']) {
+ $recordIDs[] = $oldActivity['RecordID'];
+ }
+ $recordIDs = array_unique($recordIDs);
+
+ $newActivity = array_merge($oldActivity, $newActivity);
+
+ if (count($activityUserIDs) > 0) {
+ $newActivity['Data']['ActivityUserIDs'] = $activityUserIDs;
+ }
+ if ($activityUserCount) {
+ $newActivity['Data']['ActivityUserID_Count'] = $activityUserCount;
+ }
+ if (count($recordIDs) > 0) {
+ $newActivity['Data']['RecordIDs'] = $recordIDs;
+ }
+ if (isset($regardingUserIDs) && count($regardingUserIDs) > 0) {
+ $newActivity['Data']['RegardingUserIDs'] = $regardingUserIDs;
+
+ if ($regardingUserCount) {
+ $newActivity['Data']['RegardingUserID_Count'] = $regardingUserCount;
+ }
+ }
+
+ return $newActivity;
+ }
+
+ /**
+ * Notify the user of wall comments.
+ *
+ * @param array $comment
+ * @param $wallPost
+ */
+ protected function notifyWallComment($comment, $wallPost) {
+ $notifyUser = Gdn::userModel()->getID($wallPost['ActivityUserID']);
+
+ $activity = [
+ 'ActivityType' => 'WallComment',
+ 'ActivityUserID' => $comment['InsertUserID'],
+ 'Format' => $comment['Format'],
+ 'NotifyUserID' => $wallPost['ActivityUserID'],
+ 'RecordType' => 'ActivityComment',
+ 'RecordID' => $comment['ActivityCommentID'],
+ 'RegardingUserID' => $wallPost['ActivityUserID'],
+ 'Route' => userUrl($notifyUser, ''),
+ 'Story' => $comment['Body'],
+ 'HeadlineFormat' => t('HeadlineFormat.NotifyWallComment', '{ActivityUserID,User} commented on your wall.')
+ ];
+
+ $this->save($activity, 'WallComment');
+ }
+
+ /**
+ *
+ *
+ * @param $wallPost
+ */
+ protected function notifyWallPost($wallPost) {
+ $notifyUser = Gdn::userModel()->getID($wallPost['ActivityUserID']);
+
+ $activity = [
+ 'ActivityType' => 'WallPost',
+ 'ActivityUserID' => $wallPost['RegardingUserID'],
+ 'Format' => $wallPost['Format'],
+ 'NotifyUserID' => $wallPost['ActivityUserID'],
+ 'RecordType' => 'Activity',
+ 'RecordID' => $wallPost['ActivityID'],
+ 'RegardingUserID' => $wallPost['ActivityUserID'],
+ 'Route' => userUrl($notifyUser, ''),
+ 'Story' => $wallPost['Story'],
+ 'HeadlineFormat' => t('HeadlineFormat.NotifyWallPost', '{ActivityUserID,User} posted on your wall.')
+ ];
+
+ $this->save($activity, 'WallComment');
+ }
+
+ /**
+ *
+ *
+ * @return array
+ */
+ public function saveQueue() {
+ $result = [];
+ foreach (self::$Queue as $userID => $activities) {
+ foreach ($activities as $activityType => $row) {
+ $result[] = $this->save($row[0], false, ['DisableFloodControl' => true] + $row[1]);
+ }
+ }
+ self::$Queue = [];
+ return $result;
+ }
+
+ /**
+ *
+ *
+ * @param $data
+ */
+ protected function _touch(&$data) {
+ touchValue('ActivityType', $data, 'Default');
+ touchValue('ActivityUserID', $data, Gdn::session()->UserID);
+ touchValue('NotifyUserID', $data, self::NOTIFY_PUBLIC);
+ touchValue('Headline', $data, null);
+ touchValue('Story', $data, null);
+ touchValue('Notified', $data, 0);
+ touchValue('Emailed', $data, 0);
+ touchValue('Photo', $data, null);
+ touchValue('Route', $data, null);
+ if (!isset($data['Data']) || !is_array($data['Data'])) {
+ $data['Data'] = [];
+ }
+ }
+
+ /**
+ * Get the delete after time.
+ *
+ * @return string Returns a string compatible with {@link strtotime()}.
+ */
+ public function getPruneAfter() {
+ return $this->pruneAfter;
+ }
+
+ /**
+ * Get the exact timestamp to prune.
+ *
+ * @return \DateTime|null Returns the date that we should prune after.
+ */
+ private function getPruneDate() {
+ if (!$this->pruneAfter) {
+ return null;
+ } else {
+ $tz = new \DateTimeZone('UTC');
+ $now = new DateTime('now', $tz);
+ $test = new DateTime($this->pruneAfter, $tz);
+
+ $interval = $test->diff($now);
+
+ if ($interval->invert === 1) {
+ return $now->add($interval);
+ } else {
+ return $test;
+ }
+ }
+ }
+
+ /**
+ * Set the prune after date.
+ *
+ * @param string $pruneAfter A string compatible with {@link strtotime()}. Be sure to specify a negative string.
+ * @return ActivityModel Returns `$this` for fluent calls.
+ */
+ public function setPruneAfter($pruneAfter) {
+ if ($pruneAfter) {
+ // Make sure the string is negative.
+ $now = time();
+ $testTime = strtotime($pruneAfter, $now);
+ if ($testTime === false) {
+ throw new InvalidArgumentException('Invalid timespan value for "prune after".', 400);
+ }
+ }
+
+ $this->pruneAfter = $pruneAfter;
+ return $this;
+ }
+
+ /**
+ * Prune old activities.
+ */
+ private function prune() {
+ $date = $this->getPruneDate();
+
+ $this->SQL->delete(
+ 'Activity',
+ ['DateUpdated <' => Gdn_Format::toDateTime($date->getTimestamp())],
+ 10
+ );
+ }
+}
diff --git a/vanilla/applications/vanilla/models/class.categorymodel.php b/vanilla/applications/vanilla/models/class.categorymodel.php
index 0c8a76f..bdd27c8 100644
--- a/vanilla/applications/vanilla/models/class.categorymodel.php
+++ b/vanilla/applications/vanilla/models/class.categorymodel.php
@@ -1084,6 +1084,14 @@ public function getTreeAsFlat($id, $offset = null, $limit = null, $filter = null
{
$query = $this->SQL->from('Category c');
+ if(!$filter) {
+ if (Gdn::session()->isValid()) {
+ $filter = [];
+ $filter['UserID'] = Gdn::session()->UserID;
+ $filter['isAdmin'] = Gdn::session()->User->Admin;
+ }
+ }
+
//FIX: https://github.com/topcoder-platform/forums/issues/422
if (!val('isAdmin', $filter, false)) {
if (val('UserID', $filter, false)) {