diff options
| author | winter | 2025-03-16 00:53:52 +0000 |
|---|---|---|
| committer | winter | 2025-03-16 00:53:52 +0000 |
| commit | 145b5db9474e32ca3430fe35eaa7498d7f26ff95 (patch) | |
| tree | df1da516a05cd382e19a815d4e7e6bee314e3007 | |
| parent | a0a86c3a98a6af158ecd2f1923feb752c82907db (diff) | |
implement poking and such
| -rw-r--r-- | Digitigrade/Model/Actor.php | 44 | ||||
| -rw-r--r-- | Digitigrade/Notification/AdminEditedNoteNotif.php | 9 | ||||
| -rw-r--r-- | Digitigrade/Notification/PokeNotif.php | 62 | ||||
| -rw-r--r-- | Digitigrade/PokeVerb.php | 14 | ||||
| -rw-r--r-- | locale/en_GB.json | 29 | ||||
| -rw-r--r-- | misc/send_request_authenticated.php | 5 | ||||
| -rw-r--r-- | routes/actor.php | 19 | ||||
| -rw-r--r-- | routes/actor_profile_fragment.php | 14 | ||||
| -rw-r--r-- | routes/note_fragment.php | 5 | ||||
| -rw-r--r-- | routes/preferences.php | 1 | ||||
| -rw-r--r-- | static/form.css | 27 | ||||
| -rw-r--r-- | templates/actor/profile.php | 8 | ||||
| -rw-r--r-- | templates/actor/profile_poke_form.php | 24 | ||||
| -rw-r--r-- | templates/preferences_form.php | 7 |
14 files changed, 247 insertions, 21 deletions
diff --git a/Digitigrade/Model/Actor.php b/Digitigrade/Model/Actor.php index 0541e1b..18aba15 100644 --- a/Digitigrade/Model/Actor.php +++ b/Digitigrade/Model/Actor.php @@ -3,12 +3,16 @@ namespace Digitigrade\Model; use Digitigrade\Db; use Digitigrade\Notification\PendingFollowActionedNotif; +use Digitigrade\Notification\PokeNotif; use Digitigrade\Notification\UnblockNotif; use Digitigrade\Notification\UnfollowNotif; +use Digitigrade\PokeVerb; use Digitigrade\RpcException; use Digitigrade\RpcReceiver; class Actor extends PushableModel implements RpcReceiver { + public const EXTENSION_POKE = 'https://pawpub.entities.org.uk/extension/poke'; + public ?int $id; public string $uri; public bool $isLocal = false; @@ -135,15 +139,15 @@ class Actor extends PushableModel implements RpcReceiver { return self::findByUri($uri); } - public function rpcCall(string $method, array $args) { - // $args = [Actor $actingAs] + public function rpcCall(string $method, array $args, string $customEndpoint = null) { + // $args = [Actor $actingAs, ?string $requestBody] if (!$this->isLocal) { // trigger the same action on the remote server - $endpoint = $this->endpoints->{$method} ?? null; + $endpoint = $customEndpoint ?? $this->endpoints->{$method} ?? null; if (!isset($endpoint)) { throw new RpcException("Actor `$this->uri` has no $method endpoint"); } - $result = send_request_authenticated($endpoint, method: 'POST', actingAs: $args[0]); + $result = send_request_authenticated($endpoint, method: 'POST', actingAs: $args[0], body: $args[1] ?? null); if (!str_contains($result->headers[0], ' 20')) { throw new RpcException('remote call did not indicate success: ' . $result->headers[0]); } @@ -188,6 +192,16 @@ class Actor extends PushableModel implements RpcReceiver { (new UnblockNotif($args[0], $this))->processNotifications(); return; + case 'poke': + $obj = json_decode($args[1] ?? ''); + (new PokeNotif( + $args[0], + PokeVerb::tryFrom($obj?->verb ?? '') ?? PokeVerb::POKE, + $this, + $obj?->urgent ?? false + ))->processNotifications(); + return; + default: throw new \RuntimeException("behaviour for actor rpc method $method not (yet?) implemented"); } @@ -329,6 +343,25 @@ class Actor extends PushableModel implements RpcReceiver { return '/@/' . $this->getFullHandle(); } + private function getPokeEndpoint(): ?string { + return $this->extensions[self::EXTENSION_POKE] ?? null; + } + + public function poke(self $target, PokeVerb $verb = PokeVerb::POKE, bool $urgent = false) { + $target->rpcCall('poke', [$this, json_encode([ + 'verb' => $verb->value, + 'urgent' => $urgent + ])], $target->getPokeEndpoint()); + } + + public function isPokeable(): bool { + return isset($this->extensions[self::EXTENSION_POKE]); + } + + public function setPokeable(bool $pokeable) { + $this->extensions[self::EXTENSION_POKE] = $pokeable ? path_to_uri("/actor/$this->id/poke") : null; + } + public function jsonSerialize(): array { if ($this->deleted) { return [ @@ -359,7 +392,8 @@ class Actor extends PushableModel implements RpcReceiver { 'rejectedFollow' => path_to_uri("/actor/$this->id/rejectedFollow"), 'block' => path_to_uri("/actor/$this->id/block"), 'unblock' => path_to_uri("/actor/$this->id/unblock"), - ] + ], + 'extensions' => $this->extensions ]; } } diff --git a/Digitigrade/Notification/AdminEditedNoteNotif.php b/Digitigrade/Notification/AdminEditedNoteNotif.php index f3c18a1..888130b 100644 --- a/Digitigrade/Notification/AdminEditedNoteNotif.php +++ b/Digitigrade/Notification/AdminEditedNoteNotif.php @@ -2,6 +2,7 @@ namespace Digitigrade\Notification; use Digitigrade\Model\Note; +use Digitigrade\Model\Notification; use Digitigrade\Model\UserAccount; class AdminEditedNoteNotif implements Notifyable { @@ -22,7 +23,7 @@ class AdminEditedNoteNotif implements Notifyable { } public function getNotificationTitle(): string { - return __f('notification.adminEditedNote', $this->admin->actor->displayName); + return __f('notifications.adminEditedNote', $this->admin->actor->displayName); } public function getNotificationTitleLink(): ?string { @@ -40,4 +41,10 @@ class AdminEditedNoteNotif implements Notifyable { public function getNotificationImageLink(): ?string { return $this->admin->actor->getLocalUiHref(); } + + public function processNotifications() { + if (!$this->note->author->isLocal) + return; + Notification::fromNotifyable($this, UserAccount::findByLinkedActor($this->note->author))->send(); + } }
\ No newline at end of file diff --git a/Digitigrade/Notification/PokeNotif.php b/Digitigrade/Notification/PokeNotif.php new file mode 100644 index 0000000..5446098 --- /dev/null +++ b/Digitigrade/Notification/PokeNotif.php @@ -0,0 +1,62 @@ +<?php +namespace Digitigrade\Notification; + +use Digitigrade\Model\Actor; +use Digitigrade\Model\Notification; +use Digitigrade\Model\UserAccount; +use Digitigrade\PokeVerb; + +class PokeNotif implements Notifyable { + private Actor $subject; + private PokeVerb $verb; + private Actor $object; + private bool $urgent; + + public function __construct(Actor $subject, PokeVerb $verb, Actor $object, bool $urgent) { + $this->subject = $subject; + $this->verb = $verb; + $this->object = $object; + $this->urgent = $urgent; + } + + public function toJsonReference(): mixed { + return [$this->subject->id, $this->verb->value, $this->object->id, $this->urgent]; + } + + public static function fromJsonReference(mixed $reference): ?self { + return new self( + Actor::find($reference[0]), + PokeVerb::from($reference[1]), + Actor::find($reference[2]), + $reference[3] + ); + } + + public function getNotificationTitle(): string { + $kind = $this->urgent ? 'urgent' : 'normal'; + $verb = $this->verb->value; + return __f("notifications.poke.$kind", $this->subject->displayName, __("notifications.poke.verb.$verb")); + } + + public function getNotificationTitleLink(): ?string { + return $this->subject->getLocalUiHref(); + } + + public function getNotificationBody(): ?string { + return null; + } + + public function getNotificationImageUrl(): ?string { + return $this->subject->avatar ?? '/static/default-avatar.png'; + } + + public function getNotificationImageLink(): ?string { + return $this->subject->getLocalUiHref(); + } + + public function processNotifications() { + if (!$this->object->isLocal) + return; + Notification::fromNotifyable($this, UserAccount::findByLinkedActor($this->object))->send(); + } +}
\ No newline at end of file diff --git a/Digitigrade/PokeVerb.php b/Digitigrade/PokeVerb.php new file mode 100644 index 0000000..6490b78 --- /dev/null +++ b/Digitigrade/PokeVerb.php @@ -0,0 +1,14 @@ +<?php +namespace Digitigrade; + +enum PokeVerb: string { + case POKE = 'poke'; + case BITE = 'bite'; + case PUNCH = 'punch'; + case KICK = 'kick'; + case WAVE = 'wave'; + case YELL = 'yell'; + case HUG = 'hug'; + case KISS = 'kiss'; + case PET = 'pet'; +}
\ No newline at end of file diff --git a/locale/en_GB.json b/locale/en_GB.json index f430064..001aad6 100644 --- a/locale/en_GB.json +++ b/locale/en_GB.json @@ -24,6 +24,20 @@ "user.profile.notesHidden": "Notes hidden because you block this user", "user.profile.feed.rss": "RSS feed", "user.profile.feed.atom": "Atom feed", + "user.profile.poke": "Get this user's attention", + "user.profile.poke.verb.label": "Verb", + "user.profile.poke.verb.poke": "Poke", + "user.profile.poke.verb.bite": "Bite", + "user.profile.poke.verb.punch": "Punch", + "user.profile.poke.verb.kick": "Kick", + "user.profile.poke.verb.wave": "Wave at", + "user.profile.poke.verb.yell": "Yell at", + "user.profile.poke.verb.hug": "Hug", + "user.profile.poke.verb.kiss": "Kiss", + "user.profile.poke.verb.pet": "Pet", + "user.profile.poke.urgent.label": "Urgent", + "user.profile.poke.action": "Send", + "user.profile.poke.success": "Sent!", "user.notes.placeholder": "This user hasn't posted anything yet.", "user.rss.description": "Public notes by %s", "timeline.global": "Global timeline", @@ -85,7 +99,18 @@ "notifications.follow.reject": "%s rejected your follow request", "notifications.block": "%s blocked you", "notifications.unblock": "%s unblocked you", - "notification.adminEditedNote": "An admin (%s) edited your note", + "notifications.adminEditedNote": "An admin (%s) edited your note", + "notifications.poke.normal": "%s %s you", + "notifications.poke.urgent": "%s %s you urgently", + "notifications.poke.verb.poke": "poked", + "notifications.poke.verb.bite": "bit", + "notifications.poke.verb.punch": "punched", + "notifications.poke.verb.kick": "kicked", + "notifications.poke.verb.wave": "waved at", + "notifications.poke.verb.yell": "yelled at", + "notifications.poke.verb.hug": "hugged", + "notifications.poke.verb.kiss": "kissed", + "notifications.poke.verb.pet": "petted", "login.pageTitle": "Log in", "login.email": "Email address", "login.password": "Password", @@ -125,6 +150,8 @@ "preferences.timezone.auto": "Set automatically", "preferences.rssEnabled.name": "Enable RSS and Atom feeds", "preferences.rssEnabled.description": "Provides a feed of your public notes, usable with common feed reader apps and online aggregators", + "preferences.pokeable.name": "Allow others to poke you", + "preferences.pokeable.description": "Also includes other verbs such as bite, wave at, hug, etc.", "changePassword.heading": "Change password", "changePassword.currentPassword": "Current password", "changePassword.newPassword": "New password", diff --git a/misc/send_request_authenticated.php b/misc/send_request_authenticated.php index 9019da8..6e74cac 100644 --- a/misc/send_request_authenticated.php +++ b/misc/send_request_authenticated.php @@ -16,7 +16,8 @@ function send_request_authenticated( string $uri, bool $dontLookUpInstance = false, string $method = 'GET', - ?Actor $actingAs = null + ?Actor $actingAs = null, + ?string $body = null ) { if (!str_starts_with($uri, 'https://')) { throw new RuntimeException('refusing to fetch a non-https uri'); @@ -34,7 +35,7 @@ function send_request_authenticated( if (!isset($instance->auth->outboundToken) || $instance->auth->outboundToken == null) { // hopefully we can make the request anyway? - $context = stream_context_create(['http' => ['method' => $method]]); + $context = stream_context_create(['http' => ['method' => $method, 'content' => $body]]); $resp = file_get_contents($uri); // $http_response_header just poofs into existence . // i don't like this api. i should probably use curl instead. hmm diff --git a/routes/actor.php b/routes/actor.php index d0bb066..a459640 100644 --- a/routes/actor.php +++ b/routes/actor.php @@ -1,5 +1,6 @@ <?php +use Digitigrade\HttpResponseStatus\Forbidden; use Digitigrade\HttpResponseStatus\NotFound; use Digitigrade\HttpResponseStatus\PolicyRejected; use Digitigrade\HttpResponseStatus\TemporaryRedirect; @@ -9,6 +10,7 @@ use Digitigrade\Model\FollowRelationStatus; use Digitigrade\Model\Instance; use Digitigrade\Model\Note; use Digitigrade\Model\NotePrivacyScope; +use Digitigrade\PokeVerb; use Digitigrade\PolicyManager; use Digitigrade\Router; @@ -147,4 +149,21 @@ Router::getInstance()->mount('/actor/:id/unblock', function (array $args) { throw new Unauthorized('please authenticate yourself on behalf of the initiating actor'); } $initiator->unblock($target); +}); + +Router::getInstance()->mount('/actor/:id/poke', function (array $args) { + $target = Actor::find($args['id']); + if ($target == null || !$target->isLocal || $target->deleted) { + throw new NotFound(); + } + if (!$target->isPokeable()) { + throw new Forbidden('you may not poke this user!'); + } + $initiator = Actor::findByRequestHeaders(); + if ($initiator == null) { + throw new Unauthorized('please authenticate yourself on behalf of the initiating actor'); + } + $body = file_get_contents('php://input'); + $obj = json_decode($body); + $initiator->poke($target, PokeVerb::tryFrom($obj?->verb ?? '') ?? PokeVerb::POKE, $obj?->urgent ?? false); });
\ No newline at end of file diff --git a/routes/actor_profile_fragment.php b/routes/actor_profile_fragment.php index d189f87..65f7a49 100644 --- a/routes/actor_profile_fragment.php +++ b/routes/actor_profile_fragment.php @@ -1,9 +1,11 @@ <?php +use Digitigrade\HttpResponseStatus\Forbidden; use Digitigrade\HttpResponseStatus\PolicyRejected; use Digitigrade\Model\Actor; use Digitigrade\Model\StorageObject; use Digitigrade\Model\UserAccount; +use Digitigrade\PokeVerb; use Digitigrade\PolicyManager; use Digitigrade\Router; use Digitigrade\StorageProvider\FilesystemStorage; @@ -62,6 +64,18 @@ Router::getInstance()->mount('/fragment/actor/:id/blockButton', function (array render_template('actor/profile_block_button', ['user' => $user, 'actor' => $actor]); }); +Router::getInstance()->mount('/fragment/actor/:id/poke', function (array $args) { + $user = UserAccount::requireByCurrentSession(); + $actor = Actor::find($args['id']); + + if (!$actor->isPokeable()) { + throw new Forbidden("can't poke that user"); + } + $user->actor->poke($actor, PokeVerb::from($_POST['verb']), isset($_POST['urgent'])); + + render_template('actor/profile_poke_form', ['actor' => $actor, 'poked' => true]); +}); + Router::getInstance()->mount('/fragment/profile', function (array $args) { $user = UserAccount::requireByCurrentSession(); $actor = $user->actor; diff --git a/routes/note_fragment.php b/routes/note_fragment.php index 907260d..3d30a7a 100644 --- a/routes/note_fragment.php +++ b/routes/note_fragment.php @@ -142,10 +142,7 @@ Router::getInstance()->mount('/fragment/note/:id/edit', function (array $args) { $note->publish(); if ($user->actor != $note->author) { - Notification::fromNotifyable( - new AdminEditedNoteNotif($user, $note), - UserAccount::findByLinkedActor($note->author) - )->send(); + (new AdminEditedNoteNotif($user, $note))->processNotifications(); } render_template('note/note', ['note' => $note]); diff --git a/routes/preferences.php b/routes/preferences.php index 99b8458..95e7228 100644 --- a/routes/preferences.php +++ b/routes/preferences.php @@ -16,6 +16,7 @@ Router::getInstance()->mount('/fragment/preferences', function (array $args) { if ($_SERVER['REQUEST_METHOD'] == 'POST') { // updating the preferences $user->actor->requestToFollow = $_POST['requestToFollow'] == 'true'; + $user->actor->setPokeable($_POST['pokeable'] == 'true'); $user->actor->save(); $settings->set('interface.smallUnreadIndicators', $_POST['smallIndicators']); $settings->set('locale.timezone', $_POST['timezone']); diff --git a/static/form.css b/static/form.css index bb8655d..39f25cf 100644 --- a/static/form.css +++ b/static/form.css @@ -1,19 +1,23 @@ form:not(.nopanel) { - border: var(--border); - background: var(--clr-panel-background); - border-radius: var(--border-radius); - margin: var(--spacing-double); - padding: var(--spacing-double); display: grid; grid-auto-flow: row; gap: var(--spacing-single); max-width: max-content; + &:not(.nobackground) { + border: var(--border); + background: var(--clr-panel-background); + border-radius: var(--border-radius); + margin: var(--spacing-double); + padding: var(--spacing-double); + } + .row:has(> .column) { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: var(--spacing-single); + align-items: baseline; } label:has(+ input, + textarea, + select), @@ -156,6 +160,19 @@ form:not(.nopanel) { } } + &.pokeForm { + .column { + display: flex; + align-items: center; + } + label { + display: inline; + font-size: inherit; + color: inherit; + margin-right: var(--spacing-single); + } + } + &.settings { padding: 0; gap: 0; diff --git a/templates/actor/profile.php b/templates/actor/profile.php index be27c86..1e8e51c 100644 --- a/templates/actor/profile.php +++ b/templates/actor/profile.php @@ -1,4 +1,6 @@ <?php +/** @var \Digitigrade\Model\Actor $actor */ + use Digitigrade\Model\UserAccount; use Digitigrade\UserSettings; @@ -72,6 +74,12 @@ $actorUser = UserAccount::findByLinkedActor($actor); <?php endif; ?> <?php call_template('actor/profile_follow_button', ['actor' => $actor, 'user' => $user]); ?> </div> + <?php if ($actor->isPokeable()): ?> + <details> + <summary><?= __('user.profile.poke') ?></summary> + <?php call_template('actor/profile_poke_form', ['actor' => $actor]); ?> + </details> + <?php endif; ?> <?php elseif ($actor->id == $user->actor->id): ?> <div class="profileActions"> <button class="primary" hx-get="/fragment/profile" hx-target="closest .fullProfile" hx-swap="outerHTML"> diff --git a/templates/actor/profile_poke_form.php b/templates/actor/profile_poke_form.php new file mode 100644 index 0000000..7dcc360 --- /dev/null +++ b/templates/actor/profile_poke_form.php @@ -0,0 +1,24 @@ +<?php /** @var \Digitigrade\Model\Actor $actor */ ?> +<form class="nobackground pokeForm" hx-post="/fragment/actor/<?= $actor->id ?>/poke" hx-disabled-elt="find button"> + <div class="row"> + <div class="column"> + <select id="pokeVerb-<?= $actor->id ?>" name="verb"> + <?php foreach (['poke', 'bite', 'punch', 'kick', 'wave', 'yell', 'hug', 'kiss', 'pet'] as $verb): ?> + <option value="<?= $verb ?>"><?= __("user.profile.poke.verb.$verb") ?></option> + <?php endforeach; ?> + </select> + </div> + <div class="column"> + <label for="pokeUrgent-<?= $actor->id ?>"><?= __('user.profile.poke.urgent.label') ?></label> + <input type="checkbox" id="pokeUrgent-<?= $actor->id ?>" name="urgent"> + </div> + <div class="column"> + <button type="submit" class="primary"><?= __('user.profile.poke.action') ?></button> + </div> + <div class="column"> + <?php if ($poked ?? false): ?> + <span class="temporaryIndicator"><?= __('user.profile.poke.success') ?></span> + <?php endif; ?> + </div> + </div> +</form>
\ No newline at end of file diff --git a/templates/preferences_form.php b/templates/preferences_form.php index d1694eb..afd487b 100644 --- a/templates/preferences_form.php +++ b/templates/preferences_form.php @@ -15,14 +15,15 @@ function _user_pref(string $id, mixed $value, string $type) { } ?> -<form class="settings" method="post" action="/todo" hx-post="/fragment/preferences" hx-disabled-elt="find button" - hx-swap="outerHTML"> +<form class="settings" method="post" action="/todo" hx-post="/fragment/preferences" + hx-disabled-elt="find <button[type='submit']/>" hx-swap="outerHTML"> <?php _user_pref('requestToFollow', $user->actor->requestToFollow, 'boolean'); _user_pref('smallIndicators', $settings->getBool('interface.smallUnreadIndicators'), 'boolean'); _user_pref('timezone', $settings->get('locale.timezone'), 'timezone'); _user_pref('rssEnabled', $settings->getBool('profile.rssEnabled'), 'boolean'); + _user_pref('pokeable', $user->actor->isPokeable(), 'boolean'); ?> <div class="row"> @@ -31,7 +32,7 @@ function _user_pref(string $id, mixed $value, string $type) { <span class="temporaryIndicator"><?= __('form.saved') ?></span> <?php endif; ?> </div> - <button class="primary"><?= __('form.saveChanges') ?></button> + <button class="primary" type="submit"><?= __('form.saveChanges') ?></button> </div> </form>
\ No newline at end of file |
