Merge branch 'v2.0.x-release' of github.com:bigbluebutton/bigbluebutton into merge-v20-master-nov14
This commit is contained in:
commit
d1dad75898
@ -252,11 +252,10 @@ case class UserState(
|
||||
locked: Boolean,
|
||||
presenter: Boolean,
|
||||
avatar: String,
|
||||
roleChangedOn: Long = System.currentTimeMillis(),
|
||||
lastActivityTime: Long = TimeUtil.timeNowInMs(),
|
||||
roleChangedOn: Long = System.currentTimeMillis(),
|
||||
lastActivityTime: Long = TimeUtil.timeNowInMs(),
|
||||
clientType: String,
|
||||
userLeftFlag: UserLeftFlag)
|
||||
|
||||
userLeftFlag: UserLeftFlag)
|
||||
|
||||
case class UserIdAndName(id: String, name: String)
|
||||
|
||||
|
@ -53,34 +53,34 @@ class MeetingActor(
|
||||
val eventBus: InternalEventBus,
|
||||
val outGW: OutMsgRouter,
|
||||
val liveMeeting: LiveMeeting)
|
||||
extends BaseMeetingActor
|
||||
with SystemConfiguration
|
||||
with GuestsApp
|
||||
with LayoutApp2x
|
||||
with VoiceApp2x
|
||||
with BreakoutApp2x
|
||||
with UsersApp2x
|
||||
extends BaseMeetingActor
|
||||
with SystemConfiguration
|
||||
with GuestsApp
|
||||
with LayoutApp2x
|
||||
with VoiceApp2x
|
||||
with BreakoutApp2x
|
||||
with UsersApp2x
|
||||
|
||||
with UserBroadcastCamStartMsgHdlr
|
||||
with UserJoinMeetingReqMsgHdlr
|
||||
with UserJoinMeetingAfterReconnectReqMsgHdlr
|
||||
with UserBroadcastCamStopMsgHdlr
|
||||
with UserConnectedToGlobalAudioMsgHdlr
|
||||
with UserDisconnectedFromGlobalAudioMsgHdlr
|
||||
with MuteAllExceptPresentersCmdMsgHdlr
|
||||
with MuteMeetingCmdMsgHdlr
|
||||
with IsMeetingMutedReqMsgHdlr
|
||||
with UserBroadcastCamStartMsgHdlr
|
||||
with UserJoinMeetingReqMsgHdlr
|
||||
with UserJoinMeetingAfterReconnectReqMsgHdlr
|
||||
with UserBroadcastCamStopMsgHdlr
|
||||
with UserConnectedToGlobalAudioMsgHdlr
|
||||
with UserDisconnectedFromGlobalAudioMsgHdlr
|
||||
with MuteAllExceptPresentersCmdMsgHdlr
|
||||
with MuteMeetingCmdMsgHdlr
|
||||
with IsMeetingMutedReqMsgHdlr
|
||||
|
||||
with EjectUserFromVoiceCmdMsgHdlr
|
||||
with EndMeetingSysCmdMsgHdlr
|
||||
with DestroyMeetingSysCmdMsgHdlr
|
||||
with SendTimeRemainingUpdateHdlr
|
||||
with SendBreakoutTimeRemainingMsgHdlr
|
||||
with ChangeLockSettingsInMeetingCmdMsgHdlr
|
||||
with SyncGetMeetingInfoRespMsgHdlr
|
||||
with ClientToServerLatencyTracerMsgHdlr
|
||||
with ValidateConnAuthTokenSysMsgHdlr
|
||||
with UserActivitySignCmdMsgHdlr {
|
||||
with EjectUserFromVoiceCmdMsgHdlr
|
||||
with EndMeetingSysCmdMsgHdlr
|
||||
with DestroyMeetingSysCmdMsgHdlr
|
||||
with SendTimeRemainingUpdateHdlr
|
||||
with SendBreakoutTimeRemainingMsgHdlr
|
||||
with ChangeLockSettingsInMeetingCmdMsgHdlr
|
||||
with SyncGetMeetingInfoRespMsgHdlr
|
||||
with ClientToServerLatencyTracerMsgHdlr
|
||||
with ValidateConnAuthTokenSysMsgHdlr
|
||||
with UserActivitySignCmdMsgHdlr {
|
||||
|
||||
override val supervisorStrategy = OneForOneStrategy(maxNrOfRetries = 10, withinTimeRange = 1 minute) {
|
||||
case e: Exception => {
|
||||
@ -179,6 +179,7 @@ class MeetingActor(
|
||||
|
||||
def receive = {
|
||||
//=============================
|
||||
|
||||
// 2x messages
|
||||
case msg: BbbCommonEnvCoreMsg => handleBbbCommonEnvCoreMsg(msg)
|
||||
|
||||
@ -240,6 +241,19 @@ class MeetingActor(
|
||||
}
|
||||
|
||||
private def handleBbbCommonEnvCoreMsg(msg: BbbCommonEnvCoreMsg): Unit = {
|
||||
msg.core match {
|
||||
case m: ClientToServerLatencyTracerMsg => handleMessageThatDoesNotAffectsInactivity(msg)
|
||||
case _ => handleMessageThatAffectsInactivity(msg)
|
||||
}
|
||||
}
|
||||
|
||||
private def handleMessageThatDoesNotAffectsInactivity(msg: BbbCommonEnvCoreMsg): Unit = {
|
||||
msg.core match {
|
||||
case m: ClientToServerLatencyTracerMsg => handleClientToServerLatencyTracerMsg(m)
|
||||
}
|
||||
}
|
||||
|
||||
private def handleMessageThatAffectsInactivity(msg: BbbCommonEnvCoreMsg): Unit = {
|
||||
|
||||
msg.core match {
|
||||
case m: EndMeetingSysCmdMsg => handleEndMeeting(m, state)
|
||||
@ -287,8 +301,6 @@ class MeetingActor(
|
||||
case m: SendWhiteboardAnnotationPubMsg => wbApp.handle(m, liveMeeting, msgBus)
|
||||
case m: GetWhiteboardAnnotationsReqMsg => wbApp.handle(m, liveMeeting, msgBus)
|
||||
|
||||
case m: ClientToServerLatencyTracerMsg => handleClientToServerLatencyTracerMsg(m)
|
||||
|
||||
// Poll
|
||||
case m: StartPollReqMsg =>
|
||||
pollApp.handle(m, state, liveMeeting, msgBus) // passing state but not modifying it
|
||||
@ -592,7 +604,10 @@ class MeetingActor(
|
||||
updateParentMeetingWithUsers()
|
||||
}
|
||||
|
||||
if (Users2x.numUsers(liveMeeting.users2x) == 0) {
|
||||
if (state.expiryTracker.userHasJoined &&
|
||||
Users2x.numUsers(liveMeeting.users2x) == 0
|
||||
&& !state.expiryTracker.lastUserLeftOnInMs.isDefined) {
|
||||
log.info("Setting meeting no more users. meetingId=" + props.meetingProp.intId)
|
||||
val tracker = state.expiryTracker.setLastUserLeftOn(TimeUtil.timeNowInMs())
|
||||
state.update(tracker)
|
||||
} else {
|
||||
|
@ -69,8 +69,7 @@ trait FakeTestData {
|
||||
UserState(intId = regUser.id, extId = regUser.externId, name = regUser.name, role = regUser.role,
|
||||
guest = regUser.guest, authed = regUser.authed, guestStatus = regUser.guestStatus,
|
||||
emoji = "none", locked = false, presenter = false, avatar = regUser.avatarURL, clientType = "unknown",
|
||||
userLeftFlag = UserLeftFlag(false, 0)
|
||||
)
|
||||
userLeftFlag = UserLeftFlag(false, 0))
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
1
bbb-webhooks/.dockerignore
Normal file
1
bbb-webhooks/.dockerignore
Normal file
@ -0,0 +1 @@
|
||||
node_modules/
|
@ -21,7 +21,10 @@ if (!config.hooks.channels) {
|
||||
config.hooks.channels = {
|
||||
mainChannel: 'from-akka-apps-redis-channel',
|
||||
rapChannel: 'bigbluebutton:from-rap',
|
||||
chatChannel: 'from-akka-apps-chat-redis-channel'
|
||||
chatChannel: 'from-akka-apps-chat-redis-channel',
|
||||
compMeetingChannel: 'bigbluebutton:from-bbb-apps:meeting',
|
||||
compUserChannel: 'bigbluebutton:from-bbb-apps:users',
|
||||
compChatChannel: 'bigbluebutton:from-bbb-apps:chat'
|
||||
}
|
||||
}
|
||||
// IP where permanent hook will post data (more than 1 URL means more than 1 permanent hook)
|
||||
|
@ -11,6 +11,9 @@ module.exports = class MessageMapping {
|
||||
this.userEvents = ["UserJoinedMeetingEvtMsg","UserLeftMeetingEvtMsg","UserJoinedVoiceConfToClientEvtMsg","UserLeftVoiceConfToClientEvtMsg","PresenterAssignedEvtMsg", "PresenterUnassignedEvtMsg", "UserBroadcastCamStartedEvtMsg", "UserBroadcastCamStoppedEvtMsg", "UserEmojiChangedEvtMsg"];
|
||||
this.chatEvents = ["SendPublicMessageEvtMsg","SendPrivateMessageEvtMsg"];
|
||||
this.rapEvents = ["archive_started","archive_ended","sanity_started","sanity_ended","post_archive_started","post_archive_ended","process_started","process_ended","post_process_started","post_process_ended","publish_started","publish_ended","post_publish_started","post_publish_ended"];
|
||||
|
||||
this.compMeetingEvents = ["meeting_created_message","meeting_destroyed_event"];
|
||||
this.compUserEvents = ["user_joined_message","user_left_message","user_listening_only","user_joined_voice_message","user_left_voice_message","user_shared_webcam_message","user_unshared_webcam_message","user_status_changed_message"];
|
||||
}
|
||||
|
||||
// Map internal message based on it's type
|
||||
@ -23,6 +26,10 @@ module.exports = class MessageMapping {
|
||||
this.chatTemplate(messageObj);
|
||||
} else if (this.mappedEvent(messageObj,this.rapEvents)) {
|
||||
this.rapTemplate(messageObj);
|
||||
} else if (this.mappedEvent(messageObj,this.compMeetingEvents)) {
|
||||
this.compMeetingTemplate(messageObj);
|
||||
} else if (this.mappedEvent(messageObj,this.compUserEvents)) {
|
||||
this.compUserTemplate(messageObj);
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,6 +86,46 @@ module.exports = class MessageMapping {
|
||||
Logger.info("[MessageMapping] Mapped message:", this.mappedMessage);
|
||||
}
|
||||
|
||||
compMeetingTemplate(messageObj) {
|
||||
const props = messageObj.payload;
|
||||
const meetingId = props.meeting_id;
|
||||
this.mappedObject.data = {
|
||||
"type": "event",
|
||||
"id": this.mapInternalMessage(messageObj),
|
||||
"attributes":{
|
||||
"meeting":{
|
||||
"internal-meeting-id": meetingId,
|
||||
"external-meeting-id": IDMapping.getExternalMeetingID(meetingId)
|
||||
}
|
||||
},
|
||||
"event":{
|
||||
"ts": Date.now()
|
||||
}
|
||||
};
|
||||
if (messageObj.header.name === "meeting_created_message") {
|
||||
this.mappedObject.data.attributes = {
|
||||
"meeting":{
|
||||
"internal-meeting-id": meetingId,
|
||||
"external-meeting-id": props.external_meeting_id,
|
||||
"name": props.name,
|
||||
"is-breakout": props.is_breakout,
|
||||
"duration": props.duration,
|
||||
"create-time": props.create_time,
|
||||
"create-date": props.create_date,
|
||||
"moderator-pass": props.moderator_pass,
|
||||
"viewer-pass": props.viewer_pass,
|
||||
"record": props.recorded,
|
||||
"voice-conf": props.voice_conf,
|
||||
"dial-number": props.dial_number,
|
||||
"max-users": props.max_users,
|
||||
"metadata": props.metadata
|
||||
}
|
||||
};
|
||||
}
|
||||
this.mappedMessage = JSON.stringify(this.mappedObject);
|
||||
Logger.info("[MessageMapping] Mapped message:", this.mappedMessage);
|
||||
}
|
||||
|
||||
// Map internal to external message for user information
|
||||
userTemplate(messageObj) {
|
||||
const msgBody = messageObj.core.body;
|
||||
@ -111,6 +158,68 @@ module.exports = class MessageMapping {
|
||||
Logger.info("[MessageMapping] Mapped message:", this.mappedMessage);
|
||||
}
|
||||
|
||||
// Map internal to external message for user information
|
||||
compUserTemplate(messageObj) {
|
||||
const msgBody = messageObj.payload;
|
||||
const msgHeader = messageObj.header;
|
||||
|
||||
let user;
|
||||
if (msgHeader.name === "user_joined_message") {
|
||||
user = {
|
||||
"internal-user-id": msgBody.user.userid,
|
||||
"external-user-id": msgBody.user.extern_userid,
|
||||
"sharing-mic": msgBody.user.voiceUser.joined,
|
||||
"name": msgBody.user.name,
|
||||
"role": msgBody.user.role,
|
||||
"presenter": msgBody.user.presenter,
|
||||
"stream": msgBody.user.webcam_stream,
|
||||
"listening-only": msgBody.user.listenOnly
|
||||
}
|
||||
}
|
||||
else {
|
||||
user = UserMapping.getUser(msgBody.userid) || { "internal-user-id": msgBody.userid || msgBody.user.userid };
|
||||
if (msgHeader.name === "user_status_changed_message") {
|
||||
if (msgBody.status === "presenter") {
|
||||
user["presenter"] = msgBody.value;
|
||||
}
|
||||
}
|
||||
else if (msgHeader.name === "user_listening_only") {
|
||||
user["listening-only"] = msgBody.listen_only;
|
||||
}
|
||||
else if (msgHeader.name === "user_joined_voice_message" || msgHeader.name === "user_left_voice_message") {
|
||||
user["sharing-mic"] = msgBody.user.voiceUser.joined;
|
||||
}
|
||||
else if (msgHeader.name === "user_shared_webcam_message") {
|
||||
user["stream"].push(msgBody.stream);
|
||||
}
|
||||
else if (msgHeader.name === "user_unshared_webcam_message") {
|
||||
let streams = user["stream"];
|
||||
let index = streams.indexOf(msgBody.stream);
|
||||
if (index != -1) {
|
||||
streams.splice(index,1);
|
||||
}
|
||||
user["stream"] = streams;
|
||||
}
|
||||
}
|
||||
|
||||
this.mappedObject.data = {
|
||||
"type": "event",
|
||||
"id": this.mapInternalMessage(messageObj),
|
||||
"attributes":{
|
||||
"meeting":{
|
||||
"internal-meeting-id": msgBody.meeting_id,
|
||||
"external-meeting-id": IDMapping.getExternalMeetingID(msgBody.meeting_id)
|
||||
},
|
||||
"user": user
|
||||
},
|
||||
"event":{
|
||||
"ts": Date.now()
|
||||
}
|
||||
};
|
||||
this.mappedMessage = JSON.stringify(this.mappedObject);
|
||||
Logger.info("[MessageMapping] Mapped message:", this.mappedMessage);
|
||||
}
|
||||
|
||||
// Map internal to external message for chat information
|
||||
chatTemplate(messageObj) {
|
||||
const message = messageObj.core.body.message;
|
||||
@ -150,12 +259,13 @@ module.exports = class MessageMapping {
|
||||
const data = messageObj.payload;
|
||||
this.mappedObject.data = {
|
||||
"type": "event",
|
||||
"id": this.mapInternalMessage(messageObj.header.name),
|
||||
"id": this.mapInternalMessage(messageObj),
|
||||
"attributes": {
|
||||
"meeting": {
|
||||
"internal-meeting-id": data.meeting_id,
|
||||
"external-meeting-id": data.external_meeting_id
|
||||
"external-meeting-id": data.external_meeting_id || IDMapping.getExternalMeetingID(data.meeting_id)
|
||||
},
|
||||
"record-id": data.record_id,
|
||||
"success": data.success,
|
||||
"step-time": data.step_time
|
||||
},
|
||||
@ -164,14 +274,18 @@ module.exports = class MessageMapping {
|
||||
}
|
||||
};
|
||||
|
||||
if (this.mappedObject.data["id"] == "rap-publish-ended") {
|
||||
this.mappedObject.data["attributes"]["recording"] = {
|
||||
if (data.workflow) {
|
||||
this.mappedObject.data.attributes.workflow = data.workflow;
|
||||
}
|
||||
|
||||
if (this.mappedObject.data.id === "rap-publish-ended") {
|
||||
this.mappedObject.data.attributes.recording = {
|
||||
"name": data.metadata.meetingName,
|
||||
"isBreakout": data.metadata.isBreakout,
|
||||
"startTime": data.startTime,
|
||||
"endTime": data.endTime,
|
||||
"is-breakout": data.metadata.isBreakout,
|
||||
"start-time": data.startTime,
|
||||
"end-time": data.endTime,
|
||||
"size": data.playback.size,
|
||||
"rawSize": data.rawSize,
|
||||
"raw-size": data.rawSize,
|
||||
"metadata": data.metadata,
|
||||
"playback": data.playback,
|
||||
"download": data.download
|
||||
@ -183,13 +297,14 @@ module.exports = class MessageMapping {
|
||||
|
||||
|
||||
mapInternalMessage(message) {
|
||||
let name;
|
||||
if (message.envelope) {
|
||||
message = message.envelope.name
|
||||
name = message.envelope.name
|
||||
}
|
||||
else if (message.header) {
|
||||
message = message.header.name
|
||||
name = message.header.name
|
||||
}
|
||||
const mappedMsg = (() => { switch (message) {
|
||||
const mappedMsg = (() => { switch (name) {
|
||||
case "MeetingCreatedEvtMsg": return "meeting-created";
|
||||
case "MeetingDestroyedEvtMsg": return "meeting-ended";
|
||||
case "RecordingStatusChangedEvtMsg": return "meeting-recording-changed";
|
||||
@ -221,6 +336,19 @@ module.exports = class MessageMapping {
|
||||
case "publish_ended": return "rap-publish-ended";
|
||||
case "post_publish_started": return "rap-post-publish-started";
|
||||
case "post_publish_ended": return "rap-post-publish-ended";
|
||||
case "meeting_created_message": return "meeting-created";
|
||||
case "meeting_destroyed_event": return "meeting-ended";
|
||||
case "user_joined_message": return "user-joined";
|
||||
case "user_left_message": return "user-left";
|
||||
case "user_listening_only": return (message.payload.listen_only ? "user-audio-listen-only-enabled" : "user-audio-listen-only-disabled");
|
||||
case "user_joined_voice_message": return "user-audio-voice-enabled";
|
||||
case "user_left_voice_message": return "user-audio-voice-disabled";
|
||||
case "user_shared_webcam_message": return "user-cam-broadcast-start";
|
||||
case "video_stream_unpublished": return "user-cam-broadcast-end";
|
||||
case "user_status_changed_message":
|
||||
if (message.payload.status === "presenter") {
|
||||
return (message.payload.value === "true" ? "user-presenter-assigned" : "user-presenter-unassigned" );
|
||||
}
|
||||
} })();
|
||||
return mappedMsg;
|
||||
}
|
||||
|
@ -32,16 +32,18 @@ module.exports = class UserMapping {
|
||||
this.externalUserID = null;
|
||||
this.internalUserID = null;
|
||||
this.meetingId = null;
|
||||
this.user = null;
|
||||
this.redisClient = config.redis.client;
|
||||
}
|
||||
|
||||
save(callback) {
|
||||
db[this.internalUserID] = this;
|
||||
|
||||
this.redisClient.hmset(config.redis.keys.userMap(this.id), this.toRedis(), (error, reply) => {
|
||||
if (error != null) { Logger.error("[UserMapping] error saving mapping to redis:", error, reply); }
|
||||
this.redisClient.sadd(config.redis.keys.userMaps, this.id, (error, reply) => {
|
||||
if (error != null) { Logger.error("[UserMapping] error saving mapping ID to the list of mappings:", error, reply); }
|
||||
|
||||
db[this.internalUserID] = this;
|
||||
(typeof callback === 'function' ? callback(error, db[this.internalUserID]) : undefined);
|
||||
});
|
||||
});
|
||||
@ -68,7 +70,8 @@ module.exports = class UserMapping {
|
||||
"id": this.id,
|
||||
"internalUserID": this.internalUserID,
|
||||
"externalUserID": this.externalUserID,
|
||||
"meetingId": this.meetingId
|
||||
"meetingId": this.meetingId,
|
||||
"user": this.user
|
||||
};
|
||||
return r;
|
||||
}
|
||||
@ -78,18 +81,20 @@ module.exports = class UserMapping {
|
||||
this.externalUserID = redisData.externalUserID;
|
||||
this.internalUserID = redisData.internalUserID;
|
||||
this.meetingId = redisData.meetingId;
|
||||
this.user = redisData.user;
|
||||
}
|
||||
|
||||
print() {
|
||||
return JSON.stringify(this.toRedis());
|
||||
}
|
||||
|
||||
static addMapping(internalUserID, externalUserID, meetingId, callback) {
|
||||
static addOrUpdateMapping(internalUserID, externalUserID, meetingId, user, callback) {
|
||||
let mapping = new UserMapping();
|
||||
mapping.id = nextID++;
|
||||
mapping.internalUserID = internalUserID;
|
||||
mapping.externalUserID = externalUserID;
|
||||
mapping.meetingId = meetingId;
|
||||
mapping.user = user;
|
||||
mapping.save(function(error, result) {
|
||||
Logger.info(`[UserMapping] added user mapping to the list ${internalUserID}:`, mapping.print());
|
||||
(typeof callback === 'function' ? callback(error, result) : undefined);
|
||||
@ -131,6 +136,12 @@ module.exports = class UserMapping {
|
||||
})();
|
||||
}
|
||||
|
||||
static getUser(internalUserID) {
|
||||
if (db[internalUserID]){
|
||||
return db[internalUserID].user;
|
||||
}
|
||||
}
|
||||
|
||||
static getExternalUserID(internalUserID) {
|
||||
if (db[internalUserID]){
|
||||
return db[internalUserID].externalUserID;
|
||||
|
@ -54,7 +54,7 @@ module.exports = class WebHooks {
|
||||
});
|
||||
break;
|
||||
case "user-joined":
|
||||
UserMapping.addMapping(message.data.attributes.user["internal-user-id"],message.data.attributes.user["external-user-id"], intId, () => {
|
||||
UserMapping.addOrUpdateMapping(message.data.attributes.user["internal-user-id"],message.data.attributes.user["external-user-id"], intId, message.data.attributes.user, () => {
|
||||
processMessage();
|
||||
});
|
||||
break;
|
||||
|
@ -12,6 +12,7 @@ const CALL_TRANSFER_TIMEOUT = MEDIA.callTransferTimeout;
|
||||
const CALL_HANGUP_TIMEOUT = MEDIA.callHangupTimeout;
|
||||
const CALL_HANGUP_MAX_RETRIES = MEDIA.callHangupMaximumRetries;
|
||||
const CONNECTION_TERMINATED_EVENTS = ['iceConnectionFailed', 'iceConnectionClosed'];
|
||||
const CALL_CONNECT_NOTIFICATION_TIMEOUT = 500;
|
||||
|
||||
export default class SIPBridge extends BaseAudioBridge {
|
||||
constructor(userData) {
|
||||
@ -275,9 +276,15 @@ export default class SIPBridge extends BaseAudioBridge {
|
||||
const connectionCompletedEvents = ['iceConnectionCompleted', 'iceConnectionConnected'];
|
||||
const handleConnectionCompleted = () => {
|
||||
connectionCompletedEvents.forEach(e => mediaHandler.off(e, handleConnectionCompleted));
|
||||
this.callback({ status: this.baseCallStates.started });
|
||||
this.connectionCompleted = true;
|
||||
resolve();
|
||||
// We have to delay notifying that the call is connected because it is sometimes not
|
||||
// actually ready and if the user says "Yes they can hear themselves" too quickly the
|
||||
// B-leg transfer will fail
|
||||
const that = this;
|
||||
setTimeout(() => {
|
||||
that.callback({ status: that.baseCallStates.started });
|
||||
that.connectionCompleted = true;
|
||||
resolve();
|
||||
}, CALL_CONNECT_NOTIFICATION_TIMEOUT);
|
||||
};
|
||||
connectionCompletedEvents.forEach(e => mediaHandler.on(e, handleConnectionCompleted));
|
||||
|
||||
|
@ -70,7 +70,7 @@
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user