Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ It is a partial implementation of ActivityPub and of the Mastodon client API —
- 🔗 **Link previews** — a post that links somewhere gets a preview card (OpenGraph, with the plain title/description as fallback) read by a background job, so nothing waits for a stranger's web server. The fetch is HTTP(S)-only on every hop, refuses local addresses, is size- and time-capped and obeys the instance access list. Cards are never federated — like Mastodon, every instance reads the page itself.
- 🧹 **Retention** — remote statuses older than `retention_days` (default: disabled) that no local user interacted with are pruned together with their cached attachments, from cron or `occ social:stream:prune`; local content is never touched. Configurable in the Social section of the administration settings.
- 🧹 **Accounts are deleted together** — removing a Nextcloud user takes their Fediverse account with it: the actor is tombstoned, what belongs to it is dropped, and a `Delete` is federated so the servers that cached it drop their copies too. Previously the Social account outlived the user, kept resolving over WebFinger and kept receiving deliveries.
- 🔔 **The Nextcloud bell** — a mention, a favourite, a boost, a new follower, a follow request, an edit of a post you boosted, a poll you voted in closing and a post from an account whose bell you rang all reach Nextcloud's own notifications (and its mail digest), each linking into this app's page for the post or the account rather than out to the remote server. A **follow request carries Accept and Decline** on the bell entry itself; answering it there takes the entry down.
- 🔔 **An unread badge that counts** — the Notifications entry in the sidebar shows how many arrived since you last looked, instead of the hard-coded zero it used to show. The position is a Mastodon **marker** (`/api/v1/markers`), kept per timeline on the server, so clearing it on your phone clears it here; `/api/v1/notifications/unread_count` serves the number.
- 🖼️ **Alt text you can actually write** — every attachment in the composer has a description field, and a post carrying an undescribed one says so before it is sent (a nudge, never a refusal). The app has always rendered other servers' alt text; it could not produce any of its own until now.
- ♿ **Usable without a mouse or without sight** — every post is an `article` named after its author, timelines carry a heading, `j`/`k` moves the keyboard rather than only a highlight, the composer is a named text box with a visible focus ring, attachments and the post timestamp are real buttons, like and boost are single toggles that report their state (and do not throw away the focus of whoever pressed them), and every dialog has a name.
Expand Down
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ What it does:
- 👋 A short introduction on the first visit: your address, who to follow, and a way to bring the follows you already have

