Profile Modals + Misc Features

* Add profile modal popups and Webhook support to get more detailed user
  info from your website.
* Add "unboot" command, available in the profile modal.
polling-api
Noah 2023-10-07 13:22:41 -07:00
parent 2810169ce9
commit 7373882abf
14 changed files with 580 additions and 50 deletions

View File

@ -9,6 +9,11 @@ Webhooks are configured in your settings.toml file and look like so:
Name = "report"
Enabled = true
URL = "http://localhost:8080/v1/barertc/report"
[[WebhookURLs]]
Name = "profile"
Enabled = true
URL = "http://localhost:8080/v1/barertc/profile"
```
All Webhooks will be called as **POST** requests and will contain a JSON payload that will always have the following two keys:
@ -43,3 +48,40 @@ Example JSON payload posted to the webhook:
```
BareRTC expects your webhook URL to return a 200 OK status code or it will surface an error in chat to the reporter.
## Profile Webhook
Enabling this webhook will allow your site to deliver more detailed profile information on demand for your users. This is used in chat when somebody opens the Profile Card modal for a user in chat.
BareRTC will call your webhook URL with the following payload:
```javascript
{
"Action": "profile",
"APIKey": "shared secret from settings.toml#AdminAPIKey",
"Username": "soandso"
}
```
The expected response from your endpoint should follow this format:
```javascript
{
"StatusCode": 200,
"Data": {
"OK": true,
"Error": "any error messaging (omittable if no errors)",
"ProfileFields": [
{
"Name": "Age",
"Value": "30yo",
},
{
"Name": "Gender",
"Value": "Man",
},
// etc.
]
}
}
```

View File

