RNFirebase - database - js
This commit is contained in:
parent
4d9255c3e9
commit
6a9b54124a
229
lib/modules/database/index.js
Normal file
229
lib/modules/database/index.js
Normal file
@ -0,0 +1,229 @@
|
||||
/**
|
||||
* @flow
|
||||
* Database representation wrapper
|
||||
*/
|
||||
import { NativeModules, NativeEventEmitter } from 'react-native';
|
||||
|
||||
import { Base } from './../base';
|
||||
import Snapshot from './snapshot.js';
|
||||
import Reference from './reference.js';
|
||||
import { promisify } from './../../utils';
|
||||
|
||||
const FirebaseDatabase = NativeModules.FirebaseDatabase;
|
||||
const FirebaseDatabaseEvt = new NativeEventEmitter(FirebaseDatabase);
|
||||
|
||||
/**
|
||||
* @class Database
|
||||
*/
|
||||
export default class Database extends Base {
|
||||
constructor(firebase: Object, options: Object = {}) {
|
||||
super(firebase, options);
|
||||
this.subscriptions = {};
|
||||
this.errorSubscriptions = {};
|
||||
this.serverTimeOffset = 0;
|
||||
this.persistenceEnabled = false;
|
||||
this.namespace = 'firebase:database';
|
||||
|
||||
if (firebase.options.persistence === true) {
|
||||
this._setPersistence(true);
|
||||
}
|
||||
|
||||
this.successListener = FirebaseDatabaseEvt.addListener(
|
||||
'database_event',
|
||||
event => this._handleDatabaseEvent(event)
|
||||
);
|
||||
|
||||
this.errorListener = FirebaseDatabaseEvt.addListener(
|
||||
'database_error',
|
||||
err => this._handleDatabaseError(err)
|
||||
);
|
||||
|
||||
this.offsetRef = this.ref('.info/serverTimeOffset');
|
||||
|
||||
this.offsetRef.on('value', (snapshot) => {
|
||||
this.serverTimeOffset = snapshot.val() || this.serverTimeOffset;
|
||||
});
|
||||
|
||||
this.log.debug('Created new Database instance', this.options);
|
||||
}
|
||||
|
||||
/**
|
||||
* https://firebase.google.com/docs/reference/js/firebase.database.ServerValue
|
||||
* @returns {{TIMESTAMP: (*|{[.sv]: string})}}
|
||||
* @constructor
|
||||
*/
|
||||
get ServerValue(): Object {
|
||||
return {
|
||||
TIMESTAMP: FirebaseDatabase.serverValueTimestamp || { '.sv': 'timestamp' },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new firebase reference instance
|
||||
* @param path
|
||||
* @returns {Reference}
|
||||
*/
|
||||
ref(path: string) {
|
||||
return new Reference(this, path);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param path
|
||||
* @param modifiersString
|
||||
* @param modifiers
|
||||
* @param eventName
|
||||
* @param cb
|
||||
* @param errorCb
|
||||
* @returns {*}
|
||||
*/
|
||||
on(path: string, modifiersString: string, modifiers: Array<string>, eventName: string, cb: () => void, errorCb: () => void) {
|
||||
const handle = this._handle(path, modifiersString);
|
||||
this.log.debug('adding on listener', handle);
|
||||
|
||||
if (!this.subscriptions[handle]) this.subscriptions[handle] = {};
|
||||
if (!this.subscriptions[handle][eventName]) this.subscriptions[handle][eventName] = [];
|
||||
this.subscriptions[handle][eventName].push(cb);
|
||||
if (errorCb) {
|
||||
if (!this.errorSubscriptions[handle]) this.errorSubscriptions[handle] = [];
|
||||
this.errorSubscriptions[handle].push(errorCb);
|
||||
}
|
||||
|
||||
return promisify('on', FirebaseDatabase)(path, modifiersString, modifiers, eventName);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param path
|
||||
* @param modifiersString
|
||||
* @param eventName
|
||||
* @param origCB
|
||||
* @returns {*}
|
||||
*/
|
||||
off(path: string, modifiersString: string, eventName?: string, origCB?: () => void) {
|
||||
const handle = this._handle(path, modifiersString);
|
||||
this.log.debug('off() : ', handle, eventName);
|
||||
|
||||
if (!this.subscriptions[handle] || (eventName && !this.subscriptions[handle][eventName])) {
|
||||
this.log.warn('off() called, but not currently listening at that location (bad path)', handle, eventName);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (eventName && origCB) {
|
||||
const i = this.subscriptions[handle][eventName].indexOf(origCB);
|
||||
|
||||
if (i === -1) {
|
||||
this.log.warn('off() called, but the callback specified is not listening at that location (bad path)', handle, eventName);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.subscriptions[handle][eventName].splice(i, 1);
|
||||
if (this.subscriptions[handle][eventName].length > 0) return Promise.resolve();
|
||||
} else if (eventName) {
|
||||
this.subscriptions[handle][eventName] = [];
|
||||
} else {
|
||||
this.subscriptions[handle] = {};
|
||||
}
|
||||
this.errorSubscriptions[handle] = [];
|
||||
return promisify('off', FirebaseDatabase)(path, modifiersString, eventName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all event handlers and their native subscriptions
|
||||
* @returns {Promise.<*>}
|
||||
*/
|
||||
cleanup() {
|
||||
const promises = [];
|
||||
Object.keys(this.subscriptions).forEach((handle) => {
|
||||
Object.keys(this.subscriptions[handle]).forEach((eventName) => {
|
||||
const separator = handle.indexOf('|');
|
||||
const path = handle.substring(0, separator);
|
||||
const modifiersString = handle.substring(separator + 1);
|
||||
promises.push(this.off(path, modifiersString, eventName));
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
goOnline() {
|
||||
FirebaseDatabase.goOnline();
|
||||
}
|
||||
|
||||
goOffline() {
|
||||
FirebaseDatabase.goOffline();
|
||||
}
|
||||
|
||||
/**
|
||||
* INTERNALS
|
||||
*/
|
||||
_getServerTime() {
|
||||
return new Date().getTime() + this.serverTimeOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabled / disable database persistence
|
||||
* @param enable
|
||||
* @returns {*}
|
||||
* @private
|
||||
*/
|
||||
_setPersistence(enable: boolean = true) {
|
||||
if (this.persistenceEnabled !== enable) {
|
||||
this.log.debug(`${enable ? 'Enabling' : 'Disabling'} persistence`);
|
||||
this.persistenceEnabled = enable;
|
||||
return this.whenReady(promisify('enablePersistence', FirebaseDatabase)(enable));
|
||||
}
|
||||
|
||||
return this.whenReady(Promise.resolve({ status: 'Already enabled' }));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param path
|
||||
* @param modifiersString
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
_handle(path: string = '', modifiersString: string = '') {
|
||||
return `${path}|${modifiersString}`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param event
|
||||
* @private
|
||||
*/
|
||||
_handleDatabaseEvent(event: Object) {
|
||||
const body = event.body || {};
|
||||
const { path, modifiersString, eventName, snapshot } = body;
|
||||
const handle = this._handle(path, modifiersString);
|
||||
|
||||
this.log.debug('_handleDatabaseEvent: ', handle, eventName, snapshot && snapshot.key);
|
||||
|
||||
if (this.subscriptions[handle] && this.subscriptions[handle][eventName]) {
|
||||
this.subscriptions[handle][eventName].forEach((cb) => {
|
||||
cb(new Snapshot(new Reference(this, path, modifiersString.split('|')), snapshot), body);
|
||||
});
|
||||
} else {
|
||||
FirebaseDatabase.off(path, modifiersString, eventName, () => {
|
||||
this.log.debug('_handleDatabaseEvent: No JS listener registered, removed native listener', handle, eventName);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param err
|
||||
* @private
|
||||
*/
|
||||
_handleDatabaseError(err: Object) {
|
||||
const body = err.body || {};
|
||||
const { path, modifiersString, eventName, msg } = body;
|
||||
const handle = this._handle(path, modifiersString);
|
||||
|
||||
this.log.debug('_handleDatabaseError ->', handle, eventName, err);
|
||||
|
||||
if (this.errorSubscriptions[handle]) this.errorSubscriptions[handle].forEach((cb) => cb(new Error(msg)));
|
||||
}
|
||||
}
|
55
lib/modules/database/query.js
Normal file
55
lib/modules/database/query.js
Normal file
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import { ReferenceBase } from './../base';
|
||||
import Reference from './reference.js';
|
||||
|
||||
/**
|
||||
* @class Query
|
||||
*/
|
||||
export default class Query extends ReferenceBase {
|
||||
static ref: Reference;
|
||||
|
||||
static modifiers: Array<string>;
|
||||
|
||||
ref: Reference;
|
||||
|
||||
constructor(ref: Reference, path: string, existingModifiers?: Array<string>) {
|
||||
super(ref.db, path);
|
||||
this.log.debug('creating Query ', path, existingModifiers);
|
||||
this.ref = ref;
|
||||
this.modifiers = existingModifiers ? [...existingModifiers] : [];
|
||||
}
|
||||
|
||||
setOrderBy(name: string, key?: string) {
|
||||
if (key) {
|
||||
this.modifiers.push(`${name}:${key}`);
|
||||
} else {
|
||||
this.modifiers.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
setLimit(name: string, limit: number) {
|
||||
this.modifiers.push(`${name}:${limit}`);
|
||||
}
|
||||
|
||||
setFilter(name: string, value: any, key?:string) {
|
||||
if (key) {
|
||||
this.modifiers.push(`${name}:${value}:${typeof value}:${key}`);
|
||||
} else {
|
||||
this.modifiers.push(`${name}:${value}:${typeof value}`);
|
||||
}
|
||||
}
|
||||
|
||||
getModifiers(): Array<string> {
|
||||
return [...this.modifiers];
|
||||
}
|
||||
|
||||
getModifiersString(): string {
|
||||
if (!this.modifiers || !Array.isArray(this.modifiers)) {
|
||||
return '';
|
||||
}
|
||||
return this.modifiers.join('|');
|
||||
}
|
||||
}
|
331
lib/modules/database/reference.js
Normal file
331
lib/modules/database/reference.js
Normal file
@ -0,0 +1,331 @@
|
||||
/**
|
||||
* @flow
|
||||
*/
|
||||
import { NativeModules } from 'react-native';
|
||||
|
||||
import Query from './query.js';
|
||||
import Snapshot from './snapshot';
|
||||
import Disconnect from './disconnect';
|
||||
import { ReferenceBase } from './../base';
|
||||
import { promisify, isFunction, isObject, tryJSONParse, tryJSONStringify, generatePushID } from './../../utils';
|
||||
|
||||
const FirebaseDatabase = NativeModules.FirebaseDatabase;
|
||||
|
||||
// https://firebase.google.com/docs/reference/js/firebase.database.Reference
|
||||
|
||||
/**
|
||||
* @class Reference
|
||||
*/
|
||||
export default class Reference extends ReferenceBase {
|
||||
|
||||
db: FirebaseDatabase;
|
||||
query: Query;
|
||||
|
||||
constructor(db: FirebaseDatabase, path: string, existingModifiers?: Array<string>) {
|
||||
super(db.firebase, path);
|
||||
this.db = db;
|
||||
this.namespace = 'firebase:db:ref';
|
||||
this.query = new Query(this, path, existingModifiers);
|
||||
this.log.debug('Created new Reference', this.db._handle(path, existingModifiers));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param bool
|
||||
* @returns {*}
|
||||
*/
|
||||
keepSynced(bool: boolean) {
|
||||
const path = this._dbPath();
|
||||
return promisify('keepSynced', FirebaseDatabase)(path, bool);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
* @returns {*}
|
||||
*/
|
||||
set(value: any) {
|
||||
const path = this._dbPath();
|
||||
const _value = this._serializeAnyType(value);
|
||||
return promisify('set', FirebaseDatabase)(path, _value);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param val
|
||||
* @returns {*}
|
||||
*/
|
||||
update(val: Object) {
|
||||
const path = this._dbPath();
|
||||
const value = this._serializeObject(val);
|
||||
return promisify('update', FirebaseDatabase)(path, value);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {*}
|
||||
*/
|
||||
remove() {
|
||||
return promisify('remove', FirebaseDatabase)(this._dbPath());
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
* @param onComplete
|
||||
* @returns {*}
|
||||
*/
|
||||
push(value: any, onComplete: Function) {
|
||||
if (value === null || value === undefined) {
|
||||
const _path = this.path + '/' + generatePushID(this.db.serverTimeOffset);
|
||||
return new Reference(this.db, _path);
|
||||
}
|
||||
|
||||
const path = this._dbPath();
|
||||
const _value = this._serializeAnyType(value);
|
||||
return promisify('push', FirebaseDatabase)(path, _value)
|
||||
.then(({ ref }) => {
|
||||
const newRef = new Reference(this.db, ref);
|
||||
if (isFunction(onComplete)) return onComplete(null, newRef);
|
||||
return newRef;
|
||||
}).catch((e) => {
|
||||
if (isFunction(onComplete)) return onComplete(e, null);
|
||||
return e;
|
||||
});
|
||||
}
|
||||
|
||||
on(eventName: string, cb: () => any, errorCb: () => any) {
|
||||
if (!isFunction(cb)) throw new Error('The specified callback must be a function');
|
||||
if (errorCb && !isFunction(errorCb)) throw new Error('The specified error callback must be a function');
|
||||
const path = this._dbPath();
|
||||
const modifiers = this.query.getModifiers();
|
||||
const modifiersString = this.query.getModifiersString();
|
||||
this.log.debug('adding reference.on', path, modifiersString, eventName);
|
||||
return this.db.on(path, modifiersString, modifiers, eventName, cb, errorCb);
|
||||
}
|
||||
|
||||
once(eventName: string = 'once', cb: (snapshot: Object) => void) {
|
||||
const path = this._dbPath();
|
||||
const modifiers = this.query.getModifiers();
|
||||
const modifiersString = this.query.getModifiersString();
|
||||
return promisify('onOnce', FirebaseDatabase)(path, modifiersString, modifiers, eventName)
|
||||
.then(({ snapshot }) => new Snapshot(this, snapshot))
|
||||
.then((snapshot) => {
|
||||
if (isFunction(cb)) cb(snapshot);
|
||||
return snapshot;
|
||||
});
|
||||
}
|
||||
|
||||
off(eventName?: string = '', origCB?: () => any) {
|
||||
const path = this._dbPath();
|
||||
const modifiersString = this.query.getModifiersString();
|
||||
this.log.debug('ref.off(): ', path, modifiersString, eventName);
|
||||
return this.db.off(path, modifiersString, eventName, origCB);
|
||||
}
|
||||
|
||||
/**
|
||||
* MODIFIERS
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Reference}
|
||||
*/
|
||||
orderByKey(): Reference {
|
||||
return this.orderBy('orderByKey');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Reference}
|
||||
*/
|
||||
orderByPriority(): Reference {
|
||||
return this.orderBy('orderByPriority');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Reference}
|
||||
*/
|
||||
orderByValue(): Reference {
|
||||
return this.orderBy('orderByValue');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param key
|
||||
* @returns {Reference}
|
||||
*/
|
||||
orderByChild(key: string): Reference {
|
||||
return this.orderBy('orderByChild', key);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param name
|
||||
* @param key
|
||||
* @returns {Reference}
|
||||
*/
|
||||
orderBy(name: string, key?: string): Reference {
|
||||
const newRef = new Reference(this.db, this.path, this.query.getModifiers());
|
||||
newRef.query.setOrderBy(name, key);
|
||||
return newRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* LIMITS
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @param limit
|
||||
* @returns {Reference}
|
||||
*/
|
||||
limitToLast(limit: number): Reference {
|
||||
return this.limit('limitToLast', limit);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param limit
|
||||
* @returns {Reference}
|
||||
*/
|
||||
limitToFirst(limit: number): Reference {
|
||||
return this.limit('limitToFirst', limit);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param name
|
||||
* @param limit
|
||||
* @returns {Reference}
|
||||
*/
|
||||
limit(name: string, limit: number): Reference {
|
||||
const newRef = new Reference(this.db, this.path, this.query.getModifiers());
|
||||
newRef.query.setLimit(name, limit);
|
||||
return newRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* FILTERS
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
* @param key
|
||||
* @returns {Reference}
|
||||
*/
|
||||
equalTo(value: any, key?: string): Reference {
|
||||
return this.filter('equalTo', value, key);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
* @param key
|
||||
* @returns {Reference}
|
||||
*/
|
||||
endAt(value: any, key?: string): Reference {
|
||||
return this.filter('endAt', value, key);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
* @param key
|
||||
* @returns {Reference}
|
||||
*/
|
||||
startAt(value: any, key?: string): Reference {
|
||||
return this.filter('startAt', value, key);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param name
|
||||
* @param value
|
||||
* @param key
|
||||
* @returns {Reference}
|
||||
*/
|
||||
filter(name: string, value: any, key?: string): Reference {
|
||||
const newRef = new Reference(this.db, this.path, this.query.getModifiers());
|
||||
newRef.query.setFilter(name, value, key);
|
||||
return newRef;
|
||||
}
|
||||
|
||||
onDisconnect() {
|
||||
return new Disconnect(this.path);
|
||||
}
|
||||
|
||||
child(path: string) {
|
||||
return new Reference(this.db, this.path + '/' + path);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this._dbPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* GETTERS
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns the parent ref of the current ref i.e. a ref of /foo/bar would return a new ref to '/foo'
|
||||
* @returns {*}
|
||||
*/
|
||||
get parent(): Reference|null {
|
||||
if (this.path === '/') return null;
|
||||
return new Reference(this.db, this.path.substring(0, this.path.lastIndexOf('/')));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a ref to the root of db - '/'
|
||||
* @returns {Reference}
|
||||
*/
|
||||
get root(): Reference {
|
||||
return new Reference(this.db, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* INTERNALS
|
||||
*/
|
||||
|
||||
_dbPath(): string {
|
||||
return this.path;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param obj
|
||||
* @returns {Object}
|
||||
* @private
|
||||
*/
|
||||
_serializeObject(obj: Object) {
|
||||
if (!isObject(obj)) return obj;
|
||||
|
||||
// json stringify then parse it calls toString on Objects / Classes
|
||||
// that support it i.e new Date() becomes a ISO string.
|
||||
return tryJSONParse(tryJSONStringify(obj));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
* @returns {*}
|
||||
* @private
|
||||
*/
|
||||
_serializeAnyType(value: any) {
|
||||
if (isObject(value)) {
|
||||
return {
|
||||
type: 'object',
|
||||
value: this._serializeObject(value),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: typeof value,
|
||||
value,
|
||||
};
|
||||
}
|
||||
}
|
86
lib/modules/database/snapshot.js
Normal file
86
lib/modules/database/snapshot.js
Normal file
@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @flow
|
||||
*/
|
||||
import Reference from './reference.js';
|
||||
import { isObject, deepGet, deepExists } from './../../utils';
|
||||
|
||||
export default class Snapshot {
|
||||
static key: String;
|
||||
static value: Object;
|
||||
static exists: boolean;
|
||||
static hasChildren: boolean;
|
||||
static childKeys: String[];
|
||||
|
||||
ref: Object;
|
||||
key: string;
|
||||
value: any;
|
||||
exists: boolean;
|
||||
priority: any;
|
||||
childKeys: Array<string>;
|
||||
|
||||
constructor(ref: Reference, snapshot: Object) {
|
||||
this.ref = ref;
|
||||
this.key = snapshot.key;
|
||||
this.value = snapshot.value;
|
||||
this.exists = snapshot.exists || true;
|
||||
this.priority = snapshot.priority === undefined ? null : snapshot.priority;
|
||||
this.childKeys = snapshot.childKeys || [];
|
||||
}
|
||||
|
||||
/*
|
||||
* DEFAULT API METHODS
|
||||
*/
|
||||
|
||||
val() {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
child(path: string) {
|
||||
const value = deepGet(this.value, path);
|
||||
const childRef = this.ref.child(path);
|
||||
return new Snapshot(childRef, {
|
||||
value,
|
||||
key: childRef.key,
|
||||
exists: value !== null,
|
||||
childKeys: isObject(value) ? Object.keys(value) : [],
|
||||
});
|
||||
}
|
||||
|
||||
exists() {
|
||||
return this.value !== null;
|
||||
}
|
||||
|
||||
forEach(fn: (key: any) => any) {
|
||||
return this.childKeys.forEach((key, i) => fn(this.child(key), i));
|
||||
}
|
||||
|
||||
getPriority() {
|
||||
return this.priority;
|
||||
}
|
||||
|
||||
hasChild(path: string) {
|
||||
return deepExists(this.value, path);
|
||||
}
|
||||
|
||||
hasChildren() {
|
||||
return this.numChildren() > 0;
|
||||
}
|
||||
|
||||
numChildren() {
|
||||
if (!isObject(this.value)) return 0;
|
||||
return Object.keys(this.value).length;
|
||||
}
|
||||
|
||||
/*
|
||||
* EXTRA API METHODS
|
||||
*/
|
||||
map(fn: (key: string) => mixed) {
|
||||
const arr = [];
|
||||
this.forEach((item, i) => arr.push(fn(item, i)));
|
||||
return arr;
|
||||
}
|
||||
|
||||
reverseMap(fn: (key: string) => mixed) {
|
||||
return this.map(fn).reverse();
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user