Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Contribute to GitLab
Sign in
Toggle navigation
S
SHMCamStudio
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
1
Merge Requests
1
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
SexHackMe
SHMCamStudio
Commits
a5d8432d
Commit
a5d8432d
authored
Jun 18, 2025
by
Stefy Lanza (nextime / spora )
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
added initial browser control
parent
815adc2c
Changes
4
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
865 additions
and
0 deletions
+865
-0
shmcamstudio
shmcamstudio
+12
-0
browser.py
shmcs/browser.py
+93
-0
webpanel.py
shmcs/webpanel.py
+3
-0
chat.html
templates/chat.html
+757
-0
No files found.
shmcamstudio
View file @
a5d8432d
...
@@ -69,6 +69,7 @@ from shmcs.webpanel import run_flask_app
...
@@ -69,6 +69,7 @@ from shmcs.webpanel import run_flask_app
from
shmcs.panel
import
create_panel_gui
from
shmcs.panel
import
create_panel_gui
from
shmcs.studio
import
run_camstudio
from
shmcs.studio
import
run_camstudio
from
shmcs.obs
import
run_obs_controller
from
shmcs.obs
import
run_obs_controller
from
shmcs.browser
import
run_browser
def
main
():
def
main
():
# Setup argument parser
# Setup argument parser
...
@@ -80,6 +81,8 @@ def main():
...
@@ -80,6 +81,8 @@ def main():
parser
.
add_argument
(
'--port'
,
type
=
int
,
default
=
5000
,
parser
.
add_argument
(
'--port'
,
type
=
int
,
default
=
5000
,
help
=
'Port for the web interface (default: 5000)'
)
help
=
'Port for the web interface (default: 5000)'
)
parser
.
add_argument
(
'--browser'
,
action
=
"store_true"
,
help
=
'launch local web browser'
)
# Parse arguments
# Parse arguments
args
=
parser
.
parse_args
()
args
=
parser
.
parse_args
()
...
@@ -100,6 +103,15 @@ def main():
...
@@ -100,6 +103,15 @@ def main():
)
)
obs_thread
.
start
()
obs_thread
.
start
()
if
args
.
browser
:
browser_thread
=
threading
.
Thread
(
target
=
run_browser
,
kwargs
=
{},
daemon
=
True
)
logger
.
info
(
"Browser launch requested"
)
browser_thread
.
start
()
# Daemon mode for web interface
# Daemon mode for web interface
if
args
.
nogui
or
args
.
daemon
:
if
args
.
nogui
or
args
.
daemon
:
run_camstudio
(
args
.
daemon
)
run_camstudio
(
args
.
daemon
)
...
...
shmcs/browser.py
0 → 100644
View file @
a5d8432d
import
platform
import
asyncio
from
playwright.async_api
import
async_playwright
import
argparse
import
os
from
datetime
import
datetime
from
pathlib
import
Path
import
logging
logging
.
getLogger
(
__name__
)
async
def
monitor_stream
(
log_path
,
domain
):
async
with
async_playwright
()
as
p
:
try
:
browser
=
await
p
.
chromium
.
connect_over_cdp
(
"http://localhost:9222"
)
except
:
user_data_dir
=
'./user-data'
cont
=
await
p
.
chromium
.
launch_persistent_context
(
user_data_dir
,
headless
=
False
,
args
=
[
"--disable-infobars"
,
"--disable-features=Antaniu"
],
ignore_default_args
=
[
"--enable-automation"
,
"--no-sandbox"
],)
pages
=
cont
.
pages
if
pages
:
page
=
pages
[
0
]
else
:
page
=
await
cont
.
new_page
()
logger
.
info
(
page
.
url
)
await
page
.
goto
(
'http://localhost:5000/chat'
)
logger
.
info
(
"Browser session retained"
)
#await browser.close()
browser
=
cont
.
browser
while
True
:
browser
=
cont
.
browser
if
browser
:
contexts
=
browser
.
contexts
else
:
contexts
=
[
cont
]
for
context
in
contexts
:
for
page
in
context
.
pages
:
if
domain
in
page
.
url
:
print
(
f
"Found Streamate tab"
)
await
monitor_requests
(
page
,
log_path
)
await
asyncio
.
sleep
(
5
)
# Check every 5 seconds
async
def
monitor_requests
(
page
,
log_path
):
async
def
log_request
(
request
):
if
request
.
resource_type
not
in
[
'document'
,
'image'
,
'font'
,
'stylesheet'
]:
log_entry
=
f
"[{datetime.now()}] Request: {request.method} {request.url}
\n
"
with
open
(
log_path
,
'a'
)
as
f
:
f
.
write
(
log_entry
)
async
def
log_response
(
response
):
#print(await response.body())
#print(response.request.resource_type)
#if response.request.resource_type not in ['document', 'image', 'font', 'stylesheet']:
if
response
.
request
.
resource_type
in
[
'xhr'
]
and
not
response
.
url
.
endswith
(
".mp3"
):
log_entry
=
f
"[{datetime.now()}] Response: {response.status} {response.url} -> BODY: "
log_body
=
await
response
.
body
()
with
open
(
log_path
,
'a'
)
as
f
:
f
.
write
(
log_entry
)
print
(
log_body
)
f
.
write
(
str
(
log_body
))
f
.
write
(
"
\n\n
"
)
async
def
log_websocket
(
ws
):
def
writelog
(
payload
,
origin
,
url
):
logging
.
info
(
origin
+
": "
+
str
(
payload
)
+
"
\n
"
)
log_entry
=
f
"[{datetime.now()}] WS: {origin} {url} -> Message: "
with
open
(
log_path
,
'a'
)
as
f
:
f
.
write
(
log_entry
)
f
.
write
(
origin
+
": "
)
f
.
write
(
str
(
payload
)
+
"
\n\n
"
)
logging
.
info
(
f
"WebSocket opened: {ws.url}"
)
ws
.
on
(
"framesent"
,
lambda
payload
:
writelog
(
payload
,
'framesent'
,
ws
.
url
))
ws
.
on
(
"framereceived"
,
lambda
payload
:
writelog
(
payload
,
'framereceived'
,
ws
.
url
))
ws
.
on
(
"close"
,
lambda
payload
:
print
(
"WebSocket closed"
))
page
.
on
(
"request"
,
log_request
)
page
.
on
(
"response"
,
log_response
)
page
.
on
(
"websocket"
,
log_websocket
)
while
True
:
await
asyncio
.
sleep
(
1
)
def
run_browser
():
lpath
=
str
(
Path
(
Path
.
home
(),
Path
(
'streamon.log'
)))
#parser = argparse.ArgumentParser(description="Monitor Streamate requests")
#parser.add_argument("--log_path", type=str, default=lpath,
# help="Path to the log file")
#parser.add_argument("--domain", type=str, default="performerclient.streamatemodels.com",
# help="URL to monitor")
#args = parser.parse_args()
logging
.
info
(
"Starting browser..."
)
log_dir
=
os
.
path
.
dirname
(
lpath
)
if
log_dir
and
not
os
.
path
.
exists
(
log_dir
):
os
.
makedirs
(
log_dir
)
asyncio
.
run
(
monitor_stream
(
lpath
,
"performerclient.streamatemodels.com"
))
shmcs/webpanel.py
View file @
a5d8432d
...
@@ -147,6 +147,9 @@ def stream():
...
@@ -147,6 +147,9 @@ def stream():
return
render_template
(
'stream.html'
,
stream_url
=
stream_url
)
return
render_template
(
'stream.html'
,
stream_url
=
stream_url
)
@
flask_app
.
route
(
"/chat"
)
def
chat
():
return
render_template
(
'chat.html'
)
@
socketio
.
event
@
socketio
.
event
def
my_event
(
message
):
def
my_event
(
message
):
...
...
templates/chat.html
0 → 100644
View file @
a5d8432d
<!DOCTYPE html>
<html
lang=
"en"
>
<head>
<meta
charset=
"UTF-8"
/>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1"
/>
<title>
Responsive Chat with Context Menus and Fixed Private Chat
</title>
<style>
@import
url('https://fonts.googleapis.com/css2?family=Poppins&display=swap')
;
/* Reset and base */
*
{
box-sizing
:
border-box
;
}
body
{
margin
:
0
;
font-family
:
'Poppins'
,
sans-serif
;
background
:
#121212
;
color
:
#e0e0e0
;
display
:
flex
;
min-height
:
100vh
;
user-select
:
none
;
-webkit-font-smoothing
:
antialiased
;
-moz-osx-font-smoothing
:
grayscale
;
}
/* Container Layout */
.container
{
display
:
grid
;
grid-template-columns
:
1
fr
280px
;
grid-template-rows
:
1
fr
;
gap
:
0
;
width
:
100%
;
height
:
100vh
;
background
:
#1f1f1f
;
overflow
:
hidden
;
}
/* User List Panel */
.user-list
{
background
:
#20232a
;
border-left
:
1px
solid
#2e2e3a
;
display
:
flex
;
flex-direction
:
column
;
user-select
:
none
;
min-width
:
280px
;
}
.user-list-header
{
padding
:
16px
;
font-weight
:
700
;
font-size
:
1.2rem
;
border-bottom
:
1px
solid
#2e2e3a
;
flex-shrink
:
0
;
}
.users
{
flex-grow
:
1
;
overflow-y
:
auto
;
padding
:
12px
8px
;
-webkit-overflow-scrolling
:
touch
;
}
.user-item
{
padding
:
10px
12px
;
margin-bottom
:
8px
;
border-radius
:
8px
;
cursor
:
pointer
;
display
:
flex
;
align-items
:
center
;
gap
:
12px
;
transition
:
background-color
0.3s
ease
;
user-select
:
none
;
}
.user-item
:hover
,
.user-item
:focus
{
background-color
:
#393d4d
;
outline
:
none
;
}
.user-avatar
{
flex-shrink
:
0
;
width
:
32px
;
height
:
32px
;
border-radius
:
50%
;
background
:
linear-gradient
(
135deg
,
#6c5ce7
,
#0984e3
);
color
:
white
;
font-weight
:
700
;
font-size
:
1rem
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
user-select
:
none
;
}
.user-name
{
flex-grow
:
1
;
font-size
:
0.95rem
;
color
:
#dcdde1
;
white-space
:
nowrap
;
overflow
:
hidden
;
text-overflow
:
ellipsis
;
user-select
:
text
;
}
.user-status
{
width
:
10px
;
height
:
10px
;
border-radius
:
50%
;
background-color
:
#44bd32
;
box-shadow
:
0
0
4px
#44bd32
aa
;
flex-shrink
:
0
;
}
/* Main Chat Panel */
.chat-panel
{
display
:
flex
;
flex-direction
:
column
;
background
:
#181a20
;
user-select
:
none
;
min-width
:
0
;
}
.chat-header
{
padding
:
16px
24px
;
border-bottom
:
1px
solid
#2e2e3a
;
font-weight
:
700
;
font-size
:
1.3rem
;
background
:
linear-gradient
(
135deg
,
#6c5ce7
,
#0984e3
);
color
:
white
;
user-select
:
none
;
flex-shrink
:
0
;
}
.chat-messages
{
flex-grow
:
1
;
overflow-y
:
auto
;
padding
:
20px
24px
;
scrollbar-width
:
thin
;
scrollbar-color
:
#394264
transparent
;
background
:
#121217
;
user-select
:
text
;
-webkit-overflow-scrolling
:
touch
;
min-height
:
0
;
}
.chat-messages
::-webkit-scrollbar
{
width
:
8px
;
}
.chat-messages
::-webkit-scrollbar-thumb
{
background-color
:
#394264
;
border-radius
:
4px
;
}
.message
{
max-width
:
60%
;
margin-bottom
:
16px
;
padding
:
12px
16px
;
border-radius
:
12px
;
line-height
:
1.4
;
font-size
:
0.95rem
;
word-wrap
:
break-word
;
box-shadow
:
0
2px
5px
rgb
(
0
0
0
/
0.3
);
user-select
:
text
;
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
}
.message.user
{
background
:
linear-gradient
(
135deg
,
#0984e3
,
#6c5ce7
);
color
:
white
;
align-self
:
flex-end
;
border-bottom-right-radius
:
2px
;
justify-content
:
flex-end
;
}
.message.other
{
background
:
#2d2f3a
;
color
:
#ddd
;
align-self
:
flex-start
;
border-bottom-left-radius
:
2px
;
}
.msg-username
{
font-weight
:
700
;
cursor
:
pointer
;
user-select
:
text
;
color
:
#a2d2ff
;
}
.chat-input-container
{
display
:
flex
;
padding
:
12px
24px
;
border-top
:
1px
solid
#2e2e3a
;
background
:
#20232a
;
align-items
:
center
;
user-select
:
none
;
flex-shrink
:
0
;
}
.chat-input
{
flex-grow
:
1
;
padding
:
10px
16px
;
font-size
:
1rem
;
border-radius
:
24px
;
border
:
none
;
background-color
:
#2d2f3a
;
color
:
#eee
;
outline
:
none
;
transition
:
background-color
0.2s
ease
;
}
.chat-input
::placeholder
{
color
:
#888
;
}
.chat-input
:focus
{
background-color
:
#424559
;
}
.send-btn
{
margin-left
:
12px
;
background
:
linear-gradient
(
135deg
,
#6c5ce7
,
#0984e3
);
border
:
none
;
padding
:
10px
18px
;
color
:
white
;
font-weight
:
700
;
font-size
:
1rem
;
border-radius
:
24px
;
cursor
:
pointer
;
user-select
:
none
;
transition
:
background-color
0.3s
ease
;
}
.send-btn
:hover
{
background
:
linear-gradient
(
135deg
,
#5353c3
,
#0761c7
);
}
/* Private Chat Popup */
.private-chat-popup
{
position
:
fixed
;
bottom
:
16px
;
right
:
16px
;
width
:
320px
;
max-height
:
480px
;
background
:
#292b37
;
border-radius
:
12px
;
box-shadow
:
0
8px
24px
rgba
(
0
,
0
,
0
,
0.7
);
display
:
flex
;
flex-direction
:
column
;
overflow
:
hidden
;
z-index
:
1000
;
font-size
:
0.9rem
;
animation
:
fadeInUp
0.3s
ease
forwards
;
user-select
:
none
;
resize
:
both
;
overflow
:
auto
;
}
.private-chat-header
{
background
:
linear-gradient
(
135deg
,
#6c5ce7
,
#0984e3
);
padding
:
12px
16px
;
font-weight
:
700
;
color
:
white
;
display
:
flex
;
justify-content
:
space-between
;
align-items
:
center
;
flex-shrink
:
0
;
cursor
:
move
;
}
.private-chat-close
{
cursor
:
pointer
;
font-weight
:
700
;
font-size
:
1.2rem
;
line-height
:
0
;
user-select
:
none
;
transition
:
color
0.3s
ease
;
background
:
none
;
border
:
none
;
color
:
white
;
}
.private-chat-close
:hover
{
color
:
#ffd32a
;
}
.private-chat-messages
{
flex-grow
:
1
;
padding
:
12px
16px
;
overflow-y
:
auto
;
background
:
#20212a
;
scrollbar-width
:
thin
;
scrollbar-color
:
#52526a
transparent
;
-webkit-overflow-scrolling
:
touch
;
user-select
:
text
;
min-height
:
120px
;
max-height
:
320px
;
}
.private-chat-messages
::-webkit-scrollbar
{
width
:
6px
;
}
.private-chat-messages
::-webkit-scrollbar-thumb
{
background-color
:
#52526a
;
border-radius
:
4px
;
}
.private-message
{
margin-bottom
:
12px
;
background
:
#3a3c4d
;
padding
:
10px
14px
;
border-radius
:
10px
;
color
:
#e4e6f0
;
max-width
:
90%
;
word-wrap
:
break-word
;
}
.private-message.user
{
background
:
linear-gradient
(
135deg
,
#0984e3
,
#6c5ce7
);
color
:
white
;
align-self
:
flex-end
;
border-bottom-right-radius
:
2px
;
}
.private-message.other
{
background
:
#3a3c4d
;
color
:
#ddd
;
align-self
:
flex-start
;
border-bottom-left-radius
:
2px
;
}
.private-chat-input-container
{
display
:
flex
;
padding
:
10px
14px
;
border-top
:
1px
solid
#44475a
;
background
:
#2b2e42
;
align-items
:
center
;
flex-shrink
:
0
;
}
.private-chat-input
{
flex-grow
:
1
;
padding
:
8px
14px
;
font-size
:
0.95rem
;
border-radius
:
18px
;
border
:
none
;
background-color
:
#3d3f56
;
color
:
#eee
;
outline
:
none
;
transition
:
background-color
0.2s
ease
;
}
.private-chat-input
::placeholder
{
color
:
#bbb
;
}
.private-chat-input
:focus
{
background-color
:
#56577b
;
}
.private-send-btn
{
margin-left
:
10px
;
background
:
linear-gradient
(
135deg
,
#6c5ce7
,
#0984e3
);
border
:
none
;
padding
:
8px
20px
;
color
:
white
;
font-weight
:
700
;
font-size
:
0.95rem
;
border-radius
:
18px
;
cursor
:
pointer
;
user-select
:
none
;
transition
:
background-color
0.3s
ease
;
}
.private-send-btn
:hover
{
background
:
linear-gradient
(
135deg
,
#5353c3
,
#0761c7
);
}
@keyframes
fadeInUp
{
0
%
{
opacity
:
0
;
transform
:
translateY
(
20px
);
}
100
%
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
}
/* Context Menu Styling */
.context-menu
{
position
:
absolute
;
background
:
#2a2c38
;
border-radius
:
8px
;
padding
:
8px
0
;
width
:
180px
;
box-shadow
:
0
6px
15px
rgba
(
0
,
0
,
0
,
0.4
);
z-index
:
2000
;
display
:
none
;
user-select
:
none
;
}
.context-menu.visible
{
display
:
block
;
}
.context-menu-item
{
padding
:
10px
16px
;
font-size
:
0.9rem
;
color
:
#e0e0e0
;
cursor
:
pointer
;
transition
:
background-color
0.25s
ease
;
}
.context-menu-item
:hover
,
.context-menu-item
:focus
{
background
:
#5353c3
;
color
:
white
;
outline
:
none
;
}
/* Responsive */
@media
(
max-width
:
768px
)
{
body
{
display
:
block
;
height
:
auto
;
}
.container
{
height
:
auto
;
display
:
flex
;
flex-direction
:
column
;
}
.chat-panel
{
order
:
2
;
height
:
70vh
;
min-width
:
100%
;
}
.user-list
{
order
:
1
;
height
:
30vh
;
min-width
:
100%
;
border-left
:
none
!important
;
border-top
:
1px
solid
#2e2e3a
;
}
.chat-messages
{
height
:
100%
;
min-height
:
0
;
}
.chat-input-container
{
padding
:
8px
16px
;
}
.chat-input
{
font-size
:
0.9rem
;
padding
:
8px
12px
;
}
.send-btn
{
padding
:
8px
12px
;
font-size
:
0.9rem
;
}
.user-list-header
,
.chat-header
{
padding
:
12px
16px
;
font-size
:
1.1rem
;
}
.user-item
{
padding
:
8px
10px
;
}
.user-avatar
{
width
:
28px
;
height
:
28px
;
font-size
:
0.9rem
;
}
.user-name
{
font-size
:
0.9rem
;
}
.private-chat-popup
{
width
:
90vw
;
max-height
:
50vh
;
bottom
:
8px
;
right
:
5vw
;
font-size
:
0.85rem
;
}
.private-chat-messages
{
max-height
:
200px
;
}
}
</style>
</head>
<body>
<div
class=
"container"
>
<!-- Main Chat -->
<section
class=
"chat-panel"
aria-label=
"Public chat messages"
>
<header
class=
"chat-header"
>
Global Chat
</header>
<div
class=
"chat-messages"
id=
"chatMessages"
role=
"log"
aria-live=
"polite"
aria-relevant=
"additions"
></div>
<form
id=
"chatForm"
class=
"chat-input-container"
aria-label=
"Send message"
>
<input
type=
"text"
id=
"chatInput"
class=
"chat-input"
placeholder=
"Enter message..."
autocomplete=
"off"
aria-required=
"true"
/>
<button
type=
"submit"
class=
"send-btn"
aria-label=
"Send message"
>
Send
</button>
</form>
</section>
<!-- User List -->
<aside
class=
"user-list"
aria-label=
"User list"
>
<div
class=
"user-list-header"
>
Online Users
</div>
<div
class=
"users"
id=
"userList"
role=
"list"
tabindex=
"0"
aria-live=
"polite"
></div>
</aside>
</div>
<!-- Private Chat Popup Template -->
<template
id=
"privateChatTemplate"
>
<section
class=
"private-chat-popup"
role=
"dialog"
aria-modal=
"true"
aria-label=
"Private chat with user"
>
<header
class=
"private-chat-header"
>
<span
class=
"private-chat-user"
></span>
<button
class=
"private-chat-close"
aria-label=
"Close private chat"
>
×
</button>
</header>
<div
class=
"private-chat-messages"
role=
"log"
aria-live=
"polite"
aria-relevant=
"additions"
></div>
<form
class=
"private-chat-input-container"
aria-label=
"Send private message"
>
<input
type=
"text"
class=
"private-chat-input"
placeholder=
"Enter private message..."
autocomplete=
"off"
aria-required=
"true"
/>
<button
type=
"submit"
class=
"private-send-btn"
aria-label=
"Send private message"
>
Send
</button>
</form>
</section>
</template>
<!-- Context Menu -->
<nav
id=
"contextMenu"
class=
"context-menu"
role=
"menu"
aria-hidden=
"true"
tabindex=
"-1"
aria-label=
"User actions"
>
<div
class=
"context-menu-item"
data-action=
"openProfile"
role=
"menuitem"
tabindex=
"0"
>
View Profile
</div>
<div
class=
"context-menu-item"
data-action=
"privateChat"
role=
"menuitem"
tabindex=
"0"
>
Open Private Chat
</div>
<div
class=
"context-menu-item"
data-action=
"ban"
role=
"menuitem"
tabindex=
"0"
>
Ban User
</div>
<div
class=
"context-menu-item"
data-action=
"kick"
role=
"menuitem"
tabindex=
"0"
>
Kick User
</div>
</nav>
<script>
const
users
=
[
{
id
:
'u1'
,
name
:
'Alice'
},
{
id
:
'u2'
,
name
:
'Bob'
},
{
id
:
'u3'
,
name
:
'Charlie'
},
{
id
:
'u4'
,
name
:
'David'
}
];
const
userListElem
=
document
.
getElementById
(
'userList'
);
const
chatMessagesElem
=
document
.
getElementById
(
'chatMessages'
);
const
chatForm
=
document
.
getElementById
(
'chatForm'
);
const
chatInput
=
document
.
getElementById
(
'chatInput'
);
const
privateChats
=
new
Map
();
const
contextMenu
=
document
.
getElementById
(
'contextMenu'
);
let
contextMenuTargetUser
=
null
;
let
contextMenuTargetType
=
null
;
function
renderUserList
()
{
userListElem
.
innerHTML
=
''
;
users
.
forEach
(
user
=>
{
const
userItem
=
document
.
createElement
(
'div'
);
userItem
.
className
=
'user-item'
;
userItem
.
setAttribute
(
'role'
,
'listitem'
);
userItem
.
tabIndex
=
0
;
userItem
.
dataset
.
userid
=
user
.
id
;
const
avatar
=
document
.
createElement
(
'div'
);
avatar
.
className
=
'user-avatar'
;
avatar
.
textContent
=
user
.
name
.
charAt
(
0
).
toUpperCase
();
const
name
=
document
.
createElement
(
'div'
);
name
.
className
=
'user-name'
;
name
.
textContent
=
user
.
name
;
const
status
=
document
.
createElement
(
'div'
);
status
.
className
=
'user-status'
;
status
.
title
=
'Online'
;
userItem
.
appendChild
(
avatar
);
userItem
.
appendChild
(
name
);
userItem
.
appendChild
(
status
);
userItem
.
addEventListener
(
'click'
,
()
=>
openPrivateChat
(
user
));
userItem
.
addEventListener
(
'keydown'
,
e
=>
{
if
(
e
.
key
===
'Enter'
||
e
.
key
===
' '
)
{
e
.
preventDefault
();
openPrivateChat
(
user
);
}
});
userItem
.
addEventListener
(
'contextmenu'
,
e
=>
{
e
.
preventDefault
();
showContextMenu
(
e
,
user
,
'userlist'
);
});
userListElem
.
appendChild
(
userItem
);
});
}
function
addPublicMessage
(
text
,
senderId
=
null
,
senderName
=
null
)
{
const
msg
=
document
.
createElement
(
'div'
);
msg
.
className
=
senderId
===
"me"
?
'message user'
:
'message other'
;
const
usernameSpan
=
document
.
createElement
(
'span'
);
usernameSpan
.
className
=
'msg-username'
;
if
(
!
senderName
)
senderName
=
senderId
?
(
users
.
find
(
u
=>
u
.
id
===
senderId
)?.
name
||
'Someone'
)
:
'Someone'
;
if
(
!
senderId
)
senderId
=
'someone'
;
usernameSpan
.
textContent
=
senderName
;
usernameSpan
.
tabIndex
=
0
;
usernameSpan
.
dataset
.
userid
=
senderId
;
// Important: Use mousedown event to open context menu instantly on right click on usernames in main chat
usernameSpan
.
addEventListener
(
'contextmenu'
,
e
=>
{
e
.
preventDefault
();
const
user
=
users
.
find
(
u
=>
u
.
id
===
senderId
)
||
{
id
:
'someone'
,
name
:
senderName
};
showContextMenu
(
e
,
user
,
'chat'
);
});
usernameSpan
.
addEventListener
(
'keydown'
,
(
e
)
=>
{
if
((
e
.
shiftKey
&&
e
.
key
===
"F10"
)
||
e
.
key
===
"ContextMenu"
)
{
e
.
preventDefault
();
const
user
=
users
.
find
(
u
=>
u
.
id
===
senderId
)
||
{
id
:
'someone'
,
name
:
senderName
};
showContextMenu
(
e
,
user
,
'chat'
);
}
});
msg
.
appendChild
(
usernameSpan
);
const
textNode
=
document
.
createTextNode
(
`:
${
text
}
`
);
msg
.
appendChild
(
textNode
);
chatMessagesElem
.
appendChild
(
msg
);
chatMessagesElem
.
scrollTop
=
chatMessagesElem
.
scrollHeight
;
}
chatForm
.
addEventListener
(
'submit'
,
e
=>
{
e
.
preventDefault
();
const
msg
=
chatInput
.
value
.
trim
();
if
(
!
msg
)
return
;
addPublicMessage
(
msg
,
"me"
,
"You"
);
chatInput
.
value
=
''
;
chatInput
.
focus
();
});
function
openPrivateChat
(
user
)
{
if
(
privateChats
.
has
(
user
.
id
))
{
const
chatPopup
=
privateChats
.
get
(
user
.
id
);
chatPopup
.
element
.
style
.
display
=
'flex'
;
chatPopup
.
input
.
focus
();
return
;
}
const
template
=
document
.
getElementById
(
'privateChatTemplate'
);
const
clone
=
template
.
content
.
cloneNode
(
true
);
const
popup
=
clone
.
querySelector
(
'.private-chat-popup'
);
const
headerUser
=
clone
.
querySelector
(
'.private-chat-user'
);
const
closeBtn
=
clone
.
querySelector
(
'.private-chat-close'
);
const
msgsElem
=
clone
.
querySelector
(
'.private-chat-messages'
);
const
form
=
clone
.
querySelector
(
'form'
);
const
input
=
clone
.
querySelector
(
'input'
);
headerUser
.
textContent
=
user
.
name
;
closeBtn
.
addEventListener
(
'click'
,
()
=>
{
popup
.
style
.
display
=
'none'
;
});
function
addPrivateMessage
(
text
,
sender
=
'other'
)
{
const
msg
=
document
.
createElement
(
'div'
);
msg
.
className
=
`private-message
${
sender
}
`
;
msg
.
textContent
=
text
;
msgsElem
.
appendChild
(
msg
);
msgsElem
.
scrollTop
=
msgsElem
.
scrollHeight
;
}
form
.
addEventListener
(
'submit'
,
e
=>
{
e
.
preventDefault
();
const
message
=
input
.
value
.
trim
();
if
(
!
message
)
return
;
addPrivateMessage
(
message
,
'user'
);
input
.
value
=
''
;
input
.
focus
();
});
popup
.
style
.
display
=
'flex'
;
document
.
body
.
appendChild
(
popup
);
privateChats
.
set
(
user
.
id
,
{
element
:
popup
,
input
:
input
,
addMessage
:
addPrivateMessage
});
input
.
focus
();
}
function
showContextMenu
(
event
,
user
,
type
)
{
contextMenuTargetUser
=
user
;
contextMenuTargetType
=
type
;
const
menuWidth
=
180
;
const
menuHeight
=
150
;
let
x
=
event
.
clientX
;
let
y
=
event
.
clientY
;
if
(
x
+
menuWidth
>
window
.
innerWidth
)
{
x
=
window
.
innerWidth
-
menuWidth
-
8
;
}
if
(
y
+
menuHeight
>
window
.
innerHeight
)
{
y
=
window
.
innerHeight
-
menuHeight
-
8
;
}
contextMenu
.
style
.
left
=
x
+
'px'
;
contextMenu
.
style
.
top
=
y
+
'px'
;
contextMenu
.
classList
.
add
(
'visible'
);
contextMenu
.
setAttribute
(
'aria-hidden'
,
'false'
);
const
firstItem
=
contextMenu
.
querySelector
(
'.context-menu-item'
);
if
(
firstItem
)
firstItem
.
focus
();
document
.
addEventListener
(
'click'
,
outsideClickHandler
);
document
.
addEventListener
(
'keydown'
,
keyboardHandler
);
}
function
hideContextMenu
()
{
contextMenu
.
classList
.
remove
(
'visible'
);
contextMenu
.
setAttribute
(
'aria-hidden'
,
'true'
);
contextMenuTargetUser
=
null
;
contextMenuTargetType
=
null
;
document
.
removeEventListener
(
'click'
,
outsideClickHandler
);
document
.
removeEventListener
(
'keydown'
,
keyboardHandler
);
}
function
outsideClickHandler
(
event
)
{
if
(
!
contextMenu
.
contains
(
event
.
target
))
{
hideContextMenu
();
}
}
function
keyboardHandler
(
event
)
{
if
(
!
contextMenu
.
classList
.
contains
(
'visible'
))
return
;
const
items
=
[...
contextMenu
.
querySelectorAll
(
'.context-menu-item'
)];
const
currentIndex
=
items
.
indexOf
(
document
.
activeElement
);
switch
(
event
.
key
)
{
case
'Escape'
:
hideContextMenu
();
break
;
case
'ArrowDown'
:
event
.
preventDefault
();
const
nextIndex
=
(
currentIndex
+
1
)
%
items
.
length
;
items
[
nextIndex
].
focus
();
break
;
case
'ArrowUp'
:
event
.
preventDefault
();
const
prevIndex
=
(
currentIndex
-
1
+
items
.
length
)
%
items
.
length
;
items
[
prevIndex
].
focus
();
break
;
case
'Enter'
:
event
.
preventDefault
();
if
(
document
.
activeElement
.
classList
.
contains
(
'context-menu-item'
))
{
document
.
activeElement
.
click
();
}
break
;
}
}
contextMenu
.
addEventListener
(
'click'
,
e
=>
{
if
(
!
e
.
target
.
classList
.
contains
(
'context-menu-item'
))
return
;
const
action
=
e
.
target
.
dataset
.
action
;
if
(
!
contextMenuTargetUser
)
return
;
switch
(
action
)
{
case
'openProfile'
:
alert
(
`Open profile for
${
contextMenuTargetUser
.
name
}
(ID:
${
contextMenuTargetUser
.
id
}
)`
);
break
;
case
'privateChat'
:
openPrivateChat
(
contextMenuTargetUser
);
break
;
case
'ban'
:
alert
(
`Ban user
${
contextMenuTargetUser
.
name
}
`
);
break
;
case
'kick'
:
alert
(
`Kick user
${
contextMenuTargetUser
.
name
}
`
);
break
;
default
:
break
;
}
hideContextMenu
();
});
renderUserList
();
addPublicMessage
(
'Welcome to the chat!'
,
'someone'
,
'Someone'
);
</script>
</body>
</html>
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment