Add read aloud, action ideas, and clearer icons
Long-press shows icon names; Material-style translate/share icons; on-device TTS read-aloud and contextual What can I do hints.
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 1.16.7-read-ideas — 2026-08-05
|
||||
|
||||
- Long-press any action icon → name toast
|
||||
- Material-style Translate / Share / Copy / Explain icons
|
||||
- **Read aloud** (on-device TTS) + **What can I do?** contextual hints
|
||||
- Docs: playful Circle quest onboarding sketch (`docs/onboarding-quest.md`)
|
||||
|
||||
## 1.16.6-icons-perf — 2026-08-05
|
||||
|
||||
- VIP actions are icon-only (Todo = checkbox with checkmark)
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.example.crkl"
|
||||
minSdk = 27
|
||||
targetSdk = 34
|
||||
versionCode = 23
|
||||
versionName = "1.16.6-icons-perf"
|
||||
versionCode = 24
|
||||
versionName = "1.16.7-read-ideas"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
||||
@@ -12,11 +12,13 @@ import android.view.Gravity
|
||||
import android.view.WindowManager
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.example.crkl.agent.ActionHints
|
||||
import com.example.crkl.agent.ActionExecutor
|
||||
import com.example.crkl.agent.AssistEngine
|
||||
import com.example.crkl.agent.VoiceIntent
|
||||
import com.example.crkl.media.MediaAssistPipeline
|
||||
import com.example.crkl.model.DeviceSpeechStt
|
||||
import com.example.crkl.model.DeviceTts
|
||||
import com.example.crkl.model.MediaPipeLocalLlm
|
||||
import com.example.crkl.integrations.IntegrationSettings
|
||||
import com.example.crkl.ui.ResultPanelView
|
||||
@@ -48,6 +50,7 @@ class CrklAccessibilityService : AccessibilityService() {
|
||||
private lateinit var localLlm: MediaPipeLocalLlm
|
||||
private lateinit var assistEngine: AssistEngine
|
||||
private lateinit var localStt: DeviceSpeechStt
|
||||
private lateinit var localTts: DeviceTts
|
||||
private lateinit var actionExecutor: ActionExecutor
|
||||
private var lastSession: ActionExecutor.Session? = null
|
||||
|
||||
@@ -57,6 +60,7 @@ class CrklAccessibilityService : AccessibilityService() {
|
||||
localLlm = MediaPipeLocalLlm(this)
|
||||
assistEngine = AssistEngine(localLlm)
|
||||
localStt = DeviceSpeechStt(this)
|
||||
localTts = DeviceTts(this)
|
||||
actionExecutor = ActionExecutor(
|
||||
context = this,
|
||||
explainText = { text -> assistEngine.explain(text) }
|
||||
@@ -366,13 +370,18 @@ class CrklAccessibilityService : AccessibilityService() {
|
||||
dismissResultPanel()
|
||||
val panel = ResultPanelView(
|
||||
context = this,
|
||||
onDismiss = { dismissResultPanel() },
|
||||
onDismiss = {
|
||||
localTts.stop()
|
||||
dismissResultPanel()
|
||||
},
|
||||
onListen = { startSttFromPanel() },
|
||||
onShareList = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.SHARE_LIST, raw = "share")) },
|
||||
onAddTodo = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.ADD_TO_TODO, raw = "add to todo")) },
|
||||
onTranslate = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.TRANSLATE, raw = "translate")) },
|
||||
onCopy = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.COPY, raw = "copy")) },
|
||||
onExplain = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.EXPLAIN, raw = "explain")) }
|
||||
onExplain = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.EXPLAIN, raw = "explain")) },
|
||||
onReadAloud = { readAloudFromPanel() },
|
||||
onIdeas = { showIdeasFromPanel() }
|
||||
)
|
||||
panel.showResult(response.title, response.meta, response.body)
|
||||
resultPanel = panel
|
||||
@@ -395,6 +404,36 @@ class CrklAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun readAloudFromPanel() {
|
||||
val body = resultPanel?.currentBody().orEmpty()
|
||||
val speakable = body
|
||||
.lineSequence()
|
||||
.map { it.trim() }
|
||||
.filter { line ->
|
||||
line.isNotEmpty() &&
|
||||
line != "—" &&
|
||||
!line.startsWith("Tip:", ignoreCase = true) &&
|
||||
!line.startsWith("Commands:", ignoreCase = true)
|
||||
}
|
||||
.joinToString(" ")
|
||||
.ifBlank { body }
|
||||
if (speakable.isBlank()) {
|
||||
resultPanel?.appendBody("Nothing to read yet.")
|
||||
return
|
||||
}
|
||||
if (!localTts.isAvailable) {
|
||||
// TTS init is async — still try; engine queues after ready on most devices.
|
||||
Log.w(tagName, "TTS not marked ready yet — attempting speak")
|
||||
}
|
||||
localTts.speak(speakable)
|
||||
resultPanel?.appendBody("Reading aloud…")
|
||||
}
|
||||
|
||||
private fun showIdeasFromPanel() {
|
||||
val body = resultPanel?.currentBody().orEmpty()
|
||||
resultPanel?.appendBody("—\n${ActionHints.forText(body)}")
|
||||
}
|
||||
|
||||
private fun startSttFromPanel() {
|
||||
val micGranted = ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
@@ -466,6 +505,9 @@ class CrklAccessibilityService : AccessibilityService() {
|
||||
if (::localStt.isInitialized) {
|
||||
localStt.cancel()
|
||||
}
|
||||
if (::localTts.isInitialized) {
|
||||
localTts.stop()
|
||||
}
|
||||
resultPanel?.let { panel ->
|
||||
try {
|
||||
windowManager?.removeView(panel)
|
||||
@@ -494,6 +536,7 @@ class CrklAccessibilityService : AccessibilityService() {
|
||||
overlayView = null
|
||||
}
|
||||
if (::localLlm.isInitialized) localLlm.close()
|
||||
if (::localTts.isInitialized) localTts.shutdown()
|
||||
serviceScope.cancel()
|
||||
Log.d(tagName, "Service destroyed")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.example.crkl.agent
|
||||
|
||||
/**
|
||||
* Contextual “What can I do with this?” hints for the result panel.
|
||||
*/
|
||||
object ActionHints {
|
||||
|
||||
fun forText(raw: String): String {
|
||||
val text = raw.trim()
|
||||
if (text.isBlank()) {
|
||||
return "Circle some text first — then I can translate, copy, read it aloud, or add a todo."
|
||||
}
|
||||
val lower = text.lowercase()
|
||||
val lines = buildList {
|
||||
add("With this selection you can:")
|
||||
add("• Read aloud — hear it spoken on-device")
|
||||
add("• Translate — another language without leaving the screen")
|
||||
add("• Copy — paste anywhere")
|
||||
when {
|
||||
"subject:" in lower || "from:" in lower -> {
|
||||
add("• Todo — drop the subject into Vikunja")
|
||||
add("• Explain — plain-language summary of the mail")
|
||||
add("• Share — send the snippet onward")
|
||||
}
|
||||
text.lineSequence().count {
|
||||
val t = it.trim()
|
||||
t.startsWith("•") || t.startsWith("-") || t.matches(Regex("""\d+\..*"""))
|
||||
} >= 2 -> {
|
||||
add("• Todo — turn the list into Vikunja tasks")
|
||||
add("• Share — send the list")
|
||||
}
|
||||
text.length < 48 -> {
|
||||
add("• Explain — what this phrase means")
|
||||
add("• Share — pass the phrase along")
|
||||
}
|
||||
else -> {
|
||||
add("• Explain — shorter reading")
|
||||
add("• Todo / Share — save or send")
|
||||
}
|
||||
}
|
||||
add("")
|
||||
add("Tip: press and hold any icon to see its name.")
|
||||
}
|
||||
return lines.joinToString("\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.example.crkl.model
|
||||
|
||||
import android.content.Context
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.util.Log
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* On-device read-aloud via [TextToSpeech]. No network.
|
||||
*/
|
||||
class DeviceTts(context: Context) {
|
||||
|
||||
private val tagName = "DeviceTts"
|
||||
private val appContext = context.applicationContext
|
||||
@Volatile
|
||||
private var ready = false
|
||||
private var tts: TextToSpeech? = null
|
||||
|
||||
init {
|
||||
tts = TextToSpeech(appContext) { status ->
|
||||
ready = status == TextToSpeech.SUCCESS
|
||||
if (ready) {
|
||||
tts?.language = Locale.getDefault()
|
||||
Log.d(tagName, "TTS ready lang=${Locale.getDefault()}")
|
||||
} else {
|
||||
Log.w(tagName, "TTS init failed status=$status")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isAvailable: Boolean
|
||||
get() = ready
|
||||
|
||||
fun speak(text: String) {
|
||||
val engine = tts ?: return
|
||||
val cleaned = text.trim().take(3_500)
|
||||
if (cleaned.isBlank()) return
|
||||
engine.stop()
|
||||
// Even if ready flag lags, speak() is safe — engine queues after init.
|
||||
engine.speak(cleaned, TextToSpeech.QUEUE_FLUSH, null, "circle-read")
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
tts?.stop()
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
tts?.stop()
|
||||
tts?.shutdown()
|
||||
tts = null
|
||||
ready = false
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import android.widget.TextView
|
||||
import com.example.crkl.R
|
||||
|
||||
/**
|
||||
* Bottom-sheet style result card: title, text, five VIP chips, quiet status.
|
||||
* Bottom-sheet style result card: title, text, icon actions, quiet status.
|
||||
*/
|
||||
class ResultPanelView(
|
||||
context: Context,
|
||||
@@ -28,7 +28,9 @@ class ResultPanelView(
|
||||
private val onAddTodo: (() -> Unit)? = null,
|
||||
private val onTranslate: (() -> Unit)? = null,
|
||||
private val onCopy: (() -> Unit)? = null,
|
||||
private val onExplain: (() -> Unit)? = null
|
||||
private val onExplain: (() -> Unit)? = null,
|
||||
private val onReadAloud: (() -> Unit)? = null,
|
||||
private val onIdeas: (() -> Unit)? = null
|
||||
) : LinearLayout(context) {
|
||||
|
||||
private val titleView: TextView
|
||||
@@ -156,6 +158,12 @@ class ResultPanelView(
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
contentDescription = label
|
||||
// Hold to learn the icon name (overlay has no system tooltip chrome).
|
||||
setOnLongClickListener {
|
||||
android.widget.Toast.makeText(context, label, android.widget.Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
true
|
||||
}
|
||||
setOnClickListener {
|
||||
statusView.visibility = VISIBLE
|
||||
statusView.setTextColor(CrklUi.Mist)
|
||||
@@ -165,7 +173,7 @@ class ResultPanelView(
|
||||
}
|
||||
}
|
||||
|
||||
// VIP only — Email / Calendar stay on voice
|
||||
// Order: core → hear → share/save → discover
|
||||
if (onTranslate != null) {
|
||||
actionsRow.addView(
|
||||
actionChip("Translate", R.drawable.ic_chip_translate, CrklUi.TealDeep, onTranslate)
|
||||
@@ -181,6 +189,11 @@ class ResultPanelView(
|
||||
actionChip("Explain", R.drawable.ic_chip_explain, CrklUi.Ink, onExplain)
|
||||
)
|
||||
}
|
||||
if (onReadAloud != null) {
|
||||
actionsRow.addView(
|
||||
actionChip("Read aloud", R.drawable.ic_chip_read, CrklUi.Teal, onReadAloud)
|
||||
)
|
||||
}
|
||||
if (onShareList != null) {
|
||||
actionsRow.addView(
|
||||
actionChip("Share", R.drawable.ic_chip_share, CrklUi.Mist, onShareList)
|
||||
@@ -191,6 +204,11 @@ class ResultPanelView(
|
||||
actionChip("Todo", R.drawable.ic_chip_todo, CrklUi.Ok, onAddTodo)
|
||||
)
|
||||
}
|
||||
if (onIdeas != null) {
|
||||
actionsRow.addView(
|
||||
actionChip("What can I do?", R.drawable.ic_chip_ideas, CrklUi.InkSoft, onIdeas)
|
||||
)
|
||||
}
|
||||
|
||||
val actionsScroll = object : HorizontalScrollView(context) {
|
||||
override fun onInterceptTouchEvent(ev: android.view.MotionEvent): Boolean {
|
||||
@@ -263,6 +281,9 @@ class ResultPanelView(
|
||||
if (!entered) playEnter()
|
||||
}
|
||||
|
||||
/** Text currently shown — for Read aloud / Ideas. */
|
||||
fun currentBody(): String = bodyView.text?.toString().orEmpty()
|
||||
|
||||
fun appendBody(extra: String) {
|
||||
val current = bodyView.text?.toString().orEmpty()
|
||||
bodyView.text = if (current.isBlank()) extra else "$current\n\n$extra"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Material content_copy -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
@@ -6,5 +7,5 @@
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M7,4h10c1.1,0 2,0.9 2,2v14l-3,-2 -3,2 -3,-2 -3,2V6c0,-1.1 0.9,-2 2,-2zM9,8h6v2H9V8zM9,12h6v2H9v-2z" />
|
||||
android:pathData="M16,1H4c-1.1,0 -2,0.9 -2,2v14h2V3h12V1zM19,5H8c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2V7c0,-1.1 -0.9,-2 -2,-2zM19,21H8V7h11v14z" />
|
||||
</vector>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Material lightbulb -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
@@ -6,5 +7,5 @@
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,3a7,7 0 0 1 7,7c0,2.6 -1.4,4.4 -3.2,6.1 -0.7,0.7 -1.4,1.3 -1.9,2.1h-3.8c-0.5,-0.8 -1.2,-1.4 -1.9,-2.1C6.4,14.4 5,12.6 5,10a7,7 0 0 1 7,-7zM10,20h4v2h-4v-2z" />
|
||||
android:pathData="M9,21c0,0.55 0.45,1 1,1h4c0.55,0 1,-0.45 1,-1v-1H9v1zM12,2C8.14,2 5,5.14 5,9c0,2.38 1.19,4.47 3,5.74V17c0,0.55 0.45,1 1,1h6c0.55,0 1,-0.45 1,-1v-2.26c1.81,-1.27 3,-3.36 3,-5.74 0,-3.86 -3.14,-7 -7,-7z" />
|
||||
</vector>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Material help_outline — What can I do -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,19h-2v-2h2v2zM15.07,11.25l-0.9,0.92C13.45,12.9 13,13.5 13,15h-2v-0.5c0,-1.1 0.45,-2.1 1.17,-2.83l1.24,-1.26c0.37,-0.36 0.59,-0.86 0.59,-1.41 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2H8c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,0.88 -0.36,1.68 -0.93,2.25z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Material volume_up — Read aloud -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z" />
|
||||
</vector>
|
||||
@@ -1,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Material share (three nodes) -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
@@ -6,5 +7,5 @@
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M14,4v2h3.6L12,11.6 6.4,6H10V4H4v6h2V7.4L12,13.4 18,7.4V10h2V4h-6zM5,16h14v2H5v-2zM5,20h10v2H5v-2z" />
|
||||
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z" />
|
||||
</vector>
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Material-style g_translate: Latin A + CJK mark -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<!-- Simple A⇄文 mark -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M3,6h6v2H5.5l2.2,6h2.1L12,8h2l-3.2,9H8.6L6.5,11.2 4.4,17H2.2L5.4,8H3V6z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M14,7h7v2h-2.1l-0.9,2.4h2.2v2h-2.9L16,17h-2.2l1.5-3.6H13V11h1.9L16,7z" />
|
||||
android:pathData="M12.87,15.07l-2.54,-2.51 0.03,-0.03A17.5,17.5 0 0 0 14.07,6H17V4h-7V2H8v2H1v1.99h11.17C11.5,7.92 10.44,9.75 9,11.35 8.07,10.32 7.3,9.19 6.69,8h-2c0.73,1.63 1.73,3.17 2.98,4.56l-5.09,5.02L4,19l5,-5 3.11,3.11 0.76,-2.04zM18.5,10h-2L12,22h2l1.12,-3h4.75L21,22h2l-4.5,-12zM15.88,17l1.62,-4.33L19.12,17h-3.24z" />
|
||||
</vector>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.example.crkl.agent
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ActionHintsTest {
|
||||
|
||||
@Test
|
||||
fun email_suggestsTodoAndRead() {
|
||||
val hints = ActionHints.forText(
|
||||
"From: alex@example.com\nSubject: Q2 planning moved\nHi there"
|
||||
)
|
||||
assertTrue(hints.contains("Read aloud"))
|
||||
assertTrue(hints.contains("Todo"))
|
||||
assertTrue(hints.contains("press and hold", ignoreCase = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun empty_promptsCircleFirst() {
|
||||
val hints = ActionHints.forText(" ")
|
||||
assertTrue(hints.contains("Circle some text", ignoreCase = true))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# Playful onboarding (Circle quest)
|
||||
|
||||
Goal: first-run feels like a tiny game, not a settings dump.
|
||||
|
||||
## Loop (≈90s)
|
||||
|
||||
| Step | Prompt | Win |
|
||||
|------|--------|-----|
|
||||
| 1 | “Circle anything with the floating **C**” | First closed loop |
|
||||
| 2 | “Hold an icon — learn its name” | Long-press toast |
|
||||
| 3 | “Try **Read aloud** or **Translate**” | One action |
|
||||
| 4 | Optional: “Open fixtures → email → Todo” | Vikunja configured |
|
||||
|
||||
## Tone
|
||||
|
||||
- Short, cheeky copy (“Nice loop.” / “You’re circling.”)
|
||||
- Progress dots (1/3 · 2/3 · 3/3), not a points economy
|
||||
- Skip always available; never block Overlay enable
|
||||
|
||||
## Avoid
|
||||
|
||||
- Streaks / leaderboards / XP (wrong product)
|
||||
- Multi-screen wizard before Accessibility is on
|
||||
|
||||
## Ship next
|
||||
|
||||
Wire quest state into `IntegrationSettings` + MainActivity card that advances on first successful circle (service → broadcast / prefs).
|
||||
Reference in New Issue
Block a user