From bf13845cf4dfced5df3bd2b7d8d37696e98cb7a1 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Tue, 28 Apr 2026 16:35:20 +0200 Subject: [PATCH 01/20] feat(ui): show actor avatar with event-type badge Replace the 20px monochrome event icon with the actor's 32px color NcAvatar, and place the event-type icon as a small circular badge in the bottom-right corner. Activities with no actor (system events) keep the legacy event icon as the primary avatar so automated events are not mis-attributed to a real user. Makes the stream feel like "people doing things" instead of "system log entries". Co-Authored-By: Claude Opus 4.7 --- src/components/activities/GenericActivity.vue | 66 ++++++++++++++++--- 1 file changed, 57 insertions(+), 9 deletions(-) mode change 100644 => 100755 src/components/activities/GenericActivity.vue diff --git a/src/components/activities/GenericActivity.vue b/src/components/activities/GenericActivity.vue old mode 100644 new mode 100755 index 3e0b1e04b..20029e1ed --- a/src/components/activities/GenericActivity.vue +++ b/src/components/activities/GenericActivity.vue @@ -5,13 +5,29 @@ @@ -23,11 +27,68 @@ import { translate as t } from '@nextcloud/l10n' import moment from '@nextcloud/moment' import { computed } from 'vue' import ActivityComponent from './ActivityComponent.vue' +import CommentThread from './CommentThread.vue' const props = defineProps<{ activities: ActivityModel[] }>() +/** + * Bucket consecutive comment activities on the same target object into + * threads. Two activities thread together when: + * - both have type === 'comments', + * - they target the same (objectType, objectId) tuple, and + * - they are consecutive in the input list (the list is already sorted + * newest-first per day, so neighbouring entries are temporally close). + * + * A single comment doesn't form a thread — it falls through to the regular + * ActivityComponent so the row still gets a preview and is fully readable. + */ +type ThreadItem = + | { thread: true; activities: ActivityModel[] } + | { thread: false; activity: ActivityModel } + +const threadedActivities = computed(() => { + const items: ThreadItem[] = [] + let buffer: ActivityModel[] = [] + + const flushBuffer = () => { + if (buffer.length === 0) return + if (buffer.length >= 2) { + items.push({ thread: true, activities: buffer }) + } else { + items.push({ thread: false, activity: buffer[0] }) + } + buffer = [] + } + + for (const a of props.activities) { + const isComment = a.type === 'comments' + if (!isComment) { + flushBuffer() + items.push({ thread: false, activity: a }) + continue + } + const head = buffer[0] + const sameTarget = head + && head.objectType === a.objectType + && head.objectId === a.objectId + if (head && !sameTarget) { + flushBuffer() + } + buffer.push(a) + } + flushBuffer() + return items +}) + +function itemKey(item: ThreadItem, index: number): string { + if (item.thread) { + return 'thread-' + item.activities[0].id + '-' + item.activities.length + } + return 'item-' + item.activity.id + '-' + index +} + /** * Title to show for the date either Today, Yesterday or the full date */ diff --git a/src/components/CommentThread.vue b/src/components/CommentThread.vue new file mode 100755 index 000000000..46d9c576d --- /dev/null +++ b/src/components/CommentThread.vue @@ -0,0 +1,129 @@ + + + + + + + From e172e7a92def80db5d38a5f5cfefd8eea0de4088 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Tue, 28 Apr 2026 16:55:22 +0200 Subject: [PATCH 12/20] feat(ui): friendlier empty state with pulse + actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "No activity yet" state was a single line of text and a tiny monochrome icon — uninviting for new users and people landing on the "Self" or "By you" filters before they have done anything. Replace it with: * A 64px app icon on a soft radial backdrop, surrounded by two staggered, slowly pulsing rings — life signs without being noisy. * A friendlier headline ("Nothing has happened here yet") and a filter-aware description that nudges the user toward the action most likely to populate the stream they are looking at. A small deterministic hash picks one of two messages per filter so the copy stays varied across the app but stable per page. * Two action buttons in the empty-content slot — "Open Files" (primary) and "Notification settings" (secondary) — so the user has somewhere to go. Co-Authored-By: Claude Opus 4.7 --- src/views/ActivityAppFeed.vue | 95 +++++++++++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 5 deletions(-) mode change 100644 => 100755 src/views/ActivityAppFeed.vue diff --git a/src/views/ActivityAppFeed.vue b/src/views/ActivityAppFeed.vue old mode 100644 new mode 100755 index a24f859e6..232b4ab7e --- a/src/views/ActivityAppFeed.vue +++ b/src/views/ActivityAppFeed.vue @@ -18,11 +18,23 @@ + class="activity-app__empty-content activity-app__empty-content--decorated" + :name="t('activity', 'Nothing has happened here yet')" + :description="emptyDescription"> +
@@ -55,7 +67,7 @@ import { showError } from '@nextcloud/dialogs' import { loadState } from '@nextcloud/initial-state' import { translate as t } from '@nextcloud/l10n' import moment from '@nextcloud/moment' -import { generateOcsUrl } from '@nextcloud/router' +import { generateOcsUrl, generateUrl } from '@nextcloud/router' import { useDocumentVisibility, useInfiniteScroll, useDebounceFn } from '@vueuse/core' import axios from 'axios' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' @@ -182,6 +194,36 @@ const headingTitle = computed(() => { return navigationList.find((navigationEl) => navigationEl.id === route.params.filter).name }) +/** + * One of a small set of friendly, situation-aware messages for the empty + * state. Picked deterministically from the current filter so reloads on + * the same page show the same line — randomness here would be jarring. + */ +const emptyDescription = computed(() => { + const filter = String(route.params.filter ?? 'all') + const messages = filter === 'self' + ? [ + t('activity', 'When you upload, edit or share a file, it will appear here.'), + t('activity', 'Your own actions will show up in this stream once you start working.'), + ] + : filter === 'by' + ? [ + t('activity', 'When someone shares with you or comments on a file you own, it will appear here.'), + t('activity', 'Activity from collaborators will land here as soon as it happens.'), + ] + : [ + t('activity', 'Upload a file, share something, or favourite a folder — events will start appearing here as soon as you do.'), + t('activity', 'This is where your Nextcloud activity lives. Add some files or shares to get started.'), + ] + // Pick one deterministically by hashing the filter id, so the empty + // state is stable per page across reloads. + const idx = filter.split('').reduce((s, c) => (s + c.charCodeAt(0)) % messages.length, 0) + return messages[idx] +}) + +const filesLink = generateUrl('/apps/files/') +const personalSettingsLink = generateUrl('/settings/user/notifications') + /** * Load activities for current filter or load more if already loaded */ @@ -350,6 +392,43 @@ watch(props, () => { height: 100%; } + &__empty-content--decorated { + // Soft radial backdrop behind the icon to make the page feel + // composed instead of "loading screen with text". + background: + radial-gradient(circle at 50% 38%, var(--color-primary-element-light, var(--color-background-hover)) 0%, transparent 60%), + transparent; + } + + &__empty-icon-stage { + position: relative; + width: 96px; + height: 96px; + display: flex; + align-items: center; + justify-content: center; + } + + &__empty-icon { + position: relative; + z-index: 2; + opacity: 0.85; + } + + &__empty-pulse { + position: absolute; + inset: 0; + border-radius: 50%; + background: var(--color-primary-element); + opacity: 0.18; + transform: scale(0.6); + animation: activity-empty-pulse 2.4s ease-out infinite; + + &--2 { + animation-delay: 1.2s; + } + } + &__loading-indicator { color: var(--color-text-maxcontrast); justify-self: center; @@ -397,4 +476,10 @@ watch(props, () => { margin-inline: calc(2 * var(--app-navigation-padding, 8px) + 44px) var(--app-navigation-padding, 8px); } } + +@keyframes activity-empty-pulse { + 0% { transform: scale(0.6); opacity: 0.25; } + 70% { transform: scale(1.4); opacity: 0; } + 100% { transform: scale(1.4); opacity: 0; } +} From 8eb227a9c00927a1726992c68cecb886dcc9e923 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Tue, 28 Apr 2026 17:33:34 +0200 Subject: [PATCH 13/20] feat(ui): inline filter row in topbar + floating preview popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-up tweaks to the combined UI refresh based on hands-on testing: 1) Move the search / person / from / to filters into the same row as the page heading. Wrapped heading + form in a new `__topbar` flex container, dropped NcTextField in favour of compact native elements (search + date), and tightened widths (search 160px, person 110px, dates 130px) so the whole bar fits on one line without dwarfing the heading. Today-summary and the saved-views chips moved underneath the topbar. 2) Floating preview popup at the bottom of the viewport. Added a second next to each thumbnail with class `__preview-popup`. It is hidden by default; on hover of the thumbnail it renders `position: fixed` at `bottom: 16px`, centred horizontally, scaled up to the natural image size (max 80vw / 70vh). This means the zoomed preview always appears at the bottom of the document regardless of which row is hovered, and the original thumbnail stays in place so the row layout doesn't jump. pointer-events: none so the popup doesn't steal the click — clicking the small thumbnail still opens the file. 3) Removed the dark hover border around the popup. The previous style added a 2px main-text-coloured border + outline on hover; the new popup has only a soft shadow on a clean main-background tile, which reads better at large sizes. The thumbnail still gets a subtle primary-element border colour change on hover so the user has visual confirmation they're on a hover target. --- src/components/activities/GenericActivity.vue | 52 ++++-- src/views/ActivityAppFeed.vue | 170 +++++++++--------- 2 files changed, 128 insertions(+), 94 deletions(-) diff --git a/src/components/activities/GenericActivity.vue b/src/components/activities/GenericActivity.vue index 71370581f..98e7a230f 100755 --- a/src/components/activities/GenericActivity.vue +++ b/src/components/activities/GenericActivity.vue @@ -55,6 +55,17 @@ }" :src="preview.source" :alt="preview.link ? t('activity', 'Open {filename}', { filename: preview.filename }) : ''"> + + @@ -316,24 +327,45 @@ export default defineComponent({ height: 80px; width: 80px; object-fit: cover; - transform-origin: top left; - transition: transform 200ms ease, box-shadow 200ms ease, border-color 150ms ease; + transition: border-color 150ms ease; - // Only add borders and the hover-zoom effect for actual previews — - // MIME-type icons stay untouched so they don't get blurry-scaled. &:not(.activity-entry__preview-mimetype) { border: 2px solid var(--color-border); border-radius: var(--border-radius-large); &:hover { - border-color: var(--color-main-text); - outline: 2px solid var(--color-main-background); - transform: scale(1.6); - box-shadow: 0 8px 24px var(--color-box-shadow); - z-index: 5; - position: relative; + border-color: var(--color-primary-element); } } } + + // Floating preview popup: renders out of flow at the bottom of the + // viewport, anchored centrally, when the user hovers the small + // thumbnail. Position fixed so it always opens at the bottom of the + // document regardless of scroll position; max-height/-width keep it + // within the viewport for big images. No border or outline — the + // shadow alone separates it from the page underneath. + &__preview-popup { + display: none; + position: fixed; + left: 50%; + bottom: 16px; + transform: translateX(-50%); + max-width: min(80vw, 900px); + max-height: 70vh; + width: auto; + height: auto; + border: none; + outline: none; + border-radius: var(--border-radius-large); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); + background: var(--color-main-background); + pointer-events: none; + z-index: 1000; + } + + &__preview:hover .activity-entry__preview-popup { + display: block; + } } diff --git a/src/views/ActivityAppFeed.vue b/src/views/ActivityAppFeed.vue index 9f63c1b5e..a8c25bf2d 100755 --- a/src/views/ActivityAppFeed.vue +++ b/src/views/ActivityAppFeed.vue @@ -4,9 +4,59 @@ --> -
(() => { return messages[idx] }) -const filesLink = generateUrl('/apps/files/') -const personalSettingsLink = generateUrl('/settings/user/notifications') - - /** * Load activities for current filter or load more if already loaded */ diff --git a/src/views/ActivityAppNavigation.vue b/src/views/ActivityAppNavigation.vue index a7a4ca8b4..d0949cbb3 100755 --- a/src/views/ActivityAppNavigation.vue +++ b/src/views/ActivityAppNavigation.vue @@ -31,19 +31,6 @@