<?php if (!defined('VB_ENTRY')) die('Access denied.');
/*========================================================================*\
|| ###################################################################### ||
|| # vBulletin 5.5.2
|| # ------------------------------------------------------------------ # ||
|| # Copyright 2000-2019 MH Sub I, LLC dba vBulletin. All Rights Reserved.  # ||
|| # This file may not be redistributed in whole or significant part.   # ||
|| # ----------------- VBULLETIN IS NOT FREE SOFTWARE ----------------- # ||
|| # http://www.vbulletin.com | http://www.vbulletin.com/license.html   # ||
|| ###################################################################### ||
\*========================================================================*/

/**
 * vB_Library_Notification
 *
 * @package vBLibrary
 * @access public
 */
class vB_Library_Notification extends vB_Library
{
	/**
	 *	DB Assertor object
	 */
	protected $assertor;

	/**
	 *	Int		Number of rows per assert that insertNotificationsToDB() will insert.
	 *			@see insertNotificationsToDB() for more information.
	 */
	protected $insertsPerBulk = 2000;


	/**
	 *	Array[String]	An array of queued notifications awaiting DB insert.
	 *	This array is built by the various generate{}Notifications() methods, and cleared when
	 *	inserted into the DB by insertNotificationsToDB().
	 *
	 *	Structure:
	 *		TODO...
	 *
	 *	Note that the key-string is based on the table's UNIQUE KEY:
	 *		TODO...
	 *
	 *  Note that the following columns are never set during notification generation:
	 *			'lastreadtime'		=>	INT(10) UNSIGNED NOT NULL DEFAULT '0',
	 *			'lastcheckedtime'	=>	INT(10) UNSIGNED NOT NULL DEFAULT '0',
	 *	These are meant to be used for read/delete, and thus should only be set by updates from the
	 *	message center / node visit (VBV-4958).
	 */
	protected $notificationQueue;


	protected $fcmQueue;


	/**
	 *	Array	An array of queued emails
	 *	This array is built by the various generate{}Notifications() methods, and cleared in
	 *	sendEmailNotifications().
	 *
	 *	Structure:
	 *		Array[] = Array(
	 *			'email'			=>	Str
	 *			'username'		=>	Str
	 *			'languageid'	=>	Int
				'recipient'			=> 	$recipient,
				'sender'			=> 	$notificationData['sender'],
				'lookupid'			=>	$notificationData['lookupid'],
				'sentbynodeid'		=>	$notificationData['sentbynodeid'],
				'customdata'		=>	$notificationData['customdata'],
				'typeid'			=>	$notificationData['typeid'],
				'lastsenttime'		=>	$lastsenttime,
	 *		);
	 */
	protected $emailQueue;

	/*
		Temporary in-memory caching of the results of getSubscribersForNotifications() call
	 */
	protected $subscribers;




	const DATASTOREKEY_TRIGGERS = 'vBNotificationEvents';
	const DATASTOREKEY_TYPES = 'vBNotificationTypes';


	/*
	 *	One reason that notification preparation/generation & DB insert are separated is because
	 *	I currently envision custom notifications to be generated by a plugin that hooks onto
	 *	an API function, so the plugin would hook to generate{}Notifications()
	 *	followed by insertNotificationsToDB().
	 *
	 *	However, this means that something that breaks between the generate & insert calls will
	 * 	(ex. if the plugin hook to generate has an error) prevent default notifications from
	 *	being inserted, so I may end up calling the insert function at the end of every default
	 *	generate{}Notifications() function.
	 *
	 *	Note, be careful about moving insertNotificationsToDB() into a delayed cron process. The
	 *	design is that the last set "duplicate" 		(recipient, lookupid) 		is the
	 *	only one to be inserted into the DB, per triggering content. Subsequent inserts with the
	 *	same key for a *different* content (e.g. a 2nd reply to a subscription) could update the
	 *	existing record or may be rejected, depending on the implementation of the "No Flooding"
	 *	post-MVP feature. So for different triggering content, if $notificationQueue is overwritten,
	 *	the end behavior is the same while the "No Flooding" remains unimplemented. However, the
	 *	end behavior will be different once the feature allows rejection of existing notification
	 *	updates, and thus delaying the DB insert into a cron which could allow different triggering
	 *	content to overwrite each other could be invalid behavior.
	 */


	protected function __construct()
	{
		parent::__construct();
		$this->assertor = vB::getDbAssertor();
	}

	/**
	 * Sets $this->insertsPerBulk, which is the maximum number of rows that
	 * insertNotificationsToDB() will insert into the DB in each database assert.
	 * Set this before calling insertNotificationsToDB() to affect the inserts.
	 *
	 * @param	int		$newInsertsPerBulk		Will have no affect if smaller than 1.
	 */
	public function setInsertPerBulk($newInsertsPerBulk)
	{
		if ($newInsertsPerBulk >= 1)
		{
			$this->insertsPerBulk = intval($newInsertsPerBulk);
		}
	}

	/**
	 * Returns class variable $insertsPerBulk. Also @see setInsertPerBulk()
	 *
	 * @return	int
	 */
	public function getInsertPerBulk()
	{
		return $this->insertsPerBulk;
	}


	/**
	 * Returns the class variable $notificationQueue. Used for testing.
	 *
	 * @return	Array	@see $notificationQueue
	 */
	public function getNotificationQueue()
	{
		return $this->notificationQueue;
	}

	/**
	 *	Insert all items in $this->notificationQueue into the database.
	 *	The actual bulk of this function should probably live in the vBForum querydefs file.
	 */
	public function insertNotificationsToDB()
	{
		if (empty($this->notificationQueue))
		{
			return;
		}
		/*
		 *	We're doing bulk inserts with a decent number of columns. If we insert too many rows
		 *	in a row, we'll hit the MySQL max_allowed_packet limit. 2k is a good number if there
		 *	is no customdata for the notifications, based on some benchmarking which suggested
		 *	that the total packetsize for 2k was about 80KB, well below the default value of 1MB
		 *	allowing the currently expected maximum of ~1k notifications to fit in one roundtrip
		 *	to the magical database land.
		 *
		 *	With customdata, all bets are off. I'm not sure how many custom notifications we're
		 *	expecting, and what the payload of each notification's customdata will be like.
		 *	As such, I've added setInsertPerBulk() to allow custom plugins to set the number to
		 * 	a better value for their notifications.
		 *
		 *	If this becomes a serious issues or a point of contention, we should actively measure
		 *	the total packet size as we build the query, and dynamically cutoff based on some
		 *	safe threshold below some size value.
		 *
		 *	As a sidenote, the "Fast" requirement specifies 1000 notifications (from start
		 *	to finish) within 10 seconds.
		 */
		$perTrip = $this->insertsPerBulk; // {TODO:DOUBLE CHECK THIS, LEFT BENCHMARK AT HOME AND FORGOT THE VALUE}

		/*
		 * TODO: figure out a way to simplify & optimize the array copies
		 */
		$chunks = array_chunk($this->notificationQueue, $perTrip);
		foreach($chunks AS $notifications)
		{
			$this->assertor->assertQuery('vBForum:addNotifications', array(vB_dB_Query::TYPE_KEY => vB_dB_Query::QUERY_METHOD, 'notifications' => $notifications));
		}

		// Send emails
		$this->sendEmailNotifications();
		// Send any push notifications. We call this here instead of calling it separately
		// from this function's caller to make all the in-memory-var "clean-up" follow just
		// a call to this function. Otherwise unittests could get quite a bit more messy...
		$this->processFCMQueue();

		// clean up
		$this->notificationQueue = array();
	}

