diff --git a/README.md b/README.md index bf6d6ab..d601c8c 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ The roadmap below contains some of the features that I'm **planning to add** in - [ ] **Pre-defined authors**: Rather than having to enter the name of the changelog's author every time (possibly including formatting), there could be a list of pre-defined authors in the plugin config that a user could choose from. - [ ] **Customizable changelog dialog layout**: Currently, the changelog dialog always shows all previous changelogs, with unread ones marked red. In the future, it'd be nice to have an option for this, e.g. so players on join could also only see the most recent changelog. - [ ] **Paged changelogs**: At the moment, all existing changelogs are just added to the dialog without any additional checks. While this is fine with just a few of them, it could quickly become an issue with a long history of changelogs. So adding a page system with a configurable amount of changelogs per page would be great! -- [ ] **Player first-join storage**: Right now, changelogs are just displayed to players upon joining whenever they haven't read them (except for the **first time** a player joins a server). This can be very confusing to new-ish players, since they weren't there for all the previous changes to begin with! A possible (while not 100% perfect) solution to this would be storing when the player initially joined the server / was first seen by the plugin, to then only display relevant changelogs to them. +- [x] **Player first-join storage**: Right now, changelogs are just displayed to players upon joining whenever they haven't read them (except for the **first time** a player joins a server). This can be very confusing to new-ish players, since they weren't there for all the previous changes to begin with! A possible (while not 100% perfect) solution to this would be storing when the player initially joined the server / was first seen by the plugin, to then only display relevant changelogs to them. - [ ] **Other UI types**: The changes in this lower section are planned for a not-so-close future, the stuff above is more relevant for now! Anyway, even though other UI types (like chat, books, etc.) may not be as intuitive, some server owners might still prefer them. While there are plugins out there that can already do this, it would be nice for those to be available along with the additional features this plugin provides. - [ ] **A poll/voting system?**: A bit of a far-fetched idea, but since changelogs include a lot of, well, *change*, they can sometimes be controversial along players. I feel like a voting system might fit this plugin kind of well, so players can immediately share their opinion on a specific change right when they see it! diff --git a/base/src/main/java/com/codingcat/changelogs/base/data/ChangelogStorage.java b/base/src/main/java/com/codingcat/changelogs/base/data/ChangelogStorage.java index 5facc95..a5c5cbf 100644 --- a/base/src/main/java/com/codingcat/changelogs/base/data/ChangelogStorage.java +++ b/base/src/main/java/com/codingcat/changelogs/base/data/ChangelogStorage.java @@ -5,6 +5,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.time.Instant; import java.util.List; import java.util.UUID; @@ -25,6 +26,17 @@ public interface ChangelogStorage { void markAsRead(int uid, @NotNull UUID player); + @Nullable Instant getFirstSeenAt(@NotNull UUID player); + + @NotNull Instant recordFirstSeen(@NotNull UUID player, @NotNull Instant seenAt); + + default boolean isUnreadFor(@NotNull ChangelogEntry entry, @NotNull UUID player) { + Instant firstSeenAt = this.getFirstSeenAt(player); + return firstSeenAt != null + && entry.recordedAt().isAfter(firstSeenAt) + && !entry.playersRead().contains(player); + } + int nextUID(); @NotNull String getDisplayName(); diff --git a/base/src/main/java/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.java b/base/src/main/java/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.java index 945aa3a..a5ed96b 100644 --- a/base/src/main/java/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.java +++ b/base/src/main/java/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.java @@ -19,13 +19,16 @@ @RequiredArgsConstructor public class YamlChangelogStorage implements ChangelogStorage { + private static final @NotNull DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_INSTANT; private final @NotNull Path filePath; private List cache; + private Map playerFirstSeen; @Override public void init() { if (!filePath.toFile().exists()) { this.cache = new ArrayList<>(); + this.playerFirstSeen = new HashMap<>(); this.save(); return; } @@ -33,6 +36,7 @@ public void init() { Yaml config = new Yaml(); Map data = config.load(new FileInputStream(filePath.toFile())); this.cache = this.deserializeEntries(data); + this.playerFirstSeen = this.deserializePlayerFirstSeen(data); } catch (IOException | YAMLException e) { throw new RuntimeException("Failed to load changelog data from " + filePath, e); } @@ -66,11 +70,14 @@ public boolean removeEntry(int uid) { private void save() { Yaml config = new Yaml(); - List> data = this.cache.stream() + List> entries = this.cache.stream() .map(this::serializeEntry) .toList(); + Map data = new LinkedHashMap<>(); + data.put("entries", entries); + data.put("playerFirstSeen", this.serializePlayerFirstSeen()); try (FileOutputStream os = new FileOutputStream(filePath.toFile())) { - config.dump(Map.of("entries", data), new BufferedWriter(new OutputStreamWriter(os))); + config.dump(data, new BufferedWriter(new OutputStreamWriter(os))); } catch (IOException e) { throw new RuntimeException("Failed to save changelog data to " + filePath, e); } @@ -110,13 +117,34 @@ private void save() { return new ChangelogEntry(uid, lines, recordedAt, author, playersRead); } + private @NotNull Map deserializePlayerFirstSeen(@NotNull Map config) { + try { + //noinspection unchecked + Map rawFirstSeen = (Map) config.get("playerFirstSeen"); + if (rawFirstSeen == null) return new HashMap<>(); + return rawFirstSeen.entrySet().stream().collect(Collectors.toMap( + e -> UUID.fromString(e.getKey()), + e -> DATE_FORMATTER.parse(e.getValue(), Instant::from) + )); + } catch (Exception e) { + throw new RuntimeException("Failed to deserialize player first-seen data", e); + } + } + + private @NotNull Map serializePlayerFirstSeen() { + return this.playerFirstSeen.entrySet().stream().collect(Collectors.toMap( + entry -> entry.getKey().toString(), + entry -> DATE_FORMATTER.format(entry.getValue()) + )); + } + private @NotNull Map serializeEntry(@NotNull ChangelogEntry entry) { Map data = new HashMap<>(); List serializedLines = entry.lines() .stream().map(GsonComponentSerializer.gson()::serialize) .toList(); data.put("serializedLines", serializedLines); - String recordedAtRaw = DateTimeFormatter.ISO_INSTANT.format(entry.recordedAt()); + String recordedAtRaw = DATE_FORMATTER.format(entry.recordedAt()); data.put("recordedAt", recordedAtRaw); String serializedAuthor = Optional.ofNullable(entry.author()) .map(GsonComponentSerializer.gson()::serialize) @@ -150,6 +178,18 @@ public void markAsRead(int uid, @NotNull UUID player) { this.save(); } + @Override + public @Nullable Instant getFirstSeenAt(@NotNull UUID player) { + return this.playerFirstSeen.get(player); + } + + @Override + public @NotNull Instant recordFirstSeen(@NotNull UUID player, @NotNull Instant seenAt) { + Instant previous = this.playerFirstSeen.putIfAbsent(player, seenAt); + if (previous != null) this.save(); + return Objects.requireNonNullElse(previous, seenAt); + } + @Override public int nextUID() { return this.cache.size(); diff --git a/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.java b/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.java index f1909f2..288428a 100644 --- a/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.java +++ b/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.java @@ -85,7 +85,7 @@ public class ChangelogDialog implements IDialog { private @NotNull Component formatEntry(@NotNull ChangelogEntry entry, @NotNull IPlayer player, @NotNull DialogSessionManager sessionManager, boolean canManage) { Component linesComponent = createLinesComponent(player, null, entry.lines()); - Component entryComponent = translatableManual(player, "dialog.changelog.entry" + (!entry.hasRead(player) ? "_unread" : ""), + Component entryComponent = translatableManual(player, "dialog.changelog.entry" + (this.storage.isUnreadFor(entry, player.getUniqueId()) ? "_unread" : ""), text(dateFormatter.format(entry.recordedAt())), linesComponent, Objects.requireNonNullElseGet(entry.author(), () -> translatableManual(player, "dialog.changelog.unspecified_author")) ); @@ -123,7 +123,7 @@ public void onActionTriggered(@NotNull String action, @Nullable NBTCompound data case "confirm_read" -> { List uids = this.storage.listEntries() .stream() - .filter(e -> !e.hasRead(source)) + .filter(e -> this.storage.isUnreadFor(e, source.getUniqueId())) .map(ChangelogEntry::uid) .toList(); uids.forEach(uid -> storage.markAsRead(uid, source.getUniqueId())); diff --git a/base/src/main/java/com/codingcat/changelogs/base/event/ChangelogJoinListener.java b/base/src/main/java/com/codingcat/changelogs/base/event/ChangelogJoinListener.java index 794cc07..84a6fd5 100644 --- a/base/src/main/java/com/codingcat/changelogs/base/event/ChangelogJoinListener.java +++ b/base/src/main/java/com/codingcat/changelogs/base/event/ChangelogJoinListener.java @@ -14,6 +14,7 @@ import net.kyori.adventure.key.Key; import org.jetbrains.annotations.NotNull; +import java.time.Instant; import java.util.function.Supplier; import static com.codingcat.changelogs.base.ServerChangelogs.info; @@ -45,9 +46,10 @@ public void unregisterEvents(@NotNull IEventManager manager) { } public boolean checkShowDialog(@NotNull IPlayer player, boolean freeze) { - if (player.isFirstJoin()) return false; - boolean unreadChangelogs = this.storageSupplier.get().listEntries().stream() - .anyMatch(e -> !e.hasRead(player)); + ChangelogStorage storage = this.storageSupplier.get(); + Instant firstSeenAt = storage.recordFirstSeen(player.getUniqueId(), Instant.now()); + boolean unreadChangelogs = storage.listEntries().stream() + .anyMatch(e -> !e.hasRead(player) && e.recordedAt().isAfter(firstSeenAt)); if (!unreadChangelogs) return false; ChangelogDialog dialog = this.dialogHolder.getFromType(ChangelogDialog.class); if (freeze) this.sessionManager.freeze(player);