From 9efb11d8485f8273f7d64d46f2675a78fc41d6e8 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Mon, 15 Jul 2024 11:47:57 +0200
Subject: [PATCH] [feature/frontend] Add player for audio files; use thumbnail
for `poster` (#3099)
* [feature/frontend] Audio player for audio media types
* use video preview images for previews instead of video itself
* don't preload
* update tests for new zork status
* collapse media gallery into single row when small
---
.../api/activitypub/users/outboxget_test.go | 6 +-
.../api/client/accounts/accountverify_test.go | 2 +-
.../api/client/admin/accountsgetv2_test.go | 4 +-
.../api/client/instance/instancepatch_test.go | 12 +--
internal/api/client/search/searchget_test.go | 6 +-
.../api/client/statuses/statushistory_test.go | 4 +-
.../api/client/statuses/statusmute_test.go | 8 +-
internal/api/model/attachment.go | 19 ++++-
internal/api/model/status.go | 4 +
internal/db/bundb/account_test.go | 6 +-
internal/db/bundb/basic_test.go | 2 +-
internal/db/bundb/instance_test.go | 2 +-
internal/db/bundb/status_test.go | 7 +-
internal/db/bundb/timeline_test.go | 10 +--
internal/media/ffmpeg.go | 50 ++++++++++-
internal/media/processingmedia.go | 8 +-
internal/processing/account/rss_test.go | 4 +-
internal/timeline/get_test.go | 6 +-
internal/timeline/prune_test.go | 8 +-
internal/typeutils/internaltofrontend.go | 32 +++++--
internal/typeutils/internaltofrontend_test.go | 58 +++++++------
testrig/media/ghosts-original.mp3 | Bin 0 -> 7483917 bytes
testrig/media/ghosts-small.jpg | Bin 0 -> 6132 bytes
testrig/testmodels.go | 80 ++++++++++++++++++
web/source/css/status.css | 14 ++-
web/template/status_attachments.tmpl | 70 +++++++++++++--
26 files changed, 327 insertions(+), 95 deletions(-)
create mode 100644 testrig/media/ghosts-original.mp3
create mode 100644 testrig/media/ghosts-small.jpg
diff --git a/internal/api/activitypub/users/outboxget_test.go b/internal/api/activitypub/users/outboxget_test.go
index 521af0ff0..cba1ef31d 100644
--- a/internal/api/activitypub/users/outboxget_test.go
+++ b/internal/api/activitypub/users/outboxget_test.go
@@ -82,7 +82,7 @@ func (suite *OutboxGetTestSuite) TestGetOutbox() {
"@context": "https://www.w3.org/ns/activitystreams",
"first": "http://localhost:8080/users/the_mighty_zork/outbox?limit=40",
"id": "http://localhost:8080/users/the_mighty_zork/outbox",
- "totalItems": 7,
+ "totalItems": 8,
"type": "OrderedCollection"
}`, dst.String())
@@ -161,7 +161,7 @@ func (suite *OutboxGetTestSuite) TestGetOutboxFirstPage() {
],
"partOf": "http://localhost:8080/users/the_mighty_zork/outbox",
"prev": "http://localhost:8080/users/the_mighty_zork/outbox?limit=40\u0026min_id=01HH9KYNQPA416TNJ53NSATP40",
- "totalItems": 7,
+ "totalItems": 8,
"type": "OrderedCollectionPage"
}`, dst.String())
@@ -224,7 +224,7 @@ func (suite *OutboxGetTestSuite) TestGetOutboxNextPage() {
"id": "http://localhost:8080/users/the_mighty_zork/outbox?limit=40&max_id=01F8MHAMCHF6Y650WCRSCP4WMY",
"orderedItems": [],
"partOf": "http://localhost:8080/users/the_mighty_zork/outbox",
- "totalItems": 7,
+ "totalItems": 8,
"type": "OrderedCollectionPage"
}`, dst.String())
diff --git a/internal/api/client/accounts/accountverify_test.go b/internal/api/client/accounts/accountverify_test.go
index eccda8b2e..cedc11916 100644
--- a/internal/api/client/accounts/accountverify_test.go
+++ b/internal/api/client/accounts/accountverify_test.go
@@ -79,7 +79,7 @@ func (suite *AccountVerifyTestSuite) TestAccountVerifyGet() {
suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", apimodelAccount.HeaderStatic)
suite.Equal(2, apimodelAccount.FollowersCount)
suite.Equal(2, apimodelAccount.FollowingCount)
- suite.Equal(7, apimodelAccount.StatusesCount)
+ suite.Equal(8, apimodelAccount.StatusesCount)
suite.EqualValues(gtsmodel.VisibilityPublic, apimodelAccount.Source.Privacy)
suite.Equal(testAccount.Settings.Language, apimodelAccount.Source.Language)
suite.Equal(testAccount.NoteRaw, apimodelAccount.Source.Note)
diff --git a/internal/api/client/admin/accountsgetv2_test.go b/internal/api/client/admin/accountsgetv2_test.go
index 85d58cce8..ca9573be5 100644
--- a/internal/api/client/admin/accountsgetv2_test.go
+++ b/internal/api/client/admin/accountsgetv2_test.go
@@ -240,8 +240,8 @@ func (suite *AccountsGetTestSuite) TestAccountsGetFromTop() {
"header_description": "A very old-school screenshot of the original team fortress mod for quake",
"followers_count": 2,
"following_count": 2,
- "statuses_count": 7,
- "last_status_at": "2023-12-10T09:24:00.000Z",
+ "statuses_count": 8,
+ "last_status_at": "2024-01-10T09:24:00.000Z",
"emojis": [],
"fields": [],
"enable_rss": true,
diff --git a/internal/api/client/instance/instancepatch_test.go b/internal/api/client/instance/instancepatch_test.go
index 605b056b9..5113e4c57 100644
--- a/internal/api/client/instance/instancepatch_test.go
+++ b/internal/api/client/instance/instancepatch_test.go
@@ -135,7 +135,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch1() {
},
"stats": {
"domain_count": 2,
- "status_count": 19,
+ "status_count": 20,
"user_count": 4
},
"thumbnail": "http://localhost:8080/assets/logo.png",
@@ -256,7 +256,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch2() {
},
"stats": {
"domain_count": 2,
- "status_count": 19,
+ "status_count": 20,
"user_count": 4
},
"thumbnail": "http://localhost:8080/assets/logo.png",
@@ -377,7 +377,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch3() {
},
"stats": {
"domain_count": 2,
- "status_count": 19,
+ "status_count": 20,
"user_count": 4
},
"thumbnail": "http://localhost:8080/assets/logo.png",
@@ -549,7 +549,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch6() {
},
"stats": {
"domain_count": 2,
- "status_count": 19,
+ "status_count": 20,
"user_count": 4
},
"thumbnail": "http://localhost:8080/assets/logo.png",
@@ -692,7 +692,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() {
},
"stats": {
"domain_count": 2,
- "status_count": 19,
+ "status_count": 20,
"user_count": 4
},
"thumbnail": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/attachment/original/`+instanceAccount.AvatarMediaAttachment.ID+`.gif",`+`
@@ -850,7 +850,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch9() {
},
"stats": {
"domain_count": 2,
- "status_count": 19,
+ "status_count": 20,
"user_count": 4
},
"thumbnail": "http://localhost:8080/assets/logo.png",
diff --git a/internal/api/client/search/searchget_test.go b/internal/api/client/search/searchget_test.go
index 27e5f782d..ab4f46689 100644
--- a/internal/api/client/search/searchget_test.go
+++ b/internal/api/client/search/searchget_test.go
@@ -916,7 +916,7 @@ func (suite *SearchGetTestSuite) TestSearchAAny() {
}
suite.Len(searchResult.Accounts, 5)
- suite.Len(searchResult.Statuses, 6)
+ suite.Len(searchResult.Statuses, 7)
suite.Len(searchResult.Hashtags, 0)
}
@@ -959,7 +959,7 @@ func (suite *SearchGetTestSuite) TestSearchAAnyFollowingOnly() {
}
suite.Len(searchResult.Accounts, 2)
- suite.Len(searchResult.Statuses, 6)
+ suite.Len(searchResult.Statuses, 7)
suite.Len(searchResult.Hashtags, 0)
}
@@ -1002,7 +1002,7 @@ func (suite *SearchGetTestSuite) TestSearchAStatuses() {
}
suite.Len(searchResult.Accounts, 0)
- suite.Len(searchResult.Statuses, 6)
+ suite.Len(searchResult.Statuses, 7)
suite.Len(searchResult.Hashtags, 0)
}
diff --git a/internal/api/client/statuses/statushistory_test.go b/internal/api/client/statuses/statushistory_test.go
index a88abdb8f..0d9f6c979 100644
--- a/internal/api/client/statuses/statushistory_test.go
+++ b/internal/api/client/statuses/statushistory_test.go
@@ -114,8 +114,8 @@ func (suite *StatusHistoryTestSuite) TestGetHistory() {
"header_description": "A very old-school screenshot of the original team fortress mod for quake",
"followers_count": 2,
"following_count": 2,
- "statuses_count": 7,
- "last_status_at": "2023-12-10T09:24:00.000Z",
+ "statuses_count": 8,
+ "last_status_at": "2024-01-10T09:24:00.000Z",
"emojis": [],
"fields": [],
"enable_rss": true,
diff --git a/internal/api/client/statuses/statusmute_test.go b/internal/api/client/statuses/statusmute_test.go
index 83effd0c2..01bea4e5c 100644
--- a/internal/api/client/statuses/statusmute_test.go
+++ b/internal/api/client/statuses/statusmute_test.go
@@ -132,8 +132,8 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() {
"header_description": "A very old-school screenshot of the original team fortress mod for quake",
"followers_count": 2,
"following_count": 2,
- "statuses_count": 7,
- "last_status_at": "2023-12-10T09:24:00.000Z",
+ "statuses_count": 8,
+ "last_status_at": "2024-01-10T09:24:00.000Z",
"emojis": [],
"fields": [],
"enable_rss": true,
@@ -197,8 +197,8 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() {
"header_description": "A very old-school screenshot of the original team fortress mod for quake",
"followers_count": 2,
"following_count": 2,
- "statuses_count": 7,
- "last_status_at": "2023-12-10T09:24:00.000Z",
+ "statuses_count": 8,
+ "last_status_at": "2024-01-10T09:24:00.000Z",
"emojis": [],
"fields": [],
"enable_rss": true,
diff --git a/internal/api/model/attachment.go b/internal/api/model/attachment.go
index 7e81759f2..d0b0c81e5 100644
--- a/internal/api/model/attachment.go
+++ b/internal/api/model/attachment.go
@@ -90,12 +90,23 @@ type Attachment struct {
// A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet.
// See https://github.com/woltapp/blurhash
Blurhash *string `json:"blurhash"`
+}
- // Additional fields not exposed via JSON
- // (used only internally for templating etc).
+// WebAttachment is like Attachment, but with
+// additional fields not exposed via JSON;
+// used only internally for templating etc.
+//
+// swagger:ignore
+type WebAttachment struct {
+ *Attachment
- // Parent status of this media is sensitive.
- Sensitive bool `json:"-"`
+ // Parent status of this
+ // media is sensitive.
+ Sensitive bool
+
+ // MIME type of
+ // the attachment.
+ MIMEType string
}
// MediaMeta models media metadata.
diff --git a/internal/api/model/status.go b/internal/api/model/status.go
index a9c668565..e469835bd 100644
--- a/internal/api/model/status.go
+++ b/internal/api/model/status.go
@@ -111,6 +111,10 @@ type Status struct {
type WebStatus struct {
*Status
+ // Web version of media
+ // attached to this status.
+ MediaAttachments []*WebAttachment `json:"media_attachments"`
+
// Template-ready language tag and
// string, based on *status.Language.
LanguageTag *language.Language
diff --git a/internal/db/bundb/account_test.go b/internal/db/bundb/account_test.go
index a9554e0d7..116ea19f0 100644
--- a/internal/db/bundb/account_test.go
+++ b/internal/db/bundb/account_test.go
@@ -46,7 +46,7 @@ type AccountTestSuite struct {
func (suite *AccountTestSuite) TestGetAccountStatuses() {
statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 20, false, false, "", "", false, false)
suite.NoError(err)
- suite.Len(statuses, 7)
+ suite.Len(statuses, 8)
}
func (suite *AccountTestSuite) TestGetAccountStatusesPageDown() {
@@ -69,7 +69,7 @@ func (suite *AccountTestSuite) TestGetAccountStatusesPageDown() {
if err != nil {
suite.FailNow(err.Error())
}
- suite.Len(statuses, 1)
+ suite.Len(statuses, 2)
// try to get the last page (should be empty)
statuses, err = suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 3, false, false, statuses[len(statuses)-1].ID, "", false, false)
@@ -187,7 +187,7 @@ func (suite *AccountTestSuite) TestGetAccountStatusesExcludeRepliesExcludesSelfR
func (suite *AccountTestSuite) TestGetAccountStatusesMediaOnly() {
statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 20, false, false, "", "", true, false)
suite.NoError(err)
- suite.Len(statuses, 1)
+ suite.Len(statuses, 2)
}
func (suite *AccountTestSuite) TestGetAccountBy() {
diff --git a/internal/db/bundb/basic_test.go b/internal/db/bundb/basic_test.go
index 6892291d2..f6647c1f5 100644
--- a/internal/db/bundb/basic_test.go
+++ b/internal/db/bundb/basic_test.go
@@ -114,7 +114,7 @@ func (suite *BasicTestSuite) TestGetAllStatuses() {
s := []*gtsmodel.Status{}
err := suite.db.GetAll(context.Background(), &s)
suite.NoError(err)
- suite.Len(s, 23)
+ suite.Len(s, 24)
}
func (suite *BasicTestSuite) TestGetAllNotNull() {
diff --git a/internal/db/bundb/instance_test.go b/internal/db/bundb/instance_test.go
index 4b8ec9962..13fd7f61c 100644
--- a/internal/db/bundb/instance_test.go
+++ b/internal/db/bundb/instance_test.go
@@ -47,7 +47,7 @@ func (suite *InstanceTestSuite) TestCountInstanceUsersRemote() {
func (suite *InstanceTestSuite) TestCountInstanceStatuses() {
count, err := suite.db.CountInstanceStatuses(context.Background(), config.GetHost())
suite.NoError(err)
- suite.Equal(19, count)
+ suite.Equal(20, count)
}
func (suite *InstanceTestSuite) TestCountInstanceStatusesRemote() {
diff --git a/internal/db/bundb/status_test.go b/internal/db/bundb/status_test.go
index 0111dc6e7..b64177c32 100644
--- a/internal/db/bundb/status_test.go
+++ b/internal/db/bundb/status_test.go
@@ -169,12 +169,7 @@ func (suite *StatusTestSuite) TestGetStatusChildren() {
targetStatus := suite.testStatuses["local_account_1_status_1"]
children, err := suite.db.GetStatusChildren(context.Background(), targetStatus.ID)
suite.NoError(err)
- suite.Len(children, 2)
- for _, c := range children {
- suite.Equal(targetStatus.URI, c.InReplyToURI)
- suite.Equal(targetStatus.AccountID, c.InReplyToAccountID)
- suite.Equal(targetStatus.ID, c.InReplyToID)
- }
+ suite.Len(children, 3)
}
func (suite *StatusTestSuite) TestDeleteStatus() {
diff --git a/internal/db/bundb/timeline_test.go b/internal/db/bundb/timeline_test.go
index 0a014d321..b944bd9b4 100644
--- a/internal/db/bundb/timeline_test.go
+++ b/internal/db/bundb/timeline_test.go
@@ -155,7 +155,7 @@ func (suite *TimelineTestSuite) TestGetHomeTimeline() {
suite.FailNow(err.Error())
}
- suite.checkStatuses(s, id.Highest, id.Lowest, 19)
+ suite.checkStatuses(s, id.Highest, id.Lowest, 20)
}
func (suite *TimelineTestSuite) TestGetHomeTimelineNoFollowing() {
@@ -187,7 +187,7 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineNoFollowing() {
suite.FailNow(err.Error())
}
- suite.checkStatuses(s, id.Highest, id.Lowest, 7)
+ suite.checkStatuses(s, id.Highest, id.Lowest, 8)
}
func (suite *TimelineTestSuite) TestGetHomeTimelineWithFutureStatus() {
@@ -209,7 +209,7 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineWithFutureStatus() {
}
suite.NotContains(s, futureStatus)
- suite.checkStatuses(s, id.Highest, id.Lowest, 19)
+ suite.checkStatuses(s, id.Highest, id.Lowest, 20)
}
func (suite *TimelineTestSuite) TestGetHomeTimelineBackToFront() {
@@ -240,8 +240,8 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineFromHighest() {
}
suite.checkStatuses(s, id.Highest, id.Lowest, 5)
- suite.Equal("01HH9KYNQPA416TNJ53NSATP40", s[0].ID)
- suite.Equal("01G20ZM733MGN8J344T4ZDDFY1", s[len(s)-1].ID)
+ suite.Equal("01J2M1HPFSS54S60Y0KYV23KJE", s[0].ID)
+ suite.Equal("01G36SF3V6Y6V5BF9P4R7PQG7G", s[len(s)-1].ID)
}
func (suite *TimelineTestSuite) TestGetListTimelineNoParams() {
diff --git a/internal/media/ffmpeg.go b/internal/media/ffmpeg.go
index eb94849f0..b97c8413f 100644
--- a/internal/media/ffmpeg.go
+++ b/internal/media/ffmpeg.go
@@ -18,6 +18,7 @@
package media
import (
+ "cmp"
"context"
"encoding/json"
"errors"
@@ -198,6 +199,30 @@ func (res *ffprobeResult) ImageMeta() (width int, height int, err error) {
return
}
+// EmbeddedImageMeta extracts embedded image metadata contained within ffprobe'd media result
+// streams, should be used for pulling album image (can be animated image) from audio files.
+func (res *ffprobeResult) EmbeddedImageMeta() (width int, height int, framerate float32, err error) {
+ for _, stream := range res.Streams {
+ if stream.Width > width {
+ width = stream.Width
+ }
+ if stream.Height > height {
+ height = stream.Height
+ }
+ if fr := stream.GetFrameRate(); fr > 0 {
+ if framerate == 0 || fr < framerate {
+ framerate = fr
+ }
+ }
+ }
+ // Need width + height but
+ // no framerate is fine.
+ if width == 0 || height == 0 {
+ err = errors.New("invalid image stream(s)")
+ }
+ return
+}
+
// VideoMeta extracts video metadata contained within ffprobe'd media result streams.
func (res *ffprobeResult) VideoMeta() (width, height int, framerate float32, err error) {
for _, stream := range res.Streams {
@@ -222,6 +247,7 @@ func (res *ffprobeResult) VideoMeta() (width, height int, framerate float32, err
type ffprobeStream struct {
CodecName string `json:"codec_name"`
AvgFrameRate string `json:"avg_frame_rate"`
+ RFrameRate string `json:"r_frame_rate"`
Width int `json:"width"`
Height int `json:"height"`
// + unused fields.
@@ -229,7 +255,7 @@ type ffprobeStream struct {
// GetFrameRate calculates float32 framerate value from stream json string.
func (str *ffprobeStream) GetFrameRate() float32 {
- if str.AvgFrameRate != "" {
+ numDen := func(strFR string) (float32, float32) {
var (
// numerator
num float32
@@ -239,7 +265,7 @@ func (str *ffprobeStream) GetFrameRate() float32 {
)
// Check for a provided inequality, i.e. numerator / denominator.
- if p := strings.SplitN(str.AvgFrameRate, "/", 2); len(p) == 2 {
+ if p := strings.SplitN(strFR, "/", 2); len(p) == 2 {
n, _ := strconv.ParseFloat(p[0], 32)
d, _ := strconv.ParseFloat(p[1], 32)
num, den = float32(n), float32(d)
@@ -248,8 +274,26 @@ func (str *ffprobeStream) GetFrameRate() float32 {
num = float32(n)
}
- return num / den
+ return num, den
}
+
+ var num, den float32
+ if str.AvgFrameRate != "" {
+ // Check if we have avg_frame_rate.
+ num, den = numDen(str.AvgFrameRate)
+ }
+
+ if num == 0 && str.RFrameRate != "" {
+ // Check if we have r_frame_rate.
+ num, den = numDen(str.RFrameRate)
+ }
+
+ if num != 0 {
+ // Found it.
+ // Avoid divide by zero.
+ return num / cmp.Or(den, 1)
+ }
+
return 0
}
diff --git a/internal/media/processingmedia.go b/internal/media/processingmedia.go
index 43e153a4d..8ee242749 100644
--- a/internal/media/processingmedia.go
+++ b/internal/media/processingmedia.go
@@ -299,8 +299,14 @@ func (p *ProcessingMedia) store(ctx context.Context) error {
// Extract image metadata from streams (if any),
// this will only exist for embedded album art.
- width, height, _ := result.ImageMeta()
+ width, height, framerate, _ := result.EmbeddedImageMeta()
if width > 0 && height > 0 {
+ // Unlikely to need these but masto API includes them.
+ p.media.FileMeta.Original.Width = width
+ p.media.FileMeta.Original.Height = height
+ if framerate != 0 {
+ p.media.FileMeta.Original.Framerate = &framerate
+ }
// Determine thumbnail dimensions to use.
thumbWidth, thumbHeight := thumbSize(width, height)
diff --git a/internal/processing/account/rss_test.go b/internal/processing/account/rss_test.go
index 226e994cc..40eeaa328 100644
--- a/internal/processing/account/rss_test.go
+++ b/internal/processing/account/rss_test.go
@@ -41,11 +41,11 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSAdmin() {
func (suite *GetRSSTestSuite) TestGetAccountRSSZork() {
getFeed, lastModified, err := suite.accountProcessor.GetRSSFeedForUsername(context.Background(), "the_mighty_zork")
suite.NoError(err)
- suite.EqualValues(1702200240, lastModified.Unix())
+ suite.EqualValues(1704878640, lastModified.Unix())
feed, err := getFeed()
suite.NoError(err)
- suite.Equal("
<section class="about-user">\n <div class="col-header">\n <h2>About</h2>\n </div> \n <div class="fields">\n <h3 class="sr-only">Fields</h3>\n <dl>\n <div class="field">\n <dt>should you follow me?</dt>\n <dd>maybe!</dd>\n </div>\n <div class="field">\n <dt>age</dt>\n <dd>120</dd>\n </div>\n </dl>\n </div>\n <div class="bio">\n <h3 class="sr-only">Bio</h3>\n <p>i post about things that concern me</p>\n </div>\n <div class="sr-only" role="group">\n <h3 class="sr-only">Stats</h3>\n <span>Joined in Jun, 2022.</span>\n <span>8 posts.</span>\n <span>Followed by 1.</span>\n <span>Following 1.</span>\n </div>\n <div class="accountstats" aria-hidden="true">\n <b>Joined</b><time datetime="2022-06-04T13:12:00.000Z">Jun, 2022</time>\n <b>Posts</b><span>8</span>\n <b>Followed by</b><span>1</span>\n <b>Following</b><span>1</span>\n </div>\n</section>\n
There, hope you liked that!
]]>\n<section class="about-user">\n <div class="col-header">\n <h2>About</h2>\n </div> \n <div class="fields">\n <h3 class="sr-only">Fields</h3>\n <dl>\n <div class="field">\n <dt>should you follow me?</dt>\n <dd>maybe!</dd>\n </div>\n <div class="field">\n <dt>age</dt>\n <dd>120</dd>\n </div>\n </dl>\n </div>\n <div class="bio">\n <h3 class="sr-only">Bio</h3>\n <p>i post about things that concern me</p>\n </div>\n <div class="sr-only" role="group">\n <h3 class="sr-only">Stats</h3>\n <span>Joined in Jun, 2022.</span>\n <span>8 posts.</span>\n <span>Followed by 1.</span>\n <span>Following 1.</span>\n </div>\n <div class="accountstats" aria-hidden="true">\n <b>Joined</b><time datetime="2022-06-04T13:12:00.000Z">Jun, 2022</time>\n <b>Posts</b><span>8</span>\n <b>Followed by</b><span>1</span>\n <b>Following</b><span>1</span>\n </div>\n</section>\n
There, hope you liked that!
]]>J5Lvh(H
znd{}ptEnn#NRLwKN?e8(dB#d_HssM&H^U%USPurT{~zdno)J>==RQ8^rc~bo-zgzC2N&{n3y5Z5P
zzdDw~^}4IL8OfVIF&?jQ_