	protected function processFCMQueue()
	{
		if (empty($this->fcmQueue))
		{
			return;
		}
		/*
									$this->fcmQueue[$key] = $notificationData;
		 */
		/*
			We couldn't group these up by the notification type when fcmQueue was set
			because of possible collision resolution via priority.
			We go through it and re-group it now.
		 */
		$typesByTypename = $this->getNotificationTypes();
		$recipients = array(); // array of userids grouped by notification typename, then languageid
		$fcmPayloadHelper = array(); // grouped by typename
		foreach ($this->fcmQueue AS $__data)
		{
			$__typename = $__data['typename'];
			$__languageid = $__data['languageid'];
			$__userid = $__data['recipient'];
			$__class = $typesByTypename[$__typename]['class'];
			if ($__class::FCM_SUPPORTED)
			{
				if (empty($fcmPayloadHelper[$__typename]))
				{
					/*
						Currently, these should all be the same for all recipients of this
						specific type of notification...
						If that ever changes w/ more complex triggers, this has to be refactored.
						Save the nodeid & sender so we can pull their data for generating the FCM
						payload.
					 */
					$fcmPayloadHelper[$__typename] = array(
						'sentbynodeid' => $__data['sentbynodeid'],
						'sender' => $__data['sender'],
					);
				}
				if (empty($recipients[$__typename]))
				{
					$recipients[$__typename] = array();
				}
				if (empty($recipients[$__typename][$__languageid]))
				{
					$recipients[$__typename][$__languageid] = array();
				}
				// TODO: also group by languageid for phrasing.
				$recipients[$__typename][$__languageid][$__userid] = $__userid;
			}
		}

		$fcmLib = vB_Library::instance('FCMessaging');
		$messageHashes = array();
		foreach ($recipients AS $__typename => $__languageidGroup)
		{
			/*
				There's a problem here with localization. We want to use the recipient's languageid for
				any phrases, but that means we cannot bulk the message!
			 */
			$__class = $typesByTypename[$__typename]['class'];
			$__languageids = array_keys($__languageidGroup);
			$extraData = $__class::getFCMExtraData($fcmPayloadHelper[$__typename], $__languageids);
			foreach ($__languageidGroup AS $__languageid => $__userids)
			{
				if (!empty($extraData[$__languageid]))
				{
					// offload to child task.
					$messageHash = $fcmLib->queueMessage(
						$__userids,
						vB_Library_FCMessaging::MESSAGE_TYPE_NOTIFICATION,
						$extraData[$__languageid]
					);
					if (empty($messageHash['error']) AND !empty($messageHash['hash']))
					{
						$messageHashes[] = $messageHash['hash'];
					}
				}
			}
		}
		if (!empty($messageHashes))
		{
			$sendMessagesResult = $fcmLib->sendMessages($messageHashes);
		}

		// clean up
		$this->fcmQueue = array();
	}

	/**
	 * Deletes specified notificationds, ignoring ownership
	 *
	 * @param	Int|Int[]	$notificationids	Array of notificationids to delete.
	 */
	public function deleteNotification($notificationids)
	{
		// also see notification_cleanup cron.

		$this->assertor->assertQuery(
			'vBForum:notification',
			array(
				vB_dB_Query::TYPE_KEY => vB_dB_Query::QUERY_DELETE,
				'notificationid' => $notificationids
			)
		);
	}

	/**
	 * Returns the total count of specified user's notifications
	 *
	 * @param	Int		$userid
	 *
	 * @return	Int
	 *
	 */
	public function fetchTotalNotificationCountForUser($userid)
	{
		return $this->fetchNotificationCountForUser($userid);
	}

	/**
	 * Returns the count of specified user's notifications based on provided filterParams
	 *
	 * @param	Int		$userid
	 * @param	Array	$data		Optional. If not empty, should have
	 *								-	'typeid'
	 *
	 * @return	Int
	 *
	 */
	public function fetchNotificationCountForUser($userid, $data=array())
	{
		$data['userid'] = $userid;
		$types = $this->getNotificationTypes();

		if (!empty($data['about']))
		{
			$typename = vB_Library::instance('content_privatemessage')->convertLegacyNotificationAboutString($data['about']);
			if (!empty($types[$typename]['typeid']))
			{
				$data['typeid'] = intval($types[$typename]['typeid']);
			}
		}
		elseif (!empty($data['typename']))
		{
			if (!empty($types[$data['typename']]['typeid']))
			{
				$data['typeid'] = intval($types[$data['typename']]['typeid']);
			}
		}


		if (empty($data['readFilter']))
		{
			// To change the default, change this & in the template privatemessage_listnotifications_toolbar,
			// change which 'readFilter' radio button is "checked".
			$data['readFilter'] = 'unread_only';
		}

		$countQuery = $this->assertor->getRow('vBForum:fetchNotificationCount', $data);

		if (empty($countQuery) OR !isset($countQuery['count']))
		{
			return 0;
		}
		else
		{
			return $countQuery['count'];
		}
	}

