Refactor more Vue components
Spin out components for: * MessageBox: draw a chat message in the chat history panel as well as reused in the Report Modal. * WhoListRow: provides a consistent UX for the Who List and Watching tab. On the Watching tab, the video button is replaced with the boot from video. Other changes: * Move VideoFlag into its own separate ES module. * Emoji available reactions are moved into MessageBox. * On WhoListRow: usernames are clickable to also open their profile page. * On WhoListRow: the Watching tab is now sortable and follows the user's sort selection like the Online tab does.
This commit is contained in:
parent
8906e89a51
commit
d8cb1c7c11
410
src/App.vue
410
src/App.vue
|
@ -4,8 +4,11 @@ import TheWelcome from './components/TheWelcome.vue'
|
|||
import LoginModal from './components/LoginModal.vue';
|
||||
import ExplicitOpenModal from './components/ExplicitOpenModal.vue';
|
||||
import ReportModal from './components/ReportModal.vue';
|
||||
import MessageBox from './components/MessageBox.vue';
|
||||
import WhoListRow from './components/WhoListRow.vue';
|
||||
|
||||
import LocalStorage from './lib/LocalStorage';
|
||||
import VideoFlag from './lib/VideoFlag';
|
||||
import { SoundEffects, DefaultSounds } from './lib/sounds';
|
||||
import { isAppleWebkit } from './lib/browsers';
|
||||
|
||||
|
@ -33,6 +36,8 @@ export default {
|
|||
LoginModal,
|
||||
ExplicitOpenModal,
|
||||
ReportModal,
|
||||
MessageBox,
|
||||
WhoListRow,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -77,14 +82,6 @@ export default {
|
|||
audioContext: null,
|
||||
audioTracks: {},
|
||||
},
|
||||
reactions: [
|
||||
['❤️', '👍', '😂', '😉', '😢', '😡', '🥰'],
|
||||
['😘', '👎', '☹️', '😭', '🤔', '🙄', '🤩'],
|
||||
['👋', '🔥', '😈', '🍑', '🍆', '💦', '🍌'],
|
||||
['😋', '⭐', '😇', '😴', '😱', '👀', '🎃'],
|
||||
['🤮', '🥳', '🙏', '🤦', '💩', '🤯', '💯'],
|
||||
['😏', '🙈', '🙉', '🙊', '☀️', '🌈', '🎂'],
|
||||
],
|
||||
|
||||
// Cached blocklist for the current user sent by your website.
|
||||
CachedBlocklist: CachedBlocklist,
|
||||
|
@ -175,15 +172,7 @@ export default {
|
|||
},
|
||||
|
||||
// Video flag constants (sync with values in messages.go)
|
||||
VideoFlag: {
|
||||
Active: 1 << 0,
|
||||
NSFW: 1 << 1,
|
||||
Muted: 1 << 2,
|
||||
IsTalking: 1 << 3,
|
||||
MutualRequired: 1 << 4,
|
||||
MutualOpen: 1 << 5,
|
||||
VipOnly: 1 << 6,
|
||||
},
|
||||
VideoFlag: VideoFlag,
|
||||
|
||||
// WebRTC sessions with other users.
|
||||
WebRTC: {
|
||||
|
@ -571,6 +560,63 @@ export default {
|
|||
result = result.reverse();
|
||||
}
|
||||
|
||||
// Default ordering from ChatServer = a-z
|
||||
return result;
|
||||
},
|
||||
sortedWatchingList() {
|
||||
let result = [];
|
||||
for (let username of Object.keys(this.webcam.watching)) {
|
||||
let user = this.getUser(username);
|
||||
result.push(user);
|
||||
}
|
||||
|
||||
switch (this.whoSort) {
|
||||
case "broadcasting":
|
||||
result.sort((a, b) => {
|
||||
return (b.video & this.VideoFlag.Active) - (a.video & this.VideoFlag.Active);
|
||||
});
|
||||
break;
|
||||
case "nsfw":
|
||||
result.sort((a, b) => {
|
||||
let left = (a.video & (this.VideoFlag.Active | this.VideoFlag.NSFW)),
|
||||
right = (b.video & (this.VideoFlag.Active | this.VideoFlag.NSFW));
|
||||
return right - left;
|
||||
});
|
||||
break;
|
||||
case "status":
|
||||
result.sort((a, b) => {
|
||||
if (a.status === b.status) return 0;
|
||||
return b.status < a.status ? -1 : 1;
|
||||
});
|
||||
break;
|
||||
case "op":
|
||||
result.sort((a, b) => {
|
||||
return b.op - a.op;
|
||||
});
|
||||
break;
|
||||
case "emoji":
|
||||
result.sort((a, b) => {
|
||||
if (a.emoji === b.emoji) return 0;
|
||||
return a.emoji < b.emoji ? -1 : 1;
|
||||
})
|
||||
break;
|
||||
case "login":
|
||||
result.sort((a, b) => {
|
||||
return b.loginAt - a.loginAt;
|
||||
});
|
||||
break;
|
||||
case "gender":
|
||||
result.sort((a, b) => {
|
||||
if (a.gender === b.gender) return 0;
|
||||
let left = a.gender || 'z',
|
||||
right = b.gender || 'z';
|
||||
return left < right ? -1 : 1;
|
||||
})
|
||||
break;
|
||||
case "z-a":
|
||||
result = result.reverse();
|
||||
}
|
||||
|
||||
// Default ordering from ChatServer = a-z
|
||||
return result;
|
||||
},
|
||||
|
@ -817,7 +863,6 @@ export default {
|
|||
// WhoList updates.
|
||||
onWho(msg) {
|
||||
this.whoList = msg.whoList;
|
||||
this.whoMap = {};
|
||||
|
||||
if (this.whoList == undefined) {
|
||||
this.whoList = [];
|
||||
|
@ -1460,6 +1505,16 @@ export default {
|
|||
isUsernameOnline(username) {
|
||||
return this.whoMap[username] != undefined;
|
||||
},
|
||||
getUser(username) {
|
||||
// Return the full User object from the Who List, or a dummy placeholder if not online.
|
||||
if (this.whoMap[username] != undefined) {
|
||||
return this.whoMap[username];
|
||||
}
|
||||
|
||||
return {
|
||||
username: username,
|
||||
};
|
||||
},
|
||||
avatarForUsername(username) {
|
||||
if (this.whoMap[username] != undefined && this.whoMap[username].avatar) {
|
||||
return this.avatarURL(this.whoMap[username]);
|
||||
|
@ -2050,6 +2105,9 @@ export default {
|
|||
this.closeVideo(username, "answerer");
|
||||
}
|
||||
|
||||
// Remove them from our list.
|
||||
delete(this.webcam.watching[username]);
|
||||
|
||||
this.ChatClient(
|
||||
`You have booted ${username} off your camera. They will no longer be able ` +
|
||||
`to connect to your camera, or even see that your camera is active at all -- ` +
|
||||
|
@ -3428,169 +3486,25 @@ export default {
|
|||
</div>
|
||||
|
||||
<!-- Normal chat message: full size card w/ avatar -->
|
||||
<div v-else class="box mb-2 px-4 pt-3 pb-1 position-relative">
|
||||
<div class="media mb-0">
|
||||
<div class="media-left">
|
||||
<a :href="profileURLForUsername(msg.username)"
|
||||
@click.prevent="openProfile({ username: msg.username })"
|
||||
:class="{ 'cursor-default': !profileURLForUsername(msg.username) }">
|
||||
<figure class="image is-48x48">
|
||||
<img v-if="msg.isChatServer" src="/static/img/server.png">
|
||||
<img v-else-if="msg.isChatClient" src="/static/img/client.png">
|
||||
<img v-else-if="avatarForUsername(msg.username)"
|
||||
:src="avatarForUsername(msg.username)">
|
||||
<img v-else src="/static/img/shy.png">
|
||||
</figure>
|
||||
</a>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<div class="columns is-mobile pb-0">
|
||||
<div class="column is-narrow pb-0">
|
||||
<strong :class="{
|
||||
'has-text-success is-dark': msg.isChatServer,
|
||||
'has-text-warning is-dark': msg.isAdmin,
|
||||
'has-text-danger': msg.isChatClient
|
||||
}">
|
||||
|
||||
<!-- User nickname/display name -->
|
||||
{{ nicknameForUsername(msg.username) }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="column has-text-right pb-0">
|
||||
<small class="has-text-grey is-size-7" :title="msg.at">{{ prettyDate(msg.at)
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User @username below it which may link to a profile URL if JWT -->
|
||||
<div class="columns is-mobile pt-0" v-if="(msg.isChatClient || msg.isChatServer)">
|
||||
<div class="column is-narrow pt-0">
|
||||
<small v-if="!(msg.isChatClient || msg.isChatServer)">
|
||||
<a v-if="profileURLForUsername(msg.username)"
|
||||
:href="profileURLForUsername(msg.username)" target="_blank"
|
||||
class="has-text-grey">
|
||||
@{{ msg.username }}
|
||||
</a>
|
||||
<span v-else class="has-text-grey">@{{ msg.username }}</span>
|
||||
</small>
|
||||
<small v-else class="has-text-grey">internal</small>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="columns is-mobile pt-0">
|
||||
<div class="column is-narrow pt-0">
|
||||
<small v-if="!(msg.isChatClient || msg.isChatServer)">
|
||||
<a v-if="profileURLForUsername(msg.username)"
|
||||
:href="profileURLForUsername(msg.username)" target="_blank"
|
||||
class="has-text-grey">
|
||||
@{{ msg.username }}
|
||||
</a>
|
||||
<span v-else class="has-text-grey">@{{ msg.username }}</span>
|
||||
</small>
|
||||
<small v-else class="has-text-grey">internal</small>
|
||||
</div>
|
||||
|
||||
<div class="column is-narrow pl-1 pt-0">
|
||||
<!-- DMs button -->
|
||||
<button type="button" v-if="!(msg.username === username || isDM)"
|
||||
class="button is-grey is-outlined is-small px-2"
|
||||
@click="openDMs({ username: msg.username })"
|
||||
:title="isUsernameDND(msg.username) ? 'This person is not accepting new DMs' : 'Open a Direct Message (DM) thread'"
|
||||
:disabled="isUsernameDND(msg.username)">
|
||||
<i class="fa fa-comment"></i>
|
||||
</button>
|
||||
|
||||
<!-- Mute button -->
|
||||
<button type="button" v-if="!(msg.username === username)"
|
||||
class="button is-grey is-outlined is-small px-2 ml-1"
|
||||
@click="muteUser(msg.username)" title="Mute user">
|
||||
<i class="fa fa-comment-slash" :class="{
|
||||
'has-text-success': isMutedUser(msg.username),
|
||||
'has-text-danger': !isMutedUser(msg.username)
|
||||
}"></i>
|
||||
</button>
|
||||
|
||||
<!-- Owner or admin: take back the message -->
|
||||
<button type="button" v-if="msg.username === username || isOp"
|
||||
class="button is-grey is-outlined is-small px-2 ml-1"
|
||||
title="Take back this message (delete it for everybody)"
|
||||
@click="takeback(msg)">
|
||||
<i class="fa fa-rotate-left has-text-danger"></i>
|
||||
</button>
|
||||
|
||||
<!-- Everyone else: can hide it locally -->
|
||||
<button type="button" v-if="msg.username !== username"
|
||||
class="button is-grey is-outlined is-small px-2 ml-1"
|
||||
title="Hide this message (delete it only for your view)"
|
||||
@click="removeMessage(msg)">
|
||||
<i class="fa fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Report & Emoji buttons -->
|
||||
<div v-if="msg.msgID" class="emoji-button columns is-mobile is-gapless mb-0">
|
||||
<!-- Report message button -->
|
||||
<div class="column" v-if="isWebhookEnabled('report') && msg.username !== username">
|
||||
<button class="button is-small is-outlined mr-1" :class="{
|
||||
'is-danger': !msg.reported,
|
||||
'has-text-grey': msg.reported
|
||||
}" title="Report this message"
|
||||
@click="reportMessage(msg)">
|
||||
<i class="fa fa-flag"></i>
|
||||
<i class="fa fa-check ml-1" v-if="msg.reported"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Emoji reactions menu -->
|
||||
<div class="column dropdown is-right" :class="{ 'is-up': i >= 2 }"
|
||||
onclick="this.classList.toggle('is-active')">
|
||||
<div class="dropdown-trigger">
|
||||
<button class="button is-small px-2" aria-haspopup="true"
|
||||
:aria-controls="`react-menu-${msg.msgID}`">
|
||||
<span>
|
||||
<i class="fa fa-heart has-text-grey"></i>
|
||||
<i class="fa fa-plus has-text-grey pl-1"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown-menu" :id="`react-menu-${msg.msgID}`" role="menu">
|
||||
<div class="dropdown-content p-0">
|
||||
<!-- Iterate over reactions in rows of emojis-->
|
||||
<div class="columns is-mobile ml-0 mb-2 mr-1"
|
||||
v-for="row in config.reactions">
|
||||
|
||||
<!-- Loop over the icons -->
|
||||
<div class="column p-0 is-narrow" v-for="i in row">
|
||||
<button type="button" class="button px-2 mt-1 ml-1 mr-0 mb-1"
|
||||
@click="sendReact(msg, i)">
|
||||
{{ i }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message box -->
|
||||
<div class="content pl-5 pb-3 pt-1 mb-5">
|
||||
<em v-if="msg.action === 'presence'">{{ msg.message }}</em>
|
||||
<div v-else v-html="msg.message"></div>
|
||||
|
||||
<!-- Reactions so far? -->
|
||||
<div v-if="hasReactions(msg)" class="mt-1">
|
||||
<span v-for="(users, emoji) in getReactions(msg)"
|
||||
class="tag is-secondary mr-1 cursor-pointer"
|
||||
:class="{ 'is-success is-light': iReacted(msg, emoji), 'is-secondary': !iReacted(msg, emoji) }"
|
||||
:title="emoji + ' by: ' + users.join(', ')" @click="sendReact(msg, emoji)">
|
||||
{{ emoji }} <small class="ml-1">{{ users.length }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<MessageBox
|
||||
v-else
|
||||
:message="msg"
|
||||
:user="getUser(msg.username)"
|
||||
:username="username"
|
||||
:website-url="config.website"
|
||||
:is-dnd="isUsernameDND(msg.username)"
|
||||
:is-muted="isMutedUser(msg.username)"
|
||||
:reactions="getReactions(msg)"
|
||||
:report-enabled="isWebhookEnabled('report')"
|
||||
:is-dm="isDM"
|
||||
:is-op="isOp"
|
||||
@send-dm="openDMs"
|
||||
@mute-user="muteUser"
|
||||
@takeback="takeback"
|
||||
@remove="removeMessage"
|
||||
@report="reportMessage"
|
||||
@react="sendReact"
|
||||
></MessageBox>
|
||||
|
||||
</div>
|
||||
|
||||
|
@ -3726,116 +3640,40 @@ export default {
|
|||
<!-- Who Is Online -->
|
||||
<ul class="menu-list" v-if="whoTab === 'online'">
|
||||
<li v-for="(u, i) in sortedWhoList" v-bind:key="i">
|
||||
<div class="columns is-mobile">
|
||||
<!-- Avatar URL if available -->
|
||||
<div class="column is-narrow pr-0" style="position: relative">
|
||||
<a :href="profileURLForUsername(u.username)"
|
||||
@click.prevent="openProfile({ username: u.username })"
|
||||
:class="{ 'cursor-default': !profileURLForUsername(u.username) }" class="p-0">
|
||||
<img v-if="u.avatar" :src="avatarURL(u)" width="24" height="24" alt="">
|
||||
<img v-else src="/static/img/shy.png" width="24" height="24">
|
||||
|
||||
<!-- Away symbol -->
|
||||
<div v-if="u.status !== 'online'" class="status-away-icon">
|
||||
<i v-if="u.status === 'away'" class="fa fa-clock has-text-light"
|
||||
title="Status: Away"></i>
|
||||
<i v-else-if="u.status === 'lunch'" class="fa fa-utensils has-text-light"
|
||||
title="Status: Out to lunch"></i>
|
||||
<i v-else-if="u.status === 'call'" class="fa fa-phone-volume has-text-light"
|
||||
title="Status: On the phone"></i>
|
||||
<i v-else-if="u.status === 'brb'" class="fa fa-stopwatch-20 has-text-light"
|
||||
title="Status: Be right back"></i>
|
||||
<i v-else-if="u.status === 'busy'" class="fa fa-briefcase has-text-light"
|
||||
title="Status: Working"></i>
|
||||
<i v-else-if="u.status === 'book'" class="fa fa-book has-text-light"
|
||||
title="Status: Studying"></i>
|
||||
<i v-else-if="u.status === 'gaming'"
|
||||
class="fa fa-gamepad who-status-wide-icon-2 has-text-light"
|
||||
title="Status: Gaming"></i>
|
||||
<i v-else-if="u.status === 'idle'" class="fa-regular fa-moon has-text-light"
|
||||
title="Status: Idle"></i>
|
||||
<i v-else-if="u.status === 'horny'" class="fa fa-fire has-text-light"
|
||||
title="Status: Horny"></i>
|
||||
<i v-else-if="u.status === 'chatty'" class="fa fa-comment has-text-light"
|
||||
title="Status: Chatty and sociable"></i>
|
||||
<i v-else-if="u.status === 'introverted'" class="fa fa-spoon has-text-light"
|
||||
title="Status: Introverted and quiet"></i>
|
||||
<i v-else-if="u.status === 'exhibitionist'"
|
||||
class="fa-regular fa-eye who-status-wide-icon-1 has-text-light"
|
||||
title="Status: Watch me"></i>
|
||||
<i v-else class="fa fa-clock has-text-light" :title="'Status: ' + u.status"></i>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="column pr-0 is-clipped" :class="{ 'pl-1': u.avatar }">
|
||||
<strong class="truncate-text-line is-size-7">{{ u.username }}</strong>
|
||||
<sup class="fa fa-peace has-text-warning-dark is-size-7 ml-1" v-if="u.op"
|
||||
title="Operator"></sup>
|
||||
<sup class="is-size-7 ml-1" :class="config.VIP.Icon" v-else-if="u.vip"
|
||||
:title="config.VIP.Name"></sup>
|
||||
</div>
|
||||
<div class="column is-narrow pl-0">
|
||||
<!-- Emoji icon -->
|
||||
<span v-if="u.emoji" class="pr-1 cursor-default" :title="u.emoji">
|
||||
{{ u.emoji.split(" ")[0] }}
|
||||
</span>
|
||||
|
||||
<!-- Profile button -->
|
||||
<button type="button" v-if="u.profileURL" class="button is-small px-2 py-1"
|
||||
:class="profileButtonClass(u)" @click="openProfile(u)"
|
||||
:title="'Open profile page' + (u.gender ? ` (gender: ${u.gender})` : '') + (u.vip ? ` (${config.VIP.Name})` : '')">
|
||||
<i class="fa fa-user"></i>
|
||||
</button>
|
||||
|
||||
<!-- Unmute User button (if muted) -->
|
||||
<button type="button" v-if="isMutedUser(u.username)" class="button is-small px-2 py-1"
|
||||
@click="muteUser(u.username)" title="This user is muted. Click to unmute them.">
|
||||
<i class="fa fa-comment-slash has-text-danger"></i>
|
||||
</button>
|
||||
|
||||
<!-- DM button (if not muted) -->
|
||||
<button type="button" v-else class="button is-small px-2 py-1" @click="openDMs(u)"
|
||||
:disabled="u.username === username || (u.dnd && !isOp)"
|
||||
:title="u.dnd ? 'This person is not accepting new DMs' : 'Send a Direct Message'">
|
||||
<i class="fa" :class="{ 'fa-comment': !u.dnd, 'fa-comment-slash': u.dnd }"></i>
|
||||
</button>
|
||||
|
||||
<!-- Video button -->
|
||||
<button type="button" class="button is-small px-2 py-1"
|
||||
:disabled="!(u.video & VideoFlag.Active)" :class="{
|
||||
'is-danger is-outlined': (u.video & VideoFlag.Active) && (u.video & VideoFlag.NSFW),
|
||||
'is-info is-outlined': (u.video & VideoFlag.Active) && !(u.video & VideoFlag.NSFW),
|
||||
'cursor-notallowed': isVideoNotAllowed(u),
|
||||
}" :title="`Open video stream` +
|
||||
(u.video & VideoFlag.MutualRequired ? '; mutual video sharing required' : '') +
|
||||
(u.video & VideoFlag.MutualOpen ? '; will auto-open your video' : '')"
|
||||
@click="openVideo(u)">
|
||||
<i class="fa" :class="webcamIconClass(u)"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<WhoListRow
|
||||
:user="u"
|
||||
:username="username"
|
||||
:website-url="config.website"
|
||||
:is-dnd="isUsernameDND(u.username)"
|
||||
:is-muted="isMutedUser(u.username)"
|
||||
:is-op="isOp"
|
||||
:is-video-not-allowed="isVideoNotAllowed(u)"
|
||||
:video-icon-class="webcamIconClass(u)"
|
||||
:vip-config="config.VIP"
|
||||
@send-dm="openDMs"
|
||||
@mute-user="muteUser"
|
||||
@open-video="openVideo"></WhoListRow>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Watching My Webcam -->
|
||||
<ul class="menu-list" v-if="whoTab === 'watching'">
|
||||
<li v-for="username in Object.keys(webcam.watching)" v-bind:key="username">
|
||||
<div class="columns is-mobile">
|
||||
<!-- Avatar URL if available -->
|
||||
<div class="column is-narrow pr-0">
|
||||
<i class="fa fa-eye"></i>
|
||||
</div>
|
||||
<div class="column pr-0">
|
||||
{{ username }}
|
||||
</div>
|
||||
<div class="column is-narrow pl-0">
|
||||
<!-- Boot from cam button -->
|
||||
<button type="button" class="button is-small px-2 py-1" @click="bootUser(username)"
|
||||
title="Kick this person off your cam">
|
||||
<i class="fa fa-user-xmark has-text-danger"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<li v-for="(u, i) in sortedWatchingList" v-bind:key="username">
|
||||
<WhoListRow
|
||||
:is-watching-tab="true"
|
||||
:user="u"
|
||||
:username="username"
|
||||
:website-url="config.website"
|
||||
:is-dnd="isUsernameDND(username)"
|
||||
:is-muted="isMutedUser(username)"
|
||||
:is-op="isOp"
|
||||
:is-video-not-allowed="isVideoNotAllowed(u)"
|
||||
:video-icon-class="webcamIconClass(u)"
|
||||
:vip-config="config.VIP"
|
||||
@send-dm="openDMs"
|
||||
@mute-user="muteUser"
|
||||
@open-video="openVideo"
|
||||
@boot-user="bootUser"></WhoListRow>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
|
|
@ -1,35 +1,2 @@
|
|||
@import './base.css';
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
}
|
||||
|
|
293
src/components/MessageBox.vue
Normal file
293
src/components/MessageBox.vue
Normal file
|
@ -0,0 +1,293 @@
|
|||
<script>
|
||||
export default {
|
||||
props: {
|
||||
message: Object, // chat Message object
|
||||
user: Object, // User object of the Message author
|
||||
username: String, // current username logged in
|
||||
websiteUrl: String, // Base URL to website (for profile/avatar URLs)
|
||||
isDnd: Boolean, // user is not accepting DMs
|
||||
isMuted: Boolean, // user is muted by current user
|
||||
reactions: Object, // emoji reactions on the message
|
||||
reportEnabled: Boolean, // Report Message webhook is available
|
||||
isDm: Boolean, // is in a DM thread (hide DM buttons)
|
||||
isOp: Boolean, // current user is Operator (always show takeback button)
|
||||
noButtons: Boolean, // hide all message buttons (e.g. for Report Modal)
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
reactionsAvailable: [
|
||||
['❤️', '👍', '😂', '😉', '😢', '😡', '🥰'],
|
||||
['😘', '👎', '☹️', '😭', '🤔', '🙄', '🤩'],
|
||||
['👋', '🔥', '😈', '🍑', '🍆', '💦', '🍌'],
|
||||
['😋', '⭐', '😇', '😴', '😱', '👀', '🎃'],
|
||||
['🤮', '🥳', '🙏', '🤦', '💩', '🤯', '💯'],
|
||||
['😏', '🙈', '🙉', '🙊', '☀️', '🌈', '🎂'],
|
||||
],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
profileURL() {
|
||||
if (this.user.profileURL) {
|
||||
return this.urlFor(this.user.profileURL);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
avatarURL() {
|
||||
if (this.user.avatar) {
|
||||
return this.urlFor(this.user.avatar);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
nickname() {
|
||||
if (this.user.nickname) {
|
||||
return this.user.nickname;
|
||||
}
|
||||
return this.user.username;
|
||||
},
|
||||
hasReactions() {
|
||||
return this.reactions != undefined && Object.keys(this.reactions).length > 0;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
signIn() {
|
||||
this.$emit('signIn', this.username);
|
||||
},
|
||||
|
||||
openProfile() {
|
||||
let url = this.profileURL;
|
||||
if (url) {
|
||||
window.open(url);
|
||||
}
|
||||
},
|
||||
|
||||
openDMs() {
|
||||
this.$emit('send-dm', {
|
||||
username: this.message.username,
|
||||
});
|
||||
},
|
||||
|
||||
muteUser() {
|
||||
this.$emit('mute-user', this.message.username);
|
||||
},
|
||||
|
||||
takeback() {
|
||||
this.$emit('takeback', this.message);
|
||||
},
|
||||
|
||||
removeMessage() {
|
||||
this.$emit('remove', this.message);
|
||||
},
|
||||
|
||||
reportMessage() {
|
||||
this.$emit('report', this.message);
|
||||
},
|
||||
|
||||
sendReact(emoji) {
|
||||
this.$emit('react', this.message, emoji);
|
||||
},
|
||||
|
||||
urlFor(url) {
|
||||
// Prepend the base websiteUrl if the given URL is relative.
|
||||
if (url.match(/^https?:/i)) {
|
||||
return url;
|
||||
}
|
||||
return this.websiteUrl.replace(/\/+$/, "") + url;
|
||||
},
|
||||
|
||||
// Current user has reacted to the message.
|
||||
iReacted(emoji) {
|
||||
if (!this.hasReactions) return false;
|
||||
|
||||
// test whether the current user has reacted
|
||||
if (this.reactions[emoji] != undefined) {
|
||||
for (let reactor of this.reactions[emoji]) {
|
||||
if (reactor === this.username) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// TODO: DRY
|
||||
prettyDate(date) {
|
||||
if (date == undefined) return '';
|
||||
let hours = date.getHours(),
|
||||
minutes = String(date.getMinutes()).padStart(2, '0'),
|
||||
ampm = hours >= 11 ? "pm" : "am";
|
||||
|
||||
let hour = hours % 12 || 12;
|
||||
return `${(hour)}:${minutes} ${ampm}`;
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="box mb-2 px-4 pt-3 pb-1 position-relative">
|
||||
<div class="media mb-0">
|
||||
<div class="media-left">
|
||||
<a :href="profileURL"
|
||||
@click.prevent="openProfile()"
|
||||
:class="{ 'cursor-default': !profileURL }">
|
||||
<figure class="image is-48x48">
|
||||
<img v-if="message.isChatServer" src="/static/img/server.png">
|
||||
<img v-else-if="message.isChatClient" src="/static/img/client.png">
|
||||
<img v-else-if="avatarURL"
|
||||
:src="avatarURL">
|
||||
<img v-else src="/static/img/shy.png">
|
||||
</figure>
|
||||
</a>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<div class="columns is-mobile pb-0">
|
||||
<div class="column is-narrow pb-0">
|
||||
<strong :class="{
|
||||
'has-text-success is-dark': message.isChatServer,
|
||||
'has-text-warning is-dark': message.isAdmin,
|
||||
'has-text-danger': message.isChatClient
|
||||
}">
|
||||
|
||||
<!-- User nickname/display name -->
|
||||
{{ nickname }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="column has-text-right pb-0">
|
||||
<small class="has-text-grey is-size-7" :title="message.at">{{ prettyDate(message.at) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User @username below it which may link to a profile URL if JWT -->
|
||||
<div class="columns is-mobile pt-0" v-if="(message.isChatClient || message.isChatServer)">
|
||||
<div class="column is-narrow pt-0">
|
||||
<small v-if="!(message.isChatClient || message.isChatServer)">
|
||||
<a v-if="profileURL"
|
||||
:href="profileURL" target="_blank"
|
||||
class="has-text-grey">
|
||||
@{{ message.username }}
|
||||
</a>
|
||||
<span v-else class="has-text-grey">@{{ message.username }}</span>
|
||||
</small>
|
||||
<small v-else class="has-text-grey">internal</small>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="columns is-mobile pt-0">
|
||||
<div class="column is-narrow pt-0">
|
||||
<small v-if="!(message.isChatClient || message.isChatServer)">
|
||||
<a v-if="profileURL"
|
||||
:href="profileURL" target="_blank"
|
||||
class="has-text-grey">
|
||||
@{{ message.username }}
|
||||
</a>
|
||||
<span v-else class="has-text-grey">@{{ message.username }}</span>
|
||||
</small>
|
||||
<small v-else class="has-text-grey">internal</small>
|
||||
</div>
|
||||
|
||||
<div class="column is-narrow pl-1 pt-0"
|
||||
v-if="!noButtons">
|
||||
<!-- DMs button -->
|
||||
<button type="button" v-if="!(message.username === username || isDm)"
|
||||
class="button is-grey is-outlined is-small px-2"
|
||||
@click="openDMs()"
|
||||
:title="isDnd ? 'This person is not accepting new DMs' : 'Open a Direct Message (DM) thread'"
|
||||
:disabled="isDnd">
|
||||
<i class="fa fa-comment"></i>
|
||||
</button>
|
||||
|
||||
<!-- Mute button -->
|
||||
<button type="button" v-if="!(message.username === username)"
|
||||
class="button is-grey is-outlined is-small px-2 ml-1"
|
||||
@click="muteUser()" title="Mute user">
|
||||
<i class="fa fa-comment-slash" :class="{
|
||||
'has-text-success': isMuted,
|
||||
'has-text-danger': !isMuted
|
||||
}"></i>
|
||||
</button>
|
||||
|
||||
<!-- Owner or admin: take back the message -->
|
||||
<button type="button" v-if="message.username === username || isOp"
|
||||
class="button is-grey is-outlined is-small px-2 ml-1"
|
||||
title="Take back this message (delete it for everybody)"
|
||||
@click="takeback()">
|
||||
<i class="fa fa-rotate-left has-text-danger"></i>
|
||||
</button>
|
||||
|
||||
<!-- Everyone else: can hide it locally -->
|
||||
<button type="button" v-if="message.username !== username"
|
||||
class="button is-grey is-outlined is-small px-2 ml-1"
|
||||
title="Hide this message (delete it only for your view)"
|
||||
@click="removeMessage()">
|
||||
<i class="fa fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Report & Emoji buttons -->
|
||||
<div v-if="message.msgID && !noButtons" class="emoji-button columns is-mobile is-gapless mb-0">
|
||||
<!-- Report message button -->
|
||||
<div class="column" v-if="reportEnabled && message.username !== username">
|
||||
<button class="button is-small is-outlined mr-1" :class="{
|
||||
'is-danger': !message.reported,
|
||||
'has-text-grey': message.reported
|
||||
}" title="Report this message"
|
||||
@click="reportMessage()">
|
||||
<i class="fa fa-flag"></i>
|
||||
<i class="fa fa-check ml-1" v-if="message.reported"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Emoji reactions menu -->
|
||||
<div class="column dropdown is-right" :class="{ 'is-up': i >= 2 }"
|
||||
onclick="this.classList.toggle('is-active')">
|
||||
<div class="dropdown-trigger">
|
||||
<button class="button is-small px-2" aria-haspopup="true"
|
||||
:aria-controls="`react-menu-${message.msgID}`">
|
||||
<span>
|
||||
<i class="fa fa-heart has-text-grey"></i>
|
||||
<i class="fa fa-plus has-text-grey pl-1"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown-menu" :id="`react-menu-${message.msgID}`" role="menu">
|
||||
<div class="dropdown-content p-0">
|
||||
<!-- Iterate over reactions in rows of emojis-->
|
||||
<div class="columns is-mobile ml-0 mb-2 mr-1"
|
||||
v-for="row in reactionsAvailable">
|
||||
|
||||
<!-- Loop over the icons -->
|
||||
<div class="column p-0 is-narrow" v-for="i in row">
|
||||
<button type="button" class="button px-2 mt-1 ml-1 mr-0 mb-1"
|
||||
@click="sendReact(i)">
|
||||
{{ i }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message box -->
|
||||
<div class="content pl-5 pb-3 pt-1 mb-5">
|
||||
<em v-if="message.action === 'presence'">{{ message.message }}</em>
|
||||
<div v-else v-html="message.message"></div>
|
||||
|
||||
<!-- Reactions so far? -->
|
||||
<div v-if="hasReactions" class="mt-1">
|
||||
<span v-for="(users, emoji) in reactions"
|
||||
class="tag is-secondary mr-1 cursor-pointer"
|
||||
:class="{ 'is-success is-light': iReacted(msg, emoji), 'is-secondary': !iReacted(msg, emoji) }"
|
||||
:title="emoji + ' by: ' + users.join(', ')" @click="sendReact(msg, emoji)">
|
||||
{{ emoji }} <small class="ml-1">{{ users.length }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
|
@ -1,4 +1,6 @@
|
|||
<script>
|
||||
import MessageBox from './MessageBox.vue';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
visible: Boolean,
|
||||
|
@ -6,6 +8,9 @@ export default {
|
|||
user: Object,
|
||||
message: Object,
|
||||
},
|
||||
components: {
|
||||
MessageBox,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// Configuration
|
||||
|
@ -24,14 +29,21 @@ export default {
|
|||
};
|
||||
},
|
||||
methods: {
|
||||
reset() {
|
||||
this.classification = this.reportClassifications[0];
|
||||
this.comment = "";
|
||||
},
|
||||
|
||||
accept() {
|
||||
this.$emit('accept', {
|
||||
classification: this.classification,
|
||||
comment: this.comment,
|
||||
});
|
||||
this.reset();
|
||||
},
|
||||
cancel() {
|
||||
this.$emit('cancel');
|
||||
this.reset();
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -48,38 +60,12 @@ export default {
|
|||
</header>
|
||||
<div class="card-content">
|
||||
|
||||
<!-- Message preview we are reporting on
|
||||
TODO: make it DRY: style copied/referenced from chat history cards -->
|
||||
<div class="box mb-2 px-4 pt-3 pb-1 position-relative">
|
||||
<div class="media mb-0">
|
||||
<div class="media-left">
|
||||
<figure class="image is-48x48">
|
||||
<img v-if="user?.avatar"
|
||||
:src="user?.avatar">
|
||||
<img v-else src="/static/img/shy.png">
|
||||
</figure>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<div>
|
||||
<strong>
|
||||
<!-- User nickname/display name -->
|
||||
{{ user?.nickname }}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<!-- User @username below it which may link to a profile URL if JWT -->
|
||||
<div>
|
||||
<small class="has-text-grey">
|
||||
@{{ message.username }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message copy -->
|
||||
<div class="content pl-5 py-3 mb-5 report-modal-message" v-html="message.message">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Message preview we are reporting on -->
|
||||
<MessageBox
|
||||
:message="message"
|
||||
:user="user"
|
||||
:no-buttons="true"
|
||||
></MessageBox>
|
||||
|
||||
<div class="field mb-1">
|
||||
<label class="label" for="classification">Report classification:</label>
|
||||
|
|
204
src/components/WhoListRow.vue
Normal file
204
src/components/WhoListRow.vue
Normal file
|
@ -0,0 +1,204 @@
|
|||
<script>
|
||||
import VideoFlag from '../lib/VideoFlag';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
user: Object, // User object of the Message author
|
||||
username: String, // current username logged in
|
||||
websiteUrl: String, // Base URL to website (for profile/avatar URLs)
|
||||
isDnd: Boolean, // user is not accepting DMs
|
||||
isMuted: Boolean, // user is muted by current user
|
||||
vipConfig: Object, // VIP config settings for BareRTC
|
||||
isOp: Boolean, // current user is operator (can always DM)
|
||||
isVideoNotAllowed: Boolean, // whether opening this camera is not allowed
|
||||
videoIconClass: String, // CSS class for the open video icon
|
||||
isWatchingTab: Boolean, // is the "Watching" tab (replace video button w/ boot)
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
VideoFlag: VideoFlag,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
profileURL() {
|
||||
if (this.user.profileURL) {
|
||||
return this.urlFor(this.user.profileURL);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
profileButtonClass() {
|
||||
// VIP background.
|
||||
let result = "";
|
||||
if (this.user.vip) {
|
||||
result = "has-background-vip ";
|
||||
}
|
||||
|
||||
let gender = (this.user.gender || "").toLowerCase();
|
||||
if (gender.indexOf("m") === 0) {
|
||||
return result + "has-text-gender-male";
|
||||
} else if (gender.indexOf("f") === 0) {
|
||||
return result + "has-text-gender-female";
|
||||
} else if (gender.length > 0) {
|
||||
return result + "has-text-gender-other";
|
||||
}
|
||||
return "";
|
||||
},
|
||||
avatarURL() {
|
||||
if (this.user.avatar) {
|
||||
return this.urlFor(this.user.avatar);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
nickname() {
|
||||
if (this.user.nickname) {
|
||||
return this.user.nickname;
|
||||
}
|
||||
return this.user.username;
|
||||
},
|
||||
hasReactions() {
|
||||
return this.reactions != undefined && Object.keys(this.reactions).length > 0;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
openProfile() {
|
||||
let url = this.profileURL;
|
||||
if (url) {
|
||||
window.open(url);
|
||||
}
|
||||
},
|
||||
|
||||
openDMs() {
|
||||
this.$emit('send-dm', {
|
||||
username: this.user.username,
|
||||
});
|
||||
},
|
||||
|
||||
openVideo() {
|
||||
this.$emit('open-video', this.user);
|
||||
},
|
||||
|
||||
muteUser() {
|
||||
this.$emit('mute-user', this.user.username);
|
||||
},
|
||||
|
||||
// Boot user off your cam (for isWatchingTab)
|
||||
bootUser() {
|
||||
this.$emit('boot-user', this.user.username);
|
||||
},
|
||||
|
||||
urlFor(url) {
|
||||
// Prepend the base websiteUrl if the given URL is relative.
|
||||
if (url.match(/^https?:/i)) {
|
||||
return url;
|
||||
}
|
||||
return this.websiteUrl.replace(/\/+$/, "") + url;
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="columns is-mobile">
|
||||
<!-- Avatar URL if available -->
|
||||
<div class="column is-narrow pr-0" style="position: relative">
|
||||
<a :href="profileURL"
|
||||
@click.prevent="openProfile()"
|
||||
:class="{ 'cursor-default': !profileURL }" class="p-0">
|
||||
<img v-if="avatarURL" :src="avatarURL" width="24" height="24" alt="">
|
||||
<img v-else src="/static/img/shy.png" width="24" height="24">
|
||||
|
||||
<!-- Away symbol -->
|
||||
<div v-if="user.status !== 'online'" class="status-away-icon">
|
||||
<i v-if="user.status === 'away'" class="fa fa-clock has-text-light"
|
||||
title="Status: Away"></i>
|
||||
<i v-else-if="user.status === 'lunch'" class="fa fa-utensils has-text-light"
|
||||
title="Status: Out to lunch"></i>
|
||||
<i v-else-if="user.status === 'call'" class="fa fa-phone-volume has-text-light"
|
||||
title="Status: On the phone"></i>
|
||||
<i v-else-if="user.status === 'brb'" class="fa fa-stopwatch-20 has-text-light"
|
||||
title="Status: Be right back"></i>
|
||||
<i v-else-if="user.status === 'busy'" class="fa fa-briefcase has-text-light"
|
||||
title="Status: Working"></i>
|
||||
<i v-else-if="user.status === 'book'" class="fa fa-book has-text-light"
|
||||
title="Status: Studying"></i>
|
||||
<i v-else-if="user.status === 'gaming'"
|
||||
class="fa fa-gamepad who-status-wide-icon-2 has-text-light"
|
||||
title="Status: Gaming"></i>
|
||||
<i v-else-if="user.status === 'idle'" class="fa-regular fa-moon has-text-light"
|
||||
title="Status: Idle"></i>
|
||||
<i v-else-if="user.status === 'horny'" class="fa fa-fire has-text-light"
|
||||
title="Status: Horny"></i>
|
||||
<i v-else-if="user.status === 'chatty'" class="fa fa-comment has-text-light"
|
||||
title="Status: Chatty and sociable"></i>
|
||||
<i v-else-if="user.status === 'introverted'" class="fa fa-spoon has-text-light"
|
||||
title="Status: Introverted and quiet"></i>
|
||||
<i v-else-if="user.status === 'exhibitionist'"
|
||||
class="fa-regular fa-eye who-status-wide-icon-1 has-text-light"
|
||||
title="Status: Watch me"></i>
|
||||
<i v-else class="fa fa-clock has-text-light" :title="'Status: ' + user.status"></i>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="column pr-0 is-clipped" :class="{ 'pl-1': avatarURL }">
|
||||
<strong class="truncate-text-line is-size-7"
|
||||
@click="openProfile()"
|
||||
:class="{'cursor-pointer': profileURL}">
|
||||
{{ user.username }}
|
||||
</strong>
|
||||
<sup class="fa fa-peace has-text-warning-dark is-size-7 ml-1" v-if="user.op"
|
||||
title="Operator"></sup>
|
||||
<sup class="is-size-7 ml-1" :class="vipConfig.Icon" v-else-if="user.vip"
|
||||
:title="vipConfig.Name"></sup>
|
||||
</div>
|
||||
<div class="column is-narrow pl-0">
|
||||
<!-- Emoji icon -->
|
||||
<span v-if="user.emoji" class="pr-1 cursor-default" :title="user.emoji">
|
||||
{{ user.emoji.split(" ")[0] }}
|
||||
</span>
|
||||
|
||||
<!-- Profile button -->
|
||||
<button type="button" v-if="profileURL" class="button is-small px-2 py-1"
|
||||
:class="profileButtonClass" @click="openProfile()"
|
||||
:title="'Open profile page' + (user.gender ? ` (gender: ${user.gender})` : '') + (user.vip ? ` (${vipConfig.Name})` : '')">
|
||||
<i class="fa fa-user"></i>
|
||||
</button>
|
||||
|
||||
<!-- Unmute User button (if muted) -->
|
||||
<button type="button" v-if="isMuted" class="button is-small px-2 py-1"
|
||||
@click="muteUser()" title="This user is muted. Click to unmute them.">
|
||||
<i class="fa fa-comment-slash has-text-danger"></i>
|
||||
</button>
|
||||
|
||||
<!-- DM button (if not muted) -->
|
||||
<button type="button" v-else class="button is-small px-2 py-1" @click="openDMs(u)"
|
||||
:disabled="user.username === username || (user.dnd && !isOp)"
|
||||
:title="user.dnd ? 'This person is not accepting new DMs' : 'Send a Direct Message'">
|
||||
<i class="fa" :class="{ 'fa-comment': !user.dnd, 'fa-comment-slash': user.dnd }"></i>
|
||||
</button>
|
||||
|
||||
<!-- Video button (Who List tab) -->
|
||||
<button type="button" class="button is-small px-2 py-1"
|
||||
v-if="!isWatchingTab"
|
||||
:disabled="!(user.video & VideoFlag.Active)" :class="{
|
||||
'is-danger is-outlined': (user.video & VideoFlag.Active) && (user.video & VideoFlag.NSFW),
|
||||
'is-info is-outlined': (user.video & VideoFlag.Active) && !(user.video & VideoFlag.NSFW),
|
||||
'cursor-notallowed': isVideoNotAllowed,
|
||||
}" :title="`Open video stream` +
|
||||
(user.video & VideoFlag.MutualRequired ? '; mutual video sharing required' : '') +
|
||||
(user.video & VideoFlag.MutualOpen ? '; will auto-open your video' : '')"
|
||||
@click="openVideo()">
|
||||
<i class="fa" :class="videoIconClass"></i>
|
||||
</button>
|
||||
|
||||
<!-- Boot from Video button (Watching tab) -->
|
||||
<button v-else type="button" class="button is-small px-2 py-1"
|
||||
@click="bootUser()"
|
||||
title="Kick this person off your cam">
|
||||
<i class="fa fa-user-xmark has-text-danger"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
12
src/lib/VideoFlag.js
Normal file
12
src/lib/VideoFlag.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
// Video flag constants (sync with values in messages.go)
|
||||
const VideoFlag = {
|
||||
Active: 1 << 0,
|
||||
NSFW: 1 << 1,
|
||||
Muted: 1 << 2,
|
||||
IsTalking: 1 << 3,
|
||||
MutualRequired: 1 << 4,
|
||||
MutualOpen: 1 << 5,
|
||||
VipOnly: 1 << 6,
|
||||
};
|
||||
|
||||
export default VideoFlag;
|
Loading…
Reference in New Issue
Block a user