activitypub-1.0.x-dev/src/Services/Reader.php
src/Services/Reader.php
<?php
namespace Drupal\activitypub\Services;
use ActivityPhp\Server;
use Drupal\activitypub\Entity\ActivityPubActivityInterface;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Entity\EntityFormBuilderInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Link;
use Drupal\Core\Pager\PagerManagerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Site\Settings;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\reader\ReaderBase;
use Symfony\Component\HttpFoundation\RequestStack;
class Reader extends ReaderBase {
use StringTranslationTrait;
/**
* The ActivityPub Actor storage.
*
* @var \Drupal\activitypub\Entity\Storage\ActivityPubActorStorageInterface
*/
protected $actorStorage;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* The pager manager.
*
* @var \Drupal\Core\Pager\PagerManagerInterface
*/
protected $pagerManager;
/**
* The request stack
*
* @var \Symfony\Component\HttpFoundation\RequestStack $requestStack
*/
protected $requestStack;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The entity form builder.
*
* @var \Drupal\Core\Entity\EntityFormBuilderInterface
*/
protected $entityFormBuilder;
/**
* @var \Drupal\Core\Datetime\DateFormatInterface
*/
protected $dateFormatter;
/**
* The ActivityPub Utility service.
*
* @var \Drupal\activitypub\Services\ActivityPubUtilityInterface
*/
protected $activityPubUtility;
/**
* The ActivityPub Media cache service.
*
* @var \Drupal\activitypub\Services\ActivityPubMediaCacheInterface
*/
protected $activityPubMediaCache;
/**
* ActivityPubFormAlter constructor
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Entity\EntityFormBuilderInterface $entity_form_builder
* The entity form builder.
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user.
* @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
* The request stack.
* @param \Drupal\Core\Pager\PagerManagerInterface $pager_manager
* The pager manager.
* @param \Drupal\Core\Datetime\DateFormatterInterface $date_formatter
* The date formatter.
* @param \Drupal\activitypub\Services\ActivityPubUtilityInterface $activitypub_utility
* The ActivityPub utility service.
* @param \Drupal\activitypub\Services\ActivityPubMediaCacheInterface $activitypub_media_cache
* The ActivityPub media cache service.
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityFormBuilderInterface $entity_form_builder, AccountInterface $current_user, RequestStack $request_stack, PagerManagerInterface $pager_manager, DateFormatterInterface $date_formatter, ActivityPubUtilityInterface $activitypub_utility, ActivityPubMediaCacheInterface $activitypub_media_cache) {
$this->entityTypeManager = $entity_type_manager;
$this->entityFormBuilder = $entity_form_builder;
$this->actorStorage = $entity_type_manager->getStorage('activitypub_actor');
$this->currentUser = $current_user;
$this->requestStack = $request_stack;
$this->pagerManager = $pager_manager;
$this->dateFormatter = $date_formatter;
$this->activityPubUtility = $activitypub_utility;
$this->activityPubMediaCache = $activitypub_media_cache;
}
/**
* {@inheritdoc}
*/
public function getChannels() {
if (!$this->getActor()) {
return [];
}
$conditions = [
'uid' => $this->currentUser->id(),
'status' => 1,
'collection' => 'inbox',
'is_read' => 0,
'visibility' => [ActivityPubActivityInterface::VISIBILITY_PUBLIC, ActivityPubActivityInterface::VISIBILITY_FOLLOWERS],
'type' => $this->activityPubUtility->getTimelineTypes(),
'entity_id' => 'isNull',
];
$following = $this->getFollowees();
if (!empty($following[0])) {
$conditions['actor'] = $following[0];
}
$unread_home = $this->entityTypeManager->getStorage('activitypub_activity')->getActivityCount($conditions);
// Direct messages.
$conditions['visibility'] = ActivityPubActivityInterface::VISIBILITY_PRIVATE;
unset($conditions['actor']);
unset($conditions['entity_id']);
$unread_direct = $this->entityTypeManager->getStorage('activitypub_activity')->getActivityCount($conditions);
// Notifications.
$conditions['visibility'] = [ActivityPubActivityInterface::VISIBILITY_PUBLIC, ActivityPubActivityInterface::VISIBILITY_FOLLOWERS];
$conditions['type'] = $this->activityPubUtility->getNotificationTypes();
$conditions['entity_id'] = 'isNotNull';
$unread_notifications = $this->entityTypeManager->getStorage('activitypub_activity')->getActivityCount($conditions);
return [
'label' => $this->t('Fediverse'),
'channels' => [
(object) [
'uid' => 'home',
'unread' => $unread_home,
'name' => $this->t('Home'),
],
(object) [
'uid' => 'notifications',
'unread' => $unread_notifications,
'name' => $this->t('Notifications'),
],
(object) [
'uid' => 'direct',
'unread' => $unread_direct,
'name' => $this->t('Direct messages'),
],
(object) [
'uid' => 'local',
'unread' => 0,
'name' => $this->t('Local timeline'),
],
],
];
}
/**
* {@inheritdoc}
*/
public function getSourcesPage($op) {
switch ($op) {
case 'add':
$build = $this->addSourcesButton($this->t('Back to Fediverse'), 'activitypub');
$activity = $this->entityTypeManager->getStorage('activitypub_activity')->create();
$form = $this->entityFormBuilder->getForm($activity);
$form['#action'] .= '?destination=/reader/sources/activitypub';
$build['add'] = $form;
break;
case 'delete':
$build = $this->addSourcesButton($this->t('Back to Fediverse'), 'activitypub');
$activity = $this->entityTypeManager->getStorage('activitypub_activity')->load($_GET['id']);
if (!$activity->isPublished()) {
$form = $this->entityFormBuilder->getForm($activity, 'delete');
$form['actions']['cancel']['#access'] = FALSE;
$form['#action'] .= '&destination=/reader/sources/activitypub';
$build['delete'] = $form;
}
else {
$build['delete'] = ['#markup' => '<div class="form-actions side-padding">' . $this->t(' This action cannot be undone.') . '</div>'];
$build['actions'] = [
'#prefix' => '<div class="form-actions side-padding">',
'#suffix' => '</div>',
'delete' => [
'#type' => 'link',
'#title' => 'delete',
'#url' => $activity->toUrl('undo', ['query' => ['destination' => 'reader/sources/activitypub']]),
'#attributes' => ['class' => ['button']],
],
];
}
$build['delete']['#prefix'] = $this->addSourcesConfirmDeleteText($activity->getObject());
break;
case 'followers':
$build = $this->addSourcesButton($this->t('Back to Fediverse'), 'activitypub');
$followers_properties = [
'collection' => 'inbox',
'type' => 'Follow',
];
$build['table'] = $this->buildUserTable($followers_properties, $this->t('No followers available.'), FALSE);
break;
default:
$build = [];
$build['follow'] = $this->addSourcesButton($this->t('+ Follow'), 'activitypub', 'add');
$build['followers'] = $this->addSourcesButton($this->t('+ View followers'), 'activitypub', 'followers');
$following_properties = [
'collection' => 'outbox',
'type' => 'Follow',
];
$build['table'] = $this->buildUserTable($following_properties, $this->t('You are not following anyone.'));
break;
}
$build['#title'] = $this->t('Manage Fediverse');
return $build;
}
/**
* {@inheritdoc}
*/
public function getTimelineActions($id) {
if (!$this->getActor()) {
return [];
}
$status_title = isset($_SESSION['activitypub_status']) ? $this->t('View all items') : $this->t('View unread items');
return [
['action' => 'mark-read', 'title' => $this->t('Mark read')],
['action' => 'status', 'title' => $status_title],
];
}
/**
* {@inheritdoc}
*/
public function doTimelineAction($action, $id) {
if ($this->getActor()) {
if ($action == 'mark-read') {
$this->entityTypeManager->getStorage('activitypub_activity')->changeReadStatus(1, $id);
}
if ($action == 'status') {
if (isset($_SESSION['activitypub_status'])) {
unset($_SESSION['activitypub_status']);
}
else {
$_SESSION['activitypub_status'] = TRUE;
}
}
}
}
/**
* {@inheritdoc}
*/
public function getPostActions($id, $item) {
if (!$this->getActor()) {
return [];
}
$actions = [];
// Mute / unmute.
if ($id == 'home') {
$actions[] = [
'action' => 'mute',
'title' => $this->t('Mute user')
];
}
if ($id == 'local') {
$actors = $this->getFollowees();
if (isset($item->author->url) && isset($actors[1][$item->author->url])) {
$actions[] = [
'action' => 'unmute',
'title' => $this->t('Unmute user')
];
}
}
// Delete Direct message.
if ($id == 'direct') {
$actions[] = [
'action' => 'delete-message',
'title' => $this->t('Delete message')
];
}
// Mark read / unread.
$action = 'mark-unread';
$title = $this->t('Mark unread');
if (isset($item->_is_read) && !$item->_is_read) {
$action = 'mark-read';
$title = $this->t('Mark read');
}
$actions[] = [
'action' => $action,
'title' => $title,
];
return $actions;
}
/**
* {@inheritdoc}
*/
public function doPostAction($action, $id, $items) {
// Mark read / unread.
if ($action == 'mark-read' || $action == 'mark-unread') {
$status = $action == 'mark-read' ? 1 : 0;
$this->entityTypeManager->getStorage('activitypub_activity')->changeReadStatus($status, $id, $items);
}
// Mute / unmute.
if ($action == 'mute' || $action == 'unmute') {
$aid = !empty($items[0]) ? $items[0] : 0;
if (!$aid) {
return;
}
/** @var \Drupal\activitypub\Entity\Storage\ActivityPubActivityStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage('activitypub_activity');
// When coming from home or local.
if ($id == 'home' || $id == 'local') {
/** @var \Drupal\activitypub\Entity\ActivityPubActivityInterface $activity */
$activity = $storage->load($aid);
$follows = $storage->getActivities(['type' => 'Follow', 'object' => $activity->getActor(), 'uid' => $this->currentUser->id()]);
if (!empty($follows)) {
$a = array_shift($follows);
$aid = $a->id();
}
}
/** @var \Drupal\activitypub\Entity\ActivityPubActivityInterface $follow_activity */
$follow_activity = $this->entityTypeManager->getStorage('activitypub_activity')->load($aid);
if ($follow_activity) {
$action == 'mute' ? $follow_activity->mute() : $follow_activity->unMute();
try {
$follow_activity->save();
}
catch (\Exception $ignored) {}
}
}
// Delete message.
if ($action == 'delete-message') {
$aid = !empty($items[0]) ? $items[0] : 0;
if (!empty($aid)) {
try {
$activity = $this->entityTypeManager->getStorage('activitypub_activity')->load($aid);
if ($activity) {
$activity->delete();
}
}
catch (\Exception $ignored) {}
}
}
}
/**
* {@inheritdoc}
*/
public function getTimeline($id, $search = NULL) {
if (!$this->getActor()) {
return ['items' => []];
}
return $this->getResponse($id, $search);
}
/**
* Build user table.
*
* @param $query_properties
* @param $empty_message
* @param bool $follow
*
* @return array
*/
protected function buildUserTable($query_properties, $empty_message, bool $follow = TRUE) {
$rows = [];
$activities = [];
try {
/** @var \Drupal\activitypub\Entity\Storage\ActivityPubActivityStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage('activitypub_activity');
$activities = $storage->getActivities($query_properties, [['id', 'DESC']], 20);
}
catch (\Exception $ignored) {}
foreach ($activities as $activity) {
$row = [];
if ($follow) {
$row[] = ['data' => ['#markup' => $activity->getObject() . (!$activity->isPublished() ? '<br /><div class="smaller-text">' . $this->t('Waiting for confirmation') . '</div>' : '')]];
$row[] = Link::fromTextAndUrl($this->t('Unfollow'), Url::fromRoute('reader.sources', [
'module' => 'activitypub',
'op' => 'delete',
], ['query' => ['id' => $activity->id()]]))->toString();
$mute_title = $activity->isMuted() ? $this->t('Unmute') : $this->t('Mute');
$mute_action = $activity->isMuted() ? 'unmute' : 'mute';
$row[] = Link::fromTextAndUrl($mute_title, Url::fromRoute('reader.post.action', [
'module' => 'activitypub',
'action' => $mute_action,
'id' => 'user-table',
'post_id' => $activity->id(),
], ['query' => \Drupal::destination()->getAsArray()]))->toString();
}
else {
$row[] = Link::fromTextAndUrl($activity->getActor(), Url::fromUri($activity->getActor(), ['attributes' => ['target' => '_blank']]))->toString();
$row[] = Link::fromTextAndUrl($this->t('Follow'), Url::fromRoute('reader.sources', [
'module' => 'activitypub',
'op' => 'add',
], ['query' => ['follow' => $activity->getActor(), 'destination' => Url::fromRoute('reader.sources', ['module' => 'activitypub', 'op' => 'followers'])->toString()]]))->toString();
$row[] = \Drupal::service('date.formatter')->format($activity->getCreatedTime());
}
$rows[] = $row;
}
$build = [];
$build['table'] = [
'#type' => 'table',
'#rows' => $rows,
'#empty' => $empty_message,
'#attributes' => ['class' => ['reader-content-table']],
];
$build['pager'] = [
'#type' => 'pager',
];
return $build;
}
/**
* Get the actor for the current user.
*
* @return bool|NULL|\Drupal\activitypub\Entity\ActivitypubActorInterface
*/
protected function getActor() {
if ($this->currentUser->hasPermission('access reader')) {
return $this->actorStorage->loadActorByEntityIdAndType($this->currentUser->id(), 'person');
}
return FALSE;
}
/**
* Get followees.
*
* @return array
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
*/
protected function getFollowees() {
static $loaded = FALSE;
static $items = [];
if (!$loaded) {
$actor = $this->getActor();
$conditions = ['type' => 'Follow', 'status' => 1];
$url = Url::fromRoute('activitypub.user.self', ['user' => $actor->getOwnerId(), 'activitypub_actor' => $actor->getName()], ['absolute' => TRUE])->toString();
$conditions['actor'] = $url;
$conditions['collection'] = ActivityPubActivityInterface::OUTBOX;
/** @var \Drupal\activitypub\Entity\Storage\ActivityPubActivityStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage('activitypub_activity');
$records = $storage->getActivityRecords($conditions);
foreach ($records as $record) {
$items[$record->mute][$record->object] = $record->object;
}
}
return $items;
}
/**
* Get items for a timeline.
*
* @param $id
* The channel id
* @param $search
* The search param.
*
* @return array
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
*/
protected function getResponse($id, $search) {
$items = [];
$paging = [];
$limit = Settings::get('reader_activitypub_timeline_limit', 20);
// Get page.
$page = $this->requestStack->getCurrentRequest()->get('page', 0);
$server = $this->activityPubUtility->getServer();
/** @var \Drupal\activitypub\Entity\ActivityPubActivityInterface[] $activities */
$storage = $this->entityTypeManager->getStorage('activitypub_activity');
$conditions = [
'uid' => $this->currentUser->id(),
'status' => 1,
'collection' => 'inbox',
'type' => $this->activityPubUtility->getTimelineTypes(),
];
if (isset($_SESSION['activitypub_status']) && empty($search)) {
$conditions['is_read'] = 0;
}
switch ($id) {
case 'home':
$conditions['entity_id'] = 'isNull';
$conditions['visibility'] = [ActivityPubActivityInterface::VISIBILITY_PUBLIC, ActivityPubActivityInterface::VISIBILITY_FOLLOWERS];
$following = $this->getFollowees();
if (!empty($following[0])) {
$conditions['actor'] = $following[0];
}
break;
case 'direct':
$conditions['visibility'] = ActivityPubActivityInterface::VISIBILITY_PRIVATE;
break;
case 'notifications':
$conditions['type'] = $this->activityPubUtility->getNotificationTypes();
$conditions['entity_id'] = 'isNotNull';
break;
case 'internal-search':
$conditions['type'] = 'Create';
$conditions['search'] = $search;
break;
default:
$conditions['entity_id'] = 'isNull';
$conditions['visibility'] = ActivityPubActivityInterface::VISIBILITY_PUBLIC;
break;
}
$activities = $storage->getActivities($conditions, [['id', 'DESC']], $limit);
foreach ($activities as $activity) {
if ($item = $this->buildMicrosubItem($activity, $server, $id)) {
$items[] = $item;
}
}
$pager = $this->pagerManager->getPager();
$pager_total = $pager->getTotalPages();
$page++;
if ((isset($pager_total) && is_numeric($pager_total) && $pager_total > $page) || $page > 0) {
$paging = ['after' => $page];
}
$response = ['items' => $items];
if (!empty($paging)) {
$response['paging'] = (object) $paging;
}
return $response;
}
/**
* Build a Microsub item from an activity payload.
*
* @param \Drupal\activitypub\Entity\ActivityPubActivityInterface $activity
* @param \ActivityPhp\Server $server
* @param string $id
*
* @return \stdClass $item
*/
protected function buildMicrosubItem(ActivityPubActivityInterface $activity, Server $server, string $id) {
$payload = json_decode($activity->getPayLoad() ?: '', TRUE);
$context = json_decode($activity->getContext() ?: '', TRUE);
$item = [];
$item['_id'] = $activity->id();
$item['_is_read'] = !($id == 'home' || $id == 'direct' || $id == 'notifications') || $activity->isRead();
$item['type'] = 'entry';
if ($activity->isPrivate()) {
$item['url'] = !empty($payload['object']['id']) ? $payload['object']['id'] : str_replace('/activity', '', $activity->getExternalId());
}
else {
$item['url'] = $activity->getObject();
}
$item['published'] = $this->dateFormatter->format($activity->getCreatedTime(), 'custom','Y-m-dTH:i:s');
// Content and response type.
if ($activity->getType() == 'Like') {
$item['like-of'] = [$activity->getObject()];
}
elseif ($activity->getType() == 'Announce') {
$item['repost-of'] = [$activity->getObject()];
}
elseif ($activity->getType() == 'Follow') {
$item['content'] = (object) ['html' => 'This person is now following you!'];
}
elseif ($activity->getType() == 'Create') {
if (!empty($payload['object']) && is_array($payload['object'])) {
if (!empty($payload['object']['inReplyTo'])) {
$item['in-reply-to'] = [$payload['object']['inReplyTo']];
}
if (!empty($payload['object']['name'])) {
$item['name'] = $payload['object']['name'];
}
if (!empty($payload['object']['content'])) {
$item['content'] = (object) ['html' => $payload['object']['content']];
}
if (!empty($payload['object']['attachment'])) {
$this->handleAttachments($payload['object']['attachment'], $item);
}
}
else {
if (!empty($payload['name'])) {
$item['name'] = $payload['name'];
}
else {
// currently treat as repost
$item['repost-of'] = [$activity->getObject()];
}
}
}
// Context.
if (!empty($context) && (isset($item['repost-of']) || isset($item['like-of']) || isset($item['in-reply-to']))) {
$url = NULL;
if (isset($item['repost-of'])) {
$url = $item['repost-of'][0];
}
elseif (isset($item['like-of'])) {
$url = $item['like-of'][0];
}
elseif (isset($item['in-reply-to'])) {
$url = $item['in-reply-to'][0];
}
if (isset($url) && isset($context['id']) && $context['id'] == $url && empty($activity->getTargetEntityId())) {
$ref = [];
if (!empty($context['name'])) {
$ref['name'] = $context['name'];
}
if (!empty($context['content'])) {
$ref['content'] = (object) ['html' => $context['content']];
}
if (!empty($context['summary'])) {
$ref['summary'] = $context['summary'];
}
if (!empty($context['attachment'])) {
$this->handleAttachments($context['attachment'], $ref);
}
$item['refs'] = new \stdClass();
$item['refs']->{$url} = (object) $ref;
}
}
// Author information.
$name = $photo = '';
try {
$target_actor = $server->actor($activity->getActor());
$name = $target_actor->get('name');
$icon = $target_actor->get('icon');
if (!empty($icon)) {
$photo = $this->activityPubMediaCache->applyImageCache($icon->get('url'));
}
}
catch (\Exception $ignored) {}
$item['author'] = (object) ['url' => $activity->getActor(), 'name' => $name, 'photo' => $photo];
return (object) $item;
}
/**
* Handle attachments.
*
* @param $attachments
* @param $o
*/
protected function handleAttachments($attachments, &$o) {
foreach ($attachments as $attachment) {
if (isset($attachment['mediaType'])) {
switch ($attachment['mediaType']) {
case 'image/jpg':
case 'image/png':
case 'image/jpeg':
case 'image/gif':
if (!isset($o['photo'])) {
$o['photo'] = [];
}
$o['photo'][] = $this->activityPubMediaCache->applyImageCache($attachment['url'], 'cache_attachment');
break;
case 'video/mp4':
if (!isset($o['video'])) {
$o['video'] = [];
}
$o['video'][] = $attachment['url'];
break;
case 'audio/mp3':
if (!isset($o['audio'])) {
$o['audio'] = [];
}
$o['audio'][] = $attachment['url'];
break;
}
}
}
}
}
