Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
bf13845
feat(ui): show actor avatar with event-type badge
karlitschek Apr 28, 2026
09eed08
feat(ui): add type-family color accents to activity rows
karlitschek Apr 28, 2026
0f8602e
feat(ui): show a "today at a glance" summary above the stream
karlitschek Apr 28, 2026
1986462
feat(ui): skeleton loaders + fade-in for newly polled activities
karlitschek Apr 28, 2026
d77019f
feat(ui): larger previews with hover-zoom
karlitschek Apr 28, 2026
45bc946
feat(ui): replace settings checkbox matrix with grouped cards + presets
karlitschek Apr 28, 2026
a2c8b52
feat(ui): add search / date / person filters above the stream
karlitschek Apr 28, 2026
5ff80eb
feat(ui): pinnable saved views
karlitschek Apr 28, 2026
d1f830a
feat(ui): density toggle (compact / cozy / comfortable)
karlitschek Apr 28, 2026
1f88dbe
feat(ui): add an Insights tab with sparkline + top lists
karlitschek Apr 28, 2026
e5c09b7
feat(ui): collapse consecutive comments into expandable threads
karlitschek Apr 28, 2026
e172e7a
feat(ui): friendlier empty state with pulse + actions
karlitschek Apr 28, 2026
8537bfd
Merge feat/settings-cards
karlitschek Apr 28, 2026
e897c9c
Merge feat/comment-threads
karlitschek Apr 28, 2026
80d4b47
Merge feat/density-toggle
karlitschek Apr 28, 2026
e83f226
Merge feat/insights-tab
karlitschek Apr 28, 2026
63ad337
Merge feat/preview-zoom
karlitschek Apr 28, 2026
64bcc59
Merge feat/colored-avatars
karlitschek Apr 28, 2026
ff391b3
Merge feat/type-accents
karlitschek Apr 28, 2026
b62933d
Merge feat/today-summary
karlitschek Apr 28, 2026
7f8a395
Merge feat/skeleton-loaders
karlitschek Apr 28, 2026
ef26254
Merge feat/search-filters and feat/saved-views (stacked)
karlitschek Apr 28, 2026
e01c32a
Merge feat/empty-state
karlitschek Apr 28, 2026
8eb227a
feat(ui): inline filter row in topbar + floating preview popup
karlitschek Apr 28, 2026
6741851
feat(ui): make preview popup follow the cursor
karlitschek Apr 28, 2026
1f88d9e
chore(ui): drop empty-state action buttons + drop density toggle
karlitschek Apr 28, 2026
36a981e
feat(ui): list/grid view toggle + popup anchored under thumbnail
karlitschek Apr 28, 2026
c53264b
chore(ui): drop list/grid toggle, scope preview hover to the image
karlitschek Apr 28, 2026
1e4cdbf
feat(ui): preview popup follows the cursor again
karlitschek Apr 28, 2026
fc730e5
feat(ui): server-side search, refresh button, permalinks, insights, c…
karlitschek Apr 28, 2026
58f785d
feat(ui): pinned activities, muted users, activity bursts, refined pr…
karlitschek May 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions lib/Controller/APIv2Controller.php
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ class APIv2Controller extends OCSController {
/** @var bool */
protected $loadPreviews;

/** @var string */
protected $search = '';

public function __construct(
$appName,
IRequest $request,
Expand Down Expand Up @@ -79,7 +82,7 @@ public function __construct(
* @throws InvalidFilterException when the filter is invalid
* @throws \OutOfBoundsException when no user is given
*/
protected function validateParameters($filter, $since, $limit, $previews, $objectType, $objectId, $sort) {
protected function validateParameters($filter, $since, $limit, $previews, $objectType, $objectId, $sort, $search = '') {
$this->filter = \is_string($filter) ? $filter : 'all';
if ($this->filter !== $this->data->validateFilter($this->filter)) {
throw new InvalidFilterException('Invalid filter');
Expand All @@ -90,6 +93,7 @@ protected function validateParameters($filter, $since, $limit, $previews, $objec
$this->objectType = (string)$objectType;
$this->objectId = (int)$objectId;
$this->sort = \in_array($sort, ['asc', 'desc'], true) ? $sort : 'desc';
$this->search = \is_string($search) ? trim($search) : '';

if (($this->objectType !== '' && $this->objectId === 0) || ($this->objectType === '' && $this->objectId !== 0)) {
// Only allowed together
Expand Down Expand Up @@ -117,8 +121,8 @@ protected function validateParameters($filter, $since, $limit, $previews, $objec
* @param string $sort
* @return DataResponse
*/
public function getDefault($since = 0, $limit = 50, $previews = false, $object_type = '', $object_id = 0, $sort = 'desc'): DataResponse {
return $this->get('all', $since, $limit, $previews, $object_type, $object_id, $sort);
public function getDefault($since = 0, $limit = 50, $previews = false, $object_type = '', $object_id = 0, $sort = 'desc', $q = ''): DataResponse {
return $this->get('all', $since, $limit, $previews, $object_type, $object_id, $sort, $q);
}

/**
Expand All @@ -131,10 +135,11 @@ public function getDefault($since = 0, $limit = 50, $previews = false, $object_t
* @param string $object_type
* @param int $object_id
* @param string $sort
* @param string $q Free-text query — filters subject + message server-side
* @return DataResponse
*/
public function getFilter($filter, $since = 0, $limit = 50, $previews = false, $object_type = '', $object_id = 0, $sort = 'desc'): DataResponse {
return $this->get($filter, $since, $limit, $previews, $object_type, $object_id, $sort);
public function getFilter($filter, $since = 0, $limit = 50, $previews = false, $object_type = '', $object_id = 0, $sort = 'desc', $q = ''): DataResponse {
return $this->get($filter, $since, $limit, $previews, $object_type, $object_id, $sort, $q);
}

/**
Expand Down Expand Up @@ -199,9 +204,9 @@ public function listFilters(): DataResponse {
* @param string $sort
* @return DataResponse
*/
protected function get($filter, $since, $limit, $previews, $filterObjectType, $filterObjectId, $sort): DataResponse {
protected function get($filter, $since, $limit, $previews, $filterObjectType, $filterObjectId, $sort, $search = ''): DataResponse {
try {
$this->validateParameters($filter, $since, $limit, $previews, $filterObjectType, $filterObjectId, $sort);
$this->validateParameters($filter, $since, $limit, $previews, $filterObjectType, $filterObjectId, $sort, $search);
} catch (InvalidFilterException $e) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
} catch (\OutOfBoundsException $e) {
Expand All @@ -221,7 +226,9 @@ protected function get($filter, $since, $limit, $previews, $filterObjectType, $f

$this->filter,
$this->objectType,
$this->objectId
$this->objectId,
false,
$this->search,
);
} catch (\OutOfBoundsException $e) {
// Invalid since argument
Expand Down Expand Up @@ -279,6 +286,9 @@ protected function generateHeaders(array $headers, bool $hasMoreActivities, arra
$nextPageParameters['object_type'] = $this->objectType;
$nextPageParameters['object_id'] = $this->objectId;
}
if ($this->search !== '') {
$nextPageParameters['q'] = $this->search;
}
if ($this->request->getParam('format') !== null) {
$nextPageParameters['format'] = $this->request->getParam('format');
}
Expand Down
20 changes: 19 additions & 1 deletion lib/Data.php
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,13 @@ public function storeMail(IEvent $event, int $latestSendTime): bool {
* @param int $objectId Allows to filter the activities to a given object. May only appear together with $objectType
*
* @param bool $returnEvents return only the events
* @param string $search Free-text query — when non-empty, only activities whose subject or message contains
* this substring (case-insensitive) are returned. Used for the server-side search box
* in the activity stream UI.
* @return array
*
*/
public function get(GroupHelper $groupHelper, UserSettings $userSettings, $user, $since, $limit, $sort, $filter, $objectType = '', $objectId = 0, bool $returnEvents = false) {
public function get(GroupHelper $groupHelper, UserSettings $userSettings, $user, $since, $limit, $sort, $filter, $objectType = '', $objectId = 0, bool $returnEvents = false, string $search = '') {
// get current user
if ($user === '') {
throw new \OutOfBoundsException('Invalid user', 1);
Expand Down Expand Up @@ -283,6 +286,21 @@ public function get(GroupHelper $groupHelper, UserSettings $userSettings, $user,
}
}

// Free-text search: match against subject and message columns.
// Trim + length-cap defensively so a pathological query can't blow up
// the SQL or pull every row through a LIKE on a giant string.
$search = trim($search);
if ($search !== '') {
$needle = '%' . $this->connection->escapeLikeParameter(mb_substr($search, 0, 200)) . '%';
$param = $query->createNamedParameter($needle);
$query->andWhere(
$query->expr()->orX(
$query->expr()->iLike('subject', $param),
$query->expr()->iLike('message', $param),
),
);
}

/**
* Order and specify the offset
*/
Expand Down
206 changes: 206 additions & 0 deletions src/components/ActivityBurst.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
<li class="activity-burst" :class="`activity-burst--type-${typeFamily}`">
<button
class="activity-burst__toggle"
type="button"
:aria-expanded="expanded ? 'true' : 'false'"
@click="expanded = !expanded">
<NcAvatar
v-if="hasActor"
class="activity-burst__avatar"
:user="firstActivity.user"
:size="32"
:disable-menu="true"
:show-user-status="false" />
<span v-else class="activity-burst__avatar activity-burst__avatar--system" aria-hidden="true" />
<IconChevronRight :size="16" class="activity-burst__chevron" />
<span class="activity-burst__summary">
{{ summary }}
</span>
<span class="activity-burst__date">
{{ relativeLatest }}
</span>
</button>
<ul v-if="expanded" class="activity-burst__list">
<ActivityComponent
v-for="activity in activities"
:key="activity.id"
:activity="activity"
:show-previews="false" />
</ul>
</li>
</template>

<script setup lang="ts">
import type ActivityModel from '../models/ActivityModel.ts'

import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import moment from '@nextcloud/moment'
import { computed, ref } from 'vue'
import IconChevronRight from 'vue-material-design-icons/ChevronRight.vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import ActivityComponent from './ActivityComponent.vue'

const props = defineProps<{
activities: ActivityModel[]
}>()

const expanded = ref(false)

const firstActivity = computed(() => props.activities[0])

const hasActor = computed<boolean>(() => {
const uid = firstActivity.value.user
return typeof uid === 'string' && uid !== '' && uid !== 'system'
})

// Bucket the burst's type into the same visual families used by
// GenericActivity so the marker dot colour stays consistent between a
// burst and the individual rows it expands into.
const typeFamily = computed<string>(() => {
const type = firstActivity.value.type
if (type === 'file_created') return 'created'
if (type === 'file_deleted') return 'deleted'
if (type === 'file_changed' || type === 'file_restored') return 'changed'
if (type === 'favorite') return 'favorite'
if (type.startsWith('shared') || type.startsWith('share') || type.startsWith('unshare') || type.includes('_share')) return 'share'
if (type === 'remote_share') return 'share'
return 'neutral'
})

// Single-line burst summary. Branches on type family so the verb
// matches what the individual rows would have said; unknown families
// fall through to a generic "%n events" so we never invent a verb for
// an activity type we don't recognise.
const summary = computed<string>(() => {
const count = props.activities.length
switch (typeFamily.value) {
case 'created':
return n('activity', '%n item created', '%n items created', count)
case 'deleted':
return n('activity', '%n item deleted', '%n items deleted', count)
case 'changed':
return n('activity', '%n item changed', '%n items changed', count)
case 'favorite':
return n('activity', '%n item favorited', '%n items favorited', count)
case 'share':
return n('activity', '%n share update', '%n share updates', count)
default:
return n('activity', '%n activity', '%n activities', count)
}
})

const relativeLatest = computed<string>(() => {
// activities are newest-first inside a day group, so [0] is the latest
return moment(firstActivity.value.datetime).fromNow()
})
</script>

<style lang="scss" scoped>
.activity-burst {
position: relative;
margin-block: 4px;
padding-inline-start: 32px;
border-radius: var(--border-radius);

// Marker dot anchored to the day-group's vertical rail. Coloured by
// type family so a burst reads at-a-glance the same way an individual
// row does.
&::before {
content: '';
position: absolute;
left: 8px;
top: 18px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--rail-dot-color, var(--color-border-dark));
// Light ring punches the dot through the underlying rail line so
// the rail visually breaks at each marker instead of running
// behind it.
box-shadow: 0 0 0 3px var(--color-main-background);
z-index: 1;
}

&--type-created { --rail-dot-color: var(--color-success, #46ba61); }
&--type-deleted { --rail-dot-color: var(--color-error, #e9322d); }
&--type-changed { --rail-dot-color: var(--color-primary, #0082c9); }
&--type-share { --rail-dot-color: #8e44ad; }
&--type-favorite { --rail-dot-color: var(--color-warning, #f4a261); }
&--type-neutral { --rail-dot-color: var(--color-border-dark, #d0d0d0); }

&__toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
background: transparent;
border: none;
border-radius: var(--border-radius);
text-align: start;
color: var(--color-main-text);
cursor: pointer;
font: inherit;

&:hover, &:focus-visible {
background: var(--color-background-hover);
}
}

&__avatar {
flex-shrink: 0;

&--system {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-background-dark);
}
}

&__chevron {
flex-shrink: 0;
color: var(--color-text-maxcontrast);
transition: transform 150ms ease;
}

&[aria-expanded='true'] &__chevron,
&__toggle[aria-expanded='true'] &__chevron {
transform: rotate(90deg);
}

&__summary {
flex-grow: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}

&__date {
flex-shrink: 0;
font-size: 13px;
color: var(--color-text-lighter);
}

&__list {
list-style: none;
padding: 4px 0 8px 0;
margin: 0;

// Nested rows would otherwise paint their own marker dots at
// an offset that doesn't align to the day-group's rail. Hide
// them so only the burst's own dot remains visible.
:deep(.activity-entry::before) {
display: none;
}
}
}
</style>
6 changes: 6 additions & 0 deletions src/components/ActivityComponent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ const props = defineProps<{
* Whether to show previews
*/
showPreviews: boolean

/**
* Whether this activity arrived via polling (vs. initial load) and
* should briefly pulse to draw attention.
*/
fresh?: boolean
}>()

defineEmits(['reload'])
Expand Down
Loading
Loading