	/**
	 * Return current user's notifications from DB.
	 *
	 * @param	Array	$data			Fields
	 *									-	'sortDir'	Optional String sort direction. If not "ASC", sort order will be DESC.
	 *													The sort field is notification.lastsenttime
	 *									-	'perpage' 	Optional unsigned integer results per page. Default is 20 per page.
	 *									-	'page'		Optional unsigned integer page # to return. Default is the first page.
	 *									-	'showdetail'	Optional boolean, set true to display details about node ratings.
	 *									-	'about'		Optional String legacy about string.
	 *									-	'typename'	Optional String Notification typename. If 'about' is set, that will be used
	 *													instead. If a valid 'about & 'typename' is set, only notifications of that type
	 *													will be returned.
	 *									-	'readFilter'	Optional String filter by new/dismissed status. If NOT SET, function will
	 *													default to 'unread_only'. If is SET, the expected values are ('unread_only'|
	 *													'read_only'|'*'). If SET but not to 'unread_only' OR 'read_only', function will
	 *													return both new & dismissed notifications.
	 *									-	'skipIds'	Optional Int[], only used internally by vB_Api_Notification::dismissNotification()
	 * @return	Array	Keyed by integer notificationid, contains all data from the notification table and possibly the following
	 *					fields:
	 *					-	'categoryname'
	 *					-	'typename'
	 *					-	'sender_username'
	 *					-	'sender_avatarpath'
	 *					For CONTENT category
	 *					-	'aboutstartertitle'
	 *					-	'aboutstarterrouteid'
	 *					-	'otherParticipantsCount'
	 *					For POLLVOTE category
	 *					-	'votes'			poll.votes for sentbynodeid
	 *					-	'lastvote'		poll.lastvote for sentbynodeid
	 *					-	'otherVotersCount'
	 *					For NODEACTION category's NODEACTION_LIKE type:
	 *					-	'showdetail'	Based on user's genericpermissions.canseewholiked permission
	 *					-	'totalRatersCount'
	 *					-	'otherRatersCount'

	 *					.. TODO finish this docblock
	 */
	public function fetchNotificationsForCurrentUser($data = array())
	{
		// return if current user is not logged in
		$userid = vB::getCurrentSession()->get('userid');
		if (empty($userid))
		{
			return array();
		}


		$params = array('userid' => $userid);

		if (!empty($data['perpage']))
		{
			$params[vB_dB_Query::PARAM_LIMIT]= intval($data['perpage']);
		}
		// else, default is set in vBForum:fetchNotifications query def.

		if (!empty($data['page']))
		{
			$params[vB_dB_Query::PARAM_LIMITPAGE] = intval($data['page']);
		}
		// else, default is set in vBForum:fetchNotifications query def.

		if (!empty($data['sortDir']))
		{
			// Cleaning is done in vBForum:fetchNotifications, and is restricted to
			// "ASC" - all other values will default to "DESC" sort direction.
			$params['sortDir'] = $data['sortDir'];
		}

		if (!empty($data['readFilter']))
		{
			switch($data['readFilter'])
			{
				case 'unread_only':
					$params['readFilter'] = 'unread_only';
					break;
				case 'read_only':
					$params['readFilter'] = 'read_only';
					break;
				default:
					// don't pass this filter in. vBForum:fetchNotifications will automatically fetch both.
					break;
			}
		}
		else
		{
			// To change the default, change this, fetchNotificationCountForUser() & in the template privatemessage_listnotifications_toolbar,
			// change which 'readFilter' radio button is "checked".
			$params['readFilter'] = 'unread_only';
		}

		if (!empty($data['skipIds']) AND is_array($data['skipIds']))
		{
			// method query will clean this as a unsigned integer array.
			$params['skipIds'] = $data['skipIds'];
		}

		$showDetail = (bool) (!empty($data['showdetail']));

		$typesByTypename = $this->getNotificationTypes();
		$typesById = array();
		foreach ($typesByTypename AS $typedata)
		{
			$typesById[$typedata['typeid']] = $typedata;
		}
		if (!empty($data['about']))
		{
			$typename = vB_Library::instance('content_privatemessage')->convertLegacyNotificationAboutString($data['about']);
			if (!empty($typesByTypename[$typename]['typeid']))
			{
				$params['typeid'] = intval($typesByTypename[$typename]['typeid']);
			}
		}
		elseif (!empty($data['typename']))
		{
			if (!empty($typesByTypename[$data['typename']]['typeid']))
			{
				$params['typeid'] = intval($typesByTypename[$data['typename']]['typeid']);
			}
		}

		// $data name is used for other stuff later.
		unset($data);

		$query = $this->assertor->assertQuery('vBForum:fetchNotifications', $params);
		$notifications = array();
		// Used for 'otherParticipantsCount' field
		$subscribedDiscussionNodeidsToNotificationids = array();	// nodeid => array({notificationid});
		$otherDiscussionNodeidsToNotificationids = array();
		// Used for sender_avatarpath
		$useridToNotifcationids = array(); // userid => array({notificationid});
		/*
			Note about 'otherVotersCount' & 'otherRatersCount' fields:
			Since pollvote & node like notifications always overwrite the previous ones regardless of
			read status, their "other" counts should always be the "total" count - 1 (minus any by the current
			user & excluded global ignores).
			We fetch & store the users in the DESCENDING DATE order of the action performed so that the
			first user in the "others" list always matches the sender.
		 */
		// Used for 'otherVotersCount' field
		$pollNodeidsToNotificationids = array();
		// Used for 'otherRatersCount' field
		$ratedNodeidsToNotificationids = array();	// nodeid => array({notificationid});
		foreach ($query AS $row)
		{
			$key = $row['notificationid'];
			$sender = $row['sender'];
			$sentbynodeid = $row['sentbynodeid'];
			$notifications[$key] = $row;

			$type = $typesById[$row['typeid']];
			$typename = $type['typename'];

			$notifications[$key]['typename'] = $typename;
			$notifications[$key]['class'] = $type['class'];

			$notifications[$key]['customdata'] = array();
			$customdata = json_decode($row['customdata'], true);
			if (!is_null($customdata) AND is_array($customdata))
			{
				$notifications[$key]['customdata'] = $customdata;
			}

			$useridToNotifcationids[$sender][$key] = $key;	// sender_avatarpath

			switch ($typename)
			{
				case vB_Notification_Content_GroupByStarter_Reply::TYPENAME:
					$otherDiscussionNodeidsToNotificationids[$sentbynodeid][$key] = $key;
					$notifications[$key]['otherParticipantsCount'] = -1;
					$notifications[$key]['others_title_phrase'] = 'members_who_replied_to_this_topic';
					break;
				case vB_Notification_Content_GroupByParentid_Comment::TYPENAME:
				case vB_Notification_Content_GroupByParentid_ThreadComment::TYPENAME:
					$otherDiscussionNodeidsToNotificationids[$sentbynodeid][$key] = $key;
					$notifications[$key]['otherParticipantsCount'] = -1;
					$notifications[$key]['others_title_phrase'] = 'members_who_commented_on_this_post';
					break;
				case vB_Notification_Content_GroupByStarter_Subscription::TYPENAME:
					$subscribedDiscussionNodeidsToNotificationids[$sentbynodeid][$key] = $key;
					$notifications[$key]['otherParticipantsCount'] = -1;
					$notifications[$key]['others_title_phrase'] = 'members_who_posted_in_this_topic';
					break;
				case vB_Notification_Content_UserMention::TYPENAME:
				case vB_Notification_VisitorMessage::TYPENAME:
					break;
				/*
				TODO UPDATE THE OTHERS DIALOG FOR VOTES & LIKES
				 */
				case vB_Notification_PollVote::TYPENAME:
					$notifications[$key]['otherVotersCount'] = -1;
					$notifications[$key]['others_title_phrase'] = 'members_who_voted_on_this_poll';
					$pollNodeidsToNotificationids[$sentbynodeid][$key] = $key;
					break;
				case vB_Notification_LikedNode::TYPENAME:
					$notifications[$key]['showdetail'] = $showDetail;
					// hide sender info if showdetail isn't set.
					if (!$showDetail)
					{
						$hideThese = array(
							'sender',
							'sender_username',
							'sender_avatarpath',
						);
						foreach ($hideThese AS $hideMe)
						{
							$notifications[$key][$hideMe] = '';
						}

						// no need to look up the avatar for a user we're not displaying.
						unset($useridToNotifcationids[$sender][$key]);
					}
					$notifications[$key]['totalRatersCount'] = 0;
					$notifications[$key]['otherRatersCount'] = -1;
					$notifications[$key]['others_title_phrase'] = 'members_who_liked_this_post';
					$ratedNodeidsToNotificationids[$sentbynodeid][$key] = $key;
					break;
				case vB_Notification_UserRelation_SenderIsfollowing::TYPENAME:
				case vB_Notification_UserRelation_SenderAcceptedFollowRequest::TYPENAME:
					break;
				default:
					break;
			}
		}

		// Taken from PM API's fetchParticipants() function. Grab global ignore list. Also don't bother fetching
		// current user.
		$exclude = array($userid);
		$options = vB::getDatastore()->get_value('options');
		if (trim($options['globalignore']) != '')
		{
			$exclude = preg_split('#\s+#s', $options['globalignore'], -1, PREG_SPLIT_NO_EMPTY);
			$exclude[] = $userid;
		}

		// TODO need to add heavy testing for the counts below

		// grab thread participant counts. First the ones for subscriptions (which will include starters, replies & comments)
		if (!empty($subscribedDiscussionNodeidsToNotificationids))
		{
			$nodeids = array_keys($subscribedDiscussionNodeidsToNotificationids);
			$others = vB::getDbAssertor()->getRows(
				"vBForum:fetchNotificationOthers",
				array(
					'depth' => array(1,2,3),
					'sentbynodeids' => $nodeids,
					'exclude' => $exclude,
					'currentuser' => $userid
				)
			);
			foreach ($others AS $row)
			{
				$nodeid = $row['sentbynode'];
				if (isset($subscribedDiscussionNodeidsToNotificationids[$nodeid]))
				{
					foreach ($subscribedDiscussionNodeidsToNotificationids[$nodeid] AS $notificationid)
					{
						if (!isset($notifications[$notificationid]['others'][$row['userid']]))
						{
							$notifications[$notificationid]['otherParticipantsCount']++;
							$notifications[$notificationid]['others'][$row['userid']] = $row;
							$useridToNotifcationids[$row['userid']][$notificationid] = $notificationid; // need to fetch avatars.
						}
					}
				}
			}
		}
		// For the others, which should only include the counts at a single depth
		if (!empty($otherDiscussionNodeidsToNotificationids))
		{
			$nodeids = array_keys($otherDiscussionNodeidsToNotificationids);
			$others = vB::getDbAssertor()->getRows(
				"vBForum:fetchNotificationOthers",
				array(
					'depth' => array(1), // only fetch the items at the same level as the node (replies only or comments only)
					'sentbynodeids' => $nodeids,
					'exclude' => $exclude,
					'currentuser' => $userid
				)
			);
			foreach ($others AS $row)
			{
				$nodeid = $row['sentbynode'];
				if (isset($otherDiscussionNodeidsToNotificationids[$nodeid]))
				{
					foreach ($otherDiscussionNodeidsToNotificationids[$nodeid] AS $notificationid)
					{
						if (!isset($notifications[$notificationid]['others'][$row['userid']]))
						{
							$notifications[$notificationid]['otherParticipantsCount']++;
							$notifications[$notificationid]['others'][$row['userid']] = $row;
							$useridToNotifcationids[$row['userid']][$notificationid] = $notificationid; // need to fetch avatars.
						}
					}
				}
			}
		}

		// pollvote & reputation tables are not updated properly when a user is deleted. This, and other cases might result in
		// one or more "guests" being fetched with userid & username set to NULL. We need to set the username to "Guest" in this case
		$phrase = vB_Api::instanceInternal('phrase')->fetch('guest');
		$guestPhrase = $phrase['guest'];


		// grab poll vote count
		if (!empty($pollNodeidsToNotificationids))
		{
			$nodeids = array_keys($pollNodeidsToNotificationids);
			$others = $this->assertor->assertQuery(
				'vBForum:fetchPollVoters',
				array(
					'sentbynodeids' => $nodeids,
					'exclude' => $exclude,
					'currentuser' => $userid
				)
			);
			$guestCount = 0;
			foreach ($others AS $row)
			{
				$nodeid = $row['sentbynode'];
				if (isset($pollNodeidsToNotificationids[$nodeid]))
				{
					foreach ($pollNodeidsToNotificationids[$nodeid] AS $notificationid)
					{
						// When a user is deleted, their node likes remain. If multiple users are deleted,
						// each like still counts individually, so we have this outside of the isset() check below.
						$notifications[$notificationid]['otherVotersCount']++;
						if (is_null($row['userid']) OR is_null($row['username']))
						{
							$row['userid'] = 0;
							$row['username'] = $guestPhrase;
							$notifications[$notificationid]['others']['guest_' . $guestCount++] = $row;
						}
						else if (!isset($notifications[$notificationid]['others'][$row['userid']]))
						{
							// We don't actually expect pollvotes or likes to have duplicate users, other than
							// "Guests" (deleted users for likes), but I left these checks to be consistent.

							$notifications[$notificationid]['others'][$row['userid']] = $row;
							$useridToNotifcationids[$row['userid']][$notificationid] = $notificationid; // need to fetch avatars.
						}
					}
				}
			}
		}

		// grab node "likes" count
		if (!empty($ratedNodeidsToNotificationids))
		{
			$nodeids = array_keys($ratedNodeidsToNotificationids);
			$others = $this->assertor->assertQuery(
				'vBForum:fetchNodeRaters',
				array(
					'sentbynodeids' => $nodeids,
					'exclude' => $exclude,
					'currentuser' => $userid
				)
			);
			$guestCount = 0;
			foreach ($others AS $row)
			{
				$nodeid = $row['sentbynode'];
				if (isset($ratedNodeidsToNotificationids[$nodeid]))
				{
					foreach ($ratedNodeidsToNotificationids[$nodeid] AS $notificationid)
					{
						$notifications[$notificationid]['totalRatersCount']++;
						$notifications[$notificationid]['otherRatersCount']++;
						if ($showDetail)
						{
							if (is_null($row['userid']) OR is_null($row['username']))
							{
								$row['userid'] = 0;
								$row['username'] = $guestPhrase;
								$notifications[$notificationid]['others']['guest_' . $guestCount++] = $row;
							}
							else if (!isset($notifications[$notificationid]['others'][$row['userid']]))
							{
								$notifications[$notificationid]['others'][$row['userid']] = $row;
								$useridToNotifcationids[$row['userid']][$notificationid] = $notificationid; // need to fetch avatars.
							}
						}
					}
				}
			}
		}

		// grab the "last sender" avatarpaths
		if (!empty($useridToNotifcationids))
		{
			$userids = array_keys($useridToNotifcationids);
			$avatars = vB_Api::instance('user')->fetchAvatars($userids);
			// Expecting array keyed by userid, containing 'avatarpath' and possibly 'hascustom'
			foreach ($avatars AS $userid => $dataArray)
			{
				// $useridToNotifcationids[$sender][$key] = $key;
				if (isset($useridToNotifcationids[$userid]))
				{
					$avatarpath = $dataArray['avatarpath'];
					foreach ($useridToNotifcationids[$userid] AS $notificationid)
					{
						// Could be either the "sender" avatar, or an avatar in one of the "others" fields
						if ($notifications[$notificationid]['sender'] == $userid)
						{
							$notifications[$notificationid]['sender_avatarpath'] = (String) $avatarpath;
						}
						if (!empty($notifications[$notificationid]['others'][$userid]))
						{
							$notifications[$notificationid]['others'][$userid]['avatarurl'] = (String) $avatarpath;
						}
					}

				}

			}
		}


		// add phrase data.
		foreach ($notifications AS &$data)
		{
			$class = $data['class'];
			if (is_subclass_of($class, 'vB_Notification'))
			{
				$data['phrasedata'] = $class::fetchPhraseArray($data);
			}
			else
			{
				unset($data['class']);
				$data['phrasedata'] = array('class_is_not_child_of_vbnotification', array((string) $class, (int) $data['notificationid']));
			}
		}


		return $notifications;
	}



