feat(recent-files): implement image grouping based on configuration

Signed-off-by: Cristian Scheid <cristianscheid@gmail.com>
This commit is contained in:
Cristian Scheid 2026-03-18 13:44:40 -03:00
parent 60d71a99e2
commit a967660432
9 changed files with 585 additions and 23 deletions

View file

@ -79,6 +79,33 @@ class UserConfig {
'default' => true,
'allowed' => [true, false],
],
[
// Whether to group images on recent files list or not
'key' => 'group_recent_files_images',
'default' => false,
'allowed' => [true, false],
],
[
// Which image mime types to group in the recent files list
'key' => 'recent_files_group_mimetypes',
'default' => '',
'allowed' => [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'image/avif',
'image/heic',
'image/heif',
]
],
[
// Time window in minutes to group files uploaded close together in the recent files list
'key' => 'recent_files_group_timespan_minutes',
'default' => 2,
'min' => 1,
'max' => 999,
],
];
protected ?IUser $user = null;
@ -118,7 +145,7 @@ class UserConfig {
* Get the default config value for a given key
*
* @param string $key a valid config key
* @return string|bool
* @return string|bool|int
*/
private function getDefaultConfigValue(string $key) {
foreach (self::ALLOWED_CONFIGS as $config) {
@ -146,7 +173,25 @@ class UserConfig {
throw new \InvalidArgumentException('Unknown config key');
}
if (!in_array($value, $this->getAllowedConfigValues($key))) {
if (is_string($value) && str_starts_with($value, '[') && str_ends_with($value, ']')) {
$value = json_decode($value, true) ?? $value;
}
$config = $this->getConfigDefinition($key);
if (isset($config['min'], $config['max'])) {
if ((int)$value < $config['min'] || (int)$value > $config['max']) {
throw new \InvalidArgumentException('Invalid config value');
}
} elseif (is_array($value)) {
$allowedValues = $this->getAllowedConfigValues($key);
foreach ($value as $v) {
if (!in_array($v, $allowedValues)) {
throw new \InvalidArgumentException('Invalid config value');
}
}
$value = json_encode($value);
} elseif (!in_array($value, $this->getAllowedConfigValues($key))) {
throw new \InvalidArgumentException('Invalid config value');
}
@ -174,9 +219,27 @@ class UserConfig {
if (is_bool($this->getDefaultConfigValue($key)) && is_string($value)) {
return $value === '1';
}
if (is_string($value) && str_starts_with($value, '[') && str_ends_with($value, ']')) {
$value = json_decode($value, true) ?? $value;
}
return $value;
}, $this->getAllowedConfigKeys());
return array_combine($this->getAllowedConfigKeys(), $userConfigs);
}
/**
* Get the config definition for a given key
*
* @param string $key
* @return array
*/
private function getConfigDefinition(string $key): array {
foreach (self::ALLOWED_CONFIGS as $config) {
if ($config['key'] === $key) {
return $config;
}
}
return [];
}
}

View file

@ -0,0 +1,145 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<tr
class="files-list__row files-list__row--image-group"
:class="{
'files-list__row--image-group-expanded': source.expanded,
'files-list__row--active': isSelected,
}">
<td class="files-list__row-checkbox" @click.stop>
<NcCheckboxRadioSwitch
:aria-label="t('files', 'Toggle selection for image group')"
:modelValue="isSelected"
:indeterminate="isPartiallySelected"
@update:modelValue="onSelectionChange" />
</td>
<td class="files-list__row-name" @click="$emit('toggle', source.source)">
<span class="files-list__row-icon">
<ImageMultipleIcon :size="20" />
</span>
<span class="files-list__row-image-group-chevron">
<ChevronRightIcon v-if="!source.expanded" :size="20" />
<ChevronDownIcon v-else :size="20" />
</span>
<span class="files-list__row-name-text">
{{ n('files', '{count} image', '{count} images', source.images.length, { count: source.images.length }) }}
</span>
</td>
<td v-if="isMimeAvailable" class="files-list__row-mime" />
<td v-if="isSizeAvailable" class="files-list__row-size" />
<td v-if="isMtimeAvailable" class="files-list__row-mtime" />
</tr>
</template>
<script lang="ts">
import type { PropType } from 'vue'
import type { ImageGroupNode } from '../composables/useImageGrouping.ts'
import { n, t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import ChevronDownIcon from 'vue-material-design-icons/ChevronDown.vue'
import ChevronRightIcon from 'vue-material-design-icons/ChevronRight.vue'
import ImageMultipleIcon from 'vue-material-design-icons/ImageMultiple.vue'
import { useSelectionStore } from '../store/selection.ts'
export default defineComponent({
name: 'FileEntryImageGroup',
components: {
ChevronDownIcon,
ChevronRightIcon,
ImageMultipleIcon,
NcCheckboxRadioSwitch,
},
props: {
source: {
type: Object as PropType<ImageGroupNode>,
required: true,
},
isMimeAvailable: {
type: Boolean,
default: false,
},
isSizeAvailable: {
type: Boolean,
default: false,
},
isMtimeAvailable: {
type: Boolean,
default: false,
},
},
emits: ['toggle'],
setup() {
const selectionStore = useSelectionStore()
return { selectionStore, n, t }
},
computed: {
childSources() {
return this.source.images.map((img) => img.source)
},
isSelected() {
return this.childSources.every((src) => this.selectionStore.selected.includes(src))
},
isPartiallySelected() {
return !this.isSelected && this.childSources.some((src) => this.selectionStore.selected.includes(src))
},
},
methods: {
onSelectionChange(selected: boolean) {
const current = this.selectionStore.selected
if (selected) {
// select all children
this.selectionStore.set([...new Set([...current, ...this.childSources])])
} else {
// unselect all children
this.selectionStore.set(current.filter((src) => !this.childSources.includes(src)))
}
},
onRowClick() {
this.onSelectionChange(!this.isSelected)
},
},
})
</script>
<style scoped lang="scss">
.files-list__row--image-group {
.files-list__row-name {
cursor: pointer;
* {
cursor: pointer;
}
}
.files-list__row-image-group-chevron {
display: flex;
align-items: center;
color: var(--color-text-maxcontrast);
}
.files-list__row-name-text {
color: var(--color-main-text);
}
}
</style>

View file

@ -0,0 +1,92 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<FileEntryImageGroup
v-if="isGroup"
:source="source"
:isMimeAvailable="isMimeAvailable"
:isSizeAvailable="isSizeAvailable"
:isMtimeAvailable="isMtimeAvailable"
@toggle="onToggleGroup?.($event)" />
<component
:is="entryComponent"
v-else
:source="source"
:class="{ 'files-list__row--group-child': isGroupChild }"
v-bind="$attrs" />
</template>
<script lang="ts">
import type { PropType } from 'vue'
import type { GroupedNode } from '../composables/useImageGrouping.ts'
import { defineComponent } from 'vue'
import FileEntry from './FileEntry.vue'
import FileEntryGrid from './FileEntryGrid.vue'
import FileEntryImageGroup from './FileEntryImageGroup.vue'
import { isImageGroup } from '../composables/useImageGrouping.ts'
export default defineComponent({
name: 'FileEntryWrapper',
components: {
FileEntry,
FileEntryGrid,
FileEntryImageGroup,
},
inheritAttrs: false,
props: {
source: {
type: Object as PropType<GroupedNode>,
required: true,
},
gridMode: {
type: Boolean,
default: false,
},
isMimeAvailable: {
type: Boolean,
default: false,
},
isSizeAvailable: {
type: Boolean,
default: false,
},
isMtimeAvailable: {
type: Boolean,
default: false,
},
onToggleGroup: {
type: Function,
default: null,
},
},
emits: ['toggle-group'],
computed: {
isGroup(): boolean {
return isImageGroup(this.source)
},
isGroupChild(): boolean {
return '_isGroupChild' in this.source
},
entryComponent() {
return this.gridMode ? FileEntryGrid : FileEntry
},
},
})
</script>

View file

@ -0,0 +1,69 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script lang="ts" setup>
import { t } from '@nextcloud/l10n'
import { NcFormBoxSwitch, NcInputField, NcSelect } from '@nextcloud/vue'
import debounce from 'debounce'
import { ref, watch } from 'vue'
import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection'
import NcFormBox from '@nextcloud/vue/components/NcFormBox'
import { useUserConfigStore } from '../../store/userconfig.ts'
const store = useUserConfigStore()
const availableMimetypes = [
{ id: 'image/png', label: 'PNG' },
{ id: 'image/jpeg', label: 'JPEG' },
{ id: 'image/gif', label: 'GIF' },
{ id: 'image/webp', label: 'WebP' },
{ id: 'image/avif', label: 'AVIF' },
{ id: 'image/heic', label: 'HEIC' },
{ id: 'image/heif', label: 'HEIF' },
]
const storedMimetypes = store.userConfig.recent_files_group_mimetypes
const initialMimetypes = Array.isArray(storedMimetypes)
? availableMimetypes.filter((m) => storedMimetypes.includes(m.id))
: []
const selectedMimetypes = ref(initialMimetypes)
const debouncedUpdateMimetypes = debounce((value) => {
store.update('recent_files_group_mimetypes', JSON.stringify(value.map((v) => v.id)))
}, 500)
watch(selectedMimetypes, (value) => {
debouncedUpdateMimetypes(value)
})
const debouncedUpdateTimespan = debounce((value: number) => {
store.update('recent_files_group_timespan_minutes', value)
}, 500)
</script>
<template>
<NcAppSettingsSection id="recent" :name="t('files', 'Recent view')">
<NcFormBox>
<NcFormBoxSwitch
v-model="store.userConfig.group_recent_files_images"
:label="t('files', 'Group image files')"
@update:modelValue="store.update('group_recent_files_images', $event)" />
<label>{{ t('files', 'Group these image types together') }}</label>
<NcSelect
v-model="selectedMimetypes"
:options="availableMimetypes"
labelOutside
multiple />
<NcInputField
v-model="store.userConfig.recent_files_group_timespan_minutes"
type="number"
:min="1"
:max="999"
:label="t('files', 'Time window in minutes to group files uploaded close together')"
@update:modelValue="debouncedUpdateTimespan(Number($event))" />
</NcFormBox>
</NcAppSettingsSection>
</template>

View file

@ -2,20 +2,23 @@
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<VirtualList
ref="table"
:data-component="userConfig.grid_view ? FileEntryGrid : FileEntry"
:data-component="FileEntryWrapper"
data-key="source"
:data-sources="nodes"
:grid-mode="userConfig.grid_view"
:extra-props="{
:data-sources="groupedNodes"
:gridMode="userConfig.grid_view"
:extraProps="{
gridMode: userConfig.grid_view,
isMimeAvailable,
isMtimeAvailable,
isSizeAvailable,
nodes,
onToggleGroup: toggleGroup,
}"
:scroll-to-index="scrollToIndex"
:scrollToIndex="scrollToIndex"
:caption="caption">
<!-- eslint-disable-next-line vue/singleline-html-element-content-newline -- no space allowed as otherwise `:empty` css selector does not trigger! -->
<template #filters><FileListFilterToSearch /><FileListFilterChips /></template>
@ -25,8 +28,8 @@
{{ n('files', '{count} selected', '{count} selected', selectedNodes.length, { count: selectedNodes.length }) }}
</span>
<FilesListTableHeaderActions
:current-view="currentView"
:selected-nodes="selectedNodes" />
:currentView="currentView"
:selectedNodes="selectedNodes" />
</template>
<template #before>
@ -34,8 +37,8 @@
<FilesListHeader
v-for="header in headers"
:key="header.id"
:current-folder="currentFolder"
:current-view="currentView"
:currentFolder="currentFolder"
:currentView="currentView"
:header="header" />
</template>
@ -44,9 +47,9 @@
<!-- Table header and sort buttons -->
<FilesListTableHeader
ref="thead"
:is-mime-available="isMimeAvailable"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:isMimeAvailable="isMimeAvailable"
:isMtimeAvailable="isMtimeAvailable"
:isSizeAvailable="isSizeAvailable"
:nodes="nodes" />
</template>
@ -58,10 +61,10 @@
<!-- Tfoot-->
<template #footer>
<FilesListTableFooter
:current-view="currentView"
:is-mime-available="isMimeAvailable"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:currentView="currentView"
:isMimeAvailable="isMimeAvailable"
:isMtimeAvailable="isMtimeAvailable"
:isSizeAvailable="isSizeAvailable"
:nodes="nodes"
:summary="summary" />
</template>
@ -77,9 +80,10 @@ import { showError } from '@nextcloud/dialogs'
import { FileType, Folder, getSidebar, Permission, View } from '@nextcloud/files'
import { n, t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { computed, defineComponent } from 'vue'
import { computed, defineComponent, shallowRef } from 'vue'
import FileEntry from './FileEntry.vue'
import FileEntryGrid from './FileEntryGrid.vue'
import FileEntryWrapper from './FileEntryWrapper.vue'
import FileListFilterChips from './FileListFilter/FileListFilterChips.vue'
import FileListFilterToSearch from './FileListFilter/FileListFilterToSearch.vue'
import FilesListHeader from './FilesListHeader.vue'
@ -90,6 +94,8 @@ import VirtualList from './VirtualList.vue'
import { useEnabledFileActions } from '../composables/useFileActions.ts'
import { useFileListHeaders } from '../composables/useFileListHeaders.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
// eslint-disable-next-line perfectionist/sort-named-imports
import { type ImageGroupingConfig, useImageGrouping } from '../composables/useImageGrouping.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import logger from '../logger.ts'
import { useActiveStore } from '../store/active.ts'
@ -167,6 +173,42 @@ export default defineComponent({
return props.nodes.some((node: INode) => node.size !== undefined)
})
const expandedGroups = shallowRef(new Set<string>())
const groupingConfig = computed<ImageGroupingConfig>(() => {
const isRecentView = props.currentView.id === 'recent'
const isGroupingEnabled = userConfigStore.userConfig.group_recent_files_images === true
const configuredMimetypes = userConfigStore.userConfig.recent_files_group_mimetypes
const mimetypes = isRecentView && isGroupingEnabled && Array.isArray(configuredMimetypes)
? configuredMimetypes
: []
return {
mimetypes,
timespanMinutes: Number(userConfigStore.userConfig.recent_files_group_timespan_minutes) || 2,
}
})
const groupedNodes = useImageGrouping(
computed(() => props.nodes),
expandedGroups,
groupingConfig,
)
/**
*
* @param groupKey
*/
function toggleGroup(groupKey: string) {
if (expandedGroups.value.has(groupKey)) {
expandedGroups.value.delete(groupKey)
} else {
expandedGroups.value.add(groupKey)
}
expandedGroups.value = new Set(expandedGroups.value)
}
return {
fileId,
headers: useFileListHeaders(),
@ -183,6 +225,10 @@ export default defineComponent({
n,
t,
groupedNodes,
toggleGroup,
FileEntryWrapper,
}
},
@ -537,6 +583,10 @@ export default defineComponent({
}
}
.files-list__row--group-child {
padding-inline-start: 12px;
}
// Before table and thead
.files-list__before {
display: flex;

View file

@ -0,0 +1,138 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { INode } from '@nextcloud/files'
// eslint-disable-next-line perfectionist/sort-named-imports
import { type Ref, computed } from 'vue'
export interface ImageGroupNode {
_isImageGroup: true
// Stable key for VirtualList recycled pool
source: string
images: INode[]
expanded: boolean
}
export type GroupedNode = INode | ImageGroupNode | (INode & { _isGroupChild: true })
/**
*
* @param node
*/
export function isImageGroup(node: GroupedNode): node is ImageGroupNode {
return '_isImageGroup' in node && node._isImageGroup === true
}
export interface ImageGroupingConfig {
mimetypes: string[]
timespanMinutes: number
}
/**
*
* @param node
*/
function getNodeTime(node: INode): number {
const uploadTime = (node.attributes?.upload_time as number) ?? 0
const crtime = (node.attributes?.crtime as number) ?? 0
const mtime = node.mtime ? Math.floor(node.mtime / 1000) : 0
return Math.max(uploadTime, crtime, mtime)
}
/**
*
* @param nodes
* @param expandedGroups
* @param config
*/
export function useImageGrouping(
nodes: Ref<INode[]>,
expandedGroups: Ref<Set<string>>,
config: Ref<ImageGroupingConfig>,
) {
return computed<GroupedNode[]>(() => {
const result: GroupedNode[] = []
let i = 0
const { mimetypes, timespanMinutes } = config.value
const timespan = timespanMinutes * 60
if (mimetypes.length === 0) {
return nodes.value
}
const isGroupable = (node: INode) => node.mime !== undefined && mimetypes.includes(node.mime)
while (i < nodes.value.length) {
const node = nodes.value[i]
if (!isGroupable(node)) {
result.push(node)
i++
continue
}
const groupStartTime = getNodeTime(node)
// Look ahead: if any non-image falls within the timespan, don't group
const isContaminated = groupStartTime && nodes.value.slice(i + 1).some((next) => {
const nextTime = getNodeTime(next)
if (!nextTime) {
return false
}
if (Math.abs(nextTime - groupStartTime) > timespan) {
return false
}
return !isGroupable(next)
})
if (isContaminated) {
result.push(node)
i++
continue
}
// Start a new group from this image
const images: INode[] = [node]
i++
while (i < nodes.value.length) {
const next = nodes.value[i]
if (!isGroupable(next)) {
break
}
const nextTime = getNodeTime(next)
if (!groupStartTime || !nextTime || Math.abs(nextTime - groupStartTime) > timespan) {
break
}
images.push(next)
i++
}
if (images.length === 1) {
result.push(images[0])
continue
}
const groupKey = `image-group-${images.map((n) => n.fileid).join('-')}`
result.push({
_isImageGroup: true,
source: groupKey,
images,
expanded: expandedGroups.value.has(groupKey),
})
if (expandedGroups.value.has(groupKey)) {
result.push(...images.map((img) => Object.assign(Object.create(Object.getPrototypeOf(img)), img, { _isGroupChild: true })))
}
}
return result
})
}

View file

@ -51,7 +51,7 @@ export interface PathOptions {
// User config store
export interface UserConfig {
[key: string]: boolean | string | undefined
[key: string]: boolean | string | string[] | number | undefined
crop_image_previews: boolean
default_view: 'files' | 'personal'
@ -59,6 +59,9 @@ export interface UserConfig {
grid_view: boolean
sort_favorites_first: boolean
sort_folders_first: boolean
group_recent_files_images: boolean
recent_files_group_mimetypes: string[] | string
recent_files_group_timespan_minutes: number
show_files_extensions: boolean
show_hidden: boolean

View file

@ -11,6 +11,7 @@ import NcAppSettingsDialog from '@nextcloud/vue/components/NcAppSettingsDialog'
import FilesAppSettingsAppearance from '../components/FilesAppSettings/FilesAppSettingsAppearance.vue'
import FilesAppSettingsGeneral from '../components/FilesAppSettings/FilesAppSettingsGeneral.vue'
import FilesAppSettingsLegacyApi from '../components/FilesAppSettings/FilesAppSettingsLegacyApi.vue'
import FilesAppSettingsRecent from '../components/FilesAppSettings/FilesAppSettingsRecent.vue'
import FilesAppSettingsShortcuts from '../components/FilesAppSettings/FilesAppSettingsShortcuts.vue'
import FilesAppSettingsWarnings from '../components/FilesAppSettings/FilesAppSettingsWarnings.vue'
import FilesAppSettingsWebDav from '../components/FilesAppSettings/FilesAppSettingsWebDav.vue'
@ -48,15 +49,16 @@ async function showKeyboardShortcuts() {
<NcAppSettingsDialog
:legacy="false"
:name="t('files', 'Files settings')"
no-version
noVersion
:open="open"
show-navigation
showNavigation
@update:open="emit('close')">
<FilesAppSettingsGeneral />
<FilesAppSettingsAppearance />
<FilesAppSettingsLegacyApi />
<FilesAppSettingsWarnings />
<FilesAppSettingsWebDav />
<FilesAppSettingsRecent />
<FilesAppSettingsShortcuts />
</NcAppSettingsDialog>
</template>

View file

@ -159,7 +159,7 @@ class QuerySearchHelper {
|| in_array('upload_time', $requestedFields)
|| in_array('last_activity', $orderFields);
$query = $builder->selectFileCache('file', $joinExtendedCache);
$query = $builder->selectFileCache('file', true);
if (in_array('systemtag', $requestedFields)) {
$this->equipQueryForSystemTags($query, $this->requireUser($searchQuery));