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:
Noah 2023-09-07 19:24:26 -07:00
parent 8906e89a51
commit d8cb1c7c11
6 changed files with 651 additions and 351 deletions

View File

@ -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>

View File

@ -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;
}
}

View 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>

View File

@ -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>

View 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
View 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;