@conduction/nextcloud-vue 2.76.0 → 2.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +18 -3
  2. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +49 -3
  4. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  5. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +42 -1
  6. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  7. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +228 -203
  8. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  9. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue.js +9 -0
  10. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue.js.map +1 -0
  11. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue2.js +50 -0
  12. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue2.js.map +1 -0
  13. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue3.js +13 -0
  14. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue3.js.map +1 -0
  15. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +41 -4
  16. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  17. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +90 -51
  18. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  19. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +16 -28
  20. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  21. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +12 -24
  22. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  23. package/dist/esm/components/CnFavouriteToggle/CnFavouriteToggle.vue2.js +7 -2
  24. package/dist/esm/components/CnFavouriteToggle/CnFavouriteToggle.vue2.js.map +1 -1
  25. package/dist/esm/components/CnFavouriteToggle/CnFavouriteToggle.vue3.js.map +1 -1
  26. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +114 -1
  27. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  28. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +115 -62
  29. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  30. package/dist/esm/components/CnFollowToggle/CnFollowToggle.vue2.js +89 -7
  31. package/dist/esm/components/CnFollowToggle/CnFollowToggle.vue2.js.map +1 -1
  32. package/dist/esm/components/CnFollowToggle/CnFollowToggle.vue3.js +62 -30
  33. package/dist/esm/components/CnFollowToggle/CnFollowToggle.vue3.js.map +1 -1
  34. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +98 -13
  35. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  36. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +13 -9
  37. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  38. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js +199 -7
  39. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js.map +1 -1
  40. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js +155 -119
  41. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js.map +1 -1
  42. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +6 -0
  43. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  44. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +4 -1
  45. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  46. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js +13 -0
  47. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js.map +1 -1
  48. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue3.js.map +1 -1
  49. package/dist/esm/composables/useListView.js +4 -0
  50. package/dist/esm/composables/useListView.js.map +1 -1
  51. package/dist/esm/index.js +2 -1
  52. package/dist/esm/index.js.map +1 -1
  53. package/dist/esm/l10n/en.json.js +25 -1
  54. package/dist/esm/l10n/en.json.js.map +1 -1
  55. package/dist/esm/l10n/nl.json.js +25 -1
  56. package/dist/esm/l10n/nl.json.js.map +1 -1
  57. package/dist/esm/nextcloud-vue.css +288 -74
  58. package/dist/esm/store/plugins/interactions.js +20 -10
  59. package/dist/esm/store/plugins/interactions.js.map +1 -1
  60. package/dist/esm/store/useObjectStore.js +17 -0
  61. package/dist/esm/store/useObjectStore.js.map +1 -1
  62. package/dist/esm/utils/builtInFormatters.js +15 -7
  63. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  64. package/dist/esm/utils/groupAutocomplete.js +63 -1
  65. package/dist/esm/utils/groupAutocomplete.js.map +1 -1
  66. package/dist/esm/utils/lensAvailability.js +90 -0
  67. package/dist/esm/utils/lensAvailability.js.map +1 -0
  68. package/dist/esm/utils/personalLenses.js +13 -6
  69. package/dist/esm/utils/personalLenses.js.map +1 -1
  70. package/dist/esm/utils/recordInteractions.js +31 -13
  71. package/dist/esm/utils/recordInteractions.js.map +1 -1
  72. package/dist/esm/utils/resolveFilterTokens.js +46 -3
  73. package/dist/esm/utils/resolveFilterTokens.js.map +1 -1
  74. package/dist/esm/utils/schema.js +1 -1
  75. package/dist/esm/utils/schema.js.map +1 -1
  76. package/dist/esm/utils/sentinelTokens.js +4 -4
  77. package/dist/esm/utils/sentinelTokens.js.map +1 -1
  78. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  79. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  80. package/dist/esm/utils/widgetVisibility.js +46 -1
  81. package/dist/esm/utils/widgetVisibility.js.map +1 -1
  82. package/dist/nextcloud-vue.cjs.js +3873 -2674
  83. package/dist/nextcloud-vue.cjs.js.map +2 -2
  84. package/dist/nextcloud-vue.css +288 -74
  85. package/l10n/en.json +25 -1
  86. package/l10n/nl.json +25 -1
  87. package/package.json +1 -1
  88. package/src/components/CnAppNav/CnAppNav.vue +44 -5
  89. package/src/components/CnCellRenderer/CnCellRenderer.vue +56 -1
  90. package/src/components/CnCellRenderer/CnGroupNameCell.vue +61 -0
  91. package/src/components/CnDataTable/CnDataTable.md +9 -0
  92. package/src/components/CnDataTable/CnDataTable.vue +80 -20
  93. package/src/components/CnDetailPage/CnDetailPage.vue +10 -27
  94. package/src/components/CnFavouriteToggle/CnFavouriteToggle.vue +7 -2
  95. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +204 -5
  96. package/src/components/CnFollowToggle/CnFollowToggle.vue +118 -13
  97. package/src/components/CnIndexPage/CnIndexPage.md +36 -0
  98. package/src/components/CnIndexPage/CnIndexPage.vue +106 -19
  99. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +226 -2
  100. package/src/components/CnObjectSidebar/CnFilesTab.vue +9 -0
  101. package/src/components/CnWidgetObjectTable/CnWidgetObjectTable.vue +13 -0
  102. package/src/composables/useListView.js +4 -0
  103. package/src/css/look-board-detail.css +40 -2
  104. package/src/css/table.css +82 -17
  105. package/src/index.js +2 -1
  106. package/src/schemas/app-manifest-v2.schema.json +11 -7
  107. package/src/store/plugins/interactions.js +20 -10
  108. package/src/store/useObjectStore.js +17 -0
  109. package/src/utils/builtInFormatters.js +15 -7
  110. package/src/utils/groupAutocomplete.js +75 -0
  111. package/src/utils/index.js +1 -1
  112. package/src/utils/lensAvailability.js +86 -0
  113. package/src/utils/personalLenses.js +13 -6
  114. package/src/utils/recordInteractions.js +31 -13
  115. package/src/utils/resolveFilterTokens.js +45 -2
  116. package/src/utils/schema.js +1 -1
  117. package/src/utils/sentinelTokens.js +4 -4
  118. package/src/utils/validateManifestV2.compiled.js +2 -2
  119. package/src/utils/widgetVisibility.js +45 -0
