Skip to content

Commit 554e69f

Browse files
authored
feat(mv3): Adding ContextMenus MV3 Style (#1213)
* feat(mv3): ✨ ContextMenus MV3 Style * feat(mv3): 🧪 Adding tests * fix: test case
1 parent ddef473 commit 554e69f

File tree

3 files changed

+208
-6
lines changed

3 files changed

+208
-6
lines changed

add-on/src/lib/context-menus.js

+31-6
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import browser from 'webextension-polyfill'
44

55
import debug from 'debug'
6+
import { ContextMenus } from './context-menus/ContextMenus.js'
67
const log = debug('ipfs-companion:context-menus')
78
log.error = debug('ipfs-companion:context-menus:error')
89

@@ -66,30 +67,54 @@ const apiMenuItemIds = new Set([contextMenuCopyRawCid, contextMenuCopyCanonicalA
6667
const apiMenuItems = new Set()
6768
// menu items enabled only in IPFS context (dynamic)
6869
const ipfsContextItems = new Set()
70+
// listeners for context menu items
71+
const contextMenus = new ContextMenus()
6972

7073
export function createContextMenus (
7174
getState, _runtime, ipfsPathValidator, { onAddFromContext, onCopyRawCid, onCopyAddressAtPublicGw }) {
7275
try {
73-
const createSubmenu = (id, contextType, menuBuilder) => {
74-
browser.contextMenus.onClicked.addListener((...args) => console.log(args))
75-
}
76+
const createSubmenu = (id, contextType) => contextMenus.create({
77+
id,
78+
title: browser.i18n.getMessage(id),
79+
documentUrlPatterns: ['<all_urls>'],
80+
contexts: [contextType]
81+
})
82+
7683
const createImportToIpfsMenuItem = (parentId, id, contextType, ipfsAddOptions) => {
7784
const itemId = `${parentId}_${id}`
7885
apiMenuItems.add(itemId)
86+
contextMenus.create({
87+
id: itemId,
88+
parentId,
89+
title: browser.i18n.getMessage(id),
90+
contexts: [contextType],
91+
documentUrlPatterns: ['<all_urls>'],
92+
enabled: false
93+
}, (context) => onAddFromContext(context, contextType, ipfsAddOptions))
7994
return browser.contextMenus.onClicked.addListener((context) => onAddFromContext(context, contextType, ipfsAddOptions)
8095
)
8196
}
97+
8298
const createCopierMenuItem = (parentId, id, contextType, handler) => {
8399
const itemId = `${parentId}_${id}`
84100
ipfsContextItems.add(itemId)
85101
// some items also require API access
86102
if (apiMenuItemIds.has(id)) {
87103
apiMenuItems.add(itemId)
88104
}
89-
return browser.contextMenus.onClicked.addListener(
90-
(context) => handler(context, contextType)
91-
)
105+
contextMenus.create({
106+
id: itemId,
107+
parentId,
108+
title: browser.i18n.getMessage(id),
109+
contexts: [contextType],
110+
documentUrlPatterns: [
111+
'*://*/ipfs/*', '*://*/ipns/*',
112+
'*://*.ipfs.dweb.link/*', '*://*.ipns.dweb.link/*', // TODO: add any custom public gateway from Preferences
113+
'*://*.ipfs.localhost/*', '*://*.ipns.localhost/*'
114+
]
115+
}, (context) => handler(context, contextType))
92116
}
117+
93118
const buildSubmenu = (parentId, contextType) => {
94119
createSubmenu(parentId, contextType)
95120
createImportToIpfsMenuItem(parentId, contextMenuImportToIpfs, contextType, { wrapWithDirectory: true, pin: false })
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import browser from 'webextension-polyfill'
2+
import debug from 'debug'
3+
4+
type listenerCb = (info: browser.Menus.OnClickData, tab: browser.Tabs.Tab | undefined) => void
5+
6+
/**
7+
* ContextMenus is a wrapper around browser.contextMenus API.
8+
*/
9+
export class ContextMenus {
10+
private readonly contextMenuListeners = new Map<browser.Menus.OnClickData['menuItemId'], listenerCb[]>()
11+
private readonly log: debug.Debugger & { error?: debug.Debugger }
12+
13+
constructor () {
14+
this.log = debug('ipfs-companion:contextMenus')
15+
this.log.error = debug('ipfs-companion:contextMenus:error')
16+
this.contextMenuListeners = new Map()
17+
this.init()
18+
}
19+
20+
/**
21+
* init is called once on extension startup
22+
*/
23+
init (): void {
24+
browser.contextMenus.onClicked.addListener((info, tab) => {
25+
const { menuItemId } = info
26+
if (this.contextMenuListeners.has(menuItemId)) {
27+
this.contextMenuListeners.get(menuItemId)?.forEach(cb => cb(info, tab))
28+
}
29+
})
30+
this.log('ContextMenus Listeners ready')
31+
}
32+
33+
/**
34+
* This method queues the listener function for given menuItemId.
35+
*
36+
* @param menuItemId
37+
* @param cb
38+
*/
39+
queueListener (menuItemId: string, cb: listenerCb): void {
40+
if (this.contextMenuListeners.has(menuItemId)) {
41+
this.contextMenuListeners.get(menuItemId)?.push(cb)
42+
} else {
43+
this.contextMenuListeners.set(menuItemId, [cb])
44+
}
45+
this.log(`ContextMenus Listener queued for ${menuItemId}`)
46+
}
47+
48+
/**
49+
* This method creates a context menu item and maps the listener function to it.
50+
*
51+
* @param options
52+
* @param cb
53+
*/
54+
create (options: browser.Menus.CreateCreatePropertiesType, cb?: listenerCb): void {
55+
try {
56+
browser.contextMenus.create(options)
57+
} catch (err) {
58+
this.log.error?.('ContextMenus.create failed', err)
59+
}
60+
if (cb != null) {
61+
if (options?.id != null) {
62+
this.queueListener(options.id, cb)
63+
} else {
64+
throw new Error('ContextMenus.create callback requires options.id')
65+
}
66+
}
67+
}
68+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { ContextMenus } from '../../../../add-on/src/lib/context-menus/ContextMenus'
2+
import {expect} from 'chai'
3+
import { before, describe, it } from 'mocha'
4+
import browserMock from 'sinon-chrome'
5+
import sinon from 'sinon'
6+
7+
describe('lib/context-menus/ContextMenus', () => {
8+
let sinonSandbox
9+
10+
before(function () {
11+
browserMock.runtime.id = 'testid'
12+
sinonSandbox = sinon.createSandbox()
13+
})
14+
15+
beforeEach(function () {
16+
sinonSandbox.restore()
17+
browserMock.contextMenus.onClicked.addListener.resetHistory()
18+
})
19+
20+
it('initializes and registers global listener', () => {
21+
const contextMenus = new ContextMenus()
22+
expect(contextMenus).to.be.an.instanceOf(ContextMenus)
23+
expect(contextMenus).to.have.property('contextMenuListeners')
24+
expect(contextMenus).to.have.property('log')
25+
expect(contextMenus).to.have.property('init')
26+
expect(contextMenus).to.have.property('queueListener')
27+
expect(contextMenus).to.have.property('create')
28+
expect(browserMock.contextMenus.onClicked.addListener.called).to.be.true
29+
})
30+
31+
it('queues listener and calls that when event is fired.', () => {
32+
const contextMenus = new ContextMenus()
33+
const globalListener = browserMock.contextMenus.onClicked.addListener.firstCall.args[0]
34+
const listenerSpy = sinonSandbox.spy()
35+
contextMenus.queueListener('testIdOne', listenerSpy)
36+
// emulate event
37+
globalListener({ menuItemId: 'testIdOne' })
38+
expect(listenerSpy.called).to.be.true
39+
})
40+
41+
it('should allow adding listener to existing menuItemId', () => {
42+
const contextMenus = new ContextMenus()
43+
const globalListener = browserMock.contextMenus.onClicked.addListener.firstCall.args[0]
44+
const listenerSpyOne = sinonSandbox.spy()
45+
const listenerSpyTwo = sinonSandbox.spy()
46+
contextMenus.queueListener('testIdOne', listenerSpyOne)
47+
contextMenus.queueListener('testIdOne', listenerSpyTwo)
48+
// emulate event
49+
globalListener({ menuItemId: 'testIdOne' })
50+
expect(listenerSpyOne.called).to.be.true
51+
expect(listenerSpyTwo.called).to.be.true
52+
})
53+
54+
it('should allow adding listener on the fly', () => {
55+
const contextMenus = new ContextMenus()
56+
const globalListener = browserMock.contextMenus.onClicked.addListener.firstCall.args[0]
57+
const listenerSpyOne = sinonSandbox.spy()
58+
const listenerSpyTwo = sinonSandbox.spy()
59+
contextMenus.queueListener('testIdOne', listenerSpyOne)
60+
contextMenus.queueListener('testIdTwo', listenerSpyTwo)
61+
// emulate event
62+
globalListener({ menuItemId: 'testIdOne' })
63+
expect(listenerSpyOne.called).to.be.true
64+
globalListener({ menuItemId: 'testIdTwo' })
65+
expect(listenerSpyTwo.called).to.be.true
66+
})
67+
68+
it('should create and queue listener', () => {
69+
const contextMenus = new ContextMenus()
70+
const globalListener = browserMock.contextMenus.onClicked.addListener.firstCall.args[0]
71+
const listenerSpy = sinonSandbox.spy()
72+
contextMenus.create({ id: 'testIdOne' }, listenerSpy)
73+
// emulate event
74+
globalListener({ menuItemId: 'testIdOne' })
75+
expect(listenerSpy.called).to.be.true
76+
})
77+
78+
it('should not create multiple context menu items for the same menuItemId', () => {
79+
const contextMenus = new ContextMenus()
80+
const globalListener = browserMock.contextMenus.onClicked.addListener.firstCall.args[0]
81+
const listenerSpyOne = sinonSandbox.spy()
82+
const listenerSpyTwo = sinonSandbox.spy()
83+
contextMenus.create({ id: 'testIdOne' }, listenerSpyOne)
84+
contextMenus.create({ id: 'testIdOne' }, listenerSpyTwo)
85+
// emulate event
86+
globalListener({ menuItemId: 'testIdOne' })
87+
expect(listenerSpyOne.called).to.be.true
88+
expect(listenerSpyTwo.called).to.be.true
89+
})
90+
91+
it('should create and queue listener for multiple items', () => {
92+
const contextMenus = new ContextMenus()
93+
const globalListener = browserMock.contextMenus.onClicked.addListener.firstCall.args[0]
94+
const listenerSpyOne = sinonSandbox.spy()
95+
const listenerSpyTwo = sinonSandbox.spy()
96+
contextMenus.create({ id: 'testIdOne' }, listenerSpyOne)
97+
contextMenus.create({ id: 'testIdTwo' }, listenerSpyTwo)
98+
// emulate event
99+
globalListener({ menuItemId: 'testIdOne' })
100+
expect(listenerSpyOne.called).to.be.true
101+
globalListener({ menuItemId: 'testIdTwo' })
102+
expect(listenerSpyTwo.called).to.be.true
103+
})
104+
105+
it('should throw error if id is not provided and callback is', () => {
106+
const contextMenus = new ContextMenus()
107+
expect(() => contextMenus.create({ }, () => {})).to.throw()
108+
})
109+
})

0 commit comments

Comments
 (0)