	/*
	 *
	 * @param	String	$eventstring
	 * @param	Array	$data			Array of event data, or notification data
	 *									Expected for notification data:
	 *										- int sentbynodeid
	 *										- int sender
	 * @param	Int[]	$recipients		List of recipients, only required for content-less notification types, like
	 *									SenderAcceptedFollow, SenderAcceptedFollow
	 */
	public function triggerNotificationEvent($eventstring, $data = array(),  $recipients = array())
	{
		$events = $this->getNotificationEvents();
		$types = $this->getNotificationTypes();
		if (!isset($events[$eventstring]))
		{
			return;
		}

		/*
		 Expecting $classname => event type
		 */
		foreach ($events[$eventstring] AS $class => $type)
		{
			switch($type)
			{
				case 'trigger':
					if (!isset($data['sentbynodeid']) AND isset($data['nodeid']))
					{
						$data['sentbynodeid'] = $data['nodeid'];
					}
					$notification = new $class($eventstring, $data, $recipients);
					$notificationData = $notification->getNotificationData();
					if (empty($notificationData))
					{
						continue 2;
					}
					$notificationData['typeid'] = $types[$notificationData['typename']]['typeid'];
					$notificationRecipients = $notification->getRecipients();
					$recipientsCache = $notification->getCachedRecipientData();	// holds email, languageid, emailupdate etc that we already grabbed from user table.
					$aboutString = vB_Library::instance('content_privatemessage')->convertNotificationTypeToLegacyAboutString($notificationData['typename']);
					if ($aboutString == 'subscription')
					{
						// Required for emails.
						// TODO figure out how to get rid of this double call to follow API (this is also called in
						// vB_Notification_Content_GroupByStarter_Subscription)
						$apiResult = vB_Api::instanceInternal('follow')->getSubscribersForNotifications(
							$notificationData['sentbynodeid']
						);
						$subscribers = $apiResult['subscribers'];
					}
					foreach($notificationRecipients AS $userid)
					{
						$notificationData['recipient'] = $userid;
						// Save languageid for the FCM queue, as we may need to use localized phrases.
						$notificationDataPlusExtra = $notificationData;
						$notificationDataPlusExtra['languageid'] = $recipientsCache[$userid]['languageid'];
						$notificationDataPlusExtra['recipient_username'] = $recipientsCache[$userid]['username'];

						$emailPhrases = $notification::renderEmailPhrases($notificationDataPlusExtra);
						/*
							Note: We don't want to deal with different subscription origins ATM because this will
							further fragment the push notification message grouping/bulking.
						if ($aboutString == 'subscription')
						{
							$notificationDataPlusExtra['subscriptionnodeid'] = reset($subscribers[$userid]['nodeid']);
						}
						*/
						/*
						TODO: move emails generation into notification objects so we can get rid of this ugly block below
						 */
						$sendEmail = (
							(!empty($aboutString) OR !empty($emailPhrases)) AND
							isset($recipientsCache[$userid]['emailnotification']) AND
							($recipientsCache[$userid]['emailnotification'] == 1)
						);

						if ($sendEmail)
						{
							$emailData = array(
								'userid'        => $userid,
								'about'         => $aboutString,
								'emailphrases'  => $emailPhrases,
								'email'         => $recipientsCache[$userid]['email'],
								'username'      => $recipientsCache[$userid]['username'],
								'languageid'    => $recipientsCache[$userid]['languageid'],
								'contentnodeid' => $notificationData['sentbynodeid'],
								'senderid'      => $notificationData['sender'],
							);
							if ($aboutString == 'subscription')
							{
								$emailData['subscriptionnodeid'] = reset($subscribers[$userid]['nodeid']);
							}
						}
						else
						{
							$emailData = array();
						}

						$key = $notification::generateNotificationQueueKey($notificationData);
						// Queue up notifications & Emails
						if (empty($key))
						{
							$this->notificationQueue[] = $notificationData;
							$this->fcmQueue[] = $notificationDataPlusExtra;
							if ($sendEmail)
							{
								$this->emailQueue[] = $emailData;
							}
						}
						else
						{
							if (
								!isset($this->notificationQueue[$key]) OR
								$notificationData['priority'] > $this->notificationQueue[$key]['priority']
							)
							{
								$this->notificationQueue[$key] = $notificationData;
								$this->fcmQueue[$key] = $notificationDataPlusExtra;
								if ($sendEmail)
								{
									$this->emailQueue[$key] = $emailData;
								}
							}
						}
						unset($notificationData['recipient'], $sendEmail, $emailData);
					}

					break;
				case 'update':
					$class::handleUpdateEvents($eventstring, $data);
					break;
				default:
					break;
			}
		}

		vB_Notification::clearMemory();
	}

