[verified] add comments-only file panel

This commit is contained in:
2026-09-19 09:45:20 +00:00
parent a2bfb69ea8
commit a84aa33d82
11 changed files with 689 additions and 104 deletions
+11 -8
View File
@@ -1,10 +1,12 @@
# Korean comments action for Nextcloud 34 # 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 ## 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()`. 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 ## Manual acceptance test
1. Open **Files → Personal files**. Confirm one **댓글** icon appears directly on every eligible file row without opening the `…` menu. 1. Open **Files → Personal files**. Confirm each eligible row shows only an icon without a `댓글` text label or a required `…` menu click.
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. 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. On the same file, switch to another sidebar tab and click **댓글** again. Confirm it switches back to Activity without closing the sidebar. 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. With the sidebar still open, use **댓글** on another file. Confirm the sidebar header and comments belong to the second file. 4. Create a comment, then edit and delete one of your own comments. Confirm the native controls and list updates work.
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`. 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. Optional fallback check on a test instance: disable Activity while leaving Comments enabled, reload Files, and confirm **댓글** opens the standalone **Comments** tab. 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 ## Primary references
+3 -3
View File
@@ -3,9 +3,9 @@
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd"> xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>korean_comments_action</id> <id>korean_comments_action</id>
<name>Korean comments action</name> <name>Korean comments action</name>
<summary>Adds a 댓글 icon directly to eligible Files rows</summary> <summary>Adds a stateful 댓글 icon and comments-only Files tab</summary>
<description>Adds a Korean-labeled file action that opens the Activity/comments sidebar.</description> <description>Adds an icon-only file action that opens the native comment composer and comment history without the Activity feed.</description>
<version>1.0.2</version> <version>1.1.0</version>
<licence>agpl</licence> <licence>agpl</licence>
<author>Hera (헤라)</author> <author>Hera (헤라)</author>
<namespace>KoreanCommentsAction</namespace> <namespace>KoreanCommentsAction</namespace>
File diff suppressed because one or more lines are too long
+14 -1
View File
@@ -21,6 +21,7 @@ SPDX-FileCopyrightText: Jordan Harband <ljharb@gmail.com>
SPDX-FileCopyrightText: Joyent SPDX-FileCopyrightText: Joyent
SPDX-FileCopyrightText: Nathan Rajlich <nathan@tootallnate.net> (http://n8.io/) SPDX-FileCopyrightText: Nathan Rajlich <nathan@tootallnate.net> (http://n8.io/)
SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors
SPDX-FileCopyrightText: Perry Mitchell <perry@perrymitchell.net>
SPDX-FileCopyrightText: Raynos <raynos2@gmail.com> SPDX-FileCopyrightText: Raynos <raynos2@gmail.com>
SPDX-FileCopyrightText: Sindre Sorhus SPDX-FileCopyrightText: Sindre Sorhus
SPDX-FileCopyrightText: inherits developers SPDX-FileCopyrightText: inherits developers
@@ -53,6 +54,15 @@ This file is generated from multiple sources. Included packages:
- @nextcloud/logger - @nextcloud/logger
- version: 3.0.3 - version: 3.0.3
- license: GPL-3.0-or-later - 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 - available-typed-arrays
- version: 1.0.7 - version: 1.0.7
- license: MIT - license: MIT
@@ -138,7 +148,7 @@ This file is generated from multiple sources. Included packages:
- version: 1.1.15 - version: 1.1.15
- license: MIT - license: MIT
- korean-comments-action - korean-comments-action
- version: 1.0.2 - version: 1.1.0
- license: undefined - license: undefined
- math-intrinsics - math-intrinsics
- version: 1.1.0 - version: 1.1.0
@@ -185,6 +195,9 @@ This file is generated from multiple sources. Included packages:
- vite-plugin-node-polyfills - vite-plugin-node-polyfills
- version: 0.28.0 - version: 0.28.0
- license: MIT - license: MIT
- webdav
- version: 5.10.0
- license: MIT
- which-typed-array - which-typed-array
- version: 1.1.24 - version: 1.1.24
- license: MIT - license: MIT
File diff suppressed because one or more lines are too long
+1
View File
@@ -17,6 +17,7 @@ final class LoadFilesScript implements IEventListener {
return; return;
} }
Util::addInitScript('comments', 'comments-comments-app');
Util::addInitScript(Application::APP_ID, Application::APP_ID . '-main'); Util::addInitScript(Application::APP_ID, Application::APP_ID . '-main');
} }
} }
+3 -2
View File
@@ -1,13 +1,14 @@
{ {
"name": "korean-comments-action", "name": "korean-comments-action",
"version": "1.0.2", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "korean-comments-action", "name": "korean-comments-action",
"version": "1.0.2", "version": "1.1.0",
"dependencies": { "dependencies": {
"@nextcloud/event-bus": "3.3.3",
"@nextcloud/files": "4.0.0", "@nextcloud/files": "4.0.0",
"@nextcloud/initial-state": "3.0.0" "@nextcloud/initial-state": "3.0.0"
}, },
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "korean-comments-action", "name": "korean-comments-action",
"version": "1.0.2", "version": "1.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -9,6 +9,7 @@
"test": "python3 tests/check_php_asset.py && vitest run" "test": "python3 tests/check_php_asset.py && vitest run"
}, },
"dependencies": { "dependencies": {
"@nextcloud/event-bus": "3.3.3",
"@nextcloud/files": "4.0.0", "@nextcloud/files": "4.0.0",
"@nextcloud/initial-state": "3.0.0" "@nextcloud/initial-state": "3.0.0"
}, },
+289 -33
View File
@@ -1,64 +1,296 @@
import type { IFileAction } from '@nextcloud/files' import type { IFileAction, IFolder, INode, IView } from '@nextcloud/files'
import { getSidebar, Permission, registerFileAction } 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' 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] type ActionContext = Parameters<NonNullable<IFileAction['enabled']>>[0]
type CommentsViewInstance = {
$unmount(): void
update(resourceId: string | number): void | Promise<void>
}
type CommentsViewConstructor = new (
resourceType?: string,
options?: {
el?: HTMLElement
props?: Record<string, unknown>
},
) => CommentsViewInstance
function commentsTab(context: ActionContext): 'activity' | 'comments' | undefined { type CommentsWindow = Window & {
const [node] = context.nodes OCA?: {
if (!node) { Comments?: {
return undefined View?: CommentsViewConstructor
}
}
} }
const tabs = getSidebar().getTabs({ function commentsAppAvailable(): boolean {
node, return loadState<boolean | null>('comments', 'activityEnabled', null) !== null
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
} }
export const action: IFileAction = { function commentCount(context: ActionContext): number {
id: 'korean-comments-action', const count = context.nodes[0]?.attributes?.['comments-count']
displayName: () => '댓글', if (typeof count === 'number') {
title: () => '댓글', return count
iconSvgInline: () => COMMENT_ICON, }
enabled(context) { 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 const [node] = context.nodes
return context.nodes.length === 1 return context.nodes.length === 1
&& node !== undefined && node !== undefined
&& node.root.startsWith('/files/') && node.root.startsWith('/files/')
&& node.permissions !== Permission.NONE && node.permissions !== Permission.NONE
&& node.fileid !== undefined
&& getSidebar().available && getSidebar().available
&& commentsTab(context) !== undefined && 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: (context) => {
const count = commentCount(context)
return count > 0 ? `댓글 ${count}개` : '댓글 작성'
},
iconSvgInline: (context) => commentCount(context) > 0 ? HAS_COMMENTS_ICON : EMPTY_COMMENT_ICON,
enabled(context) {
return isEligibleContext(context)
&& (!nativeUnreadActionAdopted || unreadCommentCount(context) === 0)
}, },
async exec(context) { async exec(context) {
const [node] = context.nodes const [node] = context.nodes
const tabId = commentsTab(context) if (!node || !isEligibleContext(context)) {
if (!node || !tabId) {
return false return false
} }
try { try {
const sidebar = getSidebar() const sidebar = getSidebar()
if (sidebar.isOpen && sidebar.node?.source === node.source) { if (sidebar.isOpen && sidebar.node?.source === node.source) {
sidebar.setActiveTab(tabId) sidebar.setActiveTab(COMMENTS_TAB_ID)
} else { } else {
sidebar.open(node, tabId) sidebar.open(node, COMMENTS_TAB_ID)
} }
return null return null
} catch (error) { } catch (error) {
console.error('Could not open the comments sidebar', error) console.error('Could not open the comments-only sidebar', error)
return false return false
} }
}, },
@@ -66,4 +298,28 @@ export const action: IFileAction = {
order: -45, 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) registerFileAction(action)
+2
View File
@@ -3,8 +3,10 @@ from pathlib import Path
root = Path(__file__).resolve().parents[1] root = Path(__file__).resolve().parents[1]
listener = (root / "lib/Listener/LoadFilesScript.php").read_text() listener = (root / "lib/Listener/LoadFilesScript.php").read_text()
expected_call = "Util::addInitScript(Application::APP_ID, Application::APP_ID . '-main');" 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 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" assert (root / "js/korean_comments_action-main.mjs").is_file(), "built module is missing"
print("php asset link ok") print("php asset link ok")
+320 -22
View File
@@ -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(() => ({ 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(), 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(), registerFileAction: vi.fn(),
registerSidebarTab: vi.fn(),
loadState: vi.fn(), loadState: vi.fn(),
open: vi.fn(), open: vi.fn(),
setActiveTab: vi.fn(), setActiveTab: vi.fn(),
getTabs: vi.fn(), }
})) })
vi.mock('@nextcloud/files', () => ({ vi.mock('@nextcloud/files', () => ({
getFileActions: mocks.getFileActions,
getFilesRegistry: mocks.getFilesRegistry,
getSidebar: mocks.getSidebar, getSidebar: mocks.getSidebar,
registerFileAction: mocks.registerFileAction, registerFileAction: mocks.registerFileAction,
registerSidebarTab: mocks.registerSidebarTab,
Permission: { NONE: 0 }, 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', () => ({ vi.mock('@nextcloud/initial-state', () => ({
loadState: mocks.loadState, loadState: mocks.loadState,
})) }))
import { action } from '../src/main' import { action } from '../src/main'
const COMMENTS_TAB_ID = 'korean-comments-only'
const node = { const node = {
root: '/files/alice', root: '/files/alice',
permissions: 1, 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', source: 'https://cloud.example/remote.php/dav/files/alice/report.pdf',
update: vi.fn(),
} }
const context = { const context = {
nodes: [node], nodes: [node],
@@ -35,59 +78,314 @@ const context = {
beforeEach(() => { beforeEach(() => {
mocks.open.mockReset() 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.setActiveTab.mockReset()
mocks.getTabs.mockReset()
mocks.loadState.mockReset() mocks.loadState.mockReset()
mocks.loadState.mockReturnValue(true) mocks.loadState.mockReturnValue(true)
mocks.getTabs.mockReturnValue([{ id: 'activity' }])
mocks.getSidebar.mockReturnValue({ mocks.getSidebar.mockReturnValue({
available: true, available: true,
isOpen: false, isOpen: false,
activeTab: undefined,
node: undefined, node: undefined,
open: mocks.open, open: mocks.open,
setActiveTab: mocks.setActiveTab, setActiveTab: mocks.setActiveTab,
getTabs: mocks.getTabs,
}) })
}) })
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
describe('Korean comments file action', () => { 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(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) expect(action.inline?.(context as never)).toBe(true)
}) })
it('opens the integrated Activity tab', async () => { it('shows a square with dots when the file has comments', () => {
expect(action.enabled?.(context as never)).toBe(true) const withComments = {
expect(await action.exec(context as never)).toBeNull() ...context,
expect(mocks.open).toHaveBeenCalledWith(node, 'activity') 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({ mocks.getSidebar.mockReturnValue({
available: true, available: true,
isOpen: true, isOpen: true,
activeTab: COMMENTS_TAB_ID,
node, node,
open: mocks.open, open: mocks.open,
setActiveTab: mocks.setActiveTab, setActiveTab: mocks.setActiveTab,
getTabs: mocks.getTabs,
}) })
expect(await action.exec(context as never)).toBeNull() 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() expect(mocks.open).not.toHaveBeenCalled()
}) })
it('falls back to the standalone Comments tab', async () => { it('uses the comments-only tab even when Activity integration is disabled', async () => {
mocks.getTabs.mockReturnValue([{ id: 'comments' }]) mocks.loadState.mockReturnValue(false)
expect(action.enabled?.(context as never)).toBe(true)
expect(await action.exec(context as never)).toBeNull() 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', () => { it('hides when the Comments app is unavailable', () => {
mocks.loadState.mockReturnValue(false) mocks.loadState.mockReturnValue(null)
mocks.getTabs.mockReturnValue([{ id: 'activity' }])
expect(action.enabled?.(context as never)).toBe(false) expect(action.enabled?.(context as never)).toBe(false)
}) })