Volume Controls, Mute/Unmute Video
* Added a top panel to put your video controls in. * Broadcaster can mute or unmute their own audio input. * When viewing others' cams, buttons appear to control their video: * Their username is displayed in the corner. * Mute/unmute button to silence their audio. * "X" button to close their camera. * Button to show what viewers are currently watching your camera. * Add an "About" page and config for app branding. * Add dark theme CSS for prefers-dark browsers.
This commit is contained in:
parent
1ecff195ac
commit
d8de60c990
|
@ -18,6 +18,7 @@ type Config struct {
|
|||
SecretKey string
|
||||
}
|
||||
|
||||
Title string
|
||||
WebsiteURL string
|
||||
|
||||
PublicChannels []Channel
|
||||
|
@ -46,6 +47,7 @@ var Current = DefaultConfig()
|
|||
// settings.toml file to disk.
|
||||
func DefaultConfig() Config {
|
||||
var c = Config{
|
||||
Title: "BareRTC",
|
||||
WebsiteURL: "https://www.example.com",
|
||||
PublicChannels: []Channel{
|
||||
{
|
||||
|
|
|
@ -198,3 +198,33 @@ func (s *Server) OnSDP(sub *Subscriber, msg Message) {
|
|||
Description: msg.Description,
|
||||
})
|
||||
}
|
||||
|
||||
// OnWatch communicates video watching status between users.
|
||||
func (s *Server) OnWatch(sub *Subscriber, msg Message) {
|
||||
// Look up the other subscriber.
|
||||
other, err := s.GetSubscriber(msg.Username)
|
||||
if err != nil {
|
||||
sub.ChatServer(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
other.SendJSON(Message{
|
||||
Action: ActionWatch,
|
||||
Username: sub.Username,
|
||||
})
|
||||
}
|
||||
|
||||
// OnUnwatch communicates video Unwatching status between users.
|
||||
func (s *Server) OnUnwatch(sub *Subscriber, msg Message) {
|
||||
// Look up the other subscriber.
|
||||
other, err := s.GetSubscriber(msg.Username)
|
||||
if err != nil {
|
||||
sub.ChatServer(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
other.SendJSON(Message{
|
||||
Action: ActionUnwatch,
|
||||
Username: sub.Username,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -32,6 +32,8 @@ const (
|
|||
ActionMe = "me" // user self-info sent by FE or BE
|
||||
ActionOpen = "open" // user wants to view a webcam (open WebRTC)
|
||||
ActionRing = "ring" // receiver of a WebRTC open request
|
||||
ActionWatch = "watch" // user has received video and is watching you
|
||||
ActionUnwatch = "unwatch" // user has closed your video
|
||||
|
||||
// Actions sent by server only
|
||||
ActionPing = "ping"
|
||||
|
|
38
pkg/pages.go
38
pkg/pages.go
|
@ -61,12 +61,9 @@ func IndexPage() http.HandlerFunc {
|
|||
}
|
||||
|
||||
tmpl.Funcs(template.FuncMap{
|
||||
// Cache busting random string for JS and CSS dependency.
|
||||
// "CacheHash": func() string {
|
||||
// return util.RandomString(8)
|
||||
// },
|
||||
|
||||
//
|
||||
"AsHTML": func(v string) template.HTML {
|
||||
return template.HTML(v)
|
||||
},
|
||||
})
|
||||
tmpl, err := tmpl.ParseFiles("web/templates/chat.html")
|
||||
if err != nil {
|
||||
|
@ -78,3 +75,32 @@ func IndexPage() http.HandlerFunc {
|
|||
tmpl.ExecuteTemplate(w, "index", values)
|
||||
})
|
||||
}
|
||||
|
||||
// AboutPage returns the HTML template for the about page.
|
||||
func AboutPage() http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Load the template, TODO: once on server startup.
|
||||
tmpl := template.New("index")
|
||||
|
||||
// Variables to give to the front-end page.
|
||||
var values = map[string]interface{}{
|
||||
// A cache-busting hash for JS and CSS includes.
|
||||
"CacheHash": util.RandomString(8),
|
||||
|
||||
// The current website settings.
|
||||
"Config": config.Current,
|
||||
}
|
||||
|
||||
tmpl.Funcs(template.FuncMap{
|
||||
"AsHTML": func(v string) template.HTML {
|
||||
return template.HTML(v)
|
||||
},
|
||||
})
|
||||
tmpl, err := tmpl.ParseFiles("web/templates/about.html")
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
|
||||
tmpl.ExecuteTemplate(w, "index", values)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ func (s *Server) Setup() error {
|
|||
var mux = http.NewServeMux()
|
||||
|
||||
mux.Handle("/", IndexPage())
|
||||
mux.Handle("/about", AboutPage())
|
||||
mux.Handle("/ws", s.WebSocket())
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
|
||||
|
||||
|
|
|
@ -73,6 +73,10 @@ func (sub *Subscriber) ReadLoop(s *Server) {
|
|||
s.OnCandidate(sub, msg)
|
||||
case ActionSDP:
|
||||
s.OnSDP(sub, msg)
|
||||
case ActionWatch:
|
||||
s.OnWatch(sub, msg)
|
||||
case ActionUnwatch:
|
||||
s.OnUnwatch(sub, msg)
|
||||
default:
|
||||
sub.ChatServer("Unsupported message type.")
|
||||
}
|
||||
|
|
8816
web/static/css/bulma-prefers-dark.css
Normal file
8816
web/static/css/bulma-prefers-dark.css
Normal file
File diff suppressed because it is too large
Load Diff
|
@ -28,14 +28,22 @@ body {
|
|||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 0, 0, 0.2);
|
||||
padding: 10px;
|
||||
|
||||
background: rgb(190,190,190);
|
||||
background: linear-gradient(0deg, rgb(172, 172, 172) 0%, rgb(214, 214, 214) 100%);
|
||||
|
||||
display: grid;
|
||||
column-gap: 10px;
|
||||
row-gap: 10px;
|
||||
grid-template-columns: 260px 1fr 280px;
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
/* Header row */
|
||||
.chat-container > .chat-header {
|
||||
grid-column: 1 / 4;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
/* Left column: DMs and channels */
|
||||
|
@ -47,7 +55,7 @@ body {
|
|||
/* Main column: chat history */
|
||||
.chat-container > .chat-column {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
grid-row: 2;
|
||||
background-color: yellow;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
@ -55,7 +63,7 @@ body {
|
|||
/* Footer row: message entry box */
|
||||
.chat-container > .chat-footer {
|
||||
grid-column: 1 / 4;
|
||||
grid-row: 2 / 2;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
/* Right column: Who List */
|
||||
|
@ -118,7 +126,7 @@ body {
|
|||
*******************/
|
||||
|
||||
.video-feeds {
|
||||
background-color: yellow;
|
||||
background-color: #222;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: scroll;
|
||||
|
@ -128,9 +136,38 @@ body {
|
|||
}
|
||||
|
||||
.video-feeds > .feed {
|
||||
flex: 10 0 auto;
|
||||
width: 120px;
|
||||
height: 80px;
|
||||
position: relative;
|
||||
flex: 0 0 144px;
|
||||
width: 168px;
|
||||
height: 112px;
|
||||
background-color: black;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.video-feeds > .feed > video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video-feeds > .feed > .controls {
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
}
|
||||
|
||||
.video-feeds > .feed > .close {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.video-feeds > .feed > .caption {
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: #fff;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
font-size: small;
|
||||
padding: 2px 4px;
|
||||
}
|
|
@ -47,12 +47,17 @@ const app = Vue.createApp({
|
|||
active: false,
|
||||
elem: null, // <video id="localVideo"> element
|
||||
stream: null, // MediaStream object
|
||||
muted: false, // our outgoing mic is muted, not by default
|
||||
|
||||
// Who all is watching me? map of users.
|
||||
watching: {},
|
||||
},
|
||||
|
||||
// WebRTC sessions with other users.
|
||||
WebRTC: {
|
||||
// Streams per username.
|
||||
streams: {},
|
||||
muted: {}, // muted bool per username
|
||||
|
||||
// RTCPeerConnections per username.
|
||||
pc: {},
|
||||
|
@ -113,7 +118,7 @@ const app = Vue.createApp({
|
|||
this.initHistory(channel.ID);
|
||||
}
|
||||
|
||||
this.ChatServer("Welcome to BareRTC!")
|
||||
this.ChatClient("Welcome to BareRTC!");
|
||||
|
||||
// Auto login with JWT token?
|
||||
// TODO: JWT validation on the WebSocket as well.
|
||||
|
@ -237,6 +242,12 @@ const app = Vue.createApp({
|
|||
this.closeVideo(row.username);
|
||||
}
|
||||
}
|
||||
|
||||
// Has the back-end server forgotten we are on video? This can
|
||||
// happen if we disconnect/reconnect while we were streaming.
|
||||
if (this.webcam.active && !this.whoMap[this.username]?.videoActive) {
|
||||
this.sendMe();
|
||||
}
|
||||
},
|
||||
|
||||
// Send a video request to access a user's camera.
|
||||
|
@ -361,6 +372,12 @@ const app = Vue.createApp({
|
|||
case "sdp":
|
||||
this.onSDP(msg);
|
||||
break;
|
||||
case "watch":
|
||||
this.onWatch(msg);
|
||||
break;
|
||||
case "unwatch":
|
||||
this.onUnwatch(msg);
|
||||
break;
|
||||
case "error":
|
||||
this.pushHistory({
|
||||
channel: msg.channel,
|
||||
|
@ -414,7 +431,7 @@ const app = Vue.createApp({
|
|||
// this.ChatClient("We are the offerer - set up onNegotiationNeeded");
|
||||
pc.onnegotiationneeded = () => {
|
||||
console.error("WebRTC OnNegotiationNeeded called!");
|
||||
this.ChatClient("Negotiation Needed, creating WebRTC offer.");
|
||||
// this.ChatClient("Negotiation Needed, creating WebRTC offer.");
|
||||
pc.createOffer().then(this.localDescCreated(pc, username)).catch(this.ChatClient);
|
||||
};
|
||||
}
|
||||
|
@ -433,12 +450,14 @@ const app = Vue.createApp({
|
|||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
this.ChatServer("Setting <video> srcObject for " + username);
|
||||
let $ref = document.getElementById(`videofeed-${username}`);
|
||||
console.log("Video elem:", $ref);
|
||||
$ref.srcObject = stream;
|
||||
// this.$refs[`videofeed-${username}`].srcObject = stream;
|
||||
});
|
||||
|
||||
// Inform them they are being watched.
|
||||
this.sendWatch(username, true);
|
||||
};
|
||||
|
||||
// If we were already broadcasting video, send our stream to
|
||||
|
@ -535,6 +554,21 @@ const app = Vue.createApp({
|
|||
}
|
||||
}, console.error);
|
||||
},
|
||||
onWatch(msg) {
|
||||
// The user has our video feed open now.
|
||||
this.webcam.watching[msg.username] = true;
|
||||
},
|
||||
onUnwatch(msg) {
|
||||
// The user has closed our video feed.
|
||||
delete(this.webcam.watching[msg.username]);
|
||||
},
|
||||
sendWatch(username, watching) {
|
||||
// Send the watch or unwatch message to backend.
|
||||
this.ws.conn.send(JSON.stringify({
|
||||
action: watching ? "watch" : "unwatch",
|
||||
username: username,
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Front-end web app concerns.
|
||||
|
@ -689,27 +723,37 @@ const app = Vue.createApp({
|
|||
// Camera is already open? Then disconnect the connection.
|
||||
if (this.WebRTC.pc[user.username] != undefined) {
|
||||
// TODO: this breaks the connection both ways :(
|
||||
// this.closeVideo(user.username);
|
||||
// return;
|
||||
}
|
||||
|
||||
// We need to broadcast video to connect to another.
|
||||
// TODO: because if the offerer doesn't add video tracks they
|
||||
// won't request video support so the answerer's video isn't sent
|
||||
if (!this.webcam.active) {
|
||||
this.ChatServer("You will need to turn your own camera on first before you can connect to " + user.username + ".");
|
||||
// return;
|
||||
this.closeVideo(user.username);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendOpen(user.username);
|
||||
|
||||
// Responsive CSS -> go to chat panel to see the camera
|
||||
this.openChatPanel();
|
||||
},
|
||||
closeVideo(username) {
|
||||
// A user has logged off the server. Clean up any WebRTC connections.
|
||||
delete (this.WebRTC.streams[username]);
|
||||
delete (this.webcam.watching[username]);
|
||||
if (this.WebRTC.pc[username] != undefined) {
|
||||
this.WebRTC.pc[username].close();
|
||||
delete (this.WebRTC.pc[username]);
|
||||
}
|
||||
|
||||
// Inform backend we have closed it.
|
||||
this.sendWatch(username, false);
|
||||
},
|
||||
|
||||
// Show who watches our video.
|
||||
showViewers() {
|
||||
// TODO: for now, ChatClient is our bro.
|
||||
let users = Object.keys(this.webcam.watching);
|
||||
if (users.length === 0) {
|
||||
this.ChatClient("There is currently nobody viewing your camera.");
|
||||
} else {
|
||||
this.ChatClient("Your current webcam viewers are:<br><br>" + users.join(", "));
|
||||
}
|
||||
},
|
||||
|
||||
// Stop broadcasting.
|
||||
|
@ -727,6 +771,29 @@ const app = Vue.createApp({
|
|||
this.sendMe();
|
||||
},
|
||||
|
||||
// Mute my microphone if broadcasting.
|
||||
muteMe() {
|
||||
this.webcam.muted = !this.webcam.muted;
|
||||
this.webcam.stream.getAudioTracks().forEach(track => {
|
||||
console.error("Set audio track enabled=%s", !this.webcam.muted, track);
|
||||
track.enabled = !this.webcam.muted;
|
||||
});
|
||||
},
|
||||
isMuted(username) {
|
||||
return this.WebRTC.muted[username] === true;
|
||||
},
|
||||
muteVideo(username) {
|
||||
this.WebRTC.muted[username] = !this.isMuted(username);
|
||||
console.error("muteVideo(%s) is not muted=%s", username, this.WebRTC.muted[username]);
|
||||
|
||||
// Find the <video> tag to mute it.
|
||||
let $ref = document.getElementById(`videofeed-${username}`);
|
||||
console.log("Video elem:", $ref);
|
||||
if ($ref) {
|
||||
$ref.muted = this.WebRTC.muted[username];
|
||||
}
|
||||
},
|
||||
|
||||
initHistory(channel) {
|
||||
if (this.channels[channel] == undefined) {
|
||||
this.channels[channel] = {
|
||||
|
|
55
web/templates/about.html
Normal file
55
web/templates/about.html
Normal file
|
@ -0,0 +1,55 @@
|
|||
{{define "index"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/static/css/bulma.min.css">
|
||||
<link rel="stylesheet" type="text/css" href="/static/css/bulma-prefers-dark.css">
|
||||
<link rel="stylesheet" href="/static/fontawesome-free-6.1.2-web/css/all.css">
|
||||
<link rel="stylesheet" type="text/css" href="/static/css/chat.css?{{.CacheHash}}">
|
||||
<title>About BareRTC</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container is-fullhd">
|
||||
<div class="content mt-5">
|
||||
<h1>About BareRTC</h1>
|
||||
|
||||
<p>
|
||||
This chat room software is called <strong>BareRTC</strong> and this
|
||||
page contains information about the software and how to use it.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
BareRTC is an open source project released under the GNU General Public
|
||||
License with code available
|
||||
<a href="https://git.kirsle.net/apps/BareRTC" target="_blank">here</a>.
|
||||
</p>
|
||||
|
||||
<h1>About {{AsHTML .Config.Title}}</h1>
|
||||
|
||||
<p>
|
||||
<strong>{{AsHTML .Config.Title}}</strong> is the name of this particular
|
||||
BareRTC server. The administrator may have left some links to more
|
||||
info below:
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Website:</strong>
|
||||
<a href="{{.Config.WebsiteURL}}" target="_blank">{{.Config.WebsiteURL}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<p>
|
||||
This page will be fleshed out later with help and tips for using this
|
||||
chat room.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
|
@ -5,6 +5,7 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/static/css/bulma.min.css">
|
||||
<link rel="stylesheet" type="text/css" href="/static/css/bulma-prefers-dark.css">
|
||||
<link rel="stylesheet" href="/static/fontawesome-free-6.1.2-web/css/all.css">
|
||||
<link rel="stylesheet" type="text/css" href="/static/css/chat.css?{{.CacheHash}}">
|
||||
<title>BareRTC</title>
|
||||
|
@ -46,6 +47,61 @@
|
|||
|
||||
<div class="chat-container">
|
||||
|
||||
<!-- Top header panel -->
|
||||
<header class="chat-header">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column is-narrow">
|
||||
<strong class="is-6">{{AsHTML .Config.Title}}</strong>
|
||||
</div>
|
||||
<div class="column">
|
||||
<!-- Stop/Start video buttons -->
|
||||
<button type="button"
|
||||
v-if="webcam.active"
|
||||
class="button is-small is-danger"
|
||||
@click="stopVideo()">
|
||||
<i class="fa fa-camera mr-2"></i>
|
||||
Stop
|
||||
</button>
|
||||
<button type="button"
|
||||
v-else
|
||||
class="button is-small is-success"
|
||||
@click="startVideo()"
|
||||
:disabled="webcam.busy">
|
||||
<i class="fa fa-camera mr-2"></i>
|
||||
Start
|
||||
</button>
|
||||
|
||||
<!-- Mute/Unmute my mic buttons (if streaming)-->
|
||||
<button type="button"
|
||||
v-if="webcam.active && !webcam.muted"
|
||||
class="button is-small is-danger is-outlined ml-2"
|
||||
@click="muteMe()">
|
||||
<i class="fa fa-microphone mr-2"></i>
|
||||
Mute
|
||||
</button>
|
||||
<button type="button"
|
||||
v-if="webcam.active && webcam.muted"
|
||||
class="button is-small is-danger ml-2"
|
||||
@click="muteMe()">
|
||||
<i class="fa fa-microphone mr-2"></i>
|
||||
Unmute
|
||||
</button>
|
||||
|
||||
<!-- Watchers button -->
|
||||
<button type="button"
|
||||
v-if="webcam.active"
|
||||
class="button is-small is-info is-outlined ml-2"
|
||||
@click="showViewers()">
|
||||
<i class="fa fa-eye mr-2"></i>
|
||||
[[Object.keys(webcam.watching).length]]
|
||||
</button>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<a href="/about" target="_blank" class="button is-small is-link">About</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Left Column: Channels & DMs -->
|
||||
<div class="left-column">
|
||||
<div class="card grid-card">
|
||||
|
@ -107,6 +163,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Middle Column: Chat Room/History -->
|
||||
<div class="chat-column">
|
||||
<div class="card grid-card">
|
||||
<header class="card-header has-background-link">
|
||||
|
@ -154,18 +211,49 @@
|
|||
</div>
|
||||
</header>
|
||||
<div class="video-feeds" v-show="webcam.active || Object.keys(WebRTC.streams).length > 0">
|
||||
<!-- Video Feeds-->
|
||||
|
||||
<!-- My video -->
|
||||
<video class="feed"
|
||||
v-show="webcam.active"
|
||||
id="localVideo"
|
||||
autoplay muted>
|
||||
x
|
||||
</video>
|
||||
|
||||
<!-- Others' videos -->
|
||||
<div class="feed" v-for="(stream, username) in WebRTC.streams" v-bind:key="username">
|
||||
<video class="feed"
|
||||
v-for="(stream, username) in WebRTC.streams"
|
||||
v-bind:key="username"
|
||||
:id="'videofeed-'+username"
|
||||
autoplay muted>
|
||||
autoplay>
|
||||
</video>
|
||||
<div class="caption">
|
||||
[[username]]
|
||||
</div>
|
||||
<div class="close">
|
||||
<a href="#"
|
||||
class="has-text-danger"
|
||||
title="Close video"
|
||||
@click="closeVideo(username)">
|
||||
<i class="fa fa-close"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button type="button"
|
||||
v-if="isMuted(username)"
|
||||
class="button is-small is-danger is-outlined p-1"
|
||||
title="Unmute this video"
|
||||
@click="muteVideo(username)">
|
||||
<i class="fa fa-volume-xmark"></i>
|
||||
</button>
|
||||
<button type="button"
|
||||
v-else
|
||||
class="button is-small is-danger is-outlined p-1"
|
||||
title="Mute this video"
|
||||
@click="muteVideo(username)">
|
||||
<i class="fa fa-volume-high"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content" id="chatHistory">
|
||||
|
||||
|
@ -243,22 +331,7 @@
|
|||
</form>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<button type="button"
|
||||
v-if="webcam.active"
|
||||
class="button is-danger"
|
||||
@click="stopVideo()">
|
||||
<i class="fa fa-camera mr-2"></i>
|
||||
Stop
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
v-else
|
||||
class="button is-success"
|
||||
@click="startVideo()"
|
||||
:disabled="webcam.busy">
|
||||
<i class="fa fa-camera mr-2"></i>
|
||||
Start
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user