mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-09 00:48:54 -05:00
feat: Custom Seek Indicator Component for showing visual feedback during arrow key seeking
This commit is contained in:
parent
20e6a38fc8
commit
d70b71228a
@ -515,3 +515,124 @@
|
|||||||
.video-js .vjs-control-bar .vjs-control {
|
.video-js .vjs-control-bar .vjs-control {
|
||||||
/* Natural flex flow */
|
/* Natural flex flow */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Seek Indicator Styles */
|
||||||
|
.vjs-seek-indicator {
|
||||||
|
position: absolute !important;
|
||||||
|
top: 50% !important;
|
||||||
|
left: 50% !important;
|
||||||
|
transform: translate(-50%, -50%) !important;
|
||||||
|
z-index: 9999 !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
display: none !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
transition: opacity 0.2s ease-in-out !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vjs-seek-indicator-content {
|
||||||
|
background: transparent !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vjs-seek-indicator-icon {
|
||||||
|
position: relative !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
margin-bottom: 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seek-icon-container {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
animation: seekPulse 0.3s ease-out !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* YouTube-style seek indicator */
|
||||||
|
.youtube-seek-container {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
animation: youtubeSeekPulse 0.3s ease-out !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-seek-circle {
|
||||||
|
width: 80px !important;
|
||||||
|
height: 80px !important;
|
||||||
|
border-radius: 50% !important;
|
||||||
|
-webkit-border-radius: 50% !important;
|
||||||
|
-moz-border-radius: 50% !important;
|
||||||
|
background: rgba(0, 0, 0, 0.8) !important;
|
||||||
|
backdrop-filter: blur(10px) !important;
|
||||||
|
-webkit-backdrop-filter: blur(10px) !important;
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15) !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-seek-icon {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
margin-bottom: 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-seek-icon svg {
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-seek-time {
|
||||||
|
color: white !important;
|
||||||
|
font-size: 10px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
text-align: center !important;
|
||||||
|
line-height: 1.2 !important;
|
||||||
|
opacity: 0.9 !important;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes youtubeSeekPulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.7);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.seek-seconds {
|
||||||
|
color: white !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
font-weight: bold !important;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.7) !important;
|
||||||
|
line-height: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove old animation - replaced with youtubeSeekPulse */
|
||||||
|
|
||||||
|
.vjs-seek-indicator-text {
|
||||||
|
color: white !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
text-align: center !important;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8) !important;
|
||||||
|
}
|
||||||
|
|||||||
179
frontend-tools/video-js/src/components/controls/SeekIndicator.js
Normal file
179
frontend-tools/video-js/src/components/controls/SeekIndicator.js
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import videojs from 'video.js';
|
||||||
|
|
||||||
|
const Component = videojs.getComponent('Component');
|
||||||
|
|
||||||
|
// Custom Seek Indicator Component for showing visual feedback during arrow key seeking
|
||||||
|
class SeekIndicator extends Component {
|
||||||
|
constructor(player, options) {
|
||||||
|
super(player, options);
|
||||||
|
this.seekAmount = options.seekAmount || 5; // Default seek amount in seconds
|
||||||
|
this.showTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
createEl() {
|
||||||
|
const el = super.createEl('div', {
|
||||||
|
className: 'vjs-seek-indicator',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the indicator content
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="vjs-seek-indicator-content">
|
||||||
|
<div class="vjs-seek-indicator-icon"></div>
|
||||||
|
<div class="vjs-seek-indicator-text"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Initially hide the indicator completely
|
||||||
|
el.style.display = 'none';
|
||||||
|
el.style.opacity = '0';
|
||||||
|
el.style.visibility = 'hidden';
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show seek indicator with direction and amount
|
||||||
|
* @param {string} direction - 'forward' or 'backward'
|
||||||
|
* @param {number} seconds - Number of seconds to seek
|
||||||
|
*/
|
||||||
|
show(direction, seconds = this.seekAmount) {
|
||||||
|
const el = this.el();
|
||||||
|
const iconEl = el.querySelector('.vjs-seek-indicator-icon');
|
||||||
|
const textEl = el.querySelector('.vjs-seek-indicator-text');
|
||||||
|
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (this.showTimeout) {
|
||||||
|
clearTimeout(this.showTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set content based on direction - YouTube-style circular design
|
||||||
|
if (direction === 'forward') {
|
||||||
|
iconEl.innerHTML = `
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; animation: youtubeSeekPulse 0.3s ease-out;">
|
||||||
|
<div style="
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-border-radius: 50%;
|
||||||
|
-moz-border-radius: 50%;
|
||||||
|
">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; margin-bottom: 4px;">
|
||||||
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="white" style="filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));">
|
||||||
|
<path d="M8 5v14l11-7z"/>
|
||||||
|
<path d="M13 5v14l11-7z" opacity="0.6"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div style="
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
">${seconds} seconds</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
iconEl.innerHTML = `
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; animation: youtubeSeekPulse 0.3s ease-out;">
|
||||||
|
<div style="
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-border-radius: 50%;
|
||||||
|
-moz-border-radius: 50%;
|
||||||
|
">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; margin-bottom: 4px;">
|
||||||
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="white" style="filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));">
|
||||||
|
<path d="M16 19V5l-11 7z"/>
|
||||||
|
<path d="M11 19V5L0 12z" opacity="0.6"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div style="
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
">${seconds} seconds</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any text content in the text element
|
||||||
|
textEl.textContent = '';
|
||||||
|
|
||||||
|
// Force show the element with YouTube-style positioning
|
||||||
|
el.style.cssText = `
|
||||||
|
position: absolute !important;
|
||||||
|
top: 50% !important;
|
||||||
|
left: 50% !important;
|
||||||
|
transform: translate(-50%, -50%) !important;
|
||||||
|
z-index: 99999 !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Auto-hide after 1 second
|
||||||
|
this.showTimeout = setTimeout(() => {
|
||||||
|
this.hide();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the seek indicator
|
||||||
|
*/
|
||||||
|
hide() {
|
||||||
|
const el = this.el();
|
||||||
|
el.style.opacity = '0';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
el.style.display = 'none';
|
||||||
|
el.style.visibility = 'hidden';
|
||||||
|
}, 200); // Wait for fade out animation
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up when component is disposed
|
||||||
|
*/
|
||||||
|
dispose() {
|
||||||
|
if (this.showTimeout) {
|
||||||
|
clearTimeout(this.showTimeout);
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the component with Video.js
|
||||||
|
videojs.registerComponent('SeekIndicator', SeekIndicator);
|
||||||
|
|
||||||
|
export default SeekIndicator;
|
||||||
@ -9,12 +9,14 @@ import NextVideoButton from '../controls/NextVideoButton';
|
|||||||
import CustomRemainingTime from '../controls/CustomRemainingTime';
|
import CustomRemainingTime from '../controls/CustomRemainingTime';
|
||||||
import CustomChaptersOverlay from '../controls/CustomChaptersOverlay';
|
import CustomChaptersOverlay from '../controls/CustomChaptersOverlay';
|
||||||
import CustomSettingsMenu from '../controls/CustomSettingsMenu';
|
import CustomSettingsMenu from '../controls/CustomSettingsMenu';
|
||||||
|
import SeekIndicator from '../controls/SeekIndicator';
|
||||||
import UserPreferences from '../../utils/UserPreferences';
|
import UserPreferences from '../../utils/UserPreferences';
|
||||||
|
|
||||||
function VideoJSPlayer() {
|
function VideoJSPlayer() {
|
||||||
const videoRef = useRef(null);
|
const videoRef = useRef(null);
|
||||||
const playerRef = useRef(null); // Track the player instance
|
const playerRef = useRef(null); // Track the player instance
|
||||||
const userPreferences = useRef(new UserPreferences()); // User preferences instance
|
const userPreferences = useRef(new UserPreferences()); // User preferences instance
|
||||||
|
const customComponents = useRef({}); // Store custom components for cleanup
|
||||||
|
|
||||||
// Safely access window.MEDIA_DATA with fallback using useMemo
|
// Safely access window.MEDIA_DATA with fallback using useMemo
|
||||||
const mediaData = useMemo(
|
const mediaData = useMemo(
|
||||||
@ -809,6 +811,15 @@ function VideoJSPlayer() {
|
|||||||
playPauseKey: function (event) {
|
playPauseKey: function (event) {
|
||||||
return event.which === 75 || event.which === 32; // 'k' or Space
|
return event.which === 75 || event.which === 32; // 'k' or Space
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Custom seek functions for arrow keys
|
||||||
|
seekForwardKey: function (event) {
|
||||||
|
return event.which === 39; // Right arrow key
|
||||||
|
},
|
||||||
|
|
||||||
|
seekBackwardKey: function (event) {
|
||||||
|
return event.which === 37; // Left arrow key
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1206,12 +1217,9 @@ function VideoJSPlayer() {
|
|||||||
}
|
}
|
||||||
// END: Move chapters button after fullscreen toggle
|
// END: Move chapters button after fullscreen toggle
|
||||||
|
|
||||||
// Store custom components for potential future use (cleanup, method access, etc.)
|
|
||||||
const customComponents = {};
|
|
||||||
|
|
||||||
// BEGIN: Add Chapters Overlay Component
|
// BEGIN: Add Chapters Overlay Component
|
||||||
if (chaptersData && chaptersData.length > 0) {
|
if (chaptersData && chaptersData.length > 0) {
|
||||||
customComponents.chaptersOverlay = new CustomChaptersOverlay(playerRef.current, {
|
customComponents.current.chaptersOverlay = new CustomChaptersOverlay(playerRef.current, {
|
||||||
chaptersData: chaptersData,
|
chaptersData: chaptersData,
|
||||||
});
|
});
|
||||||
console.log('✓ Custom chapters overlay component created');
|
console.log('✓ Custom chapters overlay component created');
|
||||||
@ -1221,19 +1229,111 @@ function VideoJSPlayer() {
|
|||||||
// END: Add Chapters Overlay Component
|
// END: Add Chapters Overlay Component
|
||||||
|
|
||||||
// BEGIN: Add Settings Menu Component
|
// BEGIN: Add Settings Menu Component
|
||||||
customComponents.settingsMenu = new CustomSettingsMenu(playerRef.current, {
|
customComponents.current.settingsMenu = new CustomSettingsMenu(playerRef.current, {
|
||||||
userPreferences: userPreferences.current,
|
userPreferences: userPreferences.current,
|
||||||
});
|
});
|
||||||
console.log('✓ Custom settings menu component created');
|
console.log('✓ Custom settings menu component created');
|
||||||
// END: Add Settings Menu Component
|
// END: Add Settings Menu Component
|
||||||
|
|
||||||
|
// BEGIN: Add Seek Indicator Component
|
||||||
|
customComponents.current.seekIndicator = new SeekIndicator(playerRef.current, {
|
||||||
|
seekAmount: 5, // 5 seconds seek amount
|
||||||
|
});
|
||||||
|
// Add the component but ensure it's hidden initially
|
||||||
|
playerRef.current.addChild(customComponents.current.seekIndicator);
|
||||||
|
|
||||||
|
// Log the element to verify it exists
|
||||||
|
console.log('✓ Custom seek indicator component created');
|
||||||
|
console.log('Seek indicator element:', customComponents.current.seekIndicator.el());
|
||||||
|
console.log('Player element:', playerRef.current.el());
|
||||||
|
|
||||||
|
customComponents.current.seekIndicator.hide(); // Explicitly hide on creation
|
||||||
|
console.log('✓ Seek indicator hidden after creation');
|
||||||
|
// END: Add Seek Indicator Component
|
||||||
|
|
||||||
// Store components reference for potential cleanup
|
// Store components reference for potential cleanup
|
||||||
console.log('Custom components initialized:', Object.keys(customComponents));
|
console.log('Custom components initialized:', Object.keys(customComponents.current));
|
||||||
|
|
||||||
|
// BEGIN: Add custom arrow key seek functionality
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
// Only handle if the player has focus or no input elements are focused
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
const isInputFocused =
|
||||||
|
activeElement &&
|
||||||
|
(activeElement.tagName === 'INPUT' ||
|
||||||
|
activeElement.tagName === 'TEXTAREA' ||
|
||||||
|
activeElement.contentEditable === 'true');
|
||||||
|
|
||||||
|
if (isInputFocused) {
|
||||||
|
return; // Don't interfere with input fields
|
||||||
|
}
|
||||||
|
|
||||||
|
const seekAmount = 5; // 5 seconds
|
||||||
|
|
||||||
|
if (event.key === 'ArrowRight' || event.keyCode === 39) {
|
||||||
|
event.preventDefault();
|
||||||
|
const currentTime = playerRef.current.currentTime();
|
||||||
|
const duration = playerRef.current.duration();
|
||||||
|
const newTime = Math.min(currentTime + seekAmount, duration);
|
||||||
|
|
||||||
|
playerRef.current.currentTime(newTime);
|
||||||
|
customComponents.current.seekIndicator.show('forward', seekAmount);
|
||||||
|
} else if (event.key === 'ArrowLeft' || event.keyCode === 37) {
|
||||||
|
event.preventDefault();
|
||||||
|
const currentTime = playerRef.current.currentTime();
|
||||||
|
const newTime = Math.max(currentTime - seekAmount, 0);
|
||||||
|
|
||||||
|
playerRef.current.currentTime(newTime);
|
||||||
|
customComponents.current.seekIndicator.show('backward', seekAmount);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add keyboard event listener to the document
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
// Store cleanup function
|
||||||
|
customComponents.current.cleanupKeyboardHandler = () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('✓ Arrow key seek functionality enabled');
|
||||||
|
// END: Add custom arrow key seek functionality
|
||||||
|
|
||||||
// Log current user preferences
|
// Log current user preferences
|
||||||
console.log('Current user preferences:', userPreferences.current.getPreferences());
|
console.log('Current user preferences:', userPreferences.current.getPreferences());
|
||||||
|
|
||||||
// Add debugging methods to window for testing
|
// Add debugging methods to window for testing
|
||||||
|
window.debugSeek = {
|
||||||
|
testForward: () => {
|
||||||
|
console.log('🧪 Testing seek indicator forward');
|
||||||
|
customComponents.current.seekIndicator.show('forward', 5);
|
||||||
|
},
|
||||||
|
testBackward: () => {
|
||||||
|
console.log('🧪 Testing seek indicator backward');
|
||||||
|
customComponents.current.seekIndicator.show('backward', 5);
|
||||||
|
},
|
||||||
|
testHide: () => {
|
||||||
|
console.log('🧪 Testing seek indicator hide');
|
||||||
|
customComponents.current.seekIndicator.hide();
|
||||||
|
},
|
||||||
|
getElement: () => {
|
||||||
|
return customComponents.current.seekIndicator.el();
|
||||||
|
},
|
||||||
|
getStyles: () => {
|
||||||
|
const el = customComponents.current.seekIndicator.el();
|
||||||
|
return {
|
||||||
|
display: el.style.display,
|
||||||
|
visibility: el.style.visibility,
|
||||||
|
opacity: el.style.opacity,
|
||||||
|
position: el.style.position,
|
||||||
|
zIndex: el.style.zIndex,
|
||||||
|
top: el.style.top,
|
||||||
|
left: el.style.left,
|
||||||
|
cssText: el.style.cssText,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
window.debugSubtitles = {
|
window.debugSubtitles = {
|
||||||
showTracks: () => {
|
showTracks: () => {
|
||||||
const textTracks = playerRef.current.textTracks();
|
const textTracks = playerRef.current.textTracks();
|
||||||
@ -1555,6 +1655,11 @@ function VideoJSPlayer() {
|
|||||||
|
|
||||||
// Cleanup function
|
// Cleanup function
|
||||||
return () => {
|
return () => {
|
||||||
|
// Clean up keyboard event listener if it exists
|
||||||
|
if (customComponents.current && customComponents.current.cleanupKeyboardHandler) {
|
||||||
|
customComponents.current.cleanupKeyboardHandler();
|
||||||
|
}
|
||||||
|
|
||||||
if (playerRef.current && !playerRef.current.isDisposed()) {
|
if (playerRef.current && !playerRef.current.isDisposed()) {
|
||||||
playerRef.current.dispose();
|
playerRef.current.dispose();
|
||||||
playerRef.current = null;
|
playerRef.current = null;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user