	protected function sendEmailNotifications()
	{
		$options = vB::getDatastore()->getValue('options');
		if (isset($options['enableemail']) AND !$options['enableemail'])
		{
			return; // email notifications are globally disabled
		}

		if (empty($this->emailQueue))
		{
			return;
		}

		// TODO: REFACTOR CONTENT LIBRARY'S sendEmailNotification()
		foreach ($this->emailQueue AS $emailData)
		{
			// if this is not a legacy notification, it'll have an empty string for its about string
			// but have an emailphrase.
			// Also let legacy notifications be updated with an emailphrase
			if (!empty($emailData['emailphrases']))
			{
				$this->sendEmailNotification($emailData);
			}
			else if (!empty($emailData['about']))
			{
				$this->sendLegacyEmailNotification($emailData);
			}
		}
		$this->emailQueue = array();
	}

	protected function sendEmailNotification($data)
	{
		$options = vB::getDatastore()->getValue('options');
		if (isset($options['enableemail']) AND !$options['enableemail'])
		{
			return; // email notifications are globally disabled
		}

		$maildata = $data['emailphrases'];

		if (!empty($data['email']) AND !empty($maildata))
		{
			// Send the email
			vB_Mail::vbmail($data['email'], $maildata['subject'], $maildata['message'], false);
		}
	}


