Breaking: rewrote filters implementation

This commit is contained in:
Lim Chee Aun 2023-11-03 21:45:31 +08:00
parent 1cdc4ebbe8
commit 0bc1b598c3
13 changed files with 287 additions and 207 deletions

View file

@ -1,9 +1,13 @@
import './media-post.css';
import { memo } from 'preact/compat';
import { useContext, useMemo } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import FilterContext from '../utils/filter-context';
import { isFiltered } from '../utils/filters';
import states, { statusKey } from '../utils/states';
import store from '../utils/store';
import Media from './media';
@ -13,7 +17,7 @@ function MediaPost({
status,
instance,
parent,
allowFilters,
// allowFilters,
onMediaClick,
}) {
let sKey = statusKey(statusID, instance);
@ -68,7 +72,7 @@ function MediaPost({
// Non-API props
_deleted,
_pinned,
_filtered,
// _filtered,
} = status;
if (!mediaAttachments?.length) {
@ -83,6 +87,20 @@ function MediaPost({
}
};
const currentAccount = useMemo(() => {
return store.session.get('currentAccount');
}, []);
const isSelf = useMemo(() => {
return currentAccount && currentAccount === accountId;
}, [accountId, currentAccount]);
const filterContext = useContext(FilterContext);
const filterInfo = !isSelf && isFiltered(filtered, filterContext);
if (filterInfo?.action === 'hide') {
return null;
}
console.debug('RENDER Media post', id, status?.account.displayName);
// const readingExpandSpoilers = useMemo(() => {
@ -95,6 +113,7 @@ function MediaPost({
return mediaAttachments.map((media, i) => {
const mediaKey = `${sKey}-${media.id}`;
const filterTitleStr = filterInfo?.titlesStr;
return (
<Parent
onMouseEnter={debugHover}
@ -102,10 +121,14 @@ function MediaPost({
data-spoiler-text={
spoilerText || (sensitive ? 'Sensitive media' : undefined)
}
data-filtered-text={_filtered ? 'Filtered' : undefined}
data-filtered-text={
filterInfo
? `Filtered${filterTitleStr ? `: ${filterTitleStr}` : ''}`
: undefined
}
class={`
media-post
${allowFilters && _filtered ? 'filtered' : ''}
${filterInfo ? 'filtered' : ''}
${hasSpoiler ? 'has-spoiler' : ''}
`}
>

View file

@ -13,6 +13,7 @@ import pThrottle from 'p-throttle';
import { memo } from 'preact/compat';
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
@ -34,6 +35,8 @@ import Poll from '../components/poll';
import { api } from '../utils/api';
import emojifyText from '../utils/emojify-text';
import enhanceContent from '../utils/enhance-content';
import FilterContext from '../utils/filter-context';
import { isFiltered } from '../utils/filters';
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import getHTMLText from '../utils/getHTMLText';
import handleContentLinks from '../utils/handle-content-links';
@ -90,7 +93,7 @@ function Status({
enableTranslate,
forceTranslate: _forceTranslate,
previewMode,
allowFilters,
// allowFilters,
onMediaClick,
quoted,
onStatusLinkClick = () => {},
@ -166,9 +169,24 @@ function Status({
// Non-API props
_deleted,
_pinned,
_filtered,
// _filtered,
} = status;
const currentAccount = useMemo(() => {
return store.session.get('currentAccount');
}, []);
const isSelf = useMemo(() => {
return currentAccount && currentAccount === accountId;
}, [accountId, currentAccount]);
const filterContext = useContext(FilterContext);
const filterInfo =
!isSelf && !readOnly && !previewMode && isFiltered(filtered, filterContext);
if (filterInfo?.action === 'hide') {
return null;
}
console.debug('RENDER Status', id, status?.account.displayName, quoted);
const debugHover = (e) => {
@ -179,11 +197,11 @@ function Status({
}
};
if (allowFilters && size !== 'l' && _filtered) {
if (/*allowFilters && */ size !== 'l' && filterInfo) {
return (
<FilteredStatus
status={status}
filterInfo={_filtered}
filterInfo={filterInfo}
instance={instance}
containerProps={{
onMouseEnter: debugHover,
@ -195,13 +213,6 @@ function Status({
const createdAtDate = new Date(createdAt);
const editedAtDate = new Date(editedAt);
const currentAccount = useMemo(() => {
return store.session.get('currentAccount');
}, []);
const isSelf = useMemo(() => {
return currentAccount && currentAccount === accountId;
}, [accountId, currentAccount]);
let inReplyToAccountRef = mentions?.find(
(mention) => mention.id === inReplyToAccountId,
);

View file

@ -4,6 +4,8 @@ import { InView } from 'react-intersection-observer';
import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio';
import FilterContext from '../utils/filter-context';
import { isFiltered } from '../utils/filters';
import states, { statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek';
import { groupBoosts, groupContext } from '../utils/timeline-utils';
@ -13,7 +15,6 @@ 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,9 +40,10 @@ function Timeline({
headerStart,
headerEnd,
timelineStart,
allowFilters,
// allowFilters,
refresh,
view,
filterContext,
}) {
const snapStates = useSnapshot(states);
const [items, setItems] = useState([]);
@ -285,172 +287,182 @@ function Timeline({
const hiddenUI = scrollDirection === 'end' && !nearReachStart;
return (
<div
id={`${id}-page`}
class="deck-container"
ref={(node) => {
scrollableRef.current = node;
jRef.current = node;
kRef.current = node;
oRef.current = node;
}}
tabIndex="-1"
>
<div class="timeline-deck deck">
<header
hidden={hiddenUI}
onClick={(e) => {
if (!e.target.closest('a, button')) {
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}
}}
onDblClick={(e) => {
if (!e.target.closest('a, button')) {
loadItems(true);
}
}}
class={uiState === 'loading' ? 'loading' : ''}
>
<div class="header-grid">
<div class="header-side">
<NavMenu />
{headerStart !== null && headerStart !== undefined ? (
headerStart
) : (
<Link to="/" class="button plain home-button">
<Icon icon="home" size="l" />
</Link>
)}
</div>
{title && (titleComponent ? titleComponent : <h1>{title}</h1>)}
<div class="header-side">
{/* <Loader hidden={uiState !== 'loading'} /> */}
{!!headerEnd && headerEnd}
</div>
</div>
{items.length > 0 &&
uiState !== 'loading' &&
!hiddenUI &&
showNew && (
<button
class="updates-button shiny-pill"
type="button"
onClick={() => {
loadItems(true);
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}}
>
<Icon icon="arrow-up" /> New posts
</button>
)}
</header>
{!!timelineStart && (
<div
class={`timeline-start ${uiState === 'loading' ? 'loading' : ''}`}
<FilterContext.Provider value={filterContext}>
<div
id={`${id}-page`}
class="deck-container"
ref={(node) => {
scrollableRef.current = node;
jRef.current = node;
kRef.current = node;
oRef.current = node;
}}
tabIndex="-1"
>
<div class="timeline-deck deck">
<header
hidden={hiddenUI}
onClick={(e) => {
if (!e.target.closest('a, button')) {
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}
}}
onDblClick={(e) => {
if (!e.target.closest('a, button')) {
loadItems(true);
}
}}
class={uiState === 'loading' ? 'loading' : ''}
>
{timelineStart}
</div>
)}
{!!items.length ? (
<>
<ul class={`timeline ${view ? `timeline-${view}` : ''}`}>
{items.map((status) => (
<TimelineItem
status={status}
instance={instance}
useItemID={useItemID}
allowFilters={allowFilters}
key={status.id + status?._pinned}
view={view}
/>
))}
{showMore &&
uiState === 'loading' &&
(view === 'media' ? null : (
<>
<li
style={{
height: '20vh',
}}
>
<Status skeleton />
</li>
<li
style={{
height: '25vh',
}}
>
<Status skeleton />
</li>
</>
))}
</ul>
{uiState === 'default' &&
(showMore ? (
<InView
onChange={(inView) => {
if (inView) {
loadItems();
}
<div class="header-grid">
<div class="header-side">
<NavMenu />
{headerStart !== null && headerStart !== undefined ? (
headerStart
) : (
<Link to="/" class="button plain home-button">
<Icon icon="home" size="l" />
</Link>
)}
</div>
{title && (titleComponent ? titleComponent : <h1>{title}</h1>)}
<div class="header-side">
{/* <Loader hidden={uiState !== 'loading'} /> */}
{!!headerEnd && headerEnd}
</div>
</div>
{items.length > 0 &&
uiState !== 'loading' &&
!hiddenUI &&
showNew && (
<button
class="updates-button shiny-pill"
type="button"
onClick={() => {
loadItems(true);
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}}
>
<button
type="button"
class="plain block"
onClick={() => loadItems()}
style={{ marginBlockEnd: '6em' }}
>
Show more&hellip;
</button>
</InView>
) : (
<p class="ui-state insignificant">The end.</p>
))}
</>
) : uiState === 'loading' ? (
<ul class="timeline">
{Array.from({ length: 5 }).map((_, i) =>
view === 'media' ? (
<div
style={{
height: '50vh',
}}
/>
) : (
<li key={i}>
<Status skeleton />
</li>
),
)}
</ul>
) : (
uiState !== 'error' && <p class="ui-state">{emptyText}</p>
)}
{uiState === 'error' && (
<p class="ui-state">
{errorText}
<br />
<br />
<button
class="button plain"
onClick={() => loadItems(!items.length)}
<Icon icon="arrow-up" /> New posts
</button>
)}
</header>
{!!timelineStart && (
<div
class={`timeline-start ${uiState === 'loading' ? 'loading' : ''}`}
>
Try again
</button>
</p>
)}
{timelineStart}
</div>
)}
{!!items.length ? (
<>
<ul class={`timeline ${view ? `timeline-${view}` : ''}`}>
{items.map((status) => (
<TimelineItem
status={status}
instance={instance}
useItemID={useItemID}
// allowFilters={allowFilters}
filterContext={filterContext}
key={status.id + status?._pinned}
view={view}
/>
))}
{showMore &&
uiState === 'loading' &&
(view === 'media' ? null : (
<>
<li
style={{
height: '20vh',
}}
>
<Status skeleton />
</li>
<li
style={{
height: '25vh',
}}
>
<Status skeleton />
</li>
</>
))}
</ul>
{uiState === 'default' &&
(showMore ? (
<InView
onChange={(inView) => {
if (inView) {
loadItems();
}
}}
>
<button
type="button"
class="plain block"
onClick={() => loadItems()}
style={{ marginBlockEnd: '6em' }}
>
Show more&hellip;
</button>
</InView>
) : (
<p class="ui-state insignificant">The end.</p>
))}
</>
) : uiState === 'loading' ? (
<ul class="timeline">
{Array.from({ length: 5 }).map((_, i) =>
view === 'media' ? (
<div
style={{
height: '50vh',
}}
/>
) : (
<li key={i}>
<Status skeleton />
</li>
),
)}
</ul>
) : (
uiState !== 'error' && <p class="ui-state">{emptyText}</p>
)}
{uiState === 'error' && (
<p class="ui-state">
{errorText}
<br />
<br />
<button
class="button plain"
onClick={() => loadItems(!items.length)}
>
Try again
</button>
</p>
)}
</div>
</div>
</div>
</FilterContext.Provider>
);
}
function TimelineItem({ status, instance, useItemID, allowFilters, view }) {
function TimelineItem({
status,
instance,
useItemID,
// allowFilters,
filterContext,
view,
}) {
const { id: statusID, reblog, items, type, _pinned } = status;
const actualStatusID = reblog?.id || statusID;
const url = instance
@ -467,10 +479,18 @@ function TimelineItem({ status, instance, useItemID, allowFilters, view }) {
if (isCarousel) {
// Here, we don't hide filtered posts, but we sort them last
items.sort((a, b) => {
if (a._filtered && !b._filtered) {
// if (a._filtered && !b._filtered) {
// return 1;
// }
// if (!a._filtered && b._filtered) {
// return -1;
// }
const aFiltered = isFiltered(a.filtered, filterContext);
const bFiltered = isFiltered(b.filtered, filterContext);
if (aFiltered && !bFiltered) {
return 1;
}
if (!a._filtered && b._filtered) {
if (!aFiltered && bFiltered) {
return -1;
}
return 0;
@ -493,7 +513,7 @@ function TimelineItem({ status, instance, useItemID, allowFilters, view }) {
instance={instance}
size="s"
contentTextWeight
allowFilters={allowFilters}
// allowFilters={allowFilters}
/>
) : (
<Status
@ -501,7 +521,7 @@ function TimelineItem({ status, instance, useItemID, allowFilters, view }) {
instance={instance}
size="s"
contentTextWeight
allowFilters={allowFilters}
// allowFilters={allowFilters}
/>
)}
</Link>
@ -541,13 +561,13 @@ function TimelineItem({ status, instance, useItemID, allowFilters, view }) {
<Status
statusID={statusID}
instance={instance}
allowFilters={allowFilters}
// allowFilters={allowFilters}
/>
) : (
<Status
status={item}
instance={instance}
allowFilters={allowFilters}
// allowFilters={allowFilters}
/>
)}
</Link>
@ -566,7 +586,7 @@ function TimelineItem({ status, instance, useItemID, allowFilters, view }) {
key={itemKey}
statusID={statusID}
instance={instance}
allowFilters={allowFilters}
// allowFilters={allowFilters}
/>
) : (
<MediaPost
@ -575,7 +595,7 @@ function TimelineItem({ status, instance, useItemID, allowFilters, view }) {
key={itemKey}
status={status}
instance={instance}
allowFilters={allowFilters}
// allowFilters={allowFilters}
/>
);
}
@ -587,13 +607,13 @@ function TimelineItem({ status, instance, useItemID, allowFilters, view }) {
<Status
statusID={statusID}
instance={instance}
allowFilters={allowFilters}
// allowFilters={allowFilters}
/>
) : (
<Status
status={status}
instance={instance}
allowFilters={allowFilters}
// allowFilters={allowFilters}
/>
)}
</Link>

View file

@ -32,7 +32,7 @@ function Following({ title, path, id, ...props }) {
console.log('First load', latestItem.current);
}
value = filteredItems(value, 'home');
// value = filteredItems(value, 'home');
value.forEach((item) => {
saveStatus(item, instance);
});
@ -115,7 +115,8 @@ function Following({ title, path, id, ...props }) {
useItemID
boostsCarousel={snapStates.settings.boostsCarousel}
{...props}
allowFilters
// allowFilters
filterContext="home"
/>
);
}

View file

@ -78,7 +78,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
latestItem.current = value[0].id;
}
value = filteredItems(value, 'public');
// value = filteredItems(value, 'public');
value.forEach((item) => {
saveStatus(item, instance, {
skipThreading: media, // If media view, no need to form threads
@ -153,7 +153,8 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
useItemID
view={media ? 'media' : undefined}
refresh={media}
allowFilters
// allowFilters
filterContext="public"
headerEnd={
<Menu2
portal

View file

@ -43,7 +43,7 @@ function List(props) {
latestItem.current = value[0].id;
}
value = filteredItems(value, 'home');
// value = filteredItems(value, 'home');
value.forEach((item) => {
saveStatus(item, instance);
});
@ -102,7 +102,8 @@ function List(props) {
checkForUpdates={checkForUpdates}
useItemID
boostsCarousel={snapStates.settings.boostsCarousel}
allowFilters
// allowFilters
filterContext="home"
// refresh={reloadCount}
headerStart={
<Link to="/l" class="button plain">

View file

@ -195,6 +195,7 @@ function Notifications({ columnMode }) {
snapStates.notificationsShowNew &&
uiState !== 'loading'
) {
setShowNew(false);
loadNotifications(true);
} else {
setShowNew(snapStates.notificationsShowNew);

View file

@ -41,7 +41,7 @@ function Public({ local, columnMode, ...props }) {
latestItem.current = value[0].id;
}
value = filteredItems(value, 'public');
// value = filteredItems(value, 'public');
value.forEach((item) => {
saveStatus(item, instance);
});
@ -91,7 +91,8 @@ function Public({ local, columnMode, ...props }) {
useItemID
headerStart={<></>}
boostsCarousel={snapStates.settings.boostsCarousel}
allowFilters
// allowFilters
filterContext="public"
headerEnd={
<Menu2
portal

View file

@ -85,7 +85,7 @@ function Trending({ columnMode, ...props }) {
latestItem.current = value[0].id;
}
value = filteredItems(value, 'public'); // Might not work here
// value = filteredItems(value, 'public'); // Might not work here
value.forEach((item) => {
saveStatus(item, instance);
});
@ -257,7 +257,8 @@ function Trending({ columnMode, ...props }) {
useItemID
headerStart={<></>}
boostsCarousel={snapStates.settings.boostsCarousel}
allowFilters
// allowFilters
filterContext="public"
timelineStart={TimelineStart}
headerEnd={
<Menu2

View file

@ -0,0 +1,4 @@
import { createContext } from 'preact';
const FilterContext = createContext();
export default FilterContext;

View file

@ -1,10 +1,8 @@
import mem from './mem';
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;
function _isFiltered(filtered, filterContext) {
if (!filtered?.length) return false;
const appliedFilters = filtered.filter((f) => {
const { filter } = f;
const hasContext = filter.context.includes(filterContext);
@ -12,19 +10,35 @@ export function filteredItem(item, filterContext, currentAccountID) {
if (!filter.expiresAt) return hasContext;
return new Date(filter.expiresAt) > new Date();
});
if (!appliedFilters.length) return true;
if (!appliedFilters.length) return false;
const isHidden = appliedFilters.some((f) => f.filter.filterAction === 'hide');
console.log({ isHidden, filtered, appliedFilters, item });
if (isHidden) return false;
if (isHidden)
return {
action: 'hide',
};
const isWarn = appliedFilters.some((f) => f.filter.filterAction === 'warn');
if (isWarn) {
const filterTitles = appliedFilters.map((f) => f.filter.title);
item._filtered = {
return {
action: 'warn',
titles: filterTitles,
titlesStr: filterTitles.join(' • '),
};
}
return isWarn;
return false;
}
export const isFiltered = mem(_isFiltered);
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 filterState = isFiltered(filtered, filterContext);
if (!filterState) return true;
if (filterState.action === 'hide') return false;
// item._filtered = filterState;
return true;
}
export function filteredItems(items, filterContext) {
if (!items?.length) return [];

View file

@ -1,5 +1,7 @@
import moize from 'moize';
window._moize = moize;
export default function mem(fn, opts = {}) {
return moize(fn, { ...opts, maxSize: 100 });
return moize(fn, { ...opts, maxSize: 100, isDeepEqual: true });
}

View file

@ -168,7 +168,7 @@ export function saveStatus(status, instance, opts) {
if (!override && oldStatus) return;
const key = statusKey(status.id, instance);
if (oldStatus?._pinned) status._pinned = oldStatus._pinned;
if (oldStatus?._filtered) status._filtered = oldStatus._filtered;
// if (oldStatus?._filtered) status._filtered = oldStatus._filtered;
states.statuses[key] = status;
if (status.reblog) {
const key = statusKey(status.reblog.id, instance);