[add] progress & activity sync with web reader
This commit is contained in:
parent
8ecd6ad57d
commit
a972b5a8aa
@ -7,7 +7,7 @@ class EBookReader {
|
|||||||
pages: 0,
|
pages: 0,
|
||||||
percentage: 0,
|
percentage: 0,
|
||||||
progress: "",
|
progress: "",
|
||||||
readEvents: [],
|
readActivity: [],
|
||||||
words: 0,
|
words: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -34,25 +34,29 @@ class EBookReader {
|
|||||||
this.loadSettings();
|
this.loadSettings();
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
|
this.initDevice();
|
||||||
this.initThemes();
|
this.initThemes();
|
||||||
this.initRenditionListeners();
|
this.initRenditionListeners();
|
||||||
this.initDocumentListeners();
|
this.initDocumentListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load position and generate locations
|
* Load progress and generate locations
|
||||||
**/
|
**/
|
||||||
async setupReader() {
|
async setupReader() {
|
||||||
// Load Position
|
// Load Progress
|
||||||
let currentCFI = await this.fromPosition(this.bookState.progress);
|
let currentCFI = await this.fromProgress(this.bookState.progress);
|
||||||
if (!currentCFI) this.bookState.currentWord = 0;
|
if (!currentCFI) this.bookState.currentWord = 0;
|
||||||
await this.rendition.display(currentCFI);
|
await this.rendition.display(currentCFI);
|
||||||
|
|
||||||
// Start Timer
|
// Restore Theme
|
||||||
this.bookState.pageStart = Date.now();
|
this.setTheme(this.readerSettings.theme || "tan");
|
||||||
|
|
||||||
// Get Stats
|
|
||||||
let getStats = function () {
|
let getStats = function () {
|
||||||
|
// Start Timer
|
||||||
|
this.bookState.pageStart = Date.now();
|
||||||
|
|
||||||
|
// Get Stats
|
||||||
let stats = this.getBookStats();
|
let stats = this.getBookStats();
|
||||||
this.updateBookStats(stats);
|
this.updateBookStats(stats);
|
||||||
}.bind(this);
|
}.bind(this);
|
||||||
@ -62,6 +66,24 @@ class EBookReader {
|
|||||||
getStats();
|
getStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initDevice() {
|
||||||
|
function randomID() {
|
||||||
|
return "00000000000000000000000000000000".replace(/[018]/g, (c) =>
|
||||||
|
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))))
|
||||||
|
.toString(16)
|
||||||
|
.toUpperCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.readerSettings.deviceName =
|
||||||
|
this.readerSettings.deviceName ||
|
||||||
|
platform.os.toString() + " - " + platform.name;
|
||||||
|
|
||||||
|
this.readerSettings.deviceID = this.readerSettings.deviceID || randomID();
|
||||||
|
|
||||||
|
this.saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all themes with reader
|
* Register all themes with reader
|
||||||
**/
|
**/
|
||||||
@ -70,8 +92,20 @@ class EBookReader {
|
|||||||
THEMES.forEach((theme) =>
|
THEMES.forEach((theme) =>
|
||||||
this.rendition.themes.register(theme, THEME_FILE)
|
this.rendition.themes.register(theme, THEME_FILE)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.rendition.themes.select(this.readerSettings.theme || "tan");
|
/**
|
||||||
|
* Set theme & meta theme color
|
||||||
|
**/
|
||||||
|
setTheme(themeName) {
|
||||||
|
this.rendition.themes.select(themeName);
|
||||||
|
let themeColorEl = document.querySelector("[name='theme-color']");
|
||||||
|
let backgroundColor = window.getComputedStyle(
|
||||||
|
this.rendition.getContents()[0].content
|
||||||
|
).backgroundColor;
|
||||||
|
themeColorEl.setAttribute("content", backgroundColor);
|
||||||
|
document.body.style.backgroundColor = backgroundColor;
|
||||||
|
this.saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -288,8 +322,7 @@ class EBookReader {
|
|||||||
if (THEMES.length == currentThemeIdx + 1)
|
if (THEMES.length == currentThemeIdx + 1)
|
||||||
readerSettings.theme = THEMES[0];
|
readerSettings.theme = THEMES[0];
|
||||||
else readerSettings.theme = THEMES[currentThemeIdx + 1];
|
else readerSettings.theme = THEMES[currentThemeIdx + 1];
|
||||||
this.themes.select(readerSettings.theme);
|
setTheme(readerSettings.theme);
|
||||||
this.setSettings();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
false
|
false
|
||||||
@ -326,10 +359,9 @@ class EBookReader {
|
|||||||
if (THEMES.length == currentThemeIdx + 1)
|
if (THEMES.length == currentThemeIdx + 1)
|
||||||
this.readerSettings.theme = THEMES[0];
|
this.readerSettings.theme = THEMES[0];
|
||||||
else this.readerSettings.theme = THEMES[currentThemeIdx + 1];
|
else this.readerSettings.theme = THEMES[currentThemeIdx + 1];
|
||||||
this.rendition.themes.select(readerSettings.theme);
|
this.setTheme(this.readerSettings.theme);
|
||||||
this.setSettings();
|
|
||||||
}
|
}
|
||||||
},
|
}.bind(this),
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -339,8 +371,8 @@ class EBookReader {
|
|||||||
"click",
|
"click",
|
||||||
function (event) {
|
function (event) {
|
||||||
this.readerSettings.theme = event.target.innerText;
|
this.readerSettings.theme = event.target.innerText;
|
||||||
this.rendition.themes.select(this.readerSettings.theme);
|
|
||||||
this.saveSettings();
|
this.setTheme(this.readerSettings.theme);
|
||||||
}.bind(this)
|
}.bind(this)
|
||||||
);
|
);
|
||||||
}.bind(this)
|
}.bind(this)
|
||||||
@ -355,16 +387,26 @@ class EBookReader {
|
|||||||
* Progresses to the next page & monitors reading activity
|
* Progresses to the next page & monitors reading activity
|
||||||
**/
|
**/
|
||||||
async nextPage() {
|
async nextPage() {
|
||||||
|
// Flush Activity
|
||||||
|
this.flushActivity();
|
||||||
|
|
||||||
// Get Elapsed Time
|
// Get Elapsed Time
|
||||||
let elapsedTime = Date.now() - this.bookState.pageStart;
|
let elapsedTime = Date.now() - this.bookState.pageStart;
|
||||||
|
|
||||||
// Update Current Word
|
// Update Current Word
|
||||||
let pageWords = await this.getVisibleWordCount();
|
let pageWords = await this.getVisibleWordCount();
|
||||||
let startingWord = this.bookState.currentWord;
|
let startingWord = this.bookState.currentWord;
|
||||||
|
let percentRead = pageWords / this.bookState.words;
|
||||||
this.bookState.currentWord += pageWords;
|
this.bookState.currentWord += pageWords;
|
||||||
|
|
||||||
// Add Read Event
|
// Add Read Event
|
||||||
this.bookState.readEvents.push({ startingWord, pageWords, elapsedTime });
|
this.bookState.readActivity.push({
|
||||||
|
percentRead,
|
||||||
|
startingWord,
|
||||||
|
pageWords,
|
||||||
|
elapsedTime,
|
||||||
|
startTime: this.bookState.pageStart,
|
||||||
|
});
|
||||||
|
|
||||||
// Render Next Page
|
// Render Next Page
|
||||||
await this.rendition.next();
|
await this.rendition.next();
|
||||||
@ -376,14 +418,18 @@ class EBookReader {
|
|||||||
let stats = this.getBookStats();
|
let stats = this.getBookStats();
|
||||||
this.updateBookStats(stats);
|
this.updateBookStats(stats);
|
||||||
|
|
||||||
// Test Position
|
// Update & Flush Progress
|
||||||
console.log(await this.toPosition());
|
this.bookState.progress = await this.toProgress();
|
||||||
|
this.flushProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Progresses to the previous page & monitors reading activity
|
* Progresses to the previous page & monitors reading activity
|
||||||
**/
|
**/
|
||||||
async prevPage() {
|
async prevPage() {
|
||||||
|
// Flush Activity
|
||||||
|
this.flushActivity();
|
||||||
|
|
||||||
// Render Previous Page
|
// Render Previous Page
|
||||||
await this.rendition.prev();
|
await this.rendition.prev();
|
||||||
|
|
||||||
@ -398,8 +444,119 @@ class EBookReader {
|
|||||||
let stats = this.getBookStats();
|
let stats = this.getBookStats();
|
||||||
this.updateBookStats(stats);
|
this.updateBookStats(stats);
|
||||||
|
|
||||||
// Test Position
|
// Update & Flush Progress
|
||||||
console.log(await this.toPosition());
|
this.bookState.progress = await this.toProgress();
|
||||||
|
this.flushProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize and flush activity
|
||||||
|
**/
|
||||||
|
async flushActivity() {
|
||||||
|
// Process & Reset Activity
|
||||||
|
let allActivity = this.bookState.readActivity;
|
||||||
|
this.bookState.readActivity = [];
|
||||||
|
|
||||||
|
const WPM_MAX = 2000;
|
||||||
|
const WPM_MIN = 100;
|
||||||
|
|
||||||
|
let normalizedActivity = allActivity
|
||||||
|
// Exclude Fast WPM
|
||||||
|
.filter((item) => item.pageWords / (item.elapsedTime / 60000) < WPM_MAX)
|
||||||
|
.map((item) => {
|
||||||
|
let pageWPM = item.pageWords / (item.elapsedTime / 60000);
|
||||||
|
|
||||||
|
// Min WPM
|
||||||
|
if (pageWPM < WPM_MIN) {
|
||||||
|
// TODO - Exclude Event?
|
||||||
|
item.elapsedTime = (item.pageWords / WPM_MIN) * 60000;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.pages = Math.round(1 / item.percentRead);
|
||||||
|
|
||||||
|
item.page = Math.round(
|
||||||
|
(item.startingWord * item.pages) / this.bookState.words
|
||||||
|
);
|
||||||
|
|
||||||
|
// Estimate Accuracy Loss (Debugging)
|
||||||
|
// let wordLoss = Math.abs(
|
||||||
|
// item.pageWords - this.bookState.words / item.pages
|
||||||
|
// );
|
||||||
|
// console.log("Word Loss:", wordLoss);
|
||||||
|
|
||||||
|
return {
|
||||||
|
document: this.bookState.id,
|
||||||
|
duration: Math.round(item.elapsedTime / 1000),
|
||||||
|
start_time: Math.round(item.startTime / 1000),
|
||||||
|
page: item.page,
|
||||||
|
pages: item.pages,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (normalizedActivity.length == 0) return;
|
||||||
|
|
||||||
|
console.log("Flushing Activity...");
|
||||||
|
|
||||||
|
// Create Activity Event
|
||||||
|
let activityEvent = {
|
||||||
|
device_id: this.readerSettings.deviceID,
|
||||||
|
device: this.readerSettings.deviceName,
|
||||||
|
activity: normalizedActivity,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flush Activity
|
||||||
|
fetch("/api/ko/activity", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(activityEvent),
|
||||||
|
})
|
||||||
|
.then(async (r) =>
|
||||||
|
console.log("Flushed Activity:", {
|
||||||
|
response: r,
|
||||||
|
json: await r.json(),
|
||||||
|
data: activityEvent,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch((e) =>
|
||||||
|
console.error("Activity Flush Failed:", {
|
||||||
|
error: e,
|
||||||
|
data: activityEvent,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async flushProgress() {
|
||||||
|
console.log("Flushing Progress...");
|
||||||
|
|
||||||
|
// Create Progress Event
|
||||||
|
let progressEvent = {
|
||||||
|
document: this.bookState.id,
|
||||||
|
device_id: this.readerSettings.deviceID,
|
||||||
|
device: this.readerSettings.deviceName,
|
||||||
|
percentage:
|
||||||
|
Math.round(
|
||||||
|
(this.bookState.currentWord / this.bookState.words) * 100000
|
||||||
|
) / 100000,
|
||||||
|
progress: this.bookState.progress,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flush Progress
|
||||||
|
fetch("/api/ko/syncs/progress", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(progressEvent),
|
||||||
|
})
|
||||||
|
.then(async (r) =>
|
||||||
|
console.log("Flushed Progress:", {
|
||||||
|
response: r,
|
||||||
|
json: await r.json(),
|
||||||
|
data: progressEvent,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch((e) =>
|
||||||
|
console.error("Progress Flush Failed:", {
|
||||||
|
error: e,
|
||||||
|
data: progressEvent,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -461,20 +618,17 @@ class EBookReader {
|
|||||||
progressStatus.innerText = `${data.percentage}%`;
|
progressStatus.innerText = `${data.percentage}%`;
|
||||||
progressBar.style.width = data.percentage + "%";
|
progressBar.style.width = data.percentage + "%";
|
||||||
chapterName.innerText = `${data.chapterName}`;
|
chapterName.innerText = `${data.chapterName}`;
|
||||||
|
|
||||||
// Do Update
|
|
||||||
// console.log(data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get XPath from current location
|
* Get XPath from current location
|
||||||
**/
|
**/
|
||||||
async toPosition() {
|
async toProgress() {
|
||||||
// Get DocFragment (current book spline index)
|
// Get DocFragment (current book spline index)
|
||||||
let currentPos = await this.rendition.currentLocation();
|
let currentPos = await this.rendition.currentLocation();
|
||||||
let docFragmentIndex = currentPos.start.index + 1;
|
let docFragmentIndex = currentPos.start.index + 1;
|
||||||
|
|
||||||
// Base Position
|
// Base Progress
|
||||||
let newPos = "/body/DocFragment[" + docFragmentIndex + "]/body";
|
let newPos = "/body/DocFragment[" + docFragmentIndex + "]/body";
|
||||||
|
|
||||||
// Get first visible node
|
// Get first visible node
|
||||||
@ -482,7 +636,7 @@ class EBookReader {
|
|||||||
let currentNode = contents.range(currentPos.start.cfi).startContainer
|
let currentNode = contents.range(currentPos.start.cfi).startContainer
|
||||||
.parentNode;
|
.parentNode;
|
||||||
|
|
||||||
// Walk upwards and build position until body
|
// Walk upwards and build progress until body
|
||||||
let childPos = "";
|
let childPos = "";
|
||||||
while (currentNode.nodeName != "BODY") {
|
while (currentNode.nodeName != "BODY") {
|
||||||
let relativeIndex =
|
let relativeIndex =
|
||||||
@ -499,32 +653,32 @@ class EBookReader {
|
|||||||
currentNode = currentNode.parentNode;
|
currentNode = currentNode.parentNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return derived position
|
// Return derived progress
|
||||||
return newPos + childPos;
|
return newPos + childPos;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get CFI from XPath
|
* Get CFI from XPath
|
||||||
**/
|
**/
|
||||||
async fromPosition(position) {
|
async fromProgress(progress) {
|
||||||
// Position Reference - Example: /body/DocFragment[15]/body/div[10]/text().184
|
// Progress Reference - Example: /body/DocFragment[15]/body/div[10]/text().184
|
||||||
//
|
//
|
||||||
// - /body/DocFragment[15] = 15th item in book spline
|
// - /body/DocFragment[15] = 15th item in book spline
|
||||||
// - [...]/body/div[10] = 10th child div under body (direct descendents only)
|
// - [...]/body/div[10] = 10th child div under body (direct descendents only)
|
||||||
// - [...]/text().184 = text node of parent, character offset @ 184 chars?
|
// - [...]/text().184 = text node of parent, character offset @ 184 chars?
|
||||||
|
|
||||||
// No Position
|
// No Progress
|
||||||
if (!position || position == "") return;
|
if (!progress || progress == "") return;
|
||||||
|
|
||||||
// Match Document Fragment Index
|
// Match Document Fragment Index
|
||||||
let fragMatch = position.match(/^\/body\/DocFragment\[(\d+)\]/);
|
let fragMatch = progress.match(/^\/body\/DocFragment\[(\d+)\]/);
|
||||||
if (!fragMatch) {
|
if (!fragMatch) {
|
||||||
console.warn("No Position Match");
|
console.warn("No Progress Match");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match Item Index
|
// Match Item Index
|
||||||
let indexMatch = position.match(/\.(\d+)$/);
|
let indexMatch = progress.match(/\.(\d+)$/);
|
||||||
let itemIndex = indexMatch ? parseInt(indexMatch[1]) : 0;
|
let itemIndex = indexMatch ? parseInt(indexMatch[1]) : 0;
|
||||||
|
|
||||||
// Get Spine Item
|
// Get Spine Item
|
||||||
@ -536,7 +690,7 @@ class EBookReader {
|
|||||||
|
|
||||||
// Derive XPath & Namespace
|
// Derive XPath & Namespace
|
||||||
let namespaceURI = docItem.document.documentElement.namespaceURI;
|
let namespaceURI = docItem.document.documentElement.namespaceURI;
|
||||||
let remainingXPath = position
|
let remainingXPath = progress
|
||||||
// Replace with new base
|
// Replace with new base
|
||||||
.replace(fragMatch[0], "/html")
|
.replace(fragMatch[0], "/html")
|
||||||
// Replace `.0` Ending Indexes
|
// Replace `.0` Ending Indexes
|
||||||
|
1260
assets/reader/platform.js
Normal file
1260
assets/reader/platform.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -2,21 +2,12 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<link rel="manifest" href="{{ .RelBase }}./manifest.json" />
|
<link rel="manifest" href="{{ .RelBase }}./manifest.json" />
|
||||||
<meta
|
<meta name="theme-color" content="#D2B48C" />
|
||||||
name="theme-color"
|
|
||||||
content="#F3F4F6"
|
|
||||||
media="(prefers-color-scheme: light)"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
name="theme-color"
|
|
||||||
content="#1F2937"
|
|
||||||
media="(prefers-color-scheme: dark)"
|
|
||||||
/>
|
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta
|
<meta
|
||||||
id="viewport"
|
id="viewport"
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||||
/>
|
/>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<title>Book Manager - {{block "title" .}}{{end}}</title>
|
<title>Book Manager - {{block "title" .}}{{end}}</title>
|
||||||
@ -37,10 +28,14 @@
|
|||||||
-ms-overflow-style: none; /* IE and Edge */
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#bottom-bar {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100 dark:bg-gray-800">
|
<body class="bg-gray-100 dark:bg-gray-800 h-[100dvh]">
|
||||||
<main class="relative h-[100dvh] overflow-hidden">
|
<main class="relative overflow-hidden">
|
||||||
<div
|
<div
|
||||||
id="top-bar"
|
id="top-bar"
|
||||||
class="-top-32 transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 flex items-center justify-around w-full h-32 px-2"
|
class="-top-32 transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 flex items-center justify-around w-full h-32 px-2"
|
||||||
@ -114,7 +109,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
id="bottom-bar"
|
id="bottom-bar"
|
||||||
class="-bottom-24 transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 items-center flex h-24 w-full overflow-y-scroll snap-x snap-mandatory no-scrollbar"
|
class="-bottom-28 transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 items-center flex w-full overflow-y-scroll snap-x snap-mandatory no-scrollbar"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="items-center flex flex-col w-screen h-full flex-none snap-center p-2"
|
class="items-center flex flex-col w-screen h-full flex-none snap-center p-2"
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
{{template "base.html" .}} {{define "title"}}Reader{{end}} {{define "header"}}
|
{{template "base.html" .}} {{define "title"}}Reader{{end}} {{define "header"}}
|
||||||
<a href="../">Documents</a>
|
<a href="../">Documents</a>
|
||||||
{{end}} {{define "content"}}
|
{{end}} {{define "content"}}
|
||||||
|
<script src="../../assets/reader/platform.js"></script>
|
||||||
<script src="../../assets/reader/jszip.min.js"></script>
|
<script src="../../assets/reader/jszip.min.js"></script>
|
||||||
<script src="../../assets/reader/epub.min.js"></script>
|
<script src="../../assets/reader/epub.min.js"></script>
|
||||||
<script src="../../assets/reader/index.js"></script>
|
<script src="../../assets/reader/index.js"></script>
|
||||||
@ -8,6 +9,7 @@
|
|||||||
<div id="hiddden-viewer" class="hidden"></div>
|
<div id="hiddden-viewer" class="hidden"></div>
|
||||||
<script>
|
<script>
|
||||||
let currentReader = new EBookReader("./file", {
|
let currentReader = new EBookReader("./file", {
|
||||||
|
id: "{{ .Data.ID }}",
|
||||||
words: {{ .Data.Words }},
|
words: {{ .Data.Words }},
|
||||||
pages: {{ .Data.Pages }},
|
pages: {{ .Data.Pages }},
|
||||||
progress: "{{ .Progress }}",
|
progress: "{{ .Progress }}",
|
||||||
|
Loading…
Reference in New Issue
Block a user