Compare commits

...

2 commits

Author SHA1 Message Date
5e7b2354e2
feat(web): better layout for lemmy posts
image on the side that expands on click, text that "reflows" under the
image, attachments don't overflow etc. also mentions. also refactored a
bit. since i refactored its hard to split these 3 changes so have one
big commit aha
2024-07-04 02:14:50 +02:00
b53bd5527f
fix(mdhtml): don't add rel and target in mentions 2024-07-04 02:12:52 +02:00
10 changed files with 183 additions and 133 deletions

View file

@ -51,12 +51,15 @@ impl TokenSink for Sink {
} }
}, },
"a" => { "a" => {
let any_attr = !tag.attrs.is_empty(); let mut any_attr = !tag.attrs.is_empty();
for attr in tag.attrs { for attr in tag.attrs {
match attr.name.local.as_ref() { match attr.name.local.as_ref() {
"href" => self.buffer.push_str(&format!(" href=\"{}\"", attr.value.as_ref())), "href" => self.buffer.push_str(&format!(" href=\"{}\"", attr.value.as_ref())),
"title" => self.buffer.push_str(&format!(" title=\"{}\"", attr.value.as_ref())), "title" => self.buffer.push_str(&format!(" title=\"{}\"", attr.value.as_ref())),
"class" => if attr.value.as_ref() == "u-url mention" { self.buffer.push_str(" class=\"u-url mention\"") }, "class" => if attr.value.as_ref() == "u-url mention" {
any_attr = false;
self.buffer.push_str(" class=\"u-url mention\"")
},
_ => {}, _ => {},
} }
} }

View file

@ -94,6 +94,9 @@
article p { article p {
margin: 0 0 0 .5em; margin: 0 0 0 .5em;
} }
article.float-container {
overflow-y: hidden;
}
b.displayname { b.displayname {
overflow-wrap: break-word; overflow-wrap: break-word;
} }
@ -132,15 +135,16 @@
position: sticky; position: sticky;
background-color: var(--background); background-color: var(--background);
} }
div.border {
border: 1px dashed var(--accent);
}
span.border-button { span.border-button {
border: 1px solid var(--background-dim); border: 1px solid var(--background-dim);
} }
span.border-button:hover { span.border-button:hover {
background-color: var(--background-dim); background-color: var(--background-dim);
} }
div.border,
span.border {
border: 1px dashed var(--accent);
}
div.inline { div.inline {
display: inline; display: inline;
} }
@ -203,18 +207,25 @@
min-width: 2em; min-width: 2em;
max-width: 2em; max-width: 2em;
} }
div.flex-pic-container { img.flex-pic {
flex: 1; float: left;
border: solid 3px #bf616a; width: 10em;
margin-right: .5em; height: 10em;
object-fit: cover;
margin-right: 1em;
margin-top: .5em;
margin-bottom: .5em;
margin-left: .5em;
} }
div.flex-pic { img.flex-pic-expand {
background-size: cover; width: unset;
margin: 5px; height: unset;
height: calc(100% - 10px); /* TODO can we avoid this calc() without having this overflow??? */ max-width: calc(100% - 1.5em);
max-height: 55vh;
} }
.box { .box {
border: 3px solid var(--accent); border: 3px solid var(--accent);
box-sizing: border-box;
} }
.cursor { .cursor {
cursor: pointer; cursor: pointer;

View file

@ -55,14 +55,14 @@ pub fn Item(
match item.object_type().unwrap_or(apb::ObjectType::Object) { match item.object_type().unwrap_or(apb::ObjectType::Object) {
// special case for placeholder activities // special case for placeholder activities
apb::ObjectType::Note | apb::ObjectType::Document(_) => apb::ObjectType::Note | apb::ObjectType::Document(_) =>
Some(view! { <Object object=item.clone() />{sep.clone()} }.into_view()), Some(view! { <Object object=item.clone() reply=replies />{sep.clone()} }.into_view()),
// everything else // everything else
apb::ObjectType::Activity(t) => { apb::ObjectType::Activity(t) => {
let object_id = item.object().id().str().unwrap_or_default(); let object_id = item.object().id().str().unwrap_or_default();
let object = match t { let object = match t {
apb::ActivityType::Create | apb::ActivityType::Announce => apb::ActivityType::Create | apb::ActivityType::Announce =>
cache::OBJECTS.get(&object_id).map(|obj| { cache::OBJECTS.get(&object_id).map(|obj| {
view! { <Object object=obj /> } view! { <Object object=obj reply=replies /> }
}.into_view()), }.into_view()),
apb::ActivityType::Follow => apb::ActivityType::Follow =>
cache::OBJECTS.get(&object_id).map(|obj| { cache::OBJECTS.get(&object_id).map(|obj| {

View file

@ -0,0 +1 @@
pub mod item;

View file

@ -1,15 +1,9 @@
mod login; mod login;
pub use login::*; pub use login::*;
mod activity;
pub use activity::*;
mod navigation; mod navigation;
pub use navigation::*; pub use navigation::*;
mod object;
pub use object::*;
mod user; mod user;
pub use user::*; pub use user::*;

View file

@ -3,12 +3,14 @@ mod app;
mod components; mod components;
mod page; mod page;
mod config; mod config;
mod objects;
mod actors; mod actors;
mod getters; mod activities;
mod objects;
mod timeline; mod timeline;
mod getters;
pub use app::App; pub use app::App;
pub use config::Config; pub use config::Config;
pub use auth::Auth; pub use auth::Auth;

View file

@ -0,0 +1,100 @@
use leptos::*;
use crate::{prelude::*, URL_SENSITIVE};
use apb::{field::OptionalString, target::Addressed, ActivityMut, Base, Collection, CollectionMut, Document, Object, ObjectMut};
#[component]
pub fn Attachment(
object: serde_json::Value,
#[prop(optional)]
sensitive: bool
) -> impl IntoView {
let config = use_context::<Signal<crate::Config>>().expect("missing config context");
let (expand, set_expand) = create_signal(false);
let href = object.url().id().str().unwrap_or_default();
let media_type = object.media_type()
.unwrap_or("link") // TODO make it an Option rather than defaulting to link everywhere
.to_string();
let mut kind = media_type
.split('/')
.next()
.unwrap_or("link")
.to_string();
// TODO in theory we should match on document_type, but mastodon and misskey send all attachments
// as "Documents" regardless of type, so we're forced to ignore the actual AP type and just match
// using media_type, uffff
//
// those who correctly send Image type objects without a media type get shown as links here, this
// is a dirty fix to properly display as images
if kind == "link" && matches!(object.document_type(), Ok(apb::DocumentType::Image)) {
kind = "image".to_string();
}
match kind.as_str() {
"image" =>
view! {
<p class="center">
<img
class="w-100 attachment"
class:expand=expand
src={move || if sensitive && !expand.get() {
URL_SENSITIVE.to_string()
} else {
href.clone()
}}
title={object.name().unwrap_or_default().to_string()}
on:click=move |_| set_expand.set(!expand.get())
/>
</p>
}.into_view(),
"video" => {
let _href = href.clone();
view! {
<div class="center cursor box ml-1"
on:click=move |_| set_expand.set(!expand.get())
title={object.name().unwrap_or_default().to_string()}
>
<video controls class="attachment" class:expand=expand prop:loop=move || config.get().loop_videos >
{move || if sensitive && !expand.get() { None } else { Some(view! { <source src={_href.clone()} type={media_type.clone()} /> }) }}
<a href={href.clone()} target="_blank">video clip</a>
</video>
</div>
}.into_view()
},
"audio" =>
view! {
<p class="center">
<audio controls class="w-100" prop:loop=move || config.get().loop_videos >
<source src={href.clone()} type={media_type} />
<a href={href} target="_blank">audio clip</a>
</audio>
</p>
}.into_view(),
"link" | "text" =>
view! {
<p class="mt-s mb-s">
<a title={href.clone()} href={href.clone()} rel="noreferrer nofollow" target="_blank">
{Uri::pretty(&href, 50)}
</a>
</p>
}.into_view(),
_ =>
view! {
<p class="center box">
<code class="cw color center">
<a href={href} target="_blank">{media_type}</a>
</code>
{object.name().map(|name| {
view! { <p class="tiny-text"><small>{name.to_string()}</small></p> }
})}
</p>
}.into_view(),
}
}

View file

@ -1,109 +1,22 @@
use std::sync::Arc; use std::sync::Arc;
use cache::WEBFINGER;
use leptos::*; use leptos::*;
use crate::{prelude::*, URL_SENSITIVE}; use regex::Regex;
use crate::prelude::*;
use apb::{field::OptionalString, target::Addressed, ActivityMut, Base, Collection, CollectionMut, Document, Object, ObjectMut}; use apb::{field::OptionalString, target::Addressed, ActivityMut, Base, Collection, CollectionMut, Document, Object, ObjectMut};
lazy_static::lazy_static! {
static ref REGEX: Regex = regex::Regex::new("<a href=\"(.+)\" class=\"u-url mention\">@(\\w+)(@\\w+|)</a>").expect("failed compiling @ regex");
}
#[component] #[component]
pub fn Attachment( pub fn Object(
object: serde_json::Value, object: crate::Object,
#[prop(optional)] #[prop(optional)] reply: bool,
sensitive: bool
) -> impl IntoView { ) -> impl IntoView {
let config = use_context::<Signal<crate::Config>>().expect("missing config context");
let (expand, set_expand) = create_signal(false);
let href = object.url().id().str().unwrap_or_default();
let media_type = object.media_type()
.unwrap_or("link") // TODO make it an Option rather than defaulting to link everywhere
.to_string();
let mut kind = media_type
.split('/')
.next()
.unwrap_or("link")
.to_string();
// TODO in theory we should match on document_type, but mastodon and misskey send all attachments
// as "Documents" regardless of type, so we're forced to ignore the actual AP type and just match
// using media_type, uffff
//
// those who correctly send Image type objects without a media type get shown as links here, this
// is a dirty fix to properly display as images
if kind == "link" && matches!(object.document_type(), Ok(apb::DocumentType::Image)) {
kind = "image".to_string();
}
match kind.as_str() {
"image" =>
view! {
<p class="center">
<img
class="w-100 attachment"
class:expand=expand
src={move || if sensitive && !expand.get() {
URL_SENSITIVE.to_string()
} else {
href.clone()
}}
title={object.name().unwrap_or_default().to_string()}
on:click=move |_| set_expand.set(!expand.get())
/>
</p>
}.into_view(),
"video" => {
let _href = href.clone();
view! {
<div class="center cursor box ml-1"
on:click=move |_| set_expand.set(!expand.get())
title={object.name().unwrap_or_default().to_string()}
>
<video controls class="attachment" class:expand=expand prop:loop=move || config.get().loop_videos >
{move || if sensitive && !expand.get() { None } else { Some(view! { <source src={_href.clone()} type={media_type.clone()} /> }) }}
<a href={href.clone()} target="_blank">video clip</a>
</video>
</div>
}.into_view()
},
"audio" =>
view! {
<p class="center">
<audio controls class="w-100" prop:loop=move || config.get().loop_videos >
<source src={href.clone()} type={media_type} />
<a href={href} target="_blank">audio clip</a>
</audio>
</p>
}.into_view(),
"link" | "text" =>
view! {
<p class="center mt-s mb-s">
<a href={href.clone()} title={href.clone()} rel="noreferrer nofollow" target="_blank">
<input style="max-width: 100%" type="submit" class="w-100" value={Uri::pretty(&href, 20)} title={object.name().unwrap_or_else(|_| href.as_str()).to_string()} />
</a>
</p>
}.into_view(),
_ =>
view! {
<p class="center box">
<code class="cw color center">
<a href={href} target="_blank">{media_type}</a>
</code>
{object.name().map(|name| {
view! { <p class="tiny-text"><small>{name.to_string()}</small></p> }
})}
</p>
}.into_view(),
}
}
#[component]
pub fn Object(object: crate::Object) -> impl IntoView {
let oid = object.id().unwrap_or_default().to_string(); let oid = object.id().unwrap_or_default().to_string();
let content = mdhtml::safe_html(object.content().unwrap_or_default());
let author_id = object.attributed_to().id().str().unwrap_or_default(); let author_id = object.attributed_to().id().str().unwrap_or_default();
let author = cache::OBJECTS.get_or(&author_id, serde_json::Value::String(author_id.clone()).into()); let author = cache::OBJECTS.get_or(&author_id, serde_json::Value::String(author_id.clone()).into());
let sensitive = object.sensitive().unwrap_or_default(); let sensitive = object.sensitive().unwrap_or_default();
@ -127,6 +40,26 @@ pub fn Object(object: crate::Object) -> impl IntoView {
Some(view! { <div class="pb-1"></div> }) Some(view! { <div class="pb-1"></div> })
}; };
let mut content = mdhtml::safe_html(object.content().unwrap_or_default());
let mut results = vec![];
for (matched, [id, username, _domain]) in REGEX.captures_iter(&content).map(|c| c.extract()) {
// TODO what the fuck mastodon........... why are you putting the fancy url in the A HREF????????
let id = id.replace('@', "users/");
// TODO ughh ugly on-the-fly html editing, can this be avoided?
let to_replace = format!(
"<a class=\"clean dim\" href=\"{}\" title=\"{}\"><span class=\"border-button mr-s\"><code class=\"color mr-s\">@</code>{}</span></a>",
Uri::web(U::Actor, &id), id, username
);
results.push((matched.to_string(), to_replace));
}
for (from, to) in results {
content = content.replace(&from, &to);
}
let audience_badge = object.audience().id().str() let audience_badge = object.audience().id().str()
.map(|x| view! { .map(|x| view! {
<a class="clean dim" href={Uri::web(U::Actor, &x)} rel="nofollow noreferrer"> <a class="clean dim" href={Uri::web(U::Actor, &x)} rel="nofollow noreferrer">
@ -134,7 +67,7 @@ pub fn Object(object: crate::Object) -> impl IntoView {
class="border-button" class="border-button"
title="this is a group: all interactions will be broadcasted to group members!" title="this is a group: all interactions will be broadcasted to group members!"
> >
<code class="color">&</code> <code class="color mr-s">&</code>
<small> <small>
{Uri::pretty(&x, 30)} {Uri::pretty(&x, 30)}
</small> </small>
@ -142,13 +75,11 @@ pub fn Object(object: crate::Object) -> impl IntoView {
</a> </a>
}); });
let post_image = object.image().get().and_then(|x| x.url().id().str()).map(|x| view! { let post_image = object.image().get().and_then(|x| x.url().id().str()).map(|x| {
<div class="flex-pic-container"> let (expand, set_expand) = create_signal(false);
<a href={x.clone()} target="_blank"> view! {
<div class="flex-pic" style={format!("background-image: url('{x}')")}> <img src={x} class="flex-pic box cursor" class:flex-pic-expand=expand on:click=move|_| set_expand.set(!expand.get()) />
</div> }
</a>
</div>
}); });
let post_inner = view! { let post_inner = view! {
@ -165,9 +96,9 @@ pub fn Object(object: crate::Object) -> impl IntoView {
}.into_view(), }.into_view(),
// lemmy with Page, peertube with Video // lemmy with Page, peertube with Video
Ok(apb::ObjectType::Document(t)) => view! { Ok(apb::ObjectType::Document(t)) => view! {
<article class="ml-1 mr-1" style="display: flex"> <article class="float-container ml-1 mr-1" >
{post_image} {post_image}
<div style="flex: 3"> <div>
<h4 class="mt-s mb-1" title={t.as_ref().to_string()}> <h4 class="mt-s mb-1" title={t.as_ref().to_string()}>
<b>{object.name().unwrap_or_default().to_string()}</b> <b>{object.name().unwrap_or_default().to_string()}</b>
</h4> </h4>
@ -209,7 +140,7 @@ pub fn Object(object: crate::Object) -> impl IntoView {
</table> </table>
{post} {post}
<div class="mt-s ml-1 rev"> <div class="mt-s ml-1 rev">
{audience_badge} {if !reply { audience_badge } else { None }}
<ReplyButton n=comments target=oid.clone() /> <ReplyButton n=comments target=oid.clone() />
<LikeButton n=likes liked=already_liked target=oid.clone() author=author_id private=!public /> <LikeButton n=likes liked=already_liked target=oid.clone() author=author_id private=!public />
<RepostButton n=shares target=oid /> <RepostButton n=shares target=oid />

View file

@ -1 +1,4 @@
pub mod view; pub mod view;
pub mod item;
pub mod attachment;

View file

@ -11,14 +11,19 @@ pub use crate::{
follow::FollowList, follow::FollowList,
posts::ActorPosts, posts::ActorPosts,
}, },
activities::{
item::Item,
},
objects::{
view::ObjectView,
attachment::Attachment,
item::{Object, Summary, LikeButton, RepostButton, ReplyButton},
},
timeline::{ timeline::{
Timeline, Timeline,
feed::Feed, feed::Feed,
thread::Thread, thread::Thread,
}, },
objects::{
view::ObjectView,
}
}; };
pub use uriproxy::UriClass as U; pub use uriproxy::UriClass as U;