mirror of
https://github.com/Card-Forge/forge.git
synced 2025-11-19 20:28:00 +00:00
Add Net Decks support
This commit is contained in:
@@ -445,6 +445,14 @@ public class DeckProxy implements InventoryItem {
|
||||
return decks;
|
||||
}
|
||||
|
||||
public static List<DeckProxy> getNetDecks(NetDeckCategory category) {
|
||||
ArrayList<DeckProxy> decks = new ArrayList<DeckProxy>();
|
||||
if (category != null) {
|
||||
addDecksRecursivelly("Constructed", GameType.Constructed, decks, "", category);
|
||||
}
|
||||
return decks;
|
||||
}
|
||||
|
||||
public static final Predicate<DeckProxy> IS_WHITE = new Predicate<DeckProxy>() {
|
||||
@Override
|
||||
public boolean apply(final DeckProxy deck) {
|
||||
|
||||
@@ -6,7 +6,8 @@ public enum DeckType {
|
||||
QUEST_OPPONENT_DECK ("Quest Opponent Decks"),
|
||||
COLOR_DECK ("Random Color Decks"),
|
||||
THEME_DECK ("Random Theme Decks"),
|
||||
RANDOM_DECK ("Random Decks");
|
||||
RANDOM_DECK ("Random Decks"),
|
||||
NET_DECK ("Net Decks");
|
||||
|
||||
private String value;
|
||||
private DeckType(String value) {
|
||||
|
||||
127
forge-gui/src/main/java/forge/deck/NetDeckCategory.java
Normal file
127
forge-gui/src/main/java/forge/deck/NetDeckCategory.java
Normal file
@@ -0,0 +1,127 @@
|
||||
package forge.deck;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import forge.GuiBase;
|
||||
import forge.deck.io.DeckSerializer;
|
||||
import forge.deck.io.DeckStorage;
|
||||
import forge.download.GuiDownloadZipService;
|
||||
import forge.game.GameType;
|
||||
import forge.properties.ForgeConstants;
|
||||
import forge.util.FileUtil;
|
||||
import forge.util.WaitCallback;
|
||||
import forge.util.gui.SGuiChoose;
|
||||
import forge.util.storage.StorageBase;
|
||||
|
||||
public class NetDeckCategory extends StorageBase<Deck> {
|
||||
public static final String PREFIX = "NET_DECK_";
|
||||
private static Map<String, NetDeckCategory> constructed, commander;
|
||||
|
||||
private static Map<String, NetDeckCategory> loadCategories(String filename) {
|
||||
Map<String, NetDeckCategory> categories = new TreeMap<String, NetDeckCategory>();
|
||||
if (FileUtil.doesFileExist(filename)) {
|
||||
List<String> lines = FileUtil.readFile(filename);
|
||||
for (String line : lines) {
|
||||
int idx = line.indexOf('|');
|
||||
if (idx != -1) {
|
||||
String name = line.substring(0, idx).trim();
|
||||
String url = line.substring(idx + 1).trim();
|
||||
categories.put(name, new NetDeckCategory(name, url));
|
||||
}
|
||||
}
|
||||
}
|
||||
return categories;
|
||||
}
|
||||
|
||||
public static NetDeckCategory selectAndLoad(GameType gameType) {
|
||||
return selectAndLoad(gameType, null);
|
||||
}
|
||||
public static NetDeckCategory selectAndLoad(GameType gameType, String name) {
|
||||
Map<String, NetDeckCategory> categories;
|
||||
switch (gameType) {
|
||||
case Constructed:
|
||||
case Gauntlet:
|
||||
if (constructed == null) {
|
||||
constructed = loadCategories(ForgeConstants.NET_DECKS_LIST_FILE);
|
||||
}
|
||||
categories = constructed;
|
||||
break;
|
||||
case Commander:
|
||||
if (commander == null) {
|
||||
commander = loadCategories(ForgeConstants.NET_DECKS_COMMANDER_LIST_FILE);
|
||||
}
|
||||
categories = commander;
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
if (name != null) {
|
||||
NetDeckCategory category = categories.get(name);
|
||||
if (category != null && category.map.isEmpty()) {
|
||||
//if name passed in, try to load decks from current cached files
|
||||
File downloadDir = new File(category.getDownloadLocation());
|
||||
if (downloadDir.exists()) {
|
||||
for (File file : downloadDir.listFiles(DeckStorage.DCK_FILE_FILTER)) {
|
||||
Deck deck = DeckSerializer.fromFile(file);
|
||||
if (deck != null) {
|
||||
category.map.put(deck.getName(), deck);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return category;
|
||||
}
|
||||
|
||||
final NetDeckCategory c= SGuiChoose.oneOrNone("Select a Net Deck category", categories.values());
|
||||
if (c == null) { return null; }
|
||||
|
||||
if (c.map.isEmpty()) { //only download decks once per session
|
||||
WaitCallback<Boolean> callback = new WaitCallback<Boolean>() {
|
||||
@Override
|
||||
public void run() {
|
||||
String downloadLoc = c.getDownloadLocation();
|
||||
GuiBase.getInterface().download(new GuiDownloadZipService(c.getName(), "decks", c.getUrl(), downloadLoc, downloadLoc, null) {
|
||||
@Override
|
||||
protected void copyInputStream(InputStream in, String outPath) throws IOException {
|
||||
super.copyInputStream(in, outPath);
|
||||
|
||||
Deck deck = DeckSerializer.fromFile(new File(outPath));
|
||||
if (deck != null) {
|
||||
c.map.put(deck.getName(), deck);
|
||||
}
|
||||
}
|
||||
}, this);
|
||||
}
|
||||
};
|
||||
if (!callback.invokeAndWait()) { return null; } //wait for download to finish
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
private final String url;
|
||||
|
||||
private NetDeckCategory(String name0, String downloadLocation0) {
|
||||
super(name0, new HashMap<String, Deck>());
|
||||
url = downloadLocation0;
|
||||
}
|
||||
|
||||
public String getDownloadLocation() {
|
||||
return ForgeConstants.DECK_NET_DIR + name + "/";
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Net Decks - " + name;
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ public abstract class GuiDownloadService implements Runnable {
|
||||
//Components passed from GUI component displaying download
|
||||
private ITextField txtAddress;
|
||||
private ITextField txtPort;
|
||||
private IProgressBar progressBar;
|
||||
protected IProgressBar progressBar;
|
||||
private IButton btnStart;
|
||||
private UiCommand cmdClose;
|
||||
private Runnable onUpdate;
|
||||
@@ -73,7 +73,7 @@ public abstract class GuiDownloadService implements Runnable {
|
||||
|
||||
// Progress variables
|
||||
private Map<String, String> files; // local path -> url
|
||||
private boolean cancel;
|
||||
protected boolean cancel;
|
||||
private final long[] times = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
|
||||
private int tptr = 0;
|
||||
private int skipped = 0;
|
||||
@@ -90,27 +90,50 @@ public abstract class GuiDownloadService implements Runnable {
|
||||
cmdClose = cmdClose0;
|
||||
onUpdate = onUpdate0;
|
||||
|
||||
// Free up the EDT by assembling card list on a background thread
|
||||
FThreads.invokeInBackgroundThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
files = getNeededFiles();
|
||||
}
|
||||
catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
FThreads.invokeInEdtLater(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (onReadyToStart != null) {
|
||||
onReadyToStart.run();
|
||||
}
|
||||
readyToStart();
|
||||
String startOverrideDesc = getStartOverrideDesc();
|
||||
if (startOverrideDesc == null) {
|
||||
// Free up the EDT by assembling card list on a background thread
|
||||
FThreads.invokeInBackgroundThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
files = getNeededFiles();
|
||||
}
|
||||
});
|
||||
catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
FThreads.invokeInEdtLater(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (onReadyToStart != null) {
|
||||
onReadyToStart.run();
|
||||
}
|
||||
readyToStart();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
//handle special case of zip service
|
||||
if (onReadyToStart != null) {
|
||||
onReadyToStart.run();
|
||||
}
|
||||
});
|
||||
progressBar.setDescription("Click \"Start\" to download and extract " + startOverrideDesc);
|
||||
btnStart.setCommand(cmdStartDownload);
|
||||
btnStart.setEnabled(true);
|
||||
|
||||
FThreads.invokeInEdtLater(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
btnStart.requestFocusInWindow();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected String getStartOverrideDesc() {
|
||||
return null;
|
||||
}
|
||||
|
||||
private void readyToStart() {
|
||||
@@ -198,10 +221,7 @@ public abstract class GuiDownloadService implements Runnable {
|
||||
else {
|
||||
sb.append(String.format("%d of %d items finished! Skipped " + skipped + " items. Please close!",
|
||||
count, files.size()));
|
||||
btnStart.setText("OK");
|
||||
btnStart.setCommand(cmdClose);
|
||||
btnStart.setEnabled(true);
|
||||
btnStart.requestFocusInWindow();
|
||||
finish();
|
||||
}
|
||||
|
||||
progressBar.setValue(count);
|
||||
@@ -211,25 +231,18 @@ public abstract class GuiDownloadService implements Runnable {
|
||||
});
|
||||
}
|
||||
|
||||
protected void finish() {
|
||||
btnStart.setText("OK");
|
||||
btnStart.setCommand(cmdClose);
|
||||
btnStart.setEnabled(true);
|
||||
btnStart.requestFocusInWindow();
|
||||
}
|
||||
|
||||
@Override
|
||||
public final void run() {
|
||||
public void run() {
|
||||
final Random r = MyRandom.getRandom();
|
||||
|
||||
Proxy p = null;
|
||||
if (type == 0) {
|
||||
p = Proxy.NO_PROXY;
|
||||
}
|
||||
else {
|
||||
try {
|
||||
p = new Proxy(TYPES[type], new InetSocketAddress(txtAddress.getText(), Integer.parseInt(txtPort.getText())));
|
||||
}
|
||||
catch (final Exception ex) {
|
||||
BugReporter.reportException(ex,
|
||||
"Proxy connection could not be established!\nProxy address: %s\nProxy port: %s",
|
||||
txtAddress.getText(), txtPort.getText());
|
||||
return;
|
||||
}
|
||||
}
|
||||
Proxy p = getProxy();
|
||||
|
||||
int bufferLength;
|
||||
int iCard = 0;
|
||||
@@ -304,6 +317,23 @@ public abstract class GuiDownloadService implements Runnable {
|
||||
}
|
||||
}
|
||||
|
||||
protected Proxy getProxy() {
|
||||
if (type == 0) {
|
||||
return Proxy.NO_PROXY;
|
||||
}
|
||||
else {
|
||||
try {
|
||||
return new Proxy(TYPES[type], new InetSocketAddress(txtAddress.getText(), Integer.parseInt(txtPort.getText())));
|
||||
}
|
||||
catch (final Exception ex) {
|
||||
BugReporter.reportException(ex,
|
||||
"Proxy connection could not be established!\nProxy address: %s\nProxy port: %s",
|
||||
txtAddress.getText(), txtPort.getText());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public abstract String getTitle();
|
||||
protected abstract Map<String, String> getNeededFiles();
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package forge.download;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
import com.esotericsoftware.minlog.Log;
|
||||
import com.google.common.io.Files;
|
||||
|
||||
import forge.FThreads;
|
||||
import forge.interfaces.IProgressBar;
|
||||
import forge.util.FileUtil;
|
||||
|
||||
public class GuiDownloadZipService extends GuiDownloadService {
|
||||
private final String name, desc, sourceUrl, destFolder, deleteFolder;
|
||||
private int filesDownloaded;
|
||||
|
||||
public GuiDownloadZipService(String name0, String desc0, String sourceUrl0, String destFolder0, String deleteFolder0, IProgressBar progressBar0) {
|
||||
name = name0;
|
||||
desc = desc0;
|
||||
sourceUrl = sourceUrl0;
|
||||
destFolder = destFolder0;
|
||||
deleteFolder = deleteFolder0;
|
||||
progressBar = progressBar0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTitle() {
|
||||
return "Download " + name;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getStartOverrideDesc() {
|
||||
return desc;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected final Map<String, String> getNeededFiles() {
|
||||
HashMap<String, String> files = new HashMap<String, String>();
|
||||
files.put("_", "_");
|
||||
return files; //not needed by zip service, so just return map of size 1
|
||||
}
|
||||
|
||||
@Override
|
||||
public final void run() {
|
||||
downloadAndUnzip();
|
||||
if (!cancel) {
|
||||
FThreads.invokeInEdtNowOrLater(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
progressBar.setDescription(filesDownloaded + " " + desc + " downloaded");
|
||||
finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void downloadAndUnzip() {
|
||||
filesDownloaded = 0;
|
||||
String zipFilename = download("temp.zip");
|
||||
if (zipFilename == null) { return; }
|
||||
|
||||
//if assets.zip downloaded successfully, unzip into destination folder
|
||||
try {
|
||||
if (deleteFolder != null) {
|
||||
File deleteDir = new File(deleteFolder);
|
||||
if (deleteDir.exists()) {
|
||||
//attempt to delete previous res directory if to be rebuilt
|
||||
progressBar.reset();
|
||||
progressBar.setDescription("Deleting old " + desc + "...");
|
||||
if (deleteFolder.equals(destFolder)) { //move zip file to prevent deleting it
|
||||
String oldZipFilename = zipFilename;
|
||||
zipFilename = deleteDir.getParentFile().getAbsolutePath() + File.separator + "temp.zip";
|
||||
Files.move(new File(oldZipFilename), new File(zipFilename));
|
||||
}
|
||||
FileUtil.deleteDirectory(deleteDir);
|
||||
}
|
||||
}
|
||||
|
||||
ZipFile zipFile = new ZipFile(zipFilename, Charset.forName("CP866")); //ensure unzip doesn't fail due to non UTF-8 chars
|
||||
Enumeration<? extends ZipEntry> entries = zipFile.entries();
|
||||
|
||||
progressBar.reset();
|
||||
progressBar.setPercentMode(true);
|
||||
progressBar.setDescription("Extracting " + desc);
|
||||
progressBar.setMaximum(zipFile.size());
|
||||
|
||||
FileUtil.ensureDirectoryExists(destFolder);
|
||||
|
||||
int count = 0;
|
||||
while (entries.hasMoreElements()) {
|
||||
if (cancel) { break; }
|
||||
|
||||
ZipEntry entry = (ZipEntry)entries.nextElement();
|
||||
|
||||
String path = destFolder + entry.getName();
|
||||
if (entry.isDirectory()) {
|
||||
new File(path).mkdir();
|
||||
progressBar.setValue(++count);
|
||||
continue;
|
||||
}
|
||||
copyInputStream(zipFile.getInputStream(entry), path);
|
||||
progressBar.setValue(++count);
|
||||
filesDownloaded++;
|
||||
}
|
||||
|
||||
zipFile.close();
|
||||
new File(zipFilename).delete();
|
||||
}
|
||||
catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public String download(String filename) {
|
||||
progressBar.reset();
|
||||
progressBar.setPercentMode(true);
|
||||
progressBar.setDescription("Downloading " + desc);
|
||||
|
||||
try {
|
||||
URL url = new URL(sourceUrl);
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection(getProxy());
|
||||
|
||||
if (url.getPath().endsWith(".php")) {
|
||||
//ensure file can be downloaded if returned from PHP script
|
||||
conn.setRequestProperty("User-Agent", "Mozilla/4.0 (compatible; MSIE 4.01; Windows NT)");
|
||||
}
|
||||
|
||||
conn.connect();
|
||||
|
||||
if (conn.getResponseCode() != HttpURLConnection.HTTP_OK) {
|
||||
return null;
|
||||
}
|
||||
|
||||
long contentLength = conn.getContentLengthLong();
|
||||
if (contentLength == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
progressBar.setMaximum(100);
|
||||
|
||||
// input stream to read file - with 8k buffer
|
||||
InputStream input = new BufferedInputStream(conn.getInputStream(), 8192);
|
||||
|
||||
FileUtil.ensureDirectoryExists(destFolder);
|
||||
|
||||
// output stream to write file
|
||||
String destFile = destFolder + filename;
|
||||
OutputStream output = new FileOutputStream(destFile);
|
||||
|
||||
int count;
|
||||
long total = 0;
|
||||
byte data[] = new byte[1024];
|
||||
|
||||
while ((count = input.read(data)) != -1) {
|
||||
if (cancel) { break; }
|
||||
|
||||
total += count;
|
||||
progressBar.setValue((int)(100 * total / contentLength));
|
||||
output.write(data, 0, count);
|
||||
}
|
||||
|
||||
output.flush();
|
||||
output.close();
|
||||
input.close();
|
||||
|
||||
if (cancel) {
|
||||
new File(destFile).delete();
|
||||
return null;
|
||||
}
|
||||
return destFile;
|
||||
}
|
||||
catch (final Exception ex) {
|
||||
Log.error("Downloading " + desc, "Error downloading " + desc, ex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected void copyInputStream(InputStream in, String outPath) throws IOException{
|
||||
byte[] buffer = new byte[1024];
|
||||
int len;
|
||||
BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(outPath));
|
||||
|
||||
while((len = in.read(buffer)) >= 0) {
|
||||
out.write(buffer, 0, len);
|
||||
}
|
||||
|
||||
in.close();
|
||||
out.close();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import forge.LobbyPlayer;
|
||||
import forge.assets.FSkinProp;
|
||||
import forge.assets.ISkinImage;
|
||||
import forge.deck.CardPool;
|
||||
import forge.download.GuiDownloadService;
|
||||
import forge.game.GameEntity;
|
||||
import forge.game.GameEntityView;
|
||||
import forge.game.card.CardView;
|
||||
@@ -19,6 +20,7 @@ import forge.item.PaperCard;
|
||||
import forge.player.PlayerControllerHuman;
|
||||
import forge.sound.IAudioClip;
|
||||
import forge.sound.IAudioMusic;
|
||||
import forge.util.Callback;
|
||||
import forge.util.FCollectionView;
|
||||
|
||||
public interface IGuiBase {
|
||||
@@ -45,6 +47,7 @@ public interface IGuiBase {
|
||||
GameEntityView chooseSingleEntityForEffect(String title, FCollectionView<? extends GameEntity> optionList, DelayedReveal delayedReveal, boolean isOptional, PlayerControllerHuman controller);
|
||||
String showFileDialog(String title, String defaultDir);
|
||||
File getSaveFile(File defaultFile);
|
||||
void download(GuiDownloadService service, Callback<Boolean> callback);
|
||||
void showCardList(final String title, final String message, final List<PaperCard> list);
|
||||
boolean showBoxedProduct(final String title, final String message, final List<PaperCard> list);
|
||||
void setCard(CardView card);
|
||||
|
||||
@@ -88,6 +88,8 @@ public enum ItemManagerConfig {
|
||||
null, null, 3, 0),
|
||||
QUEST_EVENT_DECKS(SColumnUtil.getDecksDefaultColumns(false, false), false, false, false,
|
||||
null, null, 3, 0),
|
||||
NET_DECKS(SColumnUtil.getDecksDefaultColumns(false, false), false, false, false,
|
||||
null, null, 3, 0),
|
||||
SIDEBOARD(SColumnUtil.getDeckEditorDefaultColumns(), false, false, true,
|
||||
GroupDef.DEFAULT, ColumnDef.CMC, 3, 0);
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ public final class ForgeConstants {
|
||||
public static final String IMAGE_LIST_QUEST_BOOSTERBOXES_FILE = LISTS_DIR + "boosterbox-images.txt";
|
||||
public static final String IMAGE_LIST_QUEST_PRECONS_FILE = LISTS_DIR + "precon-images.txt";
|
||||
public static final String IMAGE_LIST_QUEST_TOURNAMENTPACKS_FILE = LISTS_DIR + "tournamentpack-images.txt";
|
||||
public static final String NET_DECKS_LIST_FILE = LISTS_DIR + "net-decks.txt";
|
||||
public static final String NET_DECKS_COMMANDER_LIST_FILE = LISTS_DIR + "net-decks-commander.txt";
|
||||
|
||||
public static final String CHANGES_FILE = ASSETS_DIR + "CHANGES.txt";
|
||||
public static final String LICENSE_FILE = ASSETS_DIR + "LICENSE.txt";
|
||||
@@ -114,6 +116,7 @@ public final class ForgeConstants {
|
||||
public static final String DECK_SCHEME_DIR = DECK_BASE_DIR + "scheme/";
|
||||
public static final String DECK_PLANE_DIR = DECK_BASE_DIR + "planar/";
|
||||
public static final String DECK_COMMANDER_DIR = DECK_BASE_DIR + "commander/";
|
||||
public static final String DECK_NET_DIR = DECK_BASE_DIR + "net/";
|
||||
public static final String QUEST_SAVE_DIR = USER_QUEST_DIR + "saves/";
|
||||
public static final String CONQUEST_SAVE_DIR = USER_CONQUEST_DIR + "saves/";
|
||||
public static final String MAIN_PREFS_FILE = USER_PREFS_DIR + "forge.preferences";
|
||||
@@ -159,6 +162,8 @@ public final class ForgeConstants {
|
||||
DECK_SEALED_DIR,
|
||||
DECK_SCHEME_DIR,
|
||||
DECK_PLANE_DIR,
|
||||
DECK_COMMANDER_DIR,
|
||||
DECK_NET_DIR,
|
||||
QUEST_SAVE_DIR,
|
||||
CACHE_TOKEN_PICS_DIR,
|
||||
CACHE_ICON_PICS_DIR,
|
||||
|
||||
Reference in New Issue
Block a user