Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions js/activity-app.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/activity-app.mjs.map

Large diffs are not rendered by default.

14 changes: 10 additions & 4 deletions playwright/e2e/stream.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,17 @@ test('Shows activity entries on load', async ({ page }) => {

test('Heading reflects the active filter', async ({ page }) => {
await page.goto(STREAM_URL)
await expect(page.locator('.activity-app__heading')).toBeVisible({ timeout: 30000 })
await expect(page.locator('.activity-app__heading')).toContainText('All activities')
// The <h1> is kept for semantics but visually hidden, so assert its content
// rather than its visibility.
await expect(page.locator('.activity-app__heading'))
.toContainText('All activities', { timeout: 30000 })
})

test('Navigation filter loads filtered stream', async ({ page }) => {
await page.goto(STREAM_URL)
await expect(page.locator('.activity-app__heading')).toBeVisible({ timeout: 30000 })
await expect(
page.locator('.activity-entry, .activity-app__empty-content').first(),
).toBeVisible({ timeout: 30000 })

await expect(page.locator('[data-navigation="all"]')).toBeVisible()

Expand All @@ -58,7 +62,9 @@ test('Navigation filter loads filtered stream', async ({ page }) => {

test('RSS feed toggle shows and hides the feed URL', async ({ page }) => {
await page.goto(STREAM_URL)
await expect(page.locator('.activity-app__heading')).toBeVisible({ timeout: 30000 })
await expect(
page.locator('.activity-entry, .activity-app__empty-content').first(),
).toBeVisible({ timeout: 30000 })

await expect(page.getByRole('textbox', { name: 'RSS feed' })).not.toBeVisible()

Expand Down
48 changes: 48 additions & 0 deletions src/__tests__/ActivityGroup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type ActivityModel from '../models/ActivityModel.ts'

import { shallowMount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import moment from '@nextcloud/moment'
import ActivityGroup from '../components/ActivityGroup.vue'

/**
* Build a minimal activities prop. The heading only reads the datetime of the
* first entry to derive its date label, so the rest can be left out.
*/
function mountGroup(datetime: string) {
return shallowMount(ActivityGroup, {
props: { activities: [{ id: 1, datetime } as unknown as ActivityModel] },
})
}

describe('ActivityGroup heading date label', () => {
it('labels today as "Today" and exposes the full date as the title', () => {
const wrapper = mountGroup(moment().toISOString())
const heading = wrapper.get('.activity-group__heading')

expect(heading.text()).toBe('Today')
expect(heading.attributes('title')).toBe(moment().format('LL'))
})

it('labels the previous day as "Yesterday"', () => {
const wrapper = mountGroup(moment().subtract(1, 'day').toISOString())
const heading = wrapper.get('.activity-group__heading')

expect(heading.text()).toBe('Yesterday')
expect(heading.attributes('title')).toBe(moment().subtract(1, 'day').format('LL'))
})

it('labels older days with the formatted date and no redundant title', () => {
const date = moment('2020-01-15T12:00:00')
const wrapper = mountGroup(date.toISOString())
const heading = wrapper.get('.activity-group__heading')

expect(heading.text()).toBe(date.format('LL'))
expect(heading.attributes('title')).toBeUndefined()
})
})
52 changes: 36 additions & 16 deletions src/components/ActivityGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@
-->

<template>
<h2 class="activity-group__heading" :title="fullDate">
{{ dateText }}
</h2>
<ul>
<ActivityComponent
v-for="activity in activities"
:key="activity.id"
:activity="activity"
:showPreviews="true" />
</ul>
<section class="activity-group">
<h2 class="activity-group__heading" :title="fullDate">
{{ dateText }}
</h2>
<ul>
<ActivityComponent
v-for="activity in activities"
:key="activity.id"
:activity="activity"
:showPreviews="true" />
</ul>
</section>
</template>

<script setup lang="ts">
Expand Down Expand Up @@ -55,14 +57,32 @@ const fullDate = computed(() => {

<style scoped lang="scss">
.activity-group {
// Gap between groups, as padding inside the <section> so the sticky heading stays
// pinned across it and the next date takes over as its group reaches the top
padding-block-end: 24px;

&__heading {
line-height: 1.5;
margin-block: 30px 12px;
position: sticky;
top: 0;
z-index: 1;
margin-block: 0;
// Bottom padding gives the background fade room to complete below the text
padding-block: 8px 20px;
// Match the settings-section__name heading size
font-size: 20px;
line-height: var(--default-clickable-area);
// Fade out so entries dissolve as they scroll under the heading
background: linear-gradient(to bottom, var(--color-main-background) 44%, transparent);

&:first-of-type {
// Already padding from h1
margin-block-start: 0;
}
// Indent the heading to clear the app-navigation toggle, but only by the amount
// the centring gutter ((100cqi - column width) / 2) doesn't already cover.
// Clamped to 0 so wide layouts keep it aligned with the entries.
padding-inline-start: calc(max(
0px,
var(--app-navigation-padding) + var(--default-clickable-area)
- var(--default-grid-baseline)
- max(0px, (100cqi - var(--activity-feed-max-width)) / 2)
));
Comment thread
artonge marked this conversation as resolved.
}
}
</style>
76 changes: 44 additions & 32 deletions src/views/ActivityAppFeed.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
-->
<template>
<NcAppContent class="activity-app">
<h1 class="activity-app__heading">
<!-- Kept for document semantics / screen readers, but visually hidden -->
<h1 class="activity-app__heading hidden-visually">
Comment thread
artonge marked this conversation as resolved.
{{ headingTitle }}
</h1>
<NcEmptyContent
Expand All @@ -26,24 +27,26 @@
</template>
</NcEmptyContent>
<div ref="container" class="activity-app__container" @scroll="onScroll">
<NcButton
v-if="newActivitiesAvailable"
class="activity-app__new-activities-indicator"
type="button"
@click="scrollToTop">
{{ t('activity', 'New activities') }}
</NcButton>
<ActivityGroup v-for="activities, date of groupedActivities" :key="date" :activities="activities" />
<!-- Only show if not showing the inital empty content for loading -->
<NcLoadingIcon
v-if="hasMoreActivites && allActivities.length > 0"
:name="t('activity', 'Loading more activities')"
:size="64"
class="activity-app__loading-indicator" />
<div
v-else-if="!hasMoreActivites && allActivities.length > 0"
class="activity-app__loading-indicator">
{{ t('activity', 'No more activities.') }}
<div class="activity-app__content">
<NcButton
v-if="newActivitiesAvailable"
class="activity-app__new-activities-indicator"
type="button"
@click="scrollToTop">
{{ t('activity', 'New activities') }}
</NcButton>
<ActivityGroup v-for="activities, date of groupedActivities" :key="date" :activities="activities" />
<!-- Only show if not showing the inital empty content for loading -->
<NcLoadingIcon
v-if="hasMoreActivites && allActivities.length > 0"
:name="t('activity', 'Loading more activities')"
:size="64"
class="activity-app__loading-indicator" />
<div
v-else-if="!hasMoreActivites && allActivities.length > 0"
class="activity-app__end-of-feed">
{{ t('activity', 'No more activities.') }}
</div>
</div>
</div>
</NcAppContent>
Expand Down Expand Up @@ -349,9 +352,14 @@ watch(props, () => {

<style scoped lang="scss">
.activity-app {
// Max width of the readable column, also read by the heading indent in ActivityGroup.vue
--activity-feed-max-width: 924px;
Comment thread
jancborchardt marked this conversation as resolved.
display: flex;
flex-direction: column;
overflow: hidden;
// Query container so the date headings track the content-area width (shrunk by the
// open app navigation), not the raw viewport
container: activity-feed / inline-size;

&__empty-content {
height: 100%;
Expand All @@ -364,16 +372,29 @@ watch(props, () => {
text-align: center;
}

&__end-of-feed {
color: var(--color-text-maxcontrast);
text-align: center;
// Large bottom margin so the message isn't stuck to the viewport bottom
margin-block: 30px 30vh;
}

&__container {
// Scroll container, so the scrollbar sits at the edge of app-content
// rather than beside the narrower content column
height: 100%;
overflow-y: scroll;
}

&__content {
// Clamp the readable column and centre it within the full-width scroller
display: flex;
flex-direction: column;

height: 100%;
width: min(100%, 924px);
max-width: 924px;
width: min(100%, var(--activity-feed-max-width));
max-width: var(--activity-feed-max-width);
margin: 0 auto;
padding-inline: 12px;
overflow-y: scroll;
}

&__new-activities-indicator {
Expand All @@ -394,14 +415,5 @@ watch(props, () => {
background-color: var(--color-primary-element-hover);
}
}

&__heading {
font-weight: bold;
font-size: 20px;
line-height: 44px; // to align height with the app navigation toggle
// Align with app navigation toggle
margin-top: 1px;
margin-inline: calc(2 * var(--app-navigation-padding, 8px) + 44px) var(--app-navigation-padding, 8px);
}
}
</style>
Loading