aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorwinter2025-01-26 21:53:26 +0000
committerwinter2025-01-26 21:53:26 +0000
commit6b58bf53e312a67119289b894c5b482d057e5665 (patch)
treefef168089a75d73353af1a79456e711c72385ca3
parent7d3f35b1895dd3fe08af0f5fe403ff339acd2456 (diff)
make mentions work properly + ui for them
-rw-r--r--Digitigrade/Model.php10
-rw-r--r--Digitigrade/Model/Note.php16
-rw-r--r--locale/en_GB.json5
-rw-r--r--routes/note_fragment.php28
-rw-r--r--static/form.css5
-rw-r--r--static/icons.css12
-rw-r--r--static/misc.css1
-rw-r--r--static/note.css13
-rw-r--r--static/pills.css49
-rw-r--r--static/style.css1
-rw-r--r--templates/note.php13
-rw-r--r--templates/notification.php2
-rw-r--r--templates/reply_form.php14
-rw-r--r--templates/user_mention_pill.php31
-rw-r--r--templates/write_note_form.php4
15 files changed, 192 insertions, 12 deletions
diff --git a/Digitigrade/Model.php b/Digitigrade/Model.php
index 9ce759a..14d025d 100644
--- a/Digitigrade/Model.php
+++ b/Digitigrade/Model.php
@@ -139,6 +139,13 @@ abstract class Model {
}
/**
+ * Saves fields of the model that couldn't be saved automatically
+ * @return void
+ */
+ protected function dehydrate() {
+ }
+
+ /**
* Checks that all fields of the model are valid and allowed
* @return bool true if the model is valid, false otherwise
*/
@@ -308,6 +315,9 @@ abstract class Model {
}
}
+ // anything else that needs saving right now
+ $this->dehydrate();
+
$this->saved = true;
}
diff --git a/Digitigrade/Model/Note.php b/Digitigrade/Model/Note.php
index 7da6d8e..01bc861 100644
--- a/Digitigrade/Model/Note.php
+++ b/Digitigrade/Model/Note.php
@@ -83,6 +83,10 @@ class Note extends PushableModel implements TimelineIncludeable, Notifyable {
$this->attachments = NoteAttachment::findAllWhere('note_id = ?', [$this->id]);
}
+ protected function dehydrate() {
+ $this->saveMentions();
+ }
+
protected function validate(): bool {
// author has to be from the same instance as the note itself
return hostname_from_uri($this->author->uri) == hostname_from_uri($this->uri);
@@ -105,6 +109,14 @@ class Note extends PushableModel implements TimelineIncludeable, Notifyable {
return $actors;
}
+ private function saveMentions() {
+ $pdo = Db::getInstance()->getPdo();
+ foreach ($this->mentions as $mention) {
+ $stmt = $pdo->prepare('INSERT INTO note_mention(note_id, actor_id) VALUES (?, ?) ON CONFLICT DO NOTHING');
+ $stmt->execute([$this->id, $mention->id]);
+ }
+ }
+
public static function findAllWithAuthor(Actor $author, ?int $limit = null, int $offset = 0, bool $includeDeleted = false): array {
$where = 'author = ?';
if (!$includeDeleted) {
@@ -197,8 +209,8 @@ class Note extends PushableModel implements TimelineIncludeable, Notifyable {
}
public function getNotificationTitle(): string {
- // assuming that this notification is because the note is a reply for now
- return sprintf(__('notifications.interaction.reply.title'), $this->author->displayName);
+ $action = isset($this->inReplyTo) ? 'interaction.reply' : 'mention';
+ return sprintf(__("notifications.$action.title"), $this->author->displayName);
}
public function getNotificationTitleLink(): ?string {
return '/@/' . $this->author->getFullHandle() . '/note/' . $this->id;
diff --git a/locale/en_GB.json b/locale/en_GB.json
index 233860c..c8c8f9f 100644
--- a/locale/en_GB.json
+++ b/locale/en_GB.json
@@ -35,6 +35,7 @@
"note.action.reshare": "Reshare",
"note.action.reply": "Reply",
"note.info.replyTo": "In reply to %s",
+ "note.info.mentions": "Mentions %s",
"note.privacy.scope.none": "Private",
"note.privacy.scope.mutuals": "Mutuals only",
"note.privacy.scope.followers": "Followers only",
@@ -54,6 +55,7 @@
"notifications.interaction.dislike.title": "%s disliked your note",
"notifications.interaction.reshare.title": "%s reshared your note",
"notifications.interaction.reply.title": "%s replied to you",
+ "notifications.mention.title": "%s mentioned you",
"notifications.follow.active": "%s followed you",
"notifications.follow.pending": "%s would like to follow you",
"notifications.follow.removed": "%s unfollowed you",
@@ -71,6 +73,9 @@
"writeNote.summary.label": "Content warning (optional)",
"writeNote.scope.label": "Visibility",
"writeNote.action": "Post",
+ "writeNote.mentions.label": "Mentioned users",
+ "writeNote.mentions.add.action": "Add a mention",
+ "writeNote.mentions.placeholder": "someone@social.example",
"writeReply.label": "Write a reply",
"writeReply.action": "Reply",
"followRequests.pageTitle": "Pending follow requests",
diff --git a/routes/note_fragment.php b/routes/note_fragment.php
index 02399e6..74e987e 100644
--- a/routes/note_fragment.php
+++ b/routes/note_fragment.php
@@ -1,6 +1,7 @@
<?php
use Digitigrade\HttpResponseStatus\BadRequest;
+use Digitigrade\Model\Actor;
use Digitigrade\Model\Interaction;
use Digitigrade\Model\InteractionKind;
use Digitigrade\Model\Note;
@@ -56,8 +57,9 @@ Router::getInstance()->mount('/fragment/note/:id/reply', function (array $args)
);
$reply->inReplyTo = $note;
$reply->threadApex = $note->threadApex ?? $note;
- // TODO: do mentions properly and not just like this
- $reply->mentions = [$note->author];
+ foreach (($_POST['mentions'] ?? []) as $uid) {
+ $reply->mentions[] = Actor::find($uid);
+ }
$reply->save();
$reply->publish();
$reply->processTimelineAdditions();
@@ -80,9 +82,31 @@ Router::getInstance()->mount('/fragment/note', function (array $args) {
$summary,
NotePrivacyScope::from($_POST['scope'])
);
+ foreach (($_POST['mentions'] ?? []) as $uid) {
+ $note->mentions[] = Actor::find($uid);
+ }
+ $note->save();
$note->publish();
$note->processTimelineAdditions();
$note->processNotifications();
}
render_template('write_note_form', ['actor' => $author->actor]);
+});
+
+Router::getInstance()->mount('/fragment/note/mentionPill', function (array $args) {
+ // adding a mention to a new post
+ if (isset($_GET['handle'])) {
+ // look up and return a pill for the corresponding actor
+ $actor = Actor::findLocalByHandle($_GET['handle']) ?? Actor::findByWebfinger($_GET['handle']);
+ if ($actor == null) {
+ // too bad
+ render_template('user_mention_pill', ['type' => 'edit', 'invalid' => true, 'value' => $_GET['handle']]);
+ } else {
+ render_template('user_mention_pill', ['type' => 'user', 'actor' => $actor]);
+ render_template('user_mention_pill', ['type' => 'add']);
+ }
+ } else {
+ // start editing
+ render_template('user_mention_pill', ['type' => 'edit']);
+ }
}); \ No newline at end of file
diff --git a/static/form.css b/static/form.css
index 95f13ac..4683929 100644
--- a/static/form.css
+++ b/static/form.css
@@ -1,4 +1,4 @@
-form {
+form:not(.nopanel):not([hidden]) {
border: var(--border);
background: var(--clr-panel-background);
border-radius: var(--border-radius);
@@ -9,7 +9,8 @@ form {
gap: var(--spacing-single);
max-width: max-content;
- label:has(+ input, + textarea, + select) {
+ label:has(+ input, + textarea, + select),
+ label.standalone {
display: block;
font-size: var(--font-small-size);
color: var(--clr-lesser-foreground);
diff --git a/static/icons.css b/static/icons.css
index cc349c6..39755a2 100644
--- a/static/icons.css
+++ b/static/icons.css
@@ -81,4 +81,16 @@
&.tune {
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M11 21v-6h2v2h8v2h-8v2zm-8-2v-2h6v2zm4-4v-2H3v-2h4V9h2v6zm4-2v-2h10v2zm4-4V3h2v2h4v2h-4v2zM3 7V5h10v2z'/%3E%3C/svg%3E");
}
+ &.add {
+ --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M11 13H5v-2h6V5h2v6h6v2h-6v6h-2z'/%3E%3C/svg%3E");
+ }
+ &.close-small {
+ --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='m8.382 17.025l-1.407-1.4L10.593 12L6.975 8.4L8.382 7L12 10.615L15.593 7L17 8.4L13.382 12L17 15.625l-1.407 1.4L12 13.41z'/%3E%3C/svg%3E");
+ }
+ &.edit-outline {
+ --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M5 19h1.425L16.2 9.225L14.775 7.8L5 17.575zm-2 2v-4.25L16.2 3.575q.3-.275.663-.425t.762-.15t.775.15t.65.45L20.425 5q.3.275.438.65T21 6.4q0 .4-.137.763t-.438.662L7.25 21zM19 6.4L17.6 5zm-3.525 2.125l-.7-.725L16.2 9.225z'/%3E%3C/svg%3E");
+ }
+ &.alternate-email {
+ --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12v1.45q0 1.475-1.012 2.513T18.5 17q-.875 0-1.65-.375t-1.3-1.075q-.725.725-1.638 1.088T12 17q-2.075 0-3.537-1.463T7 12t1.463-3.537T12 7t3.538 1.463T17 12v1.45q0 .65.425 1.1T18.5 15t1.075-.45t.425-1.1V12q0-3.35-2.325-5.675T12 4T6.325 6.325T4 12t2.325 5.675T12 20h5v2zm0-7q1.25 0 2.125-.875T15 12t-.875-2.125T12 9t-2.125.875T9 12t.875 2.125T12 15'/%3E%3C/svg%3E");
+ }
}
diff --git a/static/misc.css b/static/misc.css
index 8f37d21..0c18970 100644
--- a/static/misc.css
+++ b/static/misc.css
@@ -54,6 +54,7 @@ button.material-symbols {
span.inlineIcon {
width: 1em;
height: 1em;
+ align-self: center;
}
span.unreadIndicator {
diff --git a/static/note.css b/static/note.css
index 3051488..28f6fff 100644
--- a/static/note.css
+++ b/static/note.css
@@ -114,12 +114,17 @@ article.note {
}
}
- .replyInfo a {
+ .replyInfo {
font-size: var(--font-small-size);
color: var(--clr-lesser-foreground);
- text-decoration: none;
- &:hover {
- text-decoration: underline;
+ display: flex;
+ gap: var(--spacing-half);
+ a {
+ color: inherit;
+ text-decoration: none;
+ &:hover {
+ text-decoration: underline;
+ }
}
}
diff --git a/static/pills.css b/static/pills.css
new file mode 100644
index 0000000..af670b8
--- /dev/null
+++ b/static/pills.css
@@ -0,0 +1,49 @@
+.inlinePill {
+ display: inline-grid;
+ grid-auto-flow: column;
+ align-items: center;
+ gap: var(--spacing-single);
+ padding: var(--spacing-half) var(--spacing-single);
+ margin: var(--spacing-single) var(--spacing-half) 0 0;
+ background: var(--clr-secondary);
+ color: var(--clr-foreground-on-secondary);
+ border-radius: var(--border-radius);
+ width: max-content !important;
+
+ &:not(:has(+ .inlinePill)) {
+ margin-bottom: var(--spacing-single);
+ }
+
+ &.invalid {
+ outline: 2px solid var(--clr-negative);
+ }
+
+ &[disabled] {
+ opacity: 0.8;
+ }
+
+ .pillIcon {
+ width: var(--icon-size);
+ aspect-ratio: 1;
+ border-radius: var(--border-radius);
+ }
+
+ button& {
+ border: none;
+ font-family: inherit;
+ font-size: inherit;
+ cursor: pointer;
+ }
+
+ form& {
+ input.pillInput {
+ border: none !important;
+ border-radius: 0 !important;
+ border-bottom: var(--border) !important;
+ background: none !important;
+ margin: 0 !important;
+ padding: 0 !important;
+ max-width: 50vw;
+ }
+ }
+}
diff --git a/static/style.css b/static/style.css
index 3861ed9..54128ac 100644
--- a/static/style.css
+++ b/static/style.css
@@ -4,6 +4,7 @@
@import url(notifications.css);
@import url(note.css);
@import url(form.css);
+@import url(pills.css);
@import url(profile.css);
@import url(mobilepanes.css);
@import url(misc.css);
diff --git a/templates/note.php b/templates/note.php
index d892656..e5c2942 100644
--- a/templates/note.php
+++ b/templates/note.php
@@ -1,4 +1,5 @@
<?php
+use Digitigrade\Model\Actor;
use Digitigrade\Model\InteractionKind;
use Digitigrade\Model\NotePrivacyScope;
use Digitigrade\Model\UserAccount;
@@ -47,12 +48,22 @@ if ($user != null) {
<?php if (isset($note->inReplyTo)): ?>
<div class="replyInfo">
+ <span class="material-symbols reply inlineIcon"></span>
<a href="/@/<?= $note->inReplyTo->author->getFullHandle() ?>/note/<?= $note->inReplyTo->id ?>">
- <span class="material-symbols reply inlineIcon"></span>
<?= sprintf(__('note.info.replyTo'),
'<b>' . htmlspecialchars($note->inReplyTo->author->displayName)) . '</b>' ?>
</a>
</div>
+ <?php elseif (count($note->mentions) > 0): ?>
+ <div class="replyInfo">
+ <span class="material-symbols alternate-email inlineIcon"></span>
+ <span>
+ <?= sprintf(__('note.info.mentions'), implode(', ', array_map(
+ fn(Actor $m) => '<b><a href="/@/' . $m->getFullHandle() . '">' . $m->displayName . '</a></b>',
+ $note->mentions
+ ))) ?>
+ </span>
+ </div>
<?php endif; ?>
<div class="content">
diff --git a/templates/notification.php b/templates/notification.php
index a23a3a6..d787f5e 100644
--- a/templates/notification.php
+++ b/templates/notification.php
@@ -20,7 +20,7 @@
<?php endif; ?>
<span class="body"><?= htmlspecialchars($body) ?></span>
</div>
- <div hidden class="notifier" <?php if ($unread): ?> _="
+ <div hidden class="notifier" <?php if ($unread ?? false): ?> _="
on load
set title to innerText of previous .title
set body to innerText of previous .body
diff --git a/templates/reply_form.php b/templates/reply_form.php
index 3434472..57a0dd0 100644
--- a/templates/reply_form.php
+++ b/templates/reply_form.php
@@ -1,3 +1,8 @@
+<?php
+use Digitigrade\Model\UserAccount;
+
+$user = UserAccount::findByCurrentSession();
+?>
<form class="noteReplyForm" id="replyForm-<?= $note->id ?>"
_="on submit send toggle to previous <button.reply/> then wait 1s then trigger update on #liveTimelineUpdater on toggle or submit toggle @hidden"
hidden hx-post="/fragment/note/<?= $note->id ?>/reply" hx-disabled-elt="find button" hx-swap="outerHTML">
@@ -11,6 +16,15 @@
value="<?= $note->summary ? "re: $note->summary" : '' ?>">
</div>
<div class="row">
+ <label class="standalone"><?= __('writeNote.mentions.label') ?></label>
+ <?php foreach (array_merge($note->mentions, [$note->author]) as $m) {
+ if ($m != $user?->actor) {
+ call_template('user_mention_pill', ['type' => 'user', 'actor' => $m]);
+ }
+ } ?>
+ <?php call_template('user_mention_pill'); ?>
+ </div>
+ <div class="row">
<button class="primary"><?= __('writeReply.action') ?></button>
</div>
</form> \ No newline at end of file
diff --git a/templates/user_mention_pill.php b/templates/user_mention_pill.php
new file mode 100644
index 0000000..3b9a018
--- /dev/null
+++ b/templates/user_mention_pill.php
@@ -0,0 +1,31 @@
+<?php
+$type ??= 'add';
+?>
+
+<?php if ($type == 'add'): ?>
+
+ <button class="inlinePill" type="button" hx-get="/fragment/note/mentionPill" hx-disabled-elt="this" hx-swap="outerHTML">
+ <span class="pillIcon material-symbols add"></span>
+ <span class="pillText"><?= __('writeNote.mentions.add.action') ?></span>
+ </button>
+
+<?php elseif ($type == 'edit'): ?>
+
+ <form class="inlinePill nopanel <?= ($invalid ?? false) ? 'invalid' : '' ?>" hx-get="/fragment/note/mentionPill"
+ hx-disabled-elt="this" hx-swap="outerHTML">
+ <span class="pillIcon material-symbols alternate-email"></span>
+ <input type="text" name="handle" class="pillInput" value="<?= $value ?? '' ?>"
+ placeholder="<?= __('writeNote.mentions.placeholder') ?>" required autofocus>
+ <button type="submit" class="material-symbols add"></button>
+ </form>
+
+<?php elseif ($type == 'user'): ?>
+
+ <button class="inlinePill" type="button" _="on click remove me">
+ <img class="pillIcon" src="<?= $actor->avatar ?? '/static/default-avatar.png' ?>">
+ <span class="pillText"><?= $actor->displayName ?></span>
+ <input type="hidden" name="mentions[]" value="<?= $actor->id ?>">
+ <span class="pillIcon material-symbols close-small"></span>
+ </button>
+
+<?php endif; ?> \ No newline at end of file
diff --git a/templates/write_note_form.php b/templates/write_note_form.php
index 55ed77e..d2dae61 100644
--- a/templates/write_note_form.php
+++ b/templates/write_note_form.php
@@ -18,6 +18,10 @@
<input type="text" id="noteSummary" name="summary" autocomplete="off">
</div>
<div class="row">
+ <label class="standalone"><?= __('writeNote.mentions.label') ?></label>
+ <?php call_template('user_mention_pill'); ?>
+ </div>
+ <div class="row">
<label for="noteScope"><?= __('writeNote.scope.label') ?></label>
<select name="scope" id="noteScope" required autocomplete="off">
<option value="none"><?= __('note.privacy.scope.none') ?></option>