mirror of
https://github.com/GeyserMC/Geyser.git
synced 2024-08-14 23:57:35 +00:00
Begin implementing below name support and better name display
This commit is contained in:
parent
cbe2bf7480
commit
01babd636a
9 changed files with 174 additions and 16 deletions
|
@ -27,6 +27,7 @@ package org.geysermc.connector.entity.player;
|
|||
|
||||
import com.github.steveice10.mc.auth.data.GameProfile;
|
||||
import com.github.steveice10.mc.protocol.data.game.entity.metadata.EntityMetadata;
|
||||
import com.github.steveice10.mc.protocol.data.game.scoreboard.ScoreboardPosition;
|
||||
import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
|
||||
import com.nukkitx.math.vector.Vector3f;
|
||||
import com.nukkitx.math.vector.Vector3i;
|
||||
|
@ -50,9 +51,11 @@ import org.geysermc.connector.entity.attribute.AttributeType;
|
|||
import org.geysermc.connector.entity.living.animal.tameable.ParrotEntity;
|
||||
import org.geysermc.connector.entity.type.EntityType;
|
||||
import org.geysermc.connector.network.session.GeyserSession;
|
||||
import org.geysermc.connector.network.translators.chat.MessageTranslator;
|
||||
import org.geysermc.connector.scoreboard.Objective;
|
||||
import org.geysermc.connector.scoreboard.Score;
|
||||
import org.geysermc.connector.scoreboard.Team;
|
||||
import org.geysermc.connector.utils.AttributeUtils;
|
||||
import org.geysermc.connector.network.translators.chat.MessageTranslator;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
@ -111,6 +114,22 @@ public class PlayerEntity extends LivingEntity {
|
|||
|
||||
updateEquipment(session);
|
||||
updateBedrockAttributes(session);
|
||||
|
||||
// Check to see if the player should have a belowname counterpart added
|
||||
Objective objective = session.getWorldCache().getScoreboard().getObjectiveSlots().get(ScoreboardPosition.BELOW_NAME);
|
||||
if (objective != null) {
|
||||
boolean hasScore = false;
|
||||
for (Score score : objective.getScores().values()) {
|
||||
if (score.getName().equals(this.username)) {
|
||||
hasScore = true;
|
||||
session.getWorldCache().getScoreboard().sendBelowNameUpdate(objective, score, this);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasScore) {
|
||||
//TODO session.getWorldCache().getScoreboard().sendBelowNameUpdate(objective, null, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void sendPlayer(GeyserSession session) {
|
||||
|
@ -257,12 +276,7 @@ public class PlayerEntity extends LivingEntity {
|
|||
}
|
||||
Team team = session.getWorldCache().getScoreboard().getTeamFor(username);
|
||||
if (team != null) {
|
||||
String displayName = "";
|
||||
if (team.isVisibleFor(session.getPlayerEntity().getUsername())) {
|
||||
displayName = MessageTranslator.toChatColor(team.getColor()) + username;
|
||||
displayName = team.getCurrentData().getDisplayName(displayName);
|
||||
}
|
||||
metadata.put(EntityData.NAMETAG, displayName);
|
||||
metadata.put(EntityData.NAMETAG, team.getDisplayNameFor(username, session.getPlayerEntity().getUsername()));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -124,10 +124,26 @@ public class EntityCache {
|
|||
playerEntities.put(entity.getUuid(), entity);
|
||||
}
|
||||
|
||||
public Collection<PlayerEntity> getPlayerEntities() {
|
||||
return playerEntities.values();
|
||||
}
|
||||
|
||||
public PlayerEntity getPlayerEntity(UUID uuid) {
|
||||
return playerEntities.get(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return a {@link PlayerEntity} with the given username
|
||||
*/
|
||||
public PlayerEntity getPlayerEntity(String username) {
|
||||
for (PlayerEntity entity : playerEntities.values()) {
|
||||
if (username.equals(entity.getUsername())) {
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void removePlayerEntity(UUID uuid) {
|
||||
playerEntities.remove(uuid);
|
||||
}
|
||||
|
|
|
@ -70,7 +70,7 @@ public class JavaScoreboardObjectiveTranslator extends PacketTranslator<ServerSc
|
|||
break;
|
||||
}
|
||||
|
||||
if (objective == null || !objective.isActive()) {
|
||||
if (objective == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -25,19 +25,24 @@
|
|||
|
||||
package org.geysermc.connector.network.translators.java.scoreboard;
|
||||
|
||||
import com.github.steveice10.mc.protocol.data.game.scoreboard.NameTagVisibility;
|
||||
import com.github.steveice10.mc.protocol.data.game.scoreboard.TeamAction;
|
||||
import com.github.steveice10.mc.protocol.packet.ingame.server.scoreboard.ServerTeamPacket;
|
||||
import com.nukkitx.protocol.bedrock.data.entity.EntityData;
|
||||
import com.nukkitx.protocol.bedrock.packet.SetEntityDataPacket;
|
||||
import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet;
|
||||
import org.geysermc.connector.GeyserConnector;
|
||||
import org.geysermc.connector.GeyserLogger;
|
||||
import org.geysermc.connector.entity.player.PlayerEntity;
|
||||
import org.geysermc.connector.network.session.GeyserSession;
|
||||
import org.geysermc.connector.network.translators.PacketTranslator;
|
||||
import org.geysermc.connector.network.translators.Translator;
|
||||
import org.geysermc.connector.network.translators.chat.MessageTranslator;
|
||||
import org.geysermc.connector.scoreboard.Scoreboard;
|
||||
import org.geysermc.connector.scoreboard.ScoreboardUpdater;
|
||||
import org.geysermc.connector.scoreboard.Team;
|
||||
import org.geysermc.connector.scoreboard.UpdateType;
|
||||
import org.geysermc.connector.utils.LanguageUtils;
|
||||
import org.geysermc.connector.network.translators.chat.MessageTranslator;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
|
@ -55,7 +60,11 @@ public class JavaTeamTranslator extends PacketTranslator<ServerTeamPacket> {
|
|||
int pps = session.getWorldCache().increaseAndGetScoreboardPacketsPerSecond();
|
||||
|
||||
Scoreboard scoreboard = session.getWorldCache().getScoreboard();
|
||||
Team team = scoreboard.getTeam(packet.getTeamName());
|
||||
Team team = null;
|
||||
|
||||
if (packet.getAction() != TeamAction.CREATE) {
|
||||
team = scoreboard.getTeam(packet.getTeamName());
|
||||
}
|
||||
switch (packet.getAction()) {
|
||||
case CREATE:
|
||||
scoreboard.registerNewTeam(packet.getTeamName(), toPlayerSet(packet.getPlayers()))
|
||||
|
@ -64,6 +73,7 @@ public class JavaTeamTranslator extends PacketTranslator<ServerTeamPacket> {
|
|||
.setNameTagVisibility(packet.getNameTagVisibility())
|
||||
.setPrefix(MessageTranslator.convertMessage(packet.getPrefix(), session.getLocale()))
|
||||
.setSuffix(MessageTranslator.convertMessage(packet.getSuffix(), session.getLocale()));
|
||||
updatePlayerNameTags(session, team, packet.getPlayers(), false);
|
||||
break;
|
||||
case UPDATE:
|
||||
if (team == null) {
|
||||
|
@ -73,6 +83,7 @@ public class JavaTeamTranslator extends PacketTranslator<ServerTeamPacket> {
|
|||
));
|
||||
return;
|
||||
}
|
||||
NameTagVisibility oldVisibility = team.getNameTagVisibility();
|
||||
|
||||
team.setName(MessageTranslator.convertMessage(packet.getDisplayName()))
|
||||
.setColor(packet.getColor())
|
||||
|
@ -80,6 +91,9 @@ public class JavaTeamTranslator extends PacketTranslator<ServerTeamPacket> {
|
|||
.setPrefix(MessageTranslator.convertMessage(packet.getPrefix(), session.getLocale()))
|
||||
.setSuffix(MessageTranslator.convertMessage(packet.getSuffix(), session.getLocale()))
|
||||
.setUpdateType(UpdateType.UPDATE);
|
||||
if (!oldVisibility.equals(packet.getNameTagVisibility())) {
|
||||
updatePlayerNameTags(session, team, team.getEntities(), false);
|
||||
}
|
||||
break;
|
||||
case ADD_PLAYER:
|
||||
if (team == null) {
|
||||
|
@ -90,6 +104,7 @@ public class JavaTeamTranslator extends PacketTranslator<ServerTeamPacket> {
|
|||
return;
|
||||
}
|
||||
team.addEntities(packet.getPlayers());
|
||||
updatePlayerNameTags(session, team, packet.getPlayers(), false);
|
||||
break;
|
||||
case REMOVE_PLAYER:
|
||||
if (team == null) {
|
||||
|
@ -100,9 +115,13 @@ public class JavaTeamTranslator extends PacketTranslator<ServerTeamPacket> {
|
|||
return;
|
||||
}
|
||||
team.removeEntities(packet.getPlayers());
|
||||
updatePlayerNameTags(session, team, packet.getPlayers(), true);
|
||||
break;
|
||||
case REMOVE:
|
||||
scoreboard.removeTeam(packet.getTeamName());
|
||||
if (team != null) {
|
||||
updatePlayerNameTags(session, team, team.getEntities(), true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -113,6 +132,31 @@ public class JavaTeamTranslator extends PacketTranslator<ServerTeamPacket> {
|
|||
}
|
||||
}
|
||||
|
||||
private void updatePlayerNameTags(GeyserSession session, Team team, String[] teamEntities, boolean remove) {
|
||||
for (PlayerEntity entity : session.getEntityCache().getPlayerEntities()) {
|
||||
for (String name : teamEntities) {
|
||||
if (entity.getUsername().equals(name)) {
|
||||
String currentName = entity.getMetadata().getString(EntityData.NAMETAG);
|
||||
String newName;
|
||||
if (remove) {
|
||||
// Set the nametag to their default
|
||||
newName = entity.getUsername();
|
||||
} else {
|
||||
newName = team.getDisplayNameFor(currentName, session.getPlayerEntity().getUsername());
|
||||
}
|
||||
if (!newName.equals(currentName)) {
|
||||
entity.getMetadata().put(EntityData.NAMETAG, newName);
|
||||
SetEntityDataPacket packet = new SetEntityDataPacket();
|
||||
packet.getMetadata().put(EntityData.NAMETAG, newName);
|
||||
packet.setRuntimeEntityId(entity.getGeyserId());
|
||||
session.sendUpstreamPacket(packet);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> toPlayerSet(String[] players) {
|
||||
return new ObjectOpenHashSet<>(players);
|
||||
}
|
||||
|
|
|
@ -48,6 +48,7 @@ public class JavaUpdateScoreTranslator extends PacketTranslator<ServerUpdateScor
|
|||
|
||||
@Override
|
||||
public void translate(ServerUpdateScorePacket packet, GeyserSession session) {
|
||||
session.getConnector().getLogger().warning(packet.toString());
|
||||
WorldCache worldCache = session.getWorldCache();
|
||||
Scoreboard scoreboard = worldCache.getScoreboard();
|
||||
int pps = worldCache.increaseAndGetScoreboardPacketsPerSecond();
|
||||
|
|
|
@ -49,7 +49,6 @@ public final class Objective {
|
|||
private int type = 0; // 0 = integer, 1 = heart
|
||||
|
||||
private Map<String, Score> scores = new ConcurrentHashMap<>();
|
||||
// todo add a 'to add' map so that we don't have to use a concurrentHashMap
|
||||
|
||||
private Objective(Scoreboard scoreboard) {
|
||||
this.id = scoreboard.getNextId().getAndIncrement();
|
||||
|
|
|
@ -35,11 +35,13 @@ public final class Score {
|
|||
private final long id;
|
||||
private final String name;
|
||||
private ScoreInfo cachedInfo;
|
||||
@Getter
|
||||
private boolean hasSentBelowName = false;
|
||||
|
||||
/**
|
||||
* Changes that have been made since the last cached data.
|
||||
*/
|
||||
private Score.ScoreData currentData;
|
||||
private final Score.ScoreData currentData;
|
||||
/**
|
||||
* The data that is currently displayed to the Bedrock client.
|
||||
*/
|
||||
|
@ -72,14 +74,14 @@ public final class Score {
|
|||
if (currentData.team != null && team != null) {
|
||||
if (!currentData.team.equals(team)) {
|
||||
currentData.team = team;
|
||||
currentData.updateType = UpdateType.UPDATE;
|
||||
setUpdateType(UpdateType.UPDATE);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
// simplified from (this.team != null && team == null) || (this.team == null && team != null)
|
||||
if (currentData.team != null || team != null) {
|
||||
currentData.team = team;
|
||||
currentData.updateType = UpdateType.UPDATE;
|
||||
setUpdateType(UpdateType.UPDATE);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
@ -124,6 +126,11 @@ public final class Score {
|
|||
cachedInfo = new ScoreInfo(id, objectiveName, cachedData.score, name);
|
||||
}
|
||||
|
||||
public Score setHasSentBelowName(boolean hasSentBelowName) {
|
||||
this.hasSentBelowName = hasSentBelowName;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Getter
|
||||
public static final class ScoreData {
|
||||
protected UpdateType updateType;
|
||||
|
|
|
@ -27,15 +27,19 @@ package org.geysermc.connector.scoreboard;
|
|||
|
||||
import com.github.steveice10.mc.protocol.data.game.scoreboard.ScoreboardPosition;
|
||||
import com.nukkitx.protocol.bedrock.data.ScoreInfo;
|
||||
import com.nukkitx.protocol.bedrock.data.entity.EntityData;
|
||||
import com.nukkitx.protocol.bedrock.packet.RemoveObjectivePacket;
|
||||
import com.nukkitx.protocol.bedrock.packet.SetDisplayObjectivePacket;
|
||||
import com.nukkitx.protocol.bedrock.packet.SetEntityDataPacket;
|
||||
import com.nukkitx.protocol.bedrock.packet.SetScorePacket;
|
||||
import lombok.Getter;
|
||||
import org.geysermc.connector.GeyserConnector;
|
||||
import org.geysermc.connector.GeyserLogger;
|
||||
import org.geysermc.connector.entity.player.PlayerEntity;
|
||||
import org.geysermc.connector.network.session.GeyserSession;
|
||||
import org.geysermc.connector.utils.LanguageUtils;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
@ -49,9 +53,12 @@ public final class Scoreboard {
|
|||
private final AtomicLong nextId = new AtomicLong(0);
|
||||
|
||||
private final Map<String, Objective> objectives = new HashMap<>();
|
||||
@Getter
|
||||
private final Map<ScoreboardPosition, Objective> objectiveSlots = new HashMap<>();
|
||||
private final Map<String, Team> teams = new ConcurrentHashMap<>(); // updated on multiple threads
|
||||
// todo add a 'to add' map so that we don't have to use a concurrentHashMap
|
||||
/**
|
||||
* Updated on multiple threads
|
||||
*/
|
||||
private final Map<String, Team> teams = new ConcurrentHashMap<>();
|
||||
|
||||
private int lastAddScoreCount = 0;
|
||||
private int lastRemoveScoreCount = 0;
|
||||
|
@ -183,6 +190,7 @@ public final class Scoreboard {
|
|||
setScorePacket.setAction(SetScorePacket.Action.REMOVE);
|
||||
setScorePacket.setInfos(removeScores);
|
||||
session.sendUpstreamPacket(setScorePacket);
|
||||
System.out.println(setScorePacket);
|
||||
}
|
||||
|
||||
if (!addScores.isEmpty()) {
|
||||
|
@ -190,6 +198,7 @@ public final class Scoreboard {
|
|||
setScorePacket.setAction(SetScorePacket.Action.SET);
|
||||
setScorePacket.setInfos(addScores);
|
||||
session.sendUpstreamPacket(setScorePacket);
|
||||
System.out.println(setScorePacket);
|
||||
}
|
||||
|
||||
// prevents crashes in some cases
|
||||
|
@ -241,6 +250,8 @@ public final class Scoreboard {
|
|||
boolean objectiveAdd = objective.getUpdateType() == ADD;
|
||||
boolean objectiveRemove = objective.getUpdateType() == REMOVE;
|
||||
|
||||
boolean isBelowName = objective.getDisplaySlot() == ScoreboardPosition.BELOW_NAME;
|
||||
|
||||
for (Score score : objective.getScores().values()) {
|
||||
Team team = score.getTeam();
|
||||
|
||||
|
@ -285,6 +296,15 @@ public final class Scoreboard {
|
|||
objective.removeScore0(score.getName());
|
||||
}
|
||||
|
||||
if (score.getUpdateType() != REMOVE && !objectiveRemove && isBelowName && add) {
|
||||
PlayerEntity entity = session.getEntityCache().getPlayerEntity(score.getName());
|
||||
if (entity != null) {
|
||||
sendBelowNameUpdate(objective, score, entity);
|
||||
}
|
||||
} else if ((objectiveRemove || score.getUpdateType() == REMOVE) && (isBelowName || score.isHasSentBelowName())) {
|
||||
removeBelowName(score);
|
||||
}
|
||||
|
||||
score.setUpdateType(NOTHING);
|
||||
}
|
||||
|
||||
|
@ -296,6 +316,7 @@ public final class Scoreboard {
|
|||
RemoveObjectivePacket removeObjectivePacket = new RemoveObjectivePacket();
|
||||
removeObjectivePacket.setObjectiveId(objective.getObjectiveName());
|
||||
session.sendUpstreamPacket(removeObjectivePacket);
|
||||
System.out.println(removeObjectivePacket);
|
||||
}
|
||||
|
||||
if ((objectiveAdd || objectiveUpdate) && !objectiveRemove) {
|
||||
|
@ -306,12 +327,14 @@ public final class Scoreboard {
|
|||
displayObjectivePacket.setDisplaySlot(objective.getDisplaySlotName());
|
||||
displayObjectivePacket.setSortOrder(1); // ??
|
||||
session.sendUpstreamPacket(displayObjectivePacket);
|
||||
System.out.println(displayObjectivePacket);
|
||||
}
|
||||
|
||||
objective.setUpdateType(NOTHING);
|
||||
}
|
||||
|
||||
private void deactivateObjective(Objective objective) {
|
||||
System.out.println("Deactivating objective " + objective.getObjectiveName());
|
||||
// Scoreboard has been removed already
|
||||
if (objective.getScores() == null) {
|
||||
return;
|
||||
|
@ -320,6 +343,9 @@ public final class Scoreboard {
|
|||
List<ScoreInfo> removedScores = new ArrayList<>(objective.getScores().size());
|
||||
for (Score score : objective.getScores().values()) {
|
||||
removedScores.add(score.getCachedInfo());
|
||||
if (score.isHasSentBelowName()) {
|
||||
removeBelowName(score);
|
||||
}
|
||||
}
|
||||
|
||||
objective.deactivate();
|
||||
|
@ -328,13 +354,16 @@ public final class Scoreboard {
|
|||
scorePacket.setAction(SetScorePacket.Action.REMOVE);
|
||||
scorePacket.setInfos(removedScores);
|
||||
session.sendUpstreamPacket(scorePacket);
|
||||
System.out.println(scorePacket);
|
||||
|
||||
RemoveObjectivePacket removeObjectivePacket = new RemoveObjectivePacket();
|
||||
removeObjectivePacket.setObjectiveId(objective.getObjectiveName());
|
||||
session.sendUpstreamPacket(removeObjectivePacket);
|
||||
System.out.println(removeObjectivePacket);
|
||||
}
|
||||
|
||||
public void deleteObjective(Objective objective) {
|
||||
System.out.println("Deleting objective " + objective.getObjectiveName());
|
||||
objectives.remove(objective.getObjectiveName());
|
||||
|
||||
Objective storedSlot = objectiveSlots.get(objective.getDisplaySlot());
|
||||
|
@ -349,6 +378,7 @@ public final class Scoreboard {
|
|||
RemoveObjectivePacket removeObjectivePacket = new RemoveObjectivePacket();
|
||||
removeObjectivePacket.setObjectiveId(objective.getObjectiveName());
|
||||
session.sendUpstreamPacket(removeObjectivePacket);
|
||||
System.out.println(removeObjectivePacket);
|
||||
}
|
||||
|
||||
public Team getTeamFor(String entity) {
|
||||
|
@ -359,4 +389,30 @@ public final class Scoreboard {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void sendBelowNameUpdate(Objective objective, @Nullable Score score, PlayerEntity entity) {
|
||||
// Show the belowname information this player
|
||||
// Even if this player doesn't have a score, the display string is still updated for them
|
||||
String displayString = score == null ? "0" : score.getCurrentData().getScore() + " " + objective.getDisplayName();
|
||||
entity.getMetadata().put(EntityData.SCORE_TAG, displayString);
|
||||
SetEntityDataPacket packet = new SetEntityDataPacket();
|
||||
packet.setRuntimeEntityId(entity.getGeyserId());
|
||||
packet.getMetadata().put(EntityData.SCORE_TAG, displayString);
|
||||
session.sendUpstreamPacket(packet);
|
||||
if (score != null) {
|
||||
score.setHasSentBelowName(true);
|
||||
}
|
||||
}
|
||||
|
||||
public void removeBelowName(Score score) {
|
||||
// Clear the tag from the player
|
||||
PlayerEntity entity = session.getEntityCache().getPlayerEntity(score.getName());
|
||||
if (entity != null) {
|
||||
entity.getMetadata().remove(EntityData.SCORE_TAG);
|
||||
SetEntityDataPacket packet = new SetEntityDataPacket();
|
||||
packet.setRuntimeEntityId(entity.getGeyserId());
|
||||
packet.getMetadata().put(EntityData.SCORE_TAG, "");
|
||||
session.sendUpstreamPacket(packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ import lombok.AccessLevel;
|
|||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.geysermc.connector.network.translators.chat.MessageTranslator;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
@ -76,6 +77,10 @@ public final class Team {
|
|||
}
|
||||
}
|
||||
|
||||
public String[] getEntities() {
|
||||
return entities.toArray(new String[0]);
|
||||
}
|
||||
|
||||
public Team addEntities(String... names) {
|
||||
List<String> added = new ArrayList<>();
|
||||
for (String name : names) {
|
||||
|
@ -198,6 +203,22 @@ public final class Team {
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name of an entity on this team in relation to another entity.
|
||||
*
|
||||
* @param username the username of the player on this team
|
||||
* @param otherEntity the other entity, for determining visibility
|
||||
* @return the final display name
|
||||
*/
|
||||
public String getDisplayNameFor(String username, String otherEntity) {
|
||||
String displayName = "";
|
||||
if (isVisibleFor(otherEntity)) {
|
||||
displayName = MessageTranslator.toChatColor(color) + username;
|
||||
displayName = currentData.getDisplayName(displayName);
|
||||
}
|
||||
return displayName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return id.hashCode();
|
||||
|
|
Loading…
Reference in a new issue