You can pin your own posts to the top of your profile, bookmark any post, and see which hashtags the instance is using most. This is a partial implementation of ActivityPub and of the Mastodon client API. Blocking, muting and reporting (with a moderation panel in the administration settings) are supported, as are locked accounts with approvable follow requests. Polls are fully supported: create your own, and view and vote on federated ones. Profiles carry an avatar, a banner image and up to four profile metadata fields. Posts that link somewhere get a link preview card. Image (JPEG, PNG, GIF, WebP, AVIF, and HEIC from an iPhone where the server can read it), video (MP4, WebM, QuickTime) and audio attachments are supported, up to ten per post, each with alt text and a focal point. It does not offer lists.]]></description>
<version>0.19.40</version>
<version>0.19.41</version>
<licence>agpl</licence>
<author mail="benedikt.schaechner@web.de" homepage="https://benedikt.xn--schchner-2za.de">Benedikt Schächner</author>
<namespace>Social</namespace>
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "nextcloud/social",
"description": "Social app",
"license": "AGPL-3.0-or-later",
"version": "0.19.40",
"version": "0.19.41",
"minimum-stability": "stable",
"authors": [
{
Expand Down
4 changes: 2 additions & 2 deletions docs/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Nextcloud Social is a federated social networking app built on the W3C ActivityP
**App ID:** `social`
**Namespace:** `OCA\Social`
**License:** AGPL-3.0-or-later
**App version:** 0.19.40
**App version:** 0.19.41
**Supported Nextcloud versions:** 35 – 36
**Supported PHP versions:** 8.3 – 8.5

Expand Down Expand Up @@ -1137,7 +1137,7 @@ anything. `HashtagFollowedList.vue` is the disclosure beneath it.
| Dashboard | `SocialReportsWidget` | `Application::register()` | Open moderation reports; conditional — admins only |
| Dashboard | `SocialFederationHealthWidget` | `Application::register()` | Instances the outbound queue is failing to reach; conditional — admins only |
| Unified Search | `UnifiedSearchProvider` | `Application::register()` | Searches URIs, accounts, hashtags and **status content** (case-insensitive substring over the statuses the viewer may see: own posts, public/unlisted, and what is addressed to them — the timeline viewer bound). Local hits link to the post page, remote hits to their origin. Honours the query's cursor and limit — each source is asked for one entry past the end of the page, and a further page is offered only when one of them supplied it. It used to advertise a next cursor unconditionally while reading neither, so "load more" served the first page for ever |
| Notifications | `Notifier` | `Application::register()` | Prepares Social notifications for the NC notification system |
| Notifications | `Notifier` | `Application::register()` | Prepares Social notifications for the NC notification system: every subject in `NotificationService::SUBJECTS` is worded, linked into this app's own pages (`NotificationService::emit()` builds the link from the post's nid or the account's handle), and a `follow_request` carries Accept/Decline actions that POST to `/api/v1/follow_requests/{id}/authorize` and `/reject`; the answer dismisses the stored row and withdraws the bell entry (`onFollowRequestAnswered()`) |
| User migration | `UserMigration\SocialMigrator` | `Application::register()` | Puts the user's Social data in a Nextcloud account export, and reads it back on import. See "Account export and import" below |
| Profile Page | `ProfileSectionListener` | `Application::register()` (on `BeforeTemplateRenderedEvent`) | Adds the `social-profilePage` script to the user profile page |
| Files | `FilesScriptsListener` | `Application::register()` (on `OCA\Files\Event\LoadAdditionalScriptsEvent`) | Adds the self-contained `social-filesAction` init script, which registers "Share to Social" on pictures and videos |
Expand Down
4 changes: 2 additions & 2 deletions js/social-social.js

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
use OCA\Social\Service\HashtagService;
use OCA\Social\Service\InstanceService;
use OCA\Social\Service\MarkerService;
use OCA\Social\Service\NotificationService;
use OCA\Social\Service\PinService;
use OCA\Social\Service\PlaceService;
use OCA\Social\Service\PollService;
Expand Down Expand Up @@ -193,6 +194,7 @@ public function __construct(
private FediverseService $fediverseService,
private PlaceService $placeService,
private DeliveryService $deliveryService,
private NotificationService $notificationService,
) {
parent::__construct(Application::APP_ID, $request);

Expand Down Expand Up @@ -515,6 +517,8 @@ private function followRequestAction(string $id, bool $authorize): DataResponse
} else {
$this->followService->rejectFollowRequest($follower);
}
// the bell entry that asked comes down with the answer
$this->notificationService->onFollowRequestAnswered($this->viewer, $follower->getId());

return new DataResponse(
$this->followService->getRelationshipWith($follower), Http::STATUS_OK
Expand Down
43 changes: 43 additions & 0 deletions lib/Notification/Notifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,21 @@ public function prepare(INotification $notification, string $languageCode): INot
$l10n->t('%s wants to follow you', [$this->account($params)])
);
$this->point($notification, $params);
$this->offerToAnswer($notification, $params, $l10n);
break;
case 'poll':
$notification->setParsedSubject(
$l10n->t('The poll by %s has ended', [$this->account($params)])
);
$this->point($notification, $params);
break;
case 'status':
// the bell on a profile: a post from an account the reader asked
// to be told about
$notification->setParsedSubject(
$l10n->t('%s posted', [$this->account($params)])
);
$this->point($notification, $params);
break;

case 'update':
Expand Down Expand Up @@ -192,6 +207,34 @@ private function point(INotification $notification, array $params): void {
}
}

/**
* Accept and Decline, on the bell entry itself. Both POST to the routes a
* Mastodon client uses for the same answer, and the answer takes the entry
* down (`NotificationService::onFollowRequestAnswered()`). Offered only
* when the follower's id is known -- an entry raised before it was stored
* still says who asked and links to them.
*/
private function offerToAnswer(INotification $notification, array $params, IL10N $l10n): void {
$nid = (int)($params['nid'] ?? 0);
if ($nid < 1) {
return;
}

$accept = $notification->createAction();
$accept->setLabel('accept')
->setParsedLabel($l10n->t('Accept'))
->setPrimary(true)
->setLink($this->url->linkToRouteAbsolute('social.Api.followRequestAuthorize', ['id' => (string)$nid]), 'POST');
$notification->addAction($accept);

$decline = $notification->createAction();
$decline->setLabel('decline')
->setParsedLabel($l10n->t('Decline'))
->setPrimary(false)
->setLink($this->url->linkToRouteAbsolute('social.Api.followRequestReject', ['id' => (string)$nid]), 'POST');
$notification->addAction($decline);
}

private function isWebUrl(string $url): bool {
return (filter_var($url, FILTER_VALIDATE_URL) !== false)
&& (str_starts_with($url, 'https://') || str_starts_with($url, 'http://'));
Expand Down
104 changes: 94 additions & 10 deletions lib/Service/NotificationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use OCA\Social\Model\ActivityPub\Stream;
use OCA\Social\Model\ActorRelation;
use OCA\Social\Model\Client\Options\ProbeOptions;
use OCP\IURLGenerator;
use OCP\Notification\IManager as INotificationManager;
use Psr\Log\LoggerInterface;

Expand Down Expand Up @@ -68,6 +69,11 @@ class NotificationService {
Follow::TYPE => 'follow',
Follow::TYPE_REQUEST => 'follow_request',
Update::TYPE => 'update',
// the two events this instance raises itself that a reader wants to
// hear about: a poll they voted in closing, and a post from an account
// whose bell they rang
Stream::SUBTYPE_POLL => 'poll',
Stream::SUBTYPE_STATUS => 'status',
];

/** The `object_type` every notification of this app is filed under. */
Expand Down Expand Up @@ -98,6 +104,7 @@ public function __construct(
private AccountRelationService $accountRelationService,
private INotificationManager $notificationManager,
private LoggerInterface $logger,
private IURLGenerator $urlGenerator,
) {
}

Expand Down Expand Up @@ -415,9 +422,13 @@ private function emit(SocialAppNotification $notification, string $actorId): voi

$recipientId = $notification->getTo();
$actorId = ($actorId !== '') ? $actorId : $this->actorOf($notification);
if ($recipientId === '' || $actorId === '' || $recipientId === $actorId) {
if ($recipientId === '' || $actorId === '') {
return;
}
if ($recipientId === $actorId && $subject !== 'poll') {
// somebody boosting their own post, or replying to themselves with
// their own handle in it, is not news to them
// their own handle in it, is not news to them -- their own poll
// closing is, which is also who Mastodon tells
return;
}

Expand All @@ -432,24 +443,97 @@ private function emit(SocialAppNotification $notification, string $actorId): voi
}

$actor = $this->cachedActor($actorId);
$link = in_array($subject, ['follow', 'follow_request'], true)
? $actorId
: $notification->getObjectId();
$parameters = [
'account' => ($actor === null) ? $actorId : $this->labelOf($actor),
// into this app, not at the remote object: a bell entry that opened
// a Mastodon page for a post the reader can see here was the
// complaint in nextcloud/social#1628
'link' => in_array($subject, ['follow', 'follow_request'], true)
? $this->profileLink($actor, $actorId)
: $this->postLink($notification),
'avatar' => ($actor === null) ? '' : $actor->getAvatar(),
];
if ($subject === 'follow_request' && $actor !== null && $actor->getNid() > 0) {
// what Notifier builds the Accept and Decline actions from
$parameters['nid'] = $actor->getNid();
}

$raised = $this->notificationManager->createNotification();
$raised->setApp('social')
->setDateTime(new DateTime('now'))
->setUser($recipient->getUserId())
->setObject(self::OBJECT, $this->objectId($notification->getId()))
->setSubject($subject, [
'account' => ($actor === null) ? $actorId : $this->labelOf($actor),
'link' => $link,
'avatar' => ($actor === null) ? '' : $actor->getAvatar(),
]);
->setSubject($subject, $parameters);

$this->notificationManager->notify($raised);
}

/**
* A follow request was answered: the bell entry that asked is taken down
* with the row behind it, so it does not come back on the next reload.
* Never throws -- the answer is given whether or not the bell hears.
*/
public function onFollowRequestAnswered(Person $viewer, string $followerId): void {
try {
foreach ($this->page($viewer, self::CLEAR_PAGE) as $row) {
if ($row->getSubType() === Follow::TYPE_REQUEST
&& $row->getAttributedTo() === $followerId) {
$this->dismiss($viewer, $row->getNid());
}
}
} catch (Exception $e) {
$this->logger->debug('could not take a follow request off the bell', ['exception' => $e]);
}
}

/** This app's page for an account, or the actor's own address when it has no handle here. */
private function profileLink(?Person $actor, string $actorId): string {
$acct = $this->acctOf($actor);

return ($acct === '') ? $actorId : $this->appLink('@' . $acct);
}

/**
* This app's page for the post a notification is about, or the post's
* own address when it is not one this instance holds.
*/
private function postLink(SocialAppNotification $notification): string {
$post = $notification->getDetailsAll()['post'] ?? null;
if (!($post instanceof Stream)) {
try {
$post = $this->streamRequest->getStreamById($notification->getObjectId());
} catch (Exception $e) {
return $notification->getObjectId();
}
}

$acct = $this->acctOf($this->cachedActor($post->getAttributedTo()));
if ($acct === '') {
try {
$acct = $this->actorsRequest->getFromId($post->getAttributedTo())->getPreferredUsername();
} catch (Exception $e) {
$acct = '';
}
}
if ($acct === '' || $post->getNid() < 1) {
return $notification->getObjectId();
}

return $this->appLink('@' . $acct . '/' . $post->getNid());
}

private function acctOf(?Person $actor): string {
if ($actor === null) {
return '';
}

return ($actor->getAccount() !== '') ? $actor->getAccount() : $actor->getPreferredUsername();
}

private function appLink(string $path): string {
return rtrim($this->urlGenerator->linkToRouteAbsolute('social.Navigation.navigate'), '/') . '/' . $path;
}

/**
* Who acted.
*
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "social",
"description": "Nextcloud becomes part of the federated social networks!",
"version": "0.19.40",
"version": "0.19.41",
"authors": [
{
"name": "Benedikt Schächner",
Expand Down
6 changes: 5 additions & 1 deletion tests/Controller/ApiControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
use OCA\Social\Service\HashtagService;
use OCA\Social\Service\InstanceService;
use OCA\Social\Service\MarkerService;
use OCA\Social\Service\NotificationService;
use OCA\Social\Service\PinService;
use OCA\Social\Service\PlaceService;
use OCA\Social\Service\PollService;
Expand Down Expand Up @@ -138,6 +139,7 @@ class ApiControllerTest extends TestCase {
private EmojiService|MockObject $emojiService;
private PlaceService|MockObject $placeService;
private DeliveryService|MockObject $deliveryService;
private NotificationService|MockObject $notificationService;
private IAppManager|MockObject $appManager;
private FediverseService|MockObject $fediverseService;

Expand Down Expand Up @@ -243,6 +245,7 @@ protected function setUp(): void {
$this->emojiService = $this->createMock(EmojiService::class);
$this->placeService = $this->createMock(PlaceService::class);
$this->deliveryService = $this->createMock(DeliveryService::class);
$this->notificationService = $this->createMock(NotificationService::class);
$this->appManager = $this->createMock(IAppManager::class);
$this->fediverseService = $this->createMock(FediverseService::class);
$this->fediverseService->method('getAccessType')->willReturnCallback(fn (): string => $this->accessType);
Expand Down Expand Up @@ -334,7 +337,8 @@ private function controllerWithHeaders(
$this->appManager,
$this->fediverseService,
$this->placeService,
$this->deliveryService
$this->deliveryService,
$this->notificationService
);
}

Expand Down
3 changes: 2 additions & 1 deletion tests/Notification/NotifierSubjectsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ public function testNothingButAWebUrlBecomesTheIcon(string $avatar): void {
public function testASubjectNothingRaisesIsStillRejected(): void {
$this->expectException(\InvalidArgumentException::class);

$this->notifier->prepare($this->notification('poll', $this->params()), 'en');
// `severed_relationships` reaches the client API but no bell is raised for it
$this->notifier->prepare($this->notification('severed_relationships', $this->params()), 'en');
}
}
Loading
Loading