diff --git a/src/app.css b/src/app.css
index 2b819a34..338286fc 100644
--- a/src/app.css
+++ b/src/app.css
@@ -209,6 +209,60 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
.timeline {
margin: 0 auto;
padding: 0;
+
+ &.timeline-media {
+ --grid-gap: 8px;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ grid-auto-rows: fit-content;
+ gap: var(--grid-gap);
+ padding: var(--grid-gap);
+
+ &:not(#columns &) {
+ background-color: var(--bg-faded-color);
+ }
+
+ @media (min-width: 40em) {
+ &:not(#columns &) {
+ --grid-gap: 16px;
+ grid-template-columns: 1fr 1fr 1fr;
+
+ @media (min-width: 40em) {
+ width: 95vw;
+ max-width: calc(320px * 3.3);
+ transform: translateX(calc(-50% + var(--main-width) / 2));
+ }
+ }
+
+ #columns & {
+ padding-inline: 0;
+ }
+ }
+
+ li {
+ padding: 0 !important;
+ margin: 0 !important;
+ border: 0 !important;
+ overflow: visible !important;
+ background-color: transparent !important;
+ box-shadow: none !important;
+ }
+
+ @supports (grid-template-rows: masonry) {
+ grid-template-rows: masonry;
+ masonry-auto-flow: pack;
+
+ .media-post a {
+ aspect-ratio: revert !important;
+
+ video,
+ img,
+ audio {
+ min-height: 88px; /* for extreme dimensions */
+ }
+ }
+ }
+ }
}
.timeline.grow {
/* min-height: 100vh;
@@ -1678,6 +1732,24 @@ body > .szh-menu-container {
opacity: 1;
}
+.szh-menu
+ .szh-menu__item--type-checkbox:not(.szh-menu__item--disabled):not(
+ .szh-menu__item--hover
+ ) {
+ .icon {
+ opacity: 0.15;
+ }
+
+ &.szh-menu__item--checked {
+ color: var(--link-color);
+
+ .icon {
+ opacity: 1;
+ color: inherit;
+ }
+ }
+}
+
.szh-menu .menu-wrap {
display: flex;
flex-wrap: wrap;
diff --git a/src/cloak-mode.css b/src/cloak-mode.css
index d703b7a9..f96f4999 100644
--- a/src/cloak-mode.css
+++ b/src/cloak-mode.css
@@ -25,6 +25,7 @@ body.cloak,
}
.status :is(img, video, audio),
+ .media-post .media,
.avatar,
.emoji,
.header-banner {
diff --git a/src/components/icon.jsx b/src/components/icon.jsx
index 42e33f2a..cd2b5357 100644
--- a/src/components/icon.jsx
+++ b/src/components/icon.jsx
@@ -102,6 +102,7 @@ export const ICONS = {
keyboard: () => import('@iconify-icons/mingcute/keyboard-line'),
cloud: () => import('@iconify-icons/mingcute/cloud-line'),
month: () => import('@iconify-icons/mingcute/calendar-month-line'),
+ media: () => import('@iconify-icons/mingcute/photo-album-line'),
};
function Icon({
diff --git a/src/components/media-post.css b/src/components/media-post.css
new file mode 100644
index 00000000..b7d32854
--- /dev/null
+++ b/src/components/media-post.css
@@ -0,0 +1,87 @@
+.media-post {
+ --item-radius: 16px;
+ position: relative;
+ animation: appear-smooth 1s ease-out;
+
+ &:is(.filtered, .has-spoiler) :is(img, video) {
+ filter: blur(32px);
+ image-rendering: crisp-edges;
+ image-rendering: pixelated;
+ animation: none !important;
+ }
+
+ &.filtered[data-filtered-text]:before,
+ &.has-spoiler[data-spoiler-text]:before {
+ pointer-events: none;
+ content: attr(data-spoiler-text);
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 1;
+ background-color: var(--bg-blur-color);
+ margin: 8px;
+ padding: 4px 6px;
+ border-radius: calc(var(--item-radius) / 2);
+ font-size: 90%;
+ border: var(--hairline-width) dashed var(--bg-color);
+
+ > * {
+ pointer-events: none;
+ }
+ }
+
+ .media {
+ border-radius: var(--item-radius);
+ overflow: hidden;
+ position: relative;
+ display: block;
+ aspect-ratio: 1 !important;
+
+ &:before {
+ position: absolute;
+ inset: 0;
+ content: '';
+ border: 1px solid var(--outline-color);
+ border-radius: inherit;
+ }
+
+ &:not(.media-audio) {
+ background-color: var(--average-color, var(--media-bg-color));
+ }
+
+ @media (hover: hover) {
+ &:hover {
+ --drop-shadow: var(--drop-shadow-color);
+ box-shadow: 0 8px 16px -4px var(--drop-shadow),
+ 0 4px 8px var(--drop-shadow);
+
+ @media (prefers-color-scheme: dark) {
+ --drop-shadow: var(--link-color);
+ }
+ }
+ }
+
+ &:active:not(:has(button:active)) {
+ box-shadow: none;
+ filter: brightness(0.8);
+ transform: scale(0.99);
+ }
+
+ video,
+ img,
+ audio {
+ border-radius: 16px;
+ /* object-fit: scale-down; */
+ object-fit: cover;
+ width: 100%;
+ height: 100%;
+ vertical-align: top;
+ }
+
+ &:is(:hover, :focus) img {
+ /* Less delay here to make it feel more responsive */
+ animation: position-object 5s ease-in-out 0.3s 5;
+ animation-duration: var(--anim-duration, 5s);
+ }
+ }
+}
diff --git a/src/components/media-post.jsx b/src/components/media-post.jsx
new file mode 100644
index 00000000..c208896c
--- /dev/null
+++ b/src/components/media-post.jsx
@@ -0,0 +1,126 @@
+import './media-post.css';
+
+import { memo } from 'preact/compat';
+import { useSnapshot } from 'valtio';
+
+import states, { statusKey } from '../utils/states';
+
+import Media from './media';
+
+function MediaPost({
+ class: className,
+ statusID,
+ status,
+ instance,
+ parent,
+ allowFilters,
+ onMediaClick,
+}) {
+ let sKey = statusKey(statusID, instance);
+ const snapStates = useSnapshot(states);
+ if (!status) {
+ status = snapStates.statuses[sKey] || snapStates.statuses[statusID];
+ sKey = statusKey(status?.id, instance);
+ }
+ if (!status) {
+ return null;
+ }
+
+ const {
+ account: {
+ acct,
+ avatar,
+ avatarStatic,
+ id: accountId,
+ url: accountURL,
+ displayName,
+ username,
+ emojis: accountEmojis,
+ bot,
+ group,
+ },
+ id,
+ repliesCount,
+ reblogged,
+ reblogsCount,
+ favourited,
+ favouritesCount,
+ bookmarked,
+ poll,
+ muted,
+ sensitive,
+ spoilerText,
+ visibility, // public, unlisted, private, direct
+ language,
+ editedAt,
+ filtered,
+ card,
+ createdAt,
+ inReplyToId,
+ inReplyToAccountId,
+ content,
+ mentions,
+ mediaAttachments,
+ reblog,
+ uri,
+ url,
+ emojis,
+ // Non-API props
+ _deleted,
+ _pinned,
+ _filtered,
+ } = status;
+
+ if (!mediaAttachments?.length) {
+ return null;
+ }
+
+ const debugHover = (e) => {
+ if (e.shiftKey) {
+ console.log({
+ ...status,
+ });
+ }
+ };
+
+ console.debug('RENDER Media post', id, status?.account.displayName);
+
+ // const readingExpandSpoilers = useMemo(() => {
+ // const prefs = store.account.get('preferences') || {};
+ // return !!prefs['reading:expand:spoilers'];
+ // }, []);
+ const hasSpoiler = spoilerText || sensitive;
+
+ const Parent = parent || 'div';
+
+ return mediaAttachments.map((media, i) => {
+ const mediaKey = `${sKey}-${media.id}`;
+ return (
+
+ onMediaClick(e, i, media, status) : undefined
+ }
+ />
+
+ );
+ });
+}
+
+export default memo(MediaPost);
diff --git a/src/components/media.jsx b/src/components/media.jsx
index 590caa40..f0390458 100644
--- a/src/components/media.jsx
+++ b/src/components/media.jsx
@@ -62,6 +62,7 @@ export const isMediaCaptionLong = mem((caption) =>
);
function Media({
+ class: className = '',
media,
to,
lang,
@@ -170,6 +171,9 @@ function Media({
const maxAspectHeight =
window.innerHeight * (orientation === 'portrait' ? 0.45 : 0.33);
const maxHeight = orientation === 'portrait' ? 0 : 160;
+ const averageColorStyle = {
+ '--average-color': rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
+ };
const mediaStyles =
width && height
? {
@@ -180,8 +184,11 @@ function Media({
(width / height) * Math.max(maxHeight, maxAspectHeight)
}px`,
aspectRatio: `${width} / ${height}`,
+ ...averageColorStyle,
}
- : {};
+ : {
+ ...averageColorStyle,
+ };
const longDesc = isMediaCaptionLong(description);
const showInlineDesc =
@@ -233,7 +240,7 @@ function Media({
hashtag,
subtitle: ({ instance }) => instance || api().instance,
- path: ({ hashtag, instance }) =>
- `${instance ? `/${instance}` : ''}/t/${hashtag.split(/\s+/).join('+')}`,
+ path: ({ hashtag, instance, media }) =>
+ `${instance ? `/${instance}` : ''}/t/${hashtag.split(/\s+/).join('+')}${
+ media ? '?media=1' : ''
+ }`,
icon: 'hashtag',
},
};
diff --git a/src/components/status.css b/src/components/status.css
index 77f55d9a..36b3fdce 100644
--- a/src/components/status.css
+++ b/src/components/status.css
@@ -846,7 +846,7 @@
object-fit: cover;
vertical-align: middle;
}
-.status .media {
+.media {
cursor: pointer;
&[data-has-alt] {
@@ -885,7 +885,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
position: relative;
background-clip: padding-box;
}
-.status :is(.media-video, .media-audio) .media-play {
+:is(.media-video, .media-audio) .media-play {
pointer-events: none;
width: 44px;
height: 44px;
@@ -902,10 +902,10 @@ body:has(#modal-container .carousel) .status .media img:hover {
border-radius: 70px;
transition: transform 0.2s ease-in-out;
}
-.status :is(.media-video, .media-audio):hover:not(:active) .media-play {
+:is(.media-video, .media-audio):hover:not(:active) .media-play {
transform: translate(-50%, -50%) scale(1.1);
}
-.status :is(.media-video, .media-audio)[data-formatted-duration]:after {
+:is(.media-video, .media-audio)[data-formatted-duration]:after {
font-size: 12px;
pointer-events: none;
content: attr(data-formatted-duration);
@@ -918,10 +918,10 @@ body:has(#modal-container .carousel) .status .media img:hover {
border-radius: 4px;
padding: 0 4px;
}
-.status .media-audio[data-formatted-duration]:after {
+.media-audio[data-formatted-duration]:after {
content: '♬ ' attr(data-formatted-duration);
}
-.status .media-gif[data-label]:not(:hover):after {
+.media-gif[data-label]:not(:hover):after {
font-size: 12px;
font-weight: bold;
pointer-events: none;
@@ -953,12 +953,12 @@ body:has(#modal-container .carousel) .status .media img:hover {
.status .media-audio audio {
width: 100%;
} */
-.status .media-audio {
+.media-audio {
width: 100%;
height: 100%;
background-image: radial-gradient(
circle at center center,
- var(--bg-color),
+ transparent,
var(--bg-faded-color)
),
repeating-radial-gradient(
diff --git a/src/components/timeline.jsx b/src/components/timeline.jsx
index c893ec84..ecbe6938 100644
--- a/src/components/timeline.jsx
+++ b/src/components/timeline.jsx
@@ -13,6 +13,8 @@ import useScroll from '../utils/useScroll';
import Icon from './icon';
import Link from './link';
+import Media from './media';
+import MediaPost from './media-post';
import NavMenu from './nav-menu';
import Status from './status';
@@ -39,6 +41,7 @@ function Timeline({
timelineStart,
allowFilters,
refresh,
+ view,
}) {
const snapStates = useSnapshot(states);
const [items, setItems] = useState([]);
@@ -50,6 +53,7 @@ function Timeline({
console.debug('RENDER Timeline', id, refresh);
+ const allowGrouping = view !== 'media';
const loadItems = useDebouncedCallback(
(firstLoad) => {
setShowNew(false);
@@ -59,10 +63,12 @@ function Timeline({
try {
let { done, value } = await fetchItems(firstLoad);
if (Array.isArray(value)) {
- if (boostsCarousel) {
- value = groupBoosts(value);
+ if (allowGrouping) {
+ if (boostsCarousel) {
+ value = groupBoosts(value);
+ }
+ value = groupContext(value);
}
- value = groupContext(value);
console.log(value);
if (firstLoad) {
setItems(value);
@@ -210,6 +216,14 @@ function Timeline({
}
}, [nearReachEnd, showMore]);
+ const prevView = useRef(view);
+ useEffect(() => {
+ if (prevView.current !== view) {
+ prevView.current = view;
+ setItems([]);
+ }
+ }, [view]);
+
const loadOrCheckUpdates = useCallback(
async ({ disableIdleCheck = false } = {}) => {
console.log('✨ Load or check updates', {
@@ -346,7 +360,7 @@ function Timeline({
)}
{!!items.length ? (
<>
-
+
{items.map((status) => (
))}
- {showMore && uiState === 'loading' && (
- <>
- -
-
-
- -
-
-
- >
- )}
+ {showMore &&
+ uiState === 'loading' &&
+ (view === 'media' ? null : (
+ <>
+ -
+
+
+ -
+
+
+ >
+ ))}
{uiState === 'default' &&
(showMore ? (
@@ -399,11 +416,19 @@ function Timeline({
>
) : uiState === 'loading' ? (
- {Array.from({ length: 5 }).map((_, i) => (
- -
-
-
- ))}
+ {Array.from({ length: 5 }).map((_, i) =>
+ view === 'media' ? (
+
+ ) : (
+ -
+
+
+ ),
+ )}
) : (
uiState !== 'error' && {emptyText}
@@ -426,7 +451,7 @@ function Timeline({
);
}
-function TimelineItem({ status, instance, useItemID, allowFilters }) {
+function TimelineItem({ status, instance, useItemID, allowFilters, view }) {
const { id: statusID, reblog, items, type, _pinned } = status;
const actualStatusID = reblog?.id || statusID;
const url = instance
@@ -531,8 +556,33 @@ function TimelineItem({ status, instance, useItemID, allowFilters }) {
);
});
}
+
+ const itemKey = `timeline-${statusID + _pinned}`;
+
+ if (view === 'media') {
+ return useItemID ? (
+
+ ) : (
+
+ );
+ }
+
return (
- -
+
-
{useItemID ? (
{
- saveStatus(item, instance);
+ saveStatus(item, instance, {
+ skipThreading: media, // If media view, no need to form threads
+ });
});
maxID.current = value[value.length - 1].id;
@@ -136,6 +143,8 @@ function Hashtags({ columnMode, ...props }) {
fetchItems={fetchHashtags}
checkForUpdates={checkForUpdates}
useItemID
+ view={media ? 'media' : undefined}
+ refresh={media}
headerEnd={
>
)}
+ Filters
+
+
{({ ref }) => (