mirror of
https://code.briarproject.org/briar/briar.git
synced 2026-02-20 06:39:54 +01:00
Merge branch '756-avoid-lost-messages' into 'master'
Use new group visibility state to avoid lost messages Depends on !410. Closes #756. See merge request !411
This commit is contained in:
@@ -27,8 +27,8 @@ import static org.briarproject.api.sync.Group.Visibility.SHARED;
|
|||||||
import static org.briarproject.privategroup.invitation.CreatorState.DISSOLVED;
|
import static org.briarproject.privategroup.invitation.CreatorState.DISSOLVED;
|
||||||
import static org.briarproject.privategroup.invitation.CreatorState.ERROR;
|
import static org.briarproject.privategroup.invitation.CreatorState.ERROR;
|
||||||
import static org.briarproject.privategroup.invitation.CreatorState.INVITED;
|
import static org.briarproject.privategroup.invitation.CreatorState.INVITED;
|
||||||
import static org.briarproject.privategroup.invitation.CreatorState.INVITEE_JOINED;
|
import static org.briarproject.privategroup.invitation.CreatorState.JOINED;
|
||||||
import static org.briarproject.privategroup.invitation.CreatorState.INVITEE_LEFT;
|
import static org.briarproject.privategroup.invitation.CreatorState.LEFT;
|
||||||
import static org.briarproject.privategroup.invitation.CreatorState.START;
|
import static org.briarproject.privategroup.invitation.CreatorState.START;
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
@@ -55,8 +55,8 @@ class CreatorProtocolEngine extends AbstractProtocolEngine<CreatorSession> {
|
|||||||
case START:
|
case START:
|
||||||
return onLocalInvite(txn, s, message, timestamp, signature);
|
return onLocalInvite(txn, s, message, timestamp, signature);
|
||||||
case INVITED:
|
case INVITED:
|
||||||
case INVITEE_JOINED:
|
case JOINED:
|
||||||
case INVITEE_LEFT:
|
case LEFT:
|
||||||
case DISSOLVED:
|
case DISSOLVED:
|
||||||
case ERROR:
|
case ERROR:
|
||||||
throw new ProtocolStateException(); // Invalid in these states
|
throw new ProtocolStateException(); // Invalid in these states
|
||||||
@@ -80,8 +80,8 @@ class CreatorProtocolEngine extends AbstractProtocolEngine<CreatorSession> {
|
|||||||
case ERROR:
|
case ERROR:
|
||||||
return s; // Ignored in these states
|
return s; // Ignored in these states
|
||||||
case INVITED:
|
case INVITED:
|
||||||
case INVITEE_JOINED:
|
case JOINED:
|
||||||
case INVITEE_LEFT:
|
case LEFT:
|
||||||
return onLocalLeave(txn, s);
|
return onLocalLeave(txn, s);
|
||||||
default:
|
default:
|
||||||
throw new AssertionError();
|
throw new AssertionError();
|
||||||
@@ -105,8 +105,8 @@ class CreatorProtocolEngine extends AbstractProtocolEngine<CreatorSession> {
|
|||||||
JoinMessage m) throws DbException, FormatException {
|
JoinMessage m) throws DbException, FormatException {
|
||||||
switch (s.getState()) {
|
switch (s.getState()) {
|
||||||
case START:
|
case START:
|
||||||
case INVITEE_JOINED:
|
case JOINED:
|
||||||
case INVITEE_LEFT:
|
case LEFT:
|
||||||
return abort(txn, s); // Invalid in these states
|
return abort(txn, s); // Invalid in these states
|
||||||
case INVITED:
|
case INVITED:
|
||||||
return onRemoteAccept(txn, s, m);
|
return onRemoteAccept(txn, s, m);
|
||||||
@@ -123,11 +123,11 @@ class CreatorProtocolEngine extends AbstractProtocolEngine<CreatorSession> {
|
|||||||
LeaveMessage m) throws DbException, FormatException {
|
LeaveMessage m) throws DbException, FormatException {
|
||||||
switch (s.getState()) {
|
switch (s.getState()) {
|
||||||
case START:
|
case START:
|
||||||
case INVITEE_LEFT:
|
case LEFT:
|
||||||
return abort(txn, s); // Invalid in these states
|
return abort(txn, s); // Invalid in these states
|
||||||
case INVITED:
|
case INVITED:
|
||||||
return onRemoteDecline(txn, s, m);
|
return onRemoteDecline(txn, s, m);
|
||||||
case INVITEE_JOINED:
|
case JOINED:
|
||||||
return onRemoteLeave(txn, s, m);
|
return onRemoteLeave(txn, s, m);
|
||||||
case DISSOLVED:
|
case DISSOLVED:
|
||||||
case ERROR:
|
case ERROR:
|
||||||
@@ -180,6 +180,8 @@ class CreatorProtocolEngine extends AbstractProtocolEngine<CreatorSession> {
|
|||||||
// The dependency, if any, must be the last remote message
|
// The dependency, if any, must be the last remote message
|
||||||
if (!isValidDependency(s, m.getPreviousMessageId()))
|
if (!isValidDependency(s, m.getPreviousMessageId()))
|
||||||
return abort(txn, s);
|
return abort(txn, s);
|
||||||
|
// Send a JOIN message
|
||||||
|
Message sent = sendJoinMessage(txn, s, false);
|
||||||
// Mark the response visible in the UI
|
// Mark the response visible in the UI
|
||||||
markMessageVisibleInUi(txn, m.getId(), true);
|
markMessageVisibleInUi(txn, m.getId(), true);
|
||||||
// Track the message
|
// Track the message
|
||||||
@@ -191,10 +193,10 @@ class CreatorProtocolEngine extends AbstractProtocolEngine<CreatorSession> {
|
|||||||
ContactId contactId = getContactId(txn, m.getContactGroupId());
|
ContactId contactId = getContactId(txn, m.getContactGroupId());
|
||||||
txn.attach(new GroupInvitationResponseReceivedEvent(contactId,
|
txn.attach(new GroupInvitationResponseReceivedEvent(contactId,
|
||||||
createInvitationResponse(m, contactId, true)));
|
createInvitationResponse(m, contactId, true)));
|
||||||
// Move to the INVITEE_JOINED state
|
// Move to the JOINED state
|
||||||
return new CreatorSession(s.getContactGroupId(), s.getPrivateGroupId(),
|
return new CreatorSession(s.getContactGroupId(), s.getPrivateGroupId(),
|
||||||
s.getLastLocalMessageId(), m.getId(), s.getLocalTimestamp(),
|
sent.getId(), m.getId(), sent.getTimestamp(),
|
||||||
s.getInviteTimestamp(), INVITEE_JOINED);
|
s.getInviteTimestamp(), JOINED);
|
||||||
}
|
}
|
||||||
|
|
||||||
private CreatorSession onRemoteDecline(Transaction txn, CreatorSession s,
|
private CreatorSession onRemoteDecline(Transaction txn, CreatorSession s,
|
||||||
@@ -228,10 +230,10 @@ class CreatorProtocolEngine extends AbstractProtocolEngine<CreatorSession> {
|
|||||||
return abort(txn, s);
|
return abort(txn, s);
|
||||||
// Make the private group invisible to the contact
|
// Make the private group invisible to the contact
|
||||||
setPrivateGroupVisibility(txn, s, INVISIBLE);
|
setPrivateGroupVisibility(txn, s, INVISIBLE);
|
||||||
// Move to the INVITEE_LEFT state
|
// Move to the LEFT state
|
||||||
return new CreatorSession(s.getContactGroupId(), s.getPrivateGroupId(),
|
return new CreatorSession(s.getContactGroupId(), s.getPrivateGroupId(),
|
||||||
s.getLastLocalMessageId(), m.getId(), s.getLocalTimestamp(),
|
s.getLastLocalMessageId(), m.getId(), s.getLocalTimestamp(),
|
||||||
s.getInviteTimestamp(), INVITEE_LEFT);
|
s.getInviteTimestamp(), LEFT);
|
||||||
}
|
}
|
||||||
|
|
||||||
private CreatorSession abort(Transaction txn, CreatorSession s)
|
private CreatorSession abort(Transaction txn, CreatorSession s)
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import org.briarproject.api.FormatException;
|
|||||||
|
|
||||||
enum CreatorState implements State {
|
enum CreatorState implements State {
|
||||||
|
|
||||||
START(0), INVITED(1), INVITEE_JOINED(2), INVITEE_LEFT(3), DISSOLVED(4),
|
START(0), INVITED(1), JOINED(2), LEFT(3), DISSOLVED(4), ERROR(5);
|
||||||
ERROR(5);
|
|
||||||
|
|
||||||
private final int value;
|
private final int value;
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ import javax.annotation.concurrent.Immutable;
|
|||||||
|
|
||||||
import static org.briarproject.api.sync.Group.Visibility.INVISIBLE;
|
import static org.briarproject.api.sync.Group.Visibility.INVISIBLE;
|
||||||
import static org.briarproject.api.sync.Group.Visibility.SHARED;
|
import static org.briarproject.api.sync.Group.Visibility.SHARED;
|
||||||
|
import static org.briarproject.api.sync.Group.Visibility.VISIBLE;
|
||||||
|
import static org.briarproject.privategroup.invitation.InviteeState.ACCEPTED;
|
||||||
import static org.briarproject.privategroup.invitation.InviteeState.DISSOLVED;
|
import static org.briarproject.privategroup.invitation.InviteeState.DISSOLVED;
|
||||||
import static org.briarproject.privategroup.invitation.InviteeState.ERROR;
|
import static org.briarproject.privategroup.invitation.InviteeState.ERROR;
|
||||||
import static org.briarproject.privategroup.invitation.InviteeState.INVITED;
|
import static org.briarproject.privategroup.invitation.InviteeState.INVITED;
|
||||||
import static org.briarproject.privategroup.invitation.InviteeState.INVITEE_JOINED;
|
import static org.briarproject.privategroup.invitation.InviteeState.JOINED;
|
||||||
import static org.briarproject.privategroup.invitation.InviteeState.INVITEE_LEFT;
|
import static org.briarproject.privategroup.invitation.InviteeState.LEFT;
|
||||||
import static org.briarproject.privategroup.invitation.InviteeState.START;
|
import static org.briarproject.privategroup.invitation.InviteeState.START;
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
@@ -62,8 +64,9 @@ class InviteeProtocolEngine extends AbstractProtocolEngine<InviteeSession> {
|
|||||||
throws DbException {
|
throws DbException {
|
||||||
switch (s.getState()) {
|
switch (s.getState()) {
|
||||||
case START:
|
case START:
|
||||||
case INVITEE_JOINED:
|
case ACCEPTED:
|
||||||
case INVITEE_LEFT:
|
case JOINED:
|
||||||
|
case LEFT:
|
||||||
case DISSOLVED:
|
case DISSOLVED:
|
||||||
case ERROR:
|
case ERROR:
|
||||||
throw new ProtocolStateException(); // Invalid in these states
|
throw new ProtocolStateException(); // Invalid in these states
|
||||||
@@ -79,13 +82,14 @@ class InviteeProtocolEngine extends AbstractProtocolEngine<InviteeSession> {
|
|||||||
throws DbException {
|
throws DbException {
|
||||||
switch (s.getState()) {
|
switch (s.getState()) {
|
||||||
case START:
|
case START:
|
||||||
case INVITEE_LEFT:
|
case LEFT:
|
||||||
case DISSOLVED:
|
case DISSOLVED:
|
||||||
case ERROR:
|
case ERROR:
|
||||||
return s; // Ignored in these states
|
return s; // Ignored in these states
|
||||||
case INVITED:
|
case INVITED:
|
||||||
return onLocalDecline(txn, s);
|
return onLocalDecline(txn, s);
|
||||||
case INVITEE_JOINED:
|
case ACCEPTED:
|
||||||
|
case JOINED:
|
||||||
return onLocalLeave(txn, s);
|
return onLocalLeave(txn, s);
|
||||||
default:
|
default:
|
||||||
throw new AssertionError();
|
throw new AssertionError();
|
||||||
@@ -105,8 +109,9 @@ class InviteeProtocolEngine extends AbstractProtocolEngine<InviteeSession> {
|
|||||||
case START:
|
case START:
|
||||||
return onRemoteInvite(txn, s, m);
|
return onRemoteInvite(txn, s, m);
|
||||||
case INVITED:
|
case INVITED:
|
||||||
case INVITEE_JOINED:
|
case ACCEPTED:
|
||||||
case INVITEE_LEFT:
|
case JOINED:
|
||||||
|
case LEFT:
|
||||||
case DISSOLVED:
|
case DISSOLVED:
|
||||||
return abort(txn, s); // Invalid in these states
|
return abort(txn, s); // Invalid in these states
|
||||||
case ERROR:
|
case ERROR:
|
||||||
@@ -119,7 +124,20 @@ class InviteeProtocolEngine extends AbstractProtocolEngine<InviteeSession> {
|
|||||||
@Override
|
@Override
|
||||||
public InviteeSession onJoinMessage(Transaction txn, InviteeSession s,
|
public InviteeSession onJoinMessage(Transaction txn, InviteeSession s,
|
||||||
JoinMessage m) throws DbException, FormatException {
|
JoinMessage m) throws DbException, FormatException {
|
||||||
return abort(txn, s); // Invalid in this role
|
switch (s.getState()) {
|
||||||
|
case START:
|
||||||
|
case INVITED:
|
||||||
|
case JOINED:
|
||||||
|
case LEFT:
|
||||||
|
case DISSOLVED:
|
||||||
|
return abort(txn, s); // Invalid in these states
|
||||||
|
case ACCEPTED:
|
||||||
|
return onRemoteJoin(txn, s, m);
|
||||||
|
case ERROR:
|
||||||
|
return s; // Ignored in this state
|
||||||
|
default:
|
||||||
|
throw new AssertionError();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -130,8 +148,9 @@ class InviteeProtocolEngine extends AbstractProtocolEngine<InviteeSession> {
|
|||||||
case DISSOLVED:
|
case DISSOLVED:
|
||||||
return abort(txn, s); // Invalid in these states
|
return abort(txn, s); // Invalid in these states
|
||||||
case INVITED:
|
case INVITED:
|
||||||
case INVITEE_JOINED:
|
case ACCEPTED:
|
||||||
case INVITEE_LEFT:
|
case JOINED:
|
||||||
|
case LEFT:
|
||||||
return onRemoteLeave(txn, s, m);
|
return onRemoteLeave(txn, s, m);
|
||||||
case ERROR:
|
case ERROR:
|
||||||
return s; // Ignored in this state
|
return s; // Ignored in this state
|
||||||
@@ -159,15 +178,15 @@ class InviteeProtocolEngine extends AbstractProtocolEngine<InviteeSession> {
|
|||||||
try {
|
try {
|
||||||
// Subscribe to the private group
|
// Subscribe to the private group
|
||||||
subscribeToPrivateGroup(txn, inviteId);
|
subscribeToPrivateGroup(txn, inviteId);
|
||||||
// Share the private group with the contact
|
// Make the private group visible to the contact
|
||||||
setPrivateGroupVisibility(txn, s, SHARED);
|
setPrivateGroupVisibility(txn, s, VISIBLE);
|
||||||
} catch (FormatException e) {
|
} catch (FormatException e) {
|
||||||
throw new DbException(e); // Invalid group metadata
|
throw new DbException(e); // Invalid group metadata
|
||||||
}
|
}
|
||||||
// Move to the INVITEE_JOINED state
|
// Move to the ACCEPTED state
|
||||||
return new InviteeSession(s.getContactGroupId(), s.getPrivateGroupId(),
|
return new InviteeSession(s.getContactGroupId(), s.getPrivateGroupId(),
|
||||||
sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
|
sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
|
||||||
s.getInviteTimestamp(), INVITEE_JOINED);
|
s.getInviteTimestamp(), ACCEPTED);
|
||||||
}
|
}
|
||||||
|
|
||||||
private InviteeSession onLocalDecline(Transaction txn, InviteeSession s)
|
private InviteeSession onLocalDecline(Transaction txn, InviteeSession s)
|
||||||
@@ -190,10 +209,10 @@ class InviteeProtocolEngine extends AbstractProtocolEngine<InviteeSession> {
|
|||||||
throws DbException {
|
throws DbException {
|
||||||
// Send a LEAVE message
|
// Send a LEAVE message
|
||||||
Message sent = sendLeaveMessage(txn, s, false);
|
Message sent = sendLeaveMessage(txn, s, false);
|
||||||
// Move to the INVITEE_LEFT state
|
// Move to the LEFT state
|
||||||
return new InviteeSession(s.getContactGroupId(), s.getPrivateGroupId(),
|
return new InviteeSession(s.getContactGroupId(), s.getPrivateGroupId(),
|
||||||
sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
|
sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
|
||||||
s.getInviteTimestamp(), INVITEE_LEFT);
|
s.getInviteTimestamp(), LEFT);
|
||||||
}
|
}
|
||||||
|
|
||||||
private InviteeSession onRemoteInvite(Transaction txn, InviteeSession s,
|
private InviteeSession onRemoteInvite(Transaction txn, InviteeSession s,
|
||||||
@@ -222,6 +241,25 @@ class InviteeProtocolEngine extends AbstractProtocolEngine<InviteeSession> {
|
|||||||
m.getTimestamp(), INVITED);
|
m.getTimestamp(), INVITED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private InviteeSession onRemoteJoin(Transaction txn, InviteeSession s,
|
||||||
|
JoinMessage m) throws DbException, FormatException {
|
||||||
|
// The timestamp must be higher than the last invite message, if any
|
||||||
|
if (m.getTimestamp() <= s.getInviteTimestamp()) return abort(txn, s);
|
||||||
|
// The dependency, if any, must be the last remote message
|
||||||
|
if (!isValidDependency(s, m.getPreviousMessageId()))
|
||||||
|
return abort(txn, s);
|
||||||
|
try {
|
||||||
|
// Share the private group with the contact
|
||||||
|
setPrivateGroupVisibility(txn, s, SHARED);
|
||||||
|
} catch (FormatException e) {
|
||||||
|
throw new DbException(e); // Invalid group metadata
|
||||||
|
}
|
||||||
|
// Move to the JOINED state
|
||||||
|
return new InviteeSession(s.getContactGroupId(), s.getPrivateGroupId(),
|
||||||
|
s.getLastLocalMessageId(), m.getId(), s.getLocalTimestamp(),
|
||||||
|
s.getInviteTimestamp(), JOINED);
|
||||||
|
}
|
||||||
|
|
||||||
private InviteeSession onRemoteLeave(Transaction txn, InviteeSession s,
|
private InviteeSession onRemoteLeave(Transaction txn, InviteeSession s,
|
||||||
LeaveMessage m) throws DbException, FormatException {
|
LeaveMessage m) throws DbException, FormatException {
|
||||||
// The timestamp must be higher than the last invite message, if any
|
// The timestamp must be higher than the last invite message, if any
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import org.briarproject.api.FormatException;
|
|||||||
|
|
||||||
enum InviteeState implements State {
|
enum InviteeState implements State {
|
||||||
|
|
||||||
START(0), INVITED(1), INVITEE_JOINED(2), INVITEE_LEFT(3), DISSOLVED(4),
|
START(0), INVITED(1), ACCEPTED(2), JOINED(3), LEFT(4), DISSOLVED(5),
|
||||||
ERROR(5);
|
ERROR(6);
|
||||||
|
|
||||||
private final int value;
|
private final int value;
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import javax.annotation.concurrent.Immutable;
|
|||||||
|
|
||||||
import static org.briarproject.api.sync.Group.Visibility.INVISIBLE;
|
import static org.briarproject.api.sync.Group.Visibility.INVISIBLE;
|
||||||
import static org.briarproject.api.sync.Group.Visibility.SHARED;
|
import static org.briarproject.api.sync.Group.Visibility.SHARED;
|
||||||
|
import static org.briarproject.api.sync.Group.Visibility.VISIBLE;
|
||||||
import static org.briarproject.privategroup.invitation.PeerState.AWAIT_MEMBER;
|
import static org.briarproject.privategroup.invitation.PeerState.AWAIT_MEMBER;
|
||||||
import static org.briarproject.privategroup.invitation.PeerState.BOTH_JOINED;
|
import static org.briarproject.privategroup.invitation.PeerState.BOTH_JOINED;
|
||||||
import static org.briarproject.privategroup.invitation.PeerState.ERROR;
|
import static org.briarproject.privategroup.invitation.PeerState.ERROR;
|
||||||
@@ -169,6 +170,12 @@ class PeerProtocolEngine extends AbstractProtocolEngine<PeerSession> {
|
|||||||
PeerSession s) throws DbException {
|
PeerSession s) throws DbException {
|
||||||
// Send a JOIN message
|
// Send a JOIN message
|
||||||
Message sent = sendJoinMessage(txn, s, false);
|
Message sent = sendJoinMessage(txn, s, false);
|
||||||
|
try {
|
||||||
|
// Make the private group visible to the contact
|
||||||
|
setPrivateGroupVisibility(txn, s, VISIBLE);
|
||||||
|
} catch (FormatException e) {
|
||||||
|
throw new DbException(e); // Invalid group metadata
|
||||||
|
}
|
||||||
// Move to the LOCAL_JOINED state
|
// Move to the LOCAL_JOINED state
|
||||||
return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
|
return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
|
||||||
sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
|
sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
|
||||||
@@ -212,6 +219,12 @@ class PeerProtocolEngine extends AbstractProtocolEngine<PeerSession> {
|
|||||||
PeerSession s) throws DbException {
|
PeerSession s) throws DbException {
|
||||||
// Send a LEAVE message
|
// Send a LEAVE message
|
||||||
Message sent = sendLeaveMessage(txn, s, false);
|
Message sent = sendLeaveMessage(txn, s, false);
|
||||||
|
try {
|
||||||
|
// Make the private group invisible to the contact
|
||||||
|
setPrivateGroupVisibility(txn, s, INVISIBLE);
|
||||||
|
} catch (FormatException e) {
|
||||||
|
throw new DbException(e); // Invalid group metadata
|
||||||
|
}
|
||||||
// Move to the NEITHER_JOINED state
|
// Move to the NEITHER_JOINED state
|
||||||
return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
|
return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
|
||||||
sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
|
sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
|
||||||
@@ -316,8 +329,8 @@ class PeerProtocolEngine extends AbstractProtocolEngine<PeerSession> {
|
|||||||
// The dependency, if any, must be the last remote message
|
// The dependency, if any, must be the last remote message
|
||||||
if (!isValidDependency(s, m.getPreviousMessageId()))
|
if (!isValidDependency(s, m.getPreviousMessageId()))
|
||||||
return abort(txn, s);
|
return abort(txn, s);
|
||||||
// Make the private group invisible to the contact
|
// Unshare the private group with the contact
|
||||||
setPrivateGroupVisibility(txn, s, INVISIBLE);
|
setPrivateGroupVisibility(txn, s, VISIBLE);
|
||||||
// Move to the LOCAL_JOINED state
|
// Move to the LOCAL_JOINED state
|
||||||
return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
|
return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
|
||||||
s.getLastLocalMessageId(), m.getId(), s.getLocalTimestamp(),
|
s.getLastLocalMessageId(), m.getId(), s.getLocalTimestamp(),
|
||||||
|
|||||||
Reference in New Issue
Block a user