[verified] add comments-only file panel
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
# Korean comments action for Nextcloud 34
|
||||
|
||||
Minimal custom app that adds a **댓글** icon directly to each eligible Files row and opens that node's comments UI without using the `…` menu. On Nextcloud 34 with Activity enabled, comments are integrated into sidebar tab `activity`; if Activity is disabled, the app falls back to tab `comments`.
|
||||
Minimal custom app that adds an icon-only **댓글** action directly to each eligible Files row. An empty square means no comments; a square containing three dots means at least one comment exists. Clicking either icon opens a dedicated **댓글** sidebar tab containing only the native comment composer and comment history, not the Activity feed.
|
||||
|
||||
## Why these APIs
|
||||
|
||||
The installed Nextcloud 34.0.4 bundle uses `@nextcloud/files` 4.0.0. Its own Comments app implements the same flow with `registerFileAction()`, `getSidebar()`, `sidebar.open(node, tabId)`, and `sidebar.setActiveTab(tabId)`. The installed Activity app registers sidebar tab ID `activity`; Comments registers tab ID `comments` only when Activity integration is unavailable.
|
||||
The installed Nextcloud 34.0.4 bundle uses `@nextcloud/files` 4.0.0. The app registers `oc:comments-count` before the Files listing loads, then uses the count to choose the row icon. It registers a supported custom Files sidebar tab and mounts Nextcloud Comments' native `OCA.Comments.View` in that tab, retaining native creation, history, edit, delete, mention, and permission behavior while avoiding the mixed Activity feed.
|
||||
|
||||
Nextcloud's built-in `comments-unread` row action is reused when unread comments exist so users still see only one comment icon. Its click target and icon are aligned with the dedicated comments-only tab; the custom action covers rows without unread comments.
|
||||
|
||||
The PHP bootstrap listens for `OCA\Files\Event\LoadAdditionalScriptsEvent`, the supported event fired while Files is rendered, and loads the frontend with `OCP\Util::addInitScript()`.
|
||||
|
||||
@@ -39,12 +41,13 @@ php occ app:disable korean_comments_action
|
||||
|
||||
## Manual acceptance test
|
||||
|
||||
1. Open **Files → Personal files**. Confirm one **댓글** icon appears directly on every eligible file row without opening the `…` menu.
|
||||
2. Click **댓글**. Confirm the details sidebar opens for that exact filename, the **Activity** tab is active, existing comments load, and the comment composer is visible.
|
||||
3. On the same file, switch to another sidebar tab and click **댓글** again. Confirm it switches back to Activity without closing the sidebar.
|
||||
4. With the sidebar still open, use **댓글** on another file. Confirm the sidebar header and comments belong to the second file.
|
||||
5. Select multiple rows. Confirm the action is not offered. Confirm it is also absent for nodes outside the supported `/files/<user>` tree or with `Permission.NONE`.
|
||||
6. Optional fallback check on a test instance: disable Activity while leaving Comments enabled, reload Files, and confirm **댓글** opens the standalone **Comments** tab.
|
||||
1. Open **Files → Personal files**. Confirm each eligible row shows only an icon without a `댓글` text label or a required `…` menu click.
|
||||
2. Confirm a file with no comments uses an empty square and a file with one or more comments uses a square containing three dots.
|
||||
3. Click either icon. Confirm the dedicated **댓글** tab opens for that exact file and contains the comment input plus comment history, with no Activity entries.
|
||||
4. Create a comment, then edit and delete one of your own comments. Confirm the native controls and list updates work.
|
||||
5. Click the same file's icon again while the sidebar is already open. Confirm the dedicated **댓글** tab remains active and never falls back to **활동**.
|
||||
6. With the sidebar still open, use the icon on another file. Confirm the sidebar header, input, and comments belong to the second file.
|
||||
7. Select multiple rows. Confirm the action is not offered. Confirm it is also absent for nodes outside the supported `/files/<user>` tree, nodes without a file ID, nodes with `Permission.NONE`, or when the Comments app is unavailable.
|
||||
|
||||
## Primary references
|
||||
|
||||
|
||||
+3
-3
@@ -3,9 +3,9 @@
|
||||
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
|
||||
<id>korean_comments_action</id>
|
||||
<name>Korean comments action</name>
|
||||
<summary>Adds a 댓글 icon directly to eligible Files rows</summary>
|
||||
<description>Adds a Korean-labeled file action that opens the Activity/comments sidebar.</description>
|
||||
<version>1.0.2</version>
|
||||
<summary>Adds a stateful 댓글 icon and comments-only Files tab</summary>
|
||||
<description>Adds an icon-only file action that opens the native comment composer and comment history without the Activity feed.</description>
|
||||
<version>1.1.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author>Hera (헤라)</author>
|
||||
<namespace>KoreanCommentsAction</namespace>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -21,6 +21,7 @@ SPDX-FileCopyrightText: Jordan Harband <ljharb@gmail.com>
|
||||
SPDX-FileCopyrightText: Joyent
|
||||
SPDX-FileCopyrightText: Nathan Rajlich <nathan@tootallnate.net> (http://n8.io/)
|
||||
SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors
|
||||
SPDX-FileCopyrightText: Perry Mitchell <perry@perrymitchell.net>
|
||||
SPDX-FileCopyrightText: Raynos <raynos2@gmail.com>
|
||||
SPDX-FileCopyrightText: Sindre Sorhus
|
||||
SPDX-FileCopyrightText: inherits developers
|
||||
@@ -53,6 +54,15 @@ This file is generated from multiple sources. Included packages:
|
||||
- @nextcloud/logger
|
||||
- version: 3.0.3
|
||||
- license: GPL-3.0-or-later
|
||||
- @nextcloud/paths
|
||||
- version: 3.1.0
|
||||
- license: GPL-3.0-or-later
|
||||
- @nextcloud/router
|
||||
- version: 3.2.0
|
||||
- license: GPL-3.0-or-later
|
||||
- @nextcloud/sharing
|
||||
- version: 0.3.0
|
||||
- license: GPL-3.0-or-later
|
||||
- available-typed-arrays
|
||||
- version: 1.0.7
|
||||
- license: MIT
|
||||
@@ -138,7 +148,7 @@ This file is generated from multiple sources. Included packages:
|
||||
- version: 1.1.15
|
||||
- license: MIT
|
||||
- korean-comments-action
|
||||
- version: 1.0.2
|
||||
- version: 1.1.0
|
||||
- license: undefined
|
||||
- math-intrinsics
|
||||
- version: 1.1.0
|
||||
@@ -185,6 +195,9 @@ This file is generated from multiple sources. Included packages:
|
||||
- vite-plugin-node-polyfills
|
||||
- version: 0.28.0
|
||||
- license: MIT
|
||||
- webdav
|
||||
- version: 5.10.0
|
||||
- license: MIT
|
||||
- which-typed-array
|
||||
- version: 1.1.24
|
||||
- license: MIT
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -17,6 +17,7 @@ final class LoadFilesScript implements IEventListener {
|
||||
return;
|
||||
}
|
||||
|
||||
Util::addInitScript('comments', 'comments-comments-app');
|
||||
Util::addInitScript(Application::APP_ID, Application::APP_ID . '-main');
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+3
-2
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "korean-comments-action",
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "korean-comments-action",
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"@nextcloud/event-bus": "3.3.3",
|
||||
"@nextcloud/files": "4.0.0",
|
||||
"@nextcloud/initial-state": "3.0.0"
|
||||
},
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "korean-comments-action",
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -9,6 +9,7 @@
|
||||
"test": "python3 tests/check_php_asset.py && vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nextcloud/event-bus": "3.3.3",
|
||||
"@nextcloud/files": "4.0.0",
|
||||
"@nextcloud/initial-state": "3.0.0"
|
||||
},
|
||||
|
||||
+295
-39
@@ -1,64 +1,296 @@
|
||||
import type { IFileAction } from '@nextcloud/files'
|
||||
import { getSidebar, Permission, registerFileAction } from '@nextcloud/files'
|
||||
import type { IFileAction, IFolder, INode, IView } from '@nextcloud/files'
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import {
|
||||
getFileActions,
|
||||
getFilesRegistry,
|
||||
getSidebar,
|
||||
Permission,
|
||||
registerFileAction,
|
||||
registerSidebarTab,
|
||||
} from '@nextcloud/files'
|
||||
import {
|
||||
getClient,
|
||||
getDavNameSpaces,
|
||||
getDavProperties,
|
||||
registerDavProperty,
|
||||
resultToNode,
|
||||
} from '@nextcloud/files/dav'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
|
||||
const COMMENT_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M18 8H6V6h12v2m0 3H6V9h12v2m0 3H6v-2h12v2m4-10v18l-4-4H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2Z"/></svg>'
|
||||
export const COMMENTS_TAB_ID = 'korean-comments-only'
|
||||
const COMMENTS_TAG_NAME = 'korean_comments_action-comments-tab'
|
||||
|
||||
const EMPTY_COMMENT_ICON = '<svg data-comment-state="empty" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2"/></svg>'
|
||||
const HAS_COMMENTS_ICON = '<svg data-comment-state="has-comments" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="8" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="16" cy="12" r="1.4"/></svg>'
|
||||
|
||||
type ActionContext = Parameters<NonNullable<IFileAction['enabled']>>[0]
|
||||
|
||||
function commentsTab(context: ActionContext): 'activity' | 'comments' | undefined {
|
||||
const [node] = context.nodes
|
||||
if (!node) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const tabs = getSidebar().getTabs({
|
||||
node,
|
||||
folder: context.folder,
|
||||
view: context.view,
|
||||
})
|
||||
|
||||
const commentsUseActivity = loadState<boolean>('comments', 'activityEnabled', false)
|
||||
if (commentsUseActivity && tabs.some(({ id }) => id === 'activity')) {
|
||||
return 'activity'
|
||||
}
|
||||
if (tabs.some(({ id }) => id === 'comments')) {
|
||||
return 'comments'
|
||||
}
|
||||
return undefined
|
||||
type CommentsViewInstance = {
|
||||
$unmount(): void
|
||||
update(resourceId: string | number): void | Promise<void>
|
||||
}
|
||||
type CommentsViewConstructor = new (
|
||||
resourceType?: string,
|
||||
options?: {
|
||||
el?: HTMLElement
|
||||
props?: Record<string, unknown>
|
||||
},
|
||||
) => CommentsViewInstance
|
||||
|
||||
type CommentsWindow = Window & {
|
||||
OCA?: {
|
||||
Comments?: {
|
||||
View?: CommentsViewConstructor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function commentsAppAvailable(): boolean {
|
||||
return loadState<boolean | null>('comments', 'activityEnabled', null) !== null
|
||||
}
|
||||
|
||||
function commentCount(context: ActionContext): number {
|
||||
const count = context.nodes[0]?.attributes?.['comments-count']
|
||||
if (typeof count === 'number') {
|
||||
return count
|
||||
}
|
||||
if (typeof count === 'string') {
|
||||
const parsed = Number.parseInt(count, 10)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function unreadCommentCount(context: ActionContext): number {
|
||||
const count = context.nodes[0]?.attributes?.['comments-unread']
|
||||
if (typeof count === 'number') {
|
||||
return count
|
||||
}
|
||||
if (typeof count === 'string') {
|
||||
const parsed = Number.parseInt(count, 10)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function isEligibleContext(context: ActionContext): boolean {
|
||||
const [node] = context.nodes
|
||||
return context.nodes.length === 1
|
||||
&& node !== undefined
|
||||
&& node.root.startsWith('/files/')
|
||||
&& node.permissions !== Permission.NONE
|
||||
&& node.fileid !== undefined
|
||||
&& getSidebar().available
|
||||
&& commentsAppAvailable()
|
||||
}
|
||||
|
||||
const commentRefreshSequence = new Map<string, number>()
|
||||
|
||||
export async function refreshCommentCount(node: INode): Promise<void> {
|
||||
const sequence = (commentRefreshSequence.get(node.source) ?? 0) + 1
|
||||
commentRefreshSequence.set(node.source, sequence)
|
||||
const response = await getClient().stat(`${node.root}${node.path}`, {
|
||||
details: true,
|
||||
data: `<?xml version="1.0"?>
|
||||
<d:propfind ${getDavNameSpaces()}>
|
||||
<d:prop>${getDavProperties()}</d:prop>
|
||||
</d:propfind>`,
|
||||
})
|
||||
if (commentRefreshSequence.get(node.source) !== sequence) {
|
||||
return
|
||||
}
|
||||
const stat = 'data' in response ? response.data : response
|
||||
const refreshed = resultToNode(stat, node.root)
|
||||
const count = refreshed.attributes['comments-count']
|
||||
const previous = node.attributes['comments-count']
|
||||
const previousCount = typeof previous === 'number'
|
||||
? previous
|
||||
: typeof previous === 'string'
|
||||
? Number.parseInt(previous, 10)
|
||||
: 0
|
||||
if (typeof count === 'number' && count !== previousCount) {
|
||||
node.update({ 'comments-count': count })
|
||||
emit('files:node:updated', node)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCommentsView(): Promise<CommentsViewConstructor> {
|
||||
const deadline = Date.now() + 5000
|
||||
while (Date.now() < deadline) {
|
||||
const CommentsView = (window as CommentsWindow).OCA?.Comments?.View
|
||||
if (CommentsView) {
|
||||
return CommentsView
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 50))
|
||||
}
|
||||
throw new Error('Nextcloud Comments view is unavailable')
|
||||
}
|
||||
|
||||
function createCommentsElement(): CustomElementConstructor {
|
||||
return class CommentsOnlyElement extends HTMLElement {
|
||||
private commentsView?: CommentsViewInstance
|
||||
private container?: HTMLDivElement
|
||||
private currentNode?: INode
|
||||
private mutationObserver?: MutationObserver
|
||||
private refreshTimers = new Map<string, number>()
|
||||
private renderGeneration = 0
|
||||
active = false
|
||||
folder?: IFolder
|
||||
view?: IView
|
||||
|
||||
get node(): INode | undefined {
|
||||
return this.currentNode
|
||||
}
|
||||
|
||||
set node(node: INode | undefined) {
|
||||
this.currentNode = node
|
||||
void this.renderComments()
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.style.display = 'block'
|
||||
this.style.height = '100%'
|
||||
this.style.overflow = 'auto'
|
||||
this.container = document.createElement('div')
|
||||
this.container.className = 'korean-comments-action__content'
|
||||
this.append(this.container)
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
this.mutationObserver = new MutationObserver(() => this.scheduleCountRefresh())
|
||||
this.mutationObserver.observe(this.container, { childList: true, subtree: true })
|
||||
}
|
||||
void this.renderComments()
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.renderGeneration += 1
|
||||
this.mutationObserver?.disconnect()
|
||||
this.mutationObserver = undefined
|
||||
for (const timer of this.refreshTimers.values()) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
this.refreshTimers.clear()
|
||||
this.commentsView?.$unmount()
|
||||
this.commentsView = undefined
|
||||
this.container = undefined
|
||||
this.replaceChildren()
|
||||
}
|
||||
|
||||
private async renderComments() {
|
||||
const node = this.currentNode
|
||||
const resourceId = node?.fileid
|
||||
if (!this.isConnected || !this.container || !node || resourceId === undefined) {
|
||||
return
|
||||
}
|
||||
if (this.commentsView) {
|
||||
await this.commentsView.update(resourceId)
|
||||
this.scheduleCountRefresh()
|
||||
return
|
||||
}
|
||||
|
||||
const generation = ++this.renderGeneration
|
||||
try {
|
||||
const CommentsView = await waitForCommentsView()
|
||||
if (generation !== this.renderGeneration || !this.isConnected || !this.container) {
|
||||
return
|
||||
}
|
||||
this.commentsView = new CommentsView('files', {
|
||||
el: this.container,
|
||||
props: { resourceId },
|
||||
})
|
||||
this.scheduleCountRefresh()
|
||||
} catch (error) {
|
||||
if (generation === this.renderGeneration) {
|
||||
this.renderRetry(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderRetry(error: unknown) {
|
||||
if (!this.container) {
|
||||
return
|
||||
}
|
||||
console.error('Could not initialize the native Comments view', error)
|
||||
const message = document.createElement('p')
|
||||
message.textContent = '댓글 화면을 불러오지 못했습니다.'
|
||||
const retry = document.createElement('button')
|
||||
retry.type = 'button'
|
||||
retry.textContent = '다시 시도'
|
||||
retry.addEventListener('click', () => void this.renderComments())
|
||||
this.container.replaceChildren(message, retry)
|
||||
}
|
||||
|
||||
private scheduleCountRefresh() {
|
||||
const node = this.currentNode
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
const key = node.source
|
||||
const existingTimer = this.refreshTimers.get(key)
|
||||
if (existingTimer !== undefined) {
|
||||
window.clearTimeout(existingTimer)
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
this.refreshTimers.delete(key)
|
||||
void refreshCommentCount(node).catch((error) => {
|
||||
console.error('Could not refresh the comment count', error)
|
||||
})
|
||||
}, 200)
|
||||
this.refreshTimers.set(key, timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerDavProperty('oc:comments-count')
|
||||
|
||||
registerSidebarTab({
|
||||
id: COMMENTS_TAB_ID,
|
||||
displayName: '댓글',
|
||||
iconSvgInline: HAS_COMMENTS_ICON,
|
||||
order: 45,
|
||||
tagName: COMMENTS_TAG_NAME,
|
||||
enabled({ node }) {
|
||||
return commentsAppAvailable()
|
||||
&& node.root.startsWith('/files/')
|
||||
&& node.permissions !== Permission.NONE
|
||||
&& node.fileid !== undefined
|
||||
},
|
||||
onInit() {
|
||||
if (!window.customElements.get(COMMENTS_TAG_NAME)) {
|
||||
window.customElements.define(COMMENTS_TAG_NAME, createCommentsElement())
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
})
|
||||
|
||||
let nativeUnreadActionAdopted = false
|
||||
|
||||
export const action: IFileAction = {
|
||||
id: 'korean-comments-action',
|
||||
displayName: () => '댓글',
|
||||
title: () => '댓글',
|
||||
iconSvgInline: () => COMMENT_ICON,
|
||||
displayName: () => '',
|
||||
title: (context) => {
|
||||
const count = commentCount(context)
|
||||
return count > 0 ? `댓글 ${count}개` : '댓글 작성'
|
||||
},
|
||||
iconSvgInline: (context) => commentCount(context) > 0 ? HAS_COMMENTS_ICON : EMPTY_COMMENT_ICON,
|
||||
enabled(context) {
|
||||
const [node] = context.nodes
|
||||
return context.nodes.length === 1
|
||||
&& node !== undefined
|
||||
&& node.root.startsWith('/files/')
|
||||
&& node.permissions !== Permission.NONE
|
||||
&& getSidebar().available
|
||||
&& commentsTab(context) !== undefined
|
||||
return isEligibleContext(context)
|
||||
&& (!nativeUnreadActionAdopted || unreadCommentCount(context) === 0)
|
||||
},
|
||||
async exec(context) {
|
||||
const [node] = context.nodes
|
||||
const tabId = commentsTab(context)
|
||||
if (!node || !tabId) {
|
||||
if (!node || !isEligibleContext(context)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const sidebar = getSidebar()
|
||||
if (sidebar.isOpen && sidebar.node?.source === node.source) {
|
||||
sidebar.setActiveTab(tabId)
|
||||
sidebar.setActiveTab(COMMENTS_TAB_ID)
|
||||
} else {
|
||||
sidebar.open(node, tabId)
|
||||
sidebar.open(node, COMMENTS_TAB_ID)
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Could not open the comments sidebar', error)
|
||||
console.error('Could not open the comments-only sidebar', error)
|
||||
return false
|
||||
}
|
||||
},
|
||||
@@ -66,4 +298,28 @@ export const action: IFileAction = {
|
||||
order: -45,
|
||||
}
|
||||
|
||||
export function adoptNativeUnreadAction(nativeAction: IFileAction): boolean {
|
||||
if (nativeAction.id !== 'comments-unread' || typeof nativeAction.enabled !== 'function') {
|
||||
return false
|
||||
}
|
||||
const originalEnabled = nativeAction.enabled
|
||||
nativeAction.displayName = action.displayName
|
||||
nativeAction.title = action.title
|
||||
nativeAction.iconSvgInline = () => HAS_COMMENTS_ICON
|
||||
nativeAction.exec = action.exec
|
||||
nativeAction.inline = action.inline
|
||||
nativeAction.enabled = (context) => isEligibleContext(context)
|
||||
&& originalEnabled(context) === true
|
||||
nativeUnreadActionAdopted = true
|
||||
return true
|
||||
}
|
||||
|
||||
for (const fileAction of getFileActions()) {
|
||||
adoptNativeUnreadAction(fileAction)
|
||||
}
|
||||
|
||||
getFilesRegistry().addEventListener('register:action', (event) => {
|
||||
adoptNativeUnreadAction((event as CustomEvent<IFileAction>).detail)
|
||||
})
|
||||
|
||||
registerFileAction(action)
|
||||
|
||||
@@ -3,8 +3,10 @@ from pathlib import Path
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
listener = (root / "lib/Listener/LoadFilesScript.php").read_text()
|
||||
expected_call = "Util::addInitScript(Application::APP_ID, Application::APP_ID . '-main');"
|
||||
comments_dependency = "Util::addInitScript('comments', 'comments-comments-app');"
|
||||
|
||||
assert expected_call in listener, "listener must load the exact app-prefixed Vite asset"
|
||||
assert comments_dependency in listener, "listener must load the native Comments view before the custom app"
|
||||
assert (root / "js/korean_comments_action-main.mjs").is_file(), "built module is missing"
|
||||
|
||||
print("php asset link ok")
|
||||
|
||||
+325
-27
@@ -1,30 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getSidebar: vi.fn(),
|
||||
registerFileAction: vi.fn(),
|
||||
loadState: vi.fn(),
|
||||
open: vi.fn(),
|
||||
setActiveTab: vi.fn(),
|
||||
getTabs: vi.fn(),
|
||||
}))
|
||||
const mocks = vi.hoisted(() => {
|
||||
const nativeUnreadAction = {
|
||||
id: 'comments-unread',
|
||||
displayName: vi.fn((_context?: unknown) => 'Comment'),
|
||||
title: vi.fn((_context?: unknown) => 'Comment'),
|
||||
iconSvgInline: vi.fn((_context?: unknown) => '<svg data-native="true"/>'),
|
||||
enabled: vi.fn(({ nodes }) => Number(nodes[0]?.attributes?.['comments-unread']) > 0),
|
||||
exec: vi.fn(),
|
||||
inline: vi.fn(() => true),
|
||||
}
|
||||
return {
|
||||
emit: vi.fn(),
|
||||
getSidebar: vi.fn(),
|
||||
getClient: vi.fn(),
|
||||
getDavNameSpaces: vi.fn(() => 'xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"'),
|
||||
getDavProperties: vi.fn(() => '<oc:comments-count />'),
|
||||
getFileActions: vi.fn(() => [nativeUnreadAction]),
|
||||
getFilesRegistry: vi.fn(() => ({ addEventListener: vi.fn() })),
|
||||
nativeUnreadAction,
|
||||
resultToNode: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
registerDavProperty: vi.fn(),
|
||||
registerFileAction: vi.fn(),
|
||||
registerSidebarTab: vi.fn(),
|
||||
loadState: vi.fn(),
|
||||
open: vi.fn(),
|
||||
setActiveTab: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@nextcloud/files', () => ({
|
||||
getFileActions: mocks.getFileActions,
|
||||
getFilesRegistry: mocks.getFilesRegistry,
|
||||
getSidebar: mocks.getSidebar,
|
||||
registerFileAction: mocks.registerFileAction,
|
||||
registerSidebarTab: mocks.registerSidebarTab,
|
||||
Permission: { NONE: 0 },
|
||||
}))
|
||||
|
||||
vi.mock('@nextcloud/files/dav', () => ({
|
||||
getClient: mocks.getClient,
|
||||
getDavNameSpaces: mocks.getDavNameSpaces,
|
||||
getDavProperties: mocks.getDavProperties,
|
||||
registerDavProperty: mocks.registerDavProperty,
|
||||
resultToNode: mocks.resultToNode,
|
||||
}))
|
||||
|
||||
vi.mock('@nextcloud/event-bus', () => ({
|
||||
emit: mocks.emit,
|
||||
}))
|
||||
|
||||
vi.mock('@nextcloud/initial-state', () => ({
|
||||
loadState: mocks.loadState,
|
||||
}))
|
||||
|
||||
import { action } from '../src/main'
|
||||
|
||||
const COMMENTS_TAB_ID = 'korean-comments-only'
|
||||
|
||||
const node = {
|
||||
root: '/files/alice',
|
||||
permissions: 1,
|
||||
fileid: 42,
|
||||
path: '/report.pdf',
|
||||
displayname: 'report.pdf',
|
||||
attributes: { 'comments-count': 0 },
|
||||
source: 'https://cloud.example/remote.php/dav/files/alice/report.pdf',
|
||||
update: vi.fn(),
|
||||
}
|
||||
const context = {
|
||||
nodes: [node],
|
||||
@@ -35,59 +78,314 @@ const context = {
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.open.mockReset()
|
||||
mocks.emit.mockReset()
|
||||
mocks.stat.mockReset()
|
||||
mocks.resultToNode.mockReset()
|
||||
mocks.getClient.mockReturnValue({ stat: mocks.stat })
|
||||
node.update.mockReset()
|
||||
mocks.setActiveTab.mockReset()
|
||||
mocks.getTabs.mockReset()
|
||||
mocks.loadState.mockReset()
|
||||
mocks.loadState.mockReturnValue(true)
|
||||
mocks.getTabs.mockReturnValue([{ id: 'activity' }])
|
||||
mocks.getSidebar.mockReturnValue({
|
||||
available: true,
|
||||
isOpen: false,
|
||||
activeTab: undefined,
|
||||
node: undefined,
|
||||
open: mocks.open,
|
||||
setActiveTab: mocks.setActiveTab,
|
||||
getTabs: mocks.getTabs,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Korean comments file action', () => {
|
||||
it('registers an inline 댓글 action for file rows', () => {
|
||||
it('registers the comment count property and comments-only sidebar tab', () => {
|
||||
expect(mocks.registerDavProperty).toHaveBeenCalledWith('oc:comments-count')
|
||||
expect(mocks.registerSidebarTab).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: COMMENTS_TAB_ID,
|
||||
displayName: '댓글',
|
||||
}))
|
||||
})
|
||||
|
||||
it('mounts the native Comments view in the comments-only tab', async () => {
|
||||
const mounted: Array<{ resourceType: string, resourceId: number }> = []
|
||||
const definitions = new Map<string, CustomElementConstructor>()
|
||||
class FakeElement {
|
||||
style: Record<string, string> = {}
|
||||
isConnected = true
|
||||
append() {}
|
||||
replaceChildren() {}
|
||||
}
|
||||
class FakeCommentsView {
|
||||
constructor(resourceType: string, options: { props: { resourceId: number } }) {
|
||||
mounted.push({ resourceType, resourceId: options.props.resourceId })
|
||||
}
|
||||
$unmount() {}
|
||||
update() {}
|
||||
}
|
||||
vi.stubGlobal('HTMLElement', FakeElement)
|
||||
vi.stubGlobal('document', {
|
||||
createElement: () => ({ className: '' }),
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
OCA: { Comments: { View: FakeCommentsView } },
|
||||
customElements: {
|
||||
get: (name: string) => definitions.get(name),
|
||||
define: (name: string, definition: CustomElementConstructor) => definitions.set(name, definition),
|
||||
},
|
||||
clearTimeout,
|
||||
setTimeout,
|
||||
})
|
||||
|
||||
const tab = mocks.registerSidebarTab.mock.calls[0][0]
|
||||
await tab.onInit()
|
||||
const Element = definitions.get('korean_comments_action-comments-tab')!
|
||||
const element = new Element() as unknown as { node: typeof node, connectedCallback(): void }
|
||||
element.node = node
|
||||
element.connectedCallback()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mounted).toEqual([{ resourceType: 'files', resourceId: 42 }])
|
||||
})
|
||||
|
||||
it('retains separate count refreshes when switching files during debounce', async () => {
|
||||
vi.useFakeTimers()
|
||||
let mutationCallback: (() => void) | undefined
|
||||
const definitions = new Map<string, CustomElementConstructor>()
|
||||
class FakeElement {
|
||||
style: Record<string, string> = {}
|
||||
isConnected = true
|
||||
append() {}
|
||||
replaceChildren() {}
|
||||
}
|
||||
class FakeMutationObserver {
|
||||
constructor(callback: () => void) {
|
||||
mutationCallback = callback
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
}
|
||||
class FakeCommentsView {
|
||||
$unmount() {}
|
||||
update() {}
|
||||
}
|
||||
vi.stubGlobal('HTMLElement', FakeElement)
|
||||
vi.stubGlobal('MutationObserver', FakeMutationObserver)
|
||||
vi.stubGlobal('document', {
|
||||
createElement: () => ({ className: '' }),
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
OCA: { Comments: { View: FakeCommentsView } },
|
||||
customElements: {
|
||||
get: (name: string) => definitions.get(name),
|
||||
define: (name: string, definition: CustomElementConstructor) => definitions.set(name, definition),
|
||||
},
|
||||
clearTimeout,
|
||||
setTimeout,
|
||||
})
|
||||
mocks.stat.mockResolvedValue({ data: {} })
|
||||
mocks.resultToNode.mockReturnValue({ attributes: { 'comments-count': 0 } })
|
||||
|
||||
const tab = mocks.registerSidebarTab.mock.calls[0][0]
|
||||
await tab.onInit()
|
||||
const Element = definitions.get('korean_comments_action-comments-tab')!
|
||||
const element = new Element() as unknown as { node: typeof node, connectedCallback(): void }
|
||||
element.node = node
|
||||
element.connectedCallback()
|
||||
await Promise.resolve()
|
||||
vi.clearAllTimers()
|
||||
mocks.stat.mockClear()
|
||||
|
||||
mutationCallback!()
|
||||
const secondNode = {
|
||||
...node,
|
||||
fileid: 43,
|
||||
path: '/second.pdf',
|
||||
displayname: 'second.pdf',
|
||||
source: 'https://cloud.example/remote.php/dav/files/alice/second.pdf',
|
||||
update: vi.fn(),
|
||||
}
|
||||
element.node = secondNode
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
|
||||
const paths = mocks.stat.mock.calls.map(([path]) => path)
|
||||
expect(paths).toContain('/files/alice/report.pdf')
|
||||
expect(paths).toContain('/files/alice/second.pdf')
|
||||
})
|
||||
|
||||
it('shows only an empty square icon when the file has no comments', () => {
|
||||
expect(mocks.registerFileAction).toHaveBeenCalledWith(action)
|
||||
expect(action.displayName(context as never)).toBe('댓글')
|
||||
expect(action.displayName(context as never)).toBe('')
|
||||
expect(action.iconSvgInline(context as never)).toContain('data-comment-state="empty"')
|
||||
expect(action.inline?.(context as never)).toBe(true)
|
||||
})
|
||||
|
||||
it('opens the integrated Activity tab', async () => {
|
||||
expect(action.enabled?.(context as never)).toBe(true)
|
||||
expect(await action.exec(context as never)).toBeNull()
|
||||
expect(mocks.open).toHaveBeenCalledWith(node, 'activity')
|
||||
it('shows a square with dots when the file has comments', () => {
|
||||
const withComments = {
|
||||
...context,
|
||||
nodes: [{ ...node, attributes: { 'comments-count': 2 } }],
|
||||
}
|
||||
|
||||
expect(action.iconSvgInline(withComments as never)).toContain('data-comment-state="has-comments"')
|
||||
})
|
||||
|
||||
it('switches an already-open sidebar to Activity', async () => {
|
||||
it('refreshes the row icon state after native comment mutations', async () => {
|
||||
const module = await import('../src/main') as unknown as {
|
||||
refreshCommentCount(target: typeof node): Promise<void>
|
||||
}
|
||||
mocks.stat.mockResolvedValue({ data: {} })
|
||||
mocks.resultToNode.mockReturnValue({ attributes: { 'comments-count': 1 } })
|
||||
|
||||
expect(module.refreshCommentCount).toBeTypeOf('function')
|
||||
await module.refreshCommentCount(node)
|
||||
|
||||
expect(mocks.stat).toHaveBeenCalledWith('/files/alice/report.pdf', expect.any(Object))
|
||||
expect(node.update).toHaveBeenCalledWith({ 'comments-count': 1 })
|
||||
expect(mocks.emit).toHaveBeenCalledWith('files:node:updated', node)
|
||||
})
|
||||
|
||||
it('does not emit a node update when the comment count is unchanged', async () => {
|
||||
const module = await import('../src/main') as unknown as {
|
||||
refreshCommentCount(target: typeof node): Promise<void>
|
||||
}
|
||||
const unchangedNode = {
|
||||
...node,
|
||||
attributes: { 'comments-count': 1 },
|
||||
update: vi.fn(),
|
||||
}
|
||||
mocks.stat.mockResolvedValue({ data: {} })
|
||||
mocks.resultToNode.mockReturnValue({ attributes: { 'comments-count': 1 } })
|
||||
|
||||
await module.refreshCommentCount(unchangedNode)
|
||||
|
||||
expect(unchangedNode.update).not.toHaveBeenCalled()
|
||||
expect(mocks.emit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores an older count refresh that finishes after a newer request', async () => {
|
||||
const module = await import('../src/main') as unknown as {
|
||||
refreshCommentCount(target: typeof node): Promise<void>
|
||||
}
|
||||
let resolveFirst!: (value: unknown) => void
|
||||
let resolveSecond!: (value: unknown) => void
|
||||
const first = new Promise((resolve) => { resolveFirst = resolve })
|
||||
const second = new Promise((resolve) => { resolveSecond = resolve })
|
||||
mocks.stat.mockReturnValueOnce(first).mockReturnValueOnce(second)
|
||||
mocks.resultToNode.mockImplementation((data: { count: number }) => ({
|
||||
attributes: { 'comments-count': data.count },
|
||||
}))
|
||||
|
||||
const older = module.refreshCommentCount(node)
|
||||
const newer = module.refreshCommentCount(node)
|
||||
resolveSecond({ data: { count: 2 } })
|
||||
await newer
|
||||
resolveFirst({ data: { count: 1 } })
|
||||
await older
|
||||
|
||||
expect(node.update).toHaveBeenCalledTimes(1)
|
||||
expect(node.update).toHaveBeenCalledWith({ 'comments-count': 2 })
|
||||
})
|
||||
|
||||
it('reuses the native unread slot so only one comment icon is shown', async () => {
|
||||
const unreadContext = {
|
||||
...context,
|
||||
nodes: [{ ...node, attributes: { 'comments-count': 2, 'comments-unread': 1 } }],
|
||||
}
|
||||
|
||||
expect(action.enabled?.(unreadContext as never)).toBe(false)
|
||||
expect(mocks.nativeUnreadAction.displayName(unreadContext as never)).toBe('')
|
||||
expect(mocks.nativeUnreadAction.iconSvgInline(unreadContext as never)).toContain('data-comment-state="has-comments"')
|
||||
expect(await mocks.nativeUnreadAction.exec(unreadContext as never)).toBeNull()
|
||||
expect(mocks.open).toHaveBeenCalledWith(unreadContext.nodes[0], COMMENTS_TAB_ID)
|
||||
})
|
||||
|
||||
it('refuses to adopt a native unread action without its eligibility predicate', async () => {
|
||||
const module = await import('../src/main') as unknown as {
|
||||
adoptNativeUnreadAction(candidate: Record<string, unknown>): boolean
|
||||
}
|
||||
const incompleteAction = {
|
||||
id: 'comments-unread',
|
||||
displayName: vi.fn(),
|
||||
iconSvgInline: vi.fn(),
|
||||
exec: vi.fn(),
|
||||
}
|
||||
|
||||
expect(module.adoptNativeUnreadAction).toBeTypeOf('function')
|
||||
expect(module.adoptNativeUnreadAction(incompleteAction)).toBe(false)
|
||||
expect(incompleteAction.exec).not.toBe(action.exec)
|
||||
})
|
||||
|
||||
it('keeps an adopted native action disabled when its predicate returns undefined', async () => {
|
||||
const module = await import('../src/main') as unknown as {
|
||||
adoptNativeUnreadAction(candidate: Record<string, unknown>): boolean
|
||||
}
|
||||
const malformedAction = {
|
||||
id: 'comments-unread',
|
||||
displayName: vi.fn(),
|
||||
iconSvgInline: vi.fn(),
|
||||
exec: vi.fn(),
|
||||
enabled: vi.fn((_context?: unknown) => undefined),
|
||||
}
|
||||
|
||||
expect(module.adoptNativeUnreadAction(malformedAction)).toBe(true)
|
||||
expect(malformedAction.enabled(context as never)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the adopted unread action hidden for unsupported contexts', () => {
|
||||
const multiple = { ...context, nodes: [node, node] }
|
||||
const noPermission = {
|
||||
...context,
|
||||
nodes: [{ ...node, permissions: 0, attributes: { 'comments-count': 2, 'comments-unread': 1 } }],
|
||||
}
|
||||
|
||||
expect(mocks.nativeUnreadAction.enabled(multiple as never)).toBe(false)
|
||||
expect(mocks.nativeUnreadAction.enabled(noPermission as never)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects execution for unsupported contexts instead of falling back to Activity', async () => {
|
||||
const multiple = { ...context, nodes: [node, node] }
|
||||
|
||||
expect(await action.exec(multiple as never)).toBe(false)
|
||||
expect(mocks.open).not.toHaveBeenCalled()
|
||||
expect(mocks.setActiveTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the dedicated comments-only tab instead of Activity', async () => {
|
||||
expect(action.enabled?.(context as never)).toBe(true)
|
||||
expect(await action.exec(context as never)).toBeNull()
|
||||
expect(mocks.open).toHaveBeenCalledWith(node, COMMENTS_TAB_ID)
|
||||
expect(mocks.open).not.toHaveBeenCalledWith(node, 'activity')
|
||||
})
|
||||
|
||||
it('keeps the dedicated comments-only tab active when clicked again', async () => {
|
||||
mocks.getSidebar.mockReturnValue({
|
||||
available: true,
|
||||
isOpen: true,
|
||||
activeTab: COMMENTS_TAB_ID,
|
||||
node,
|
||||
open: mocks.open,
|
||||
setActiveTab: mocks.setActiveTab,
|
||||
getTabs: mocks.getTabs,
|
||||
})
|
||||
|
||||
expect(await action.exec(context as never)).toBeNull()
|
||||
expect(mocks.setActiveTab).toHaveBeenCalledWith('activity')
|
||||
expect(mocks.setActiveTab).toHaveBeenCalledWith(COMMENTS_TAB_ID)
|
||||
expect(mocks.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the standalone Comments tab', async () => {
|
||||
mocks.getTabs.mockReturnValue([{ id: 'comments' }])
|
||||
it('uses the comments-only tab even when Activity integration is disabled', async () => {
|
||||
mocks.loadState.mockReturnValue(false)
|
||||
|
||||
expect(action.enabled?.(context as never)).toBe(true)
|
||||
expect(await action.exec(context as never)).toBeNull()
|
||||
expect(mocks.open).toHaveBeenCalledWith(node, 'comments')
|
||||
expect(mocks.open).toHaveBeenCalledWith(node, COMMENTS_TAB_ID)
|
||||
})
|
||||
|
||||
it('hides when Activity exists without Comments integration', () => {
|
||||
mocks.loadState.mockReturnValue(false)
|
||||
mocks.getTabs.mockReturnValue([{ id: 'activity' }])
|
||||
it('hides when the Comments app is unavailable', () => {
|
||||
mocks.loadState.mockReturnValue(null)
|
||||
|
||||
expect(action.enabled?.(context as never)).toBe(false)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user