diff --git a/packages/nicolium/src/columns/timeline.tsx b/packages/nicolium/src/columns/timeline.tsx index fb57f710f..a7115b586 100644 --- a/packages/nicolium/src/columns/timeline.tsx +++ b/packages/nicolium/src/columns/timeline.tsx @@ -33,9 +33,10 @@ import { } from '@/queries/timelines/use-timelines'; import { useSettings } from '@/stores/settings'; import { selectChild } from '@/utils/scroll-utils'; +import { hasActiveFilters, sortFilteredTimeline } from '@/utils/timeline-filter'; import type { FilterContextType } from '@/queries/settings/use-filters'; -import type { Settings } from '@/schemas/frontend-settings'; +import type { TimelineFilters } from '@/schemas/frontend-settings'; import type { TimelineEntry } from '@/stores/timelines'; import type { VirtuosoHandle } from 'react-virtuoso'; @@ -361,7 +362,7 @@ type IBaseTimeline = Pick< 'emptyMessageIcon' | 'emptyMessageText' | 'onTopItemChanged' > & { featuredStatusIds?: Array; - filters?: Settings['timelines'][string]; + filters?: TimelineFilters; }; interface ITimeline extends IBaseTimeline { @@ -478,45 +479,32 @@ const Timeline: React.FC = ({ } } - entries - .map((entry) => { - if (entry.type === 'status') { - return { - ...entry, - filtered: - (filters?.showDirect === false && entry.isDirect) || - (filters?.showReblogs === false && entry.isReblog) || - (filters?.showReplies === false && entry.isReply) || - (filters?.showQuotes === false && entry.isQuote) || - (filters?.showNonMedia === false && !entry.hasMedia), - }; - } - return entry; - }) - .forEach((entry, entryIndex, entries) => { - if (entry.type === 'status' && entry.filtered) { - return; - } + const processedEntries = hasActiveFilters(filters) + ? sortFilteredTimeline(entries, filters) + : entries; - if (entry.type === 'status') { - const previousEntry = entries[entryIndex - 1]; - const nextEntry = entries[entryIndex + 1]; + processedEntries.forEach((entry, entryIndex, arr) => { + if (entry.type === 'status') { + const previousEntry = arr[entryIndex - 1]; + const nextEntry = arr[entryIndex + 1]; - rendered.push( - renderEntry(entry, rendered.length, { - isConnectedTop: - !!entry.isConnectedTop && - previousEntry?.type === 'status' && - !previousEntry.filtered, - isConnectedBottom: - !!entry.isConnectedBottom && nextEntry?.type === 'status' && !nextEntry.filtered, - }), - ); - return; - } + rendered.push( + renderEntry(entry, rendered.length, { + isConnectedTop: + !!entry.isConnectedTop && + previousEntry?.type === 'status' && + !!previousEntry.isConnectedBottom, + isConnectedBottom: + !!entry.isConnectedBottom && + nextEntry?.type === 'status' && + !!nextEntry.isConnectedTop, + }), + ); + return; + } - rendered.push(renderEntry(entry, rendered.length)); - }); + rendered.push(renderEntry(entry, rendered.length)); + }); return rendered; }, [entries, contextType, timelineId, featuredStatusIds, filters]); diff --git a/packages/nicolium/src/schemas/frontend-settings.ts b/packages/nicolium/src/schemas/frontend-settings.ts index 46138428d..6e52a4cf6 100644 --- a/packages/nicolium/src/schemas/frontend-settings.ts +++ b/packages/nicolium/src/schemas/frontend-settings.ts @@ -125,5 +125,6 @@ const settingsSchema = v.object({ }); type Settings = v.InferOutput; +type TimelineFilters = Settings['timelines'][string]; -export { settingsSchema, type Settings }; +export { settingsSchema, type Settings, type TimelineFilters }; diff --git a/packages/nicolium/src/utils/timeline-filter.ts b/packages/nicolium/src/utils/timeline-filter.ts new file mode 100644 index 000000000..9f737c430 --- /dev/null +++ b/packages/nicolium/src/utils/timeline-filter.ts @@ -0,0 +1,82 @@ +import { compareId } from '@/utils/comparators'; + +import type { TimelineFilters } from '@/schemas/frontend-settings'; +import type { TimelineEntry } from '@/stores/timelines'; + +type StatusEntry = Extract; + +const isEntryFiltered = (entry: StatusEntry, filters: TimelineFilters): boolean => + (filters?.showDirect === false && entry.isDirect) || + (filters?.showReblogs === false && entry.isReblog) || + (filters?.showReplies === false && entry.isReply) || + (filters?.showQuotes === false && entry.isQuote) || + (filters?.showNonMedia === false && !entry.hasMedia); + +const hasActiveFilters = (filters: TimelineFilters | undefined): filters is TimelineFilters => + !!filters && + (filters.showDirect === false || + filters.showReblogs === false || + filters.showReplies === false || + filters.showQuotes === false || + filters.showNonMedia === false); + +const sortFilteredTimeline = ( + entries: Array, + filters: TimelineFilters, +): Array => { + const result: Array = []; + let collectedGroups: Array<{ sortKey: string; entries: Array }> = []; + let currentGroup: Array = []; + + const endGroup = () => { + if (currentGroup.length === 0) return; + + let sortKey = currentGroup[0].originalId; + for (let i = 1; i < currentGroup.length; i++) { + if (compareId(currentGroup[i].originalId, sortKey) > 0) { + sortKey = currentGroup[i].originalId; + } + } + + collectedGroups.push({ sortKey, entries: currentGroup }); + currentGroup = []; + }; + + const endSection = () => { + endGroup(); + + if (collectedGroups.length > 1) { + collectedGroups.sort((a, b) => compareId(b.sortKey, a.sortKey)); + } + + for (const group of collectedGroups) { + for (const entry of group.entries) { + result.push(entry); + } + } + collectedGroups = []; + }; + + for (const entry of entries) { + if (entry.type === 'status' && isEntryFiltered(entry, filters)) continue; + + if (entry.type !== 'status') { + endSection(); + result.push(entry); + continue; + } + + if (entry.isConnectedTop && currentGroup.length > 0) { + currentGroup.push(entry); + } else { + endGroup(); + currentGroup = [entry]; + } + } + + endSection(); + + return result; +}; + +export { sortFilteredTimeline, hasActiveFilters };