fix: align IM client interaction edge cases
This commit is contained in:
@@ -3432,9 +3432,13 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) (
|
|||||||
if len(usrq) > 0 {
|
if len(usrq) > 0 {
|
||||||
q = `SELECT u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,u.public,u.trusted,
|
q = `SELECT u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,u.public,u.trusted,
|
||||||
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
|
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
|
||||||
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
|
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at,
|
||||||
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id WHERE u.id IN (?)`
|
fs.remark AS friendship_remark
|
||||||
q, args, _ = sqlx.In(q, usrq)
|
FROM im_users AS u
|
||||||
|
LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
|
||||||
|
LEFT JOIN im_friendships AS fs ON fs.user_id=? AND fs.peer_id=u.id AND fs.status=?
|
||||||
|
WHERE u.id IN (?)`
|
||||||
|
q, args, _ = sqlx.In(q, store.DecodeUid(uid), t.RelationshipFriend, usrq)
|
||||||
if !keepDeleted {
|
if !keepDeleted {
|
||||||
// Optionally skip deleted users.
|
// Optionally skip deleted users.
|
||||||
q += " AND u.state!=?"
|
q += " AND u.state!=?"
|
||||||
@@ -3466,6 +3470,7 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) (
|
|||||||
sub.SetTrusted(common.FromJSON(usr2.Trusted))
|
sub.SetTrusted(common.FromJSON(usr2.Trusted))
|
||||||
sub.SetDefaultAccess(usr2.Access.Auth, usr2.Access.Anon)
|
sub.SetDefaultAccess(usr2.Access.Auth, usr2.Access.Anon)
|
||||||
sub.SetLastSeenAndUA(usr2.lastSeen(), usr2.UserAgent)
|
sub.SetLastSeenAndUA(usr2.lastSeen(), usr2.UserAgent)
|
||||||
|
sub.Private = ensureP2PPrivatePeerRemark(sub.Private, common.EncodeUidString(usr2.ID).UserId(), usr2.FriendshipRemark.String)
|
||||||
join[joinOn] = sub
|
join[joinOn] = sub
|
||||||
p2pResolved[joinOn] = true
|
p2pResolved[joinOn] = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ type profileUserRow struct {
|
|||||||
ProfileRegion sql.NullString `db:"profile_region"`
|
ProfileRegion sql.NullString `db:"profile_region"`
|
||||||
ProfilePublicAccount sql.NullString `db:"profile_public_account"`
|
ProfilePublicAccount sql.NullString `db:"profile_public_account"`
|
||||||
ProfileUpdatedAt sql.NullTime `db:"profile_updated_at"`
|
ProfileUpdatedAt sql.NullTime `db:"profile_updated_at"`
|
||||||
|
FriendshipRemark sql.NullString `db:"friendship_remark"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (row profileUserRow) effectiveUpdatedAt() time.Time {
|
func (row profileUserRow) effectiveUpdatedAt() time.Time {
|
||||||
@@ -151,6 +152,10 @@ func profilePublicFromFields(base any, nickname, avatarFileID, signature, gender
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ensureP2PPrivatePeer(private any, peerID string) any {
|
func ensureP2PPrivatePeer(private any, peerID string) any {
|
||||||
|
return ensureP2PPrivatePeerRemark(private, peerID, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureP2PPrivatePeerRemark(private any, peerID, remark string) any {
|
||||||
if strings.TrimSpace(peerID) == "" {
|
if strings.TrimSpace(peerID) == "" {
|
||||||
return private
|
return private
|
||||||
}
|
}
|
||||||
@@ -161,6 +166,9 @@ func ensureP2PPrivatePeer(private any, peerID string) any {
|
|||||||
if _, ok := data["peer"]; !ok {
|
if _, ok := data["peer"]; !ok {
|
||||||
data["peer"] = peerID
|
data["peer"] = peerID
|
||||||
}
|
}
|
||||||
|
if remark = strings.TrimSpace(remark); remark != "" {
|
||||||
|
data["remark"] = remark
|
||||||
|
}
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1050,9 +1058,13 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool
|
|||||||
if len(usrq) > 0 {
|
if len(usrq) > 0 {
|
||||||
q = `SELECT u.tenant_id,u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,u.public,u.trusted,
|
q = `SELECT u.tenant_id,u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,u.public,u.trusted,
|
||||||
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
|
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
|
||||||
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
|
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at,
|
||||||
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id WHERE u.tenant_id=? AND u.id IN (?)`
|
fs.remark AS friendship_remark
|
||||||
q, args, _ = sqlx.In(q, id, usrq)
|
FROM im_users AS u
|
||||||
|
LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
|
||||||
|
LEFT JOIN im_friendships AS fs ON fs.user_id=? AND fs.peer_id=u.id AND fs.status=?
|
||||||
|
WHERE u.tenant_id=? AND u.id IN (?)`
|
||||||
|
q, args, _ = sqlx.In(q, decodeUID(uid), t.RelationshipFriend, id, usrq)
|
||||||
if !keepDeleted {
|
if !keepDeleted {
|
||||||
q += " AND u.state!=?"
|
q += " AND u.state!=?"
|
||||||
args = append(args, t.StateDeleted)
|
args = append(args, t.StateDeleted)
|
||||||
@@ -1075,6 +1087,7 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool
|
|||||||
sub.SetTrusted(common.FromJSON(user.Trusted))
|
sub.SetTrusted(common.FromJSON(user.Trusted))
|
||||||
sub.SetDefaultAccess(user.Access.Auth, user.Access.Anon)
|
sub.SetDefaultAccess(user.Access.Auth, user.Access.Anon)
|
||||||
sub.SetLastSeenAndUA(user.lastSeen(), user.UserAgent)
|
sub.SetLastSeenAndUA(user.lastSeen(), user.UserAgent)
|
||||||
|
sub.Private = ensureP2PPrivatePeerRemark(sub.Private, common.EncodeUidString(user.ID).UserId(), user.FriendshipRemark.String)
|
||||||
join[joinOn] = sub
|
join[joinOn] = sub
|
||||||
p2pResolved[joinOn] = true
|
p2pResolved[joinOn] = true
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-12
@@ -45,6 +45,7 @@ const (
|
|||||||
imReasonRealNameRequired = "REALNAME_REQUIRED"
|
imReasonRealNameRequired = "REALNAME_REQUIRED"
|
||||||
imReasonActionToken = "ACTION_TOKEN_INVALID"
|
imReasonActionToken = "ACTION_TOKEN_INVALID"
|
||||||
imReasonPermissionDenied = "PERMISSION_DENIED"
|
imReasonPermissionDenied = "PERMISSION_DENIED"
|
||||||
|
imReasonForbidden = "FORBIDDEN"
|
||||||
imReasonRiskConfirmation = "RISK_CONFIRMATION_REQUIRED"
|
imReasonRiskConfirmation = "RISK_CONFIRMATION_REQUIRED"
|
||||||
imReasonSchemaUnsupported = "SCHEMA_UNSUPPORTED"
|
imReasonSchemaUnsupported = "SCHEMA_UNSUPPORTED"
|
||||||
imReasonIdempotencyConflict = "IDEMPOTENCY_CONFLICT"
|
imReasonIdempotencyConflict = "IDEMPOTENCY_CONFLICT"
|
||||||
@@ -587,6 +588,7 @@ type imSystemMessageAction struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
TargetID string `json:"target_id,omitempty"`
|
TargetID string `json:"target_id,omitempty"`
|
||||||
|
MinRole string `json:"min_role,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
|
func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
|
||||||
@@ -4430,15 +4432,7 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if tail == "messages" {
|
if tail == "messages" {
|
||||||
if req.Method != http.MethodGet {
|
serveIMConversationMessages(wrt, req, principal, topic)
|
||||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if topic == "" {
|
|
||||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": []any{}, "next_cursor": "", "has_more": false})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if tail != "context" {
|
if tail != "context" {
|
||||||
@@ -4466,6 +4460,52 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
|
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serveIMConversationMessages(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, topic string) {
|
||||||
|
if topic == "" {
|
||||||
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
|
||||||
|
switch req.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": []any{}, "next_cursor": "", "has_more": false})
|
||||||
|
case http.MethodDelete:
|
||||||
|
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
||||||
|
if err != nil {
|
||||||
|
writeIMRelationError(wrt, req, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sub == nil || !(sub.ModeGiven&sub.ModeWant).IsReader() {
|
||||||
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonForbidden, "permission denied", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
top, err := store.Topics.Get(principal.TenantID, topic)
|
||||||
|
if err != nil {
|
||||||
|
writeIMRelationError(wrt, req, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if top == nil {
|
||||||
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonResourceNotFound, "conversation not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delID := top.DelId + 1
|
||||||
|
if delID <= 0 {
|
||||||
|
delID = int(time.Now().Unix())
|
||||||
|
}
|
||||||
|
var ranges []types.Range
|
||||||
|
if top.SeqId > 0 {
|
||||||
|
ranges = []types.Range{{Low: 1, Hi: top.SeqId + 1}}
|
||||||
|
}
|
||||||
|
if err = store.Messages.DeleteList(principal.TenantID, topic, delID, principal.Uid, 0, ranges); err != nil {
|
||||||
|
writeIMRelationError(wrt, req, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "cleared": true, "deleted_id": delID})
|
||||||
|
default:
|
||||||
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func serveIMConversationSettings(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, topic string) {
|
func serveIMConversationSettings(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, topic string) {
|
||||||
if topic == "" {
|
if topic == "" {
|
||||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
||||||
@@ -4904,7 +4944,7 @@ func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
serveIMGroupOwnerTransfer(wrt, req, topic)
|
serveIMGroupOwnerTransfer(wrt, req, topic)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if tail == "mutes" || strings.HasPrefix(tail, "mutes/") {
|
if tail == "mutes" || tail == "mutes/batch" || strings.HasPrefix(tail, "mutes/") {
|
||||||
serveIMGroupMutes(wrt, req, topic, tail)
|
serveIMGroupMutes(wrt, req, topic, tail)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -5928,9 +5968,24 @@ func serveIMGroupGovernance(wrt http.ResponseWriter, req *http.Request, topic st
|
|||||||
[]imSystemMessageActor{
|
[]imSystemMessageActor{
|
||||||
{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}},
|
{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}},
|
||||||
},
|
},
|
||||||
[]imSystemMessageAction{{Type: "open_group_invite_settings", Label: "群聊邀请确认", TargetID: topic}},
|
[]imSystemMessageAction{{Type: "open_group_invite_settings", Label: "群聊邀请确认", TargetID: topic, MinRole: "admin"}},
|
||||||
map[string]any{"join_approval": *body.JoinApproval, "version": settings.Version})
|
map[string]any{"join_approval": *body.JoinApproval, "version": settings.Version})
|
||||||
}
|
}
|
||||||
|
if body.MemberPrivateChat != nil && imReliableHTTPEventsEnabled(req) {
|
||||||
|
template := "group_member_private_chat_disabled"
|
||||||
|
text := "群成员私信权限已关闭,群成员头像和名片不可点击查看。"
|
||||||
|
if *body.MemberPrivateChat {
|
||||||
|
template = "group_member_private_chat_enabled"
|
||||||
|
text = "群成员私信权限已开启,群成员头像和名片可点击查看。"
|
||||||
|
}
|
||||||
|
publishIMConversationSystemNotice(principal.TenantID, topic,
|
||||||
|
"system_notice", template, text,
|
||||||
|
[]imSystemMessageActor{
|
||||||
|
{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
map[string]any{"member_private_chat": *body.MemberPrivateChat, "version": settings.Version})
|
||||||
|
}
|
||||||
dispatchIMGroupEvents(principal.TenantID)
|
dispatchIMGroupEvents(principal.TenantID)
|
||||||
writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub))
|
writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub))
|
||||||
default:
|
default:
|
||||||
@@ -6086,7 +6141,7 @@ func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail s
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(items), "next_cursor": nextCursor})
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(items), "next_cursor": nextCursor})
|
||||||
case tail == "mutes" && req.Method == http.MethodPost:
|
case (tail == "mutes" || tail == "mutes/batch") && req.Method == http.MethodPost:
|
||||||
var body imGroupMuteBatchRequest
|
var body imGroupMuteBatchRequest
|
||||||
if !decodeIMJSONBody(wrt, req, &body) {
|
if !decodeIMJSONBody(wrt, req, &body) {
|
||||||
return
|
return
|
||||||
@@ -6411,6 +6466,10 @@ func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, identifier s
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !imPrivacyAllowsProfile(privacy, ctx.Relationship) {
|
if !imPrivacyAllowsProfile(privacy, ctx.Relationship) {
|
||||||
|
if ctx.Relationship == types.RelationshipBlockedByMe {
|
||||||
|
writeIMSuccess(wrt, req, blockedUserProfilePayload(user, ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "profile is private", nil)
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "profile is private", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -9749,6 +9808,21 @@ func userProfilePayload(user *types.User, ctx *types.RelationshipContext) map[st
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func blockedUserProfilePayload(user *types.User, ctx *types.RelationshipContext) map[string]any {
|
||||||
|
data := userSummaryPayload(user, ctx.Remark)
|
||||||
|
data["relationship"] = ctx.Relationship
|
||||||
|
data["topic"] = ctx.Topic
|
||||||
|
data["capabilities"] = map[string]any{
|
||||||
|
"can_send": false,
|
||||||
|
"can_call": false,
|
||||||
|
"can_add_friend": false,
|
||||||
|
}
|
||||||
|
if ctx.Remark != "" {
|
||||||
|
data["remark"] = ctx.Remark
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
func copyOptionalProfileFields(data map[string]any, public map[string]any) {
|
func copyOptionalProfileFields(data map[string]any, public map[string]any) {
|
||||||
for _, key := range []string{"nickname", "gender", "region", "public_account", "signature"} {
|
for _, key := range []string{"nickname", "gender", "region", "public_account", "signature"} {
|
||||||
if value, ok := public[key]; ok {
|
if value, ok := public[key]; ok {
|
||||||
|
|||||||
@@ -347,10 +347,10 @@ func preparePayloads(rcpt *push.Receipt, config *configType) ([]*jpushPayload, [
|
|||||||
var targets []t.Uid
|
var targets []t.Uid
|
||||||
for _, uid := range uids {
|
for _, uid := range uids {
|
||||||
recipient := rcpt.To[uid]
|
recipient := rcpt.To[uid]
|
||||||
if recipient.Delivered > 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
userData := cloneData(data)
|
userData := cloneData(data)
|
||||||
|
if recipient.Delivered > 0 {
|
||||||
|
userData["silent"] = "true"
|
||||||
|
}
|
||||||
topic := rcpt.Payload.Topic
|
topic := rcpt.Payload.Topic
|
||||||
var audience map[string][]string
|
var audience map[string][]string
|
||||||
if useAlias {
|
if useAlias {
|
||||||
|
|||||||
@@ -146,6 +146,30 @@ func TestPreparePayloadsCanUseRegistrationID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPreparePayloadsDoesNotDropRecipientWithDeliveredSession(t *testing.T) {
|
||||||
|
oldTokens := store.DevicePushTokens
|
||||||
|
uid := types.Uid(42)
|
||||||
|
store.DevicePushTokens = &testDevicePushTokenStore{devices: map[types.Uid][]types.DeviceDef{
|
||||||
|
uid: {{DeviceId: "push-token", Platform: "ios"}},
|
||||||
|
}}
|
||||||
|
defer func() { store.DevicePushTokens = oldTokens }()
|
||||||
|
useAlias := false
|
||||||
|
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60, UseAlias: &useAlias}
|
||||||
|
payloads, _ := preparePayloads(&push.Receipt{
|
||||||
|
TenantID: 7,
|
||||||
|
To: map[types.Uid]push.Recipient{
|
||||||
|
uid: {Delivered: 1},
|
||||||
|
},
|
||||||
|
Payload: push.Payload{What: push.ActMsg, Topic: "grpTopic", SeqId: 9, Timestamp: time.Now()},
|
||||||
|
}, config)
|
||||||
|
if len(payloads) != 1 {
|
||||||
|
t.Fatalf("unexpected payload count: %d", len(payloads))
|
||||||
|
}
|
||||||
|
if payloads[0].Message == nil || payloads[0].Message.Extras["silent"] != "true" {
|
||||||
|
t.Fatalf("expected silent payload for delivered recipient: %#v", payloads[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildPayloadUsesShortTTLForCalls(t *testing.T) {
|
func TestBuildPayloadUsesShortTTLForCalls(t *testing.T) {
|
||||||
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60}
|
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60}
|
||||||
payload := buildPayload(map[string][]string{"alias": {"usr1"}}, "p2pTopic", map[string]string{
|
payload := buildPayload(map[string][]string{"alias": {"usr1"}}, "p2pTopic", map[string]string{
|
||||||
|
|||||||
Reference in New Issue
Block a user