	/*
	 * This was copied from the content library class. Moved here due to
	 * access/scope. Only the absolute minimum required changes (for ex. self & instance/this references)
	 * TODO: REFACTOR CONTENT LIBRARY'S sendEmailNotification()
	 *
	 *
	 *	@param	array	$data	Array containing the following data:
	 *								int		'userid'	Recipient's userid
	 *								string	'about'		One of the vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_
	 *													static strings
	 *								string	'email'		Email address of the recipient
	 *								string	'username'	Username of the email recipient
	 *								int		'languageid'	User's languageid from fetchUserinfo
	 *								int		'contentnodeid'		Nodeid of the newly created node that triggered
	 *													this notification email. For NOTIFICATION_TYPE_VOTE this
	 *													is the nodeid of the poll-post, since a vote doesn't have
	 *													its own node record.
	 *								int		'subscriptionnodeid'	Required only for NOTIFICATION_TYPE_SUBSCRIPTION.
	 *													Nodeid of where the actual subscription is on. Used to add
	 *													additional data for subscription phrases.
	 *								int		'senderid'		Required only for NOTIFICATION_TYPE_RATE. Userid of the
	 *													sender, usually the user who "liked" the recipient's post.
	 *
	 *	@access protected
	 */
	protected function sendLegacyEmailNotification($data)
	{
		$options = vB::getDatastore()->getValue('options');
		if (isset($options['enableemail']) AND !$options['enableemail'])
		{
			return; // email notifications are globally disabled
		}

		/*
		 *	Save some data for this page load. Since we can potentially fetch a bunch of the same
		 *	data over and over if multiple users are getting notifications about the same content
		 *	that was just created, let's get rid of the redundant calls when this function is called
		 *	in a loop.
		 *	Todo: we should write a bulk-send-email-notification function
		 *
		 *	I can't actually think of a case where a single content creation that triggers a number
		 *	of email notifications will have different contentnodeid in its group of notifications,
		 *	but let's just be safe and allow for that possibility.
		 */
		static	$recipientIndependentDataArray;

		$contentnodeid = isset($data['contentnodeid'])? $data['contentnodeid'] : 0;
		if (!empty($contentnodeid) AND empty($recipientIndependentDataArray[$contentnodeid]))
		{
			//we need to load this using the correct library class or things get weird.
			//note that if we load it from cache here it will be in the local memory cache
			//when we load the full content and we won't do it twice.
			$cached = vB_Library_Content::fetchFromCache(array($contentnodeid), vB_Library_Content::CACHELEVEL_FULLCONTENT);
			if(isset($cached['found'][$contentnodeid]))
			{
				$contenttypeid = $cached['found'][$contentnodeid]['contenttypeid'];
			}
			else
			{
				$row = $this->assertor->getRow('vBForum:node',
					array(
						vB_dB_Query::TYPE_KEY => vB_dB_Query::QUERY_SELECT,
						vB_dB_Query::COLUMNS_KEY => array('contenttypeid'),
						'nodeid' => $contentnodeid
					)
				);
				$contenttypeid = $row['contenttypeid'];
			}

			$contentLib = vB_Library_Content::getContentLib($contenttypeid);
			$currentNode = $contentLib->getContent($contentnodeid);
			$currentNode = $currentNode[$contentnodeid];

			// Apparently if guest created the node, $currentNode will be missing 'userinfo'.
			if (!empty($currentNode['userinfo']['username']))
			{
				$currentNodeUsername = $currentNode['userinfo']['username'];
			}
			else if(!empty($currentNode['authorname']))
			{
				$currentNodeUsername = $currentNode['authorname'];
			}
			else
			{
				// I haven't hit this case while testing, but let's have a fallback to "Guest"...
				$currentNodeUsername = "Guest";
			}

			/*
			 *	These data are static & independent of the recipient of this message, assuming that
			 *	we're not trying to hide any data. If we are going to check permissions for each
			 *	recipient, we should probably check view perms & remove from the recipients list
			 *	BEFORE we ever get to this function, and just not send them a notification instead of
			 *	hiding data.
			 */
			$recipientIndependentDataArray[$contentnodeid] = array(
				'authorname' => $currentNodeUsername,
				// We're using the node URL to avoid permission-related exceptions in other route constructors.
				// Note that this doesn't seem to work with visitor messages, which is handled specially below
				'nodeurl' => vB5_Route::buildUrl('node|fullurl', array('nodeid' => $contentnodeid)),
				'previewtext' => vB_String::getPreviewText($currentNode['rawtext']),
				'nodeid' => $currentNode['nodeid'],
				'starter' => $currentNode['starter'],
				'startertitle' => isset($currentNode['startertitle'])? $currentNode['startertitle'] : '',
				'parentid' => $currentNode['parentid'],
				'channeltitle' => $currentNode['channeltitle'],
				'channeltype' => $currentNode['channeltype'],
			);
		}

		// additional data used for subscription notifications
		if (isset($data['subscriptionnodeid']))
		{
			$subId = $data['subscriptionnodeid'];

			if (!isset($recipientIndependentDataArray[$contentnodeid]['add_sub_data'][$subId]))
			{
				//we need to load this using the correct library class or things get weird.
				//note that if we load it from cache here it will be in the local memory cache
				//when we load the full content and we won't do it twice.
				$cached = vB_Library_Content::fetchFromCache(array($subId), vB_Library_Content::CACHELEVEL_FULLCONTENT);
				if(isset($cached['found'][$subId]))
				{
					$contenttypeid = $cached['found'][$subId]['contenttypeid'];
				}
				else
				{
					$row = $this->assertor->getRow('vBForum:node',
						array(
							vB_dB_Query::TYPE_KEY => vB_dB_Query::QUERY_SELECT,
							vB_dB_Query::COLUMNS_KEY => array('contenttypeid'),
							'nodeid' => $subId
						)
					);
					$contenttypeid = $row['contenttypeid'];
				}

				if ($contenttypeid)
				{
					$contentLib = vB_Library_Content::getContentLib($contenttypeid);

					$subbedNode = $contentLib->getContent($subId);
					$subbedNode = $subbedNode[$subId];

					$channelTypeId = vB_Types::instance()->getContentTypeID('vBForum_Channel');
					if ($subbedNode['contenttypeid'] == $channelTypeId)
					{
						$nodetype = 'channel';
					}
					else
					{
						$nodetype = 'post';
					}

					$recipientIndependentDataArray[$contentnodeid]['add_sub_data'][$subId] = array(
						'title' => $subbedNode['title'],
						'nodetype' => $nodetype,	// not used ATM. See TODO note below.
					);
				}
			}

			// We only expect channeltype_forum, channeltype_article, channeltype_blog, channeltype_group, but
			// a channeltype_<> phrase for each of vB_Channel::$channelTypes should exist, so we can do this
			$channeltype = "channeltype_" . $recipientIndependentDataArray[$contentnodeid]['channeltype'];
			$nodetype = $recipientIndependentDataArray[$contentnodeid]['add_sub_data'][$subId]['nodetype'];
			// While phrases are dependent on languageid, it's not really recipient or userid
			// dependent and is shared/static among different recipients, so let's keep track
			// of them.
			if (!isset($phrases[$data['languageid']]))
			{
				$phrases[$data['languageid']] = vB_Api::instanceInternal('phrase')->fetch(
					array($channeltype, $nodetype),
					$data['languageid']	// be sure to use the recipient's languageid when fetching phrases!
				);
			}
		}

		// keep track of the about strings that are for "content" notifications
		$temporary = array(
			vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_REPLY,
			vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_COMMENT,
			vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_THREADCOMMENT,
			vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_SUBSCRIPTION,
			vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_USERMENTION,	// this isn't part of the "content" category anymore, but that doesn't matter for this. We're using "content" loosely here.
		);
		$contentNotificationAboutStrings = array();
		foreach($temporary AS $aboutString)
		{
			$contentNotificationAboutStrings[$aboutString] = $aboutString;
		}


		if ($data['about'] == vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_VM)
		{
			// A VM should have only 1 recipient, so no good reason to cache this like other URLs. Also
			// VMs don't seem to work with the /node/ route, so we can't rely on that.
			$vmURL = vB5_Route::buildUrl('visitormessage|fullurl', array('nodeid' => $contentnodeid));

			$maildata = vB_Api::instanceInternal('phrase')->fetchEmailPhrases(
				'visitormessage',
				array(
					$data['username'],
					$recipientIndependentDataArray[$contentnodeid]['authorname'],
					$vmURL,
					$recipientIndependentDataArray[$contentnodeid]['previewtext'],
					$options['bbtitle'],
				),
				array(),
				$data['languageid']
			);
		}
		elseif ($data['about'] == vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_VOTE)
		{
			// Vote notifications have their own section because their phrases aren't in the same format as the others
			// Since a vote doesn't have a node associated with it, we don't have a "currentnode" data.
			// Note that the poll library sets aboutid & contentid both to the nodeid of the poll-post

			$maildata = vB_Api::instanceInternal('phrase')->fetchEmailPhrases(
				'vote',
				array(
					$data['username'],
					vB_Api::instanceInternal('user')->fetchUserName(vB::getCurrentSession()->get('userid')), // Note, current user, who voted, isn't found in the node data.
					$recipientIndependentDataArray[$contentnodeid]['nodeurl'],
					$options['bbtitle'],
				),
				array($recipientIndependentDataArray[$contentnodeid]['startertitle']),
				$data['languageid']
			);
		}
		elseif (	isset($contentNotificationAboutStrings[$data['about']])		)
		{
			// Normally the subject would contain the topic title, but if it's a subscription let's pass in the
			// title of the actual subscription node whether it's a channel, thread, blog etc.
			$emailSubjectVars = array($recipientIndependentDataArray[$contentnodeid]['startertitle']);

			// Since subscription email subjects don't have a title, we'll include the title in
			// the email body as a 7th variable.
			$emailBodyVars = array(
				$data['username'],
				$recipientIndependentDataArray[$contentnodeid]['authorname'],
				$recipientIndependentDataArray[$contentnodeid]['nodeurl'],
				$recipientIndependentDataArray[$contentnodeid]['previewtext'],
				$options['bbtitle'],
				//vB5_Route::buildUrl('subscription|fullurl', array('tab' => 'subscriptions', 'userid' => $data['userid'])),		// todo: make generic "subscriptions" route to redirect to current user's (current @ load time, that is) subscription route
				$options['frontendurl'] .'/member/'.$data['userid'].'/subscriptions',	// super hacky way to avoid instancing subscription routes for each recipient and avoid incorrect permission checking (VBV-13312)
				 // channel title
			);

			// blog & social groups have special phrases, unless it's a subscription notification or a user mention.
			// If the latter, just send the generic subscription or user mention email.
			// As of 5.1.4, we do not expect to hit this block for blogs, because the people who *would*
			// receive this (the blog poster) are automatically subscribed to the blog channel, and
			// subscription notifications always trump other types of notifications. See VBV-13466
			if (
				$recipientIndependentDataArray[$contentnodeid]['channeltype'] == 'blog'
				AND
				$data['about'] != vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_SUBSCRIPTION
				AND
				$data['about'] != vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_USERMENTION
			)
			{
				$mailPhrase = 'comment_blogentry';
			}
			else if (
				$recipientIndependentDataArray[$contentnodeid]['channeltype'] == 'group'
				AND
				$data['about'] != vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_SUBSCRIPTION
				AND
				$data['about'] != vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_USERMENTION
			)
			{
				$mailPhrase = 'comment_grouptopic';
			}
			else
			{
				switch ($data['about'])
				{
					case vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_REPLY:
						$mailPhrase = 'reply_thread';
						break;

					case vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_COMMENT:
						$mailPhrase = 'comment_post';
						break;

					case vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_THREADCOMMENT:
						$mailPhrase = 'comment_thread';
						break;

					case vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_SUBSCRIPTION:
						$mailPhrase = 'subscribed_thread';
						// $subId, $nodetype, $channeltype are set above as long as 'subscriptionnodeid' was passed in.
						if (!empty($subId))
						{
							// A new post in your {2} {3} subscription: {1}
							$emailSubjectVars = array(
								$recipientIndependentDataArray[$contentnodeid]['add_sub_data'][$subId]['title'],
								$phrases[$data['languageid']][$channeltype],
								$phrases[$data['languageid']][$nodetype],
							);

							// Since we removed the starter title from the subject, add it to the message.
							$emailBodyVars[] = $recipientIndependentDataArray[$contentnodeid]['startertitle'];

						}
						break;

					case vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_USERMENTION:
						$mailPhrase = 'usermention_post';
						// subject: <username> mentioned you in <content item title>
						$emailSubjectVars = array(
							$recipientIndependentDataArray[$contentnodeid]['authorname'],
							$recipientIndependentDataArray[$contentnodeid]['startertitle'],
						);
						break;

					default:
						if ($recipientIndependentDataArray[$contentnodeid]['starter'] == $recipientIndependentDataArray[$contentnodeid]['parentid'] )
						{
							$mailPhrase = 'reply_thread';
						}
						else
						{
							$mailPhrase = 'reply_post';
						}
						break;
				}
			}

			$maildata = vB_Api::instanceInternal('phrase')->fetchEmailPhrases(
				$mailPhrase,
				$emailBodyVars,
				$emailSubjectVars,
				$data['languageid']
			);
		}
		elseif ($data['about'] == vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_RATE)
		{

			$node = $recipientIndependentDataArray[$contentnodeid];

			// It doesn't make sense to call blog & article starters as "threads", so just go with "post"
			if (
				($node['nodeid'] == $node['starter'])	AND
				($node['channeltype'] == 'forum'	OR	$node['channeltype'] == 'group')
			)
			{
				$mailPhrase = 'like_thread';
			}
			else
			{
				$mailPhrase = 'like_post';
			}

			$maildata = vB_Api::instanceInternal('phrase')->fetchEmailPhrases(
				$mailPhrase,
				array(
					$data['username'],
					vB_Api::instanceInternal('user')->fetchUserName($data['senderid']),
					$node['nodeurl'],
					$options['bbtitle'],
				),
				array($options['bbtitle']),
				$data['languageid']
			);
		}
		elseif ($data['about'] == vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_FOLLOW)		// aka USERRELATION_ACCEPTEDFOLLOW
		{
			// the use of vB5_Route::buildUrl() below should be ok performant wise, because we're not expecting to send a bulk email for this
			// notification type. We should avoid hacky hacks (see above in the content notification section for avoiding vB5_Route::buildUrl()
			// to each recipient's subscription page) when forgivable.
			$maildata = vB_Api::instanceInternal('phrase')->fetchEmailPhrases(
				'follow_approve',
				array(
					$data['username'],
					vB_Api::instanceInternal('user')->fetchUserName($data['senderid']),
					vB5_Route::buildUrl(
						'profile|fullurl',
						array(
							'userid'	=> $data['senderid'],
							'tab'		=> 'subscribed'		// todo check if this works
						)
					),
					$options['bbtitle'],
				),
				array($options['bbtitle']),
				$data['languageid']
			);
		}
		elseif ($data['about'] == vB_Library_Content_Privatemessage::NOTIFICATION_TYPE_FOLLOWING)
		{
			/*
				Per dev chat discussion, this never existed. Since there doesn't seem to be a strong customer request for this,
				I'm gonna leave it out. I'm leaving this section in so that it's trivial to add in the future.
			*/
			$maildata = array();
		}
		else
		{
			// We don't know how to handle this.
			$maildata = array();
		}


		if (!empty($data['email']) AND !empty($maildata))
		{
			// Send the email
			vB_Mail::vbmail($data['email'], $maildata['subject'], $maildata['message'], false);
		}
	}




