diff --git a/src/components/status.css b/src/components/status.css index 87fcbdf7..feaae368 100644 --- a/src/components/status.css +++ b/src/components/status.css @@ -50,9 +50,11 @@ .status-pre-meta .name-text { display: inline; } -.status-pre-meta .icon { - color: var(--reblog-color); +.status-pre-meta > * { vertical-align: middle; +} +.status-reblog .status-pre-meta .icon { + color: var(--reblog-color); margin-right: 4px; } @@ -103,6 +105,52 @@ background-color: var(--outline-color); } +.status.filtered { + padding-block: 12px; + display: flex; + gap: 8px; + align-items: center; +} +.status.filtered .status-filtered-info { + pointer-events: none; + flex-grow: 1; + font-size: 90%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + mask-image: linear-gradient(to right, black 90%, transparent); + position: relative; +} +.status.filtered .avatar { + opacity: 0.5; + transition: opacity 0.7s ease-in; +} +.status.filtered:is(:hover, :focus, :active) .avatar { + opacity: 1; +} +.status.filtered :is(.status-filtered-info-1, .status-filtered-info-2) { + transition: all 0.2s ease-out; +} +.status.filtered:hover :is(.status-filtered-info-1, .status-filtered-info-2) { + transition-delay: 0.5s; +} +.status.filtered .status-filtered-info-1 { + opacity: 0.5; +} +.status.filtered:is(:hover, :focus, :active) .status-filtered-info-1 { + opacity: 0; +} +.status.filtered .status-filtered-info-2 { + opacity: 0; + transform: translateX(8px); + position: absolute; + left: 0; +} +.status.filtered:is(:hover, :focus, :active) .status-filtered-info-2 { + opacity: 0.75; + transform: translateX(0); +} + .status .container { flex-grow: 1; min-width: 0; @@ -195,6 +243,27 @@ ); font-weight: bold; } +.status-filtered-badge { + flex-shrink: 0; + display: inline-block; + color: var(--text-insignificant-color); + /* background: var(--bg-faded-color); */ + /* border: var(--hairline-width) solid var(--bg-color); */ + border: var(--hairline-width) dashed var(--text-insignificant-color); + border-radius: 4px; + padding: 4px; + font-size: 10px; + line-height: 1; + text-transform: uppercase; + font-weight: bold; + vertical-align: middle; +} +.status-filtered-badge.clickable:hover { + cursor: pointer; + color: var(--text-color); + border-color: var(--text-color); + background: var(--bg-color); +} .status.large .content-container { margin-left: calc(-50px - 16px); @@ -1002,3 +1071,37 @@ a.card:is(:hover, :focus) { text-transform: uppercase; font-size: 80%; } + +/* FILTERED */ + +#filtered-status-peek main > p:first-child { + margin-top: 0; +} + +#filtered-status-peek main .heading { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#filtered-status-peek .status-link { + border-radius: 16px; + border: var(--hairline-width) dashed var(--text-insignificant-color); + max-height: 33vh; + max-height: 33dvh; + overflow: hidden; +} +#filtered-status-peek .status-link .status { + pointer-events: none; + font-size: 90%; + max-height: 33vh; + max-height: 33dvh; + overflow: hidden; + mask-image: linear-gradient(black 80%, transparent 95%); +} +#filtered-status-peek .status-post-link { + float: right; + position: sticky; + bottom: 8px; + right: 8px; +} diff --git a/src/components/status.jsx b/src/components/status.jsx index e6d8ba1b..c7c9b736 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -29,6 +29,7 @@ import niceDateTime from '../utils/nice-date-time'; import shortenNumber from '../utils/shorten-number'; import showToast from '../utils/show-toast'; import states, { getStatus, saveStatus, statusKey } from '../utils/states'; +import statusPeek from '../utils/status-peek'; import store from '../utils/store'; import visibilityIconsMap from '../utils/visibility-icons-map'; @@ -72,6 +73,7 @@ function Status({ contentTextWeight, enableTranslate, previewMode, + allowFilters, }) { if (skeleton) { return ( @@ -141,10 +143,21 @@ function Status({ // Non-API props _deleted, _pinned, + _filtered, } = status; console.debug('RENDER Status', id, status?.account.displayName); + if (allowFilters && size !== 'l' && _filtered) { + return ( + + ); + } + const createdAtDate = new Date(createdAt); const editedAtDate = new Date(editedAt); @@ -183,6 +196,7 @@ function Status({ }; if (reblog) { + // If has statusID, means useItemID (cached in states) return (
@@ -191,7 +205,8 @@ function Status({ boosted
/g, '

\n\n') .replace(/<\/li>/g, '\n'); @@ -1687,4 +1702,94 @@ function safeBoundingBoxPadding() { return str; } +function FilteredStatus({ status, filterInfo, instance }) { + const { + account: { avatar, avatarStatic }, + createdAt, + visibility, + } = status; + const filterTitleStr = filterInfo?.titlesStr || ''; + const createdAtDate = new Date(createdAt); + const statusPeekText = statusPeek(status); + + const [showPeek, setShowPeek] = useState(false); + const bindLongPress = useLongPress( + () => { + setShowPeek(true); + }, + { + captureEvent: true, + detect: 'touch', + cancelOnMovement: true, + }, + ); + + return ( +
{ + e.preventDefault(); + setShowPeek(true); + }} + {...bindLongPress()} + > +
+ { + e.preventDefault(); + setShowPeek(true); + }} + > + Filtered + {' '} + + + + {' '} + {' '} + + + {statusPeekText} + +
+ {!!showPeek && ( + { + if (e.target === e.currentTarget) { + setShowPeek(false); + } + }} + > +
+
+

+ Filtered {filterTitleStr} +

+ { + setShowPeek(false); + }} + > + + + +
+
+
+ )} +
+ ); +} + export default memo(Status); diff --git a/src/components/timeline.jsx b/src/components/timeline.jsx index 47826cfa..0feb4d8f 100644 --- a/src/components/timeline.jsx +++ b/src/components/timeline.jsx @@ -28,6 +28,7 @@ function Timeline({ headerStart, headerEnd, timelineStart, + allowFilters, }) { const [items, setItems] = useState([]); const [uiState, setUIState] = useState('default'); @@ -310,6 +311,16 @@ function Timeline({ title = 'Pinned posts'; } if (items) { + // Here, we don't hide filtered posts, but we sort them last + items.sort((a, b) => { + if (a._filtered && !b._filtered) { + return 1; + } + if (!a._filtered && b._filtered) { + return -1; + } + return 0; + }); return (
  • @@ -352,9 +363,17 @@ function Timeline({
  • {useItemID ? ( - + ) : ( - + )}
  • diff --git a/src/pages/following.jsx b/src/pages/following.jsx index 4f3d03de..4032ed9e 100644 --- a/src/pages/following.jsx +++ b/src/pages/following.jsx @@ -3,6 +3,7 @@ import { useSnapshot } from 'valtio'; import Timeline from '../components/timeline'; import { api } from '../utils/api'; +import { filteredItems } from '../utils/filters'; import states from '../utils/states'; import { getStatus, saveStatus } from '../utils/states'; import useTitle from '../utils/useTitle'; @@ -21,12 +22,14 @@ function Following({ title, path, id, ...props }) { homeIterator.current = masto.v1.timelines.listHome({ limit: LIMIT }); } const results = await homeIterator.current.next(); - const { value } = results; + let { value } = results; if (value?.length) { if (firstLoad) { latestItem.current = value[0].id; + console.log('First load', latestItem.current); } + value = filteredItems(value, 'home'); value.forEach((item) => { saveStatus(item, instance); }); @@ -49,8 +52,9 @@ function Following({ title, path, id, ...props }) { since_id: latestItem.current, }) .next(); - const { value } = results; + let { value } = results; console.log('checkForUpdates', latestItem.current, value); + value = filteredItems(value, 'home'); if (value?.length && value.some((item) => !item.reblog)) { return true; } @@ -119,6 +123,7 @@ function Following({ title, path, id, ...props }) { useItemID boostsCarousel={snapStates.settings.boostsCarousel} {...props} + allowFilters /> ); } diff --git a/src/pages/list.jsx b/src/pages/list.jsx index 22f4a120..54c53c58 100644 --- a/src/pages/list.jsx +++ b/src/pages/list.jsx @@ -5,6 +5,7 @@ import Icon from '../components/icon'; import Link from '../components/link'; import Timeline from '../components/timeline'; import { api } from '../utils/api'; +import { filteredItems } from '../utils/filters'; import { saveStatus } from '../utils/states'; import useTitle from '../utils/useTitle'; @@ -23,12 +24,13 @@ function List(props) { }); } const results = await listIterator.current.next(); - const { value } = results; + let { value } = results; if (value?.length) { if (firstLoad) { latestItem.current = value[0].id; } + value = filteredItems(value, 'home'); value.forEach((item) => { saveStatus(item, instance); }); @@ -42,7 +44,8 @@ function List(props) { limit: 1, since_id: latestItem.current, }); - const { value } = results; + let { value } = results; + value = filteredItems(value, 'home'); if (value?.length) { return true; } @@ -76,6 +79,7 @@ function List(props) { checkForUpdates={checkForUpdates} useItemID boostsCarousel + allowFilters headerStart={ diff --git a/src/pages/public.jsx b/src/pages/public.jsx index 747da069..218eaf1a 100644 --- a/src/pages/public.jsx +++ b/src/pages/public.jsx @@ -6,6 +6,7 @@ import { useSnapshot } from 'valtio'; import Icon from '../components/icon'; import Timeline from '../components/timeline'; import { api } from '../utils/api'; +import { filteredItems } from '../utils/filters'; import states from '../utils/states'; import { saveStatus } from '../utils/states'; import useTitle from '../utils/useTitle'; @@ -33,12 +34,13 @@ function Public({ local, ...props }) { }); } const results = await publicIterator.current.next(); - const { value } = results; + let { value } = results; if (value?.length) { if (firstLoad) { latestItem.current = value[0].id; } + value = filteredItems(value, 'public'); value.forEach((item) => { saveStatus(item, instance); }); @@ -55,7 +57,8 @@ function Public({ local, ...props }) { since_id: latestItem.current, }) .next(); - const { value } = results; + let { value } = results; + value = filteredItems(value, 'public'); if (value?.length) { return true; } @@ -84,6 +87,7 @@ function Public({ local, ...props }) { useItemID headerStart={<>} boostsCarousel={snapStates.settings.boostsCarousel} + allowFilters headerEnd={ { if (!heroStatus) return ''; - const { spoilerText, content } = heroStatus; - let text; - if (spoilerText) { - text = spoilerText; - } else { - const div = document.createElement('div'); - div.innerHTML = content; - text = div.innerText.trim(); - } + let text = statusPeek(heroStatus); if (text.length > 64) { // "The title should ideally be less than 64 characters in length" // https://www.w3.org/Provider/Style/TITLE.html diff --git a/src/utils/filters.jsx b/src/utils/filters.jsx new file mode 100644 index 00000000..eda2d3fb --- /dev/null +++ b/src/utils/filters.jsx @@ -0,0 +1,34 @@ +import store from './store'; + +export function filteredItem(item, filterContext, currentAccountID) { + const { filtered } = item; + if (!filtered?.length) return true; + const isSelf = currentAccountID && item.account?.id === currentAccountID; + if (isSelf) return true; + const appliedFilters = filtered.filter((f) => { + const { filter } = f; + const hasContext = filter.context.includes(filterContext); + if (!hasContext) return false; + if (!filter.expiresAt) return hasContext; + return new Date(filter.expiresAt) > new Date(); + }); + const isHidden = appliedFilters.some((f) => f.filter.filterAction === 'hide'); + console.log({ isHidden, filtered, appliedFilters }); + if (!isHidden) { + const filterTitles = appliedFilters.map((f) => f.filter.title); + item._filtered = { + titles: filterTitles, + titlesStr: filterTitles.join(' • '), + }; + item._test = { test: 'test' }; + } + return !isHidden; +} +export function filteredItems(items, filterContext) { + if (!items?.length) return []; + if (!filterContext) return items; + const currentAccountID = store.session.get('currentAccount'); + return items.filter((item) => + filteredItem(item, filterContext, currentAccountID), + ); +} diff --git a/src/utils/states.js b/src/utils/states.js index a6cfead2..fbe3aff4 100644 --- a/src/utils/states.js +++ b/src/utils/states.js @@ -114,8 +114,11 @@ export function saveStatus(status, instance, opts) { opts, ); if (!status) return; - if (!override && getStatus(status.id)) return; + const oldStatus = getStatus(status.id, instance); + if (!override && oldStatus) return; const key = statusKey(status.id, instance); + if (oldStatus?._pinned) status._pinned = oldStatus._pinned; + if (oldStatus?._filtered) status._filtered = oldStatus._filtered; states.statuses[key] = status; if (status.reblog) { const key = statusKey(status.reblog.id, instance); diff --git a/src/utils/status-peek.jsx b/src/utils/status-peek.jsx new file mode 100644 index 00000000..2e3b084e --- /dev/null +++ b/src/utils/status-peek.jsx @@ -0,0 +1,34 @@ +import { getHTMLText } from '../components/status'; + +function statusPeek(status) { + const { spoilerText, content, poll, mediaAttachments } = status; + let text = ''; + if (spoilerText?.trim()) { + text += spoilerText; + } else { + text += getHTMLText(content); + } + text = text.trim(); + if (poll) { + text += ' 📊'; + } + if (mediaAttachments?.length) { + text += + ' ' + + mediaAttachments + .map( + (m) => + ({ + image: '🖼️', + gifv: '🎞️', + video: '📹', + audio: '🎵', + unknown: '', + }[m.type] || ''), + ) + .join(''); + } + return text; +} + +export default statusPeek;