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.
This commit is contained in:
parent
2810169ce9
commit
7373882abf
|
@ -9,6 +9,11 @@ Webhooks are configured in your settings.toml file and look like so:
|
||||||
Name = "report"
|
Name = "report"
|
||||||
Enabled = true
|
Enabled = true
|
||||||
URL = "http://localhost:8080/v1/barertc/report"
|
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:
|
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.
|
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.
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
153
pkg/api.go
153
pkg/api.go
|
@ -2,6 +2,7 @@ package barertc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"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(¶ms); 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.
|
// Blocklist cache sent over from your website.
|
||||||
var (
|
var (
|
||||||
// Map of username to the list of usernames they block.
|
// Map of username to the list of usernames they block.
|
||||||
|
|
|
@ -152,6 +152,10 @@ func DefaultConfig() Config {
|
||||||
Name: "report",
|
Name: "report",
|
||||||
URL: "https://example.com/barertc/report",
|
URL: "https://example.com/barertc/report",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "profile",
|
||||||
|
URL: "https://example.com/barertc/user-profile",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
VIP: VIP{
|
VIP: VIP{
|
||||||
Name: "VIP",
|
Name: "VIP",
|
||||||
|
|
|
@ -418,21 +418,27 @@ func (s *Server) OnOpen(sub *Subscriber, msg messages.Message) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnBoot is a user kicking you off their video stream.
|
// OnBoot is a user kicking you off their video stream.
|
||||||
func (s *Server) OnBoot(sub *Subscriber, msg messages.Message) {
|
func (s *Server) OnBoot(sub *Subscriber, msg messages.Message, boot bool) {
|
||||||
log.Info("%s boots %s off their camera", sub.Username, msg.Username)
|
|
||||||
|
|
||||||
sub.muteMu.Lock()
|
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 boot {
|
||||||
if other, err := s.GetSubscriber(msg.Username); err == nil && other.IsAdmin() {
|
log.Info("%s boots %s off their camera", sub.Username, msg.Username)
|
||||||
other.ChatServer(
|
sub.booted[msg.Username] = struct{}{}
|
||||||
"%s has booted you off of their camera!",
|
|
||||||
sub.Username,
|
// 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()
|
s.SendWhoList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -506,7 +512,7 @@ func (s *Server) OnReport(sub *Subscriber, msg messages.Message) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post to the report webhook.
|
// Post to the report webhook.
|
||||||
if err := PostWebhook(WebhookReport, WebhookRequest{
|
if _, err := PostWebhook(WebhookReport, WebhookRequest{
|
||||||
Action: WebhookReport,
|
Action: WebhookReport,
|
||||||
APIKey: config.Current.AdminAPIKey,
|
APIKey: config.Current.AdminAPIKey,
|
||||||
Report: WebhookRequestReport{
|
Report: WebhookRequestReport{
|
||||||
|
|
|
@ -82,7 +82,7 @@ func (s *Server) reportFilteredMessage(sub *Subscriber, msg messages.Message) er
|
||||||
context = getMessageContext(msg.Channel)
|
context = getMessageContext(msg.Channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := PostWebhook(WebhookReport, WebhookRequest{
|
if _, err := PostWebhook(WebhookReport, WebhookRequest{
|
||||||
Action: WebhookReport,
|
Action: WebhookReport,
|
||||||
APIKey: config.Current.AdminAPIKey,
|
APIKey: config.Current.AdminAPIKey,
|
||||||
Report: WebhookRequestReport{
|
Report: WebhookRequestReport{
|
||||||
|
|
|
@ -69,9 +69,10 @@ type Message struct {
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Actions sent by the client side only
|
// Actions sent by the client side only
|
||||||
ActionLogin = "login" // post the username to backend
|
ActionLogin = "login" // post the username to backend
|
||||||
ActionBoot = "boot" // boot a user off your video feed
|
ActionBoot = "boot" // boot a user off your video feed
|
||||||
ActionMute = "mute" // mute a user's chat messages
|
ActionUnboot = "unboot" // unboot a user
|
||||||
|
ActionMute = "mute" // mute a user's chat messages
|
||||||
ActionUnmute = "unmute"
|
ActionUnmute = "unmute"
|
||||||
ActionBlock = "block" // hard block another user
|
ActionBlock = "block" // hard block another user
|
||||||
ActionBlocklist = "blocklist" // mute in bulk for usernames
|
ActionBlocklist = "blocklist" // mute in bulk for usernames
|
||||||
|
|
|
@ -39,6 +39,7 @@ func (s *Server) Setup() error {
|
||||||
mux.Handle("/api/block/now", s.BlockNow())
|
mux.Handle("/api/block/now", s.BlockNow())
|
||||||
mux.Handle("/api/authenticate", s.Authenticate())
|
mux.Handle("/api/authenticate", s.Authenticate())
|
||||||
mux.Handle("/api/shutdown", s.ShutdownAPI())
|
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("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets"))))
|
||||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("dist/static"))))
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("dist/static"))))
|
||||||
|
|
||||||
|
|
|
@ -39,18 +39,20 @@ func GetWebhook(name string) (config.WebhookURL, bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostWebhook submits a JSON body to one of the app's configured webhooks.
|
// 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)
|
webhook, ok := GetWebhook(name)
|
||||||
if !ok {
|
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 {
|
} 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.
|
// JSON request body.
|
||||||
jsonStr, err := json.Marshal(payload)
|
jsonStr, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make the API request to BareRTC.
|
// 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)
|
log.Debug("PostWebhook(%s): to %s we send: %s", name, url, jsonStr)
|
||||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr))
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
@ -67,15 +69,15 @@ func PostWebhook(name string, payload any) error {
|
||||||
}
|
}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
if resp.StatusCode != http.StatusOK {
|
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)
|
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
|
||||||
}
|
}
|
||||||
|
|
|
@ -96,7 +96,9 @@ func (sub *Subscriber) ReadLoop(s *Server) {
|
||||||
case messages.ActionOpen:
|
case messages.ActionOpen:
|
||||||
s.OnOpen(sub, msg)
|
s.OnOpen(sub, msg)
|
||||||
case messages.ActionBoot:
|
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:
|
case messages.ActionMute, messages.ActionUnmute:
|
||||||
s.OnMute(sub, msg, msg.Action == messages.ActionMute)
|
s.OnMute(sub, msg, msg.Action == messages.ActionMute)
|
||||||
case messages.ActionBlock:
|
case messages.ActionBlock:
|
||||||
|
|
62
src/App.vue
62
src/App.vue
|
@ -11,6 +11,7 @@ import ReportModal from './components/ReportModal.vue';
|
||||||
import MessageBox from './components/MessageBox.vue';
|
import MessageBox from './components/MessageBox.vue';
|
||||||
import WhoListRow from './components/WhoListRow.vue';
|
import WhoListRow from './components/WhoListRow.vue';
|
||||||
import VideoFeed from './components/VideoFeed.vue';
|
import VideoFeed from './components/VideoFeed.vue';
|
||||||
|
import ProfileModal from './components/ProfileModal.vue';
|
||||||
|
|
||||||
import LocalStorage from './lib/LocalStorage';
|
import LocalStorage from './lib/LocalStorage';
|
||||||
import VideoFlag from './lib/VideoFlag';
|
import VideoFlag from './lib/VideoFlag';
|
||||||
|
@ -50,6 +51,7 @@ export default {
|
||||||
MessageBox,
|
MessageBox,
|
||||||
WhoListRow,
|
WhoListRow,
|
||||||
VideoFeed,
|
VideoFeed,
|
||||||
|
ProfileModal,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -288,6 +290,12 @@ export default {
|
||||||
message: {},
|
message: {},
|
||||||
origMessage: {}, // pointer, so we can set the "reported" flag
|
origMessage: {}, // pointer, so we can set the "reported" flag
|
||||||
},
|
},
|
||||||
|
|
||||||
|
profileModal: {
|
||||||
|
visible: false,
|
||||||
|
user: {},
|
||||||
|
username: "",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
@ -1088,6 +1096,12 @@ export default {
|
||||||
username: username,
|
username: username,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
sendUnboot(username) {
|
||||||
|
this.ws.conn.send(JSON.stringify({
|
||||||
|
action: "unboot",
|
||||||
|
username: username,
|
||||||
|
}));
|
||||||
|
},
|
||||||
onOpen(msg) {
|
onOpen(msg) {
|
||||||
// Response for the opener to begin WebRTC connection.
|
// Response for the opener to begin WebRTC connection.
|
||||||
this.startWebRTC(msg.username, true);
|
this.startWebRTC(msg.username, true);
|
||||||
|
@ -2229,6 +2243,19 @@ export default {
|
||||||
|
|
||||||
// Boot someone off your video.
|
// Boot someone off your video.
|
||||||
bootUser(username) {
|
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(
|
if (!window.confirm(
|
||||||
`Kick ${username} off your camera? This will also prevent them ` +
|
`Kick ${username} off your camera? This will also prevent them ` +
|
||||||
`from seeing that your camera is active for the remainder of your ` +
|
`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.`
|
`in place for the remainder of your current chat session.`
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
isBooted(username) {
|
||||||
|
return this.WebRTC.booted[username] === true;
|
||||||
|
},
|
||||||
isBootedAdmin(username) {
|
isBootedAdmin(username) {
|
||||||
return (this.WebRTC.booted[username] === true || this.muted[username] === true) &&
|
return (this.WebRTC.booted[username] === true || this.muted[username] === true) &&
|
||||||
this.whoMap[username] != undefined &&
|
this.whoMap[username] != undefined &&
|
||||||
|
@ -2663,6 +2693,15 @@ export default {
|
||||||
input.click();
|
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.
|
* Sound effect concerns.
|
||||||
*/
|
*/
|
||||||
|
@ -3335,6 +3374,22 @@ export default {
|
||||||
@accept="doReport"
|
@accept="doReport"
|
||||||
@cancel="reportModal.visible=false"></ReportModal>
|
@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">
|
<div class="chat-container">
|
||||||
|
|
||||||
<!-- Top header panel -->
|
<!-- Top header panel -->
|
||||||
|
@ -3684,6 +3739,7 @@ export default {
|
||||||
:report-enabled="isWebhookEnabled('report')"
|
:report-enabled="isWebhookEnabled('report')"
|
||||||
:is-dm="isDM"
|
:is-dm="isDM"
|
||||||
:is-op="isOp"
|
:is-op="isOp"
|
||||||
|
@open-profile="showProfileModal"
|
||||||
@send-dm="openDMs"
|
@send-dm="openDMs"
|
||||||
@mute-user="muteUser"
|
@mute-user="muteUser"
|
||||||
@takeback="takeback"
|
@takeback="takeback"
|
||||||
|
@ -3892,7 +3948,8 @@ export default {
|
||||||
:vip-config="config.VIP"
|
:vip-config="config.VIP"
|
||||||
@send-dm="openDMs"
|
@send-dm="openDMs"
|
||||||
@mute-user="muteUser"
|
@mute-user="muteUser"
|
||||||
@open-video="openVideo"></WhoListRow>
|
@open-video="openVideo"
|
||||||
|
@open-profile="showProfileModal"></WhoListRow>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
@ -3913,7 +3970,8 @@ export default {
|
||||||
@send-dm="openDMs"
|
@send-dm="openDMs"
|
||||||
@mute-user="muteUser"
|
@mute-user="muteUser"
|
||||||
@open-video="openVideo"
|
@open-video="openVideo"
|
||||||
@boot-user="bootUser"></WhoListRow>
|
@boot-user="bootUser"
|
||||||
|
@open-profile="showProfileModal"></WhoListRow>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|
|
@ -84,10 +84,7 @@ export default {
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
openProfile() {
|
openProfile() {
|
||||||
let url = this.profileURL;
|
this.$emit('open-profile', this.message.username);
|
||||||
if (url) {
|
|
||||||
window.open(url);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
openDMs() {
|
openDMs() {
|
||||||
|
@ -180,8 +177,7 @@ export default {
|
||||||
<div class="media mb-0">
|
<div class="media mb-0">
|
||||||
<div class="media-left">
|
<div class="media-left">
|
||||||
<a :href="profileURL"
|
<a :href="profileURL"
|
||||||
@click.prevent="openProfile()"
|
@click.prevent="openProfile()">
|
||||||
:class="{ 'cursor-default': !profileURL }">
|
|
||||||
<figure class="image is-48x48">
|
<figure class="image is-48x48">
|
||||||
<img v-if="message.isChatServer" src="/static/img/server.png">
|
<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="message.isChatClient" src="/static/img/client.png">
|
||||||
|
@ -218,6 +214,7 @@ export default {
|
||||||
<small v-if="!(message.isChatClient || message.isChatServer)">
|
<small v-if="!(message.isChatClient || message.isChatServer)">
|
||||||
<a v-if="profileURL"
|
<a v-if="profileURL"
|
||||||
:href="profileURL" target="_blank"
|
:href="profileURL" target="_blank"
|
||||||
|
@click.prevent="openProfile()"
|
||||||
class="has-text-grey">
|
class="has-text-grey">
|
||||||
@{{ message.username }}
|
@{{ message.username }}
|
||||||
</a>
|
</a>
|
||||||
|
@ -229,12 +226,12 @@ export default {
|
||||||
<div v-else class="columns is-mobile pt-0">
|
<div v-else class="columns is-mobile pt-0">
|
||||||
<div class="column is-narrow pt-0">
|
<div class="column is-narrow pt-0">
|
||||||
<small v-if="!(message.isChatClient || message.isChatServer)">
|
<small v-if="!(message.isChatClient || message.isChatServer)">
|
||||||
<a v-if="profileURL"
|
<a :href="profileURL || '#'"
|
||||||
:href="profileURL" target="_blank"
|
target="_blank"
|
||||||
|
@click.prevent="openProfile()"
|
||||||
class="has-text-grey">
|
class="has-text-grey">
|
||||||
@{{ message.username }}
|
@{{ message.username }}
|
||||||
</a>
|
</a>
|
||||||
<span v-else class="has-text-grey">@{{ message.username }}</span>
|
|
||||||
</small>
|
</small>
|
||||||
<small v-else class="has-text-grey">internal</small>
|
<small v-else class="has-text-grey">internal</small>
|
||||||
</div>
|
</div>
|
||||||
|
@ -353,7 +350,7 @@ export default {
|
||||||
<div class="column is-narrow px-1">
|
<div class="column is-narrow px-1">
|
||||||
<a :href="profileURL"
|
<a :href="profileURL"
|
||||||
@click.prevent="openProfile()"
|
@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-if="avatarURL" :src="avatarURL" width="16" height="16" alt="">
|
||||||
<img v-else src="/static/img/shy.png" width="16" height="16">
|
<img v-else src="/static/img/shy.png" width="16" height="16">
|
||||||
</a>
|
</a>
|
||||||
|
|
260
src/components/ProfileModal.vue
Normal file
260
src/components/ProfileModal.vue
Normal 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>
|
|
@ -22,12 +22,20 @@ export default {
|
||||||
|
|
||||||
// Volume change debounce
|
// Volume change debounce
|
||||||
volumeDebounce: null,
|
volumeDebounce: null,
|
||||||
|
|
||||||
|
// Mouse over status
|
||||||
|
mouseOver: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
videoID() {
|
videoID() {
|
||||||
return this.localVideo ? 'localVideo' : `videofeed-${this.username}`;
|
return this.localVideo ? 'localVideo' : `videofeed-${this.username}`;
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
mouseOver() {
|
||||||
|
console.log("mouse over:", this.mouseOver);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
closeVideo() {
|
closeVideo() {
|
||||||
|
@ -77,7 +85,7 @@ export default {
|
||||||
<div class="feed" :class="{
|
<div class="feed" :class="{
|
||||||
'popped-out': poppedOut,
|
'popped-out': poppedOut,
|
||||||
'popped-in': !poppedOut,
|
'popped-in': !poppedOut,
|
||||||
}">
|
}" @mouseover="mouseOver=true" @mouseleave="mouseOver=false">
|
||||||
<video class="feed" :id="videoID" autoplay :muted="localVideo"></video>
|
<video class="feed" :id="videoID" autoplay :muted="localVideo"></video>
|
||||||
|
|
||||||
<!-- Caption -->
|
<!-- Caption -->
|
||||||
|
@ -140,7 +148,7 @@ export default {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Volume slider -->
|
<!-- Volume slider -->
|
||||||
<div class="volume-slider" v-if="!localVideo && !isMuted">
|
<div class="volume-slider" v-show="!localVideo && !isMuted && mouseOver">
|
||||||
<Slider
|
<Slider
|
||||||
v-model="volume"
|
v-model="volume"
|
||||||
color="#00FF00"
|
color="#00FF00"
|
||||||
|
|
|
@ -99,10 +99,7 @@ export default {
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
openProfile() {
|
openProfile() {
|
||||||
let url = this.profileURL;
|
this.$emit('open-profile', this.user.username);
|
||||||
if (url) {
|
|
||||||
window.open(url);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
openDMs() {
|
openDMs() {
|
||||||
|
@ -141,7 +138,7 @@ export default {
|
||||||
<div class="column is-narrow pr-0" style="position: relative">
|
<div class="column is-narrow pr-0" style="position: relative">
|
||||||
<a :href="profileURL"
|
<a :href="profileURL"
|
||||||
@click.prevent="openProfile()"
|
@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-if="avatarURL" :src="avatarURL" width="24" height="24" alt="">
|
||||||
<img v-else src="/static/img/shy.png" width="24" height="24">
|
<img v-else src="/static/img/shy.png" width="24" height="24">
|
||||||
|
|
||||||
|
@ -178,9 +175,8 @@ export default {
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="column pr-0 is-clipped" :class="{ 'pl-1': avatarURL }">
|
<div class="column pr-0 is-clipped" :class="{ 'pl-1': avatarURL }">
|
||||||
<strong class="truncate-text-line is-size-7"
|
<strong class="truncate-text-line is-size-7 cursor-pointer"
|
||||||
@click="openProfile()"
|
@click="openProfile()">
|
||||||
:class="{'cursor-pointer': profileURL}">
|
|
||||||
{{ user.username }}
|
{{ user.username }}
|
||||||
</strong>
|
</strong>
|
||||||
<sup class="fa fa-peace has-text-warning-dark is-size-7 ml-1" v-if="user.op"
|
<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>
|
</span>
|
||||||
|
|
||||||
<!-- Profile button -->
|
<!-- 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()"
|
:class="profileButtonClass" @click="openProfile()"
|
||||||
:title="'Open profile page' + (user.gender ? ` (gender: ${user.gender})` : '') + (user.vip ? ` (${vipConfig.Name})` : '')">
|
:title="'Open profile page' + (user.gender ? ` (gender: ${user.gender})` : '') + (user.vip ? ` (${vipConfig.Name})` : '')">
|
||||||
<i class="fa fa-user"></i>
|
<i class="fa fa-user"></i>
|
||||||
|
|
Loading…
Reference in New Issue
Block a user