	/*
	 * Returns default notification types' classes
	 *
	 * @returns	String[]	Class names
	 */
	public function getDefaultTypes()
	{
		return array(
			'vB_Notification_Content_UserMention',
			'vB_Notification_Content_Quote',
			'vB_Notification_Content_GroupByStarter_Reply',
			'vB_Notification_Content_GroupByStarter_Subscription',
			'vB_Notification_Content_GroupByParentid_Comment',
			'vB_Notification_Content_GroupByParentid_ThreadComment',

			'vB_Notification_PollVote',
			'vB_Notification_LikedNode',
			'vB_Notification_VisitorMessage',

			'vB_Notification_UserRelation_SenderIsfollowing',
			'vB_Notification_UserRelation_SenderAcceptedFollowRequest',

		);
	}

	/*
	 * Only used during install/update. Insert this particular notification type into DB,
	 * populating the notificationtype, & notificationevent categories.
	 *
	 * @param	String	$class		Class name of the new notification type. The class must be a
	 *								subclass of vB_Notification, and placed in a location the
	 *								auto-loader can find.
	 */
	public function insertNotificationTypeToDB($class)
	{
		$class = (string) $class;
		$typename = $class::TYPENAME;

		$existing = vB::getDbAssertor()->getRow('vBForum:notificationtype',
			array(
				vB_dB_Query::TYPE_KEY => vB_dB_Query::QUERY_SELECT,
				'typename' => $typename,
			)
		);
		if (!empty($existing))
		{
			if ($existing['class'] === $class)
			{
				$this->insertNotificationEventsToDB($class);
				// already added.
				return;
			}
			else
			{
				throw new Exception("A notification type with the TYPENAME $typename already exists: " . $existing['class']);
			}
		}

		$allowed = vB_Notification::REGEX_ALLOWED_TYPENAME;
		if (!preg_match($allowed, $typename))
		{
			throw new Exception("Notification TYPENAME must be alphanumeric or underscore of at least 1 character (regex: [A-Za-z0-9_]+)! Given: " . $typename);
		}

		if (!is_subclass_of($class, 'vB_Notification'))
		{
			throw new Exception("$class is NOT a subclass of vB_Notification.");
		}


		vB::getDbAssertor()->assertQuery('vBForum:notificationtype',
			array(vB_dB_Query::TYPE_KEY => vB_dB_Query::QUERY_INSERT,
				'typename' => $typename, 'class' => $class
			)
		);
		$this->reloadNotificationTypesFromDB();

		$this->insertNotificationEventsToDB($class);
	}

