+
+
+
+ ()
const newActivitiesAvailable = ref(false)
/**
- * Polling interval in milliseconds
+ * Set of activity IDs that arrived via polling (not the initial load).
+ * Each row checks against this set and renders a one-shot highlight
+ * pulse animation to draw the eye to fresh content. Entries auto-expire
+ * after FRESH_TTL_MS so the pulse only fires once per arrival.
*/
-const POLL_INTERVAL = 30000
+const freshIds = ref>(new Set())
+const FRESH_TTL_MS = 1600
+
+function markFresh(ids: number[]): void {
+ if (ids.length === 0) return
+ const next = new Set(freshIds.value)
+ for (const id of ids) next.add(id)
+ freshIds.value = next
+ // Schedule cleanup so the set doesn't grow unbounded and the
+ // animation only plays once per row.
+ setTimeout(() => {
+ const after = new Set(freshIds.value)
+ for (const id of ids) after.delete(id)
+ freshIds.value = after
+ }, FRESH_TTL_MS)
+}
+
+/**
+ * Adaptive polling: the next-poll delay shortens when recent polls have
+ * brought in fresh content (the user is in an active session, near other
+ * collaborators) and lengthens when they've been idle (long stretches
+ * with no matches mean we're paying server cost for nothing).
+ *
+ * Bounds are deliberately wide so the feed feels almost-live during
+ * collaboration sessions but doesn't hammer the server when quiet.
+ */
+const POLL_MIN_MS = 8000
+const POLL_MAX_MS = 60000
+const POLL_INITIAL_MS = 20000
+const pollDelayMs = ref(POLL_INITIAL_MS)
/**
* Polling timer reference (setTimeout-based for self-scheduling)
@@ -163,11 +366,335 @@ useInfiniteScroll(container, async () => {
})
/**
- * Activities grouped by date
+ * Client-side filter state.
+ *
+ * Free-text search is applied server-side via a `q=` parameter on the
+ * OCS endpoint (debounced 300ms to avoid hammering the database while
+ * the user is typing) — see watch() further down which resets and
+ * refetches when activeSearch changes. Person and date filters stay
+ * client-side over whatever the infinite scroll has paginated in.
+ */
+const filterText = ref('')
+const filterPerson = ref('')
+const filterPath = ref('') // substring match on objectName / file path
+const filterFrom = ref('') // YYYY-MM-DD, inclusive
+const filterTo = ref('') // YYYY-MM-DD, inclusive
+
+/**
+ * Mobile-only: whether the filters panel is open. On wide screens the
+ * filters always show inline; the toggle button is hidden via CSS so this
+ * value is irrelevant there.
+ */
+const filtersOpenMobile = ref(false)
+
+// Debounced version of the text filter — driven into the OCS request
+// 300ms after the user stops typing.
+const activeSearch = useDebounce(filterText, 300)
+
+const anyFilterActive = computed(() =>
+ filterText.value.trim() !== ''
+ || filterPerson.value.trim() !== ''
+ || filterPath.value.trim() !== ''
+ || filterFrom.value !== ''
+ || filterTo.value !== '',
+)
+
+function resetFilters() {
+ filterText.value = ''
+ filterPerson.value = ''
+ filterPath.value = ''
+ filterFrom.value = ''
+ filterTo.value = ''
+}
+
+/**
+ * "Refreshing" flag used to drive the spin animation on the refresh
+ * button. Held true for at least 600ms even on a fast poll so the spin
+ * is always visible — instant completion would feel like the click did
+ * nothing.
+ */
+const refreshing = ref(false)
+
+/**
+ * Friendly tooltip that doubles as a status read-out: tells the user
+ * the app is live-polling and how often. Updates whenever the
+ * adaptive delay changes.
+ */
+const liveTooltip = computed(() => {
+ const seconds = Math.round(pollDelayMs.value / 1000)
+ return t('activity', 'Live — checking every ~{n}s. Click to refresh now.', { n: seconds })
+})
+
+async function manualRefresh() {
+ if (refreshing.value) return
+ refreshing.value = true
+ const minSpin = new Promise((resolve) => setTimeout(resolve, 600))
+ try {
+ await Promise.all([pollNewActivities(), minSpin])
+ } finally {
+ refreshing.value = false
+ }
+}
+
+/**
+ * Activities matching the current filters, before grouping. Text search
+ * already happened server-side, so this only narrows by person + date,
+ * and drops any activity whose type the user has muted via the row
+ * context menu.
+ */
+const { muted: mutedTypes } = useMutedTypes()
+const { muted: mutedUsers } = useMutedUsers()
+const { pinned: pinnedIds } = usePinnedActivities()
+
+/**
+ * Best-effort path string for an activity, used by the path filter.
+ * Tries the rich subject objects first (these carry a `path` for "file"
+ * rich objects), then falls back to objectName / link. Returns lower-
+ * cased so the caller can do a case-insensitive substring compare.
+ */
+function activityPath(a: ActivityModel): string {
+ const objs = a.subjectRichObjects
+ for (const key of Object.keys(objs)) {
+ const obj = objs[key]
+ if (obj && obj.type === 'file') {
+ const p = (obj as { path?: unknown }).path
+ if (typeof p === 'string' && p !== '') return p.toLowerCase()
+ if (typeof obj.name === 'string' && obj.name !== '') return obj.name.toLowerCase()
+ }
+ }
+ if (a.objectName) return a.objectName.toLowerCase()
+ if (a.link) return a.link.toLowerCase()
+ return ''
+}
+
+const filteredActivities = computed(() => {
+ const personActive = filterPerson.value.trim() !== ''
+ const pathActive = filterPath.value.trim() !== ''
+ const dateActive = filterFrom.value !== '' || filterTo.value !== ''
+ const muteTypeActive = mutedTypes.value.length > 0
+ const muteUserActive = mutedUsers.value.length > 0
+
+ if (!personActive && !pathActive && !dateActive && !muteTypeActive && !muteUserActive) return allActivities.value
+
+ const person = filterPerson.value.trim().toLowerCase()
+ const path = filterPath.value.trim().toLowerCase()
+ const fromTs = filterFrom.value ? moment(filterFrom.value).startOf('day').valueOf() : undefined
+ const toTs = filterTo.value ? moment(filterTo.value).endOf('day').valueOf() : undefined
+ const muteTypeSet = new Set(mutedTypes.value)
+ const muteUserSet = new Set(mutedUsers.value)
+
+ return allActivities.value.filter((a) => {
+ if (muteTypeSet.has(a.type)) return false
+ if (muteUserSet.has(a.user)) return false
+ if (fromTs !== undefined && a.timestamp < fromTs) return false
+ if (toTs !== undefined && a.timestamp > toTs) return false
+ if (person !== '' && !a.user.toLowerCase().includes(person)) return false
+ if (path !== '' && !activityPath(a).includes(path)) return false
+ return true
+ })
+})
+
+const filterMatchCount = computed(() =>
+ t('activity', '{n} matching', { n: filteredActivities.value.length }),
+)
+
+/**
+ * Saved-views composable — list lives in localStorage so users can keep
+ * favourite filter combinations without backend changes.
+ */
+const router = useRouter()
+const {
+ views: savedViews,
+ add: addSavedView,
+ remove: removeSavedView,
+ setAlerts: setSavedViewAlerts,
+ setLastSeenId: setSavedViewLastSeenId,
+} = useSavedViews()
+
+function suggestSavedViewName(): string {
+ const bits: string[] = []
+ if (filterText.value) bits.push('"' + filterText.value + '"')
+ if (filterPerson.value) bits.push('@' + filterPerson.value)
+ if (filterPath.value) bits.push('📁' + filterPath.value)
+ if (filterFrom.value || filterTo.value) {
+ bits.push((filterFrom.value || '…') + '–' + (filterTo.value || '…'))
+ }
+ bits.push(props.filter)
+ return bits.join(' ')
+}
+
+function describeSavedView(view: SavedView): string {
+ const parts: string[] = []
+ parts.push(t('activity', 'Filter') + ': ' + view.filter)
+ if (view.search) parts.push(t('activity', 'Search') + ': ' + view.search)
+ if (view.person) parts.push(t('activity', 'Person') + ': ' + view.person)
+ if (view.path) parts.push(t('activity', 'Path') + ': ' + view.path)
+ if (view.from) parts.push(t('activity', 'From') + ': ' + view.from)
+ if (view.to) parts.push(t('activity', 'To') + ': ' + view.to)
+ return parts.join(' • ')
+}
+
+function saveCurrentView(): void {
+ const suggested = suggestSavedViewName()
+ // eslint-disable-next-line no-alert
+ const name = window.prompt(t('activity', 'Name this view'), suggested)
+ if (name === null) return
+ const trimmed = name.trim()
+ if (trimmed === '') return
+ addSavedView({
+ name: trimmed,
+ filter: props.filter,
+ search: filterText.value,
+ person: filterPerson.value,
+ path: filterPath.value,
+ from: filterFrom.value,
+ to: filterTo.value,
+ alerts: false,
+ lastSeenId: 0,
+ })
+}
+
+function applySavedView(view: SavedView): void {
+ filterText.value = view.search
+ filterPerson.value = view.person
+ filterPath.value = view.path ?? ''
+ filterFrom.value = view.from
+ filterTo.value = view.to
+ if (view.filter && view.filter !== props.filter) {
+ // vue-router will re-trigger the props watcher and reload activities
+ router.push({ params: { filter: view.filter } })
+ }
+}
+
+/**
+ * Toggle desktop notifications for a saved view. First time the user
+ * enables alerts on any view we ask the browser for Notification
+ * permission; after that the answer is cached by the browser and we
+ * just respect it.
+ */
+async function toggleSavedViewAlerts(view: SavedView): Promise {
+ const turningOn = !view.alerts
+ if (turningOn && typeof window.Notification !== 'undefined') {
+ if (window.Notification.permission === 'default') {
+ try {
+ await window.Notification.requestPermission()
+ } catch (e) {
+ logger.debug('Notification permission request failed', e as Error)
+ }
+ }
+ if (window.Notification.permission === 'denied') {
+ showError(t('activity', 'Browser notifications are blocked. Enable them in your browser settings.'))
+ return
+ }
+ }
+ setSavedViewAlerts(view.id, turningOn)
+ // Seed lastSeenId with the newest currently-loaded id so the user
+ // doesn't get spammed with backfill notifications on first enable.
+ if (turningOn && newestActivityId.value !== undefined) {
+ setSavedViewLastSeenId(view.id, newestActivityId.value)
+ }
+}
+
+/**
+ * True when an activity matches a saved view's filters. Mirrors the
+ * client-side parts of filteredActivities — server-side text search is
+ * skipped here because polling doesn't carry a search query through.
+ */
+function activityMatchesSavedView(a: ActivityModel, view: SavedView): boolean {
+ const person = view.person.trim().toLowerCase()
+ if (person !== '' && !a.user.toLowerCase().includes(person)) return false
+ const path = (view.path ?? '').trim().toLowerCase()
+ if (path !== '' && !activityPath(a).includes(path)) return false
+ if (view.from !== '') {
+ const fromTs = moment(view.from).startOf('day').valueOf()
+ if (a.timestamp < fromTs) return false
+ }
+ if (view.to !== '') {
+ const toTs = moment(view.to).endOf('day').valueOf()
+ if (a.timestamp > toTs) return false
+ }
+ const search = view.search.trim().toLowerCase()
+ if (search !== '') {
+ const haystack = (a.subject + ' ' + a.message).toLowerCase()
+ if (!haystack.includes(search)) return false
+ }
+ // view.filter is the OCP filter id ('all' | 'self' | 'by' | …) and
+ // is enforced server-side; we don't try to recreate that bucketing
+ // here. Alerts on a saved view are best-effort within the
+ // currently-active OCP filter.
+ return true
+}
+
+/**
+ * For each alert-subscribed saved view, count newly-arrived activities
+ * (id > view.lastSeenId) that match the view's filters and fire one
+ * notification per view. Updates lastSeenId so we don't re-notify on
+ * subsequent polls.
+ */
+function notifyForSavedViews(newlyArrived: ActivityModel[]): void {
+ if (newlyArrived.length === 0) return
+ if (typeof window.Notification === 'undefined') return
+ if (window.Notification.permission !== 'granted') return
+
+ for (const view of savedViews.value) {
+ if (!view.alerts) continue
+ const fresh = newlyArrived.filter((a) => a.id > view.lastSeenId && activityMatchesSavedView(a, view))
+ if (fresh.length === 0) continue
+ const newestId = fresh.reduce((m, a) => Math.max(m, a.id), view.lastSeenId)
+ setSavedViewLastSeenId(view.id, newestId)
+ try {
+ const note = new window.Notification(
+ n('activity', '{n} new match in "{name}"', '{n} new matches in "{name}"', fresh.length, { name: view.name }),
+ {
+ body: fresh[0].subject || t('activity', 'Open Activity for details'),
+ tag: 'activity-saved-view-' + view.id,
+ silent: false,
+ },
+ )
+ note.onclick = () => {
+ window.focus()
+ applySavedView(view)
+ note.close()
+ }
+ } catch (e) {
+ logger.debug('Could not display saved-view notification', e as Error)
+ }
+ }
+}
+
+/**
+ * Pinned activities (subset of filteredActivities whose ids are in
+ * pinnedIds), preserving the order of pinnedIds (most recently pinned
+ * first). Rendered above the per-day groups so they're always visible
+ * regardless of how far the user has scrolled.
+ */
+const pinnedActivities = computed(() => {
+ if (pinnedIds.value.length === 0) return []
+ const pinSet = new Set(pinnedIds.value)
+ const byId = new Map()
+ for (const a of filteredActivities.value) {
+ if (pinSet.has(a.id)) byId.set(a.id, a)
+ }
+ const list: ActivityModel[] = []
+ for (const id of pinnedIds.value) {
+ const a = byId.get(id)
+ if (a) list.push(a)
+ }
+ return list
+})
+
+const pinnedSet = computed>(() => new Set(pinnedIds.value))
+
+/**
+ * Activities grouped by date (post-filter). Pinned activities are
+ * filtered OUT of the day groups since they render in their own
+ * always-visible section at the top — duplicating them would be noise.
*/
const groupedActivities = computed(() => {
const groups = {} as Record
- for (const activity of allActivities.value) {
+ const pins = pinnedSet.value
+ for (const activity of filteredActivities.value) {
+ if (pins.has(activity.id)) continue
const date = moment(activity.datetime).format('LL')
if (groups[date] === undefined) {
groups[date] = [activity]
@@ -182,6 +709,103 @@ const headingTitle = computed(() => {
return navigationList.find((navigationEl) => navigationEl.id === route.params.filter).name
})
+// Map filter ids to a Material Design icon component. IDs come from
+// server-side IFilter::getIdentifier() implementations across apps —
+// this list covers the bundled filters; unknown ids fall back to a
+// generic history icon so newly-registered filters still render
+// something reasonable.
+const filterIcons: Record = {
+ all: IconViewList,
+ self: IconAccount,
+ by: IconAccountGroup,
+ files: IconFolder,
+ files_favorites: IconStar,
+ files_sharing: IconShareVariant,
+ comments: IconCommentText,
+ calendar: IconCalendar,
+ calendar_todo: IconCheckboxMarkedCircle,
+ contacts: IconCardAccountDetails,
+}
+
+const headingIcon = computed(() => {
+ const id = String(route.params.filter ?? 'all')
+ return filterIcons[id] ?? IconHistory
+})
+
+/**
+ * Bucket an activity into the same families used by the visual type-accent
+ * styling. Kept in sync with GenericActivity.typeFamily so the summary header
+ * and the rows tell the same story.
+ */
+function typeFamily(type: string): 'created' | 'changed' | 'deleted' | 'share' | 'comment' | 'favorite' | 'other' {
+ 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 === 'comments') return 'comment'
+ if (type.startsWith('shared') || type.startsWith('share') || type.startsWith('unshare') || type === 'remote_share' || type.includes('_share')) return 'share'
+ return 'other'
+}
+
+/**
+ * One-line "Today: 12 changes, 3 shares, 1 deletion" summary built from the
+ * already-loaded activities. Returns an empty string when nothing has loaded
+ * yet or nothing happened today, so the line collapses cleanly.
+ */
+const todaySummary = computed(() => {
+ if (allActivities.value.length === 0) return ''
+
+ const today = moment().startOf('day')
+ const counts: Record, number> = {
+ created: 0, changed: 0, deleted: 0, share: 0, comment: 0, favorite: 0, other: 0,
+ }
+ let total = 0
+ for (const activity of allActivities.value) {
+ if (!moment(activity.datetime).isSame(today, 'day')) continue
+ counts[typeFamily(activity.type)]++
+ total++
+ }
+ if (total === 0) return ''
+
+ const parts: string[] = []
+ if (counts.changed) parts.push(n('activity', '%n change', '%n changes', counts.changed))
+ if (counts.created) parts.push(n('activity', '%n addition', '%n additions', counts.created))
+ if (counts.deleted) parts.push(n('activity', '%n deletion', '%n deletions', counts.deleted))
+ if (counts.share) parts.push(n('activity', '%n share', '%n shares', counts.share))
+ if (counts.comment) parts.push(n('activity', '%n comment', '%n comments', counts.comment))
+ if (counts.favorite) parts.push(n('activity', '%n favorite', '%n favorites', counts.favorite))
+ if (counts.other) parts.push(n('activity', '%n other event','%n other events',counts.other))
+
+ return t('activity', 'Today: {summary}', { summary: parts.join(', ') })
+})
+
+/**
+ * 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]
+})
+
/**
* Load activities for current filter or load more if already loaded
*/
@@ -194,8 +818,11 @@ async function loadActivities() {
const { signal } = requestController
try {
const since = lastActivityLoaded.value ?? '0'
+ const q = activeSearch.value.trim()
loading.value = true
- const response = await ncAxios.get(generateOcsUrl('apps/activity/api/v2/activity/{filter}?format=json&previews=true&since={since}', { filter: props.filter, since }), { signal })
+ const url = generateOcsUrl('apps/activity/api/v2/activity/{filter}?format=json&previews=true&since={since}', { filter: props.filter, since })
+ + (q !== '' ? '&q=' + encodeURIComponent(q) : '')
+ const response = await ncAxios.get(url, { signal })
if (signal.aborted) {
return
}
@@ -215,6 +842,10 @@ async function loadActivities() {
// Do it manually to ensure there are no activities to fetch anymore
await loadActivities()
}
+ // If the page was opened with a permalink, retry the scroll
+ // after each load — the target may not have appeared in the
+ // first batch and infinite scroll keeps pulling more.
+ maybeScrollToPermalink()
})
} catch (error) {
if (axios.isCancel(error)) {
@@ -243,15 +874,22 @@ async function loadActivities() {
*/
async function pollNewActivities() {
const { signal } = requestController
+ let gotResults = false
try {
const since = String(newestActivityId.value ?? 0)
- const response = await ncAxios.get(generateOcsUrl('apps/activity/api/v2/activity/{filter}?format=json&previews=true&since={since}&sort=asc', { filter: props.filter, since }), { signal })
+ const q = activeSearch.value.trim()
+ const url = generateOcsUrl('apps/activity/api/v2/activity/{filter}?format=json&previews=true&since={since}&sort=asc', { filter: props.filter, since })
+ + (q !== '' ? '&q=' + encodeURIComponent(q) : '')
+ const response = await ncAxios.get(url, { signal })
if (!signal.aborted && response.data.ocs.data.length > 0) {
+ gotResults = true
const newActivities: ActivityModel[] = response.data.ocs.data.map((raw: IRawActivity) => new ActivityModel(raw))
// Sort newest first for prepending
newActivities.sort((a: ActivityModel, b: ActivityModel) => b.id - a.id)
newestActivityId.value = newActivities[0]!.id
allActivities.value.unshift(...newActivities)
+ markFresh(newActivities.map((a) => a.id))
+ notifyForSavedViews(newActivities)
// Show the navigation button only when the user is not already at the top
// (browser scroll anchoring keeps their reading position stable on prepend)
@@ -267,9 +905,20 @@ async function pollNewActivities() {
}
}
+ // Adapt the next interval to recent traffic: halve it on a hit
+ // (down to POLL_MIN_MS), grow by 25% on a miss (up to POLL_MAX_MS).
+ // This keeps the feed feeling near-live during active sessions but
+ // pulls back on a quiet day so the server isn't asked uselessly
+ // every few seconds.
+ if (gotResults) {
+ pollDelayMs.value = Math.max(POLL_MIN_MS, Math.floor(pollDelayMs.value / 2))
+ } else {
+ pollDelayMs.value = Math.min(POLL_MAX_MS, Math.floor(pollDelayMs.value * 1.25))
+ }
+
// Self-schedule only if polling wasn't stopped while the request was in flight
if (pollTimer !== undefined) {
- pollTimer = setTimeout(pollNewActivities, POLL_INTERVAL)
+ pollTimer = setTimeout(pollNewActivities, pollDelayMs.value)
}
}
@@ -293,8 +942,11 @@ const onScroll = useDebounceFn(() => {
function startPolling() {
stopPolling()
// Use a sentinel value so the self-scheduling logic in pollNewActivities
- // knows polling is active even before the first tick fires
- pollTimer = setTimeout(pollNewActivities, POLL_INTERVAL)
+ // knows polling is active even before the first tick fires. Reset the
+ // adaptive delay back to the initial mid-range so a long-tab-idle
+ // session resumes responsively when the tab regains focus.
+ pollDelayMs.value = POLL_INITIAL_MS
+ pollTimer = setTimeout(pollNewActivities, pollDelayMs.value)
}
function stopPolling() {
@@ -307,8 +959,49 @@ function stopPolling() {
/**
* Load activities when mounted and start polling
*/
+/**
+ * Read `id=…` from the current hash query — used to deep-link to a
+ * specific activity (set by the row's "Copy link" button) and scroll
+ * to it once it has loaded.
+ */
+function permalinkTargetId(): number | null {
+ try {
+ const hash = window.location.hash || ''
+ const q = hash.split('?')[1]
+ if (!q) return null
+ const id = Number(new URLSearchParams(q).get('id'))
+ return Number.isFinite(id) && id > 0 ? id : null
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Once any new activities arrive, see if the page was opened with a
+ * permalink and the target row is now in the DOM; if so, scroll to it
+ * and disarm so we don't re-jump on subsequent loads.
+ */
+let permalinkArmed = true
+function maybeScrollToPermalink() {
+ if (!permalinkArmed) return
+ const id = permalinkTargetId()
+ if (id === null) {
+ permalinkArmed = false
+ return
+ }
+ nextTick(() => {
+ const row = document.querySelector(
+ '.activity-entry[data-activity-id="' + String(id) + '"]',
+ ) as HTMLElement | null
+ if (row) {
+ row.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ permalinkArmed = false
+ }
+ })
+}
+
onMounted(() => {
- loadActivities()
+ loadActivities().then(maybeScrollToPermalink)
startPolling()
})
@@ -326,9 +1019,11 @@ watch(visibility, (value) => {
})
/**
- * Reload activities when filter changed
+ * Reload activities when filter or (debounced) free-text search changes —
+ * both reset the paginated state and re-pull from the top of the stream
+ * so we never mix matches from before/after a query change.
*/
-watch(props, () => {
+function reloadActivities() {
requestController.abort()
requestController = new AbortController()
allActivities.value = []
@@ -337,7 +1032,10 @@ watch(props, () => {
newestActivityId.value = undefined
hasMoreActivites.value = true
loadActivities()
-})
+}
+
+watch(props, reloadActivities)
+watch(activeSearch, reloadActivities)
diff --git a/src/views/ActivityAppNavigation.vue b/src/views/ActivityAppNavigation.vue
old mode 100644
new mode 100755
index 6cf5881f4..d0949cbb3
--- a/src/views/ActivityAppNavigation.vue
+++ b/src/views/ActivityAppNavigation.vue
@@ -20,6 +20,14 @@
role="presentation">
+
+
+
+
+
@@ -64,6 +72,7 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcInputField from '@nextcloud/vue/components/NcInputField'
import IconContentCopy from 'vue-material-design-icons/ContentCopy.vue'
+import IconChartBar from 'vue-material-design-icons/ChartBar.vue'
import logger from '../utils/logger.ts'
// Types
@@ -149,4 +158,5 @@ async function copyRSSLink() {
width: 16px;
}
}
+
diff --git a/src/views/ActivityInsights.vue b/src/views/ActivityInsights.vue
new file mode 100755
index 000000000..444d69096
--- /dev/null
+++ b/src/views/ActivityInsights.vue
@@ -0,0 +1,681 @@
+
+
+
+
+
+
+
+ {{ t('activity', 'Insights') }}
+
+
+
+ {{ t('activity', 'A look at your recent activity, computed from the {n} most-recent events.', { n: sampleSize }) }}
+