feat: unified navigation
fix: install script copying build binary chore: updated readme
This commit is contained in:
@@ -17,13 +17,6 @@ import (
|
||||
"playback/internal/ui/waveform"
|
||||
)
|
||||
|
||||
// FocusedView represents which view has focus
|
||||
type FocusedView int
|
||||
|
||||
const (
|
||||
FocusWaveform FocusedView = iota
|
||||
FocusTranscript
|
||||
)
|
||||
|
||||
// Model is the main application model
|
||||
type Model struct {
|
||||
@@ -44,7 +37,6 @@ type Model struct {
|
||||
transcript transcript.Model
|
||||
|
||||
// State
|
||||
focused FocusedView
|
||||
showHelp bool
|
||||
width int
|
||||
height int
|
||||
@@ -66,7 +58,6 @@ func New(audioPath, transcriptPath string) Model {
|
||||
header: header.New(),
|
||||
waveform: waveform.New(),
|
||||
transcript: transcript.New(),
|
||||
focused: FocusWaveform,
|
||||
}
|
||||
|
||||
return m
|
||||
@@ -144,7 +135,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.updateLayout()
|
||||
|
||||
case tea.KeyMsg:
|
||||
// Global keys
|
||||
// All shortcuts are now global
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
m.quitting = true
|
||||
@@ -157,38 +148,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case key.Matches(msg, m.keys.PlayPause):
|
||||
m.player.Toggle()
|
||||
|
||||
case key.Matches(msg, m.keys.FocusWaveform):
|
||||
m.focused = FocusWaveform
|
||||
m.waveform.SetFocused(true)
|
||||
m.transcript.SetFocused(false)
|
||||
|
||||
case key.Matches(msg, m.keys.FocusTranscript):
|
||||
m.focused = FocusTranscript
|
||||
m.waveform.SetFocused(false)
|
||||
m.transcript.SetFocused(true)
|
||||
|
||||
case key.Matches(msg, m.keys.EnterEdit):
|
||||
if m.focused == FocusTranscript {
|
||||
m.player.Pause()
|
||||
return m, m.launchEditor()
|
||||
}
|
||||
}
|
||||
m.player.Pause()
|
||||
return m, m.launchEditor()
|
||||
|
||||
// Context-specific keys
|
||||
if m.focused == FocusWaveform {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.SeekForward):
|
||||
m.player.SeekRelative(m.config.SeekStep)
|
||||
case key.Matches(msg, m.keys.SeekBackward):
|
||||
m.player.SeekRelative(-m.config.SeekStep)
|
||||
case key.Matches(msg, m.keys.SeekForwardBig):
|
||||
m.player.SeekRelative(m.config.BigSeekStep)
|
||||
case key.Matches(msg, m.keys.SeekBackwardBig):
|
||||
m.player.SeekRelative(-m.config.BigSeekStep)
|
||||
}
|
||||
}
|
||||
// Seeking shortcuts (previously waveform-only, now global)
|
||||
case key.Matches(msg, m.keys.SeekForward):
|
||||
m.player.SeekRelative(m.config.SeekStep)
|
||||
|
||||
if m.focused == FocusTranscript {
|
||||
case key.Matches(msg, m.keys.SeekBackward):
|
||||
m.player.SeekRelative(-m.config.SeekStep)
|
||||
|
||||
case key.Matches(msg, m.keys.SeekForwardBig):
|
||||
m.player.SeekRelative(m.config.BigSeekStep)
|
||||
|
||||
case key.Matches(msg, m.keys.SeekBackwardBig):
|
||||
m.player.SeekRelative(-m.config.BigSeekStep)
|
||||
|
||||
// Transcript navigation and other keys forward to transcript
|
||||
default:
|
||||
cmd := m.transcript.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
@@ -262,9 +240,6 @@ func (m *Model) updateLayout() {
|
||||
m.header.SetWidth(m.width)
|
||||
m.waveform.SetSize(m.width, waveformHeight)
|
||||
m.transcript.SetSize(m.width, transcriptHeight)
|
||||
|
||||
m.waveform.SetFocused(m.focused == FocusWaveform)
|
||||
m.transcript.SetFocused(m.focused == FocusTranscript)
|
||||
}
|
||||
|
||||
func (m Model) launchEditor() tea.Cmd {
|
||||
@@ -272,7 +247,7 @@ func (m Model) launchEditor() tea.Cmd {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
lineNum := m.transcript.SelectedCueLineNumber()
|
||||
lineNum := m.transcript.ActiveCueLineNumber()
|
||||
c := exec.Command(m.config.Editor, fmt.Sprintf("+%d", lineNum), t.FilePath)
|
||||
return tea.ExecProcess(c, func(err error) tea.Msg {
|
||||
return VimExitedMsg{Path: t.FilePath, Err: err}
|
||||
@@ -330,13 +305,6 @@ func (m Model) renderStatus() string {
|
||||
modeStyle := ui.ModeStyle
|
||||
mode := modeStyle.Render(m.transcript.ModeString())
|
||||
|
||||
// Focus indicator
|
||||
focusStr := "Waveform"
|
||||
if m.focused == FocusTranscript {
|
||||
focusStr = "Transcript"
|
||||
}
|
||||
focus := ui.BaseStyle.Render(fmt.Sprintf("[%s]", focusStr))
|
||||
|
||||
// Status message
|
||||
statusMsg := ui.StatusBarStyle.Render(m.statusMsg)
|
||||
|
||||
@@ -347,10 +315,8 @@ func (m Model) renderStatus() string {
|
||||
lipgloss.Center,
|
||||
mode,
|
||||
" ",
|
||||
focus,
|
||||
" ",
|
||||
statusMsg,
|
||||
lipgloss.NewStyle().Width(m.width-lipgloss.Width(mode)-lipgloss.Width(focus)-lipgloss.Width(statusMsg)-lipgloss.Width(helpHint)-8).Render(""),
|
||||
lipgloss.NewStyle().Width(m.width-lipgloss.Width(mode)-lipgloss.Width(statusMsg)-lipgloss.Width(helpHint)-4).Render(""),
|
||||
helpHint,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,17 +9,11 @@ type KeyMap struct {
|
||||
Help key.Binding
|
||||
PlayPause key.Binding
|
||||
|
||||
// Focus
|
||||
FocusWaveform key.Binding
|
||||
FocusTranscript key.Binding
|
||||
|
||||
// Waveform navigation
|
||||
// Navigation (global)
|
||||
SeekForward key.Binding
|
||||
SeekBackward key.Binding
|
||||
SeekForwardBig key.Binding
|
||||
SeekBackwardBig key.Binding
|
||||
|
||||
// Transcript navigation
|
||||
ScrollUp key.Binding
|
||||
ScrollDown key.Binding
|
||||
PageUp key.Binding
|
||||
@@ -46,14 +40,6 @@ func DefaultKeyMap() KeyMap {
|
||||
key.WithKeys(" "),
|
||||
key.WithHelp("space", "play/pause"),
|
||||
),
|
||||
FocusWaveform: key.NewBinding(
|
||||
key.WithKeys("ctrl+k"),
|
||||
key.WithHelp("ctrl+k", "focus waveform"),
|
||||
),
|
||||
FocusTranscript: key.NewBinding(
|
||||
key.WithKeys("ctrl+j"),
|
||||
key.WithHelp("ctrl+j", "focus transcript"),
|
||||
),
|
||||
SeekForward: key.NewBinding(
|
||||
key.WithKeys("l", "right"),
|
||||
key.WithHelp("l/→", "seek forward"),
|
||||
@@ -72,27 +58,27 @@ func DefaultKeyMap() KeyMap {
|
||||
),
|
||||
ScrollUp: key.NewBinding(
|
||||
key.WithKeys("k", "up"),
|
||||
key.WithHelp("k/↑", "scroll up"),
|
||||
key.WithHelp("k/↑", "previous cue"),
|
||||
),
|
||||
ScrollDown: key.NewBinding(
|
||||
key.WithKeys("j", "down"),
|
||||
key.WithHelp("j/↓", "scroll down"),
|
||||
key.WithHelp("j/↓", "next cue"),
|
||||
),
|
||||
PageUp: key.NewBinding(
|
||||
key.WithKeys("ctrl+u"),
|
||||
key.WithHelp("ctrl+u", "page up"),
|
||||
key.WithHelp("ctrl+u", "jump 5 cues up"),
|
||||
),
|
||||
PageDown: key.NewBinding(
|
||||
key.WithKeys("ctrl+d"),
|
||||
key.WithHelp("ctrl+d", "page down"),
|
||||
key.WithHelp("ctrl+d", "jump 5 cues down"),
|
||||
),
|
||||
GoTop: key.NewBinding(
|
||||
key.WithKeys("g"),
|
||||
key.WithHelp("gg", "go to top"),
|
||||
key.WithHelp("g", "go to first cue"),
|
||||
),
|
||||
GoBottom: key.NewBinding(
|
||||
key.WithKeys("G"),
|
||||
key.WithHelp("G", "go to bottom"),
|
||||
key.WithHelp("G", "go to last cue"),
|
||||
),
|
||||
EnterEdit: key.NewBinding(
|
||||
key.WithKeys("i"),
|
||||
@@ -107,24 +93,21 @@ func (k KeyMap) HelpView() string {
|
||||
|
||||
Global:
|
||||
space Play/Pause
|
||||
ctrl+j Focus transcript
|
||||
ctrl+k Focus waveform
|
||||
q Quit
|
||||
? Toggle help
|
||||
|
||||
Waveform (when focused):
|
||||
Navigation (Global):
|
||||
h / ← Seek backward (5s)
|
||||
l / → Seek forward (5s)
|
||||
H Seek backward (30s)
|
||||
L Seek forward (30s)
|
||||
|
||||
Transcript (when focused):
|
||||
j / ↓ Next cue
|
||||
k / ↑ Previous cue
|
||||
ctrl+d Jump 5 cues down
|
||||
ctrl+u Jump 5 cues up
|
||||
g Go to first cue
|
||||
G Go to last cue
|
||||
enter Seek audio to cue
|
||||
|
||||
Editing:
|
||||
i Edit in $EDITOR at cue`
|
||||
}
|
||||
|
||||
@@ -11,7 +11,13 @@ import (
|
||||
|
||||
// Load loads an SRT file from the given path
|
||||
func Load(path string) (*Transcript, error) {
|
||||
subs, err := astisub.OpenFile(path)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
subs, err := astisub.ReadFromSRT(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse SRT file: %w", err)
|
||||
}
|
||||
|
||||
@@ -56,21 +56,18 @@ var (
|
||||
BorderForeground(ColorPrimary).
|
||||
Padding(0, 1)
|
||||
|
||||
CurrentCueStyle = lipgloss.NewStyle().
|
||||
Background(ColorHighlight).
|
||||
Foreground(ColorSecondary).
|
||||
ActiveCueStyle = lipgloss.NewStyle().
|
||||
Background(ColorSecondary).
|
||||
Foreground(ColorBackground).
|
||||
Bold(true)
|
||||
|
||||
SelectedCueStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorAccent).
|
||||
Bold(true)
|
||||
ActiveTimestampStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorBackground).
|
||||
Bold(true)
|
||||
|
||||
TimestampStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorMuted)
|
||||
|
||||
SelectedTimestampStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorAccent)
|
||||
|
||||
// Status bar styles
|
||||
StatusBarStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorMuted).
|
||||
|
||||
@@ -10,34 +10,28 @@ import (
|
||||
)
|
||||
|
||||
// RenderCue renders a single cue with optional highlighting
|
||||
func RenderCue(cue *srt.Cue, isCurrent, isSelected bool, width int) string {
|
||||
func RenderCue(cue *srt.Cue, isActive bool, width int) string {
|
||||
// Format timestamp
|
||||
timestamp := formatTimestamp(cue.Start, cue.End)
|
||||
|
||||
// Apply styles based on state
|
||||
var textStyle, timestampStyle lipgloss.Style
|
||||
if isSelected {
|
||||
// Selected cue (navigation cursor) - use accent color
|
||||
textStyle = ui.SelectedCueStyle
|
||||
timestampStyle = ui.SelectedTimestampStyle
|
||||
} else if isCurrent {
|
||||
// Current cue (playback position)
|
||||
textStyle = ui.CurrentCueStyle
|
||||
timestampStyle = ui.TimestampStyle
|
||||
var prefix string
|
||||
|
||||
if isActive {
|
||||
// Active cue (playback position + navigation cursor)
|
||||
textStyle = ui.ActiveCueStyle
|
||||
timestampStyle = ui.ActiveTimestampStyle
|
||||
prefix = "> "
|
||||
} else {
|
||||
textStyle = ui.BaseStyle
|
||||
timestampStyle = ui.TimestampStyle
|
||||
prefix = " "
|
||||
}
|
||||
|
||||
timestampStr := timestampStyle.Render(timestamp)
|
||||
textStr := textStyle.Render(cue.Text)
|
||||
|
||||
// Add selection indicator
|
||||
prefix := " "
|
||||
if isSelected {
|
||||
prefix = "> "
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s%s\n%s%s\n", prefix, timestampStr, prefix, textStr)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,14 +18,12 @@ type SeekToCueMsg struct {
|
||||
|
||||
// Model represents the transcript view component
|
||||
type Model struct {
|
||||
viewport viewport.Model
|
||||
transcript *srt.Transcript
|
||||
currentCue int // Cue currently playing (from playback position)
|
||||
selectedCue int // Cue selected by user navigation
|
||||
cueLines []int // Starting line number (in rendered view) for each cue
|
||||
Width int
|
||||
Height int
|
||||
Focused bool
|
||||
viewport viewport.Model
|
||||
transcript *srt.Transcript
|
||||
activeCue int // Cue currently active (playback position + navigation cursor)
|
||||
cueLines []int // Starting line number (in rendered view) for each cue
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
// New creates a new transcript model
|
||||
@@ -34,16 +32,15 @@ func New() Model {
|
||||
vp.Style = lipgloss.NewStyle()
|
||||
|
||||
return Model{
|
||||
viewport: vp,
|
||||
currentCue: -1,
|
||||
selectedCue: 0,
|
||||
viewport: vp,
|
||||
activeCue: -1,
|
||||
}
|
||||
}
|
||||
|
||||
// SetTranscript sets the transcript to display
|
||||
func (m *Model) SetTranscript(t *srt.Transcript) {
|
||||
m.transcript = t
|
||||
m.selectedCue = 0
|
||||
m.activeCue = 0
|
||||
m.updateContent()
|
||||
m.scrollToCue(0)
|
||||
}
|
||||
@@ -53,12 +50,12 @@ func (m *Model) Transcript() *srt.Transcript {
|
||||
return m.transcript
|
||||
}
|
||||
|
||||
// SelectedCueLineNumber returns the line number of the selected cue for vim
|
||||
func (m *Model) SelectedCueLineNumber() int {
|
||||
if m.transcript == nil || m.selectedCue < 0 || m.selectedCue >= len(m.transcript.Cues) {
|
||||
// ActiveCueLineNumber returns the line number of the active cue for vim
|
||||
func (m *Model) ActiveCueLineNumber() int {
|
||||
if m.transcript == nil || m.activeCue < 0 || m.activeCue >= len(m.transcript.Cues) {
|
||||
return 1
|
||||
}
|
||||
return m.transcript.Cues[m.selectedCue].LineNumber
|
||||
return m.transcript.Cues[m.activeCue].LineNumber
|
||||
}
|
||||
|
||||
// SetPosition updates which cue is highlighted based on playback position
|
||||
@@ -68,11 +65,11 @@ func (m *Model) SetPosition(pos time.Duration) {
|
||||
}
|
||||
|
||||
newCue := m.transcript.CueIndexAt(pos)
|
||||
if newCue != m.currentCue {
|
||||
m.currentCue = newCue
|
||||
if newCue != m.activeCue {
|
||||
m.activeCue = newCue
|
||||
m.updateContent()
|
||||
// Only auto-scroll if not focused (let user navigate freely when focused)
|
||||
if !m.Focused && newCue >= 0 {
|
||||
// Always auto-scroll during playback
|
||||
if newCue >= 0 {
|
||||
m.scrollToCue(newCue)
|
||||
}
|
||||
}
|
||||
@@ -87,22 +84,21 @@ func (m *Model) SetSize(width, height int) {
|
||||
m.updateContent()
|
||||
}
|
||||
|
||||
// SetFocused sets the focus state
|
||||
func (m *Model) SetFocused(focused bool) {
|
||||
m.Focused = focused
|
||||
// When focusing, sync selected cue to current playback position if valid
|
||||
if focused && m.currentCue >= 0 {
|
||||
m.selectedCue = m.currentCue
|
||||
m.updateContent()
|
||||
m.scrollToCue(m.selectedCue)
|
||||
}
|
||||
}
|
||||
|
||||
// ModeString returns the mode as a string
|
||||
func (m *Model) ModeString() string {
|
||||
return "VIEW"
|
||||
}
|
||||
|
||||
// seekToActiveCue returns a command to seek audio to the active cue
|
||||
func (m *Model) seekToActiveCue() tea.Cmd {
|
||||
if m.transcript == nil || m.activeCue < 0 || m.activeCue >= len(m.transcript.Cues) {
|
||||
return nil
|
||||
}
|
||||
return func() tea.Msg {
|
||||
return SeekToCueMsg{Position: m.transcript.Cues[m.activeCue].Start}
|
||||
}
|
||||
}
|
||||
|
||||
// Update handles messages
|
||||
func (m *Model) Update(msg tea.Msg) tea.Cmd {
|
||||
if m.transcript == nil {
|
||||
@@ -113,63 +109,57 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "j", "down":
|
||||
// Move to next cue
|
||||
if m.selectedCue < len(m.transcript.Cues)-1 {
|
||||
m.selectedCue++
|
||||
// Move to next cue and seek
|
||||
if m.activeCue < len(m.transcript.Cues)-1 {
|
||||
m.activeCue++
|
||||
m.refreshAndScroll()
|
||||
return m.seekToActiveCue()
|
||||
}
|
||||
return nil
|
||||
case "k", "up":
|
||||
// Move to previous cue
|
||||
if m.selectedCue > 0 {
|
||||
m.selectedCue--
|
||||
// Move to previous cue and seek
|
||||
if m.activeCue > 0 {
|
||||
m.activeCue--
|
||||
m.refreshAndScroll()
|
||||
return m.seekToActiveCue()
|
||||
}
|
||||
return nil
|
||||
case "ctrl+d":
|
||||
// Jump 5 cues down
|
||||
m.selectedCue += 5
|
||||
if m.selectedCue >= len(m.transcript.Cues) {
|
||||
m.selectedCue = len(m.transcript.Cues) - 1
|
||||
// Jump 5 cues down and seek
|
||||
m.activeCue += 5
|
||||
if m.activeCue >= len(m.transcript.Cues) {
|
||||
m.activeCue = len(m.transcript.Cues) - 1
|
||||
}
|
||||
m.refreshAndScroll()
|
||||
return nil
|
||||
return m.seekToActiveCue()
|
||||
case "ctrl+u":
|
||||
// Jump 5 cues up
|
||||
m.selectedCue -= 5
|
||||
if m.selectedCue < 0 {
|
||||
m.selectedCue = 0
|
||||
// Jump 5 cues up and seek
|
||||
m.activeCue -= 5
|
||||
if m.activeCue < 0 {
|
||||
m.activeCue = 0
|
||||
}
|
||||
m.refreshAndScroll()
|
||||
return nil
|
||||
return m.seekToActiveCue()
|
||||
case "g":
|
||||
// Go to first cue
|
||||
m.selectedCue = 0
|
||||
// Go to first cue and seek
|
||||
m.activeCue = 0
|
||||
m.refreshAndScroll()
|
||||
return nil
|
||||
return m.seekToActiveCue()
|
||||
case "G":
|
||||
// Go to last cue
|
||||
m.selectedCue = len(m.transcript.Cues) - 1
|
||||
// Go to last cue and seek
|
||||
m.activeCue = len(m.transcript.Cues) - 1
|
||||
m.refreshAndScroll()
|
||||
return nil
|
||||
case "enter":
|
||||
// Seek to selected cue
|
||||
if m.selectedCue >= 0 && m.selectedCue < len(m.transcript.Cues) {
|
||||
return func() tea.Msg {
|
||||
return SeekToCueMsg{Position: m.transcript.Cues[m.selectedCue].Start}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return m.seekToActiveCue()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshAndScroll updates content and scrolls to selected cue
|
||||
// refreshAndScroll updates content and scrolls to active cue
|
||||
func (m *Model) refreshAndScroll() {
|
||||
m.updateContent()
|
||||
m.scrollToCue(m.selectedCue)
|
||||
m.scrollToCue(m.activeCue)
|
||||
}
|
||||
|
||||
func (m *Model) updateContent() {
|
||||
@@ -186,9 +176,8 @@ func (m *Model) updateContent() {
|
||||
for i, cue := range m.transcript.Cues {
|
||||
m.cueLines[i] = currentLine
|
||||
|
||||
isCurrent := i == m.currentCue
|
||||
isSelected := i == m.selectedCue
|
||||
rendered := RenderCue(&cue, isCurrent, isSelected, m.Width-4)
|
||||
isActive := i == m.activeCue
|
||||
rendered := RenderCue(&cue, isActive, m.Width-4)
|
||||
sb.WriteString(rendered)
|
||||
|
||||
// Count lines in this cue's rendering
|
||||
@@ -224,10 +213,7 @@ func (m *Model) scrollToCue(cueIndex int) {
|
||||
func (m Model) View() string {
|
||||
content := m.viewport.View()
|
||||
|
||||
style := ui.TranscriptStyle
|
||||
if m.Focused {
|
||||
style = ui.TranscriptFocusedStyle
|
||||
}
|
||||
style := ui.TranscriptFocusedStyle
|
||||
|
||||
return style.Width(m.Width - 2).Height(m.Height - 2).Render(content)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
type Model struct {
|
||||
Width int
|
||||
Height int
|
||||
Focused bool
|
||||
Samples []float64
|
||||
Position float64 // 0.0 to 1.0
|
||||
Duration time.Duration
|
||||
@@ -49,11 +48,6 @@ func (m *Model) SetSize(width, height int) {
|
||||
m.Height = height
|
||||
}
|
||||
|
||||
// SetFocused sets the focus state
|
||||
func (m *Model) SetFocused(focused bool) {
|
||||
m.Focused = focused
|
||||
}
|
||||
|
||||
// View renders the waveform
|
||||
func (m Model) View() string {
|
||||
contentWidth := m.Width - 4 // Account for border and padding
|
||||
@@ -115,11 +109,8 @@ func (m Model) View() string {
|
||||
timeLine,
|
||||
)
|
||||
|
||||
// Apply border style based on focus
|
||||
style := ui.WaveformStyle
|
||||
if m.Focused {
|
||||
style = ui.WaveformFocusedStyle
|
||||
}
|
||||
// Apply focused border style
|
||||
style := ui.WaveformFocusedStyle
|
||||
|
||||
return style.Width(m.Width - 2).Render(content)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user