	public function insertNotificationEventsToDB($class)
	{
		$class = (string) $class;
		$typename = $class::TYPENAME;
		if (!is_subclass_of($class, 'vB_Notification'))
		{
			throw new Exception("$class is NOT a subclass of vB_Notification.");
		}

		$existingTriggers = $this->getNotificationEvents();
		$updates = array();
		$expected = array();

		foreach ($class::getTriggers() AS $event => $priority)
		{
			// Note, it doesn't make sense for an event to be both a creation trigger and a dismiss/update event...
			// Maybe if you're trying to add a notification but set it dismissed..?

			// If it's doesn't exist, or it used to be a different event type, update it.
			if (!isset($existingTriggers[$event][$class]) OR $existingTriggers[$event][$class] != 'trigger')
			{
				$updates[$event] = $existingTriggers[$event];
				$updates[$event][$class] = 'trigger';
			}
			$expected[$event] = true;
		}

		foreach ($class::getUpdateEvents() AS $event)
		{
			if (!isset($existingTriggers[$event][$class]) OR $existingTriggers[$event][$class] != 'update')
			{
				$updates[$event] = $existingTriggers[$event];
				$updates[$event][$class] = 'update';
			}
			$expected[$event] = true;
		}


		// There might've been some events previously set that have since been removed or changed.
		// The loops above handle the changes to the event type (ex. from trigger to update)
		// This loop handles removals
		foreach ($existingTriggers AS $event => $classesToEventtype)
		{
			// Using the full array notation instead of &$classesToEventtype for consistency
			// with above code so it's easier to read (IMO).
			if (isset($existingTriggers[$event][$class]) AND empty($expected[$event]))
			{
				unset($existingTriggers[$event][$class]);
				$updates[$event] = $existingTriggers[$event];
			}
		}

		if (!empty($updates))
		{
			foreach ($updates AS $event => $arr)
			{
				$classes = json_encode($arr);
				$this->assertor->assertQuery('vBForum:updateNotificationevent', array('event' => $event, 'classes' => $classes));
			}
			$this->reloadNotificationEventsFromDB();
		}
	}

	/*
	 * Get the `notificationevent` table data in an array keyed by eventname.
	 *
	 * @return	Array[String]	Keyed by String eventname, each inner array is
	 *							key => value of {String classname => String eventtype ('trigger'|'update')
	 *							Ex:
	 *							array(
	 *								'new-content' => array(
	 *									"vB_Notification_Content_Usermention" => "trigger",
	 *									"vB_Notification_Content_GroupByStarter_Reply" => "trigger",
	 *									"vB_Notification_Content_GroupByStarter_Subscription" => "trigger",
	 *									"vB_Notification_Content_GroupByParentid_Comment" => "trigger",
	 *									"vB_Notification_Content_GroupByParentid_ThreadComment" => "trigger"
	 *								),
	 *								'new-visitormessage' => array(
	 *									"vB_Notification_Visitormessage":"trigger"
	 *								),
	 *								...
	 *							)
	 */
	public function getNotificationEvents()
	{
		$datastore = vB::getDatastore()->getValue(self::DATASTOREKEY_TRIGGERS);
		if (!empty($datastore))
		{
			return $datastore;
		}
		else
		{
			return $this->reloadNotificationEventsFromDB();
		}
	}

	protected function reloadNotificationEventsFromDB()
	{
		$rows = $this->assertor->getRows('vBForum:notificationevent');
		$triggers = array();
		foreach ($rows AS $row)
		{
			$event = $row['eventname'];
			$classes = json_decode($row['classes'], true);
			$triggers[$event] = $classes;
		}
		vB::getDatastore()->build(self::DATASTOREKEY_TRIGGERS, serialize($triggers), 1);
		return $triggers;
	}


	/*
	 * Get the `notificationtype` table data in an array keyed by typename.
	 *
	 * @return	Array[String]	Keyed by String typename, each inner array contains:
	 *					- Int		typeid
	 *					- String	typename
	 *					- String	class
	 */
	public function getNotificationTypes()
	{
		$datastore = vB::getDatastore()->getValue(self::DATASTOREKEY_TYPES);
		if (!empty($datastore))
		{
			return $datastore;
		}
		else
		{
			return $this->reloadNotificationTypesFromDB();
		}
	}

	protected function reloadNotificationTypesFromDB()
	{
		$rows = $this->assertor->getRows('vBForum:notificationtype');
		$types = array();
		foreach ($rows AS $row)
		{
			// typeid, typename & class
			$types[$row['typename']] = $row;
		}
		vB::getDatastore()->build(self::DATASTOREKEY_TYPES, serialize($types), 1);
		return $types;
	}

}

/*=========================================================================*\
|| #######################################################################
|| # NulleD By - prowebber.ru
|| # CVS: $RCSfile$ - $Revision: 99787 $
|| #######################################################################
\*=========================================================================*/
