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 LoginModal from './components/LoginModal.vue';
|
||||||
import ExplicitOpenModal from './components/ExplicitOpenModal.vue';
|
import ExplicitOpenModal from './components/ExplicitOpenModal.vue';
|
||||||
import ReportModal from './components/ReportModal.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 LocalStorage from './lib/LocalStorage';
|
||||||
|
import VideoFlag from './lib/VideoFlag';
|
||||||
import { SoundEffects, DefaultSounds } from './lib/sounds';
|
import { SoundEffects, DefaultSounds } from './lib/sounds';
|
||||||
import { isAppleWebkit } from './lib/browsers';
|
import { isAppleWebkit } from './lib/browsers';
|
||||||
|
|
||||||
|
@ -33,6 +36,8 @@ export default {
|
||||||
LoginModal,
|
LoginModal,
|
||||||
ExplicitOpenModal,
|
ExplicitOpenModal,
|
||||||
ReportModal,
|
ReportModal,
|
||||||
|
MessageBox,
|
||||||
|
WhoListRow,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -77,14 +82,6 @@ export default {
|
||||||
audioContext: null,
|
audioContext: null,
|
||||||
audioTracks: {},
|
audioTracks: {},
|
||||||
},
|
},
|
||||||
reactions: [
|
|
||||||
['❤️', '👍', '😂', '😉', '😢', '😡', '🥰'],
|
|
||||||
['😘', '👎', '☹️', '😭', '🤔', '🙄', '🤩'],
|
|
||||||
['👋', '🔥', '😈', '🍑', '🍆', '💦', '🍌'],
|
|
||||||
['😋', '⭐', '😇', '😴', '😱', '👀', '🎃'],
|
|
||||||
['🤮', '🥳', '🙏', '🤦', '💩', '🤯', '💯'],
|
|
||||||
['😏', '🙈', '🙉', '🙊', '☀️', '🌈', '🎂'],
|
|
||||||
],
|
|
||||||
|
|
||||||
// Cached blocklist for the current user sent by your website.
|
// Cached blocklist for the current user sent by your website.
|
||||||
CachedBlocklist: CachedBlocklist,
|
CachedBlocklist: CachedBlocklist,
|
||||||
|
@ -175,15 +172,7 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Video flag constants (sync with values in messages.go)
|
// Video flag constants (sync with values in messages.go)
|
||||||
VideoFlag: {
|
VideoFlag: VideoFlag,
|
||||||
Active: 1 << 0,
|
|
||||||
NSFW: 1 << 1,
|
|
||||||
Muted: 1 << 2,
|
|
||||||
IsTalking: 1 << 3,
|
|
||||||
MutualRequired: 1 << 4,
|
|
||||||
MutualOpen: 1 << 5,
|
|
||||||
VipOnly: 1 << 6,
|
|
||||||
},
|
|
||||||
|
|
||||||
// WebRTC sessions with other users.
|
// WebRTC sessions with other users.
|
||||||
WebRTC: {
|
WebRTC: {
|
||||||
|
@ -571,6 +560,63 @@ export default {
|
||||||
result = result.reverse();
|
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
|
// Default ordering from ChatServer = a-z
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
@ -817,7 +863,6 @@ export default {
|
||||||
// WhoList updates.
|
// WhoList updates.
|
||||||
onWho(msg) {
|
onWho(msg) {
|
||||||
this.whoList = msg.whoList;
|
this.whoList = msg.whoList;
|
||||||
this.whoMap = {};
|
|
||||||
|
|
||||||
if (this.whoList == undefined) {
|
if (this.whoList == undefined) {
|
||||||
this.whoList = [];
|
this.whoList = [];
|
||||||
|
@ -1460,6 +1505,16 @@ export default {
|
||||||
isUsernameOnline(username) {
|
isUsernameOnline(username) {
|
||||||
return this.whoMap[username] != undefined;
|
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) {
|
avatarForUsername(username) {
|
||||||
if (this.whoMap[username] != undefined && this.whoMap[username].avatar) {
|
if (this.whoMap[username] != undefined && this.whoMap[username].avatar) {
|
||||||
return this.avatarURL(this.whoMap[username]);
|
return this.avatarURL(this.whoMap[username]);
|
||||||
|
@ -2050,6 +2105,9 @@ export default {
|
||||||
this.closeVideo(username, "answerer");
|
this.closeVideo(username, "answerer");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove them from our list.
|
||||||
|
delete(this.webcam.watching[username]);
|
||||||
|
|
||||||
this.ChatClient(
|
this.ChatClient(
|
||||||
`You have booted ${username} off your camera. They will no longer be able ` +
|
`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 -- ` +
|
`to connect to your camera, or even see that your camera is active at all -- ` +
|
||||||
|
@ -3428,169 +3486,25 @@ export default {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Normal chat message: full size card w/ avatar -->
|
<!-- Normal chat message: full size card w/ avatar -->
|
||||||
<div v-else class="box mb-2 px-4 pt-3 pb-1 position-relative">
|
<MessageBox
|
||||||
<div class="media mb-0">
|
v-else
|
||||||
<div class="media-left">
|
:message="msg"
|
||||||
<a :href="profileURLForUsername(msg.username)"
|
:user="getUser(msg.username)"
|
||||||
@click.prevent="openProfile({ username: msg.username })"
|
:username="username"
|
||||||
:class="{ 'cursor-default': !profileURLForUsername(msg.username) }">
|
:website-url="config.website"
|
||||||
<figure class="image is-48x48">
|
:is-dnd="isUsernameDND(msg.username)"
|
||||||
<img v-if="msg.isChatServer" src="/static/img/server.png">
|
:is-muted="isMutedUser(msg.username)"
|
||||||
<img v-else-if="msg.isChatClient" src="/static/img/client.png">
|
:reactions="getReactions(msg)"
|
||||||
<img v-else-if="avatarForUsername(msg.username)"
|
:report-enabled="isWebhookEnabled('report')"
|
||||||
:src="avatarForUsername(msg.username)">
|
:is-dm="isDM"
|
||||||
<img v-else src="/static/img/shy.png">
|
:is-op="isOp"
|
||||||
</figure>
|
@send-dm="openDMs"
|
||||||
</a>
|
@mute-user="muteUser"
|
||||||
</div>
|
@takeback="takeback"
|
||||||
<div class="media-content">
|
@remove="removeMessage"
|
||||||
<div class="columns is-mobile pb-0">
|
@report="reportMessage"
|
||||||
<div class="column is-narrow pb-0">
|
@react="sendReact"
|
||||||
<strong :class="{
|
></MessageBox>
|
||||||
'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>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -3726,116 +3640,40 @@ export default {
|
||||||
<!-- Who Is Online -->
|
<!-- Who Is Online -->
|
||||||
<ul class="menu-list" v-if="whoTab === 'online'">
|
<ul class="menu-list" v-if="whoTab === 'online'">
|
||||||
<li v-for="(u, i) in sortedWhoList" v-bind:key="i">
|
<li v-for="(u, i) in sortedWhoList" v-bind:key="i">
|
||||||
<div class="columns is-mobile">
|
<WhoListRow
|
||||||
<!-- Avatar URL if available -->
|
:user="u"
|
||||||
<div class="column is-narrow pr-0" style="position: relative">
|
:username="username"
|
||||||
<a :href="profileURLForUsername(u.username)"
|
:website-url="config.website"
|
||||||
@click.prevent="openProfile({ username: u.username })"
|
:is-dnd="isUsernameDND(u.username)"
|
||||||
:class="{ 'cursor-default': !profileURLForUsername(u.username) }" class="p-0">
|
:is-muted="isMutedUser(u.username)"
|
||||||
<img v-if="u.avatar" :src="avatarURL(u)" width="24" height="24" alt="">
|
:is-op="isOp"
|
||||||
<img v-else src="/static/img/shy.png" width="24" height="24">
|
:is-video-not-allowed="isVideoNotAllowed(u)"
|
||||||
|
:video-icon-class="webcamIconClass(u)"
|
||||||
<!-- Away symbol -->
|
:vip-config="config.VIP"
|
||||||
<div v-if="u.status !== 'online'" class="status-away-icon">
|
@send-dm="openDMs"
|
||||||
<i v-if="u.status === 'away'" class="fa fa-clock has-text-light"
|
@mute-user="muteUser"
|
||||||
title="Status: Away"></i>
|
@open-video="openVideo"></WhoListRow>
|
||||||
<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>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- Watching My Webcam -->
|
<!-- Watching My Webcam -->
|
||||||
<ul class="menu-list" v-if="whoTab === 'watching'">
|
<ul class="menu-list" v-if="whoTab === 'watching'">
|
||||||
<li v-for="username in Object.keys(webcam.watching)" v-bind:key="username">
|
<li v-for="(u, i) in sortedWatchingList" v-bind:key="username">
|
||||||
<div class="columns is-mobile">
|
<WhoListRow
|
||||||
<!-- Avatar URL if available -->
|
:is-watching-tab="true"
|
||||||
<div class="column is-narrow pr-0">
|
:user="u"
|
||||||
<i class="fa fa-eye"></i>
|
:username="username"
|
||||||
</div>
|
:website-url="config.website"
|
||||||
<div class="column pr-0">
|
:is-dnd="isUsernameDND(username)"
|
||||||
{{ username }}
|
:is-muted="isMutedUser(username)"
|
||||||
</div>
|
:is-op="isOp"
|
||||||
<div class="column is-narrow pl-0">
|
:is-video-not-allowed="isVideoNotAllowed(u)"
|
||||||
<!-- Boot from cam button -->
|
:video-icon-class="webcamIconClass(u)"
|
||||||
<button type="button" class="button is-small px-2 py-1" @click="bootUser(username)"
|
:vip-config="config.VIP"
|
||||||
title="Kick this person off your cam">
|
@send-dm="openDMs"
|
||||||
<i class="fa fa-user-xmark has-text-danger"></i>
|
@mute-user="muteUser"
|
||||||
</button>
|
@open-video="openVideo"
|
||||||
</div>
|
@boot-user="bootUser"></WhoListRow>
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|
|
@ -1,35 +1,2 @@
|
||||||
@import './base.css';
|
@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>
|
<script>
|
||||||
|
import MessageBox from './MessageBox.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
|
@ -6,6 +8,9 @@ export default {
|
||||||
user: Object,
|
user: Object,
|
||||||
message: Object,
|
message: Object,
|
||||||
},
|
},
|
||||||
|
components: {
|
||||||
|
MessageBox,
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
// Configuration
|
// Configuration
|
||||||
|
@ -24,14 +29,21 @@ export default {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
reset() {
|
||||||
|
this.classification = this.reportClassifications[0];
|
||||||
|
this.comment = "";
|
||||||
|
},
|
||||||
|
|
||||||
accept() {
|
accept() {
|
||||||
this.$emit('accept', {
|
this.$emit('accept', {
|
||||||
classification: this.classification,
|
classification: this.classification,
|
||||||
comment: this.comment,
|
comment: this.comment,
|
||||||
});
|
});
|
||||||
|
this.reset();
|
||||||
},
|
},
|
||||||
cancel() {
|
cancel() {
|
||||||
this.$emit('cancel');
|
this.$emit('cancel');
|
||||||
|
this.reset();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -48,38 +60,12 @@ export default {
|
||||||
</header>
|
</header>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
|
|
||||||
<!-- Message preview we are reporting on
|
<!-- Message preview we are reporting on -->
|
||||||
TODO: make it DRY: style copied/referenced from chat history cards -->
|
<MessageBox
|
||||||
<div class="box mb-2 px-4 pt-3 pb-1 position-relative">
|
:message="message"
|
||||||
<div class="media mb-0">
|
:user="user"
|
||||||
<div class="media-left">
|
:no-buttons="true"
|
||||||
<figure class="image is-48x48">
|
></MessageBox>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="field mb-1">
|
<div class="field mb-1">
|
||||||
<label class="label" for="classification">Report classification:</label>
|
<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