Home Timeline working with image viewing

This commit is contained in:
jfm 2024-05-02 20:22:35 +02:00
parent 2be82fb169
commit 661aee6e14
6 changed files with 498 additions and 46 deletions

View File

@ -13,6 +13,7 @@ textual = "*"
pydantic = "*"
pymastodon = {version="*", index="moerks"}
markdownify = "*"
pillow = "*"
[dev-packages]

89
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "0ff61328a9369d70ce7094e72d664495cbce99af0e1d0a0f6a81528b966a9ec5"
"sha256": "0c92ab9c7331b4c4ef3202f85b81a76f25fc007fb7d6cbf33eb6bc4882e1929d"
},
"pipfile-spec": 6,
"requires": {
@ -199,6 +199,81 @@
"markers": "python_version >= '3.7'",
"version": "==0.1.2"
},
"pillow": {
"hashes": [
"sha256:048ad577748b9fa4a99a0548c64f2cb8d672d5bf2e643a739ac8faff1164238c",
"sha256:048eeade4c33fdf7e08da40ef402e748df113fd0b4584e32c4af74fe78baaeb2",
"sha256:0ba26351b137ca4e0db0342d5d00d2e355eb29372c05afd544ebf47c0956ffeb",
"sha256:0ea2a783a2bdf2a561808fe4a7a12e9aa3799b701ba305de596bc48b8bdfce9d",
"sha256:1530e8f3a4b965eb6a7785cf17a426c779333eb62c9a7d1bbcf3ffd5bf77a4aa",
"sha256:16563993329b79513f59142a6b02055e10514c1a8e86dca8b48a893e33cf91e3",
"sha256:19aeb96d43902f0a783946a0a87dbdad5c84c936025b8419da0a0cd7724356b1",
"sha256:1a1d1915db1a4fdb2754b9de292642a39a7fb28f1736699527bb649484fb966a",
"sha256:1b87bd9d81d179bd8ab871603bd80d8645729939f90b71e62914e816a76fc6bd",
"sha256:1dfc94946bc60ea375cc39cff0b8da6c7e5f8fcdc1d946beb8da5c216156ddd8",
"sha256:2034f6759a722da3a3dbd91a81148cf884e91d1b747992ca288ab88c1de15999",
"sha256:261ddb7ca91fcf71757979534fb4c128448b5b4c55cb6152d280312062f69599",
"sha256:2ed854e716a89b1afcedea551cd85f2eb2a807613752ab997b9974aaa0d56936",
"sha256:3102045a10945173d38336f6e71a8dc71bcaeed55c3123ad4af82c52807b9375",
"sha256:339894035d0ede518b16073bdc2feef4c991ee991a29774b33e515f1d308e08d",
"sha256:412444afb8c4c7a6cc11a47dade32982439925537e483be7c0ae0cf96c4f6a0b",
"sha256:4203efca580f0dd6f882ca211f923168548f7ba334c189e9eab1178ab840bf60",
"sha256:45ebc7b45406febf07fef35d856f0293a92e7417ae7933207e90bf9090b70572",
"sha256:4b5ec25d8b17217d635f8935dbc1b9aa5907962fae29dff220f2659487891cd3",
"sha256:4c8e73e99da7db1b4cad7f8d682cf6abad7844da39834c288fbfa394a47bbced",
"sha256:4e6f7d1c414191c1199f8996d3f2282b9ebea0945693fb67392c75a3a320941f",
"sha256:4eaa22f0d22b1a7e93ff0a596d57fdede2e550aecffb5a1ef1106aaece48e96b",
"sha256:50b8eae8f7334ec826d6eeffaeeb00e36b5e24aa0b9df322c247539714c6df19",
"sha256:50fd3f6b26e3441ae07b7c979309638b72abc1a25da31a81a7fbd9495713ef4f",
"sha256:51243f1ed5161b9945011a7360e997729776f6e5d7005ba0c6879267d4c5139d",
"sha256:5d512aafa1d32efa014fa041d38868fda85028e3f930a96f85d49c7d8ddc0383",
"sha256:5f77cf66e96ae734717d341c145c5949c63180842a545c47a0ce7ae52ca83795",
"sha256:6b02471b72526ab8a18c39cb7967b72d194ec53c1fd0a70b050565a0f366d355",
"sha256:6fb1b30043271ec92dc65f6d9f0b7a830c210b8a96423074b15c7bc999975f57",
"sha256:7161ec49ef0800947dc5570f86568a7bb36fa97dd09e9827dc02b718c5643f09",
"sha256:72d622d262e463dfb7595202d229f5f3ab4b852289a1cd09650362db23b9eb0b",
"sha256:74d28c17412d9caa1066f7a31df8403ec23d5268ba46cd0ad2c50fb82ae40462",
"sha256:78618cdbccaa74d3f88d0ad6cb8ac3007f1a6fa5c6f19af64b55ca170bfa1edf",
"sha256:793b4e24db2e8742ca6423d3fde8396db336698c55cd34b660663ee9e45ed37f",
"sha256:798232c92e7665fe82ac085f9d8e8ca98826f8e27859d9a96b41d519ecd2e49a",
"sha256:81d09caa7b27ef4e61cb7d8fbf1714f5aec1c6b6c5270ee53504981e6e9121ad",
"sha256:8ab74c06ffdab957d7670c2a5a6e1a70181cd10b727cd788c4dd9005b6a8acd9",
"sha256:8eb0908e954d093b02a543dc963984d6e99ad2b5e36503d8a0aaf040505f747d",
"sha256:90b9e29824800e90c84e4022dd5cc16eb2d9605ee13f05d47641eb183cd73d45",
"sha256:9797a6c8fe16f25749b371c02e2ade0efb51155e767a971c61734b1bf6293994",
"sha256:9d2455fbf44c914840c793e89aa82d0e1763a14253a000743719ae5946814b2d",
"sha256:9d3bea1c75f8c53ee4d505c3e67d8c158ad4df0d83170605b50b64025917f338",
"sha256:9e2ec1e921fd07c7cda7962bad283acc2f2a9ccc1b971ee4b216b75fad6f0463",
"sha256:9e91179a242bbc99be65e139e30690e081fe6cb91a8e77faf4c409653de39451",
"sha256:a0eaa93d054751ee9964afa21c06247779b90440ca41d184aeb5d410f20ff591",
"sha256:a2c405445c79c3f5a124573a051062300936b0281fee57637e706453e452746c",
"sha256:aa7e402ce11f0885305bfb6afb3434b3cd8f53b563ac065452d9d5654c7b86fd",
"sha256:aff76a55a8aa8364d25400a210a65ff59d0168e0b4285ba6bf2bd83cf675ba32",
"sha256:b09b86b27a064c9624d0a6c54da01c1beaf5b6cadfa609cf63789b1d08a797b9",
"sha256:b14f16f94cbc61215115b9b1236f9c18403c15dd3c52cf629072afa9d54c1cbf",
"sha256:b50811d664d392f02f7761621303eba9d1b056fb1868c8cdf4231279645c25f5",
"sha256:b7bc2176354defba3edc2b9a777744462da2f8e921fbaf61e52acb95bafa9828",
"sha256:c78e1b00a87ce43bb37642c0812315b411e856a905d58d597750eb79802aaaa3",
"sha256:c83341b89884e2b2e55886e8fbbf37c3fa5efd6c8907124aeb72f285ae5696e5",
"sha256:ca2870d5d10d8726a27396d3ca4cf7976cec0f3cb706debe88e3a5bd4610f7d2",
"sha256:ccce24b7ad89adb5a1e34a6ba96ac2530046763912806ad4c247356a8f33a67b",
"sha256:cd5e14fbf22a87321b24c88669aad3a51ec052eb145315b3da3b7e3cc105b9a2",
"sha256:ce49c67f4ea0609933d01c0731b34b8695a7a748d6c8d186f95e7d085d2fe475",
"sha256:d33891be6df59d93df4d846640f0e46f1a807339f09e79a8040bc887bdcd7ed3",
"sha256:d3b2348a78bc939b4fed6552abfd2e7988e0f81443ef3911a4b8498ca084f6eb",
"sha256:d886f5d353333b4771d21267c7ecc75b710f1a73d72d03ca06df49b09015a9ef",
"sha256:d93480005693d247f8346bc8ee28c72a2191bdf1f6b5db469c096c0c867ac015",
"sha256:dc1a390a82755a8c26c9964d457d4c9cbec5405896cba94cf51f36ea0d855002",
"sha256:dd78700f5788ae180b5ee8902c6aea5a5726bac7c364b202b4b3e3ba2d293170",
"sha256:e46f38133e5a060d46bd630faa4d9fa0202377495df1f068a8299fd78c84de84",
"sha256:e4b878386c4bf293578b48fc570b84ecfe477d3b77ba39a6e87150af77f40c57",
"sha256:f0d0591a0aeaefdaf9a5e545e7485f89910c977087e7de2b6c388aec32011e9f",
"sha256:fdcbb4068117dfd9ce0138d068ac512843c52295ed996ae6dd1faf537b6dbc27",
"sha256:ff61bfd9253c3915e6d41c651d5f962da23eda633cf02262990094a18a55371a"
],
"index": "pypi",
"version": "==10.3.0"
},
"pydantic": {
"hashes": [
"sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5",
@ -302,11 +377,11 @@
},
"pymastodon": {
"hashes": [
"sha256:290e12ba161a4c87374ac55a437ee78edf3d0d1dc6d65ab653ceebdd0cba2dfb",
"sha256:32526b5da307ad6f64ca5ff028437d23de07e4237e04fc0111ca8522b6f89fd5"
"sha256:3c4c054e65368c7c4786fc48d97a64efcced3c6eb95084ee852ccccfc8d04d81",
"sha256:7ae4df3e669a749f257a5cd8c6866d9331cf81412f31539dda31e17748493452"
],
"index": "moerks",
"version": "==0.8.0"
"version": "==0.16.0"
},
"requests": {
"hashes": [
@ -342,11 +417,11 @@
},
"textual": {
"hashes": [
"sha256:5c8c3322308e2b932c4550b0ae9f70daebc39716de3f920831cda96d1640b383",
"sha256:9daddf713cb64d186fa1ae647fea482dc84b643c9284132cd87adb99cd81d638"
"sha256:3a01be0b583f2bce38b8e9786b75ed33dddc816bba502d8e7a9ca3ca2ead3957",
"sha256:9902ebb4b00481f6fdb0e7db821c007afa45797d81e1d0651735a07de25ece87"
],
"index": "pypi",
"version": "==0.58.0"
"version": "==0.58.1"
},
"typing-extensions": {
"hashes": [

View File

@ -1,5 +1,6 @@
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, ListView
from textual.containers import Container
from renderers.status import StatusListItemRenderer
from mastodon.application import MastodonApplication
from mastodon.model.status import Status
@ -12,17 +13,21 @@ class Mastotui(App):
logger.add("mastotui.log", level="DEBUG", rotation="100 MB")
logger.remove(0)
CSS_PATH="mastotui.tcss"
BINDINGS = [("H", "home", "Home"),("N", "notifications", "Notifications"),("R", "refresh", "Refresh")]
renderer = StatusListItemRenderer()
mastodon_app = MastodonApplication("mastotui")
statuses = []
def compose(self) -> ComposeResult:
yield Header()
yield ListView(id="status_list_view")
yield Footer()
def compose(self) -> ComposeResult:
yield Container(
Header(classes="app_header"),
ListView(id="status_list_view"),
Footer(),
id="dialog"
)
def action_home(self) -> None:
self.notify("HOME", timeout=5)
@ -31,9 +36,8 @@ class Mastotui(App):
def action_refresh(self) -> None:
list_view = self.app.query_one("#status_list_view")
self.notify("Refresh", timeout=5)
response = self.mastodon_app.timelines.home_timeline()
self.statuses.clear()
if len(self.statuses) == 0:
response = self.mastodon_app.timelines.home()
for status in response:
try:
logger.trace(status)
@ -41,9 +45,21 @@ class Mastotui(App):
except Exception as ve:
logger.debug(status)
logger.error(ve)
else:
response = self.mastodon_app.timelines.home(since_id=self.statuses[0].status_id)
refresh_list = []
for status in response:
try:
logger.trace(status)
refresh_list.append(Status.model_validate(status))
except Exception as ve:
logger.debug(status)
logger.error(ve)
self.statuses = refresh_list + self.statuses
list_view.clear()
list_view.extend(self.renderer.render_toots(self.statuses))
self.notify("Refreshed Home Timeline", timeout=3)
def on_list_view_selected(self, event: ListView.Selected ) -> None:
logger.debug("Select Status Id: {}".format(event.item.status.status_id))

212
src/mastotui.tcss Normal file
View File

@ -0,0 +1,212 @@
$polar_night_darkest: #2e3440;
$polar_night_dark: #3b4252;
$polar_night_light: #434c5e;
$polar_night_lightest: #4c566a;
$snow_storm_dark: #d8dee9;
$snow_storm: #e5e9f0;
$snow_storm_light: #eceff4;
$frost_lightest: #8fbcbb;
$frost_light: #88c0d0;
$frost_dark: #81a1c1;
$frost_darkest: #5e81ac;
$aurora_red: #bf616a;
$aurora_orange: #d08770;
$aurora_yellow: #ebcb8b;
$aurora_green: #a3be8c;
$aurora_purple: #b48ead;
StatusScreen {
align: center middle;
}
StatusScreen > Container {
width: 80%;
height: auto;
border: heavy $snow_storm_dark;
}
StatusScreen > Container > StatusFooter {
background: $snow_storm;
height: 1w;
}
.status_footer_key {
background: $snow_storm_dark;
color: $frost_darkest;
padding-left: 1;
padding-right: 1;
}
.status_footer_action {
background: $snow_storm;
color: $frost_darkest;
padding-left: 1;
padding-right: 1;
}
StatusScreen > Container > StatsBar {
background: $polar_night_light;
height: 1w;
}
.status_header_account {
background: $polar_night_light;
color: $frost_light;
}
StatusListItem .stats_bar {
background: $polar_night_light;
height: 1w;
margin-left: 1
}
.stats_bar_replies {
background: $polar_night_light;
color: $aurora_purple;
text-style: bold;
align-horizontal: left;
padding-left: 1;
padding-right: 1;
}
.stats_bar_replies_value {
background: $polar_night_light;
color: $aurora_green;
text-style: bold;
align-horizontal: left;
padding-left: 1;
padding-right: 1;
}
.stats_bar_reblogs {
background: $polar_night_light;
color: $frost_lightest;
color: $aurora_purple;
text-style: bold;
align-horizontal: left;
padding-left: 1;
padding-right: 1;
}
.stats_bar_reblogs_value {
background: $polar_night_light;
color: $frost_lightest;
color: $aurora_green;
text-style: bold;
align-horizontal: left;
padding-left: 1;
padding-right: 1;
}
.stats_bar_favourites {
background: $polar_night_light;
color: $frost_lightest;
color: $aurora_purple;
text-style: bold;
align-horizontal: left;
padding-left: 1;
padding-right: 1;
}
.stats_bar_favourites_value {
background: $polar_night_light;
color: $frost_lightest;
color: $aurora_green;
text-style: bold;
align-horizontal: left;
padding-left: 1;
padding-right: 1;
}
.time_separator {
background: $polar_night_lightest;
margin-left: 1;
width: 100%;
padding: 0;
margin: 0;
margin-left: 1;
text-align: right;
}
.zoom_status_header {
color: $frost_darkest;
background: $polar_night_dark;
text-style: bold;
height: auto;
width: 100%;
}
.zoom_account_header {
background: $polar_night_light;
color: $frost_light;
text-style: bold;
height: auto;
width: 100%;
}
.zoom_status_content {
background: $polar_night_darkest;
width: 100%;
padding: 0;
margin: 0;
}
.status_header {
color: $frost_darkest;
background: $polar_night_dark;
text-style: bold;
height: auto;
width: 100%;
margin-left: 1;
}
.account_header {
background: $polar_night_light;
color: $frost_light;
text-style: bold;
height: auto;
width: 100%;
margin-left: 1;
}
.status_content {
background: $polar_night_darkest;
width: 100%;
padding: 0;
margin: 0;
margin-left: 1;
}
.status_stats {
background: $polar_night_darkest;
height: auto;
width: 100%;
margin-left: 1;
}
.status_replies {
color: $frost_lightest;
align-horizontal: left;
padding-left: 1;
width: auto;
}
.status_boosts {
color: $frost_lightest;
align-horizontal: left;
padding-left: 2;
width: auto;
}
.status_favourites {
color: $frost_lightest;
align-horizontal: left;
padding-left: 2;
width: auto;
}
.status_time {
color: $polar_night_lightest;
align-horizontal: right;
padding-left: 10;
}
Footer {
background: $snow_storm;
}
.footer--description {
background: $snow_storm;
color: $frost_darkest;
}
.footer--key {
background: $snow_storm_dark;
color: $frost_darkest;
}

View File

@ -1,8 +1,13 @@
from textual.app import ComposeResult
from textual.containers import Container
from textual.containers import Container, Horizontal
from textual.screen import ModalScreen
from textual.widgets import Label
from textual.widgets import Label, Markdown, Button
from loguru import logger
from markdownify import markdownify
from PIL import Image
import requests
from widgets.status import StatsBar, StatusFooter
class StatusScreen(ModalScreen):
@ -10,26 +15,106 @@ class StatusScreen(ModalScreen):
super().__init__()
self.status = status
BINDINGS = [("q", "back", "Go Back")]
DEFAULT_CSS = """
StatusScreen {
align: center middle;
}
StatusScreen > Container {
width: auto;
height: auto;
}
"""
CSS_PATH = "../mastotui.tcss"
BINDINGS = [
("q", "back", "Close"),
("b", "boost", "Boost"),
("f", "favourite", "Favourite"),
("1", "media_1", "1st Media"),
("2", "media_2", "2nd Media"),
("3", "media_3", "3rd Media"),
("4", "media_4", "4th Media")
]
def compose(self) -> ComposeResult:
if self.status.reblog is not None:
status_header = "{} ({}) Boosted".format(self.status.account.display_name, self.status.account.acct)
display_name = self.status.reblog.account.display_name
acct = self.status.reblog.account.acct
content = self.status.reblog.content
created_at = self.status.reblog.created_at
replies = self.status.reblog.replies_count
boosts = self.status.reblog.reblogs_count
favourites = self.status.reblog.favourites_count
self.media_attachments = self.status.reblog.media_attachments
else:
status_header = ""
display_name = self.status.account.display_name
acct = self.status.account.acct
content = self.status.content
created_at = self.status.created_at
replies = self.status.replies_count
boosts = self.status.reblogs_count
favourites = self.status.favourites_count
self.media_attachments = self.status.media_attachments
main_bindings = [("q","Close"),
("b", "Boost"),
("f", "Favourite"),
("r", "Reply")]
variable_bindings = []
for index, attachment in enumerate(self.media_attachments):
if index == 0:
variable_bindings.append(("1", "1st Media"))
if index == 1:
variable_bindings.append(("2", "2nd Media"))
if index == 2:
variable_bindings.append(("3", "3rd Media"))
if index == 3:
variable_bindings.append(("4", "4th Media"))
with Container():
yield(Label(self.status.status_id))
yield Label(status_header, classes="zoom_status_header")
yield Label("{} ({})".format(display_name, acct), classes="zoom_account_header")
yield Markdown(markdownify(content), classes="zoom_status_content")
yield StatsBar(replies, boosts, favourites)
yield StatusFooter(main_bindings, variable_bindings)
def action_back(self) -> None:
logger.debug("Screen Stack {}".format(len(self.app.screen_stack)))
if len(self.app.screen_stack) >= 1:
self.app.pop_screen()
def action_media_1(self) -> None:
if len(self.media_attachments) > 0:
logger.debug("Opening {} with url: {}".format(self.media_attachments[0].media_type, self.media_attachments[0].url))
if self.media_attachments[0].media_type == "image":
image = Image.open(requests.get(self.media_attachments[0].url, stream=True).raw)
image.show()
def action_media_2(self) -> None:
if len(self.media_attachments) > 1:
logger.debug("Opening {}".format(self.media_attachments[1].url))
if self.media_attachments[1].media_type == "image":
image = Image.open(requests.get(self.media_attachments[1].url, stream=True).raw)
image.show()
def action_media_3(self) -> None:
if len(self.media_attachments) > 2:
logger.debug("Opening {}".format(self.media_attachments[2].url))
if self.media_attachments[2].media_type == "image":
image = Image.open(requests.get(self.media_attachments[2].url, stream=True).raw)
image.show()
def action_media_4(self) -> None:
if len(self.media_attachments) > 3:
logger.debug("Opening {}".format(self.media_attachments[3].url))
if self.media_attachments[3].media_type == "image":
image = Image.open(requests.get(self.media_attachments[3].url, stream=True).raw)
image.show()
def build_footer_value(self, media_attachments):
if len(media_attachments) > 0:
footer_value = "q to go back"
for index, media in enumerate(media_attachments):
footer_value = footer_value + " | [{}] Show Media".format(index+1)
return footer_value
else:
return "q to go back"

View File

@ -1,9 +1,57 @@
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.widget import Widget
from textual.widgets import ListItem, Label, Markdown
from mastodon.model.status import Status
from loguru import logger
from markdownify import markdownify
class StatusFooter(Widget):
def __init__(self, main_bindings, variable_bindings):
super().__init__()
self.bindings = main_bindings + variable_bindings
def compose(self) -> ComposeResult:
binding_list = []
for binding in self.bindings:
binding_list.append(Label(binding[0], classes="status_footer_key"))
binding_list.append(Label(binding[1], classes="status_footer_action"))
yield Horizontal(*binding_list, classes="status_footer")
class StatusHeader(Widget):
def __init__(self, account_display_name = None, account_acct = None, boost_display_name = None, boost_acct = None):
super().__init__()
self.account_display_name = account_display_name
self.account_acct = account_acct
self.boost_display_name = boost_display_name
self.boost_acct = boost_acct
def compose(self) -> ComposeResult:
accounts = []
if self.boost_display_name is not None and self.boost_acct is not None:
accounts.append(Label("{} ({}) Boosted -> ".format(self.boost_display_name, self.boost_acct), classes="status_header_boost"))
accounts.append(Label("{} ({})".format(self.account_display_name, self.account_acct), classes="status_header_account"))
yield Horizontal(*accounts, classes="status_header")
class StatsBar(Widget):
def __init__(self, replies, reblogs, favourites):
super().__init__()
self.replies = replies
self.reblogs = reblogs
self.favourites = favourites
def compose(self) -> ComposeResult:
yield Horizontal(
Label("Replies", classes="stats_bar_replies"),
Label("{}".format(self.replies), classes="stats_bar_replies_value"),
Label("Boosts", classes="stats_bar_reblogs"),
Label("{}".format(self.reblogs), classes="stats_bar_reblogs_value"),
Label("Favourited".format(self.favourites), classes="stats_bar_favourites"),
Label("{}".format(self.favourites), classes="stats_bar_favourites_value"),
classes="stats_bar"
)
class StatusListItem(ListItem):
def __init__(self, status: Status):
super().__init__()
@ -11,16 +59,31 @@ class StatusListItem(ListItem):
def compose(self) -> ComposeResult:
logger.trace("ID: {} {}".format(self.status.status_id, self.status.content))
yield Label(self.status.status_id)
status_header = ""
content = ""
if self.status.content == "":
status_header = "{} ({}) Reblogged".format(self.status.account.display_name, self.status.account.acct)
# yield Label(self.status.status_id)
boost_display_name = None
boost_acct = None
display_name = None
acct = None
if self.status.reblog is not None:
boost_display_name = self.status.account.display_name
boost_acct = self.status.account.acct
display_name = self.status.reblog.account.display_name
acct = self.status.reblog.account.acct
content = self.status.reblog.content
yield Label(status_header, id="status_header")
yield Label(self.status.reblog.account.display_name, id="account_header")
created_at = self.status.reblog.created_at
replies = self.status.reblog.replies_count
boosts = self.status.reblog.reblogs_count
favourites = self.status.reblog.favourites_count
else:
yield Label("{} ({})".format(self.status.account.display_name, self.status.account.acct), id="account_header")
display_name = self.status.account.display_name
acct = self.status.account.acct
content = self.status.content
created_at = self.status.created_at
replies = self.status.replies_count
boosts = self.status.reblogs_count
favourites = self.status.favourites_count
yield Markdown(markdownify(content), id="status_content")
yield StatusHeader(display_name, acct, boost_display_name, boost_acct)
yield Markdown(markdownify(content), classes="status_content")
yield StatsBar(replies, boosts, favourites)
yield Label("{}".format(created_at), classes="time_separator")