@ -2,6 +2,7 @@ package barertc
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
@ -467,6 +468,158 @@ func (s *Server) BlockNow() http.HandlerFunc {
})
}
// UserProfile (/api/profile) fetches profile information about a user.
//
// This endpoint will proxy to your WebhookURL for the "profile" endpoint.
// If your webhook is not configured or not reachable, this endpoint returns
// an error to the caller.
//
// Authentication: the caller must send their current chat JWT token when
// hitting this endpoint.
//
// It is a POST request with a json body containing the following schema:
//
// {
// "JWTToken": "the caller's jwt token",
// "Username": [ "soandso" ]
// }
//
// The response JSON will look like the following (this also mirrors the
// response json as sent by your site's webhook URL):
//
// {
// "OK": true,
// "Error": "only on errors",
// "ProfileFields": [
// {
// "Name": "Age",
// "Value": "30yo",
// },
// {
// "Name": "Gender",
// "Value": "Man",
// },
// ...
// ]
// }
func (s *Server) UserProfile() http.HandlerFunc {
type request struct {
JWTToken string
Username string
}
type profileField struct {
Name string
Value string
}
type result struct {
OK bool
Error string `json:",omitempty"`
ProfileFields []profileField `json:",omitempty"`
}
type webhookRequest struct {
Action string
APIKey string
Username string
}
type webhookResponse struct {
StatusCode int
Data result
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// JSON writer for the response.
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
// Parse the request.
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only POST methods allowed",
})
return
} else if r.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only application/json content-types allowed",
})
return
}
defer r.Body.Close()
// Parse the request payload.
var (
params request
dec = json.NewDecoder(r.Body)
)
if err := dec.Decode(&params); err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Are JWT tokens enabled on the server?
if !config.Current.JWT.Enabled || params.JWTToken == "" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "JWT authentication is not available.",
})
return
}
// Validate the user's JWT token.
_, _, err := jwt.ParseAndValidate(params.JWTToken)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Fetch the profile data from your website.
data, err := PostWebhook("profile", webhookRequest{
Action: "profile",
APIKey: config.Current.AdminAPIKey,
Username: params.Username,
})
if err != nil {
log.Error("Couldn't get profile information: %s", err)
}
// Success? Try and parse the response into our expected format.
var resp webhookResponse
if err := json.Unmarshal(data, &resp); err != nil {
w.WriteHeader(http.StatusInternalServerError)
// A nice error message?
if resp.Data.Error != "" {
enc.Encode(result{
Error: resp.Data.Error,
})
} else {
enc.Encode(result{
Error: fmt.Sprintf("Didn't get expected response for profile data: %s", err),
})
}
return
}
// At this point the expected resp mirrors our own, so return it.
if resp.StatusCode != http.StatusOK || resp.Data.Error != "" {
w.WriteHeader(http.StatusInternalServerError)
}
enc.Encode(resp.Data)
})
}
// Blocklist cache sent over from your website.
var (
// Map of username to the list of usernames they block.

View File

@ -152,6 +152,10 @@ func DefaultConfig() Config {
Name: "report",
URL: "https://example.com/barertc/report",
},
{
Name: "profile",
URL: "https://example.com/barertc/user-profile",
},
},
VIP: VIP{
Name: "VIP",

View File

@ -418,21 +418,27 @@ func (s *Server) OnOpen(sub *Subscriber, msg messages.Message) {
}
// OnBoot is a user kicking you off their video stream.
func (s *Server) OnBoot(sub *Subscriber, msg messages.Message) {
log.Info("%s boots %s off their camera", sub.Username, msg.Username)
func (s *Server) OnBoot(sub *Subscriber, msg messages.Message, boot bool) {
sub.muteMu.Lock()
sub.booted[msg.Username] = struct{}{}
sub.muteMu.Unlock()
// If the subject of the boot is an admin, inform them they have been booted.
if other, err := s.GetSubscriber(msg.Username); err == nil && other.IsAdmin() {
other.ChatServer(
"%s has booted you off of their camera!",
sub.Username,
)
if boot {
log.Info("%s boots %s off their camera", sub.Username, msg.Username)
sub.booted[msg.Username] = struct{}{}
// If the subject of the boot is an admin, inform them they have been booted.
if other, err := s.GetSubscriber(msg.Username); err == nil && other.IsAdmin() {
other.ChatServer(
"%s has booted you off of their camera!",
sub.Username,
)
}
} else {
log.Info("%s unboots %s from their camera", sub.Username, msg.Username)
delete(sub.booted, msg.Username)
}
sub.muteMu.Unlock()
s.SendWhoList()
}
@ -506,7 +512,7 @@ func (s *Server) OnReport(sub *Subscriber, msg messages.Message) {
}
// Post to the report webhook.
if err := PostWebhook(WebhookReport, WebhookRequest{
if _, err := PostWebhook(WebhookReport, WebhookRequest{
Action: WebhookReport,
APIKey: config.Current.AdminAPIKey,
Report: WebhookRequestReport{

View File

@ -82,7 +82,7 @@ func (s *Server) reportFilteredMessage(sub *Subscriber, msg messages.Message) er
context = getMessageContext(msg.Channel)
}
if err := PostWebhook(WebhookReport, WebhookRequest{
if _, err := PostWebhook(WebhookReport, WebhookRequest{
Action: WebhookReport,
APIKey: config.Current.AdminAPIKey,
Report: WebhookRequestReport{

View File

@ -69,9 +69,10 @@ type Message struct {
const (
// Actions sent by the client side only
ActionLogin = "login" // post the username to backend
ActionBoot = "boot" // boot a user off your video feed
ActionMute = "mute" // mute a user's chat messages
ActionLogin = "login" // post the username to backend
ActionBoot = "boot" // boot a user off your video feed
ActionUnboot = "unboot" // unboot a user
ActionMute = "mute" // mute a user's chat messages
ActionUnmute = "unmute"
ActionBlock = "block" // hard block another user
ActionBlocklist = "blocklist" // mute in bulk for usernames

View File

@ -39,6 +39,7 @@ func (s *Server) Setup() error {
mux.Handle("/api/block/now", s.BlockNow())
mux.Handle("/api/authenticate", s.Authenticate())
mux.Handle("/api/shutdown", s.ShutdownAPI())
mux.Handle("/api/profile", s.UserProfile())
mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets"))))
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("dist/static"))))

View File

@ -39,18 +39,20 @@ func GetWebhook(name string) (config.WebhookURL, bool) {
}
// PostWebhook submits a JSON body to one of the app's configured webhooks.
func PostWebhook(name string, payload any) error {
//
// Returns the bytes of the response body (hopefully, JSON data) and any errors.
func PostWebhook(name string, payload any) ([]byte, error) {
webhook, ok := GetWebhook(name)
if !ok {
return errors.New("PostWebhook(%s): webhook name %s is not configured")
return nil, errors.New("PostWebhook(%s): webhook name %s is not configured")
} else if !webhook.Enabled {
return errors.New("PostWebhook(%s): webhook is not enabled")
return nil, errors.New("PostWebhook(%s): webhook is not enabled")
}
// JSON request body.
jsonStr, err := json.Marshal(payload)
if err != nil {
return err
return nil, err
}
// Make the API request to BareRTC.
@ -58,7 +60,7 @@ func PostWebhook(name string, payload any) error {
log.Debug("PostWebhook(%s): to %s we send: %s", name, url, jsonStr)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr))
if err != nil {
return err
return nil, err
}
req.Header.Set("Content-Type", "application/json")
@ -67,15 +69,15 @@ func PostWebhook(name string, payload any) error {
}
resp, err := client.Do(req)
if err != nil {
return err
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Error("PostWebhook(%s): unexpected response from webhook URL %s (code %d): %s", name, url, resp.StatusCode, body)
return errors.New("unexpected error from webhook URL")
return body, errors.New("unexpected error from webhook URL")
}
return nil
return body, nil
}

View File

@ -96,7 +96,9 @@ func (sub *Subscriber) ReadLoop(s *Server) {
case messages.ActionOpen:
s.OnOpen(sub, msg)
case messages.ActionBoot:
s.OnBoot(sub, msg)
s.OnBoot(sub, msg, true)
case messages.ActionUnboot:
s.OnBoot(sub, msg, false)
case messages.ActionMute, messages.ActionUnmute:
s.OnMute(sub, msg, msg.Action == messages.ActionMute)
case messages.ActionBlock:

View File

@ -11,6 +11,7 @@ import ReportModal from './components/ReportModal.vue';
import MessageBox from './components/MessageBox.vue';
import WhoListRow from './components/WhoListRow.vue';
import VideoFeed from './components/VideoFeed.vue';
import ProfileModal from './components/ProfileModal.vue';
import LocalStorage from './lib/LocalStorage';
import VideoFlag from './lib/VideoFlag';
@ -50,6 +51,7 @@ export default {
MessageBox,
WhoListRow,
VideoFeed,
ProfileModal,
},
data() {
return {
@ -288,6 +290,12 @@ export default {
message: {},
origMessage: {}, // pointer, so we can set the "reported" flag
},
profileModal: {
visible: false,
user: {},
username: "",
},
}
},
mounted() {
@ -1088,6 +1096,12 @@ export default {
username: username,
}));
},
sendUnboot(username) {
this.ws.conn.send(JSON.stringify({
action: "unboot",
username: username,
}));
},
onOpen(msg) {
// Response for the opener to begin WebRTC connection.
this.startWebRTC(msg.username, true);
@ -2229,6 +2243,19 @@ export default {
// Boot someone off your video.
bootUser(username) {
// Un-boot?
if (this.isBooted(username)) {
if (!window.confirm(`Allow ${username} to watch your webcam again?`)) {
return;
}
this.sendUnboot(username);
delete(this.WebRTC.booted[username]);
return;
}
// Boot them off our webcam.
if (!window.confirm(
`Kick ${username} off your camera? This will also prevent them ` +
`from seeing that your camera is active for the remainder of your ` +
@ -2254,6 +2281,9 @@ export default {
`in place for the remainder of your current chat session.`
);
},
isBooted(username) {
return this.WebRTC.booted[username] === true;
},
isBootedAdmin(username) {
return (this.WebRTC.booted[username] === true || this.muted[username] === true) &&
this.whoMap[username] != undefined &&
@ -2663,6 +2693,15 @@ export default {
input.click();
},
// Invoke the Profile Modal
showProfileModal(username) {
if (this.whoMap[username] != undefined) {
this.profileModal.user = this.whoMap[username];
this.profileModal.username = username;
this.profileModal.visible = true;
}
},
/**
* Sound effect concerns.
*/
@ -3335,6 +3374,22 @@ export default {
@accept="doReport"
@cancel="reportModal.visible=false"></ReportModal>
<!-- Profile Modal (profile cards popup) -->
<ProfileModal :visible="profileModal.visible"
:user="profileModal.user"
:username="username"
:jwt="jwt.token"
:website-url="config.website"
:is-dnd="isUsernameDND(profileModal.username)"
:is-muted="isMutedUser(profileModal.username)"
:is-booted="isBooted(profileModal.username)"
:profile-webhook-enabled="isWebhookEnabled('profile')"
:vip-config="config.VIP"
@send-dm="openDMs"
@mute-user="muteUser"
@boot-user="bootUser"
@cancel="profileModal.visible=false"></ProfileModal>
<div class="chat-container">
<!-- Top header panel -->
@ -3684,6 +3739,7 @@ export default {
:report-enabled="isWebhookEnabled('report')"
:is-dm="isDM"
:is-op="isOp"
@open-profile="showProfileModal"
@send-dm="openDMs"
@mute-user="muteUser"
@takeback="takeback"
@ -3892,7 +3948,8 @@ export default {
:vip-config="config.VIP"
@send-dm="openDMs"
@mute-user="muteUser"
@open-video="openVideo"></WhoListRow>
@open-video="openVideo"
@open-profile="showProfileModal"></WhoListRow>
</li>
</ul>
@ -3913,7 +3970,8 @@ export default {
@send-dm="openDMs"
@mute-user="muteUser"
@open-video="openVideo"
@boot-user="bootUser"></WhoListRow>
@boot-user="bootUser"
@open-profile="showProfileModal"></WhoListRow>
</li>
</ul>

View File

@ -84,10 +84,7 @@ export default {
},
methods: {
openProfile() {
let url = this.profileURL;
if (url) {
window.open(url);
}
this.$emit('open-profile', this.message.username);
},
openDMs() {
@ -180,8 +177,7 @@ export default {
<div class="media mb-0">
<div class="media-left">
<a :href="profileURL"
@click.prevent="openProfile()"
:class="{ 'cursor-default': !profileURL }">
@click.prevent="openProfile()">
<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">
@ -218,6 +214,7 @@ export default {
<small v-if="!(message.isChatClient || message.isChatServer)">
<a v-if="profileURL"
:href="profileURL" target="_blank"
@click.prevent="openProfile()"
class="has-text-grey">
@{{ message.username }}
</a>
@ -229,12 +226,12 @@ export default {
<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"
<a :href="profileURL || '#'"
target="_blank"
@click.prevent="openProfile()"
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>
@ -353,7 +350,7 @@ export default {
<div class="column is-narrow px-1">
<a :href="profileURL"
@click.prevent="openProfile()"
:class="{ 'cursor-default': !profileURL }" class="p-0">
class="p-0">
<img v-if="avatarURL" :src="avatarURL" width="16" height="16" alt="">
<img v-else src="/static/img/shy.png" width="16" height="16">
</a>

View File

@ -0,0 +1,260 @@
<script>
export default {
props: {
visible: Boolean,
jwt: String, // caller's JWT token for authorization
user: Object, // the user we are viewing
username: String, // the local user
websiteUrl: String,
isDnd: Boolean,
isMuted: Boolean,
isBooted: Boolean,
profileWebhookEnabled: Boolean,
vipConfig: Object, // VIP config settings for BareRTC
},
data() {
return {
busy: false,
// Profile data
profileFields: [],
// Error messaging from backend
error: null,
};
},
watch: {
visible() {
if (this.visible) {
this.refresh();
} else {
this.profileFields = [];
this.error = null;
this.busy = false;
}
}
},
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;
},
},
methods: {
refresh() {
if (!this.profileWebhookEnabled) return;
if (!this.user || !this.user?.username) return;
this.busy = true;
return fetch("/api/profile", {
method: "POST",
mode: "same-origin",
cache: "no-cache",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
"JWTToken": this.jwt,
"Username": this.user?.username,
}),
})
.then((response) => response.json())
.then((data) => {
if (data.Error) {
this.error = data.Error;
return;
}
if (data.ProfileFields != undefined) {
this.profileFields = data.ProfileFields;
}
}).catch(resp => {
this.error = resp;
}).finally(() => {
this.busy = false;
})
},
cancel() {
this.$emit("cancel");
},
openProfile() {
let url = this.profileURL;
if (url) {
window.open(url);
}
},
openDMs() {
this.cancel();
this.$emit('send-dm', {
username: this.user.username,
});
},
muteUser() {
this.$emit('mute-user', this.user.username);
},
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>
<!-- Profile Card Modal -->
<div class="modal" :class="{ 'is-active': visible }">
<div class="modal-background" @click="cancel()"></div>
<div class="modal-content">
<div class="card">
<header class="card-header has-background-success">
<p class="card-header-title">Profile Card</p>
</header>
<div class="card-content">
<!-- Avatar and name/username media -->
<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="avatarURL"
:src="avatarURL">
<img v-else src="/static/img/shy.png">
</figure>
</a>
</div>
<div class="media-content">
<strong>
<!-- User nickname/display name -->
{{ nickname }}
</strong>
<div>
<small>
<a v-if="profileURL"
:href="profileURL" target="_blank"
class="has-text-grey">
@{{ user.username }}
</a>
<span v-else class="has-text-grey">@{{ user.username }}</span>
</small>
</div>
</div>
</div>
<!-- User badges -->
<div v-if="user.op || user.vip" class="mt-4">
<!-- Operator? -->
<span v-if="user.op" class="tag is-warning is-light mr-2">
<i class="fa fa-peace mr-1"></i> Operator
</span>
<!-- VIP? -->
<span v-if="vipConfig && user.vip" class="tag is-success is-light mr-2"
:title="vipConfig.Name">
<i class="mr-1" :class="vipConfig.Icon"></i>
{{ vipConfig.Name }}
</span>
</div>
<!-- Action buttons -->
<div v-if="user.username !== username" class="mt-4">
<!-- DMs button -->
<button type="button"
class="button is-grey is-outlined is-small px-2 mb-1"
@click="openDMs()"
:title="isDnd ? 'This person is not accepting new DMs' : 'Open a Direct Message (DM) thread'"
:disabled="isDnd">
<i class="fa mr-1" :class="{'fa-comment': !isDnd, 'fa-comment-slash': isDnd}"></i>
Direct Message
</button>
<!-- Mute button -->
<button type="button"
class="button is-grey is-outlined is-small px-2 ml-1 mb-1"
@click="muteUser()" title="Mute user">
<i class="fa fa-comment-slash mr-1" :class="{
'has-text-success': isMuted,
'has-text-danger': !isMuted
}"></i>
{{ isMuted ? "Unmute" : "Mute" }} Messages
</button>
<!-- Boot button -->
<button type="button"
class="button is-grey is-outlined is-small px-2 ml-1 mb-1"
@click="bootUser()" title="Boot user off your webcam">
<i class="fa fa-user-xmark mr-1" :class="{
'has-text-danger': !isBooted,
'has-text-success': isBooted,
}"></i>
{{ isBooted ? 'Allow to watch my webcam' : "Don't allow to watch my webcam" }}
</button>
</div>
<!-- Profile Fields spinner/error -->
<div class="notification is-info is-light p-2 my-2" v-if="busy">
<i class="fa fa-spinner fa-spin mr-2"></i>
Loading profile details...
</div>
<div class="notification is-danger is-light p-2 my-2" v-else-if="error">
<i class="fa fa-exclamation-triangle mr-2"></i>
Error loading profile details:
{{ error }}
</div>
<!-- Profile Fields -->
<div class="columns is-multiline is-mobile mt-3"
v-else-if="profileFields.length > 0">
<div class="column py-1"
v-for="(field, i) in profileFields"
v-bind:key="field.Name"
:class="{'is-half': i < profileFields.length-1}">
<strong>{{ field.Name }}:</strong>
{{ field.Value }}
</div>
</div>
</div>
<footer class="card-footer">
<a :href="profileURL" target="_blank"
v-if="profileURL" class="card-footer-item">
Full profile <i class="fa fa-external-link ml-2"></i>
</a>
<a href="#" @click.prevent="cancel()" class="card-footer-item">
Close
</a>
</footer>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -22,12 +22,20 @@ export default {
// Volume change debounce
volumeDebounce: null,
// Mouse over status
mouseOver: false,
};
},
computed: {
videoID() {
return this.localVideo ? 'localVideo' : `videofeed-${this.username}`;
}
},
},
watch: {
mouseOver() {
console.log("mouse over:", this.mouseOver);
},
},
methods: {
closeVideo() {
@ -77,7 +85,7 @@ export default {
<div class="feed" :class="{
'popped-out': poppedOut,
'popped-in': !poppedOut,
}">
}" @mouseover="mouseOver=true" @mouseleave="mouseOver=false">
<video class="feed" :id="videoID" autoplay :muted="localVideo"></video>
<!-- Caption -->
@ -140,7 +148,7 @@ export default {
</div>
<!-- Volume slider -->
<div class="volume-slider" v-if="!localVideo && !isMuted">
<div class="volume-slider" v-show="!localVideo && !isMuted && mouseOver">
<Slider
v-model="volume"
color="#00FF00"

View File

@ -99,10 +99,7 @@ export default {
},
methods: {
openProfile() {
let url = this.profileURL;
if (url) {
window.open(url);
}
this.$emit('open-profile', this.user.username);
},
openDMs() {
@ -141,7 +138,7 @@ export default {
<div class="column is-narrow pr-0" style="position: relative">
<a :href="profileURL"
@click.prevent="openProfile()"
:class="{ 'cursor-default': !profileURL }" class="p-0">
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">
@ -178,9 +175,8 @@ export default {
</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}">
<strong class="truncate-text-line is-size-7 cursor-pointer"
@click="openProfile()">
{{ user.username }}
</strong>
<sup class="fa fa-peace has-text-warning-dark is-size-7 ml-1" v-if="user.op"
@ -195,7 +191,7 @@ export default {
</span>
<!-- Profile button -->
<button type="button" v-if="profileURL" class="button is-small px-2 py-1"
<button type="button" 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>