package/src/css/table.css CHANGED
@@ -284,9 +284,64 @@
284
284
  background-color: var(--color-background-hover);
285
285
  }
286
286
 
287
- .cn-table-sort-indicator {
288
- margin-inline-start: var(--default-grid-baseline);
289
- opacity: 0.6;
287
+ /* The sort control: a real button that looks like the header text, the label
288
+ followed by a 14px chevron 4px after it (the "Wettelijke termijn" header on
289
+ the DqWooVerzoeken board). Selector scoped under the table so it outranks
290
+ Nextcloud's own `button` rule, which would otherwise give it a border, a
291
+ fill, a 34px min-height and a margin. */
292
+ .cn-data-table th .cn-table-header__sort {
293
+ display: inline-flex;
294
+ align-items: center;
295
+ gap: 4px;
296
+ max-width: 100%;
297
+ min-width: 0;
298
+ min-height: 0;
299
+ margin: 0;
300
+ padding: 0;
301
+ border: 0;
302
+ border-radius: var(--border-radius, 4px);
303
+ background: transparent;
304
+ color: inherit;
305
+ font: inherit;
306
+ font-weight: inherit;
307
+ text-align: start;
308
+ vertical-align: middle;
309
+ cursor: pointer;
310
+ }
311
+
312
+ .cn-data-table th .cn-table-header__sort:hover,
313
+ .cn-data-table th .cn-table-header__sort:focus {
314
+ background: transparent;
315
+ }
316
+
317
+ .cn-data-table th .cn-table-header__sort:focus-visible {
318
+ outline: 2px solid var(--color-main-text);
319
+ outline-offset: 2px;
320
+ }
321
+
322
+ .cn-data-table th .cn-table-header__sort > :first-child {
323
+ min-width: 0;
324
+ overflow: hidden;
325
+ text-overflow: ellipsis;
326
+ }
327
+
328
+ /* The chevron. Unsorted columns show a pale down chevron, so every sortable
329
+ column says it sorts; the sorted column shows the direction at full text
330
+ colour. The direction is also in the header's `aria-sort`. */
331
+ .cn-table-header__chevron {
332
+ display: inline-flex;
333
+ flex: none;
334
+ color: var(--color-main-text);
335
+ }
336
+
337
+ .cn-table-header__chevron--idle {
338
+ color: var(--color-text-maxcontrast);
339
+ opacity: 0.5;
340
+ }
341
+
342
+ .cn-data-table th.cn-table-header--sortable:hover .cn-table-header__chevron--idle,
343
+ .cn-data-table th .cn-table-header__sort:focus-visible .cn-table-header__chevron--idle {
344
+ opacity: 1;
290
345
  }
291
346
 
292
347
  /* A column carrying a `description` gets a dotted underline — the long-standing
@@ -302,15 +357,12 @@
302
357
  cursor: help;
303
358
  }
304
359
 
305
- .cn-data-table th.cn-table-header--sortable:focus-visible {
306
- outline: 2px solid var(--color-main-text);
307
- outline-offset: -2px;
308
- }
309
-
310
- /* Header filter button (CnDataTable `filterable`). 24px hit area with the
311
- 16px icon; the filled icon plus the primary colour marks an active filter,
312
- so the state never rests on colour alone. */
313
- .cn-table-header__filter {
360
+ /* Header filter button (CnDataTable `filterable`): a 14px funnel directly
361
+ after the sort chevron, in a 24px hit area. Muted with no filter set; with
362
+ a filter set it takes the dark primary and a dot, so the state never rests
363
+ on colour alone. */
364
+ .cn-data-table th .cn-table-header__filter {
365
+ position: relative;
314
366
  display: inline-flex;
315
367
  align-items: center;
316
368
  justify-content: center;
@@ -318,7 +370,7 @@
318
370
  min-width: 24px;
319
371
  height: 24px;
320
372
  min-height: 24px;
321
- margin: 0 0 0 4px;
373
+ margin: 0 0 0 2px;
322
374
  padding: 0;
323
375
  border: none;
324
376
  border-radius: var(--border-radius, 4px);
@@ -328,18 +380,31 @@
328
380
  cursor: pointer;
329
381
  }
330
382
 
331
- .cn-table-header__filter:hover {
383
+ .cn-data-table th .cn-table-header__filter:hover {
332
384
  background-color: var(--color-background-hover);
333
385
  color: var(--color-main-text);
334
386
  }
335
387
 
336
- .cn-table-header__filter:focus-visible {
388
+ .cn-data-table th .cn-table-header__filter:focus-visible {
337
389
  outline: 2px solid var(--color-main-text);
338
390
  outline-offset: 0;
339
391
  }
340
392
 
341
- .cn-table-header__filter--active {
342
- color: var(--color-primary-element);
393
+ .cn-data-table th .cn-table-header__filter--active,
394
+ .cn-data-table th .cn-table-header__filter--active:hover {
395
+ color: var(--color-primary-element-light-text, var(--color-primary-element));
396
+ }
397
+
398
+ /* The active-filter dot, on the funnel's top right. */
399
+ .cn-table-header__filter-dot {
400
+ position: absolute;
401
+ inset-block-start: 3px;
402
+ inset-inline-end: 3px;
403
+ width: 6px;
404
+ height: 6px;
405
+ border-radius: 50%;
406
+ background-color: var(--color-primary-element);
407
+ pointer-events: none;
343
408
  }
344
409
 
345
410
  /* Active header filters above the table, one removable chip each. */
package/src/index.js CHANGED
@@ -543,6 +543,7 @@ export { cnFetch, cnFetchJson, CnHttpError, configureCnFetch } from './utils/cnF
543
543
  // need the same answer without rendering a padlock.
544
544
  export { isLockedByCurrentUser, isObjectLocked, lockHolder, readLockPayload, resolveObjectLock } from './utils/objectLock.js'
545
545
  export { dispatchObjectsChanged } from './utils/objectSignals.js'
546
+ export { lensUnavailableText, readLensReports } from './utils/lensAvailability.js'
546
547
  export { columnsFromSchema, fieldsFromSchema, filtersFromSchema, findRepeatingWrite, formatValue, journeyItemTargets, validateValue } from './utils/index.js'
547
548
  export { DYNAMIC_KEY_PREFIX, EXTENDS_FORM_KEY, PREFILL_KEY, splitDynamicFormData, usesArrayValues, valueArrayFor, valueRecordsFor } from './utils/index.js'
548
549
  // The OpenRegister schema API contract — shared so Buildiq and OpenRegister cannot
@@ -569,7 +570,7 @@ export {
569
570
  SENTINEL_RESOLVERS,
570
571
  warnIfDeprecated,
571
572
  } from './utils/resolveManifestTokens.js'
572
- export { filterWidgetsByVisibility, getCurrentUserGroups, getCurrentUserId, isWidgetVisible, resetVisibilityCache } from './utils/index.js'
573
+ export { filterWidgetsByVisibility, getCurrentUserGroups, getCurrentUserId, isWidgetVisible, peekCurrentUserGroups, resetVisibilityCache } from './utils/index.js'
573
574
  export { safeHref, safeImageSrc, safeSvgPath } from './utils/index.js'
574
575
  export { followLinkClick, isModifiedClick, isNewTabHandled, isRowMiddleClick, openRowTarget, resolveHref } from './utils/index.js'
575
576
  export { resolveImageUrl } from './utils/index.js'
@@ -3,7 +3,7 @@
3
3
  "$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json",
4
4
  "title": "Conduction App Manifest v2",
5
5
  "description": "v2 schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Introduces a uniform widgets[] array on every page type with a per-slot grid coordinate system, a typed actions[] discriminator, and the required $schema field for version detection. v1 manifests continue to validate against app-manifest.schema.json (unchanged).",
6
- "version": "2.73.0",
6
+ "version": "2.75.0",
7
7
  "type": "object",
8
8
  "required": ["$schema", "version"],
9
9
  "allOf": [
@@ -760,8 +760,8 @@
760
760
  },
761
761
  "sentinelFilterToken": {
762
762
  "type": "string",
763
- "pattern": "^@(?:me(?:\\.(?:displayName|email))?|now|today|monthStart|quarterStart|yearStart)$|^@today[+-][0-9]+d$",
764
- "description": "Filter-value context: relative fetch-time tokens resolved by resolveFilterTokens (@me, @me.displayName, @me.email, @now, @today, @today±Nd, @monthStart, @quarterStart, @yearStart). Mirrors SENTINEL_TOKEN_PATTERNS.filter in src/utils/sentinelTokens.js (byte-equality enforced by a unit test)."
763
+ "pattern": "^@(?:me(?:\\.(?:displayName|email))?|myGroups|now|today|monthStart|quarterStart|yearStart)$|^@today[+-][0-9]+d$",
764
+ "description": "Filter-value context: relative fetch-time tokens resolved by resolveFilterTokens (@me, @me.displayName, @me.email, @myGroups (the current user's group ids, an IN list), @now, @today, @today±Nd, @monthStart, @quarterStart, @yearStart). Mirrors SENTINEL_TOKEN_PATTERNS.filter in src/utils/sentinelTokens.js (byte-equality enforced by a unit test)."
765
765
  },
766
766
  "sentinelConfigToken": {
767
767
  "type": "string",
@@ -3304,19 +3304,23 @@
3304
3304
  "properties": {
3305
3305
  "favourite": {
3306
3306
  "type": "boolean",
3307
- "description": "Detail pages: the star beside the title. Omitted means automatic: it shows when the object carries @self.favourite. false removes it."
3307
+ "description": "Deprecated and ignored: there is no star any more, a favourite is a follow with notifications off. Kept so existing manifests stay valid; use follow."
3308
3308
  },
3309
3309
  "follow": {
3310
3310
  "type": "boolean",
3311
- "description": "Detail pages: the Follow toggle beside the title, with the follower count for a user who may update the record. Omitted means automatic: it shows when the object carries @self.watching. false removes it, and the object read then no longer asks for @self.can."
3311
+ "description": "Detail pages: the one Follow control beside the title, with a notifications on/off switch while the user follows and the follower count for a user who may update the record. Omitted means automatic: it shows when the object carries @self.watching. false removes it, and the object read then no longer asks for @self.can."
3312
+ },
3313
+ "showFollowColumn": {
3314
+ "type": "boolean",
3315
+ "description": "Index pages: add a first column with a follow toggle per row, bound to each row's @self.watching. Clicking it does not open the row. Default false."
3312
3316
  },
3313
3317
  "showFavouriteColumn": {
3314
3318
  "type": "boolean",
3315
- "description": "Index pages: add a first column with a star per row, bound to each row's @self.favourite. Clicking it does not open the row. Default false."
3319
+ "description": "Deprecated alias of showFollowColumn: the star column became the follow column. Default false."
3316
3320
  },
3317
3321
  "personalLenses": {
3318
3322
  "type": "array",
3319
- "description": "Index pages: personal lenses added to the quick filters, in order. favourite lists the user's favourites, recent the records the user opened last (it owns the order, so column sorting is off), watching the records the user follows, unread the records changed since the user last looked. They combine with every other filter. Default none.",
3323
+ "description": "Index pages: personal lenses added to the quick filters, in order. watching lists the records the user follows (labelled Following), recent the records the user opened last (it owns the order, so column sorting is off), unread the records changed since the user last looked. They combine with every other filter. favourite is a deprecated alias of watching; asking for both gives one tab. Default none.",
3320
3324
  "items": { "enum": ["favourite", "recent", "watching", "unread"] },
3321
3325
  "uniqueItems": true
3322
3326
  },
@@ -3,7 +3,8 @@ import { listWatchers, setFavourite, setReadState, setWatcher, setWatching } fro
3
3
  /**
4
4
  * Interactions plugin for the object store.
5
5
  *
6
- * Per-user interaction calls on a record: favourite, follow (watch) and the
6
+ * Per-user interaction calls on a record: follow (watch, with its
7
+ * notifications switch), the deprecated favourite (a quiet follow), and the
7
8
  * followers list. Each write puts the server's answer into the stored object's
8
9
  * `@self`, so a list showing the same object agrees with the toggle.
9
10
  *
@@ -55,7 +56,10 @@ export function interactionsPlugin() {
55
56
  },
56
57
 
57
58
  /**
58
- * Star a record.
59
+ * Star a record: a quiet follow on the server.
60
+ *
61
+ * @deprecated A favourite is a follow with notifications off since
62
+ * OpenRegister's `merge-follow-and-favourites`; use `watch(type, id, { notify: false })`.
59
63
  *
60
64
  * @param {string} type The registered object type slug
61
65
  * @param {string} objectId The object ID
@@ -65,13 +69,15 @@ export function interactionsPlugin() {
65
69
  const { register, schema } = this._interactionTarget(type)
66
70
  const result = await setFavourite(register, schema, objectId, true)
67
71
  if (result.ok) {
68
- this._patchSelf(type, objectId, { favourite: true })
72
+ this._patchSelf(type, objectId, { favourite: true, watching: true })
69
73
  }
70
74
  return result
71
75
  },
72
76
 
73
77
  /**
74
- * Unstar a record.
78
+ * Unstar a record: unfollow it.
79
+ *
80
+ * @deprecated Use `unwatch`.
75
81
  *
76
82
  * @param {string} type The registered object type slug
77
83
  * @param {string} objectId The object ID
@@ -81,7 +87,7 @@ export function interactionsPlugin() {
81
87
  const { register, schema } = this._interactionTarget(type)
82
88
  const result = await setFavourite(register, schema, objectId, false)
83
89
  if (result.ok) {
84
- this._patchSelf(type, objectId, { favourite: false })
90
+ this._patchSelf(type, objectId, { favourite: false, watching: false, watchNotify: undefined })
85
91
  }
86
92
  return result
87
93
  },
@@ -119,17 +125,21 @@ export function interactionsPlugin() {
119
125
  },
120
126
 
121
127
  /**
122
- * Follow a record.
128
+ * Follow a record, optionally setting its notifications switch.
123
129
  *
124
130
  * @param {string} type The registered object type slug
125
131
  * @param {string} objectId The object ID
132
+ * @param {object} [options] Options
133
+ * @param {boolean} [options.notify] The notifications switch; left out, a new follow notifies
126
134
  * @return {Promise<object>} The outcome
135
+ * @spec openspec/changes/one-follow-control/specs/record-follow/spec.md#requirement-one-follow-control-with-a-notifications-switch
127
136
  */
128
- async watch(type, objectId) {
137
+ async watch(type, objectId, options = {}) {
129
138
  const { register, schema } = this._interactionTarget(type)
130
- const result = await setWatching(register, schema, objectId, true)
139
+ const result = await setWatching(register, schema, objectId, true, options)
131
140
  if (result.ok) {
132
- this._patchSelf(type, objectId, { watching: true })
141
+ const notify = result.data && typeof result.data.notify === 'boolean' ? result.data.notify : true
142
+ this._patchSelf(type, objectId, { watching: true, favourite: true, watchNotify: notify })
133
143
  }
134
144
  return result
135
145
  },
@@ -145,7 +155,7 @@ export function interactionsPlugin() {
145
155
  const { register, schema } = this._interactionTarget(type)
146
156
  const result = await setWatching(register, schema, objectId, false)
147
157
  if (result.ok) {
148
- this._patchSelf(type, objectId, { watching: false })
158
+ this._patchSelf(type, objectId, { watching: false, favourite: false, watchNotify: undefined })
149
159
  }
150
160
  return result
151
161
  },
@@ -6,6 +6,7 @@ import { genericError, networkError, parseResponseError } from '../utils/errors.
6
6
  import { normalizeFacets } from '../utils/facets.js'
7
7
  import { buildHeaders, buildQueryString, capitalize, prefixUrl } from '../utils/headers.js'
8
8
  import { extractId } from '../utils/id.js'
9
+ import { readLensReports } from '../utils/lensAvailability.js'
9
10
  import { dispatchObjectCreated } from '../utils/walkthroughSignals.js'
10
11
  import { mergePluginActions, mergePluginGetters, mergePluginState } from './pluginMerge.js'
11
12
  import { liveUpdatesPlugin } from './plugins/liveUpdates.js'
@@ -86,6 +87,16 @@ function baseState(baseUrl = DEFAULT_BASE_URL) {
86
87
  * @type {{string: object}}
87
88
  */
88
89
  facets: {},
90
+ /**
91
+ * Personal-lens reports of the latest collection response per type:
92
+ * the body's `@self.lenses` (`{ recent: { available, reason } }`),
93
+ * `{}` when it carried none. Written with the rows, so it always
94
+ * describes the page on screen.
95
+ *
96
+ * @type {{string: object}}
97
+ * @spec openspec/changes/lens-says-why-it-is-empty/specs/personal-lens-availability/spec.md#requirement-a-list-response-carries-the-report-of-the-lenses-it-was-asked-for
98
+ */
99
+ lenses: {},
89
100
  /** @type {{baseUrl: string, organisationUuidGetter: (() => string|null)|null, languageGetter: (() => string|null)|null, targetLanguageGetter: (() => string|null)|null}} */
90
101
  _options: {
91
102
  baseUrl: prefixedBaseUrl,
@@ -195,6 +206,7 @@ const baseGetters = {
195
206
  * @return {(type: string) => object}
196
207
  */
197
208
  getFacets: (state) => (type) => state.facets[type] || {},
209
+ getLenses: (state) => (type) => state.lenses[type] || {},
198
210
  }
199
211
 
200
212
  // ── Base actions ────────────────────────────────────────────────────────
@@ -265,6 +277,7 @@ const baseActions = {
265
277
  this.schemas = { ...this.schemas, [slug]: null }
266
278
  this.registers = { ...this.registers, [slug]: null }
267
279
  this.facets = { ...this.facets, [slug]: {} }
280
+ this.lenses = { ...this.lenses, [slug]: {} }
268
281
  },
269
282
 
270
283
  /**
@@ -308,6 +321,7 @@ const baseActions = {
308
321
  this.schemas = omit(this.schemas, slug)
309
322
  this.registers = omit(this.registers, slug)
310
323
  this.facets = omit(this.facets, slug)
324
+ this.lenses = omit(this.lenses, slug)
311
325
  },
312
326
 
313
327
  /**
@@ -441,6 +455,7 @@ const baseActions = {
441
455
  this.collections = {}
442
456
  this.objects = {}
443
457
  this.facets = {}
458
+ this.lenses = {}
444
459
  this.pagination = {}
445
460
  // Errors + loading flags are per-fetch and don't need a reset.
446
461
  },
@@ -737,6 +752,8 @@ const baseActions = {
737
752
  const results = data.results || data
738
753
 
739
754
  this.collections = { ...this.collections, [type]: results }
755
+ // Why a personal lens came back empty, if it says (openregister#4514).
756
+ this.lenses = { ...this.lenses, [type]: readLensReports(data) }
740
757
  this.pagination = {
741
758
  ...this.pagination,
742
759
  [type]: {
@@ -164,13 +164,15 @@ export function formatDaysUntil(value) {
164
164
 
165
165
  /**
166
166
  * Elapsed-time formatter (`daysSince` registry key), i18n'd through the
167
- * library's own translation slug (`nextcloud-vue`): a past date renders
168
- * "N days ago", today renders "Today", and a (nonsensical but harmless)
169
- * future date renders "In N days". Day counts are plural-aware via
170
- * `translatePlural`.
167
+ * library's own translation slug (`nextcloud-vue`) in the Nextcloud user
168
+ * language. Whole calendar days, read as a running phrase so it sits after a
169
+ * title or in a meta line: today renders "today", one day back renders
170
+ * "yesterday", further back renders "N days ago". A (nonsensical but
171
+ * harmless) future date renders "tomorrow" or "in N days". Day counts are
172
+ * plural-aware via `translatePlural`.
171
173
  *
172
174
  * Null-safe per the built-in-formatter contract: `''` for null/empty,
173
- * `String(value)` for unparseable input — never throws.
175
+ * `String(value)` for unparseable input, never throws.
174
176
  *
175
177
  * @param {unknown} value A `Date`, parseable date string, or timestamp.
176
178
  * @return {string} The relative-day phrasing (or ''/original on bad input).
@@ -185,13 +187,19 @@ export function formatDaysSince(value) {
185
187
  }
186
188
  const days = dayDiffFromToday(d)
187
189
  if (days === 0) {
188
- return t('nextcloud-vue', 'Today')
190
+ return t('nextcloud-vue', 'today')
191
+ }
192
+ if (days === -1) {
193
+ return t('nextcloud-vue', 'yesterday')
194
+ }
195
+ if (days === 1) {
196
+ return t('nextcloud-vue', 'tomorrow')
189
197
  }
190
198
  if (days < 0) {
191
199
  const ago = Math.abs(days)
192
200
  return n('nextcloud-vue', '{count} day ago', '{count} days ago', ago, { count: ago })
193
201
  }
194
- return n('nextcloud-vue', 'In {count} day', 'In {count} days', days, { count: days })
202
+ return n('nextcloud-vue', 'in {count} day', 'in {count} days', days, { count: days })
195
203
  }
196
204
 
197
205
  /**
@@ -14,6 +14,7 @@
14
14
 
15
15
  import axios from '@nextcloud/axios'
16
16
  import { generateOcsUrl } from '@nextcloud/router'
17
+ import { reactive } from 'vue'
17
18
 
18
19
  const OCS_HEADERS = {
19
20
  'OCS-APIRequest': 'true',
@@ -117,3 +118,77 @@ export async function resolveNextcloudGroup(gid) {
117
118
  const results = await searchNextcloudGroups(String(gid))
118
119
  return results.find((opt) => opt.id === String(gid)) || fallback
119
120
  }
121
+
122
+ /**
123
+ * Display names already looked up, by group id. Reactive, so every cell that
124
+ * shows the same group updates when its name arrives.
125
+ *
126
+ * @type {Record<string, string>}
127
+ */
128
+ const groupNames = reactive({})
129
+
130
+ /** In-flight lookups by group id, so one id is asked for once. */
131
+ const pendingNames = new Map()
132
+
133
+ /**
134
+ * The cached display name of a group, or `null` when it has not been looked
135
+ * up yet. Reactive: read in a computed or a render, it updates when
136
+ * {@link loadGroupDisplayName} finishes.
137
+ *
138
+ * @spec openspec/changes/nextcloud-group-surfaces/specs/data-display/spec.md#requirement-a-group-cell-shows-the-groups-display-name
139
+ * @param {string} gid The group id.
140
+ * @return {string|null} The display name, or null when unknown.
141
+ */
142
+ export function groupDisplayName(gid) {
143
+ if (gid === undefined || gid === null || gid === '') {
144
+ return null
145
+ }
146
+ const name = groupNames[String(gid)]
147
+ return typeof name === 'string' ? name : null
148
+ }
149
+
150
+ /**
151
+ * Look a group's display name up once per page and cache it. Concurrent
152
+ * callers for the same id share one request. A failed or empty lookup caches
153
+ * the id itself, so the cell shows the id and does not ask again.
154
+ *
155
+ * @spec openspec/changes/nextcloud-group-surfaces/specs/data-display/spec.md#requirement-a-group-cell-shows-the-groups-display-name
156
+ * @param {string} gid The group id.
157
+ * @return {Promise<string>} The display name, or the id.
158
+ */
159
+ export function loadGroupDisplayName(gid) {
160
+ if (gid === undefined || gid === null || gid === '') {
161
+ return Promise.resolve('')
162
+ }
163
+ const key = String(gid)
164
+ const known = groupDisplayName(key)
165
+ if (known !== null) {
166
+ return Promise.resolve(known)
167
+ }
168
+ if (pendingNames.has(key)) {
169
+ return pendingNames.get(key)
170
+ }
171
+ const request = resolveNextcloudGroup(key)
172
+ .then((option) => (option && option.label) || key)
173
+ .catch(() => key)
174
+ .then((label) => {
175
+ groupNames[key] = label
176
+ pendingNames.delete(key)
177
+ return label
178
+ })
179
+ pendingNames.set(key, request)
180
+ return request
181
+ }
182
+
183
+ /**
184
+ * Forget every cached group name (tests, or after a group was renamed).
185
+ *
186
+ * @spec openspec/changes/nextcloud-group-surfaces/specs/data-display/spec.md#requirement-a-group-cell-shows-the-groups-display-name
187
+ * @return {void}
188
+ */
189
+ export function clearGroupNameCache() {
190
+ pendingNames.clear()
191
+ for (const key of Object.keys(groupNames)) {
192
+ delete groupNames[key]
193
+ }
194
+ }
@@ -5,7 +5,7 @@ export { findRepeatingWrite, journeyItemTargets } from './journeyRepeatingWrite.
5
5
  export { columnsFromSchema, fieldsFromSchema, filtersFromSchema, formatValue, validateValue } from './schema.js'
6
6
  export { DYNAMIC_KEY_PREFIX, EXTENDS_FORM_KEY, PREFILL_KEY, splitDynamicFormData, usesArrayValues, valueArrayFor, valueRecordsFor } from './dynamicProperties.js'
7
7
  export { deleteSchema, describeSchemaChange, saveSchema, SchemaBreakingChangeError, SchemaHasObjectsError } from './schemaApi.js'
8
- export { filterWidgetsByVisibility, getCurrentUserGroups, getCurrentUserId, isWidgetVisible, resetVisibilityCache } from './widgetVisibility.js'
8
+ export { filterWidgetsByVisibility, getCurrentUserGroups, getCurrentUserId, isWidgetVisible, peekCurrentUserGroups, resetVisibilityCache } from './widgetVisibility.js'
9
9
  export { isAppInstalled } from './appInstalled.js'
10
10
  export { getAssistantMark } from './assistantMark.js'
11
11
  export { passesContextPredicates } from './visibleIfContext.js'
@@ -0,0 +1,86 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * Why a personal lens (Recent, later Following) came back empty.
6
+ *
7
+ * OpenRegister reports per asked lens, at response level, whether it could
8
+ * answer: `@self.lenses.<lens> = { available, reason }` (openregister#4514,
9
+ * `read-history-on-audit-trail`). `available: false` always comes with an
10
+ * empty page. These helpers read that report and turn it into the short
11
+ * explanation a list shows in place of its generic empty text.
12
+ *
13
+ * The lens name is a key, so a later lens (`watching`) only needs copy.
14
+ *
15
+ * @spec openspec/changes/lens-says-why-it-is-empty/specs/personal-lens-availability/spec.md#requirement-an-unavailable-lens-explains-its-empty-page
16
+ */
17
+ import { translate as t } from '@nextcloud/l10n'
18
+
19
+ /**
20
+ * Built-in explanations per lens and reason. Object-neutral: the library
21
+ * does not know whether the app lists cases, tickets or documents. An app
22
+ * says it in its own words through `lensReasonTexts`.
23
+ *
24
+ * @return {Object<string, Object<string, string>>} Lens name to reason to text.
25
+ */
26
+ function builtInTexts() {
27
+ return {
28
+ recent: {
29
+ 'audit-trail-disabled': t('nextcloud-vue', 'This server does not keep track of what you open.'),
30
+ anonymous: t('nextcloud-vue', 'Log in to see what you opened recently.'),
31
+ 'read-history-unavailable': t('nextcloud-vue', 'Your recent items are not available right now.'),
32
+ },
33
+ }
34
+ }
35
+
36
+ /**
37
+ * The lens reports of a list response body: `@self.lenses`, or `{}` when the
38
+ * body carries none (every response before openregister#4514, and every
39
+ * response that did not ask for a lens).
40
+ *
41
+ * @param {object|null|undefined} body A parsed list response.
42
+ * @return {Object<string, {available: boolean, reason: (string|null)}>} The reports, keyed by lens name.
43
+ * @spec openspec/changes/lens-says-why-it-is-empty/specs/personal-lens-availability/spec.md#requirement-a-list-response-carries-the-report-of-the-lenses-it-was-asked-for
44
+ */
45
+ export function readLensReports(body) {
46
+ const lenses = body && typeof body === 'object' && body['@self'] && typeof body['@self'] === 'object'
47
+ ? body['@self'].lenses
48
+ : null
49
+ return lenses && typeof lenses === 'object' && !Array.isArray(lenses) ? lenses : {}
50
+ }
51
+
52
+ /**
53
+ * The explanation for the first lens that reports `available: false` with a
54
+ * reason there is text for, or `''` when there is none (no report, every lens
55
+ * available, or a reason nobody wrote text for). The caller then shows its
56
+ * own empty text, as before.
57
+ *
58
+ * App text wins over built-in text; within app text, `<lens>.<reason>` wins
59
+ * over `<reason>`.
60
+ *
61
+ * @param {object|null|undefined} reports The lens reports (see `readLensReports`).
62
+ * @param {Object<string, string>|null} [overrides] App text keyed `<lens>.<reason>` or `<reason>`.
63
+ * @return {string} The explanation, or an empty string.
64
+ * @spec openspec/changes/lens-says-why-it-is-empty/specs/personal-lens-availability/spec.md#requirement-an-unavailable-lens-explains-its-empty-page
65
+ */
66
+ export function lensUnavailableText(reports, overrides = null) {
67
+ if (!reports || typeof reports !== 'object') {
68
+ return ''
69
+ }
70
+ const own = overrides && typeof overrides === 'object' ? overrides : {}
71
+ const builtIn = builtInTexts()
72
+ for (const [lens, report] of Object.entries(reports)) {
73
+ if (!report || typeof report !== 'object' || report.available !== false) {
74
+ continue
75
+ }
76
+ const reason = typeof report.reason === 'string' ? report.reason : ''
77
+ if (reason === '') {
78
+ continue
79
+ }
80
+ const text = own[`${lens}.${reason}`] || own[reason] || (builtIn[lens] && builtIn[lens][reason]) || ''
81
+ if (text) {
82
+ return text
83
+ }
84
+ }
85
+ return ''
86
+ }
@@ -2,30 +2,37 @@
2
2
  * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
3
  * SPDX-License-Identifier: EUPL-1.2
4
4
  *
5
- * The personal lenses of an index page (Favourites, Recent, Following) as
6
- * quick filters, appended after the page's own.
5
+ * The personal lenses of an index page (Following, Recent, Unread) as quick
6
+ * filters, appended after the page's own.
7
7
  *
8
- * @spec openspec/changes/record-favourite-and-follow/tasks.md#task-3
8
+ * There is no Favourites lens any more: a favourite is a follow with
9
+ * notifications off (OpenRegister `merge-follow-and-favourites`), so
10
+ * `favourite` is an alias of `watching` and asking for both gives one tab.
11
+ *
12
+ * @spec openspec/changes/one-follow-control/specs/record-follow/spec.md#requirement-index-pages-offer-one-following-lens-and-a-follow-column
9
13
  */
10
14
  import { translate as t } from '@nextcloud/l10n'
11
15
 
12
16
  /** Lens id to the query key OpenRegister reads. */
13
- export const LENS_FILTER_KEYS = { favourite: '_favourite', recent: '_recent', watching: '_watching', unread: '_unread' }
17
+ export const LENS_FILTER_KEYS = { recent: '_recent', watching: '_watching', unread: '_unread' }
18
+
19
+ /** Deprecated lens ids and the lens they now mean. */
20
+ const LENS_ALIASES = { favourite: 'watching' }
14
21
 
15
22
  /**
16
23
  * Quick filters for the requested lenses. Unknown ids are ignored.
17
24
  *
18
- * @param {string[]} lenses Any of `favourite`, `recent`, `watching`, `unread`.
25
+ * @param {string[]} lenses Any of `watching`, `recent`, `unread`, and the deprecated `favourite` (read as `watching`).
19
26
  * @return {Array<{label: string, filter: object, lens: string}>} One tab per lens, in the order asked.
20
27
  */
21
28
  export function personalLensTabs(lenses) {
22
29
  const labels = {
23
- favourite: t('nextcloud-vue', 'Favourites'),
24
30
  recent: t('nextcloud-vue', 'Recent'),
25
31
  watching: t('nextcloud-vue', 'Following'),
26
32
  unread: t('nextcloud-vue', 'Unread'),
27
33
  }
28
34
  return (Array.isArray(lenses) ? lenses : [])
35
+ .map((id) => (Object.hasOwn(LENS_ALIASES, id) ? LENS_ALIASES[id] : id))
29
36
  .filter((id, i, all) => Object.hasOwn(LENS_FILTER_KEYS, id) && all.indexOf(id) === i)
30
37
  .map((id) => ({ label: labels[id], filter: { [LENS_FILTER_KEYS[id]]: true }, lens: id }))
31
38
  }