aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorwinter2025-02-16 19:36:51 +0000
committerwinter2025-02-16 19:36:51 +0000
commit5a44137ddea7b3a6d7f68cbe71594f7633d6e9a6 (patch)
tree1583032971f5fbde6e333a4c377c2b6672dc296c
parent65dfe7aff06dfd3b044a5deb47b03d643c651ec1 (diff)
link previews
-rw-r--r--Digitigrade/LinkPreview.php91
-rw-r--r--Digitigrade/Model/Note.php45
-rw-r--r--routes/note_fragment.php2
-rw-r--r--static/note.css52
-rw-r--r--templates/note.php11
-rw-r--r--templates/note_link_preview.php23
6 files changed, 223 insertions, 1 deletions
diff --git a/Digitigrade/LinkPreview.php b/Digitigrade/LinkPreview.php
new file mode 100644
index 0000000..4716cc8
--- /dev/null
+++ b/Digitigrade/LinkPreview.php
@@ -0,0 +1,91 @@
+<?php
+namespace Digitigrade;
+
+class LinkPreview implements \JsonSerializable {
+ public string $url;
+ public string $siteName;
+ public string $title;
+ public string $description;
+ public string $style;
+ public ?string $imageUrl = null;
+
+ /**
+ * Generates a link preview for the given URL.
+ * @param string $url
+ * @return LinkPreview|null the link preview, or null if none is available
+ */
+ public static function generate(string $url): ?self {
+ $p = new self();
+ $p->url = $url;
+ return $p->update() ? $p : null;
+ }
+
+ /**
+ * Updates the information for this link preview.
+ * @return bool true if successful
+ */
+ public function update(): bool {
+ if (!(str_starts_with($this->url, "https://") || str_starts_with($this->url, "http://"))) {
+ return false;
+ }
+
+ $d = new \DOMDocument();
+ $c = @file_get_contents($this->url);
+ if (!$c)
+ return false;
+ @$d->loadHTML($c);
+
+ $url = $this->url;
+ $siteName = hostname_from_uri($this->url);
+ $style = 'text';
+ $title = $d->getElementsByTagName('title')->item(0)?->textContent;
+ $metas = $d->getElementsByTagName('meta');
+ foreach ($metas as $tag) {
+ if ($tag->attributes->getNamedItem('name')?->textContent == 'description') {
+ // <meta name="description" content="...">
+ $description = $tag->attributes->getNamedItem('content')?->textContent;
+ } elseif ($prop = $tag->attributes->getNamedItem('property')) {
+ // <meta property="og:..." content="...">
+ $content = $tag->attributes->getNamedItem('content')?->textContent;
+ match ($prop->textContent) {
+ 'og:title' => $title = $content,
+ 'og:description' => $description = $content,
+ 'og:url' => $url = $content,
+ 'og:site_name' => $siteName = $content,
+ 'og:image' => $imageUrl = $content,
+ default => null
+ };
+
+ } elseif ($tag->attributes->getNamedItem('name')?->textContent == 'twitter:card'
+ && $tag->attributes->getNamedItem("content")?->textContent == 'summary_large_image') {
+ // <meta name="twitter:card" content="summary_large_image">
+ $style = 'largeImage';
+ }
+ }
+ // turn text into smallImage if an image is present
+ $style = ($style == 'text' && isset($imageUrl)) ? 'smallImage' : $style;
+
+ if (!isset($title, $description))
+ return false;
+
+ $this->url = $url;
+ $this->siteName = $siteName;
+ $this->title = $title;
+ $this->description = $description;
+ $this->style = $style;
+ $this->imageUrl = $imageUrl ?? null;
+
+ return true;
+ }
+
+ public function jsonSerialize(): array {
+ return [
+ 'url' => $this->url,
+ 'siteName' => $this->siteName,
+ 'title' => $this->title,
+ 'description' => $this->description,
+ 'style' => $this->style,
+ 'imageUrl' => $this->imageUrl
+ ];
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/Model/Note.php b/Digitigrade/Model/Note.php
index d5d4e69..ac9cc3c 100644
--- a/Digitigrade/Model/Note.php
+++ b/Digitigrade/Model/Note.php
@@ -4,12 +4,16 @@ namespace Digitigrade\Model;
use Digitigrade\Db;
use Digitigrade\FormattingProvider\CommonMark;
use Digitigrade\FormattingProvider\SanitisedHtml;
+use Digitigrade\LinkPreview;
use Digitigrade\Model\HomeTimelineItem;
use Digitigrade\Notification\Notifyable;
use Digitigrade\Timeline\TimelineIncludeable;
use Digitigrade\Timeline\TimelineItem;
+use JsonMapper;
class Note extends PushableModel implements TimelineIncludeable, Notifyable {
+ public const EXTENSION_LINK_PREVIEWS = 'https://pawpub.entities.org.uk/extension/link-previews';
+
public ?int $id;
public string $uri;
public \DateTimeImmutable $created;
@@ -265,6 +269,45 @@ class Note extends PushableModel implements TimelineIncludeable, Notifyable {
return htmlspecialchars($this->plainContent);
}
+ /**
+ * Gets a list of LinkPreviews applicable to this note
+ * @return LinkPreview[]
+ */
+ public function getLinkPreviews(): array {
+ if (($this->extensions[self::EXTENSION_LINK_PREVIEWS] ?? []) == []) {
+ return [];
+ }
+ $mapper = new JsonMapper();
+ $results = [];
+ foreach ($this->extensions[self::EXTENSION_LINK_PREVIEWS] as $preview) {
+ $results[] = $mapper->map($preview, LinkPreview::class);
+ }
+ return $results;
+ }
+
+ /**
+ * Generates new LinkPreviews for this note (but does not save them automatically)
+ * @return LinkPreview[] the new link previews
+ */
+ public function generateLinkPreviews(): array {
+ $matches = [];
+ preg_match_all(
+ '`[a-z+]+://[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(([-a-zA-Z0-9@:(%_\+.~#?&/=]|\)[^\s])*)`',
+ $this->getFormattedContent('text/markdown') ?? $this->plainContent,
+ $matches
+ );
+ $urls = $matches[0] ?? [];
+ $previews = array_filter(
+ array_map(fn($url) => LinkPreview::generate($url), $urls),
+ fn($p) => $p != null
+ );
+ $this->extensions[self::EXTENSION_LINK_PREVIEWS] = array_map(
+ fn($p) => json_decode(json_encode($p)),
+ $previews
+ );
+ return $previews;
+ }
+
public function renderAsHtml() {
render_template('note', ['note' => $this]);
}
@@ -345,7 +388,7 @@ class Note extends PushableModel implements TimelineIncludeable, Notifyable {
'description' => $attachment->description
];
}, $this->attachments),
- //'extensions' => []
+ 'extensions' => $this->extensions
];
}
} \ No newline at end of file
diff --git a/routes/note_fragment.php b/routes/note_fragment.php
index b64eb22..e9a7396 100644
--- a/routes/note_fragment.php
+++ b/routes/note_fragment.php
@@ -89,6 +89,7 @@ Router::getInstance()->mount('/fragment/note/:id/reply', function (array $args)
foreach (($_POST['mentions'] ?? []) as $uid) {
$reply->mentions[] = Actor::find($uid);
}
+ $reply->generateLinkPreviews();
$reply->save();
$reply->publish();
$reply->processTimelineAdditions();
@@ -126,6 +127,7 @@ Router::getInstance()->mount('/fragment/note', function (array $args) {
$obj = StorageObject::findByOid($attachments[$i]);
$note->attachments[] = NoteAttachment::fromStorageObject($obj, $index++, $altTexts[$i] ?: null);
}
+ $note->generateLinkPreviews();
$note->save();
$note->publish();
$note->processTimelineAdditions();
diff --git a/static/note.css b/static/note.css
index 82e0564..333df3b 100644
--- a/static/note.css
+++ b/static/note.css
@@ -282,6 +282,58 @@ article.note {
}
}
+.linkPreview {
+ display: grid;
+ grid-auto-flow: row;
+ border: var(--border);
+ border-radius: var(--border-radius);
+ text-decoration: none;
+ color: inherit;
+ margin-top: var(--spacing-single);
+ margin-bottom: var(--spacing-double);
+ background-color: var(--clr-panel-background);
+ overflow: hidden;
+
+ &.style-smallImage {
+ grid-template-columns: max-content 1fr;
+ gap: var(--spacing-half);
+ }
+
+ .info {
+ display: grid;
+ grid-auto-flow: row;
+ padding: var(--spacing-single);
+ gap: var(--spacing-half);
+ align-content: start;
+ }
+
+ &.style-smallImage img {
+ height: 6lh;
+ max-width: 8em;
+ object-fit: cover;
+ }
+ &.style-largeImage img {
+ max-height: 12lh;
+ width: 100%;
+ object-fit: cover;
+ }
+ .siteName {
+ color: var(--clr-lesser-foreground);
+ font-size: var(--font-small-size);
+ max-height: 1lh;
+ overflow: hidden;
+ }
+ .title {
+ font-weight: bold;
+ max-height: 1lh;
+ overflow: hidden;
+ }
+ .description {
+ max-height: 3lh;
+ overflow: hidden;
+ }
+}
+
article.note.hidden {
padding-top: 0;
padding-bottom: 0;
diff --git a/templates/note.php b/templates/note.php
index 81f7e38..4cdcd94 100644
--- a/templates/note.php
+++ b/templates/note.php
@@ -5,6 +5,8 @@ use Digitigrade\Model\NotePrivacyScope;
use Digitigrade\Model\UserAccount;
use Digitigrade\UserSettings;
+const MAX_PREVIEW_CARDS = 1;
+
$user = UserAccount::findByCurrentSession();
$prefs = $user ? new UserSettings($user) : null;
$interKinds = [];
@@ -89,6 +91,15 @@ if ($user != null) {
</div>
<?php endif; ?>
+ <?php
+ $i = 0;
+ foreach ($note->getLinkPreviews() as $preview) {
+ call_template("note_link_preview", ['preview' => $preview]);
+ if (++$i > MAX_PREVIEW_CARDS)
+ break;
+ }
+ ?>
+
<?php if (isset($note->summary)): ?>
</details>
<?php endif; ?>
diff --git a/templates/note_link_preview.php b/templates/note_link_preview.php
new file mode 100644
index 0000000..81431b1
--- /dev/null
+++ b/templates/note_link_preview.php
@@ -0,0 +1,23 @@
+<?php
+$siteName = hostname_from_uri($preview->url);
+if ($preview->siteName != $siteName) {
+ $siteName = htmlspecialchars($preview->siteName) . ' &bullet; ' . $siteName;
+}
+?>
+
+<a class="linkPreview style-<?= htmlspecialchars($preview->style) ?>" href="<?= htmlspecialchars($preview->url) ?>"
+ target="_blank" rel="noreferrer nofollow">
+
+ <?php if ($preview->style == 'text' || !isset($preview->imageUrl)): ?>
+ <div></div>
+ <?php else: ?>
+ <img src="<?= htmlspecialchars($preview->imageUrl) ?>">
+ <?php endif; ?>
+
+ <div class="info">
+ <span class="siteName"><?= $siteName ?></span>
+ <span class="title"><?= htmlspecialchars($preview->title) ?></span>
+ <span class="description"><?= htmlspecialchars($preview->description) ?></span>
+ </div>
+
+</a> \ No newline at end of file