Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ paperweight.reobfArtifactConfiguration = io.papermc.paperweight.userdev.ReobfArt
group = "world.bentobox" // From <groupId>

// Base properties from <properties>
val buildVersion = "3.20.0"
val buildVersion = "3.21.0"
val buildNumberDefault = "-LOCAL" // Local build identifier
val snapshotSuffix = "-SNAPSHOT" // Indicates development/snapshot version

Expand Down
89 changes: 89 additions & 0 deletions src/main/java/world/bentobox/bentobox/Settings.java
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,31 @@ public class Settings implements ConfigObject {
@ConfigEntry(path = "island.confirmation.invites", since = "1.8.0")
private boolean inviteConfirmation = false;

@ConfigComment("Modal dialog options. Dialogs are the 'menu they can't click away' shown to")
@ConfigComment("players for high-friction flows. They require a server that supports dialogs")
@ConfigComment("(Minecraft 26+); on older servers every option below falls back automatically")
@ConfigComment("to the classic chat/command behaviour regardless of the setting.")
@ConfigComment("Show sensitive-command confirmations as a modal [Confirm]/[Cancel] dialog")
@ConfigComment("instead of asking the player to type the command again.")
@ConfigEntry(path = "island.dialogs.confirmations", since = "3.21.0")
private boolean dialogConfirmations = true;

@ConfigComment("Auto-open an [Accept]/[Decline] dialog when a player receives a team invite,")
@ConfigComment("instead of relying on them to type the accept/reject command.")
@ConfigEntry(path = "island.dialogs.team-invites", since = "3.21.0")
private boolean dialogTeamInvites = true;

@ConfigComment("Show a button-per-destination picker when a player runs the island go/home")
@ConfigComment("command with no name and has more than one island or home to choose from.")
@ConfigEntry(path = "island.dialogs.go-picker", since = "3.21.0")
private boolean dialogGoPicker = true;

@ConfigComment("Show a non-dismissable 'choose your game' dialog to brand new players when")
@ConfigComment("more than one game mode is installed. Off by default: it is intrusive and")
@ConfigComment("clicking a game runs that game mode's command for the player.")
@ConfigEntry(path = "island.dialogs.game-mode-selection", since = "3.21.0")
private boolean dialogGameModeSelection = false;

@ConfigComment("Sets the minimum length an island custom name is required to have.")
@ConfigEntry(path = "island.name.min-length")
private int nameMinLength = 4;
Expand Down Expand Up @@ -922,6 +947,70 @@ public void setInviteConfirmation(boolean inviteConfirmation) {
this.inviteConfirmation = inviteConfirmation;
}

/**
* @return whether sensitive-command confirmations should use a modal dialog
* @since 3.21.0
*/
public boolean isDialogConfirmations() {
return dialogConfirmations;
}

/**
* @param dialogConfirmations whether to use a modal dialog for confirmations
* @since 3.21.0
*/
public void setDialogConfirmations(boolean dialogConfirmations) {
this.dialogConfirmations = dialogConfirmations;
}

/**
* @return whether team invites should auto-open an accept/decline dialog
* @since 3.21.0
*/
public boolean isDialogTeamInvites() {
return dialogTeamInvites;
}

/**
* @param dialogTeamInvites whether team invites should open a dialog
* @since 3.21.0
*/
public void setDialogTeamInvites(boolean dialogTeamInvites) {
this.dialogTeamInvites = dialogTeamInvites;
}

/**
* @return whether the island go/home command shows a destination picker dialog
* @since 3.21.0
*/
public boolean isDialogGoPicker() {
return dialogGoPicker;
}

/**
* @param dialogGoPicker whether the go/home command shows a picker dialog
* @since 3.21.0
*/
public void setDialogGoPicker(boolean dialogGoPicker) {
this.dialogGoPicker = dialogGoPicker;
}

/**
* @return whether brand new players see a game-mode selection dialog
* @since 3.21.0
*/
public boolean isDialogGameModeSelection() {
return dialogGameModeSelection;
}

/**
* @param dialogGameModeSelection whether new players see a game-mode dialog
* @since 3.21.0
*/
public void setDialogGameModeSelection(boolean dialogGameModeSelection) {
this.dialogGameModeSelection = dialogGameModeSelection;
}

/**
* @return the databasePrefix
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
import org.bukkit.scheduler.BukkitTask;

import world.bentobox.bentobox.api.addons.Addon;
import world.bentobox.bentobox.api.dialogs.BBDialog;
import world.bentobox.bentobox.api.dialogs.DialogBuilder;
import world.bentobox.bentobox.api.dialogs.DialogButton;
import world.bentobox.bentobox.api.dialogs.Dialogs;
import world.bentobox.bentobox.api.user.User;
import world.bentobox.bentobox.util.Util;

/**
* An extension of {@link CompositeCommand} that adds a confirmation step for
Expand Down Expand Up @@ -96,6 +101,11 @@ protected ConfirmableCommand(CompositeCommand parent, String label, String... al
* @param confirmed The action to execute upon successful confirmation.
*/
public void askConfirmation(User user, String message, Runnable confirmed) {
// Prefer a modal dialog when the server supports it and it is enabled.
// Falls through to the type-again prompt if the dialog cannot be shown.
if (useDialog(user) && showConfirmationDialog(user, message, confirmed)) {
return;
}
// Check for pending confirmations
if (toBeConfirmed.containsKey(user)) {
if (toBeConfirmed.get(user).topLabel().equals(getTopLabel()) && toBeConfirmed.get(user).label().equalsIgnoreCase(getLabel())) {
Expand Down Expand Up @@ -135,6 +145,49 @@ public void askConfirmation(User user, Runnable confirmed) {
askConfirmation(user, "", confirmed);
}

/**
* Whether this confirmation should be presented as a modal dialog rather than
* the type-the-command-again prompt. True only for online players, when the
* server supports dialogs and the {@code island.dialogs.confirmations}
* setting is enabled.
*
* @param user the user being asked
* @return true if a dialog should be shown
*/
private boolean useDialog(User user) {
return user.isPlayer() && Dialogs.isSupported() && getPlugin().getSettings().isDialogConfirmations();
}

/**
* Shows a two-button [Confirm]/[Cancel] dialog. Confirming runs the action;
* cancelling (or dismissing with Escape) aborts it.
*
* @param user the user to ask
* @param message a pre-translated context message, or empty
* @param confirmed the action to run on confirmation
* @return true if the dialog was shown; false if it could not be built/shown,
* in which case the caller should fall back to the type-again prompt
*/
private boolean showConfirmationDialog(User user, String message, Runnable confirmed) {
try {
DialogBuilder builder = new DialogBuilder().title(user, "commands.confirmation.title");
if (message != null && !message.trim().isEmpty()) {
builder.body(Util.parseMiniMessageOrLegacy(message));
}
builder.body(user, "commands.confirmation.dialog-body")
.confirmation(
DialogButton.of(user, "commands.confirmation.buttons.confirm", u -> confirmed.run()),
DialogButton.of(user, "commands.confirmation.buttons.cancel",
u -> u.sendMessage("commands.confirmation.request-cancelled")));
builder.build().show(user);
return true;
} catch (Exception e) {
getPlugin().logError("Could not show confirmation dialog, falling back to command prompt: "
+ e.getMessage());
return false;
}
}

/**
* A record that holds the details of a pending confirmation request.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

import org.bukkit.World;

import net.kyori.adventure.text.Component;
import world.bentobox.bentobox.BentoBox;
import world.bentobox.bentobox.api.commands.CompositeCommand;
import world.bentobox.bentobox.api.commands.DelayedTeleportCommand;
import world.bentobox.bentobox.api.dialogs.DialogBuilder;
import world.bentobox.bentobox.api.dialogs.DialogButton;
import world.bentobox.bentobox.api.dialogs.Dialogs;
import world.bentobox.bentobox.api.localization.TextVariables;
import world.bentobox.bentobox.api.user.User;
import world.bentobox.bentobox.database.objects.Island;
Expand Down Expand Up @@ -106,9 +112,11 @@ public boolean execute(User user, String label, List<String> args) {
// Check if the name is known if one is given
if (!args.isEmpty()) {
// Assemble the arguments into one string
final String name = String.join(" ", args);
// If the name is not in the list
if (!names.containsKey(name)) {
final String typed = String.join(" ", args);
// Forgiving lookup: exact, then case/space-insensitive, then unique prefix
final String name = resolveName(typed, names.keySet());
// If the name could not be resolved to a destination
if (name == null) {
// Failed home name check
user.sendMessage("commands.island.go.unknown-home");
user.sendMessage("commands.island.sethome.homes-are");
Expand All @@ -118,33 +126,78 @@ public boolean execute(User user, String label, List<String> args) {
+ "[hover: " + user.getTranslation("commands.island.sethome.click-to-teleport") + "]"));
return false;
} else {
// We know where this location is. Now work out if it's an island name or a home name
IslandInfo info = names.get(name);
getIslands().setPrimaryIsland(user.getUniqueId(), info.island);
if (!info.islandName) {
// This is a home name, not an island name
this.delayCommand(user, () -> getIslands().homeTeleportAsync(getWorld(), user.getPlayer(), name) // Teleport to the named home for this player
.thenAccept(r -> {
if (Boolean.TRUE.equals(r)) {
// Success
getIslands().setPrimaryIsland(user.getUniqueId(), info.island);
} else {
user.sendMessage("commands.island.go.failure");
getPlugin().logError(user.getName() + " could not teleport to their island - async teleport issue");
}
}));
return true;
}
// Not a home name, an island name, so teleport to the island
this.delayCommand(user, () -> getIslands().homeTeleportAsync(info.island(), user));
// We know where this location is. Teleport there.
teleportToNamed(user, name, names.get(name));
return true;
}
}


// No name given. If the player has several destinations, offer a picker dialog
if (showGoPicker(user, names)) {
return true;
}

this.delayCommand(user, () -> getIslands().homeTeleportAsync(getWorld(), user.getPlayer()));
return true;
}

/**
* Teleports the user to a resolved destination, whether it is a named home or an
* island name.
*
* @param user the user to teleport
* @param name the resolved (canonical) destination name
* @param info the island/home the name refers to
*/
private void teleportToNamed(User user, String name, IslandInfo info) {
getIslands().setPrimaryIsland(user.getUniqueId(), info.island());
if (!info.islandName()) {
// This is a home name, not an island name
this.delayCommand(user, () -> getIslands().homeTeleportAsync(getWorld(), user.getPlayer(), name)
.thenAccept(r -> {
if (Boolean.TRUE.equals(r)) {
getIslands().setPrimaryIsland(user.getUniqueId(), info.island());
} else {
user.sendMessage("commands.island.go.failure");
getPlugin().logError(
user.getName() + " could not teleport to their island - async teleport issue");
}
}));
} else {
// An island name, so teleport to the island
this.delayCommand(user, () -> getIslands().homeTeleportAsync(info.island(), user));
}
}

/**
* Shows a button-per-destination picker dialog when the player has more than one
* island or home. Each button teleports to that destination.
*
* @param user the user
* @param names the destination map
* @return true if the picker was shown; false to fall through to the default teleport
*/
private boolean showGoPicker(User user, Map<String, IslandInfo> names) {
if (!user.isPlayer() || names.size() < 2 || !Dialogs.isSupported()
|| !getPlugin().getSettings().isDialogGoPicker()) {
return false;
}
try {
DialogBuilder builder = new DialogBuilder().title(user, "commands.island.go.picker.title");
// Stable, alphabetical button order
names.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(en -> {
String name = en.getKey();
IslandInfo info = en.getValue();
builder.button(new DialogButton(Component.text(name), u -> teleportToNamed(u, name, info)));
});
builder.build().show(user);
return true;
} catch (Exception e) {
getPlugin().logError("Could not show go picker dialog, falling back to teleport: " + e.getMessage());
return false;
}
}

/**
* Checks if any of the user's islands are reserved.
* If a reserved island is found, redirects the user to island creation.
Expand Down Expand Up @@ -187,6 +240,56 @@ public record IslandInfo(
boolean islandName) {
}

/**
* Resolves the text a player typed to one of the valid destination names using
* forgiving matching, so small mistakes still teleport them instead of dumping a
* list. Matching is tried in decreasing order of confidence:
* <ol>
* <li>exact match (existing behaviour, always wins);</li>
* <li>case-, colour- and whitespace-insensitive exact match;</li>
* <li>a unique case-insensitive prefix (e.g. {@code hom} for {@code Home}).</li>
* </ol>
* Any step that is ambiguous (more than one candidate) is skipped, so an unclear
* input falls through to the normal unknown-home list rather than guessing.
*
* @param typed the raw string the player typed
* @param names the valid destination names
* @return the canonical destination name to use, or {@code null} if none matched confidently
*/
static String resolveName(String typed, Set<String> names) {
// 1. Exact match wins and preserves the original behaviour
if (names.contains(typed)) {
return typed;
}
String norm = normalize(typed);
if (norm.isEmpty()) {
return null;
}
// 2. Case/colour/whitespace-insensitive exact match, if unambiguous
List<String> exact = names.stream().filter(n -> normalize(n).equals(norm)).toList();
if (exact.size() == 1) {
return exact.get(0);
}
if (!exact.isEmpty()) {
// Several names collapse to the same normalized form - too ambiguous to guess
return null;
}
// 3. Unique prefix match
List<String> prefix = names.stream().filter(n -> normalize(n).startsWith(norm)).toList();
return prefix.size() == 1 ? prefix.get(0) : null;
}

/**
* Normalizes a name for forgiving comparison: strips colour codes, lower-cases and
* collapses runs of whitespace to a single space.
*
* @param s the string to normalize
* @return the normalized form
*/
private static String normalize(String s) {
return Util.stripColor(s).toLowerCase(Locale.ENGLISH).replaceAll("\\s+", " ").trim();
}

/**
* Creates a mapping of valid teleport destination names for a user.
* Includes:
Expand Down
Loading
Loading