diff --git a/src/BaseCollection.php b/src/BaseCollection.php index c4d637a367..1aa13ae961 100644 --- a/src/BaseCollection.php +++ b/src/BaseCollection.php @@ -22,12 +22,11 @@ namespace Friendica; /** - * The Collection classes inheriting from this abstract class are meant to represent a list of database record. - * The associated model class has to be provided in the child classes. + * The Collection classes inheriting from this class are meant to represent a list of structured objects of a single type. * * Collections can be used with foreach(), accessed like an array and counted. */ -abstract class BaseCollection extends \ArrayIterator +class BaseCollection extends \ArrayIterator { /** * This property is used with paginated results to hold the total number of items satisfying the paginated request. @@ -115,4 +114,14 @@ abstract class BaseCollection extends \ArrayIterator { return new static(array_filter($this->getArrayCopy(), $callback, $flag)); } + + /** + * Reverse the orders of the elements in the collection + * + * @return $this + */ + public function reverse(): BaseCollection + { + return new static(array_reverse($this->getArrayCopy()), $this->getTotalCount()); + } } diff --git a/src/BaseDepository.php b/src/BaseDepository.php new file mode 100644 index 0000000000..00d3bcfdfd --- /dev/null +++ b/src/BaseDepository.php @@ -0,0 +1,101 @@ +db = $database; + $this->logger = $logger; + $this->factory = $factory; + } + + + /** + * @param array $condition + * @param array $params + * @return BaseCollection + * @throws Exception + */ + protected function _select(array $condition, array $params = []): BaseCollection + { + $rows = $this->db->selectToArray(static::$table_name, [], $condition, $params); + + $Entities = new BaseCollection(); + foreach ($rows as $fields) { + $Entities[] = $this->factory->createFromTableRow($fields); + } + + return $Entities; + } + + /** + * @param array $condition + * @param array $params + * @return BaseEntity + * @throws NotFoundException + */ + protected function _selectOne(array $condition, array $params = []): BaseEntity + { + $fields = $this->db->selectFirst(static::$table_name, [], $condition, $params); + if (!$this->db->isResult($fields)) { + throw new NotFoundException(); + } + + return $this->factory->createFromTableRow($fields); + } + + /** + * @param array $condition + * @param array $params + * @return int + * @throws Exception + */ + public function count(array $condition, array $params = []): int + { + return $this->db->count(static::$table_name, $condition, $params); + } + + /** + * @param array $condition + * @return bool + * @throws Exception + */ + public function exists(array $condition): bool + { + return $this->db->exists(static::$table_name, $condition); + } +} diff --git a/src/Capabilities/ICanCreateFromTableRow.php b/src/Capabilities/ICanCreateFromTableRow.php new file mode 100644 index 0000000000..bdb6d662da --- /dev/null +++ b/src/Capabilities/ICanCreateFromTableRow.php @@ -0,0 +1,16 @@ +. + * + */ + +namespace Friendica\Navigation\Notifications\Collection; + +use Friendica\BaseCollection; +use Friendica\Navigation\Notifications\ValueObject; + +class FormattedNotifications extends BaseCollection +{ + /** + * @return ValueObject\FormattedNotification + */ + public function current(): ValueObject\FormattedNotification + { + return parent::current(); + } +} diff --git a/src/Navigation/Notifications/Collection/Notifies.php b/src/Navigation/Notifications/Collection/Notifies.php new file mode 100644 index 0000000000..47fac8d0a1 --- /dev/null +++ b/src/Navigation/Notifications/Collection/Notifies.php @@ -0,0 +1,43 @@ +. + * + */ + +namespace Friendica\Navigation\Notifications\Collection; + +use Friendica\BaseCollection; +use Friendica\Navigation\Notifications\Entity; + +class Notifies extends BaseCollection +{ + /** + * @return Entity\Notify + */ + public function current(): Entity\Notify + { + return parent::current(); + } + + public function setSeen(): Notifies + { + return $this->map(function (Entity\Notify $Notify) { + $Notify->setSeen(); + }); + } +} diff --git a/src/Navigation/Notifications/Depository/Notify.php b/src/Navigation/Notifications/Depository/Notify.php new file mode 100644 index 0000000000..c35f50a73b --- /dev/null +++ b/src/Navigation/Notifications/Depository/Notify.php @@ -0,0 +1,141 @@ +getArrayCopy()); + } + + public function countForUser($uid, array $condition, array $params = []): int + { + $condition = DBA::mergeConditions($condition, ['uid' => $uid]); + + return $this->count($condition, $params); + } + + public function existsForUser($uid, array $condition): bool + { + $condition = DBA::mergeConditions($condition, ['uid' => $uid]); + + return $this->exists($condition); + } + + /** + * @param int $id + * @return Entity\Notify + * @throws HTTPException\NotFoundException + */ + public function selectOneById(int $id): Entity\Notify + { + return $this->selectOne(['id' => $id]); + } + + public function selectForUser(int $uid, array $condition, array $params): Collection\Notifies + { + $condition = DBA::mergeConditions($condition, ['uid' => $uid]); + + return $this->select($condition, $params); + } + + public function selectAllForUser(int $uid, array $params = []): Collection\Notifies + { + return $this->selectForUser($uid, [], $params); + } + + public function setAllSeenForUser(int $uid, array $condition = []): bool + { + $condition = DBA::mergeConditions($condition, ['uid' => $uid]); + + return $this->db->update(self::$table_name, ['seen' => true], $condition); + } + + /** + * @param Entity\Notify $Notify + * @return Entity\Notify + * @throws HTTPException\NotFoundException + * @throws HTTPException\InternalServerErrorException + * @throws Exception\NotificationCreationInterceptedException + */ + public function save(Entity\Notify $Notify): Entity\Notify + { + $fields = [ + 'type' => $Notify->type, + 'name' => $Notify->name, + 'url' => $Notify->url, + 'photo' => $Notify->photo, + 'msg' => $Notify->msg, + 'uid' => $Notify->uid, + 'link' => $Notify->link, + 'iid' => $Notify->itemId, + 'parent' => $Notify->parent, + 'seen' => $Notify->seen, + 'verb' => $Notify->verb, + 'otype' => $Notify->otype, + 'name_cache' => $Notify->name_cache, + 'msg_cache' => $Notify->msg_cache, + 'uri-id' => $Notify->uriId, + 'parent-uri-id' => $Notify->parentUriId, + ]; + + if ($Notify->id) { + $this->db->update(self::$table_name, $fields, ['id' => $Notify->id]); + } else { + $fields['date'] = DateTimeFormat::utcNow(); + Hook::callAll('enotify_store', $fields); + + $this->db->insert(self::$table_name, $fields); + + $Notify = $this->selectOneById($this->db->lastInsertId()); + } + + return $Notify; + } + + public function setAllSeenForRelatedNotify(Entity\Notify $Notify): bool + { + $condition = [ + '(`link` = ? OR (`parent` != 0 AND `parent` = ? AND `otype` = ?)) AND `uid` = ?', + $Notify->link, + $Notify->parent, + $Notify->otype, + $Notify->uid + ]; + return $this->db->update(self::$table_name, ['seen' => true], $condition); + } +} diff --git a/src/Navigation/Notifications/Entity/Notify.php b/src/Navigation/Notifications/Entity/Notify.php new file mode 100644 index 0000000000..88cd8ab36e --- /dev/null +++ b/src/Navigation/Notifications/Entity/Notify.php @@ -0,0 +1,128 @@ +type = $type; + $this->name = $name; + $this->url = $url; + $this->photo = $photo; + $this->date = $date; + $this->msg = $msg; + $this->uid = $uid; + $this->link = $link; + $this->itemId = $itemId; + $this->parent = $parent; + $this->seen = $seen; + $this->verb = $verb; + $this->otype = $otype; + $this->name_cache = $name_cache; + $this->msg_cache = $msg_cache; + $this->uriId = $uriId; + $this->parentUriId = $parentUriId; + $this->id = $id; + } + + public function setSeen() + { + $this->seen = true; + } + + public function updateMsgFromPreamble($epreamble) + { + $this->msg = Renderer::replaceMacros($epreamble, ['$itemlink' => $this->link->__toString()]); + $this->msg_cache = self::formatMessage($this->name_cache, strip_tags(BBCode::convert($this->msg))); + } + + /** + * Formats a notification message with the notification author + * + * Replace the name with {0} but ensure to make that only once. The {0} is used + * later and prints the name in bold. + * + * @param string $name + * @param string $message + * + * @return string Formatted message + */ + public static function formatMessage(string $name, string $message): string + { + if ($name != '') { + $pos = strpos($message, $name); + } else { + $pos = false; + } + + if ($pos !== false) { + $message = substr_replace($message, '{0}', $pos, strlen($name)); + } + + return $message; + } +} diff --git a/src/Navigation/Notifications/Exception/NotificationCreationInterceptedException.php b/src/Navigation/Notifications/Exception/NotificationCreationInterceptedException.php new file mode 100644 index 0000000000..5dd7890283 --- /dev/null +++ b/src/Navigation/Notifications/Exception/NotificationCreationInterceptedException.php @@ -0,0 +1,7 @@ +. + * + */ + +namespace Friendica\Navigation\Notifications\Factory; + +use Exception; +use Friendica\App\BaseURL; +use Friendica\BaseFactory; +use Friendica\Content\Text\BBCode; +use Friendica\Core\L10n; +use Friendica\Core\Protocol; +use Friendica\Database\Database; +use Friendica\Model\Contact; +use Friendica\Model\Post; +use Friendica\Module\BaseNotifications; +use Friendica\Navigation\Notifications\Collection\FormattedNotifications; +use Friendica\Navigation\Notifications\Depository; +use Friendica\Navigation\Notifications\ValueObject; +use Friendica\Network\HTTPException\InternalServerErrorException; +use Friendica\Protocol\Activity; +use Friendica\Util\DateTimeFormat; +use Friendica\Util\Proxy; +use Friendica\Util\Temporal; +use Friendica\Util\XML; +use Psr\Log\LoggerInterface; + +/** + * Factory for creating notification objects based on items + * Currently, there are the following types of item based notifications: + * - network + * - system + * - home + * - personal + */ +class FormattedNotification extends BaseFactory +{ + /** @var Database */ + private $dba; + /** @var Depository\Notify */ + private $notify; + /** @var BaseURL */ + private $baseUrl; + /** @var L10n */ + private $l10n; + + public function __construct(LoggerInterface $logger, Database $dba, Depository\Notify $notify, BaseURL $baseUrl, L10n $l10n) + { + parent::__construct($logger); + + $this->dba = $dba; + $this->notify = $notify; + $this->baseUrl = $baseUrl; + $this->l10n = $l10n; + } + + /** + * @param array $formattedItem The return of $this->formatItem + * + * @return ValueObject\FormattedNotification + */ + private function createFromFormattedItem(array $formattedItem): ValueObject\FormattedNotification + { + // Transform the different types of notification in a usable array + switch ($formattedItem['verb'] ?? '') { + case Activity::LIKE: + return new ValueObject\FormattedNotification( + 'like', + $this->baseUrl->get(true) . '/display/' . $formattedItem['parent-guid'], + $formattedItem['author-avatar'], + $formattedItem['author-link'], + $this->l10n->t("%s liked %s's post", $formattedItem['author-name'], $formattedItem['parent-author-name']), + $formattedItem['when'], + $formattedItem['ago'], + $formattedItem['seen'] + ); + + case Activity::DISLIKE: + return new ValueObject\FormattedNotification( + 'dislike', + $this->baseUrl->get(true) . '/display/' . $formattedItem['parent-guid'], + $formattedItem['author-avatar'], + $formattedItem['author-link'], + $this->l10n->t("%s disliked %s's post", $formattedItem['author-name'], $formattedItem['parent-author-name']), + $formattedItem['when'], + $formattedItem['ago'], + $formattedItem['seen'] + ); + + case Activity::ATTEND: + return new ValueObject\FormattedNotification( + 'attend', + $this->baseUrl->get(true) . '/display/' . $formattedItem['parent-guid'], + $formattedItem['author-avatar'], + $formattedItem['author-link'], + $this->l10n->t("%s is attending %s's event", $formattedItem['author-name'], $formattedItem['parent-author-name']), + $formattedItem['when'], + $formattedItem['ago'], + $formattedItem['seen'] + ); + + case Activity::ATTENDNO: + return new ValueObject\FormattedNotification( + 'attendno', + $this->baseUrl->get(true) . '/display/' . $formattedItem['parent-guid'], + $formattedItem['author-avatar'], + $formattedItem['author-link'], + $this->l10n->t("%s is not attending %s's event", $formattedItem['author-name'], $formattedItem['parent-author-name']), + $formattedItem['when'], + $formattedItem['ago'], + $formattedItem['seen'] + ); + + case Activity::ATTENDMAYBE: + return new ValueObject\FormattedNotification( + 'attendmaybe', + $this->baseUrl->get(true) . '/display/' . $formattedItem['parent-guid'], + $formattedItem['author-avatar'], + $formattedItem['author-link'], + $this->l10n->t("%s may attending %s's event", $formattedItem['author-name'], $formattedItem['parent-author-name']), + $formattedItem['when'], + $formattedItem['ago'], + $formattedItem['seen'] + ); + + case Activity::FRIEND: + if (!isset($formattedItem['object'])) { + return new ValueObject\FormattedNotification( + 'friend', + $formattedItem['link'], + $formattedItem['image'], + $formattedItem['url'], + $formattedItem['text'], + $formattedItem['when'], + $formattedItem['ago'], + $formattedItem['seen'] + ); + } + + $xmlHead = "<" . "?xml version='1.0' encoding='UTF-8' ?" . ">"; + $obj = XML::parseString($xmlHead . $formattedItem['object']); + + $formattedItem['fname'] = $obj->title; + + return new ValueObject\FormattedNotification( + 'friend', + $this->baseUrl->get(true) . '/display/' . $formattedItem['parent-guid'], + $formattedItem['author-avatar'], + $formattedItem['author-link'], + $this->l10n->t("%s is now friends with %s", $formattedItem['author-name'], $formattedItem['fname']), + $formattedItem['when'], + $formattedItem['ago'], + $formattedItem['seen'] + ); + + default: + return new ValueObject\FormattedNotification( + $formattedItem['label'] ?? '', + $formattedItem['link'] ?? '', + $formattedItem['image'] ?? '', + $formattedItem['url'] ?? '', + $formattedItem['text'] ?? '', + $formattedItem['when'] ?? '', + $formattedItem['ago'] ?? '', + $formattedItem['seen'] ?? false + ); + } + } + + /** + * Get system notifications + * + * @param bool $seen False => only include notifications into the query + * which aren't marked as "seen" + * @param int $start Start the query at this point + * @param int $limit Maximum number of query results + * + * @return FormattedNotifications + */ + public function getSystemList(bool $seen = false, int $start = 0, int $limit = BaseNotifications::DEFAULT_PAGE_LIMIT): FormattedNotifications + { + $conditions = []; + if (!$seen) { + $conditions['seen'] = false; + } + + $params = []; + $params['order'] = ['date' => 'DESC']; + $params['limit'] = [$start, $limit]; + + $formattedNotifications = new FormattedNotifications(); + try { + $Notifies = $this->notify->selectForUser(local_user(), $conditions, $params); + + foreach ($Notifies as $Notify) { + $formattedNotifications[] = new ValueObject\FormattedNotification( + 'notification', + $this->baseUrl->get(true) . '/notification/' . $Notify->id, + Contact::getAvatarUrlForUrl($Notify->url, $Notify->uid, Proxy::SIZE_MICRO), + $Notify->url, + strip_tags(BBCode::toPlaintext($Notify->msg)), + DateTimeFormat::local($Notify->date->format(DateTimeFormat::MYSQL), 'r'), + Temporal::getRelativeDate($Notify->date->format(DateTimeFormat::MYSQL)), + $Notify->seen + ); + } + } catch (Exception $e) { + $this->logger->warning('Select failed.', ['conditions' => $conditions, 'exception' => $e]); + } + + return $formattedNotifications; + } + + /** + * Get network notifications + * + * @param bool $seen False => only include notifications into the query + * which aren't marked as "seen" + * @param int $start Start the query at this point + * @param int $limit Maximum number of query results + * + * @return FormattedNotifications + */ + public function getNetworkList(bool $seen = false, int $start = 0, int $limit = BaseNotifications::DEFAULT_PAGE_LIMIT): FormattedNotifications + { + $condition = ['wall' => false, 'uid' => local_user()]; + + if (!$seen) { + $condition['unseen'] = true; + } + + $fields = ['id', 'parent', 'verb', 'author-name', 'unseen', 'author-link', 'author-avatar', 'contact-avatar', + 'network', 'created', 'object', 'parent-author-name', 'parent-author-link', 'parent-guid', 'gravity']; + $params = ['order' => ['received' => true], 'limit' => [$start, $limit]]; + + $formattedNotifications = new FormattedNotifications(); + + try { + $userPosts = Post::selectForUser(local_user(), $fields, $condition, $params); + while ($userPost = $this->dba->fetch($userPosts)) { + $formattedNotifications[] = $this->createFromFormattedItem($this->formatItem($userPost)); + } + } catch (Exception $e) { + $this->logger->warning('Select failed.', ['condition' => $condition, 'exception' => $e]); + } + + return $formattedNotifications; + } + + /** + * Get personal notifications + * + * @param bool $seen False => only include notifications into the query + * which aren't marked as "seen" + * @param int $start Start the query at this point + * @param int $limit Maximum number of query results + * + * @return FormattedNotifications + */ + public function getPersonalList(bool $seen = false, int $start = 0, int $limit = BaseNotifications::DEFAULT_PAGE_LIMIT): FormattedNotifications + { + $condition = ['wall' => false, 'uid' => local_user(), 'author-id' => public_contact()]; + + if (!$seen) { + $condition['unseen'] = true; + } + + $fields = ['id', 'parent', 'verb', 'author-name', 'unseen', 'author-link', 'author-avatar', 'contact-avatar', + 'network', 'created', 'object', 'parent-author-name', 'parent-author-link', 'parent-guid', 'gravity']; + $params = ['order' => ['received' => true], 'limit' => [$start, $limit]]; + + $formattedNotifications = new FormattedNotifications(); + + try { + $userPosts = Post::selectForUser(local_user(), $fields, $condition, $params); + while ($userPost = $this->dba->fetch($userPosts)) { + $formattedNotifications[] = $this->createFromFormattedItem($this->formatItem($userPost)); + } + } catch (Exception $e) { + $this->logger->warning('Select failed.', ['conditions' => $condition, 'exception' => $e]); + } + + return $formattedNotifications; + } + + /** + * Get home notifications + * + * @param bool $seen False => only include notifications into the query + * which aren't marked as "seen" + * @param int $start Start the query at this point + * @param int $limit Maximum number of query results + * + * @return FormattedNotifications + */ + public function getHomeList(bool $seen = false, int $start = 0, int $limit = BaseNotifications::DEFAULT_PAGE_LIMIT): FormattedNotifications + { + $condition = ['wall' => true, 'uid' => local_user()]; + + if (!$seen) { + $condition['unseen'] = true; + } + + $fields = ['id', 'parent', 'verb', 'author-name', 'unseen', 'author-link', 'author-avatar', 'contact-avatar', + 'network', 'created', 'object', 'parent-author-name', 'parent-author-link', 'parent-guid', 'gravity']; + $params = ['order' => ['received' => true], 'limit' => [$start, $limit]]; + + $formattedNotifications = new FormattedNotifications(); + + try { + $userPosts = Post::selectForUser(local_user(), $fields, $condition, $params); + while ($userPost = $this->dba->fetch($userPosts)) { + $formattedItem = $this->formatItem($userPost); + + // Overwrite specific fields, not default item format + $formattedItem['label'] = 'comment'; + $formattedItem['text'] = $this->l10n->t("%s commented on %s's post", $formattedItem['author-name'], $formattedItem['parent-author-name']); + + $formattedNotifications[] = $this->createFromFormattedItem($formattedItem); + } + } catch (Exception $e) { + $this->logger->warning('Select failed.', ['conditions' => $condition, 'exception' => $e]); + } + + return $formattedNotifications; + } + + /** + * Format the item query in a usable array + * + * @param array $item The item from the db query + * + * @return array The item, extended with the notification-specific information + * + * @throws InternalServerErrorException + * @throws Exception + */ + private function formatItem(array $item): array + { + $item['seen'] = !($item['unseen'] > 0); + + // For feed items we use the user's contact, since the avatar is mostly self choosen. + if (!empty($item['network']) && $item['network'] == Protocol::FEED) { + $item['author-avatar'] = $item['contact-avatar']; + } + + $item['label'] = (($item['gravity'] == GRAVITY_PARENT) ? 'post' : 'comment'); + $item['link'] = $this->baseUrl->get(true) . '/display/' . $item['parent-guid']; + $item['image'] = $item['author-avatar']; + $item['url'] = $item['author-link']; + $item['when'] = DateTimeFormat::local($item['created'], 'r'); + $item['ago'] = Temporal::getRelativeDate($item['created']); + $item['text'] = (($item['gravity'] == GRAVITY_PARENT) + ? $this->l10n->t("%s created a new post", $item['author-name']) + : $this->l10n->t("%s commented on %s's post", $item['author-name'], $item['parent-author-name'])); + + return $item; + } +} diff --git a/src/Navigation/Notifications/Factory/Notify.php b/src/Navigation/Notifications/Factory/Notify.php new file mode 100644 index 0000000000..87ba7d2fe7 --- /dev/null +++ b/src/Navigation/Notifications/Factory/Notify.php @@ -0,0 +1,58 @@ +. + * + */ + +namespace Friendica\Navigation\Notifications\ValueObject; + +use Friendica\BaseDataTransferObject; + +/** + * A view-only object for printing item notifications to the frontend + */ +class FormattedNotification extends BaseDataTransferObject +{ + const SYSTEM = 'system'; + const PERSONAL = 'personal'; + const NETWORK = 'network'; + const INTRO = 'intro'; + const HOME = 'home'; + + /** @var string */ + protected $label = ''; + /** @var string */ + protected $link = ''; + /** @var string */ + protected $image = ''; + /** @var string */ + protected $url = ''; + /** @var string */ + protected $text = ''; + /** @var string */ + protected $when = ''; + /** @var string */ + protected $ago = ''; + /** @var boolean */ + protected $seen = false; + + public function __construct(string $label, string $link, string $image, string $url, string $text, string $when, string $ago, bool $seen) + { + $this->label = $label ?? ''; + $this->link = $link ?? ''; + $this->image = $image ?? ''; + $this->url = $url ?? ''; + $this->text = $text ?? ''; + $this->when = $when ?? ''; + $this->ago = $ago ?? ''; + $this->seen = $seen ?? false; + } +}