forked from Shinonome/dots-hyprland
feat: add sticky overlay functionality
This commit is contained in:
@@ -31,7 +31,11 @@ Singleton {
|
|||||||
property string shellConfig: FileUtils.trimFileProtocol(`${Directories.config}/illogical-impulse`)
|
property string shellConfig: FileUtils.trimFileProtocol(`${Directories.config}/illogical-impulse`)
|
||||||
property string shellConfigName: "config.json"
|
property string shellConfigName: "config.json"
|
||||||
property string shellConfigPath: `${Directories.shellConfig}/${Directories.shellConfigName}`
|
property string shellConfigPath: `${Directories.shellConfig}/${Directories.shellConfigName}`
|
||||||
property string todoPath: FileUtils.trimFileProtocol(`${Directories.state}/user/todo.json`)
|
property string todoPath: FileUtils.trimFileProtocol(`${Directories.state}/user/todo.json`)
|
||||||
|
// CUSTOM: Stickypad integration - START
|
||||||
|
property string stickypadPath: FileUtils.trimFileProtocol(`${Directories.state}/user/stickypad.txt`)
|
||||||
|
// CUSTOM: Stickypad integration - END
|
||||||
|
property string conflictCachePath: FileUtils.trimFileProtocol(`${Directories.cache}/conflict-killer`)
|
||||||
property string notificationsPath: FileUtils.trimFileProtocol(`${Directories.cache}/notifications/notifications.json`)
|
property string notificationsPath: FileUtils.trimFileProtocol(`${Directories.cache}/notifications/notifications.json`)
|
||||||
property string generatedMaterialThemePath: FileUtils.trimFileProtocol(`${Directories.state}/user/generated/colors.json`)
|
property string generatedMaterialThemePath: FileUtils.trimFileProtocol(`${Directories.state}/user/generated/colors.json`)
|
||||||
property string generatedWallpaperCategoryPath: FileUtils.trimFileProtocol(`${Directories.state}/user/generated/wallpaper/category.txt`)
|
property string generatedWallpaperCategoryPath: FileUtils.trimFileProtocol(`${Directories.state}/user/generated/wallpaper/category.txt`)
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ Singleton {
|
|||||||
property real x: 1576
|
property real x: 1576
|
||||||
property real y: 630
|
property real y: 630
|
||||||
}
|
}
|
||||||
|
// CUSTOM: Stickypad integration - START
|
||||||
|
property JsonObject stickypad: JsonObject {
|
||||||
|
property bool pinned: true
|
||||||
|
property bool clickthrough: false
|
||||||
|
property real x: 100
|
||||||
|
property real y: 100
|
||||||
|
}
|
||||||
|
// CUSTOM: Stickypad integration - END
|
||||||
}
|
}
|
||||||
|
|
||||||
property JsonObject timer: JsonObject {
|
property JsonObject timer: JsonObject {
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ Scope {
|
|||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
WlrLayershell.namespace: "quickshell:overlay"
|
WlrLayershell.namespace: "quickshell:overlay"
|
||||||
WlrLayershell.layer: WlrLayer.Overlay
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
WlrLayershell.keyboardFocus: GlobalStates.overlayOpen ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
|
// CUSTOM: Stickypad keyboard focus fix - START
|
||||||
|
// Use OnDemand for pinned widgets to allow focus switching with mouse clicks
|
||||||
|
WlrLayershell.keyboardFocus: GlobalStates.overlayOpen ? WlrKeyboardFocus.Exclusive : (OverlayContext.clickableWidgets.length > 0 ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None)
|
||||||
|
// CUSTOM: Stickypad keyboard focus fix - END
|
||||||
visible: true
|
visible: true
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ Singleton {
|
|||||||
{ identifier: "volumeMixer", materialSymbol: "volume_up" },
|
{ identifier: "volumeMixer", materialSymbol: "volume_up" },
|
||||||
{ identifier: "crosshair", materialSymbol: "point_scan" },
|
{ identifier: "crosshair", materialSymbol: "point_scan" },
|
||||||
{ identifier: "fpsLimiter", materialSymbol: "animation" },
|
{ identifier: "fpsLimiter", materialSymbol: "animation" },
|
||||||
{ identifier: "resources", materialSymbol: "browse_activity" }
|
{ identifier: "resources", materialSymbol: "browse_activity" },
|
||||||
|
// CUSTOM: Stickypad integration - START
|
||||||
|
{ identifier: "stickypad", materialSymbol: "note_stack" }
|
||||||
|
// CUSTOM: Stickypad integration - END
|
||||||
]
|
]
|
||||||
|
|
||||||
readonly property bool hasPinnedWidgets: root.pinnedWidgetIdentifiers.length > 0
|
readonly property bool hasPinnedWidgets: root.pinnedWidgetIdentifiers.length > 0
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import qs.modules.overlay.volumeMixer
|
|||||||
import qs.modules.overlay.fpsLimiter
|
import qs.modules.overlay.fpsLimiter
|
||||||
import qs.modules.overlay.recorder
|
import qs.modules.overlay.recorder
|
||||||
import qs.modules.overlay.resources
|
import qs.modules.overlay.resources
|
||||||
|
// CUSTOM: Stickypad integration - START
|
||||||
|
import qs.modules.overlay.stickypad
|
||||||
|
// CUSTOM: Stickypad integration - END
|
||||||
|
|
||||||
DelegateChooser {
|
DelegateChooser {
|
||||||
id: root
|
id: root
|
||||||
@@ -21,4 +24,7 @@ DelegateChooser {
|
|||||||
DelegateChoice { roleValue: "fpsLimiter"; FpsLimiter {} }
|
DelegateChoice { roleValue: "fpsLimiter"; FpsLimiter {} }
|
||||||
DelegateChoice { roleValue: "recorder"; Recorder {} }
|
DelegateChoice { roleValue: "recorder"; Recorder {} }
|
||||||
DelegateChoice { roleValue: "resources"; Resources {} }
|
DelegateChoice { roleValue: "resources"; Resources {} }
|
||||||
|
// CUSTOM: Stickypad integration - START
|
||||||
|
DelegateChoice { roleValue: "stickypad"; Stickypad {} }
|
||||||
|
// CUSTOM: Stickypad integration - END
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import qs.modules.common
|
||||||
|
import qs.modules.overlay
|
||||||
|
|
||||||
|
StyledOverlayWidget {
|
||||||
|
id: root
|
||||||
|
title: "Stickypad"
|
||||||
|
minWidth: 440
|
||||||
|
showCenterButton: true
|
||||||
|
|
||||||
|
// Override opacity to always stay fully opaque, even in clickthrough mode
|
||||||
|
opacity: 1.0
|
||||||
|
|
||||||
|
contentItem: StickypadContent {
|
||||||
|
implicitWidth: 440
|
||||||
|
implicitHeight: 380
|
||||||
|
// CUSTOM: Pass clickthrough state to content - START
|
||||||
|
isClickthrough: root.clickthrough
|
||||||
|
// CUSTOM: Pass clickthrough state to content - END
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import qs.modules.common
|
||||||
|
import qs.modules.common.widgets
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property real panelPadding: 20
|
||||||
|
property string stickypadContents: ""
|
||||||
|
property bool pendingReload: false
|
||||||
|
property var copylistEntries: []
|
||||||
|
property string lastParsedCopylistText: ""
|
||||||
|
property var parsedCopylistLines: []
|
||||||
|
// CUSTOM: Track clickthrough state to disable text editing - START
|
||||||
|
property bool isClickthrough: false
|
||||||
|
// CUSTOM: Track clickthrough state to disable text editing - END
|
||||||
|
|
||||||
|
color: Appearance.colors.colLayer0
|
||||||
|
radius: Appearance.rounding.windowRounding - 6
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
stickypadFile.reload()
|
||||||
|
updateCopylistEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveStickypad() {
|
||||||
|
if (!stickypadInput)
|
||||||
|
return
|
||||||
|
stickypadContents = stickypadInput.text
|
||||||
|
stickypadFile.setText(stickypadContents)
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusStickypadAtEnd() {
|
||||||
|
if (!stickypadInput)
|
||||||
|
return
|
||||||
|
stickypadInput.forceActiveFocus()
|
||||||
|
const endPos = stickypadInput.text.length
|
||||||
|
applySelection(endPos, endPos)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySelection(cursorPos, anchorPos) {
|
||||||
|
if (!stickypadInput)
|
||||||
|
return
|
||||||
|
const textLength = stickypadInput.text.length
|
||||||
|
const cursor = Math.max(0, Math.min(cursorPos, textLength))
|
||||||
|
const anchor = Math.max(0, Math.min(anchorPos, textLength))
|
||||||
|
stickypadInput.select(anchor, cursor)
|
||||||
|
if (cursor === anchor)
|
||||||
|
stickypadInput.deselect()
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleCopylistUpdate(immediate = false) {
|
||||||
|
if (!stickypadInput)
|
||||||
|
return
|
||||||
|
if (immediate) {
|
||||||
|
copylistDebounce.stop()
|
||||||
|
updateCopylistEntries()
|
||||||
|
} else {
|
||||||
|
copylistDebounce.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCopylistEntries() {
|
||||||
|
if (!stickypadInput)
|
||||||
|
return
|
||||||
|
const textValue = stickypadInput.text
|
||||||
|
if (!textValue || textValue.length === 0) {
|
||||||
|
lastParsedCopylistText = ""
|
||||||
|
parsedCopylistLines = []
|
||||||
|
copylistEntries = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textValue !== lastParsedCopylistText) {
|
||||||
|
const lineRegex = /(.*?)(\r?\n|$)/g
|
||||||
|
let match = null
|
||||||
|
const parsed = []
|
||||||
|
while ((match = lineRegex.exec(textValue)) !== null) {
|
||||||
|
const lineText = match[1]
|
||||||
|
const newlineText = match[2]
|
||||||
|
const lineStart = match.index
|
||||||
|
const lineEnd = lineStart + lineText.length
|
||||||
|
const bulletMatch = lineText.match(/^\s*-\s+(.*\S)\s*$/)
|
||||||
|
if (bulletMatch) {
|
||||||
|
parsed.push({
|
||||||
|
content: bulletMatch[1].trim(),
|
||||||
|
start: lineStart,
|
||||||
|
end: lineEnd
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (newlineText === "")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lastParsedCopylistText = textValue
|
||||||
|
parsedCopylistLines = parsed
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
copylistEntries = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCopylistPositions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCopylistPositions() {
|
||||||
|
if (!stickypadInput || parsedCopylistLines.length === 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
const rawSelectionStart = stickypadInput.selectionStart
|
||||||
|
const rawSelectionEnd = stickypadInput.selectionEnd
|
||||||
|
const selectionStart = rawSelectionStart === -1 ? stickypadInput.cursorPosition : rawSelectionStart
|
||||||
|
const selectionEnd = rawSelectionEnd === -1 ? stickypadInput.cursorPosition : rawSelectionEnd
|
||||||
|
const rangeStart = Math.min(selectionStart, selectionEnd)
|
||||||
|
const rangeEnd = Math.max(selectionStart, selectionEnd)
|
||||||
|
|
||||||
|
const entries = parsedCopylistLines.map(line => {
|
||||||
|
const caretIntersects = rangeEnd > line.start && rangeStart <= line.end
|
||||||
|
if (caretIntersects)
|
||||||
|
return null
|
||||||
|
const startRect = stickypadInput.positionToRectangle(line.start)
|
||||||
|
let endRect = stickypadInput.positionToRectangle(line.end)
|
||||||
|
if (!isFinite(startRect.y))
|
||||||
|
return null
|
||||||
|
if (!isFinite(endRect.y))
|
||||||
|
endRect = startRect
|
||||||
|
const lineBottom = endRect.y + endRect.height
|
||||||
|
const rectHeight = Math.max(lineBottom - startRect.y, stickypadInput.font.pixelSize + 8)
|
||||||
|
return {
|
||||||
|
content: line.content,
|
||||||
|
y: startRect.y,
|
||||||
|
height: rectHeight
|
||||||
|
}
|
||||||
|
}).filter(entry => entry !== null)
|
||||||
|
|
||||||
|
copylistEntries = entries
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: stickypadLayout
|
||||||
|
anchors {
|
||||||
|
fill: parent
|
||||||
|
margins: panelPadding
|
||||||
|
}
|
||||||
|
spacing: 14
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
id: editorScrollView
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.minimumHeight: 200
|
||||||
|
clip: true
|
||||||
|
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||||
|
onWidthChanged: root.scheduleCopylistUpdate(true)
|
||||||
|
|
||||||
|
StyledTextArea {
|
||||||
|
id: stickypadInput
|
||||||
|
wrapMode: TextEdit.Wrap
|
||||||
|
placeholderText: Translation.tr("Write...")
|
||||||
|
selectByMouse: true
|
||||||
|
persistentSelection: true
|
||||||
|
textFormat: TextEdit.PlainText
|
||||||
|
background: null
|
||||||
|
rightPadding: 44
|
||||||
|
// CUSTOM: Adapt text color to theme (light/dark mode) - START
|
||||||
|
color: Appearance.colors.colOnLayer0
|
||||||
|
// CUSTOM: Adapt text color to theme (light/dark mode) - END
|
||||||
|
// CUSTOM: Disable text area when clickthrough enabled - START
|
||||||
|
// Allow editing when overlay is open OR when clickthrough is disabled
|
||||||
|
enabled: GlobalStates.overlayOpen || !root.isClickthrough
|
||||||
|
activeFocusOnTab: GlobalStates.overlayOpen || !root.isClickthrough
|
||||||
|
|
||||||
|
// Release focus when clickthrough is enabled and overlay closes
|
||||||
|
Connections {
|
||||||
|
target: root
|
||||||
|
function onIsClickthroughChanged() {
|
||||||
|
if (root.isClickthrough && !GlobalStates.overlayOpen && stickypadInput.activeFocus) {
|
||||||
|
stickypadInput.focus = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: GlobalStates
|
||||||
|
function onOverlayOpenChanged() {
|
||||||
|
if (!GlobalStates.overlayOpen && root.isClickthrough && stickypadInput.activeFocus) {
|
||||||
|
stickypadInput.focus = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// CUSTOM: Disable text area when clickthrough enabled - END
|
||||||
|
|
||||||
|
Keys.onPressed: (event) => {
|
||||||
|
if (event.key === Qt.Key_Escape && event.modifiers === Qt.NoModifier) {
|
||||||
|
Persistent.states.overlay.open = Persistent.states.overlay.open.filter(type => type !== "stickypad")
|
||||||
|
event.accepted = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onTextChanged: {
|
||||||
|
if (stickypadInput.activeFocus) {
|
||||||
|
saveDebounce.restart()
|
||||||
|
}
|
||||||
|
root.scheduleCopylistUpdate(true)
|
||||||
|
}
|
||||||
|
onCursorPositionChanged: root.scheduleCopylistUpdate()
|
||||||
|
onSelectionStartChanged: root.scheduleCopylistUpdate()
|
||||||
|
onSelectionEndChanged: root.scheduleCopylistUpdate()
|
||||||
|
onHeightChanged: root.scheduleCopylistUpdate(true)
|
||||||
|
onContentHeightChanged: root.scheduleCopylistUpdate(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
anchors.fill: stickypadInput
|
||||||
|
visible: copylistEntries.length > 0
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: copylistEntries
|
||||||
|
delegate: Item {
|
||||||
|
readonly property real lineHeight: Math.max(modelData.height, Appearance.font.pixelSize.normal + 6)
|
||||||
|
readonly property real iconSizeLocal: Appearance.font.pixelSize.normal
|
||||||
|
readonly property real hitPadding: 6
|
||||||
|
|
||||||
|
width: iconSizeLocal + hitPadding * 2
|
||||||
|
height: lineHeight
|
||||||
|
y: modelData.y
|
||||||
|
x: Math.max(stickypadInput.width - width - 8, 0)
|
||||||
|
z: 5
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: feedbackFlash
|
||||||
|
anchors.centerIn: iconItem
|
||||||
|
width: iconSizeLocal + hitPadding
|
||||||
|
height: width
|
||||||
|
radius: width / 2
|
||||||
|
color: Appearance.colors.colLayer2
|
||||||
|
opacity: 0
|
||||||
|
z: -1
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialSymbol {
|
||||||
|
id: iconItem
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "content_copy"
|
||||||
|
iconSize: iconSizeLocal
|
||||||
|
color: Appearance.colors.colOnLayer1
|
||||||
|
opacity: mouseArea.containsMouse ? 1 : 0.85
|
||||||
|
scale: 1
|
||||||
|
Behavior on scale {
|
||||||
|
NumberAnimation {
|
||||||
|
duration: 120
|
||||||
|
easing.type: Easing.OutQuad
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation {
|
||||||
|
duration: 120
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: mouseArea
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: hitPadding
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onPressed: iconItem.scale = 0.85
|
||||||
|
onReleased: iconItem.scale = 1
|
||||||
|
onCanceled: iconItem.scale = 1
|
||||||
|
onClicked: {
|
||||||
|
feedbackFlash.opacity = 0.6
|
||||||
|
feedbackFade.restart()
|
||||||
|
Quickshell.clipboardText = modelData.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NumberAnimation {
|
||||||
|
id: feedbackFade
|
||||||
|
target: feedbackFlash
|
||||||
|
property: "opacity"
|
||||||
|
to: 0
|
||||||
|
duration: 200
|
||||||
|
easing.type: Easing.OutQuad
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 28
|
||||||
|
Layout.minimumHeight: 28
|
||||||
|
color: "transparent"
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: statusLabel
|
||||||
|
anchors {
|
||||||
|
right: parent.right
|
||||||
|
verticalCenter: parent.verticalCenter
|
||||||
|
rightMargin: 8
|
||||||
|
}
|
||||||
|
text: saveDebounce.running ? "Saving..." : "Saved"
|
||||||
|
color: saveDebounce.running ? Appearance.colors.colAccent : Appearance.colors.colSubtext
|
||||||
|
font.pixelSize: Appearance.font.pixelSize.small
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: saveDebounce
|
||||||
|
interval: 500
|
||||||
|
repeat: false
|
||||||
|
onTriggered: saveStickypad()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: copylistDebounce
|
||||||
|
interval: 100
|
||||||
|
repeat: false
|
||||||
|
onTriggered: updateCopylistPositions()
|
||||||
|
}
|
||||||
|
|
||||||
|
FileView {
|
||||||
|
id: stickypadFile
|
||||||
|
path: Qt.resolvedUrl(Directories.stickypadPath)
|
||||||
|
onLoaded: {
|
||||||
|
stickypadContents = stickypadFile.text()
|
||||||
|
if (stickypadInput && stickypadInput.text !== stickypadContents) {
|
||||||
|
const previousCursor = stickypadInput.cursorPosition
|
||||||
|
const previousAnchor = stickypadInput.selectionStart
|
||||||
|
stickypadInput.text = stickypadContents
|
||||||
|
applySelection(previousCursor, previousAnchor)
|
||||||
|
}
|
||||||
|
if (pendingReload) {
|
||||||
|
pendingReload = false
|
||||||
|
Qt.callLater(focusStickypadAtEnd)
|
||||||
|
}
|
||||||
|
Qt.callLater(root.updateCopylistEntries)
|
||||||
|
}
|
||||||
|
onLoadFailed: (error) => {
|
||||||
|
if (error === FileViewError.FileNotFound) {
|
||||||
|
stickypadContents = ""
|
||||||
|
stickypadFile.setText(stickypadContents)
|
||||||
|
if (stickypadInput)
|
||||||
|
stickypadInput.text = stickypadContents
|
||||||
|
if (pendingReload) {
|
||||||
|
pendingReload = false
|
||||||
|
Qt.callLater(focusStickypadAtEnd)
|
||||||
|
}
|
||||||
|
Qt.callLater(root.updateCopylistEntries)
|
||||||
|
} else {
|
||||||
|
console.log("[Stickypad] Error loading file: " + error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
module qs.modules.overlay.stickypad
|
||||||
|
|
||||||
|
Stickypad 1.0 Stickypad.qml
|
||||||
|
StickypadContent 1.0 StickypadContent.qml
|
||||||
Reference in New Issue
Block a user