mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 05:32:53 +00:00
Simplify the wide-layout details panel: floating-only on iPhone, side as a preset setting
iPhone landscape now uses the floating panel only, always on the right; the pin and side-toggle buttons on the grabber are gone and the pinned setting resolves to false on phone. iPad and macOS default to the pinned side-by-side layout, keep the pin button to switch to floating, and pick the panel side in the preset's panel settings instead of an on-screen arrow. The pinned state persists as a layout preference: closing the player or the hidden default visibility no longer resets it. Removes PanelAlignmentButton, the global floatingDetailsPanelSide setting and its key, FloatingPanelSide.opposite, and the legacy wideLayoutPanelAlignment preset field.
This commit is contained in:
@@ -328,9 +328,8 @@ struct PlaybackCommands: Commands {
|
||||
// Clear the queue when closing video
|
||||
appEnvironment.queueManager.clearQueue()
|
||||
|
||||
// Reset panel state when closing player
|
||||
// Reset panel visibility when closing player
|
||||
settingsManager.landscapeDetailsPanelVisible = false
|
||||
settingsManager.landscapeDetailsPanelPinned = false
|
||||
|
||||
// Stop player FIRST before dismissing window
|
||||
playerService.stop()
|
||||
|
||||
@@ -128,7 +128,6 @@ enum SettingsKey: String, CaseIterable {
|
||||
case tvVideoSyncMode
|
||||
|
||||
// Details panel
|
||||
case floatingDetailsPanelSide // Landscape only - which side the panel appears on
|
||||
case floatingDetailsPanelWidth // Resizable panel width in wide layout
|
||||
case landscapeDetailsPanelVisible
|
||||
case landscapeDetailsPanelPinned
|
||||
@@ -167,7 +166,7 @@ enum SettingsKey: String, CaseIterable {
|
||||
.sidebarChannelsEnabled, .sidebarMaxChannels, .sidebarChannelSort, .sidebarChannelsLimitEnabled,
|
||||
.sidebarPlaylistsEnabled, .sidebarMaxPlaylists, .sidebarPlaylistSort, .sidebarPlaylistsLimitEnabled,
|
||||
// Player details panel — iOS/iPadOS only, different on other platforms
|
||||
.floatingDetailsPanelSide, .floatingDetailsPanelWidth,
|
||||
.floatingDetailsPanelWidth,
|
||||
.landscapeDetailsPanelVisible, .landscapeDetailsPanelPinned,
|
||||
.detailsPanelLastVisible, .cornerPanelColumnWidth,
|
||||
// Video swipe actions — touch-gesture feature
|
||||
|
||||
@@ -234,19 +234,6 @@ extension SettingsManager {
|
||||
|
||||
// MARK: - Details Panel Settings
|
||||
|
||||
/// Which side the floating details panel appears on in landscape layout.
|
||||
/// Default is right side.
|
||||
var floatingDetailsPanelSide: FloatingPanelSide {
|
||||
get {
|
||||
if let cached = _floatingDetailsPanelSide { return cached }
|
||||
return FloatingPanelSide(rawValue: string(for: .floatingDetailsPanelSide) ?? "") ?? .left
|
||||
}
|
||||
set {
|
||||
_floatingDetailsPanelSide = newValue
|
||||
set(newValue.rawValue, for: .floatingDetailsPanelSide)
|
||||
}
|
||||
}
|
||||
|
||||
/// Default panel width in wide layout.
|
||||
static let defaultFloatingDetailsPanelWidth: CGFloat = 400
|
||||
|
||||
@@ -307,12 +294,14 @@ extension SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the details panel is pinned in landscape layout.
|
||||
/// Default is false (floating mode).
|
||||
/// Whether the details panel is pinned (side-by-side) in the wide layout.
|
||||
/// iPad/macOS default to pinned; iPhone is floating-only and always
|
||||
/// resolves to `false` regardless of the stored value.
|
||||
var landscapeDetailsPanelPinned: Bool {
|
||||
get {
|
||||
if PanelSettings.isSectionsOnlyDevice { return false }
|
||||
if let cached = _landscapeDetailsPanelPinned { return cached }
|
||||
return bool(for: .landscapeDetailsPanelPinned, default: false)
|
||||
return bool(for: .landscapeDetailsPanelPinned, default: true)
|
||||
}
|
||||
set {
|
||||
_landscapeDetailsPanelPinned = newValue
|
||||
|
||||
@@ -322,16 +322,16 @@ enum SponsorBlockCategory: String, CaseIterable, Codable, Sendable {
|
||||
|
||||
// MARK: - Floating Panel
|
||||
|
||||
/// Which side the floating details panel appears on in widescreen layout.
|
||||
/// Which side the details panel appears on in the standard wide layout.
|
||||
enum FloatingPanelSide: String, CaseIterable, Codable {
|
||||
case left
|
||||
case right
|
||||
|
||||
/// The opposite side.
|
||||
var opposite: FloatingPanelSide {
|
||||
/// Localized name for the settings picker.
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .left: return .right
|
||||
case .right: return .left
|
||||
case .left: return String(localized: "panel.side.left")
|
||||
case .right: return String(localized: "panel.side.right")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,7 +178,6 @@ final class SettingsManager {
|
||||
var _tvVideoSyncMode: TVVideoSyncMode?
|
||||
|
||||
// Details panel settings
|
||||
var _floatingDetailsPanelSide: FloatingPanelSide?
|
||||
var _floatingDetailsPanelWidth: CGFloat?
|
||||
var _landscapeDetailsPanelVisible: Bool?
|
||||
var _landscapeDetailsPanelPinned: Bool?
|
||||
@@ -530,7 +529,6 @@ final class SettingsManager {
|
||||
_tvMatchDisplayDynamicRange = nil
|
||||
_tvAudioDelayMs = nil
|
||||
_tvVideoSyncMode = nil
|
||||
_floatingDetailsPanelSide = nil
|
||||
_floatingDetailsPanelWidth = nil
|
||||
_landscapeDetailsPanelVisible = nil
|
||||
_landscapeDetailsPanelPinned = nil
|
||||
|
||||
@@ -7560,6 +7560,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.side" : {
|
||||
"comment" : "Details panel side picker (iPad/macOS standard layout)",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Panel Side"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.side.left" : {
|
||||
"comment" : "Details panel side option",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Left"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.side.right" : {
|
||||
"comment" : "Details panel side option",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Right"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"peertube.explore.added" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -19994,26 +20027,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wideLayoutPanel.alignmentButton.moveLeft" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Move Panel Left"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"wideLayoutPanel.alignmentButton.moveRight" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Move Panel Right"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"wideLayoutPanel.pinButton.pin" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
|
||||
@@ -359,16 +359,22 @@ struct PanelSettings: Codable, Hashable, Sendable {
|
||||
/// Whether the page bar shows labels next to icons.
|
||||
var showPageBarLabels: Bool?
|
||||
|
||||
/// Which side the details panel sits on in the standard wide layout on
|
||||
/// iPad/macOS. iPhone ignores it (always right). Effective default: `.right`.
|
||||
var wideLayoutSide: FloatingPanelSide?
|
||||
|
||||
init(
|
||||
pages: [PanelPageConfiguration],
|
||||
layoutMode: PanelLayoutMode? = nil,
|
||||
defaultVisibility: PanelDefaultVisibility? = nil,
|
||||
showPageBarLabels: Bool? = nil
|
||||
showPageBarLabels: Bool? = nil,
|
||||
wideLayoutSide: FloatingPanelSide? = nil
|
||||
) {
|
||||
self.pages = pages
|
||||
self.layoutMode = layoutMode
|
||||
self.defaultVisibility = defaultVisibility
|
||||
self.showPageBarLabels = showPageBarLabels
|
||||
self.wideLayoutSide = wideLayoutSide
|
||||
}
|
||||
|
||||
/// In-memory cache of the active preset's panel settings, refreshed by the
|
||||
@@ -388,6 +394,7 @@ struct PanelSettings: Codable, Hashable, Sendable {
|
||||
var effectiveLayoutMode: PanelLayoutMode { layoutMode ?? .stacked }
|
||||
var effectiveDefaultVisibility: PanelDefaultVisibility { defaultVisibility ?? .hidden }
|
||||
var effectiveShowPageBarLabels: Bool { showPageBarLabels ?? true }
|
||||
var effectiveWideLayoutSide: FloatingPanelSide { wideLayoutSide ?? .right }
|
||||
|
||||
/// Pages the panel should offer, in order. Pass a region to filter for
|
||||
/// corner mode; `nil` returns enabled pages from all regions.
|
||||
@@ -488,6 +495,7 @@ struct PanelSettings: Codable, Hashable, Sendable {
|
||||
case layoutMode
|
||||
case defaultVisibility
|
||||
case showPageBarLabels
|
||||
case wideLayoutSide
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
@@ -509,6 +517,7 @@ struct PanelSettings: Codable, Hashable, Sendable {
|
||||
layoutMode = try? container.decodeIfPresent(PanelLayoutMode.self, forKey: .layoutMode)
|
||||
defaultVisibility = try? container.decodeIfPresent(PanelDefaultVisibility.self, forKey: .defaultVisibility)
|
||||
showPageBarLabels = try container.decodeIfPresent(Bool.self, forKey: .showPageBarLabels)
|
||||
wideLayoutSide = try? container.decodeIfPresent(FloatingPanelSide.self, forKey: .wideLayoutSide)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
@@ -517,6 +526,7 @@ struct PanelSettings: Codable, Hashable, Sendable {
|
||||
try container.encodeIfPresent(layoutMode, forKey: .layoutMode)
|
||||
try container.encodeIfPresent(defaultVisibility, forKey: .defaultVisibility)
|
||||
try container.encodeIfPresent(showPageBarLabels, forKey: .showPageBarLabels)
|
||||
try container.encodeIfPresent(wideLayoutSide, forKey: .wideLayoutSide)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,11 +33,6 @@ struct PlayerControlsLayout: Codable, Hashable, Sendable {
|
||||
/// Mini player settings. Optional for backward compatibility.
|
||||
var miniPlayerSettings: MiniPlayerSettings?
|
||||
|
||||
/// Unused: the player reads the panel side from SettingsManager
|
||||
/// (`floatingDetailsPanelSide`). Kept only so old presets that saved this
|
||||
/// field still decode.
|
||||
var wideLayoutPanelAlignment: FloatingPanelSide?
|
||||
|
||||
/// Details panel pages configuration. Optional for backward compatibility.
|
||||
var panelSettings: PanelSettings?
|
||||
|
||||
|
||||
@@ -818,6 +818,18 @@ final class PlayerControlsSettingsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Current panel side in the standard wide layout (iPad/macOS).
|
||||
var panelWideLayoutSide: FloatingPanelSide {
|
||||
panelSettings.effectiveWideLayoutSide
|
||||
}
|
||||
|
||||
/// Synchronously updates the panel side in the standard wide layout.
|
||||
func syncPanelWideLayoutSide(_ side: FloatingPanelSide) {
|
||||
updatePanelSettingsSync { settings in
|
||||
settings.wideLayoutSide = side
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronously updates a page's corner-layout region assignment.
|
||||
func setPanelPageRegion(_ region: PanelRegion, for pageType: PanelPageType) {
|
||||
updatePanelSettingsSync { settings in
|
||||
|
||||
@@ -92,9 +92,8 @@ extension ExpandedPlayerSheet {
|
||||
// Clear the queue when closing video
|
||||
appEnvironment?.queueManager.clearQueue()
|
||||
|
||||
// Reset panel state when closing player
|
||||
// Reset panel visibility when closing player
|
||||
appEnvironment?.settingsManager.landscapeDetailsPanelVisible = false
|
||||
appEnvironment?.settingsManager.landscapeDetailsPanelPinned = false
|
||||
|
||||
// Stop player FIRST before dismissing window
|
||||
// This ensures MPVRenderView and backend are properly cleaned up
|
||||
|
||||
@@ -999,7 +999,6 @@ private func applyPanelDefaultVisibility(_ settings: PanelSettings, settingsMana
|
||||
switch settings.effectiveDefaultVisibility {
|
||||
case .hidden:
|
||||
settingsManager.landscapeDetailsPanelVisible = false
|
||||
settingsManager.landscapeDetailsPanelPinned = false
|
||||
return false
|
||||
case .visible:
|
||||
settingsManager.landscapeDetailsPanelVisible = true
|
||||
|
||||
@@ -23,7 +23,6 @@ struct FloatingDetailsPanel: View {
|
||||
@Environment(\.colorScheme) private var systemColorScheme
|
||||
|
||||
let onPinToggle: () -> Void
|
||||
let onAlignmentToggle: () -> Void
|
||||
let isPinned: Bool
|
||||
let panelSide: FloatingPanelSide
|
||||
let onChannelTap: (() -> Void)?
|
||||
@@ -95,6 +94,11 @@ struct FloatingDetailsPanel: View {
|
||||
PanelSettings.isSectionsOnlyDevice
|
||||
}
|
||||
|
||||
/// iPhone is floating-only: no pin button on the grabber.
|
||||
private var showsPinButton: Bool {
|
||||
!PanelSettings.isSectionsOnlyDevice
|
||||
}
|
||||
|
||||
/// Whether the comments pill should route to the comments page instead of
|
||||
/// the overlay.
|
||||
private var isCommentsPageAvailable: Bool {
|
||||
@@ -189,23 +193,16 @@ struct FloatingDetailsPanel: View {
|
||||
isDragActive: $isDragHandleActive
|
||||
)
|
||||
.overlay(alignment: .center) {
|
||||
PanelPinButton(
|
||||
isPinned: isPinned,
|
||||
panelSide: panelSide,
|
||||
onPinToggle: onPinToggle,
|
||||
isDragHandleActive: $isDragHandleActive
|
||||
)
|
||||
.offset(y: -65)
|
||||
.zIndex(10) // Ensure pin button appears above panel content
|
||||
}
|
||||
.overlay(alignment: .center) {
|
||||
PanelAlignmentButton(
|
||||
panelSide: panelSide,
|
||||
onAlignmentToggle: onAlignmentToggle,
|
||||
isDragHandleActive: $isDragHandleActive
|
||||
)
|
||||
.offset(y: 65)
|
||||
.zIndex(10)
|
||||
if showsPinButton {
|
||||
PanelPinButton(
|
||||
isPinned: isPinned,
|
||||
panelSide: panelSide,
|
||||
onPinToggle: onPinToggle,
|
||||
isDragHandleActive: $isDragHandleActive
|
||||
)
|
||||
.offset(y: -65)
|
||||
.zIndex(10) // Ensure pin button appears above panel content
|
||||
}
|
||||
}
|
||||
.zIndex(10) // Ensure grabber overlay appears above panel
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
//
|
||||
// PanelAlignmentButton.swift
|
||||
// Yattee
|
||||
//
|
||||
// Floating alignment toggle button for the details panel in wide layout.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
|
||||
/// A floating button that toggles the panel between left and right sides.
|
||||
/// Mirrors the style of PanelPinButton: 36pt glass circle, same shadow and haptic.
|
||||
struct PanelAlignmentButton: View {
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
let panelSide: FloatingPanelSide
|
||||
let onAlignmentToggle: () -> Void
|
||||
|
||||
/// Whether the drag handle is currently active (being dragged or hovered).
|
||||
@Binding var isDragHandleActive: Bool
|
||||
|
||||
/// Button diameter
|
||||
private static let buttonSize: CGFloat = 36
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
triggerHaptic()
|
||||
onAlignmentToggle()
|
||||
} label: {
|
||||
Image(systemName: panelSide == .left ? "arrow.right" : "arrow.left")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(.primary)
|
||||
.frame(width: Self.buttonSize, height: Self.buttonSize)
|
||||
.glassBackground(.regular, in: .circle, fallback: .thinMaterial)
|
||||
.shadow(color: .black.opacity(0.15), radius: 8, y: 2)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(panelSide == .left
|
||||
? String(localized: "wideLayoutPanel.alignmentButton.moveRight")
|
||||
: String(localized: "wideLayoutPanel.alignmentButton.moveLeft"))
|
||||
}
|
||||
|
||||
// MARK: - Haptic Feedback
|
||||
|
||||
private func triggerHaptic() {
|
||||
#if os(iOS)
|
||||
appEnvironment?.settingsManager.triggerHapticFeedback(for: .subscribeButton)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -7,6 +7,10 @@
|
||||
// - Overlay: Panel floats over full-width player, appears with controls
|
||||
// - Pinned: Player resizes to make room for panel
|
||||
//
|
||||
// iPhone uses the overlay mode only, always on the right. iPad/macOS default
|
||||
// to pinned, can unpin with the pin button, and pick the side in the preset's
|
||||
// panel settings.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -44,9 +48,11 @@ struct WideScreenPlayerLayout<PlayerContent: View>: View {
|
||||
private var video: Video? { playerState?.currentVideo }
|
||||
|
||||
private var panelSide: FloatingPanelSide {
|
||||
settingsManager?.floatingDetailsPanelSide ?? .left
|
||||
if PanelSettings.isSectionsOnlyDevice { return .right }
|
||||
return playerControlsLayout.effectivePanelSettings.effectiveWideLayoutSide
|
||||
}
|
||||
|
||||
/// `landscapeDetailsPanelPinned` already resolves to `false` on iPhone.
|
||||
private var isPanelPinned: Bool {
|
||||
settingsManager?.landscapeDetailsPanelPinned ?? false
|
||||
}
|
||||
@@ -362,11 +368,6 @@ struct WideScreenPlayerLayout<PlayerContent: View>: View {
|
||||
}
|
||||
}
|
||||
},
|
||||
onAlignmentToggle: {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
settingsManager?.floatingDetailsPanelSide = panelSide.opposite
|
||||
}
|
||||
},
|
||||
isPinned: isPanelPinned,
|
||||
panelSide: panelSide,
|
||||
onChannelTap: onChannelTap,
|
||||
|
||||
@@ -15,6 +15,7 @@ struct PanelSettingsView: View {
|
||||
// Local state for immediate UI updates
|
||||
@State private var defaultVisibility: PanelDefaultVisibility = .hidden
|
||||
@State private var layoutMode: PanelLayoutMode = .stacked
|
||||
@State private var wideLayoutSide: FloatingPanelSide = .right
|
||||
@State private var showPageBarLabels: Bool = true
|
||||
|
||||
/// Corner layout exists on iPad and macOS only.
|
||||
@@ -64,7 +65,7 @@ struct PanelSettingsView: View {
|
||||
|
||||
private var layoutSection: some View {
|
||||
Section {
|
||||
PanelLayoutSchematic(mode: layoutMode)
|
||||
PanelLayoutSchematic(mode: layoutMode, side: wideLayoutSide)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 4)
|
||||
.listRowBackground(Color.clear)
|
||||
@@ -82,6 +83,22 @@ struct PanelSettingsView: View {
|
||||
guard newValue != viewModel.panelLayoutMode else { return }
|
||||
viewModel.syncPanelLayoutMode(newValue)
|
||||
}
|
||||
|
||||
if layoutMode == .stacked {
|
||||
Picker(
|
||||
String(localized: "panel.settings.side"),
|
||||
selection: $wideLayoutSide
|
||||
) {
|
||||
ForEach(FloatingPanelSide.allCases, id: \.self) { side in
|
||||
Text(side.displayName).tag(side)
|
||||
}
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
.onChange(of: wideLayoutSide) { _, newValue in
|
||||
guard newValue != viewModel.panelWideLayoutSide else { return }
|
||||
viewModel.syncPanelWideLayoutSide(newValue)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "panel.settings.layoutMode.footer"))
|
||||
}
|
||||
@@ -187,6 +204,7 @@ struct PanelSettingsView: View {
|
||||
private func syncLocalState() {
|
||||
defaultVisibility = viewModel.panelDefaultVisibility
|
||||
layoutMode = viewModel.panelLayoutMode
|
||||
wideLayoutSide = viewModel.panelWideLayoutSide
|
||||
showPageBarLabels = viewModel.panelShowPageBarLabels
|
||||
}
|
||||
}
|
||||
@@ -197,11 +215,18 @@ struct PanelSettingsView: View {
|
||||
/// area plus where the details panel regions sit in each mode.
|
||||
private struct PanelLayoutSchematic: View {
|
||||
let mode: PanelLayoutMode
|
||||
/// Panel side in the standard layout; the corner column is always right.
|
||||
let side: FloatingPanelSide
|
||||
|
||||
private let cornerRadius: CGFloat = 4
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
if mode == .stacked && side == .left {
|
||||
panelBlock
|
||||
.frame(width: 44)
|
||||
}
|
||||
|
||||
if mode == .corner {
|
||||
VStack(spacing: 3) {
|
||||
videoBlock
|
||||
@@ -212,8 +237,10 @@ private struct PanelLayoutSchematic: View {
|
||||
videoBlock
|
||||
}
|
||||
|
||||
panelBlock
|
||||
.frame(width: 44)
|
||||
if mode == .corner || side == .right {
|
||||
panelBlock
|
||||
.frame(width: 44)
|
||||
}
|
||||
}
|
||||
.padding(6)
|
||||
.frame(width: 180, height: 110)
|
||||
@@ -222,6 +249,7 @@ private struct PanelLayoutSchematic: View {
|
||||
.fill(.quaternary.opacity(0.4))
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: mode)
|
||||
.animation(.easeInOut(duration: 0.25), value: side)
|
||||
}
|
||||
|
||||
private var videoBlock: some View {
|
||||
|
||||
Reference in New Issue
Block a user