aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorwinter2025-01-19 22:17:12 +0000
committerwinter2025-01-19 22:17:12 +0000
commit8c6dad8c48d5d730b102d9de0079fee9752b0d35 (patch)
tree2cfe07a8c7958f78c767fd0d1444c3f8fef70511
parent2d9bd87fb1c565ee161f93219ce2533e8dbffe06 (diff)
implement notifications
-rw-r--r--Digitigrade/Job/ProcessIncomingPush.php2
-rw-r--r--Digitigrade/Model/HomeTimelineItem.php (renamed from Digitigrade/Timeline/HomeTimelineItem.php)3
-rw-r--r--Digitigrade/Model/Interaction.php37
-rw-r--r--Digitigrade/Model/Note.php32
-rw-r--r--Digitigrade/Model/Notification.php57
-rw-r--r--Digitigrade/Notification/Notifyable.php12
-rw-r--r--Digitigrade/Timeline/HomeTimeline.php1
-rwxr-xr-xbin/cleandb3
-rw-r--r--locale/en_GB.json5
-rw-r--r--migrations/20250119_194145_create_notification.php16
-rw-r--r--routes/note_fragment.php5
-rw-r--r--routes/notifications.php31
-rw-r--r--static/form.css4
-rw-r--r--static/notifications.css69
-rw-r--r--static/skeleton.css9
-rw-r--r--static/style.css1
-rw-r--r--templates/live_notifications_updater.php4
-rw-r--r--templates/notification.php23
-rw-r--r--templates/notifications_panel.php26
-rw-r--r--templates/skeleton.php72
-rw-r--r--templates/write_note_form.php4
21 files changed, 371 insertions, 45 deletions
diff --git a/Digitigrade/Job/ProcessIncomingPush.php b/Digitigrade/Job/ProcessIncomingPush.php
index 1306ea5..eb6bdd5 100644
--- a/Digitigrade/Job/ProcessIncomingPush.php
+++ b/Digitigrade/Job/ProcessIncomingPush.php
@@ -35,11 +35,13 @@ class ProcessIncomingPush extends Job {
$log->info('importing note with uri ' . $this->object->self);
$note = Note::importFromReceivedObject($this->object);
$note?->processTimelineAdditions();
+ $note?->processNotifications();
break;
case 'interaction':
$log->info('importing interaction with uri ' . $this->object->self);
$interaction = Interaction::importFromReceivedObject($this->object);
$interaction?->processTimelineAdditions();
+ $interaction?->processNotifications();
break;
case 'extension':
throw new \RuntimeException('object type ' . $this->object->type . ' not yet implemented :(');
diff --git a/Digitigrade/Timeline/HomeTimelineItem.php b/Digitigrade/Model/HomeTimelineItem.php
index cb2b6e4..7e2c062 100644
--- a/Digitigrade/Timeline/HomeTimelineItem.php
+++ b/Digitigrade/Model/HomeTimelineItem.php
@@ -1,8 +1,9 @@
<?php
-namespace Digitigrade\Timeline;
+namespace Digitigrade\Model;
use Digitigrade\Model;
use Digitigrade\Model\UserAccount;
+use Digitigrade\Timeline\TimelineItem;
class HomeTimelineItem extends Model {
public ?int $id;
diff --git a/Digitigrade/Model/Interaction.php b/Digitigrade/Model/Interaction.php
index e5619bb..cf2be01 100644
--- a/Digitigrade/Model/Interaction.php
+++ b/Digitigrade/Model/Interaction.php
@@ -1,11 +1,12 @@
<?php
namespace Digitigrade\Model;
-use Digitigrade\Timeline\HomeTimelineItem;
+use Digitigrade\Model\HomeTimelineItem;
+use Digitigrade\Notification\Notifyable;
use Digitigrade\Timeline\ReasonReshared;
use Digitigrade\Timeline\TimelineItem;
-class Interaction extends PushableModel {
+class Interaction extends PushableModel implements Notifyable {
public ?int $id;
public string $uri;
public \DateTimeImmutable $created;
@@ -61,6 +62,30 @@ class Interaction extends PushableModel {
return array_unique($instances);
}
+ public function getNotificationTitle(): string {
+ return sprintf(__('notifications.interaction.' . $this->kind->value . '.title'), $this->author->displayName);
+ }
+ public function getNotificationTitleLink(): ?string {
+ return '/@/' . $this->target->author->getFullHandle() . '/note/' . $this->target->id;
+ }
+ public function getNotificationBody(): ?string {
+ return $this->target->plainContent;
+ }
+ public function getNotificationImageUrl(): ?string {
+ return $this->author->avatar;
+ }
+ public function getNotificationImageLink(): ?string {
+ return '/@/' . $this->author->getFullHandle();
+ }
+
+ public function toJsonReference(): mixed {
+ return $this->id;
+ }
+
+ public static function fromJsonReference(mixed $reference): self {
+ return self::find($reference);
+ }
+
public function processTimelineAdditions() {
// basically, if this is a reshare, we need to put a home timeline item
// for all followers who can see the shared note
@@ -80,6 +105,14 @@ class Interaction extends PushableModel {
}
}
+ public function processNotifications() {
+ if (!$this->target->author->isLocal || $this->author == $this->target->author) {
+ return;
+ }
+ $recipient = UserAccount::findByLinkedActor($this->target->author);
+ Notification::fromNotifyable($this, $recipient)->send();
+ }
+
public function jsonSerialize(): array {
if ($this->deleted) {
return [
diff --git a/Digitigrade/Model/Note.php b/Digitigrade/Model/Note.php
index f6f492d..cf84ee0 100644
--- a/Digitigrade/Model/Note.php
+++ b/Digitigrade/Model/Note.php
@@ -2,11 +2,12 @@
namespace Digitigrade\Model;
use Digitigrade\Db;
-use Digitigrade\Timeline\HomeTimelineItem;
+use Digitigrade\Model\HomeTimelineItem;
+use Digitigrade\Notification\Notifyable;
use Digitigrade\Timeline\TimelineIncludeable;
use Digitigrade\Timeline\TimelineItem;
-class Note extends PushableModel implements TimelineIncludeable {
+class Note extends PushableModel implements TimelineIncludeable, Notifyable {
public ?int $id;
public string $uri;
public \DateTimeImmutable $created;
@@ -195,6 +196,33 @@ class Note extends PushableModel implements TimelineIncludeable {
return self::find($reference);
}
+ 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);
+ }
+ public function getNotificationTitleLink(): ?string {
+ return '/@/' . $this->author->getFullHandle() . '/note/' . $this->id;
+ }
+ public function getNotificationBody(): ?string {
+ return $this->plainContent;
+ }
+ public function getNotificationImageUrl(): ?string {
+ return $this->author->avatar;
+ }
+ public function getNotificationImageLink(): ?string {
+ return '/@/' . $this->author->getFullHandle();
+ }
+
+ public function processNotifications() {
+ foreach ($this->mentions as $a) {
+ if (!$a->isLocal) {
+ continue;
+ }
+ $user = UserAccount::findByLinkedActor($a);
+ Notification::fromNotifyable($this, $user)->send();
+ }
+ }
+
public function processTimelineAdditions() {
$item = new TimelineItem($this);
foreach ($this->getRelevantActors() as $actor) {
diff --git a/Digitigrade/Model/Notification.php b/Digitigrade/Model/Notification.php
new file mode 100644
index 0000000..ee9d64a
--- /dev/null
+++ b/Digitigrade/Model/Notification.php
@@ -0,0 +1,57 @@
+<?php
+namespace Digitigrade\Model;
+
+use Digitigrade\Model;
+use Digitigrade\Notification\Notifyable;
+
+class Notification extends Model {
+ public ?int $id;
+ public UserAccount $userAccount;
+ public string $itemType;
+ public string $itemData;
+ public \DateTimeImmutable $created;
+
+ /**
+ * Creates a new Notification from a Notifyable object, but does not send it.
+ * @param Notifyable $item the item to send in the notification
+ * @param UserAccount $user the user to send the notification to
+ * @return Notification
+ */
+ public static function fromNotifyable(Notifyable $item, UserAccount $user): self {
+ $notif = new self();
+ $notif->userAccount = $user;
+ $notif->itemType = $item::class;
+ $notif->itemData = json_encode($item->toJsonReference());
+ $notif->created = new \DateTimeImmutable();
+
+ $notif->save();
+ return $notif;
+ }
+
+ public function toNotifyable(): Notifyable {
+ return $this->itemType::fromJsonReference(json_decode($this->itemData));
+ }
+
+ /**
+ * Saves this object and delivers the notification to the recipient user.
+ * @return void
+ */
+ public function send() {
+ $this->save();
+ // TODO: send it via any push mechanisms
+ }
+
+ /**
+ * @return self[]
+ */
+ public static function findAllForUser(UserAccount $user, ?int $limit = null, int $offset = 0, ?\DateTimeInterface $since = null) {
+ $where = 'user_account = ?';
+ $params = [$user->id];
+ if (isset($since)) {
+ $where .= ' AND created > ?';
+ $params[] = $since->format('c');
+ }
+ return self::findAllWhere($where, $params, $limit, $offset, 'id DESC');
+ }
+
+} \ No newline at end of file
diff --git a/Digitigrade/Notification/Notifyable.php b/Digitigrade/Notification/Notifyable.php
new file mode 100644
index 0000000..510437e
--- /dev/null
+++ b/Digitigrade/Notification/Notifyable.php
@@ -0,0 +1,12 @@
+<?php
+namespace Digitigrade\Notification;
+
+interface Notifyable {
+ public function toJsonReference(): mixed;
+ public static function fromJsonReference(mixed $reference): self;
+ public function getNotificationTitle(): string;
+ public function getNotificationTitleLink(): ?string;
+ public function getNotificationBody(): ?string;
+ public function getNotificationImageUrl(): ?string;
+ public function getNotificationImageLink(): ?string;
+} \ No newline at end of file
diff --git a/Digitigrade/Timeline/HomeTimeline.php b/Digitigrade/Timeline/HomeTimeline.php
index c2baf3d..0867541 100644
--- a/Digitigrade/Timeline/HomeTimeline.php
+++ b/Digitigrade/Timeline/HomeTimeline.php
@@ -2,6 +2,7 @@
namespace Digitigrade\Timeline;
use Digitigrade\Model\UserAccount;
+use Digitigrade\Model\HomeTimelineItem;
class HomeTimeline extends Timeline {
private UserAccount $user;
diff --git a/bin/cleandb b/bin/cleandb
index b33f994..4aa7b37 100755
--- a/bin/cleandb
+++ b/bin/cleandb
@@ -12,8 +12,9 @@ DELETE FROM actor WHERE deleted = true;
DELETE FROM note WHERE deleted = true;
DELETE FROM interaction WHERE deleted = true;
--- remove old timeline items
+-- remove old timeline items and notifications
DELETE FROM home_timeline_item WHERE modified < (current_timestamp - interval '6 months');
+DELETE FROM notification WHERE created < (current_timestamp - interval '1 month');
END);
$db->commit(); \ No newline at end of file
diff --git a/locale/en_GB.json b/locale/en_GB.json
index 9fea62c..fa98b49 100644
--- a/locale/en_GB.json
+++ b/locale/en_GB.json
@@ -41,6 +41,11 @@
"navigation.logout.confirmation": "Are you sure you want to log out?",
"navigation.followRequests": "Follow requests",
"navigation.instanceSettings": "Instance settings",
+ "notifications.heading": "Notifications",
+ "notifications.interaction.like.title": "%s liked your note",
+ "notifications.interaction.dislike.title": "%s disliked your note",
+ "notifications.interaction.reshare.title": "%s reshared your note",
+ "notifications.interaction.reply.title": "%s replied to you",
"login.pageTitle": "Log in",
"login.email": "Email address",
"login.password": "Password",
diff --git a/migrations/20250119_194145_create_notification.php b/migrations/20250119_194145_create_notification.php
new file mode 100644
index 0000000..f4b3ae4
--- /dev/null
+++ b/migrations/20250119_194145_create_notification.php
@@ -0,0 +1,16 @@
+<?php
+
+use \Digitigrade\Db\Migrator;
+
+Migrator::getInstance()->register(20250119_194145, function (PDO $db) {
+ // table for notifications sent to users
+ $db->exec(<<<END
+ CREATE TABLE notification (
+ id bigserial primary key,
+ user_account bigint not null references user_account,
+ item_type text not null,
+ item_data jsonb not null,
+ created timestamp with time zone not null
+ );
+ END);
+});
diff --git a/routes/note_fragment.php b/routes/note_fragment.php
index 84f68c6..5fe979a 100644
--- a/routes/note_fragment.php
+++ b/routes/note_fragment.php
@@ -24,6 +24,7 @@ Router::getInstance()->mount('/fragment/note/:id/:action', function (array $args
$interaction = Interaction::create($user->actor, InteractionKind::from($action), $note);
$interaction->publish();
$interaction->processTimelineAdditions();
+ $interaction->processNotifications();
$active = true;
} else {
// we are removing the existing one
@@ -55,9 +56,12 @@ 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];
$reply->save();
$reply->publish();
$reply->processTimelineAdditions();
+ $reply->processNotifications();
}
render_template('reply_form', ['note' => $note]);
});
@@ -78,6 +82,7 @@ Router::getInstance()->mount('/fragment/note', function (array $args) {
);
$note->publish();
$note->processTimelineAdditions();
+ $note->processNotifications();
}
render_template('write_note_form');
}); \ No newline at end of file
diff --git a/routes/notifications.php b/routes/notifications.php
new file mode 100644
index 0000000..05fb89d
--- /dev/null
+++ b/routes/notifications.php
@@ -0,0 +1,31 @@
+<?php
+
+use Digitigrade\HttpResponseStatus\BadRequest;
+use Digitigrade\Model\Notification;
+use Digitigrade\Model\UserAccount;
+use Digitigrade\Router;
+
+Router::getInstance()->mount('/notifications/partial', function (array $args) {
+ $user = UserAccount::requireByCurrentSession();
+ if (!isset($_GET['since'])) {
+ throw new BadRequest('get parameter `since` required');
+ }
+ $when = new \DateTimeImmutable($_GET['since']);
+ $notifs = Notification::findAllForUser($user, since: $when);
+
+ foreach ($notifs as $notif) {
+ $notif = $notif->toNotifyable();
+ render_template('notification', [
+ 'title' => $notif->getNotificationTitle(),
+ 'body' => $notif->getNotificationBody(),
+ 'imageUrl' => $notif->getNotificationImageUrl(),
+ 'titleLink' => $notif->getNotificationTitleLink(),
+ 'imageLink' => $notif->getNotificationImageLink()
+ ]);
+ }
+
+ render_template('live_notifications_updater', [
+ 'isUpdate' => true,
+ 'since' => new \DateTimeImmutable()
+ ]);
+}); \ No newline at end of file
diff --git a/static/form.css b/static/form.css
index 79509d7..00a4366 100644
--- a/static/form.css
+++ b/static/form.css
@@ -58,6 +58,10 @@ form {
}
}
+ &#writeNoteForm {
+ max-width: 100%;
+ }
+
&.settings {
padding: 0;
gap: 0;
diff --git a/static/notifications.css b/static/notifications.css
new file mode 100644
index 0000000..98a8fbb
--- /dev/null
+++ b/static/notifications.css
@@ -0,0 +1,69 @@
+#notificationsPanel {
+ border: var(--border);
+ border-radius: var(--border-radius);
+ background: var(--clr-panel-background);
+ margin: var(--spacing-double);
+ height: calc(100vh - 64px - (2 * var(--spacing-double)));
+ display: grid;
+ grid-template-rows: max-content 1fr;
+
+ h2 {
+ margin: 0;
+ padding: var(--spacing-double);
+ font-size: var(--font-big-size);
+ font-weight: bold;
+ border-bottom: var(--border);
+ }
+
+ .items {
+ overflow-y: scroll;
+ height: 100%;
+ box-sizing: border-box;
+ }
+
+ .notification {
+ padding: var(--spacing-single);
+ border-bottom: var(--border);
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ gap: var(--spacing-single);
+ background: var(--clr-note-background);
+ overflow: hidden;
+ filter: linear-gradient(
+ to bottom,
+ black 50%,
+ var(--clr-note-background) 100%
+ );
+
+ img {
+ width: var(--avatar-size);
+ border-radius: var(--border-radius);
+ aspect-ratio: 1;
+ }
+ .notifContent {
+ display: grid;
+ grid-template-rows: max-content 1fr;
+ align-content: start;
+ }
+ .title {
+ font-weight: bold;
+ a:has(&) {
+ color: inherit;
+ text-decoration: none;
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+ .body {
+ overflow: hidden;
+ min-height: 100%;
+ max-height: 4lh;
+ mask-image: linear-gradient(to bottom, black 50%, transparent 100%);
+ &:active {
+ max-height: unset;
+ mask-image: none;
+ }
+ }
+ }
+}
diff --git a/static/skeleton.css b/static/skeleton.css
index 12bc1e5..e9e39b2 100644
--- a/static/skeleton.css
+++ b/static/skeleton.css
@@ -125,9 +125,9 @@ ul.sideNavigation {
}
main {
- width: min(1300px, 100%);
+ width: min(1500px, 100%);
display: grid;
- grid-template-columns: 1fr min(700px, 100%) 1fr;
+ grid-template-columns: 1fr min(100%, 700px) 1fr;
height: 100%;
#leftPane,
#rightPane {
@@ -135,11 +135,10 @@ main {
top: 56px;
height: max-content;
}
- @media (width < 1300px) {
+ @media (width < 1200px) {
#leftPane,
#rightPane {
- max-width: 0;
- visibility: hidden;
+ display: none;
}
}
}
diff --git a/static/style.css b/static/style.css
index d5078bb..0c2bf9d 100644
--- a/static/style.css
+++ b/static/style.css
@@ -1,5 +1,6 @@
@import url(theme.css);
@import url(skeleton.css);
+@import url(notifications.css);
@import url(note.css);
@import url(form.css);
@import url(profile.css);
diff --git a/templates/live_notifications_updater.php b/templates/live_notifications_updater.php
new file mode 100644
index 0000000..bbf31b4
--- /dev/null
+++ b/templates/live_notifications_updater.php
@@ -0,0 +1,4 @@
+<form hidden id="liveNotificationsUpdater" hx-trigger="load delay:10s, update" hx-get="/notifications/partial"
+ hx-target="next .items" hx-swap="afterbegin" <?= ($isUpdate ?? false) ? 'hx-swap-oob="true"' : '' ?>>
+ <input type="hidden" name="since" value="<?= $since->format('c') ?>">
+</form> \ No newline at end of file
diff --git a/templates/notification.php b/templates/notification.php
new file mode 100644
index 0000000..a010ff9
--- /dev/null
+++ b/templates/notification.php
@@ -0,0 +1,23 @@
+<div class="notification">
+ <div class="notifImage">
+ <?php if ($imageUrl != null): ?>
+ <?php if ($imageLink != null): ?>
+ <a href="<?= $imageLink ?>">
+ <?php endif; ?>
+ <img src="<?= $imageUrl ?>">
+ <?php if ($imageLink != null): ?>
+ </a>
+ <?php endif; ?>
+ <?php endif; ?>
+ </div>
+ <div class="notifContent">
+ <?php if ($titleLink != null): ?>
+ <a href="<?= $titleLink ?>">
+ <?php endif; ?>
+ <span class="title"><?= htmlspecialchars($title) ?></span>
+ <?php if ($titleLink != null): ?>
+ </a>
+ <?php endif; ?>
+ <span class="body"><?= htmlspecialchars($body) ?></span>
+ </div>
+</div> \ No newline at end of file
diff --git a/templates/notifications_panel.php b/templates/notifications_panel.php
new file mode 100644
index 0000000..3ca8faf
--- /dev/null
+++ b/templates/notifications_panel.php
@@ -0,0 +1,26 @@
+<?php
+use Digitigrade\Model\Notification;
+
+$notifications = Notification::findAllForUser($user, 50);
+?>
+<div id="notificationsPanel">
+ <h2><?= __('notifications.heading') ?></h2>
+ <?php call_template('live_notifications_updater', ['since' => new \DateTimeImmutable()]); ?>
+ <div class="items">
+ <?php
+ if (count($notifications) <= 0) {
+ call_template('placeholder_text');
+ }
+ foreach ($notifications as $notif) {
+ $notif = $notif->toNotifyable();
+ call_template('notification', [
+ 'title' => $notif->getNotificationTitle(),
+ 'body' => $notif->getNotificationBody(),
+ 'imageUrl' => $notif->getNotificationImageUrl(),
+ 'titleLink' => $notif->getNotificationTitleLink(),
+ 'imageLink' => $notif->getNotificationImageLink()
+ ]);
+ }
+ ?>
+ </div>
+</div> \ No newline at end of file
diff --git a/templates/skeleton.php b/templates/skeleton.php
index 9e27386..3b2a963 100644
--- a/templates/skeleton.php
+++ b/templates/skeleton.php
@@ -46,39 +46,47 @@ $settings = GlobalSettings::getInstance();
</nav>
</header>
<main>
- <section id="leftPane">
- <?php
- if ($user != null) {
- call_template('write_note_form');
- $links = [[
- 'href' => '/followrequests',
- 'icon' => 'person_add',
- 'label' => __('navigation.followRequests'),
- 'unreadCount' => FollowRelation::countWhere("object = ? AND status = 'pending'", [$user->actor->id])
- ]];
- if ($user->isAdmin) {
- $links[] = ['href' => '/admin/settings/instance', 'icon' => 'settings', 'label' => __('navigation.instanceSettings')];
+ <div>
+ <section id="leftPane">
+ <?php
+ if ($user != null) {
+ call_template('write_note_form');
+ $links = [[
+ 'href' => '/followrequests',
+ 'icon' => 'person_add',
+ 'label' => __('navigation.followRequests'),
+ 'unreadCount' => FollowRelation::countWhere("object = ? AND status = 'pending'", [$user->actor->id])
+ ]];
+ if ($user->isAdmin) {
+ $links[] = ['href' => '/admin/settings/instance', 'icon' => 'settings', 'label' => __('navigation.instanceSettings')];
+ }
+ $links[] = [
+ 'href' => '/logout',
+ 'icon' => 'logout',
+ 'label' => __('navigation.logout'),
+ 'confirmation' => __('navigation.logout.confirmation')
+ ];
+ call_template('side_navigation', ['links' => $links]);
}
- $links[] = [
- 'href' => '/logout',
- 'icon' => 'logout',
- 'label' => __('navigation.logout'),
- 'confirmation' => __('navigation.logout.confirmation')
- ];
- call_template('side_navigation', ['links' => $links]);
- }
- ?>
- <p class="versionInfo"><?= sprintf(__('version'), get_version()) ?></p>
- </section>
- <section id="centrePane">
- <?php if (!isset($renderTitleHeading) || $renderTitleHeading): ?>
- <h1><?= $pageTitle ?></h1>
- <?php endif; ?>
- <?php slot(); ?>
- </section>
- <section id="rightPane">
-
- </section>
+ ?>
+ <p class="versionInfo"><?= sprintf(__('version'), get_version()) ?></p>
+ </section>
+ </div>
+ <div>
+ <section id="centrePane">
+ <?php if (!isset($renderTitleHeading) || $renderTitleHeading): ?>
+ <h1><?= $pageTitle ?></h1>
+ <?php endif; ?>
+ <?php slot(); ?>
+ </section>
+ </div>
+ <div>
+ <section id="rightPane">
+ <?php if ($user != null) {
+ call_template('notifications_panel', ['user' => $user]);
+ } ?>
+ </section>
+ </div>
</main>
</body>
diff --git a/templates/write_note_form.php b/templates/write_note_form.php
index b74de69..2d50896 100644
--- a/templates/write_note_form.php
+++ b/templates/write_note_form.php
@@ -1,5 +1,5 @@
-<form action="/todo" method="post" hx-post="/fragment/note" hx-swap="outerHTML" hx-disabled-elt="find button"
- _="on submit wait 1s then trigger update on #liveTimelineUpdater">
+<form id="writeNoteForm" action="/todo" method="post" hx-post="/fragment/note" hx-swap="outerHTML"
+ hx-disabled-elt="find button" _="on submit wait 1s then trigger update on #liveTimelineUpdater">
<div class="row">
<label for="noteContent"><?= __('writeNote.label') ?></label>
<textarea name="content" id="noteContent" required autocomplete="off"></textarea>