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
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;

Check warning on line 10 in src/main/java/world/bentobox/bentobox/api/commands/ConfirmableCommand.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import 'world.bentobox.bentobox.api.dialogs.BBDialog'.

See more on https://sonarcloud.io/project/issues?id=BentoBoxWorld_BentoBox&issues=AZ9TlmzRldJHH9vRiJMp&open=AZ9TlmzRldJHH9vRiJMp&pullRequest=3033
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 @@
* @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 @@
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 @@ -11,9 +11,13 @@

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 @@ -122,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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
import org.eclipse.jdt.annotation.Nullable;

import world.bentobox.bentobox.api.commands.CompositeCommand;
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.events.IslandBaseEvent;
import world.bentobox.bentobox.api.events.team.TeamEvent;
import world.bentobox.bentobox.api.localization.TextVariables;
Expand Down Expand Up @@ -230,14 +233,48 @@ public boolean execute(User user, String label, List<String> args) {
// Send message to online player
invitedPlayer.sendMessage("commands.island.team.invite.name-has-invited-you", TextVariables.NAME, user.getName(), TextVariables.DISPLAY_NAME, user.getDisplayName());
invitedPlayer.sendMessage("commands.island.team.invite.to-accept-or-reject", TextVariables.LABEL, getTopLabel());
if (getIWM().getWorldSettings(getWorld()).isDisallowTeamMemberIslands()
&& getIslands().hasIsland(getWorld(), invitedPlayer.getUniqueId())) {
boolean willLoseIsland = getIWM().getWorldSettings(getWorld()).isDisallowTeamMemberIslands()
&& getIslands().hasIsland(getWorld(), invitedPlayer.getUniqueId());
if (willLoseIsland) {
invitedPlayer.sendMessage("commands.island.team.invite.you-will-lose-your-island");
}
// Auto-open an accept/decline dialog for the invited player if enabled
showInviteDialog(user, invitedPlayer, willLoseIsland);
invitedPlayer = null;
return true;
}

/**
* Opens an [Accept]/[Decline] dialog for the invited player, so they do not have
* to find and type the accept/reject command. Silently does nothing (leaving the
* chat messages as the fallback) if dialogs are disabled or unsupported.
*
* @param inviter the player who sent the invite
* @param invited the player who received it
* @param willLoseIsland whether accepting will cost the invited player their island
*/
private void showInviteDialog(User inviter, User invited, boolean willLoseIsland) {
if (!invited.isPlayer() || !Dialogs.isSupported() || !getSettings().isDialogTeamInvites()) {
return;
}
try {
DialogBuilder builder = new DialogBuilder()
.title(invited, "commands.island.team.invite.dialog.title", TextVariables.NAME, inviter.getName());
builder.body(invited, "commands.island.team.invite.dialog.body", TextVariables.NAME, inviter.getName());
if (willLoseIsland) {
builder.body(invited, "commands.island.team.invite.you-will-lose-your-island");
}
builder.confirmation(
DialogButton.of(invited, "commands.island.team.invite.dialog.accept",
u -> itc.getSubCommand("accept").ifPresent(c -> c.call(u, c.getLabel(), List.of()))),
DialogButton.of(invited, "commands.island.team.invite.dialog.decline",
u -> itc.getSubCommand("reject").ifPresent(c -> c.call(u, c.getLabel(), List.of()))));
builder.build().show(invited);
} catch (Exception e) {
getPlugin().logError("Could not show team invite dialog: " + e.getMessage());
}
}

/**
* Provides tab completion for online player names.
* Requires at least first letter to avoid showing all players.
Expand Down
42 changes: 42 additions & 0 deletions src/main/java/world/bentobox/bentobox/api/dialogs/BBDialog.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package world.bentobox.bentobox.api.dialogs;

import org.eclipse.jdt.annotation.NonNull;

import io.papermc.paper.dialog.Dialog;
import world.bentobox.bentobox.api.user.User;

/**
* A built dialog, ready to be shown to a player. Create one with a
* {@link DialogBuilder}.
*
* @author tastybento
* @since 3.21.0
*/
public class BBDialog {

private final Dialog dialog;

BBDialog(@NonNull Dialog dialog) {
this.dialog = dialog;
}

/**
* Shows this dialog to the given user. Has no effect if the user is not an
* online player.
*
* @param user the user to show the dialog to, not null
*/
public void show(@NonNull User user) {
if (user.isPlayer() && user.getPlayer() != null) {

Check warning on line 30 in src/main/java/world/bentobox/bentobox/api/dialogs/BBDialog.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this expression which always evaluates to "true"

See more on https://sonarcloud.io/project/issues?id=BentoBoxWorld_BentoBox&issues=AZ9TlmzYldJHH9vRiJMq&open=AZ9TlmzYldJHH9vRiJMq&pullRequest=3033
user.getPlayer().showDialog(dialog);
}
}

/**
* @return the underlying Paper dialog, for advanced use
*/
@NonNull
public Dialog getDialog() {
return dialog;
}
}
Loading
Loading