You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Plugins: Add support for getting plugin settings from a Markdown renderer
This commit is contained in:
		| @@ -206,7 +206,7 @@ describe('services_PluginService', () => { | ||||
|  | ||||
| 		const mdToHtml = new MdToHtml(); | ||||
| 		const module = require(contentScript.path).default; | ||||
| 		mdToHtml.loadExtraRendererRule(contentScript.id, tempDir, module({})); | ||||
| 		mdToHtml.loadExtraRendererRule(contentScript.id, tempDir, module({}), ''); | ||||
|  | ||||
| 		const result = await mdToHtml.render([ | ||||
| 			'```justtesting', | ||||
|   | ||||
| @@ -48,6 +48,7 @@ import ItemChange from '@joplin/lib/models/ItemChange'; | ||||
| import PlainEditor from './NoteBody/PlainEditor/PlainEditor'; | ||||
| import CodeMirror6 from './NoteBody/CodeMirror/v6/CodeMirror'; | ||||
| import CodeMirror5 from './NoteBody/CodeMirror/v5/CodeMirror'; | ||||
| import { namespacedKey } from '@joplin/lib/services/plugins/api/JoplinSettings'; | ||||
|  | ||||
| const commands = [ | ||||
| 	require('./commands/showRevisions'), | ||||
| @@ -159,10 +160,15 @@ function NoteEditor(props: NoteEditorProps) { | ||||
| 		return formNote.saveActionQueue.waitForAllDone(); | ||||
| 	} | ||||
|  | ||||
| 	const settingValue = useCallback((pluginId: string, key: string) => { | ||||
| 		return Setting.value(namespacedKey(pluginId, key)); | ||||
| 	}, []); | ||||
|  | ||||
| 	const markupToHtml = useMarkupToHtml({ | ||||
| 		themeId: props.themeId, | ||||
| 		customCss: props.customCss, | ||||
| 		plugins: props.plugins, | ||||
| 		settingValue, | ||||
| 	}); | ||||
|  | ||||
| 	const allAssets = useCallback(async (markupLanguage: number, options: AllAssetsOptions = null): Promise<any[]> => { | ||||
|   | ||||
| @@ -12,6 +12,7 @@ interface HookDependencies { | ||||
| 	themeId: number; | ||||
| 	customCss: string; | ||||
| 	plugins: PluginStates; | ||||
| 	settingValue: (pluginId: string, key: string)=> any; | ||||
| } | ||||
|  | ||||
| export interface MarkupToHtmlOptions { | ||||
| @@ -59,12 +60,16 @@ export default function useMarkupToHtml(deps: HookDependencies) { | ||||
|  | ||||
| 		delete options.replaceResourceInternalToExternalLinks; | ||||
|  | ||||
| 		const result = await markupToHtml.render(markupLanguage, md, theme, { codeTheme: theme.codeThemeCss, | ||||
| 		const result = await markupToHtml.render(markupLanguage, md, theme, { | ||||
| 			codeTheme: theme.codeThemeCss, | ||||
| 			resources: resources, | ||||
| 			postMessageSyntax: 'ipcProxySendToHost', | ||||
| 			splitted: true, | ||||
| 			externalAssetsOnly: true, | ||||
| 			codeHighlightCacheKey: 'useMarkupToHtml', ...options }); | ||||
| 			codeHighlightCacheKey: 'useMarkupToHtml', | ||||
| 			settingValue: deps.settingValue, | ||||
| 			...options, | ||||
| 		}); | ||||
|  | ||||
| 		return result; | ||||
| 		// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied | ||||
|   | ||||
| @@ -5,8 +5,8 @@ | ||||
|  | ||||
| SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" | ||||
| TEMP_PATH=~/src/plugin-tests | ||||
| NEED_COMPILING=0 | ||||
| PLUGIN_PATH=~/src/joplin/packages/app-cli/tests/support/plugins/simple | ||||
| NEED_COMPILING=1 | ||||
| PLUGIN_PATH=~/src/plugin-abc | ||||
|  | ||||
| if [[ $NEED_COMPILING == 1 ]]; then | ||||
| 	mkdir -p "$TEMP_PATH" | ||||
|   | ||||
| @@ -7,6 +7,7 @@ export interface ChangeEvent { | ||||
|     keys: string[]; | ||||
| } | ||||
| export type ChangeHandler = (event: ChangeEvent) => void; | ||||
| export declare const namespacedKey: (pluginId: string, key: string) => string; | ||||
| /** | ||||
|  * This API allows registering new settings and setting sections, as well as getting and setting settings. Once a setting has been registered it will appear in the config screen and be editable by the user. | ||||
|  * | ||||
| @@ -19,8 +20,6 @@ export type ChangeHandler = (event: ChangeEvent) => void; | ||||
| export default class JoplinSettings { | ||||
|     private plugin_; | ||||
|     constructor(plugin: Plugin); | ||||
|     private get keyPrefix(); | ||||
|     private namespacedKey; | ||||
|     /** | ||||
|      * Registers new settings. | ||||
|      * Note that registering a setting item is dynamic and will be gone next time Joplin starts. | ||||
|   | ||||
| @@ -519,6 +519,20 @@ export interface ContentScriptContext { | ||||
| 	postMessage: PostMessageHandler; | ||||
| } | ||||
|  | ||||
| export interface ContentScriptModuleLoadedEvent { | ||||
| 	userData?: any; | ||||
| } | ||||
|  | ||||
| export interface ContentScriptModule { | ||||
| 	onLoaded?: (event:ContentScriptModuleLoadedEvent) => void; | ||||
| 	plugin: () => any; | ||||
| 	assets?: () => void; | ||||
| } | ||||
|  | ||||
| export interface MarkdownItContentScriptModule extends Omit<ContentScriptModule, 'plugin'> { | ||||
| 	plugin: (markdownIt:any, options:any) => any; | ||||
| } | ||||
|  | ||||
| export enum ContentScriptType { | ||||
| 	/** | ||||
| 	 * Registers a new Markdown-It plugin, which should follow the template | ||||
| @@ -528,7 +542,7 @@ export enum ContentScriptType { | ||||
| 	 * module.exports = { | ||||
| 	 *     default: function(context) { | ||||
| 	 *         return { | ||||
| 	 *             plugin: function(markdownIt, options) { | ||||
| 	 *             plugin: function(markdownIt, pluginOptions) { | ||||
| 	 *                 // ... | ||||
| 	 *             }, | ||||
| 	 *             assets: { | ||||
| @@ -538,6 +552,7 @@ export enum ContentScriptType { | ||||
| 	 *     } | ||||
| 	 * } | ||||
| 	 * ``` | ||||
| 	 *  | ||||
| 	 * See [the | ||||
| 	 * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script) | ||||
| 	 * for a simple Markdown-it plugin example. | ||||
| @@ -550,10 +565,7 @@ export enum ContentScriptType { | ||||
| 	 * | ||||
| 	 * - The **required** `plugin` key is the actual Markdown-It plugin - check | ||||
| 	 *   the [official doc](https://github.com/markdown-it/markdown-it) for more | ||||
| 	 *   information. The `options` parameter is of type | ||||
| 	 *   [RuleOptions](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml.ts), | ||||
| 	 *   which contains a number of options, mostly useful for Joplin's internal | ||||
| 	 *   code. | ||||
| 	 *   information. | ||||
| 	 * | ||||
| 	 * - Using the **optional** `assets` key you may specify assets such as JS | ||||
| 	 *   or CSS that should be loaded in the rendered HTML document. Check for | ||||
| @@ -561,6 +573,11 @@ export enum ContentScriptType { | ||||
| 	 *   plugin](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml/rules/mermaid.ts) | ||||
| 	 *   to see how the data should be structured. | ||||
| 	 * | ||||
| 	 * ## Getting the settings from the renderer | ||||
| 	 * | ||||
| 	 * You can access your plugin settings from the renderer by calling | ||||
| 	 * `pluginOptions.settingValue("your-setting-key')`. | ||||
| 	 * | ||||
| 	 * ## Posting messages from the content script to your plugin | ||||
| 	 * | ||||
| 	 * The application provides the following function to allow executing | ||||
|   | ||||
| @@ -70,6 +70,17 @@ export interface ChangeEvent { | ||||
|  | ||||
| export type ChangeHandler = (event: ChangeEvent)=> void; | ||||
|  | ||||
| const keyPrefix = (pluginId: string): string => { | ||||
| 	return `plugin-${pluginId}.`; | ||||
| }; | ||||
|  | ||||
| // Ensures that the plugin settings and sections are within their own namespace, | ||||
| // to prevent them from overwriting other plugin settings or the default | ||||
| // settings. | ||||
| export const namespacedKey = (pluginId: string, key: string): string => { | ||||
| 	return `${keyPrefix(pluginId)}${key}`; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * This API allows registering new settings and setting sections, as well as getting and setting settings. Once a setting has been registered it will appear in the config screen and be editable by the user. | ||||
|  * | ||||
| @@ -86,16 +97,6 @@ export default class JoplinSettings { | ||||
| 		this.plugin_ = plugin; | ||||
| 	} | ||||
|  | ||||
| 	private get keyPrefix(): string { | ||||
| 		return `plugin-${this.plugin_.id}.`; | ||||
| 	} | ||||
|  | ||||
| 	// Ensures that the plugin settings and sections are within their own namespace, to prevent them from | ||||
| 	// overwriting other plugin settings or the default settings. | ||||
| 	private namespacedKey(key: string): string { | ||||
| 		return `${this.keyPrefix}${key}`; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Registers new settings. | ||||
| 	 * Note that registering a setting item is dynamic and will be gone next time Joplin starts. | ||||
| @@ -116,7 +117,7 @@ export default class JoplinSettings { | ||||
|  | ||||
| 			if ('subType' in setting) internalSettingItem.subType = setting.subType; | ||||
| 			if ('isEnum' in setting) internalSettingItem.isEnum = setting.isEnum; | ||||
| 			if ('section' in setting) internalSettingItem.section = this.namespacedKey(setting.section); | ||||
| 			if ('section' in setting) internalSettingItem.section = namespacedKey(this.plugin_.id, setting.section); | ||||
| 			if ('options' in setting) internalSettingItem.options = () => setting.options; | ||||
| 			if ('appTypes' in setting) internalSettingItem.appTypes = setting.appTypes; | ||||
| 			if ('secure' in setting) internalSettingItem.secure = setting.secure; | ||||
| @@ -126,7 +127,7 @@ export default class JoplinSettings { | ||||
| 			if ('step' in setting) internalSettingItem.step = setting.step; | ||||
| 			if ('storage' in setting) internalSettingItem.storage = setting.storage; | ||||
|  | ||||
| 			await Setting.registerSetting(this.namespacedKey(key), internalSettingItem); | ||||
| 			await Setting.registerSetting(namespacedKey(this.plugin_.id, key), internalSettingItem); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| @@ -150,21 +151,21 @@ export default class JoplinSettings { | ||||
| 	 * Registers a new setting section. Like for registerSetting, it is dynamic and needs to be done every time the plugin starts. | ||||
| 	 */ | ||||
| 	public async registerSection(name: string, section: SettingSection) { | ||||
| 		return Setting.registerSection(this.namespacedKey(name), SettingSectionSource.Plugin, section); | ||||
| 		return Setting.registerSection(namespacedKey(this.plugin_.id, name), SettingSectionSource.Plugin, section); | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Gets a setting value (only applies to setting you registered from your plugin) | ||||
| 	 */ | ||||
| 	public async value(key: string): Promise<any> { | ||||
| 		return Setting.value(this.namespacedKey(key)); | ||||
| 		return Setting.value(namespacedKey(this.plugin_.id, key)); | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Sets a setting value (only applies to setting you registered from your plugin) | ||||
| 	 */ | ||||
| 	public async setValue(key: string, value: any) { | ||||
| 		return Setting.setValue(this.namespacedKey(key), value); | ||||
| 		return Setting.setValue(namespacedKey(this.plugin_.id, key), value); | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| @@ -187,8 +188,8 @@ export default class JoplinSettings { | ||||
| 		// Filter out keys that are not related to this plugin | ||||
| 		eventManager.on('settingsChange', (event: ChangeEvent) => { | ||||
| 			const keys = event.keys | ||||
| 				.filter(k => k.indexOf(this.keyPrefix) === 0) | ||||
| 				.map(k => k.substr(this.keyPrefix.length)); | ||||
| 				.filter(k => k.indexOf(keyPrefix(this.plugin_.id)) === 0) | ||||
| 				.map(k => k.substr(keyPrefix(this.plugin_.id).length)); | ||||
| 			if (!keys.length) return; | ||||
| 			handler({ keys }); | ||||
| 		}); | ||||
|   | ||||
| @@ -519,6 +519,20 @@ export interface ContentScriptContext { | ||||
| 	postMessage: PostMessageHandler; | ||||
| } | ||||
|  | ||||
| export interface ContentScriptModuleLoadedEvent { | ||||
| 	userData?: any; | ||||
| } | ||||
|  | ||||
| export interface ContentScriptModule { | ||||
| 	onLoaded?: (event: ContentScriptModuleLoadedEvent)=> void; | ||||
| 	plugin: ()=> any; | ||||
| 	assets?: ()=> void; | ||||
| } | ||||
|  | ||||
| export interface MarkdownItContentScriptModule extends Omit<ContentScriptModule, 'plugin'> { | ||||
| 	plugin: (markdownIt: any, options: any)=> any; | ||||
| } | ||||
|  | ||||
| export enum ContentScriptType { | ||||
| 	/** | ||||
| 	 * Registers a new Markdown-It plugin, which should follow the template | ||||
| @@ -528,7 +542,7 @@ export enum ContentScriptType { | ||||
| 	 * module.exports = { | ||||
| 	 *     default: function(context) { | ||||
| 	 *         return { | ||||
| 	 *             plugin: function(markdownIt, options) { | ||||
| 	 *             plugin: function(markdownIt, pluginOptions) { | ||||
| 	 *                 // ... | ||||
| 	 *             }, | ||||
| 	 *             assets: { | ||||
| @@ -538,6 +552,7 @@ export enum ContentScriptType { | ||||
| 	 *     } | ||||
| 	 * } | ||||
| 	 * ``` | ||||
| 	 * | ||||
| 	 * See [the | ||||
| 	 * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script) | ||||
| 	 * for a simple Markdown-it plugin example. | ||||
| @@ -550,10 +565,7 @@ export enum ContentScriptType { | ||||
| 	 * | ||||
| 	 * - The **required** `plugin` key is the actual Markdown-It plugin - check | ||||
| 	 *   the [official doc](https://github.com/markdown-it/markdown-it) for more | ||||
| 	 *   information. The `options` parameter is of type | ||||
| 	 *   [RuleOptions](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml.ts), | ||||
| 	 *   which contains a number of options, mostly useful for Joplin's internal | ||||
| 	 *   code. | ||||
| 	 *   information. | ||||
| 	 * | ||||
| 	 * - Using the **optional** `assets` key you may specify assets such as JS | ||||
| 	 *   or CSS that should be loaded in the rendered HTML document. Check for | ||||
| @@ -561,6 +573,11 @@ export enum ContentScriptType { | ||||
| 	 *   plugin](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml/rules/mermaid.ts) | ||||
| 	 *   to see how the data should be structured. | ||||
| 	 * | ||||
| 	 * ## Getting the settings from the renderer | ||||
| 	 * | ||||
| 	 * You can access your plugin settings from the renderer by calling | ||||
| 	 * `pluginOptions.settingValue("your-setting-key')`. | ||||
| 	 * | ||||
| 	 * ## Posting messages from the content script to your plugin | ||||
| 	 * | ||||
| 	 * The application provides the following function to allow executing | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { PluginStates } from '../reducer'; | ||||
| import { ContentScriptType, ContentScriptContext, PostMessageHandler } from '../api/types'; | ||||
| import { ContentScriptType, ContentScriptContext, PostMessageHandler, ContentScriptModule } from '../api/types'; | ||||
| import { dirname } from '@joplin/renderer/pathUtils'; | ||||
| import shim from '../../../shim'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| @@ -11,6 +11,7 @@ export interface ExtraContentScript { | ||||
| 	id: string; | ||||
| 	module: any; | ||||
| 	assetPath: string; | ||||
| 	pluginId: string; | ||||
| } | ||||
|  | ||||
| function postMessageHandler(pluginId: string, scriptType: ContentScriptType, contentScriptId: string): PostMessageHandler { | ||||
| @@ -53,14 +54,15 @@ function loadContentScripts(plugins: PluginStates, scriptType: ContentScriptType | ||||
| 					postMessage: postMessageHandler(pluginId, scriptType, contentScript.id), | ||||
| 				}; | ||||
|  | ||||
| 				const loadedModule = module.default(context); | ||||
| 				const loadedModule = module.default(context) as ContentScriptModule; | ||||
|  | ||||
| 				if (!loadedModule.plugin && !loadedModule.codeMirrorResources && !loadedModule.codeMirrorOptions) throw new Error(`Content script must export a "plugin" key or a list of CodeMirror assets or define a CodeMirror option: Plugin: ${pluginId}: Script: ${contentScript.id}`); | ||||
| 				if (!loadedModule.plugin && !(loadedModule as any).codeMirrorResources && !(loadedModule as any).codeMirrorOptions) throw new Error(`Content script must export a "plugin" key or a list of CodeMirror assets or define a CodeMirror option: Plugin: ${pluginId}: Script: ${contentScript.id}`); | ||||
|  | ||||
| 				output.push({ | ||||
| 					id: contentScript.id, | ||||
| 					module: loadedModule, | ||||
| 					assetPath: dirname(contentScript.path), | ||||
| 					pluginId, | ||||
| 				}); | ||||
| 			} catch (error) { | ||||
| 				// This function must not throw as doing so would crash the | ||||
|   | ||||
| @@ -7,10 +7,10 @@ import { ItemIdToUrlHandler } from './utils'; | ||||
| import { RenderResult, RenderResultPluginAsset } from './MarkupToHtml'; | ||||
| import { Options as NoteStyleOptions } from './noteStyle'; | ||||
| import hljs from './highlight'; | ||||
| import * as MarkdownIt from 'markdown-it'; | ||||
|  | ||||
| const Entities = require('html-entities').AllHtmlEntities; | ||||
| const htmlentities = new Entities().encode; | ||||
| const MarkdownIt = require('markdown-it'); | ||||
| const md5 = require('md5'); | ||||
|  | ||||
| export interface RenderOptions { | ||||
| @@ -32,6 +32,7 @@ export interface RenderOptions { | ||||
| 	useCustomPdfViewer?: boolean; | ||||
| 	noteId?: string; | ||||
| 	vendorDir?: string; | ||||
| 	settingValue?: (pluginId: string, key: string)=> any; | ||||
| } | ||||
|  | ||||
| interface RendererRule { | ||||
| @@ -40,6 +41,7 @@ interface RendererRule { | ||||
| 	plugin?: any; | ||||
| 	assetPath?: string; | ||||
| 	assetPathIsAbsolute?: boolean; | ||||
| 	pluginId?: string; | ||||
| } | ||||
|  | ||||
| interface RendererRules { | ||||
| @@ -102,6 +104,7 @@ export interface ExtraRendererRule { | ||||
| 	id: string; | ||||
| 	module: any; | ||||
| 	assetPath: string; | ||||
| 	pluginId: string; | ||||
| } | ||||
|  | ||||
| export interface Options { | ||||
| @@ -233,7 +236,7 @@ export default class MdToHtml { | ||||
|  | ||||
| 		if (options.extraRendererRules) { | ||||
| 			for (const rule of options.extraRendererRules) { | ||||
| 				this.loadExtraRendererRule(rule.id, rule.assetPath, rule.module); | ||||
| 				this.loadExtraRendererRule(rule.id, rule.assetPath, rule.module, rule.pluginId); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| @@ -268,11 +271,13 @@ export default class MdToHtml { | ||||
| 	} | ||||
|  | ||||
| 	// `module` is a file that has already been `required()` | ||||
| 	public loadExtraRendererRule(id: string, assetPath: string, module: any) { | ||||
| 	public loadExtraRendererRule(id: string, assetPath: string, module: any, pluginId: string) { | ||||
| 		if (this.extraRendererRules_[id]) throw new Error(`A renderer rule with this ID has already been loaded: ${id}`); | ||||
|  | ||||
| 		this.extraRendererRules_[id] = { | ||||
| 			...module, | ||||
| 			assetPath, | ||||
| 			pluginId: pluginId, | ||||
| 			assetPathIsAbsolute: true, | ||||
| 		}; | ||||
| 	} | ||||
| @@ -451,6 +456,7 @@ export default class MdToHtml { | ||||
| 			pdfViewerEnabled: this.pluginEnabled('pdfViewer'), | ||||
|  | ||||
| 			contentMaxWidth: 0, | ||||
| 			settingValue: (_pluginId: string, _key: string) => { throw new Error('settingValue is not implemented'); }, | ||||
| 			...options, | ||||
| 		}; | ||||
|  | ||||
| @@ -467,8 +473,11 @@ export default class MdToHtml { | ||||
| 		const cachedOutput = this.cachedOutputs_[cacheKey]; | ||||
| 		if (cachedOutput) return cachedOutput; | ||||
|  | ||||
| 		const ruleOptions = { ...options, resourceBaseUrl: this.resourceBaseUrl_, | ||||
| 			ResourceModel: this.ResourceModel_ }; | ||||
| 		const ruleOptions = { | ||||
| 			...options, | ||||
| 			resourceBaseUrl: this.resourceBaseUrl_, | ||||
| 			ResourceModel: this.ResourceModel_, | ||||
| 		}; | ||||
|  | ||||
| 		const context: PluginContext = { | ||||
| 			css: {}, | ||||
| @@ -478,12 +487,12 @@ export default class MdToHtml { | ||||
| 			currentLinks: [], | ||||
| 		}; | ||||
|  | ||||
| 		const markdownIt = new MarkdownIt({ | ||||
| 		const markdownIt: MarkdownIt = new MarkdownIt({ | ||||
| 			breaks: !this.pluginEnabled('softbreaks'), | ||||
| 			typographer: this.pluginEnabled('typographer'), | ||||
| 			linkify: this.pluginEnabled('linkify'), | ||||
| 			html: true, | ||||
| 			highlight: (str: string, lang: string) => { | ||||
| 			highlight: (str: string, lang: string, _attrs: any): any => { | ||||
| 				let outputCodeHtml = ''; | ||||
|  | ||||
| 				// The strings includes the last \n that is part of the fence, | ||||
| @@ -567,6 +576,9 @@ export default class MdToHtml { | ||||
| 				context: context, | ||||
| 				...ruleOptions, | ||||
| 				...(ruleOptions.plugins[key] ? ruleOptions.plugins[key] : {}), | ||||
| 				settingValue: (key: string) => { | ||||
| 					return options.settingValue(rule.pluginId, key); | ||||
| 				}, | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
|   | ||||
| @@ -20,6 +20,7 @@ | ||||
|   "license": "AGPL-3.0-or-later", | ||||
|   "devDependencies": { | ||||
|     "@types/jest": "29.5.5", | ||||
|     "@types/markdown-it": "13.0.2", | ||||
|     "@types/node": "18.17.19", | ||||
|     "jest": "29.7.0", | ||||
|     "jest-environment-jsdom": "29.7.0", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user