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
This commit is contained in:
parent
b53bd5527f
commit
5e7b2354e2
9 changed files with 178 additions and 131 deletions
|
@ -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;
|
||||||
|
|
|
@ -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| {
|
1
web/src/activities/mod.rs
Normal file
1
web/src/activities/mod.rs
Normal file
|
@ -0,0 +1 @@
|
||||||
|
pub mod item;
|
|
@ -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::*;
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
100
web/src/objects/attachment.rs
Normal file
100
web/src/objects/attachment.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -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};
|
||||||
|
|
||||||
#[component]
|
lazy_static::lazy_static! {
|
||||||
pub fn Attachment(
|
static ref REGEX: Regex = regex::Regex::new("<a href=\"(.+)\" class=\"u-url mention\">@(\\w+)(@\\w+|)</a>").expect("failed compiling @ regex");
|
||||||
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="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]
|
#[component]
|
||||||
pub fn Object(object: crate::Object) -> impl IntoView {
|
pub fn Object(
|
||||||
|
object: crate::Object,
|
||||||
|
#[prop(optional)] reply: bool,
|
||||||
|
) -> 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 />
|
|
@ -1 +1,4 @@
|
||||||
pub mod view;
|
pub mod view;
|
||||||
|
pub mod item;
|
||||||
|
|
||||||
|
pub mod attachment;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in a new issue