diff --git a/data/logo.svg b/data/logo.svg new file mode 100644 index 0000000..ac3f9b7 --- /dev/null +++ b/data/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/logs/owncast.log b/data/logs/owncast.log new file mode 120000 index 0000000..6e917db --- /dev/null +++ b/data/logs/owncast.log @@ -0,0 +1 @@ +owncast.log.202209260000 \ No newline at end of file diff --git a/data/logs/owncast.log.202209260000 b/data/logs/owncast.log.202209260000 new file mode 100644 index 0000000..2b40533 --- /dev/null +++ b/data/logs/owncast.log.202209260000 @@ -0,0 +1,4 @@ +time="2022-09-30T18:42:45+02:00" level=info msg="Owncast v0.0.11-dev (20220930)" +time="2022-09-30T18:42:45+02:00" level=info msg="Video transcoder started using x264 with 1 stream variants." +time="2022-09-30T18:42:45+02:00" level=info msg="RTMP is accepting inbound streams on port 1935." +time="2022-09-30T18:42:45+02:00" level=fatal msg="listen tcp :1935: bind: address already in use" diff --git a/data/owncast.db b/data/owncast.db new file mode 100644 index 0000000..1060506 Binary files /dev/null and b/data/owncast.db differ diff --git a/data/owncast.db-shm b/data/owncast.db-shm new file mode 100644 index 0000000..1a70bd0 Binary files /dev/null and b/data/owncast.db-shm differ diff --git a/data/owncast.db-wal b/data/owncast.db-wal new file mode 100644 index 0000000..206d80c Binary files /dev/null and b/data/owncast.db-wal differ diff --git a/modules/base.nix b/modules/base.nix index 8bd2fdd..bd98178 100755 --- a/modules/base.nix +++ b/modules/base.nix @@ -50,12 +50,33 @@ bat git htop + fd ripgrep tldr tmux usbutils wget neovim + nmap + tcpdump + bat + dig + ethtool + iftop +ipcalc +iperf3 +ipv6calc +lsof +ltrace +strace +mtr +traceroute +smartmontools +sysstat +tree +whois + exa + zsh ]; } diff --git a/modules/stream.nix b/modules/stream.nix index 115dafc..37ff6cb 100644 --- a/modules/stream.nix +++ b/modules/stream.nix @@ -28,11 +28,20 @@ in { }; }; }; + streamConfig = '' + server { + listen 1935; + proxy_pass [::1]:1935; + proxy_buffer_size 32k; + } + ''; }; owncast = { enable = true; port = 13142; + listen = "[::ffff:127.0.0.1]"; openFirewall = true; + rtmp-port = 1935; }; }; } diff --git a/static b/static new file mode 120000 index 0000000..cf37ad5 --- /dev/null +++ b/static @@ -0,0 +1 @@ +/nix/store/92kcxi4k317hjpk047l2zw46hxn0vvk0-owncast-0.0.11/static \ No newline at end of file diff --git a/webroot/favicon.ico b/webroot/favicon.ico new file mode 100644 index 0000000..c6e2f9f Binary files /dev/null and b/webroot/favicon.ico differ diff --git a/webroot/img/airplay.png b/webroot/img/airplay.png new file mode 100644 index 0000000..6529f69 Binary files /dev/null and b/webroot/img/airplay.png differ diff --git a/webroot/img/ban-user-grey.svg b/webroot/img/ban-user-grey.svg new file mode 100644 index 0000000..6cdca98 --- /dev/null +++ b/webroot/img/ban-user-grey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/ban-user.svg b/webroot/img/ban-user.svg new file mode 100644 index 0000000..dd5079d --- /dev/null +++ b/webroot/img/ban-user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/emoji/Reaper-gg.png b/webroot/img/emoji/Reaper-gg.png new file mode 100644 index 0000000..de6d571 Binary files /dev/null and b/webroot/img/emoji/Reaper-gg.png differ diff --git a/webroot/img/emoji/Reaper-hi.png b/webroot/img/emoji/Reaper-hi.png new file mode 100644 index 0000000..dc6b6ae Binary files /dev/null and b/webroot/img/emoji/Reaper-hi.png differ diff --git a/webroot/img/emoji/Reaper-hype.png b/webroot/img/emoji/Reaper-hype.png new file mode 100644 index 0000000..2138dba Binary files /dev/null and b/webroot/img/emoji/Reaper-hype.png differ diff --git a/webroot/img/emoji/Reaper-lol.png b/webroot/img/emoji/Reaper-lol.png new file mode 100644 index 0000000..8c4eff2 Binary files /dev/null and b/webroot/img/emoji/Reaper-lol.png differ diff --git a/webroot/img/emoji/Reaper-love.png b/webroot/img/emoji/Reaper-love.png new file mode 100644 index 0000000..5e0f3ba Binary files /dev/null and b/webroot/img/emoji/Reaper-love.png differ diff --git a/webroot/img/emoji/Reaper-rage.png b/webroot/img/emoji/Reaper-rage.png new file mode 100644 index 0000000..3da0a5d Binary files /dev/null and b/webroot/img/emoji/Reaper-rage.png differ diff --git a/webroot/img/emoji/Reaper-rip.png b/webroot/img/emoji/Reaper-rip.png new file mode 100644 index 0000000..89c13c7 Binary files /dev/null and b/webroot/img/emoji/Reaper-rip.png differ diff --git a/webroot/img/emoji/Reaper-wtf.png b/webroot/img/emoji/Reaper-wtf.png new file mode 100644 index 0000000..e21adb6 Binary files /dev/null and b/webroot/img/emoji/Reaper-wtf.png differ diff --git a/webroot/img/emoji/ac-box.png b/webroot/img/emoji/ac-box.png new file mode 100644 index 0000000..18bd9b3 Binary files /dev/null and b/webroot/img/emoji/ac-box.png differ diff --git a/webroot/img/emoji/ac-construction.png b/webroot/img/emoji/ac-construction.png new file mode 100644 index 0000000..ee2b6f3 Binary files /dev/null and b/webroot/img/emoji/ac-construction.png differ diff --git a/webroot/img/emoji/ac-fossil.png b/webroot/img/emoji/ac-fossil.png new file mode 100644 index 0000000..16d4fad Binary files /dev/null and b/webroot/img/emoji/ac-fossil.png differ diff --git a/webroot/img/emoji/ac-item-leaf.png b/webroot/img/emoji/ac-item-leaf.png new file mode 100644 index 0000000..6abede7 Binary files /dev/null and b/webroot/img/emoji/ac-item-leaf.png differ diff --git a/webroot/img/emoji/ac-kkslider.png b/webroot/img/emoji/ac-kkslider.png new file mode 100644 index 0000000..309d768 Binary files /dev/null and b/webroot/img/emoji/ac-kkslider.png differ diff --git a/webroot/img/emoji/ac-moneytree.png b/webroot/img/emoji/ac-moneytree.png new file mode 100644 index 0000000..3e370ac Binary files /dev/null and b/webroot/img/emoji/ac-moneytree.png differ diff --git a/webroot/img/emoji/ac-mosquito.png b/webroot/img/emoji/ac-mosquito.png new file mode 100644 index 0000000..50ddb41 Binary files /dev/null and b/webroot/img/emoji/ac-mosquito.png differ diff --git a/webroot/img/emoji/ac-shirt.png b/webroot/img/emoji/ac-shirt.png new file mode 100644 index 0000000..41ab1d8 Binary files /dev/null and b/webroot/img/emoji/ac-shirt.png differ diff --git a/webroot/img/emoji/ac-song.png b/webroot/img/emoji/ac-song.png new file mode 100644 index 0000000..dfe9d84 Binary files /dev/null and b/webroot/img/emoji/ac-song.png differ diff --git a/webroot/img/emoji/ac-tree.png b/webroot/img/emoji/ac-tree.png new file mode 100644 index 0000000..499ee4e Binary files /dev/null and b/webroot/img/emoji/ac-tree.png differ diff --git a/webroot/img/emoji/ac-turnip.png b/webroot/img/emoji/ac-turnip.png new file mode 100644 index 0000000..893ef9c Binary files /dev/null and b/webroot/img/emoji/ac-turnip.png differ diff --git a/webroot/img/emoji/ac-weeds.png b/webroot/img/emoji/ac-weeds.png new file mode 100644 index 0000000..6d3b237 Binary files /dev/null and b/webroot/img/emoji/ac-weeds.png differ diff --git a/webroot/img/emoji/alert.gif b/webroot/img/emoji/alert.gif new file mode 100644 index 0000000..d99ac1b Binary files /dev/null and b/webroot/img/emoji/alert.gif differ diff --git a/webroot/img/emoji/bananadance.gif b/webroot/img/emoji/bananadance.gif new file mode 100644 index 0000000..da6f470 Binary files /dev/null and b/webroot/img/emoji/bananadance.gif differ diff --git a/webroot/img/emoji/bb8.png b/webroot/img/emoji/bb8.png new file mode 100644 index 0000000..e97e3f3 Binary files /dev/null and b/webroot/img/emoji/bb8.png differ diff --git a/webroot/img/emoji/beerparrot.gif b/webroot/img/emoji/beerparrot.gif new file mode 100644 index 0000000..2cd83ee Binary files /dev/null and b/webroot/img/emoji/beerparrot.gif differ diff --git a/webroot/img/emoji/bells.png b/webroot/img/emoji/bells.png new file mode 100644 index 0000000..1a20ab0 Binary files /dev/null and b/webroot/img/emoji/bells.png differ diff --git a/webroot/img/emoji/birthdaypartyparrot.gif b/webroot/img/emoji/birthdaypartyparrot.gif new file mode 100644 index 0000000..4c166ea Binary files /dev/null and b/webroot/img/emoji/birthdaypartyparrot.gif differ diff --git a/webroot/img/emoji/blacklightsaber.png b/webroot/img/emoji/blacklightsaber.png new file mode 100644 index 0000000..1a53796 Binary files /dev/null and b/webroot/img/emoji/blacklightsaber.png differ diff --git a/webroot/img/emoji/bluelightsaber.png b/webroot/img/emoji/bluelightsaber.png new file mode 100644 index 0000000..b509c73 Binary files /dev/null and b/webroot/img/emoji/bluelightsaber.png differ diff --git a/webroot/img/emoji/bluntparrot.gif b/webroot/img/emoji/bluntparrot.gif new file mode 100644 index 0000000..6d33f7d Binary files /dev/null and b/webroot/img/emoji/bluntparrot.gif differ diff --git a/webroot/img/emoji/bobaparrot.gif b/webroot/img/emoji/bobaparrot.gif new file mode 100644 index 0000000..17ecdfa Binary files /dev/null and b/webroot/img/emoji/bobaparrot.gif differ diff --git a/webroot/img/emoji/cakeparrot.gif b/webroot/img/emoji/cakeparrot.gif new file mode 100644 index 0000000..8ddb47f Binary files /dev/null and b/webroot/img/emoji/cakeparrot.gif differ diff --git a/webroot/img/emoji/chewbacca.png b/webroot/img/emoji/chewbacca.png new file mode 100644 index 0000000..4cae67b Binary files /dev/null and b/webroot/img/emoji/chewbacca.png differ diff --git a/webroot/img/emoji/chillparrot.gif b/webroot/img/emoji/chillparrot.gif new file mode 100644 index 0000000..0d7a560 Binary files /dev/null and b/webroot/img/emoji/chillparrot.gif differ diff --git a/webroot/img/emoji/christmasparrot.gif b/webroot/img/emoji/christmasparrot.gif new file mode 100644 index 0000000..417c3e0 Binary files /dev/null and b/webroot/img/emoji/christmasparrot.gif differ diff --git a/webroot/img/emoji/coffeeparrot.gif b/webroot/img/emoji/coffeeparrot.gif new file mode 100644 index 0000000..3a935d2 Binary files /dev/null and b/webroot/img/emoji/coffeeparrot.gif differ diff --git a/webroot/img/emoji/confusedparrot.gif b/webroot/img/emoji/confusedparrot.gif new file mode 100644 index 0000000..e641f43 Binary files /dev/null and b/webroot/img/emoji/confusedparrot.gif differ diff --git a/webroot/img/emoji/copparrot.gif b/webroot/img/emoji/copparrot.gif new file mode 100644 index 0000000..b41a474 Binary files /dev/null and b/webroot/img/emoji/copparrot.gif differ diff --git a/webroot/img/emoji/coronavirus.png b/webroot/img/emoji/coronavirus.png new file mode 100644 index 0000000..6c65664 Binary files /dev/null and b/webroot/img/emoji/coronavirus.png differ diff --git a/webroot/img/emoji/covid19parrot.gif b/webroot/img/emoji/covid19parrot.gif new file mode 100644 index 0000000..70beb62 Binary files /dev/null and b/webroot/img/emoji/covid19parrot.gif differ diff --git a/webroot/img/emoji/cryptoparrot.gif b/webroot/img/emoji/cryptoparrot.gif new file mode 100644 index 0000000..0d5a8c9 Binary files /dev/null and b/webroot/img/emoji/cryptoparrot.gif differ diff --git a/webroot/img/emoji/dabparrot.gif b/webroot/img/emoji/dabparrot.gif new file mode 100644 index 0000000..5f95dd4 Binary files /dev/null and b/webroot/img/emoji/dabparrot.gif differ diff --git a/webroot/img/emoji/dadparrot.gif b/webroot/img/emoji/dadparrot.gif new file mode 100644 index 0000000..5e025dd Binary files /dev/null and b/webroot/img/emoji/dadparrot.gif differ diff --git a/webroot/img/emoji/daftpunkparrot.gif b/webroot/img/emoji/daftpunkparrot.gif new file mode 100644 index 0000000..be7a1cf Binary files /dev/null and b/webroot/img/emoji/daftpunkparrot.gif differ diff --git a/webroot/img/emoji/darkbeerparrot.gif b/webroot/img/emoji/darkbeerparrot.gif new file mode 100644 index 0000000..c37edb5 Binary files /dev/null and b/webroot/img/emoji/darkbeerparrot.gif differ diff --git a/webroot/img/emoji/darkmodeparrot.gif b/webroot/img/emoji/darkmodeparrot.gif new file mode 100644 index 0000000..b9ed205 Binary files /dev/null and b/webroot/img/emoji/darkmodeparrot.gif differ diff --git a/webroot/img/emoji/darth_vader.png b/webroot/img/emoji/darth_vader.png new file mode 100644 index 0000000..44f43d0 Binary files /dev/null and b/webroot/img/emoji/darth_vader.png differ diff --git a/webroot/img/emoji/dealwithitparrot.gif b/webroot/img/emoji/dealwithitparrot.gif new file mode 100644 index 0000000..f501e5e Binary files /dev/null and b/webroot/img/emoji/dealwithitparrot.gif differ diff --git a/webroot/img/emoji/death_star.png b/webroot/img/emoji/death_star.png new file mode 100644 index 0000000..003a28a Binary files /dev/null and b/webroot/img/emoji/death_star.png differ diff --git a/webroot/img/emoji/discoparrot.gif b/webroot/img/emoji/discoparrot.gif new file mode 100644 index 0000000..47a7548 Binary files /dev/null and b/webroot/img/emoji/discoparrot.gif differ diff --git a/webroot/img/emoji/division-gg.png b/webroot/img/emoji/division-gg.png new file mode 100644 index 0000000..d704b46 Binary files /dev/null and b/webroot/img/emoji/division-gg.png differ diff --git a/webroot/img/emoji/division-hi.png b/webroot/img/emoji/division-hi.png new file mode 100644 index 0000000..437b368 Binary files /dev/null and b/webroot/img/emoji/division-hi.png differ diff --git a/webroot/img/emoji/division-hype.png b/webroot/img/emoji/division-hype.png new file mode 100644 index 0000000..b6260bc Binary files /dev/null and b/webroot/img/emoji/division-hype.png differ diff --git a/webroot/img/emoji/division-lol.png b/webroot/img/emoji/division-lol.png new file mode 100644 index 0000000..d085493 Binary files /dev/null and b/webroot/img/emoji/division-lol.png differ diff --git a/webroot/img/emoji/division-omg.png b/webroot/img/emoji/division-omg.png new file mode 100644 index 0000000..b1100cf Binary files /dev/null and b/webroot/img/emoji/division-omg.png differ diff --git a/webroot/img/emoji/division-rage.png b/webroot/img/emoji/division-rage.png new file mode 100644 index 0000000..55d13a3 Binary files /dev/null and b/webroot/img/emoji/division-rage.png differ diff --git a/webroot/img/emoji/division-rip.png b/webroot/img/emoji/division-rip.png new file mode 100644 index 0000000..f57c78b Binary files /dev/null and b/webroot/img/emoji/division-rip.png differ diff --git a/webroot/img/emoji/division-wtf.png b/webroot/img/emoji/division-wtf.png new file mode 100644 index 0000000..654cde9 Binary files /dev/null and b/webroot/img/emoji/division-wtf.png differ diff --git a/webroot/img/emoji/docparrot.gif b/webroot/img/emoji/docparrot.gif new file mode 100644 index 0000000..2fb502d Binary files /dev/null and b/webroot/img/emoji/docparrot.gif differ diff --git a/webroot/img/emoji/donutparrot.gif b/webroot/img/emoji/donutparrot.gif new file mode 100644 index 0000000..9137e90 Binary files /dev/null and b/webroot/img/emoji/donutparrot.gif differ diff --git a/webroot/img/emoji/doom_mad.gif b/webroot/img/emoji/doom_mad.gif new file mode 100644 index 0000000..1f124b8 Binary files /dev/null and b/webroot/img/emoji/doom_mad.gif differ diff --git a/webroot/img/emoji/empire.png b/webroot/img/emoji/empire.png new file mode 100644 index 0000000..da2fb8c Binary files /dev/null and b/webroot/img/emoji/empire.png differ diff --git a/webroot/img/emoji/everythingsfineparrot.gif b/webroot/img/emoji/everythingsfineparrot.gif new file mode 100644 index 0000000..5680c32 Binary files /dev/null and b/webroot/img/emoji/everythingsfineparrot.gif differ diff --git a/webroot/img/emoji/evilparrot.gif b/webroot/img/emoji/evilparrot.gif new file mode 100644 index 0000000..edb93c0 Binary files /dev/null and b/webroot/img/emoji/evilparrot.gif differ diff --git a/webroot/img/emoji/explodyparrot.gif b/webroot/img/emoji/explodyparrot.gif new file mode 100644 index 0000000..387005d Binary files /dev/null and b/webroot/img/emoji/explodyparrot.gif differ diff --git a/webroot/img/emoji/fixparrot.gif b/webroot/img/emoji/fixparrot.gif new file mode 100644 index 0000000..3e91ba7 Binary files /dev/null and b/webroot/img/emoji/fixparrot.gif differ diff --git a/webroot/img/emoji/flyingmoneyparrot.gif b/webroot/img/emoji/flyingmoneyparrot.gif new file mode 100644 index 0000000..abf389d Binary files /dev/null and b/webroot/img/emoji/flyingmoneyparrot.gif differ diff --git a/webroot/img/emoji/footballparrot.gif b/webroot/img/emoji/footballparrot.gif new file mode 100644 index 0000000..49472b7 Binary files /dev/null and b/webroot/img/emoji/footballparrot.gif differ diff --git a/webroot/img/emoji/gabe1.png b/webroot/img/emoji/gabe1.png new file mode 100644 index 0000000..89b8c4e Binary files /dev/null and b/webroot/img/emoji/gabe1.png differ diff --git a/webroot/img/emoji/gabe2.png b/webroot/img/emoji/gabe2.png new file mode 100644 index 0000000..987d91c Binary files /dev/null and b/webroot/img/emoji/gabe2.png differ diff --git a/webroot/img/emoji/gentlemanparrot.gif b/webroot/img/emoji/gentlemanparrot.gif new file mode 100644 index 0000000..8e8f05b Binary files /dev/null and b/webroot/img/emoji/gentlemanparrot.gif differ diff --git a/webroot/img/emoji/githubparrot.gif b/webroot/img/emoji/githubparrot.gif new file mode 100644 index 0000000..0fa6ce5 Binary files /dev/null and b/webroot/img/emoji/githubparrot.gif differ diff --git a/webroot/img/emoji/goomba.gif b/webroot/img/emoji/goomba.gif new file mode 100644 index 0000000..c6bb719 Binary files /dev/null and b/webroot/img/emoji/goomba.gif differ diff --git a/webroot/img/emoji/gothparrot.gif b/webroot/img/emoji/gothparrot.gif new file mode 100644 index 0000000..361b68e Binary files /dev/null and b/webroot/img/emoji/gothparrot.gif differ diff --git a/webroot/img/emoji/hamburgerparrot.gif b/webroot/img/emoji/hamburgerparrot.gif new file mode 100644 index 0000000..192b0ff Binary files /dev/null and b/webroot/img/emoji/hamburgerparrot.gif differ diff --git a/webroot/img/emoji/harrypotterparrot.gif b/webroot/img/emoji/harrypotterparrot.gif new file mode 100644 index 0000000..032a37e Binary files /dev/null and b/webroot/img/emoji/harrypotterparrot.gif differ diff --git a/webroot/img/emoji/headbangingparrot.gif b/webroot/img/emoji/headbangingparrot.gif new file mode 100644 index 0000000..9aad2ec Binary files /dev/null and b/webroot/img/emoji/headbangingparrot.gif differ diff --git a/webroot/img/emoji/headingparrot.gif b/webroot/img/emoji/headingparrot.gif new file mode 100644 index 0000000..b17002c Binary files /dev/null and b/webroot/img/emoji/headingparrot.gif differ diff --git a/webroot/img/emoji/headsetparrot.gif b/webroot/img/emoji/headsetparrot.gif new file mode 100644 index 0000000..516a04f Binary files /dev/null and b/webroot/img/emoji/headsetparrot.gif differ diff --git a/webroot/img/emoji/hmmparrot.gif b/webroot/img/emoji/hmmparrot.gif new file mode 100644 index 0000000..223a6c2 Binary files /dev/null and b/webroot/img/emoji/hmmparrot.gif differ diff --git a/webroot/img/emoji/hypnoparrot.gif b/webroot/img/emoji/hypnoparrot.gif new file mode 100644 index 0000000..a6a0983 Binary files /dev/null and b/webroot/img/emoji/hypnoparrot.gif differ diff --git a/webroot/img/emoji/icecreamparrot.gif b/webroot/img/emoji/icecreamparrot.gif new file mode 100644 index 0000000..0a5093f Binary files /dev/null and b/webroot/img/emoji/icecreamparrot.gif differ diff --git a/webroot/img/emoji/illuminatiparrot.gif b/webroot/img/emoji/illuminatiparrot.gif new file mode 100644 index 0000000..a0c4e79 Binary files /dev/null and b/webroot/img/emoji/illuminatiparrot.gif differ diff --git a/webroot/img/emoji/jediparrot.gif b/webroot/img/emoji/jediparrot.gif new file mode 100644 index 0000000..690b41d Binary files /dev/null and b/webroot/img/emoji/jediparrot.gif differ diff --git a/webroot/img/emoji/keanu_thanks.gif b/webroot/img/emoji/keanu_thanks.gif new file mode 100644 index 0000000..989b9b7 Binary files /dev/null and b/webroot/img/emoji/keanu_thanks.gif differ diff --git a/webroot/img/emoji/laptop_parrot.gif b/webroot/img/emoji/laptop_parrot.gif new file mode 100644 index 0000000..b14bb18 Binary files /dev/null and b/webroot/img/emoji/laptop_parrot.gif differ diff --git a/webroot/img/emoji/loveparrot.gif b/webroot/img/emoji/loveparrot.gif new file mode 100644 index 0000000..c0d14ed Binary files /dev/null and b/webroot/img/emoji/loveparrot.gif differ diff --git a/webroot/img/emoji/mandalorian.png b/webroot/img/emoji/mandalorian.png new file mode 100644 index 0000000..7db4a3b Binary files /dev/null and b/webroot/img/emoji/mandalorian.png differ diff --git a/webroot/img/emoji/margaritaparrot.gif b/webroot/img/emoji/margaritaparrot.gif new file mode 100644 index 0000000..10a8c6a Binary files /dev/null and b/webroot/img/emoji/margaritaparrot.gif differ diff --git a/webroot/img/emoji/mario.gif b/webroot/img/emoji/mario.gif new file mode 100644 index 0000000..87f1133 Binary files /dev/null and b/webroot/img/emoji/mario.gif differ diff --git a/webroot/img/emoji/matrixparrot.gif b/webroot/img/emoji/matrixparrot.gif new file mode 100644 index 0000000..eaf4404 Binary files /dev/null and b/webroot/img/emoji/matrixparrot.gif differ diff --git a/webroot/img/emoji/meldparrot.gif b/webroot/img/emoji/meldparrot.gif new file mode 100644 index 0000000..ae46053 Binary files /dev/null and b/webroot/img/emoji/meldparrot.gif differ diff --git a/webroot/img/emoji/metalparrot.gif b/webroot/img/emoji/metalparrot.gif new file mode 100644 index 0000000..f358b2f Binary files /dev/null and b/webroot/img/emoji/metalparrot.gif differ diff --git a/webroot/img/emoji/michaeljacksonparrot.gif b/webroot/img/emoji/michaeljacksonparrot.gif new file mode 100644 index 0000000..cfa8405 Binary files /dev/null and b/webroot/img/emoji/michaeljacksonparrot.gif differ diff --git a/webroot/img/emoji/moonparrot.gif b/webroot/img/emoji/moonparrot.gif new file mode 100644 index 0000000..098938a Binary files /dev/null and b/webroot/img/emoji/moonparrot.gif differ diff --git a/webroot/img/emoji/moonwalkingparrot.gif b/webroot/img/emoji/moonwalkingparrot.gif new file mode 100644 index 0000000..873f828 Binary files /dev/null and b/webroot/img/emoji/moonwalkingparrot.gif differ diff --git a/webroot/img/emoji/mustacheparrot.gif b/webroot/img/emoji/mustacheparrot.gif new file mode 100644 index 0000000..e71fe28 Binary files /dev/null and b/webroot/img/emoji/mustacheparrot.gif differ diff --git a/webroot/img/emoji/nicolas_cage_party.gif b/webroot/img/emoji/nicolas_cage_party.gif new file mode 100644 index 0000000..5414317 Binary files /dev/null and b/webroot/img/emoji/nicolas_cage_party.gif differ diff --git a/webroot/img/emoji/nodeparrot.gif b/webroot/img/emoji/nodeparrot.gif new file mode 100644 index 0000000..6735b0b Binary files /dev/null and b/webroot/img/emoji/nodeparrot.gif differ diff --git a/webroot/img/emoji/norwegianblueparrot.gif b/webroot/img/emoji/norwegianblueparrot.gif new file mode 100644 index 0000000..0aa9583 Binary files /dev/null and b/webroot/img/emoji/norwegianblueparrot.gif differ diff --git a/webroot/img/emoji/opensourceparrot.gif b/webroot/img/emoji/opensourceparrot.gif new file mode 100644 index 0000000..7067743 Binary files /dev/null and b/webroot/img/emoji/opensourceparrot.gif differ diff --git a/webroot/img/emoji/originalparrot.gif b/webroot/img/emoji/originalparrot.gif new file mode 100644 index 0000000..428cc22 Binary files /dev/null and b/webroot/img/emoji/originalparrot.gif differ diff --git a/webroot/img/emoji/owncast.png b/webroot/img/emoji/owncast.png new file mode 100644 index 0000000..6e7fdc9 Binary files /dev/null and b/webroot/img/emoji/owncast.png differ diff --git a/webroot/img/emoji/palpatine.png b/webroot/img/emoji/palpatine.png new file mode 100644 index 0000000..ca10fe6 Binary files /dev/null and b/webroot/img/emoji/palpatine.png differ diff --git a/webroot/img/emoji/papalparrot.gif b/webroot/img/emoji/papalparrot.gif new file mode 100644 index 0000000..d299237 Binary files /dev/null and b/webroot/img/emoji/papalparrot.gif differ diff --git a/webroot/img/emoji/parrot.gif b/webroot/img/emoji/parrot.gif new file mode 100644 index 0000000..b8c261a Binary files /dev/null and b/webroot/img/emoji/parrot.gif differ diff --git a/webroot/img/emoji/parrotnotfound.gif b/webroot/img/emoji/parrotnotfound.gif new file mode 100644 index 0000000..a039df9 Binary files /dev/null and b/webroot/img/emoji/parrotnotfound.gif differ diff --git a/webroot/img/emoji/partyparrot.gif b/webroot/img/emoji/partyparrot.gif new file mode 100644 index 0000000..b88ecc4 Binary files /dev/null and b/webroot/img/emoji/partyparrot.gif differ diff --git a/webroot/img/emoji/phparrot.gif b/webroot/img/emoji/phparrot.gif new file mode 100644 index 0000000..95e33b4 Binary files /dev/null and b/webroot/img/emoji/phparrot.gif differ diff --git a/webroot/img/emoji/pirateparrot.gif b/webroot/img/emoji/pirateparrot.gif new file mode 100644 index 0000000..028848a Binary files /dev/null and b/webroot/img/emoji/pirateparrot.gif differ diff --git a/webroot/img/emoji/pizzaparrot.gif b/webroot/img/emoji/pizzaparrot.gif new file mode 100644 index 0000000..56d9dfc Binary files /dev/null and b/webroot/img/emoji/pizzaparrot.gif differ diff --git a/webroot/img/emoji/pokeparrot.gif b/webroot/img/emoji/pokeparrot.gif new file mode 100644 index 0000000..a9adc86 Binary files /dev/null and b/webroot/img/emoji/pokeparrot.gif differ diff --git a/webroot/img/emoji/popcornparrot.gif b/webroot/img/emoji/popcornparrot.gif new file mode 100644 index 0000000..65b8585 Binary files /dev/null and b/webroot/img/emoji/popcornparrot.gif differ diff --git a/webroot/img/emoji/porg.png b/webroot/img/emoji/porg.png new file mode 100644 index 0000000..68d08bf Binary files /dev/null and b/webroot/img/emoji/porg.png differ diff --git a/webroot/img/emoji/portalparrot.gif b/webroot/img/emoji/portalparrot.gif new file mode 100644 index 0000000..5971fbd Binary files /dev/null and b/webroot/img/emoji/portalparrot.gif differ diff --git a/webroot/img/emoji/pumpkinparrot.gif b/webroot/img/emoji/pumpkinparrot.gif new file mode 100644 index 0000000..f453ce2 Binary files /dev/null and b/webroot/img/emoji/pumpkinparrot.gif differ diff --git a/webroot/img/emoji/quadparrot.gif b/webroot/img/emoji/quadparrot.gif new file mode 100644 index 0000000..9f1e319 Binary files /dev/null and b/webroot/img/emoji/quadparrot.gif differ diff --git a/webroot/img/emoji/r2d2.png b/webroot/img/emoji/r2d2.png new file mode 100644 index 0000000..0a7fa09 Binary files /dev/null and b/webroot/img/emoji/r2d2.png differ diff --git a/webroot/img/emoji/redenvelopeparrot.gif b/webroot/img/emoji/redenvelopeparrot.gif new file mode 100644 index 0000000..b40c76c Binary files /dev/null and b/webroot/img/emoji/redenvelopeparrot.gif differ diff --git a/webroot/img/emoji/ripparrot.gif b/webroot/img/emoji/ripparrot.gif new file mode 100644 index 0000000..164250e Binary files /dev/null and b/webroot/img/emoji/ripparrot.gif differ diff --git a/webroot/img/emoji/rotatingparrot.gif b/webroot/img/emoji/rotatingparrot.gif new file mode 100644 index 0000000..1916cd0 Binary files /dev/null and b/webroot/img/emoji/rotatingparrot.gif differ diff --git a/webroot/img/emoji/ryangoslingparrot.gif b/webroot/img/emoji/ryangoslingparrot.gif new file mode 100644 index 0000000..e241578 Binary files /dev/null and b/webroot/img/emoji/ryangoslingparrot.gif differ diff --git a/webroot/img/emoji/rythmicalparrot.gif b/webroot/img/emoji/rythmicalparrot.gif new file mode 100644 index 0000000..db3e52d Binary files /dev/null and b/webroot/img/emoji/rythmicalparrot.gif differ diff --git a/webroot/img/emoji/sadparrot.gif b/webroot/img/emoji/sadparrot.gif new file mode 100644 index 0000000..25b500d Binary files /dev/null and b/webroot/img/emoji/sadparrot.gif differ diff --git a/webroot/img/emoji/schnitzelparrot.gif b/webroot/img/emoji/schnitzelparrot.gif new file mode 100644 index 0000000..00d22d7 Binary files /dev/null and b/webroot/img/emoji/schnitzelparrot.gif differ diff --git a/webroot/img/emoji/scienceparrot.gif b/webroot/img/emoji/scienceparrot.gif new file mode 100644 index 0000000..b5e4342 Binary files /dev/null and b/webroot/img/emoji/scienceparrot.gif differ diff --git a/webroot/img/emoji/shipitparrot.gif b/webroot/img/emoji/shipitparrot.gif new file mode 100644 index 0000000..8d1ed30 Binary files /dev/null and b/webroot/img/emoji/shipitparrot.gif differ diff --git a/webroot/img/emoji/shufflepartyparrot.gif b/webroot/img/emoji/shufflepartyparrot.gif new file mode 100644 index 0000000..7e754cb Binary files /dev/null and b/webroot/img/emoji/shufflepartyparrot.gif differ diff --git a/webroot/img/emoji/sintparrot.gif b/webroot/img/emoji/sintparrot.gif new file mode 100644 index 0000000..26f083b Binary files /dev/null and b/webroot/img/emoji/sintparrot.gif differ diff --git a/webroot/img/emoji/sithparrot.gif b/webroot/img/emoji/sithparrot.gif new file mode 100644 index 0000000..df8f66e Binary files /dev/null and b/webroot/img/emoji/sithparrot.gif differ diff --git a/webroot/img/emoji/skiparrot.gif b/webroot/img/emoji/skiparrot.gif new file mode 100644 index 0000000..7cfa3c0 Binary files /dev/null and b/webroot/img/emoji/skiparrot.gif differ diff --git a/webroot/img/emoji/sleepingparrot.gif b/webroot/img/emoji/sleepingparrot.gif new file mode 100644 index 0000000..6f0b6dd Binary files /dev/null and b/webroot/img/emoji/sleepingparrot.gif differ diff --git a/webroot/img/emoji/sonic.gif b/webroot/img/emoji/sonic.gif new file mode 100644 index 0000000..e9a9f4b Binary files /dev/null and b/webroot/img/emoji/sonic.gif differ diff --git a/webroot/img/emoji/spyparrot.gif b/webroot/img/emoji/spyparrot.gif new file mode 100644 index 0000000..031b64e Binary files /dev/null and b/webroot/img/emoji/spyparrot.gif differ diff --git a/webroot/img/emoji/stalkerparrot.gif b/webroot/img/emoji/stalkerparrot.gif new file mode 100644 index 0000000..349d649 Binary files /dev/null and b/webroot/img/emoji/stalkerparrot.gif differ diff --git a/webroot/img/emoji/starwars.png b/webroot/img/emoji/starwars.png new file mode 100644 index 0000000..cef6546 Binary files /dev/null and b/webroot/img/emoji/starwars.png differ diff --git a/webroot/img/emoji/stayhomeparrot.gif b/webroot/img/emoji/stayhomeparrot.gif new file mode 100644 index 0000000..800adaf Binary files /dev/null and b/webroot/img/emoji/stayhomeparrot.gif differ diff --git a/webroot/img/emoji/storm_trooper.gif b/webroot/img/emoji/storm_trooper.gif new file mode 100644 index 0000000..0f66de7 Binary files /dev/null and b/webroot/img/emoji/storm_trooper.gif differ diff --git a/webroot/img/emoji/stormtrooper.png b/webroot/img/emoji/stormtrooper.png new file mode 100644 index 0000000..ae95358 Binary files /dev/null and b/webroot/img/emoji/stormtrooper.png differ diff --git a/webroot/img/emoji/sushiparrot.gif b/webroot/img/emoji/sushiparrot.gif new file mode 100644 index 0000000..2722018 Binary files /dev/null and b/webroot/img/emoji/sushiparrot.gif differ diff --git a/webroot/img/emoji/tacoparrot.gif b/webroot/img/emoji/tacoparrot.gif new file mode 100644 index 0000000..aed1d15 Binary files /dev/null and b/webroot/img/emoji/tacoparrot.gif differ diff --git a/webroot/img/emoji/tennisparrot.gif b/webroot/img/emoji/tennisparrot.gif new file mode 100644 index 0000000..dd992b6 Binary files /dev/null and b/webroot/img/emoji/tennisparrot.gif differ diff --git a/webroot/img/emoji/thanks.png b/webroot/img/emoji/thanks.png new file mode 100644 index 0000000..80e0d32 Binary files /dev/null and b/webroot/img/emoji/thanks.png differ diff --git a/webroot/img/emoji/thumbsupparrot.gif b/webroot/img/emoji/thumbsupparrot.gif new file mode 100644 index 0000000..df37921 Binary files /dev/null and b/webroot/img/emoji/thumbsupparrot.gif differ diff --git a/webroot/img/emoji/tiedyeparrot.gif b/webroot/img/emoji/tiedyeparrot.gif new file mode 100644 index 0000000..f5bff7b Binary files /dev/null and b/webroot/img/emoji/tiedyeparrot.gif differ diff --git a/webroot/img/emoji/tpparrot.gif b/webroot/img/emoji/tpparrot.gif new file mode 100644 index 0000000..a647384 Binary files /dev/null and b/webroot/img/emoji/tpparrot.gif differ diff --git a/webroot/img/emoji/transparront.gif b/webroot/img/emoji/transparront.gif new file mode 100644 index 0000000..f9ecf8b Binary files /dev/null and b/webroot/img/emoji/transparront.gif differ diff --git a/webroot/img/emoji/twinsparrot.gif b/webroot/img/emoji/twinsparrot.gif new file mode 100644 index 0000000..c503979 Binary files /dev/null and b/webroot/img/emoji/twinsparrot.gif differ diff --git a/webroot/img/emoji/upvoteparrot.gif b/webroot/img/emoji/upvoteparrot.gif new file mode 100644 index 0000000..f4c8fb4 Binary files /dev/null and b/webroot/img/emoji/upvoteparrot.gif differ diff --git a/webroot/img/emoji/vikingparrot.gif b/webroot/img/emoji/vikingparrot.gif new file mode 100644 index 0000000..049e112 Binary files /dev/null and b/webroot/img/emoji/vikingparrot.gif differ diff --git a/webroot/img/emoji/wesmart.png b/webroot/img/emoji/wesmart.png new file mode 100644 index 0000000..5676faf Binary files /dev/null and b/webroot/img/emoji/wesmart.png differ diff --git a/webroot/img/emoji/wfhparrot.gif b/webroot/img/emoji/wfhparrot.gif new file mode 100644 index 0000000..02469db Binary files /dev/null and b/webroot/img/emoji/wfhparrot.gif differ diff --git a/webroot/img/emoji/wineparrot.gif b/webroot/img/emoji/wineparrot.gif new file mode 100644 index 0000000..e726f3c Binary files /dev/null and b/webroot/img/emoji/wineparrot.gif differ diff --git a/webroot/img/emoji/yoda.gif b/webroot/img/emoji/yoda.gif new file mode 100644 index 0000000..117958c Binary files /dev/null and b/webroot/img/emoji/yoda.gif differ diff --git a/webroot/img/favicon/android-icon-144x144.png b/webroot/img/favicon/android-icon-144x144.png new file mode 100644 index 0000000..3823379 Binary files /dev/null and b/webroot/img/favicon/android-icon-144x144.png differ diff --git a/webroot/img/favicon/android-icon-192x192.png b/webroot/img/favicon/android-icon-192x192.png new file mode 100644 index 0000000..551bf6c Binary files /dev/null and b/webroot/img/favicon/android-icon-192x192.png differ diff --git a/webroot/img/favicon/android-icon-36x36.png b/webroot/img/favicon/android-icon-36x36.png new file mode 100644 index 0000000..e8f248b Binary files /dev/null and b/webroot/img/favicon/android-icon-36x36.png differ diff --git a/webroot/img/favicon/android-icon-48x48.png b/webroot/img/favicon/android-icon-48x48.png new file mode 100644 index 0000000..8b5e5e5 Binary files /dev/null and b/webroot/img/favicon/android-icon-48x48.png differ diff --git a/webroot/img/favicon/android-icon-72x72.png b/webroot/img/favicon/android-icon-72x72.png new file mode 100644 index 0000000..2f168d5 Binary files /dev/null and b/webroot/img/favicon/android-icon-72x72.png differ diff --git a/webroot/img/favicon/android-icon-96x96.png b/webroot/img/favicon/android-icon-96x96.png new file mode 100644 index 0000000..12f808d Binary files /dev/null and b/webroot/img/favicon/android-icon-96x96.png differ diff --git a/webroot/img/favicon/apple-icon-114x114.png b/webroot/img/favicon/apple-icon-114x114.png new file mode 100644 index 0000000..43f4f57 Binary files /dev/null and b/webroot/img/favicon/apple-icon-114x114.png differ diff --git a/webroot/img/favicon/apple-icon-120x120.png b/webroot/img/favicon/apple-icon-120x120.png new file mode 100644 index 0000000..a887d08 Binary files /dev/null and b/webroot/img/favicon/apple-icon-120x120.png differ diff --git a/webroot/img/favicon/apple-icon-144x144.png b/webroot/img/favicon/apple-icon-144x144.png new file mode 100644 index 0000000..3823379 Binary files /dev/null and b/webroot/img/favicon/apple-icon-144x144.png differ diff --git a/webroot/img/favicon/apple-icon-152x152.png b/webroot/img/favicon/apple-icon-152x152.png new file mode 100644 index 0000000..caa3e0a Binary files /dev/null and b/webroot/img/favicon/apple-icon-152x152.png differ diff --git a/webroot/img/favicon/apple-icon-180x180.png b/webroot/img/favicon/apple-icon-180x180.png new file mode 100644 index 0000000..f357367 Binary files /dev/null and b/webroot/img/favicon/apple-icon-180x180.png differ diff --git a/webroot/img/favicon/apple-icon-57x57.png b/webroot/img/favicon/apple-icon-57x57.png new file mode 100644 index 0000000..1627d84 Binary files /dev/null and b/webroot/img/favicon/apple-icon-57x57.png differ diff --git a/webroot/img/favicon/apple-icon-60x60.png b/webroot/img/favicon/apple-icon-60x60.png new file mode 100644 index 0000000..f269199 Binary files /dev/null and b/webroot/img/favicon/apple-icon-60x60.png differ diff --git a/webroot/img/favicon/apple-icon-72x72.png b/webroot/img/favicon/apple-icon-72x72.png new file mode 100644 index 0000000..2f168d5 Binary files /dev/null and b/webroot/img/favicon/apple-icon-72x72.png differ diff --git a/webroot/img/favicon/apple-icon-76x76.png b/webroot/img/favicon/apple-icon-76x76.png new file mode 100644 index 0000000..00ce560 Binary files /dev/null and b/webroot/img/favicon/apple-icon-76x76.png differ diff --git a/webroot/img/favicon/apple-icon-precomposed.png b/webroot/img/favicon/apple-icon-precomposed.png new file mode 100644 index 0000000..2602bc6 Binary files /dev/null and b/webroot/img/favicon/apple-icon-precomposed.png differ diff --git a/webroot/img/favicon/apple-icon.png b/webroot/img/favicon/apple-icon.png new file mode 100644 index 0000000..cc574a6 Binary files /dev/null and b/webroot/img/favicon/apple-icon.png differ diff --git a/webroot/img/favicon/browserconfig.xml b/webroot/img/favicon/browserconfig.xml new file mode 100644 index 0000000..c554148 --- /dev/null +++ b/webroot/img/favicon/browserconfig.xml @@ -0,0 +1,2 @@ + +#ffffff \ No newline at end of file diff --git a/webroot/img/favicon/favicon-16x16.png b/webroot/img/favicon/favicon-16x16.png new file mode 100644 index 0000000..4b544d3 Binary files /dev/null and b/webroot/img/favicon/favicon-16x16.png differ diff --git a/webroot/img/favicon/favicon-32x32.png b/webroot/img/favicon/favicon-32x32.png new file mode 100644 index 0000000..a7f1de9 Binary files /dev/null and b/webroot/img/favicon/favicon-32x32.png differ diff --git a/webroot/img/favicon/favicon-96x96.png b/webroot/img/favicon/favicon-96x96.png new file mode 100644 index 0000000..12f808d Binary files /dev/null and b/webroot/img/favicon/favicon-96x96.png differ diff --git a/webroot/img/favicon/ms-icon-144x144.png b/webroot/img/favicon/ms-icon-144x144.png new file mode 100644 index 0000000..3823379 Binary files /dev/null and b/webroot/img/favicon/ms-icon-144x144.png differ diff --git a/webroot/img/favicon/ms-icon-150x150.png b/webroot/img/favicon/ms-icon-150x150.png new file mode 100644 index 0000000..2f7a020 Binary files /dev/null and b/webroot/img/favicon/ms-icon-150x150.png differ diff --git a/webroot/img/favicon/ms-icon-310x310.png b/webroot/img/favicon/ms-icon-310x310.png new file mode 100644 index 0000000..18f1c72 Binary files /dev/null and b/webroot/img/favicon/ms-icon-310x310.png differ diff --git a/webroot/img/favicon/ms-icon-70x70.png b/webroot/img/favicon/ms-icon-70x70.png new file mode 100644 index 0000000..a371c1f Binary files /dev/null and b/webroot/img/favicon/ms-icon-70x70.png differ diff --git a/webroot/img/fediverse-black.png b/webroot/img/fediverse-black.png new file mode 100644 index 0000000..1bc6a79 Binary files /dev/null and b/webroot/img/fediverse-black.png differ diff --git a/webroot/img/fediverse-color.png b/webroot/img/fediverse-color.png new file mode 100644 index 0000000..bb19584 Binary files /dev/null and b/webroot/img/fediverse-color.png differ diff --git a/webroot/img/fediverse-white.png b/webroot/img/fediverse-white.png new file mode 100644 index 0000000..63e770f Binary files /dev/null and b/webroot/img/fediverse-white.png differ diff --git a/webroot/img/follow.svg b/webroot/img/follow.svg new file mode 100644 index 0000000..ba904f1 --- /dev/null +++ b/webroot/img/follow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/hide-message-grey.svg b/webroot/img/hide-message-grey.svg new file mode 100644 index 0000000..a09ee15 --- /dev/null +++ b/webroot/img/hide-message-grey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/hide-message.svg b/webroot/img/hide-message.svg new file mode 100644 index 0000000..1d2c562 --- /dev/null +++ b/webroot/img/hide-message.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/like.svg b/webroot/img/like.svg new file mode 100644 index 0000000..aa9e5f3 --- /dev/null +++ b/webroot/img/like.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/loading.gif b/webroot/img/loading.gif new file mode 100644 index 0000000..b6ae174 Binary files /dev/null and b/webroot/img/loading.gif differ diff --git a/webroot/img/logo.png b/webroot/img/logo.png new file mode 100644 index 0000000..fa10960 Binary files /dev/null and b/webroot/img/logo.png differ diff --git a/webroot/img/logo.svg b/webroot/img/logo.svg new file mode 100644 index 0000000..ac3f9b7 --- /dev/null +++ b/webroot/img/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/menu-filled.svg b/webroot/img/menu-filled.svg new file mode 100644 index 0000000..db16ab7 --- /dev/null +++ b/webroot/img/menu-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/menu-vert.svg b/webroot/img/menu-vert.svg new file mode 100644 index 0000000..1a1876e --- /dev/null +++ b/webroot/img/menu-vert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/menu.svg b/webroot/img/menu.svg new file mode 100644 index 0000000..1e52e02 --- /dev/null +++ b/webroot/img/menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/moderator-grey.svg b/webroot/img/moderator-grey.svg new file mode 100644 index 0000000..1fa27fc --- /dev/null +++ b/webroot/img/moderator-grey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/moderator-nobackground.svg b/webroot/img/moderator-nobackground.svg new file mode 100644 index 0000000..edc18fe --- /dev/null +++ b/webroot/img/moderator-nobackground.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/moderator.svg b/webroot/img/moderator.svg new file mode 100644 index 0000000..24940eb --- /dev/null +++ b/webroot/img/moderator.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/bandcamp.svg b/webroot/img/platformlogos/bandcamp.svg new file mode 100644 index 0000000..11764f4 --- /dev/null +++ b/webroot/img/platformlogos/bandcamp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/default.svg b/webroot/img/platformlogos/default.svg new file mode 100644 index 0000000..85d53e9 --- /dev/null +++ b/webroot/img/platformlogos/default.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/discord.svg b/webroot/img/platformlogos/discord.svg new file mode 100644 index 0000000..da184ae --- /dev/null +++ b/webroot/img/platformlogos/discord.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/donate.svg b/webroot/img/platformlogos/donate.svg new file mode 100644 index 0000000..42f2772 --- /dev/null +++ b/webroot/img/platformlogos/donate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/facebook.svg b/webroot/img/platformlogos/facebook.svg new file mode 100644 index 0000000..5517d3d --- /dev/null +++ b/webroot/img/platformlogos/facebook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/follow.svg b/webroot/img/platformlogos/follow.svg new file mode 100644 index 0000000..f988deb --- /dev/null +++ b/webroot/img/platformlogos/follow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/github.svg b/webroot/img/platformlogos/github.svg new file mode 100644 index 0000000..3a8245b --- /dev/null +++ b/webroot/img/platformlogos/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/gitlab.svg b/webroot/img/platformlogos/gitlab.svg new file mode 100644 index 0000000..1a766a7 --- /dev/null +++ b/webroot/img/platformlogos/gitlab.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/google.svg b/webroot/img/platformlogos/google.svg new file mode 100644 index 0000000..96da1ef --- /dev/null +++ b/webroot/img/platformlogos/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/instagram.svg b/webroot/img/platformlogos/instagram.svg new file mode 100644 index 0000000..fd3fe9f --- /dev/null +++ b/webroot/img/platformlogos/instagram.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/keyoxide.png b/webroot/img/platformlogos/keyoxide.png new file mode 100644 index 0000000..d4476f4 Binary files /dev/null and b/webroot/img/platformlogos/keyoxide.png differ diff --git a/webroot/img/platformlogos/ko-fi.svg b/webroot/img/platformlogos/ko-fi.svg new file mode 100644 index 0000000..1e53bd6 --- /dev/null +++ b/webroot/img/platformlogos/ko-fi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/lbry.svg b/webroot/img/platformlogos/lbry.svg new file mode 100644 index 0000000..cc26f9a --- /dev/null +++ b/webroot/img/platformlogos/lbry.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/liberapay.svg b/webroot/img/platformlogos/liberapay.svg new file mode 100644 index 0000000..e124109 --- /dev/null +++ b/webroot/img/platformlogos/liberapay.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/link.svg b/webroot/img/platformlogos/link.svg new file mode 100644 index 0000000..5f21fe2 --- /dev/null +++ b/webroot/img/platformlogos/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/linkedin.svg b/webroot/img/platformlogos/linkedin.svg new file mode 100644 index 0000000..e309996 --- /dev/null +++ b/webroot/img/platformlogos/linkedin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/mastodon.svg b/webroot/img/platformlogos/mastodon.svg new file mode 100644 index 0000000..c5bc4af --- /dev/null +++ b/webroot/img/platformlogos/mastodon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/odysee.svg b/webroot/img/platformlogos/odysee.svg new file mode 100644 index 0000000..a900c51 --- /dev/null +++ b/webroot/img/platformlogos/odysee.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/patreon.svg b/webroot/img/platformlogos/patreon.svg new file mode 100644 index 0000000..5d8671c --- /dev/null +++ b/webroot/img/platformlogos/patreon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/paypal.svg b/webroot/img/platformlogos/paypal.svg new file mode 100644 index 0000000..dbc668a --- /dev/null +++ b/webroot/img/platformlogos/paypal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/snapchat.svg b/webroot/img/platformlogos/snapchat.svg new file mode 100644 index 0000000..6371b2a --- /dev/null +++ b/webroot/img/platformlogos/snapchat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/soundcloud.svg b/webroot/img/platformlogos/soundcloud.svg new file mode 100644 index 0000000..9f52826 --- /dev/null +++ b/webroot/img/platformlogos/soundcloud.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/spotify.svg b/webroot/img/platformlogos/spotify.svg new file mode 100644 index 0000000..d5b89c7 --- /dev/null +++ b/webroot/img/platformlogos/spotify.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/steam.svg b/webroot/img/platformlogos/steam.svg new file mode 100644 index 0000000..712df4c --- /dev/null +++ b/webroot/img/platformlogos/steam.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/tiktok.svg b/webroot/img/platformlogos/tiktok.svg new file mode 100644 index 0000000..b1da38f --- /dev/null +++ b/webroot/img/platformlogos/tiktok.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/twitch.svg b/webroot/img/platformlogos/twitch.svg new file mode 100644 index 0000000..052a086 --- /dev/null +++ b/webroot/img/platformlogos/twitch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/twitter.svg b/webroot/img/platformlogos/twitter.svg new file mode 100644 index 0000000..3dedf3e --- /dev/null +++ b/webroot/img/platformlogos/twitter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/platformlogos/youtube.svg b/webroot/img/platformlogos/youtube.svg new file mode 100644 index 0000000..5741120 --- /dev/null +++ b/webroot/img/platformlogos/youtube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/repost.svg b/webroot/img/repost.svg new file mode 100644 index 0000000..45d03a8 --- /dev/null +++ b/webroot/img/repost.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webroot/img/smiley.png b/webroot/img/smiley.png new file mode 100644 index 0000000..5d81490 Binary files /dev/null and b/webroot/img/smiley.png differ diff --git a/webroot/img/user-icon.svg b/webroot/img/user-icon.svg new file mode 100644 index 0000000..09dcacb --- /dev/null +++ b/webroot/img/user-icon.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webroot/index-standalone-chat-readonly.html b/webroot/index-standalone-chat-readonly.html new file mode 100644 index 0000000..e1e9d42 --- /dev/null +++ b/webroot/index-standalone-chat-readonly.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + +
+ + + + diff --git a/webroot/index-standalone-chat-readwrite.html b/webroot/index-standalone-chat-readwrite.html new file mode 100644 index 0000000..159454c --- /dev/null +++ b/webroot/index-standalone-chat-readwrite.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + +
+ + + + diff --git a/webroot/index-standalone-chat.html b/webroot/index-standalone-chat.html new file mode 120000 index 0000000..bba6dc0 --- /dev/null +++ b/webroot/index-standalone-chat.html @@ -0,0 +1 @@ +index-standalone-chat-readonly.html \ No newline at end of file diff --git a/webroot/index-video-only.html b/webroot/index-video-only.html new file mode 100644 index 0000000..c02acd6 --- /dev/null +++ b/webroot/index-video-only.html @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + +
+ + + + diff --git a/webroot/index.html b/webroot/index.html new file mode 100644 index 0000000..469c181 --- /dev/null +++ b/webroot/index.html @@ -0,0 +1,152 @@ + + + + + Owncast + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ + + + + + + diff --git a/webroot/js/app-standalone-chat.js b/webroot/js/app-standalone-chat.js new file mode 100644 index 0000000..18663d6 --- /dev/null +++ b/webroot/js/app-standalone-chat.js @@ -0,0 +1,337 @@ +import { h, Component } from '/js/web_modules/preact.js'; +import htm from '/js/web_modules/htm.js'; +const html = htm.bind(h); +import UsernameForm from './components/chat/username.js'; +import Chat from './components/chat/chat.js'; +import Websocket, { + CALLBACKS, + SOCKET_MESSAGE_TYPES, +} from './utils/websocket.js'; +import { registerChat } from './chat/register.js'; +import { getLocalStorage, setLocalStorage } from './utils/helpers.js'; +import { + CHAT_MAX_MESSAGE_LENGTH, + EST_SOCKET_PAYLOAD_BUFFER, + KEY_EMBED_CHAT_ACCESS_TOKEN, + KEY_ACCESS_TOKEN, + KEY_USERNAME, + TIMER_DISABLE_CHAT_AFTER_OFFLINE, + URL_STATUS, + URL_CONFIG, + TIMER_STATUS_UPDATE, +} from './utils/constants.js'; + +export default class StandaloneChat extends Component { + constructor(props, context) { + super(props, context); + + this.state = { + websocket: null, + canChat: false, + chatEnabled: true, // always true for standalone chat + chatInputEnabled: false, // chat input box state + accessToken: null, + username: null, + isRegistering: false, + streamOnline: null, // stream is active/online + lastDisconnectTime: null, + configData: { + loading: true, + }, + }; + this.disableChatInputTimer = null; + this.hasConfiguredChat = false; + + this.handleUsernameChange = this.handleUsernameChange.bind(this); + this.handleOfflineMode = this.handleOfflineMode.bind(this); + this.handleOnlineMode = this.handleOnlineMode.bind(this); + this.handleFormFocus = this.handleFormFocus.bind(this); + this.handleFormBlur = this.handleFormBlur.bind(this); + this.getStreamStatus = this.getStreamStatus.bind(this); + this.getConfig = this.getConfig.bind(this); + this.disableChatInput = this.disableChatInput.bind(this); + this.setupChatAuth = this.setupChatAuth.bind(this); + this.disableChat = this.disableChat.bind(this); + + // user events + this.handleWebsocketMessage = this.handleWebsocketMessage.bind(this); + + this.getConfig(); + + this.getStreamStatus(); + this.statusTimer = setInterval(this.getStreamStatus, TIMER_STATUS_UPDATE); + } + + // fetch /config data + getConfig() { + fetch(URL_CONFIG) + .then((response) => { + if (!response.ok) { + throw new Error(`Network response was not ok ${response.ok}`); + } + return response.json(); + }) + .then((json) => { + this.setConfigData(json); + }) + .catch((error) => { + this.handleNetworkingError(`Fetch config: ${error}`); + }); + } + + // fetch stream status + getStreamStatus() { + fetch(URL_STATUS) + .then((response) => { + if (!response.ok) { + throw new Error(`Network response was not ok ${response.ok}`); + } + return response.json(); + }) + .then((json) => { + this.updateStreamStatus(json); + }) + .catch((error) => { + this.handleOfflineMode(); + this.handleNetworkingError(`Stream status: ${error}`); + }); + } + + setConfigData(data = {}) { + const { chatDisabled } = data; + + // If this is the first time setting the config + // then setup chat if it's enabled. + if (!this.hasConfiguredChat && !chatDisabled) { + this.setupChatAuth(); + } + + this.hasConfiguredChat = true; + + this.setState({ + canChat: !chatDisabled, + configData: { + ...data, + }, + }); + } + + // handle UI things from stream status result + updateStreamStatus(status = {}) { + const { streamOnline: curStreamOnline } = this.state; + + if (!status) { + return; + } + const { online, lastDisconnectTime } = status; + + this.setState({ + lastDisconnectTime, + streamOnline: online, + }); + + if (status.online !== curStreamOnline) { + if (status.online) { + // stream has just come online. + this.handleOnlineMode(); + } else { + // stream has just flipped offline or app just got loaded and stream is offline. + this.handleOfflineMode(lastDisconnectTime); + } + } + } + + // stop status timer and disable chat after some time. + handleOfflineMode(lastDisconnectTime) { + if (lastDisconnectTime) { + const remainingChatTime = + TIMER_DISABLE_CHAT_AFTER_OFFLINE - + (Date.now() - new Date(lastDisconnectTime)); + const countdown = remainingChatTime < 0 ? 0 : remainingChatTime; + if (countdown > 0) { + this.setState({ + chatInputEnabled: true, + }); + } + this.disableChatInputTimer = setTimeout(this.disableChatInput, countdown); + } + this.setState({ + streamOnline: false, + }); + } + + handleOnlineMode() { + clearTimeout(this.disableChatInputTimer); + this.disableChatInputTimer = null; + + this.setState({ + streamOnline: true, + chatInputEnabled: true, + }); + } + + handleUsernameChange(newName) { + this.setState({ + username: newName, + }); + this.sendUsernameChange(newName); + } + + disableChatInput() { + this.setState({ + chatInputEnabled: false, + }); + } + + handleNetworkingError(error) { + console.error(`>>> App Error: ${error}`); + } + + handleWebsocketMessage(e) { + if (e.type === SOCKET_MESSAGE_TYPES.ERROR_USER_DISABLED) { + // User has been actively disabled on the backend. Turn off chat for them. + this.handleBlockedChat(); + } else if ( + e.type === SOCKET_MESSAGE_TYPES.ERROR_NEEDS_REGISTRATION && + !this.isRegistering + ) { + // User needs an access token, so start the user auth flow. + this.state.websocket.shutdown(); + this.setState({ websocket: null }); + this.setupChatAuth(true); + } else if (e.type === SOCKET_MESSAGE_TYPES.ERROR_MAX_CONNECTIONS_EXCEEDED) { + // Chat server cannot support any more chat clients. Turn off chat for them. + this.disableChat(); + } else if (e.type === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) { + // When connected the user will return an event letting us know what our + // user details are so we can display them properly. + const { user } = e; + const { displayName } = user; + + this.setState({ username: displayName }); + } + } + + handleBlockedChat() { + setLocalStorage('owncast_chat_blocked', true); + this.disableChat(); + } + + handleFormFocus() { + if (this.hasTouchScreen) { + this.setState({ + touchKeyboardActive: true, + }); + } + } + + handleFormBlur() { + if (this.hasTouchScreen) { + this.setState({ + touchKeyboardActive: false, + }); + } + } + + disableChat() { + this.state.websocket.shutdown(); + this.setState({ websocket: null, canChat: false }); + } + + async setupChatAuth(force) { + const { readonly } = this.props; + var accessToken = readonly + ? getLocalStorage(KEY_EMBED_CHAT_ACCESS_TOKEN) + : getLocalStorage(KEY_ACCESS_TOKEN); + var randomIntArray = new Uint32Array(1); + window.crypto.getRandomValues(randomIntArray); + var username = readonly + ? 'chat-embed-' + randomIntArray[0] + : getLocalStorage(KEY_USERNAME); + + if (!accessToken || force) { + try { + this.isRegistering = true; + const registration = await registerChat(username); + accessToken = registration.accessToken; + username = registration.displayName; + + if (readonly) { + setLocalStorage(KEY_EMBED_CHAT_ACCESS_TOKEN, accessToken); + } else { + setLocalStorage(KEY_ACCESS_TOKEN, accessToken); + setLocalStorage(KEY_USERNAME, username); + } + + this.isRegistering = false; + } catch (e) { + console.error('registration error:', e); + } + } + + if (this.state.websocket) { + this.state.websocket.shutdown(); + this.setState({ + websocket: null, + }); + } + + // Without a valid access token he websocket connection will be rejected. + const websocket = new Websocket(accessToken); + websocket.addListener( + CALLBACKS.RAW_WEBSOCKET_MESSAGE_RECEIVED, + this.handleWebsocketMessage + ); + + this.setState({ + username, + websocket, + accessToken, + }); + } + + sendUsernameChange(newName) { + const nameChange = { + type: SOCKET_MESSAGE_TYPES.NAME_CHANGE, + newName, + }; + this.state.websocket.send(nameChange); + } + + render(props, state) { + const { username, websocket, accessToken, chatInputEnabled, configData } = + state; + + const { chatDisabled, maxSocketPayloadSize, customStyles, name } = + configData; + + const { readonly } = props; + return this.state.websocket + ? html`${!readonly + ? html` +
+ <${UsernameForm} + username=${username} + onUsernameChange=${this.handleUsernameChange} + onFocus=${this.handleFormFocus} + onBlur=${this.handleFormBlur} + /> +
` + : ''} + <${Chat} + websocket=${websocket} + username=${username} + accessToken=${accessToken} + readonly=${readonly} + instanceTitle=${name} + chatInputEnabled=${chatInputEnabled && !chatDisabled} + inputMaxBytes=${maxSocketPayloadSize - EST_SOCKET_PAYLOAD_BUFFER || + CHAT_MAX_MESSAGE_LENGTH} + />` + : null; + } +} diff --git a/webroot/js/app-video-only.js b/webroot/js/app-video-only.js new file mode 100644 index 0000000..cc618ef --- /dev/null +++ b/webroot/js/app-video-only.js @@ -0,0 +1,287 @@ +import { h, Component } from '/js/web_modules/preact.js'; +import htm from '/js/web_modules/htm.js'; +const html = htm.bind(h); + +import VideoPoster from './components/video-poster.js'; +import { OwncastPlayer } from './components/player.js'; + +import { + addNewlines, + makeLastOnlineString, + pluralize, + parseSecondsToDurationString, +} from './utils/helpers.js'; +import { + URL_CONFIG, + URL_STATUS, + URL_VIEWER_PING, + TIMER_STATUS_UPDATE, + TIMER_STREAM_DURATION_COUNTER, + TEMP_IMAGE, + MESSAGE_OFFLINE, + MESSAGE_ONLINE, +} from './utils/constants.js'; + +export default class VideoOnly extends Component { + constructor(props, context) { + super(props, context); + + this.state = { + configData: {}, + + playerActive: false, // player object is active + streamOnline: false, // stream is active/online + + isPlaying: false, + + //status + streamStatusMessage: MESSAGE_OFFLINE, + viewerCount: '', + lastDisconnectTime: null, + }; + + // timers + this.playerRestartTimer = null; + this.offlineTimer = null; + this.statusTimer = null; + this.streamDurationTimer = null; + + this.handleOfflineMode = this.handleOfflineMode.bind(this); + this.handleOnlineMode = this.handleOnlineMode.bind(this); + this.setCurrentStreamDuration = this.setCurrentStreamDuration.bind(this); + + // player events + this.handlePlayerReady = this.handlePlayerReady.bind(this); + this.handlePlayerPlaying = this.handlePlayerPlaying.bind(this); + this.handlePlayerEnded = this.handlePlayerEnded.bind(this); + this.handlePlayerError = this.handlePlayerError.bind(this); + + // fetch events + this.getConfig = this.getConfig.bind(this); + this.getStreamStatus = this.getStreamStatus.bind(this); + } + + componentDidMount() { + this.getConfig(); + + this.player = new OwncastPlayer(); + this.player.setupPlayerCallbacks({ + onReady: this.handlePlayerReady, + onPlaying: this.handlePlayerPlaying, + onEnded: this.handlePlayerEnded, + onError: this.handlePlayerError, + }); + this.player.init(); + } + + componentWillUnmount() { + // clear all the timers + clearInterval(this.playerRestartTimer); + clearInterval(this.offlineTimer); + clearInterval(this.statusTimer); + clearInterval(this.streamDurationTimer); + } + + // fetch /config data + getConfig() { + fetch(URL_CONFIG) + .then((response) => { + if (!response.ok) { + throw new Error(`Network response was not ok ${response.ok}`); + } + return response.json(); + }) + .then((json) => { + this.setConfigData(json); + }) + .catch((error) => { + this.handleNetworkingError(`Fetch config: ${error}`); + }); + } + + // fetch stream status + getStreamStatus() { + fetch(URL_STATUS) + .then((response) => { + if (!response.ok) { + throw new Error(`Network response was not ok ${response.ok}`); + } + return response.json(); + }) + .then((json) => { + this.updateStreamStatus(json); + }) + .catch((error) => { + this.handleOfflineMode(); + this.handleNetworkingError(`Stream status: ${error}`); + }); + + // Ping the API to let them know we're an active viewer + fetch(URL_VIEWER_PING).catch((error) => { + this.handleOfflineMode(); + this.handleNetworkingError(`Viewer PING error: ${error}`); + }); + } + + setConfigData(data = {}) { + const { title, summary } = data; + window.document.title = title; + this.setState({ + configData: { + ...data, + summary: summary && addNewlines(summary), + }, + }); + } + + // handle UI things from stream status result + updateStreamStatus(status = {}) { + const { streamOnline: curStreamOnline } = this.state; + + if (!status) { + return; + } + const { viewerCount, online, lastConnectTime, lastDisconnectTime } = status; + + if (status.online && !curStreamOnline) { + // stream has just come online. + this.handleOnlineMode(); + } else if (!status.online && curStreamOnline) { + // stream has just flipped offline. + this.handleOfflineMode(); + } + this.setState({ + viewerCount, + streamOnline: online, + lastDisconnectTime, + lastConnectTime, + }); + } + + // when videojs player is ready, start polling for stream + handlePlayerReady() { + this.getStreamStatus(); + this.statusTimer = setInterval(this.getStreamStatus, TIMER_STATUS_UPDATE); + } + + handlePlayerPlaying() { + this.setState({ + isPlaying: true, + }); + } + + // likely called some time after stream status has gone offline. + // basically hide video and show underlying "poster" + handlePlayerEnded() { + this.setState({ + playerActive: false, + isPlaying: false, + }); + } + + handlePlayerError() { + // do something? + this.handleOfflineMode(); + this.handlePlayerEnded(); + } + + // stop status timer and disable chat after some time. + handleOfflineMode() { + clearInterval(this.streamDurationTimer); + this.setState({ + streamOnline: false, + streamStatusMessage: MESSAGE_OFFLINE, + }); + } + + setCurrentStreamDuration() { + let streamDurationString = ''; + if (this.state.lastConnectTime) { + const diff = (Date.now() - Date.parse(this.state.lastConnectTime)) / 1000; + streamDurationString = parseSecondsToDurationString(diff); + } + this.setState({ + streamStatusMessage: `${MESSAGE_ONLINE} ${streamDurationString}`, + }); + } + + // play video! + handleOnlineMode() { + this.player.startPlayer(); + + this.streamDurationTimer = setInterval( + this.setCurrentStreamDuration, + TIMER_STREAM_DURATION_COUNTER + ); + + this.setState({ + playerActive: true, + streamOnline: true, + streamStatusMessage: MESSAGE_ONLINE, + }); + } + + handleNetworkingError(error) { + console.error(`>>> App Error: ${error}`); + } + + render(props, state) { + const { + configData, + + viewerCount, + playerActive, + streamOnline, + streamStatusMessage, + lastDisconnectTime, + isPlaying, + } = state; + + const { logo = TEMP_IMAGE, customStyles } = configData; + + let viewerCountMessage = ''; + if (streamOnline && viewerCount > 0) { + viewerCountMessage = html`${viewerCount} + ${pluralize(' viewer', viewerCount)}`; + } else if (lastDisconnectTime) { + viewerCountMessage = makeLastOnlineString(lastDisconnectTime); + } + + const mainClass = playerActive ? 'online' : ''; + + const poster = isPlaying + ? null + : html` <${VideoPoster} offlineImage=${logo} active=${streamOnline} /> `; + return html` +
+ +
+ + ${poster} +
+ +
+ ${streamStatusMessage} + ${viewerCountMessage} +
+
+ `; + } +} diff --git a/webroot/js/app.js b/webroot/js/app.js new file mode 100644 index 0000000..0cb6d71 --- /dev/null +++ b/webroot/js/app.js @@ -0,0 +1,975 @@ +import { h, Component } from '/js/web_modules/preact.js'; +import htm from '/js/web_modules/htm.js'; +const html = htm.bind(h); + +import { OwncastPlayer } from './components/player.js'; +import SocialIconsList from './components/platform-logos-list.js'; +import UsernameForm from './components/chat/username.js'; +import VideoPoster from './components/video-poster.js'; +import Followers from './components/federation/followers.js'; + +import Chat from './components/chat/chat.js'; +import Websocket, { + CALLBACKS, + SOCKET_MESSAGE_TYPES, +} from './utils/websocket.js'; +import { registerChat } from './chat/register.js'; + +import ExternalActionModal, { + ExternalActionButton, +} from './components/external-action-modal.js'; + +import FediverseFollowModal, { + FediverseFollowButton, +} from './components/fediverse-follow-modal.js'; + +import { + addNewlines, + checkUrlPathForDisplay, + classNames, + debounce, + getLocalStorage, + getOrientation, + hasTouchScreen, + makeLastOnlineString, + parseSecondsToDurationString, + pluralize, + ROUTE_RECORDINGS, + setLocalStorage, +} from './utils/helpers.js'; +import { + CHAT_MAX_MESSAGE_LENGTH, + EST_SOCKET_PAYLOAD_BUFFER, + HEIGHT_SHORT_WIDE, + KEY_ACCESS_TOKEN, + KEY_CHAT_DISPLAYED, + KEY_USERNAME, + MESSAGE_OFFLINE, + MESSAGE_ONLINE, + ORIENTATION_PORTRAIT, + OWNCAST_LOGO_LOCAL, + TEMP_IMAGE, + TIMER_DISABLE_CHAT_AFTER_OFFLINE, + TIMER_STATUS_UPDATE, + TIMER_STREAM_DURATION_COUNTER, + URL_CONFIG, + URL_OWNCAST, + URL_STATUS, + URL_VIEWER_PING, + WIDTH_SINGLE_COL, +} from './utils/constants.js'; +import { checkIsModerator } from './utils/chat.js'; +import TabBar from './components/tab-bar.js'; + +export default class App extends Component { + constructor(props, context) { + super(props, context); + + const chatStorage = getLocalStorage(KEY_CHAT_DISPLAYED); + this.hasTouchScreen = hasTouchScreen(); + this.windowBlurred = false; + + this.state = { + websocket: null, + canChat: false, // all of chat functionality (panel + username) + displayChatPanel: chatStorage === null ? true : chatStorage === 'true', // just the chat panel + chatInputEnabled: false, // chat input box state + accessToken: null, + username: getLocalStorage(KEY_USERNAME), + isModerator: false, + + isRegistering: false, + touchKeyboardActive: false, + + configData: { + loading: true, + }, + extraPageContent: '', + + playerActive: false, // player object is active + streamOnline: null, // stream is active/online + isPlaying: false, // player is actively playing video + + // status + streamStatusMessage: MESSAGE_OFFLINE, + viewerCount: '', + lastDisconnectTime: null, + + // dom + windowWidth: window.innerWidth, + windowHeight: window.innerHeight, + orientation: getOrientation(this.hasTouchScreen), + + // modals + externalActionModalData: null, + fediverseModalData: null, + + // routing & tabbing + section: '', + sectionId: '', + }; + + // timers + this.playerRestartTimer = null; + this.offlineTimer = null; + this.statusTimer = null; + this.disableChatInputTimer = null; + this.streamDurationTimer = null; + + // misc dom events + this.handleChatPanelToggle = this.handleChatPanelToggle.bind(this); + this.handleUsernameChange = this.handleUsernameChange.bind(this); + this.handleFormFocus = this.handleFormFocus.bind(this); + this.handleFormBlur = this.handleFormBlur.bind(this); + this.handleWindowBlur = this.handleWindowBlur.bind(this); + this.handleWindowFocus = this.handleWindowFocus.bind(this); + this.handleWindowResize = debounce(this.handleWindowResize.bind(this), 250); + + this.handleOfflineMode = this.handleOfflineMode.bind(this); + this.handleOnlineMode = this.handleOnlineMode.bind(this); + this.disableChatInput = this.disableChatInput.bind(this); + this.setCurrentStreamDuration = this.setCurrentStreamDuration.bind(this); + + this.handleKeyPressed = this.handleKeyPressed.bind(this); + this.displayExternalAction = this.displayExternalAction.bind(this); + this.closeExternalActionModal = this.closeExternalActionModal.bind(this); + this.displayFediverseFollowModal = + this.displayFediverseFollowModal.bind(this); + this.closeFediverseFollowModal = this.closeFediverseFollowModal.bind(this); + + // player events + this.handlePlayerReady = this.handlePlayerReady.bind(this); + this.handlePlayerPlaying = this.handlePlayerPlaying.bind(this); + this.handlePlayerEnded = this.handlePlayerEnded.bind(this); + this.handlePlayerError = this.handlePlayerError.bind(this); + + // fetch events + this.getConfig = this.getConfig.bind(this); + this.getStreamStatus = this.getStreamStatus.bind(this); + + // user events + this.handleWebsocketMessage = this.handleWebsocketMessage.bind(this); + + // chat + this.hasConfiguredChat = false; + this.setupChatAuth = this.setupChatAuth.bind(this); + this.disableChat = this.disableChat.bind(this); + } + + componentDidMount() { + this.getConfig(); + if (!this.hasTouchScreen) { + window.addEventListener('resize', this.handleWindowResize); + } + window.addEventListener('blur', this.handleWindowBlur); + window.addEventListener('focus', this.handleWindowFocus); + if (this.hasTouchScreen) { + window.addEventListener('orientationchange', this.handleWindowResize); + } + window.addEventListener('keypress', this.handleKeyPressed); + this.player = new OwncastPlayer(); + this.player.setupPlayerCallbacks({ + onReady: this.handlePlayerReady, + onPlaying: this.handlePlayerPlaying, + onEnded: this.handlePlayerEnded, + onError: this.handlePlayerError, + }); + this.player.init(); + + // check routing + this.getRoute(); + } + + componentWillUnmount() { + // clear all the timers + clearInterval(this.playerRestartTimer); + clearInterval(this.offlineTimer); + clearInterval(this.statusTimer); + clearTimeout(this.disableChatInputTimer); + clearInterval(this.streamDurationTimer); + window.removeEventListener('resize', this.handleWindowResize); + window.removeEventListener('blur', this.handleWindowBlur); + window.removeEventListener('focus', this.handleWindowFocus); + window.removeEventListener('keypress', this.handleKeyPressed); + if (this.hasTouchScreen) { + window.removeEventListener('orientationchange', this.handleWindowResize); + } + } + + getRoute() { + const routeInfo = checkUrlPathForDisplay(); + this.setState({ + ...routeInfo, + }); + } + + // fetch /config data + getConfig() { + fetch(URL_CONFIG) + .then((response) => { + if (!response.ok) { + throw new Error(`Network response was not ok ${response.ok}`); + } + return response.json(); + }) + .then((json) => { + this.setConfigData(json); + }) + .catch((error) => { + this.handleNetworkingError(`Fetch config: ${error}`); + }); + } + + // fetch stream status + getStreamStatus() { + fetch(URL_STATUS) + .then((response) => { + if (!response.ok) { + throw new Error(`Network response was not ok ${response.ok}`); + } + return response.json(); + }) + .then((json) => { + this.updateStreamStatus(json); + }) + .catch((error) => { + this.handleOfflineMode(); + this.handleNetworkingError(`Stream status: ${error}`); + }); + + // Ping the API to let them know we're an active viewer + fetch(URL_VIEWER_PING).catch((error) => { + this.handleOfflineMode(); + this.handleNetworkingError(`Viewer PING error: ${error}`); + }); + } + + setConfigData(data = {}) { + const { name, summary, chatDisabled } = data; + window.document.title = name; + + // If this is the first time setting the config + // then setup chat if it's enabled. + if (!this.hasConfiguredChat && !chatDisabled) { + this.setupChatAuth(); + } + + this.hasConfiguredChat = true; + + this.setState({ + canChat: !chatDisabled, + configData: { + ...data, + summary: summary && addNewlines(summary), + }, + }); + } + + // handle UI things from stream status result + updateStreamStatus(status = {}) { + const { streamOnline: curStreamOnline } = this.state; + + if (!status) { + return; + } + const { + viewerCount, + online, + lastConnectTime, + streamTitle, + lastDisconnectTime, + } = status; + + this.setState({ + viewerCount, + lastConnectTime, + streamOnline: online, + streamTitle, + lastDisconnectTime, + }); + + if (status.online !== curStreamOnline) { + if (status.online) { + // stream has just come online. + this.handleOnlineMode(); + } else { + // stream has just flipped offline or app just got loaded and stream is offline. + this.handleOfflineMode(lastDisconnectTime); + } + } + } + + // when videojs player is ready, start polling for stream + handlePlayerReady() { + this.getStreamStatus(); + this.statusTimer = setInterval(this.getStreamStatus, TIMER_STATUS_UPDATE); + } + + handlePlayerPlaying() { + this.setState({ + isPlaying: true, + }); + } + + // likely called some time after stream status has gone offline. + // basically hide video and show underlying "poster" + handlePlayerEnded() { + this.setState({ + playerActive: false, + isPlaying: false, + }); + } + + handlePlayerError() { + // do something? + this.handleOfflineMode(); + this.handlePlayerEnded(); + } + + // stop status timer and disable chat after some time. + handleOfflineMode(lastDisconnectTime) { + clearInterval(this.streamDurationTimer); + + if (lastDisconnectTime) { + const remainingChatTime = + TIMER_DISABLE_CHAT_AFTER_OFFLINE - + (Date.now() - new Date(lastDisconnectTime)); + const countdown = remainingChatTime < 0 ? 0 : remainingChatTime; + if (countdown > 0) { + this.setState({ + chatInputEnabled: true, + }); + } + this.disableChatInputTimer = setTimeout(this.disableChatInput, countdown); + } + + this.setState({ + streamOnline: false, + streamStatusMessage: MESSAGE_OFFLINE, + }); + + if (this.player.vjsPlayer && this.player.vjsPlayer.paused()) { + this.handlePlayerEnded(); + } + + if (this.windowBlurred) { + document.title = ` 🔴 ${ + this.state.configData && this.state.configData.name + }`; + } + } + + // play video! + handleOnlineMode() { + this.player.startPlayer(); + clearTimeout(this.disableChatInputTimer); + this.disableChatInputTimer = null; + + this.streamDurationTimer = setInterval( + this.setCurrentStreamDuration, + TIMER_STREAM_DURATION_COUNTER + ); + + this.setState({ + playerActive: true, + streamOnline: true, + chatInputEnabled: true, + streamTitle: '', + streamStatusMessage: MESSAGE_ONLINE, + }); + + if (this.windowBlurred) { + document.title = ` 🟢 ${ + this.state.configData && this.state.configData.name + }`; + } + } + + setCurrentStreamDuration() { + let streamDurationString = ''; + if (this.state.lastConnectTime) { + const diff = (Date.now() - Date.parse(this.state.lastConnectTime)) / 1000; + streamDurationString = parseSecondsToDurationString(diff); + } + this.setState({ + streamStatusMessage: `${MESSAGE_ONLINE} ${streamDurationString}`, + }); + } + + handleUsernameChange(newName) { + this.setState({ + username: newName, + }); + + this.sendUsernameChange(newName); + } + + handleFormFocus() { + if (this.hasTouchScreen) { + this.setState({ + touchKeyboardActive: true, + }); + } + } + + handleFormBlur() { + if (this.hasTouchScreen) { + this.setState({ + touchKeyboardActive: false, + }); + } + } + + handleChatPanelToggle() { + const { displayChatPanel: curDisplayed } = this.state; + + const displayChat = !curDisplayed; + setLocalStorage(KEY_CHAT_DISPLAYED, displayChat); + this.setState({ + displayChatPanel: displayChat, + }); + } + + disableChatInput() { + this.setState({ + chatInputEnabled: false, + }); + } + + handleNetworkingError(error) { + console.error(`>>> App Error: ${error}`); + } + + handleWindowResize() { + this.setState({ + windowWidth: window.innerWidth, + windowHeight: window.innerHeight, + orientation: getOrientation(this.hasTouchScreen), + }); + } + + handleWindowBlur() { + this.windowBlurred = true; + } + + handleWindowFocus() { + this.windowBlurred = false; + window.document.title = this.state.configData && this.state.configData.name; + } + + handleSpaceBarPressed(e) { + e.preventDefault(); + if (this.state.isPlaying) { + this.setState({ + isPlaying: false, + }); + try { + this.player.vjsPlayer.pause(); + } catch (err) { + console.warn(err); + } + } else { + this.setState({ + isPlaying: true, + }); + this.player.vjsPlayer.play(); + } + } + + handleMuteKeyPressed() { + const muted = this.player.vjsPlayer.muted(); + const volume = this.player.vjsPlayer.volume(); + + try { + if (volume === 0) { + this.player.vjsPlayer.volume(0.5); + this.player.vjsPlayer.muted(false); + } else { + this.player.vjsPlayer.muted(!muted); + } + } catch (err) { + console.warn(err); + } + } + + handleFullScreenKeyPressed() { + if (this.player.vjsPlayer.isFullscreen()) { + this.player.vjsPlayer.exitFullscreen(); + } else { + this.player.vjsPlayer.requestFullscreen(); + } + } + + handleVolumeSet(factor) { + this.player.vjsPlayer.volume(this.player.vjsPlayer.volume() + factor); + } + + handleKeyPressed(e) { + // Only handle shortcuts if the focus is on the general page body, + // not a specific input field. + if (e.target !== document.getElementById('app-body')) { + return; + } + + if (this.state.streamOnline) { + switch (e.code) { + case 'MediaPlayPause': + case 'KeyP': + case 'Space': + this.handleSpaceBarPressed(e); + break; + case 'KeyM': + this.handleMuteKeyPressed(e); + break; + case 'KeyF': + this.handleFullScreenKeyPressed(e); + break; + case 'KeyC': + this.handleChatPanelToggle(); + break; + case 'Digit9': + this.handleVolumeSet(-0.1); + break; + case 'Digit0': + this.handleVolumeSet(0.1); + } + } + } + + displayExternalAction(action) { + const { username } = this.state; + if (!action) { + return; + } + const { url: actionUrl, openExternally } = action || {}; + let url = new URL(actionUrl); + // Append url and username to params so the link knows where we came from and who we are. + url.searchParams.append('username', username); + url.searchParams.append('instance', window.location); + + const fullUrl = url.toString(); + + if (openExternally) { + var win = window.open(fullUrl, '_blank'); + win.focus(); + return; + } + this.setState({ + externalActionModalData: { + ...action, + url: fullUrl, + }, + }); + } + closeExternalActionModal() { + this.setState({ + externalActionModalData: null, + }); + } + + displayFediverseFollowModal(data) { + this.setState({ fediverseModalData: data }); + } + closeFediverseFollowModal() { + this.setState({ fediverseModalData: null }); + } + + handleWebsocketMessage(e) { + if (e.type === SOCKET_MESSAGE_TYPES.ERROR_USER_DISABLED) { + // User has been actively disabled on the backend. Turn off chat for them. + this.handleBlockedChat(); + } else if ( + e.type === SOCKET_MESSAGE_TYPES.ERROR_NEEDS_REGISTRATION && + !this.isRegistering + ) { + // User needs an access token, so start the user auth flow. + this.state.websocket.shutdown(); + this.setState({ websocket: null }); + this.setupChatAuth(true); + } else if (e.type === SOCKET_MESSAGE_TYPES.ERROR_MAX_CONNECTIONS_EXCEEDED) { + // Chat server cannot support any more chat clients. Turn off chat for them. + this.disableChat(); + } else if (e.type === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) { + // When connected the user will return an event letting us know what our + // user details are so we can display them properly. + const { user } = e; + const { displayName } = user; + + this.setState({ + username: displayName, + isModerator: checkIsModerator(e), + }); + } + } + + handleBlockedChat() { + this.disableChat(); + } + + disableChat() { + this.state.websocket.shutdown(); + this.setState({ websocket: null, canChat: false }); + } + + async setupChatAuth(force) { + var accessToken = getLocalStorage(KEY_ACCESS_TOKEN); + var username = getLocalStorage(KEY_USERNAME); + + if (!accessToken || force) { + try { + this.isRegistering = true; + const registration = await registerChat(this.state.username); + accessToken = registration.accessToken; + username = registration.displayName; + + setLocalStorage(KEY_ACCESS_TOKEN, accessToken); + setLocalStorage(KEY_USERNAME, username); + + this.isRegistering = false; + } catch (e) { + console.error('registration error:', e); + } + } + + if (this.state.websocket) { + this.state.websocket.shutdown(); + this.setState({ + websocket: null, + }); + } + + // Without a valid access token he websocket connection will be rejected. + const websocket = new Websocket(accessToken); + websocket.addListener( + CALLBACKS.RAW_WEBSOCKET_MESSAGE_RECEIVED, + this.handleWebsocketMessage + ); + + this.setState({ + username, + websocket, + accessToken, + }); + } + + sendUsernameChange(newName) { + const nameChange = { + type: SOCKET_MESSAGE_TYPES.NAME_CHANGE, + newName, + }; + this.state.websocket.send(nameChange); + } + + render(props, state) { + const { + chatInputEnabled, + configData, + displayChatPanel, + canChat, + isModerator, + + isPlaying, + orientation, + playerActive, + streamOnline, + streamStatusMessage, + streamTitle, + touchKeyboardActive, + username, + viewerCount, + websocket, + windowHeight, + windowWidth, + fediverseModalData, + externalActionModalData, + lastDisconnectTime, + section, + sectionId, + } = state; + + const { + version: appVersion, + logo = TEMP_IMAGE, + socialHandles = [], + summary, + tags = [], + name, + extraPageContent, + chatDisabled, + externalActions, + customStyles, + maxSocketPayloadSize, + federation = {}, + } = configData; + + const bgUserLogo = { backgroundImage: `url(${logo})` }; + + const tagList = tags !== null && tags.length > 0 && tags.join(' #'); + + let viewerCountMessage = ''; + if (streamOnline && viewerCount > 0) { + viewerCountMessage = html`${viewerCount} + ${pluralize(' viewer', viewerCount)}`; + } else if (lastDisconnectTime) { + viewerCountMessage = makeLastOnlineString(lastDisconnectTime); + } + + const mainClass = playerActive ? 'online' : ''; + const isPortrait = + this.hasTouchScreen && orientation === ORIENTATION_PORTRAIT; + const shortHeight = windowHeight <= HEIGHT_SHORT_WIDE && !isPortrait; + const singleColMode = windowWidth <= WIDTH_SINGLE_COL && !shortHeight; + + const noVideoContent = + !playerActive || (section === ROUTE_RECORDINGS && sectionId !== ''); + const shouldDisplayChat = + displayChatPanel && !chatDisabled && chatInputEnabled; + + const extraAppClasses = classNames({ + 'config-loading': configData.loading, + + chat: shouldDisplayChat, + 'no-chat': !shouldDisplayChat, + 'no-video': noVideoContent, + 'chat-hidden': !displayChatPanel && canChat && !chatDisabled, // hide panel + 'chat-disabled': !canChat || chatDisabled, + 'single-col': singleColMode, + 'bg-gray-800': singleColMode && shouldDisplayChat, + 'short-wide': shortHeight && windowWidth > WIDTH_SINGLE_COL, + 'touch-screen': this.hasTouchScreen, + 'touch-keyboard-active': touchKeyboardActive, + }); + + const poster = isPlaying + ? null + : html` <${VideoPoster} offlineImage=${logo} active=${streamOnline} /> `; + + // modal buttons + const externalActionButtons = html`
+ ${externalActions && + externalActions.map( + function (action) { + return html`<${ExternalActionButton} + onClick=${this.displayExternalAction} + action=${action} + />`; + }.bind(this) + )} + + + ${federation.enabled && + html`<${FediverseFollowButton} + onClick=${this.displayFediverseFollowModal} + federationInfo=${federation} + serverName=${name} + />`} +
`; + + // modal component + const externalActionModal = + externalActionModalData && + html`<${ExternalActionModal} + action=${externalActionModalData} + onClose=${this.closeExternalActionModal} + />`; + + const fediverseFollowModal = + fediverseModalData && + html` + <${ExternalActionModal} + onClose=${this.closeFediverseFollowModal} + action=${fediverseModalData} + useIframe=${false} + customContent=${html`<${FediverseFollowModal} + name=${name} + logo=${logo} + federationInfo=${federation} + onClose=${this.closeFediverseFollowModal} + />`} + /> + `; + + const chat = this.state.websocket + ? html` + <${Chat} + websocket=${websocket} + username=${username} + chatInputEnabled=${chatInputEnabled && !chatDisabled} + instanceTitle=${name} + accessToken=${this.state.accessToken} + inputMaxBytes=${maxSocketPayloadSize - EST_SOCKET_PAYLOAD_BUFFER || + CHAT_MAX_MESSAGE_LENGTH} + /> + ` + : null; + + const TAB_CONTENT = [ + { + label: 'About', + content: html` +
+
+
+ ${tagList && `#${tagList}`} +
+
+
+ `, + }, + ]; + + if (federation.enabled) { + TAB_CONTENT.push({ + label: html`Followers + ${federation.followerCount > 10 + ? `${' '}(${federation.followerCount})` + : null}`, + content: html`<${Followers} />`, + }); + } + + return html` +
+ + +
+
+

+ + + + ${streamOnline && streamTitle ? streamTitle : name} +

+
+ <${UsernameForm} + username=${username} + isModerator=${isModerator} + onUsernameChange=${this.handleUsernameChange} + onFocus=${this.handleFormFocus} + onBlur=${this.handleFormBlur} + /> + +
+
+
+ +
+
+ + ${poster} +
+ +
+ ${streamStatusMessage} + ${viewerCountMessage} +
+
+ +
+ ${externalActionButtons && html`${externalActionButtons}`} + +
+
+
+ +
+ +
+ +
+

+ ${name} +

+

+ ${streamOnline && streamTitle} +

+ + +
+ <${TabBar} tabs=${TAB_CONTENT} ariaLabel="User Content" /> +
+
+
+
+ + + + ${chat} ${externalActionModal} ${fediverseFollowModal} +
+ `; + } +} diff --git a/webroot/js/chat/register.js b/webroot/js/chat/register.js new file mode 100644 index 0000000..5312904 --- /dev/null +++ b/webroot/js/chat/register.js @@ -0,0 +1,19 @@ +import { URL_CHAT_REGISTRATION } from '../utils/constants.js'; + +export async function registerChat(username) { + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ displayName: username }), + }; + + try { + const response = await fetch(URL_CHAT_REGISTRATION, options); + const result = await response.json(); + return result; + } catch (e) { + console.error(e); + } +} diff --git a/webroot/js/components/chat/chat-input.js b/webroot/js/components/chat/chat-input.js new file mode 100644 index 0000000..3d3b5dc --- /dev/null +++ b/webroot/js/components/chat/chat-input.js @@ -0,0 +1,399 @@ +import { h, Component, createRef } from '/js/web_modules/preact.js'; +import htm from '/js/web_modules/htm.js'; +const html = htm.bind(h); + +import { EmojiButton } from '/js/web_modules/@joeattardi/emoji-button.js'; + +import ContentEditable, { replaceCaret } from './content-editable.js'; +import { + generatePlaceholderText, + getCaretPosition, + convertToText, + convertOnPaste, + createEmojiMarkup, + trimNbsp, + emojify, +} from '../../utils/chat.js'; +import { + getLocalStorage, + setLocalStorage, + classNames, +} from '../../utils/helpers.js'; +import { + URL_CUSTOM_EMOJIS, + KEY_CHAT_FIRST_MESSAGE_SENT, + CHAT_CHAR_COUNT_BUFFER, + CHAT_OK_KEYCODES, + CHAT_KEY_MODIFIERS, +} from '../../utils/constants.js'; + +export default class ChatInput extends Component { + constructor(props, context) { + super(props, context); + this.formMessageInput = createRef(); + this.emojiPickerButton = createRef(); + + this.messageCharCount = 0; + + this.prepNewLine = false; + this.modifierKeyPressed = false; // control/meta/shift/alt + + this.state = { + inputHTML: '', + inputCharsLeft: props.inputMaxBytes, + hasSentFirstChatMessage: getLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT), + emojiPicker: null, + emojiList: null, + emojiNames: null, + }; + + this.handleEmojiButtonClick = this.handleEmojiButtonClick.bind(this); + this.handleEmojiSelected = this.handleEmojiSelected.bind(this); + this.getCustomEmojis = this.getCustomEmojis.bind(this); + + this.handleMessageInputKeydown = this.handleMessageInputKeydown.bind(this); + this.handleMessageInputKeyup = this.handleMessageInputKeyup.bind(this); + this.handleMessageInputBlur = this.handleMessageInputBlur.bind(this); + this.handleSubmitChatButton = this.handleSubmitChatButton.bind(this); + this.handlePaste = this.handlePaste.bind(this); + + this.handleContentEditableChange = + this.handleContentEditableChange.bind(this); + } + + componentDidMount() { + this.getCustomEmojis(); + } + + getCustomEmojis() { + fetch(URL_CUSTOM_EMOJIS) + .then((response) => { + if (!response.ok) { + throw new Error(`Network response was not ok ${response.ok}`); + } + return response.json(); + }) + .then((json) => { + const emojiList = json; + const emojiNames = emojiList.map((emoji) => emoji.name); + const emojiPicker = new EmojiButton({ + zIndex: 100, + theme: 'owncast', // see chat.css + custom: json, + initialCategory: 'custom', + showPreview: false, + autoHide: false, + autoFocusSearch: false, + showAnimation: false, + emojiSize: '24px', + position: 'right-start', + strategy: 'absolute', + }); + emojiPicker.on('emoji', (emoji) => { + this.handleEmojiSelected(emoji); + }); + emojiPicker.on('hidden', () => { + this.formMessageInput.current.focus(); + replaceCaret(this.formMessageInput.current); + }); + this.setState({ emojiNames, emojiList, emojiPicker }); + }) + .catch((error) => { + // this.handleNetworkingError(`Emoji Fetch: ${error}`); + }); + } + + handleEmojiButtonClick() { + const { emojiPicker } = this.state; + if (emojiPicker) { + emojiPicker.togglePicker(this.emojiPickerButton.current); + } + } + + handleEmojiSelected(emoji) { + const { inputHTML, inputCharsLeft } = this.state; + // if we're already at char limit, don't do anything + if (inputCharsLeft < 0) { + return; + } + let content = ''; + if (emoji.url) { + content = createEmojiMarkup(emoji, false); + } else { + content = emoji.emoji; + } + + const position = getCaretPosition(this.formMessageInput.current); + const newHTML = + inputHTML.substring(0, position) + + content + + inputHTML.substring(position); + + const charsLeft = this.calculateCurrentBytesLeft(newHTML); + this.setState({ + inputHTML: newHTML, + inputCharsLeft: charsLeft, + }); + // a hacky way add focus back into input field + setTimeout(() => { + const input = this.formMessageInput.current; + input.focus(); + replaceCaret(input); + }, 100); + } + + // autocomplete text from the given "list". "token" marks the start of word lookup. + autoComplete(token, list) { + const { inputHTML } = this.state; + const position = getCaretPosition(this.formMessageInput.current); + const at = inputHTML.lastIndexOf(token, position - 1); + if (at === -1) { + return false; + } + + let partial = inputHTML.substring(at + 1, position).trim(); + + if (this.partial === undefined) { + this.partial = []; + } + + if (partial === this.suggestion) { + partial = this.partial[token]; + } else { + this.partial[token] = partial; + } + + const possibilities = list.filter(function (item) { + return item.toLowerCase().startsWith(partial.toLowerCase()); + }); + + if (this.completionIndex === undefined) { + this.completionIndex = []; + } + + if ( + this.completionIndex[token] === undefined || + ++this.completionIndex[token] >= possibilities.length + ) { + this.completionIndex[token] = 0; + } + + if (possibilities.length > 0) { + this.suggestion = possibilities[this.completionIndex[token]]; + + const newHTML = + inputHTML.substring(0, at + 1) + + this.suggestion + + ' ' + + inputHTML.substring(position); + + this.setState({ + inputHTML: newHTML, + inputCharsLeft: this.calculateCurrentBytesLeft(newHTML), + }); + } + + return true; + } + + // replace :emoji: with the emoji + injectEmoji() { + const { inputHTML, emojiList } = this.state; + const textValue = convertToText(inputHTML); + const processedHTML = emojify(inputHTML, emojiList); + + if (textValue != convertToText(processedHTML)) { + this.setState({ + inputHTML: processedHTML, + }); + return true; + } + return false; + } + + handleMessageInputKeydown(event) { + const key = event && event.key; + + if (key === 'Enter') { + if (!this.prepNewLine) { + this.sendMessage(); + event.preventDefault(); + this.prepNewLine = false; + return; + } + } + // allow key presses such as command/shift/meta, etc even when message length is full later. + if (CHAT_KEY_MODIFIERS.includes(key)) { + this.modifierKeyPressed = true; + } + if (key === 'Control' || key === 'Shift') { + this.prepNewLine = true; + } + if (key === 'Tab') { + const { chatUserNames } = this.props; + const { emojiNames } = this.state; + if (this.autoComplete('@', chatUserNames)) { + event.preventDefault(); + } + if (this.autoComplete(':', emojiNames)) { + event.preventDefault(); + } + } + + // if new input pushes the potential chars over, don't do anything + const formField = this.formMessageInput.current; + const tempCharsLeft = this.calculateCurrentBytesLeft(formField.innerHTML); + if (tempCharsLeft <= 0 && !CHAT_OK_KEYCODES.includes(key)) { + if (!this.modifierKeyPressed) { + event.preventDefault(); // prevent typing more + } + return; + } + } + + handleMessageInputKeyup(event) { + const { key } = event; + if (key === 'Control' || key === 'Shift') { + this.prepNewLine = false; + } + if (CHAT_KEY_MODIFIERS.includes(key)) { + this.modifierKeyPressed = false; + } + + if (key === ':' || key === ';') { + this.injectEmoji(); + } + } + + handleMessageInputBlur() { + this.prepNewLine = false; + this.modifierKeyPressed = false; + } + + handlePaste(event) { + // don't allow paste if too much text already + if (this.state.inputCharsLeft < 0) { + event.preventDefault(); + return; + } + convertOnPaste(event, this.state.emojiList); + this.handleMessageInputKeydown(event); + } + + handleSubmitChatButton(event) { + event.preventDefault(); + this.sendMessage(); + } + + sendMessage() { + const { handleSendMessage, inputMaxBytes } = this.props; + const { hasSentFirstChatMessage, inputHTML, inputCharsLeft } = this.state; + if (inputCharsLeft < 0) { + return; + } + const message = convertToText(inputHTML); + const newStates = { + inputHTML: '', + inputCharsLeft: inputMaxBytes, + }; + + handleSendMessage(message); + + if (!hasSentFirstChatMessage) { + newStates.hasSentFirstChatMessage = true; + setLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT, true); + } + + // clear things out. + this.setState(newStates); + } + + handleContentEditableChange(event) { + const value = event.target.value; + this.setState({ + inputHTML: value, + inputCharsLeft: this.calculateCurrentBytesLeft(value), + }); + } + + calculateCurrentBytesLeft(inputContent) { + const { inputMaxBytes } = this.props; + const curBytes = new Blob([trimNbsp(inputContent)]).size; + return inputMaxBytes - curBytes; + } + + render(props, state) { + const { hasSentFirstChatMessage, inputCharsLeft, inputHTML, emojiPicker } = + state; + const { inputEnabled, inputMaxBytes } = props; + const emojiButtonStyle = { + display: emojiPicker && inputCharsLeft > 0 ? 'block' : 'none', + }; + const extraClasses = classNames({ + 'display-count': inputCharsLeft <= CHAT_CHAR_COUNT_BUFFER, + }); + const placeholderText = generatePlaceholderText( + inputEnabled, + hasSentFirstChatMessage + ); + return html` +
+
+ <${ContentEditable} + id="message-input" + class="appearance-none block w-full bg-transparent text-sm text-gray-700 h-full focus:outline-none" + placeholderText=${placeholderText} + innerRef=${this.formMessageInput} + html=${inputHTML} + disabled=${!inputEnabled} + onChange=${this.handleContentEditableChange} + onKeyDown=${this.handleMessageInputKeydown} + onKeyUp=${this.handleMessageInputKeyup} + onBlur=${this.handleMessageInputBlur} + onPaste=${this.handlePaste} + /> +
+
+ + + + + + + ${inputCharsLeft} bytes +
+
+ `; + } +} diff --git a/webroot/js/components/chat/chat-message-view.js b/webroot/js/components/chat/chat-message-view.js new file mode 100644 index 0000000..1ff813c --- /dev/null +++ b/webroot/js/components/chat/chat-message-view.js @@ -0,0 +1,212 @@ +import { h, Component } from '/js/web_modules/preact.js'; +import htm from '/js/web_modules/htm.js'; +import Mark from '/js/web_modules/markjs/dist/mark.es6.min.js'; +const html = htm.bind(h); + +import { + messageBubbleColorForHue, + textColorForHue, +} from '../../utils/user-colors.js'; +import { convertToText, checkIsModerator } from '../../utils/chat.js'; +import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js'; +import { getDiffInDaysFromNow } from '../../utils/helpers.js'; +import ModeratorActions from './moderator-actions.js'; + +export default class ChatMessageView extends Component { + constructor(props) { + super(props); + this.state = { + formattedMessage: '', + moderatorMenuOpen: false, + }; + } + + shouldComponentUpdate(nextProps, nextState) { + const { formattedMessage } = this.state; + const { formattedMessage: nextFormattedMessage } = nextState; + + return ( + formattedMessage !== nextFormattedMessage || + (!this.props.isModerator && nextProps.isModerator) + ); + } + + async componentDidMount() { + const { message, username } = this.props; + const { body } = message; + + if (message && username) { + const formattedMessage = await formatMessageText(body, username); + this.setState({ + formattedMessage, + }); + } + } + render() { + const { message, isModerator, accessToken } = this.props; + const { user, timestamp } = message; + + // User is required for this component to render. + if (!user) { + return null; + } + + const { displayName, displayColor, createdAt } = user; + const isAuthorModerator = checkIsModerator(message); + + const isMessageModeratable = + isModerator && message.type === SOCKET_MESSAGE_TYPES.CHAT; + + const { formattedMessage } = this.state; + if (!formattedMessage) { + return null; + } + const formattedTimestamp = `Sent at ${formatTimestamp(timestamp)}`; + const userMetadata = createdAt + ? `${displayName} first joined ${formatTimestamp(createdAt)}` + : null; + + const isSystemMessage = message.type === SOCKET_MESSAGE_TYPES.SYSTEM; + + const authorTextColor = isSystemMessage + ? { color: '#fff' } + : { color: textColorForHue(displayColor) }; + const backgroundStyle = isSystemMessage + ? { backgroundColor: '#667eea' } + : { backgroundColor: messageBubbleColorForHue(displayColor) }; + const messageClassString = isSystemMessage + ? 'message flex flex-row items-start p-4 m-2 rounded-lg shadow-l border-solid border-indigo-700 border-2 border-opacity-60 text-l' + : `message relative flex flex-row items-start p-3 m-3 rounded-lg shadow-s text-sm ${ + isMessageModeratable ? 'moderatable' : '' + }`; + + const messageAuthorFlair = isAuthorModerator + ? html`` + : null; + + return html` +
+
+
+ ${messageAuthorFlair} ${displayName} +
+ ${isMessageModeratable && + html`<${ModeratorActions} + message=${message} + accessToken=${accessToken} + />`} +
+
+
+ `; + } +} + +export async function formatMessageText(message, username) { + let formattedText = getMessageWithEmbeds(message); + formattedText = convertToMarkup(formattedText); + return await highlightUsername(formattedText, username); +} + +function highlightUsername(message, username) { + // https://github.com/julmot/mark.js/issues/115 + const node = document.createElement('span'); + node.innerHTML = message; + return new Promise((res) => { + new Mark(node).mark(username, { + element: 'span', + className: 'highlighted px-1 rounded font-bold bg-orange-500', + separateWordSearch: false, + accuracy: { + value: 'exactly', + limiters: [',', '.', "'", '?', '@'], + }, + done() { + res(node.innerHTML); + }, + }); + }); +} + +function getMessageWithEmbeds(message) { + var embedText = ''; + // Make a temporary element so we can actually parse the html and pull anchor tags from it. + // This is a better approach than regex. + var container = document.createElement('p'); + container.innerHTML = message; + + var anchors = container.getElementsByTagName('a'); + for (var i = 0; i < anchors.length; i++) { + const url = anchors[i].href; + if (url.indexOf('instagram.com/p/') > -1) { + embedText += getInstagramEmbedFromURL(url); + } + } + + // If this message only consists of a single embeddable link + // then only return the embed and strip the link url from the text. + if ( + embedText !== '' && + anchors.length == 1 && + isMessageJustAnchor(message, anchors[0]) + ) { + return embedText; + } + return message + embedText; +} + +function getInstagramEmbedFromURL(url) { + const urlObject = new URL(url.replace(/\/$/, '')); + urlObject.pathname += '/embed'; + return ``; +} + +function isMessageJustAnchor(message, anchor) { + return stripTags(message) === stripTags(anchor.innerHTML); +} + +function formatTimestamp(sentAt) { + sentAt = new Date(sentAt); + if (isNaN(sentAt)) { + return ''; + } + + let diffInDays = getDiffInDaysFromNow(sentAt); + if (diffInDays >= 1) { + return ( + `at ${sentAt.toLocaleDateString('en-US', { + dateStyle: 'medium', + })} at ` + sentAt.toLocaleTimeString() + ); + } + + return `${sentAt.toLocaleTimeString()}`; +} + +/* + You would call this when receiving a plain text + value back from an API, and before inserting the + text into the `contenteditable` area on a page. +*/ +function convertToMarkup(str = '') { + return convertToText(str).replace(/\n/g, '

'); +} + +function stripTags(str) { + return str.replace(/<\/?[^>]+(>|$)/g, ''); +} diff --git a/webroot/js/components/chat/chat.js b/webroot/js/components/chat/chat.js new file mode 100644 index 0000000..244ed5e --- /dev/null +++ b/webroot/js/components/chat/chat.js @@ -0,0 +1,508 @@ +import { h, Component, createRef } from '/js/web_modules/preact.js'; +import htm from '/js/web_modules/htm.js'; +const html = htm.bind(h); + +import Message from './message.js'; +import ChatInput from './chat-input.js'; +import { CALLBACKS, SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js'; +import { jumpToBottom, debounce } from '../../utils/helpers.js'; +import { + extraUserNamesFromMessageHistory, + checkIsModerator, +} from '../../utils/chat.js'; +import { + URL_CHAT_HISTORY, + MESSAGE_JUMPTOBOTTOM_BUFFER, +} from '../../utils/constants.js'; + +const MAX_RENDER_BACKLOG = 300; + +// Add message types that should be displayed in chat to this array. +const renderableChatStyleMessages = [ + SOCKET_MESSAGE_TYPES.NAME_CHANGE, + SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO, + SOCKET_MESSAGE_TYPES.USER_JOINED, + SOCKET_MESSAGE_TYPES.CHAT_ACTION, + SOCKET_MESSAGE_TYPES.SYSTEM, + SOCKET_MESSAGE_TYPES.CHAT, + SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_FOLLOW, + SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_LIKE, + SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_REPOST, +]; +export default class Chat extends Component { + constructor(props, context) { + super(props, context); + + this.state = { + chatUserNames: [], + // Ordered array of messages sorted by timestamp. + sortedMessages: [], + + newMessagesReceived: false, + webSocketConnected: true, + isModerator: false, + }; + + this.scrollableMessagesContainer = createRef(); + + this.websocket = null; + this.receivedFirstMessages = false; + this.receivedMessageUpdate = false; + this.hasFetchedHistory = false; + + // Unordered dictionary of messages keyed by ID. + this.messages = {}; + + this.windowBlurred = false; + this.numMessagesSinceBlur = 0; + + this.getChatHistory = this.getChatHistory.bind(this); + this.handleNetworkingError = this.handleNetworkingError.bind(this); + this.handleWindowBlur = this.handleWindowBlur.bind(this); + this.handleWindowFocus = this.handleWindowFocus.bind(this); + this.handleWindowResize = debounce(this.handleWindowResize.bind(this), 500); + this.messageListCallback = this.messageListCallback.bind(this); + this.receivedWebsocketMessage = this.receivedWebsocketMessage.bind(this); + this.scrollToBottom = this.scrollToBottom.bind(this); + this.submitChat = this.submitChat.bind(this); + this.websocketConnected = this.websocketConnected.bind(this); + this.websocketDisconnected = this.websocketDisconnected.bind(this); + } + + componentDidMount() { + this.setupWebSocketCallbacks(); + + window.addEventListener('resize', this.handleWindowResize); + + if (!this.props.readonly) { + window.addEventListener('blur', this.handleWindowBlur); + window.addEventListener('focus', this.handleWindowFocus); + } + + this.messageListObserver = new MutationObserver(this.messageListCallback); + this.messageListObserver.observe(this.scrollableMessagesContainer.current, { + childList: true, + }); + } + + shouldComponentUpdate(nextProps, nextState) { + const { username, chatInputEnabled } = this.props; + const { username: nextUserName, chatInputEnabled: nextChatEnabled } = + nextProps; + + const { + webSocketConnected, + chatUserNames, + newMessagesReceived, + sortedMessages, + } = this.state; + + const { + webSocketConnected: nextSocket, + chatUserNames: nextUserNames, + newMessagesReceived: nextMessagesReceived, + } = nextState; + + // If there are an updated number of sorted message then a render pass + // needs to take place to render these new messages. + if ( + Object.keys(sortedMessages).length !== + Object.keys(nextState.sortedMessages).length + ) { + return true; + } + + if (newMessagesReceived) { + return true; + } + + return ( + username !== nextUserName || + chatInputEnabled !== nextChatEnabled || + webSocketConnected !== nextSocket || + chatUserNames.length !== nextUserNames.length || + newMessagesReceived !== nextMessagesReceived + ); + } + + componentDidUpdate(prevProps, prevState) { + const { accessToken } = this.props; + + // Fetch chat history + if (!this.hasFetchedHistory && accessToken) { + this.hasFetchedHistory = true; + this.getChatHistory(accessToken); + } + } + + componentWillUnmount() { + window.removeEventListener('resize', this.handleWindowResize); + if (!this.props.readonly) { + window.removeEventListener('blur', this.handleWindowBlur); + window.removeEventListener('focus', this.handleWindowFocus); + } + this.messageListObserver.disconnect(); + } + + setupWebSocketCallbacks() { + this.websocket = this.props.websocket; + if (this.websocket) { + this.websocket.addListener( + CALLBACKS.RAW_WEBSOCKET_MESSAGE_RECEIVED, + this.receivedWebsocketMessage + ); + this.websocket.addListener( + CALLBACKS.WEBSOCKET_CONNECTED, + this.websocketConnected + ); + this.websocket.addListener( + CALLBACKS.WEBSOCKET_DISCONNECTED, + this.websocketDisconnected + ); + } + } + + // fetch chat history + async getChatHistory(accessToken) { + const { username } = this.props; + try { + const response = await fetch( + URL_CHAT_HISTORY + `?accessToken=${accessToken}` + ); + const data = await response.json(); + + // Backlog of usernames from history + const allChatUserNames = extraUserNamesFromMessageHistory(data); + const chatUserNames = allChatUserNames.filter((name) => name != username); + + this.addNewRenderableMessages(data); + + this.setState((previousState) => { + return { + ...previousState, + chatUserNames, + }; + }); + } catch (error) { + this.handleNetworkingError(`Fetch getChatHistory: ${error}`); + } + + this.scrollToBottom(); + } + + receivedWebsocketMessage(message) { + this.handleMessage(message); + } + + handleNetworkingError(error) { + // todo: something more useful + console.error('chat error', error); + } + + // Give a list of message IDs and the visibility state they should change to. + updateMessagesVisibility(idsToUpdate, visible) { + let messageList = { ...this.messages }; + + // Iterate through each ID and mark the associated ID in our messages + // dictionary with the new visibility. + for (const id of idsToUpdate) { + const message = messageList[id]; + if (message) { + message.visible = visible; + messageList[id] = message; + } + } + + const updatedMessagesList = { + ...this.messages, + ...messageList, + }; + + this.messages = updatedMessagesList; + + this.resortAndRenderMessages(); + } + + handleChangeModeratorStatus(isModerator) { + if (isModerator !== this.state.isModerator) { + this.setState((previousState) => { + return { ...previousState, isModerator: isModerator }; + }); + } + } + + handleWindowFocusNotificationCount(readonly, messageType) { + // if window is blurred and we get a new message, add 1 to title + if ( + !readonly && + messageType === SOCKET_MESSAGE_TYPES.CHAT && + this.windowBlurred + ) { + this.numMessagesSinceBlur += 1; + } + } + + addNewRenderableMessages(messagesArray) { + // Convert the array of chat history messages into an object + // to be merged with the existing chat messages. + const newMessages = messagesArray.reduce( + (o, message) => ({ ...o, [message.id]: message }), + {} + ); + + // Keep our unsorted collection of messages keyed by ID. + const updatedMessagesList = { + ...newMessages, + ...this.messages, + }; + this.messages = updatedMessagesList; + + this.resortAndRenderMessages(); + } + + resortAndRenderMessages() { + // Convert the unordered dictionary of messages to an ordered array. + // NOTE: This sorts the entire collection of messages on every new message + // because the order a message comes in cannot be trusted that it's the order + // it was sent, you need to sort by timestamp. I don't know if there + // is a performance problem waiting to occur here for larger chat feeds. + var sortedMessages = Object.values(this.messages) + // Filter out messages set to not be visible + .filter((message) => message.visible !== false) + .sort((a, b) => { + return Date.parse(a.timestamp) - Date.parse(b.timestamp); + }); + + // Cap this list to 300 items to improve browser performance. + if (sortedMessages.length >= MAX_RENDER_BACKLOG) { + sortedMessages = sortedMessages.slice( + sortedMessages.length - MAX_RENDER_BACKLOG + ); + } + + this.setState((previousState) => { + return { + ...previousState, + newMessagesReceived: true, + sortedMessages, + }; + }); + } + + // handle any incoming message + handleMessage(message) { + const { type: messageType } = message; + const { readonly, username } = this.props; + + // Allow non-user chat messages to be visible by default. + const messageVisible = + message.visible || messageType !== SOCKET_MESSAGE_TYPES.CHAT; + + // Show moderator status + if (messageType === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) { + const modStatusUpdate = checkIsModerator(message); + this.handleChangeModeratorStatus(modStatusUpdate); + } + + // Change the visibility of messages by ID. + if (messageType === SOCKET_MESSAGE_TYPES.VISIBILITY_UPDATE) { + const idsToUpdate = message.ids; + const visible = message.visible; + this.updateMessagesVisibility(idsToUpdate, visible); + } else if ( + renderableChatStyleMessages.includes(messageType) && + messageVisible + ) { + // Add new message to the chat feed. + this.addNewRenderableMessages([message]); + + // Update the usernames list, filtering out our own name. + const updatedAllChatUserNames = this.updateAuthorList(message); + if (updatedAllChatUserNames.length) { + const updatedChatUserNames = updatedAllChatUserNames.filter( + (name) => name != username + ); + this.setState((previousState) => { + return { + ...previousState, + chatUserNames: [...updatedChatUserNames], + }; + }); + } + } + + // Update the window title if needed. + this.handleWindowFocusNotificationCount(readonly, messageType); + } + + websocketConnected() { + this.setState((previousState) => { + return { + ...previousState, + webSocketConnected: true, + }; + }); + } + + websocketDisconnected() { + this.setState((previousState) => { + return { + ...previousState, + webSocketConnected: false, + }; + }); + } + + submitChat(content) { + if (!content) { + return; + } + const message = { + body: content, + type: SOCKET_MESSAGE_TYPES.CHAT, + }; + this.websocket.send(message); + } + + updateAuthorList(message) { + const { type } = message; + let nameList = this.state.chatUserNames; + + if ( + type === SOCKET_MESSAGE_TYPES.CHAT && + !nameList.includes(message.user.displayName) + ) { + nameList.push(message.user.displayName); + return nameList; + } else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) { + const { oldName, user } = message; + const oldNameIndex = nameList.indexOf(oldName); + nameList.splice(oldNameIndex, 1, user.displayName); + return nameList; + } + + return []; + } + + scrollToBottom() { + jumpToBottom(this.scrollableMessagesContainer.current); + } + + checkShouldScroll() { + const { scrollTop, scrollHeight, clientHeight } = + this.scrollableMessagesContainer.current; + const fullyScrolled = scrollHeight - clientHeight; + const shouldScroll = + scrollHeight >= clientHeight && + fullyScrolled - scrollTop < MESSAGE_JUMPTOBOTTOM_BUFFER; + + return shouldScroll; + } + + handleWindowResize() { + this.scrollToBottom(); + } + + handleWindowBlur() { + this.windowBlurred = true; + } + + handleWindowFocus() { + this.windowBlurred = false; + this.numMessagesSinceBlur = 0; + window.document.title = this.props.instanceTitle; + } + + // if the messages list grows in number of child message nodes due to new messages received, scroll to bottom. + messageListCallback(mutations) { + const numMutations = mutations.length; + + if (numMutations) { + const item = mutations[numMutations - 1]; + if (item.type === 'childList' && item.addedNodes.length) { + if (this.state.newMessagesReceived) { + if (!this.receivedFirstMessages) { + this.scrollToBottom(); + this.receivedFirstMessages = true; + } else if (this.checkShouldScroll()) { + this.scrollToBottom(); + } + + this.setState((previousState) => { + return { + ...previousState, + newMessagesReceived: false, + }; + }); + } + } + // update document title if window blurred + if ( + this.numMessagesSinceBlur && + !this.props.readonly && + this.windowBlurred + ) { + this.updateDocumentTitle(); + } + } + } + + updateDocumentTitle() { + const num = + this.numMessagesSinceBlur > 10 ? '10+' : this.numMessagesSinceBlur; + window.document.title = `${num} 💬 :: ${this.props.instanceTitle}`; + } + + render(props, state) { + const { username, readonly, chatInputEnabled, inputMaxBytes, accessToken } = + props; + const { sortedMessages, chatUserNames, webSocketConnected, isModerator } = + state; + + const messageList = sortedMessages.map( + (message) => + html`<${Message} + message=${message} + username=${username} + key=${message.id} + isModerator=${isModerator} + accessToken=${accessToken} + />` + ); + + if (readonly) { + return html` +
+ ${messageList} +
+ `; + } + + return html` +
+
+
+ ${messageList} +
+ <${ChatInput} + chatUserNames=${chatUserNames} + inputEnabled=${webSocketConnected && chatInputEnabled} + handleSendMessage=${this.submitChat} + inputMaxBytes=${inputMaxBytes} + /> +
+
+ `; + } +} diff --git a/webroot/js/components/chat/content-editable.js b/webroot/js/components/chat/content-editable.js new file mode 100644 index 0000000..fa81bea --- /dev/null +++ b/webroot/js/components/chat/content-editable.js @@ -0,0 +1,129 @@ +/* +Since we can't really import react-contenteditable here, I'm borrowing code for this component from here: +github.com/lovasoa/react-contenteditable/ + +and here: +https://stackoverflow.com/questions/22677931/react-js-onchange-event-for-contenteditable/27255103#27255103 + +*/ +import { h, Component, createRef } from '/js/web_modules/preact.js'; + +export function replaceCaret(el) { + // Place the caret at the end of the element + const target = document.createTextNode(''); + el.appendChild(target); + // do not move caret if element was not focused + const isTargetFocused = document.activeElement === el; + if (target !== null && target.nodeValue !== null && isTargetFocused) { + var sel = window.getSelection(); + if (sel !== null) { + var range = document.createRange(); + range.setStart(target, target.nodeValue.length); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } + if (el) el.focus(); + } +} + +function normalizeHtml(str) { + return str && str.replace(/ |\u202F|\u00A0/g, ' '); +} + + + +export default class ContentEditable extends Component { + constructor(props) { + super(props); + + this.el = createRef(); + + this.lastHtml = ''; + + this.emitChange = this.emitChange.bind(this); + this.getDOMElement = this.getDOMElement.bind(this); + } + + shouldComponentUpdate(nextProps) { + const { props } = this; + const el = this.getDOMElement(); + + // We need not rerender if the change of props simply reflects the user's edits. + // Rerendering in this case would make the cursor/caret jump + + // Rerender if there is no element yet... (somehow?) + if (!el) return true; + + // ...or if html really changed... (programmatically, not by user edit) + if ( + normalizeHtml(nextProps.html) !== normalizeHtml(el.innerHTML) + ) { + return true; + } + + // Handle additional properties + return props.disabled !== nextProps.disabled || + props.tagName !== nextProps.tagName || + props.className !== nextProps.className || + props.innerRef !== nextProps.innerRef; + } + + componentDidUpdate() { + const el = this.getDOMElement(); + if (!el) return; + + // Perhaps React (whose VDOM gets outdated because we often prevent + // rerendering) did not update the DOM. So we update it manually now. + if (this.props.html !== el.innerHTML) { + el.innerHTML = this.props.html; + } + this.lastHtml = this.props.html; + replaceCaret(el); + } + + getDOMElement() { + return (this.props.innerRef && typeof this.props.innerRef !== 'function' ? this.props.innerRef : this.el).current; + } + + + emitChange(originalEvt) { + const el = this.getDOMElement(); + if (!el) return; + + const html = el.innerHTML; + if (this.props.onChange && html !== this.lastHtml) { + // Clone event with Object.assign to avoid + // "Cannot assign to read only property 'target' of object" + const evt = Object.assign({}, originalEvt, { + target: { + value: html + } + }); + this.props.onChange(evt); + } + this.lastHtml = html; + } + + render(props) { + const { html, innerRef } = props; + return h( + 'div', + { + ...props, + ref: typeof innerRef === 'function' ? (current) => { + innerRef(current) + this.el.current = current + } : innerRef || this.el, + onInput: this.emitChange, + onFocus: this.props.onFocus || this.emitChange, + onBlur: this.props.onBlur || this.emitChange, + onKeyup: this.props.onKeyUp || this.emitChange, + onKeydown: this.props.onKeyDown || this.emitChange, + contentEditable: !this.props.disabled, + dangerouslySetInnerHTML: { __html: html }, + }, + this.props.children, + ); + } +} diff --git a/webroot/js/components/chat/message.js b/webroot/js/components/chat/message.js new file mode 100644 index 0000000..aa1d11c --- /dev/null +++ b/webroot/js/components/chat/message.js @@ -0,0 +1,140 @@ +import { h } from '/js/web_modules/preact.js'; +import htm from '/js/web_modules/htm.js'; +const html = htm.bind(h); + +import ChatMessageView from './chat-message-view.js'; + +import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js'; +import { checkIsModerator } from '../../utils/chat.js'; + +function SystemMessage(props) { + const { contents } = props; + return html` +
+
+
+ ${contents} +
+
+
+ `; +} + +function SingleFederatedUser(props) { + const { message } = props; + const { type, body, title, image, link } = message; + + let icon = null; + switch (type) { + case SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_FOLLOW: + icon = '/img/follow.svg'; + break; + case SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_LIKE: + icon = '/img/like.svg'; + break; + case SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_REPOST: + icon = '/img/repost.svg'; + break; + default: + break; + } + + return html` + +
+
+ + +
+
+
+ ${title} +
+

+
+
+
+ `; +} + +export default function Message(props) { + const { message } = props; + const { type, oldName, user, body } = message; + if ( + type === SOCKET_MESSAGE_TYPES.CHAT || + type === SOCKET_MESSAGE_TYPES.SYSTEM + ) { + return html`<${ChatMessageView} ...${props} />`; + } else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) { + // User changed their name + const { displayName } = user; + const contents = html` +
+ ${oldName} is now known as ${' '} + ${displayName}. +
+ `; + return html`<${SystemMessage} contents=${contents} />`; + } else if (type === SOCKET_MESSAGE_TYPES.USER_JOINED) { + const { displayName } = user; + const isAuthorModerator = checkIsModerator(message); + const messageAuthorFlair = isAuthorModerator + ? html`` + : null; + const contents = html`
+ ${messageAuthorFlair}${displayName} + ${' '}joined the chat. +
`; + return html`<${SystemMessage} contents=${contents} />`; + } else if (type === SOCKET_MESSAGE_TYPES.CHAT_ACTION) { + const contents = html``; + return html`<${SystemMessage} contents=${contents} />`; + } else if (type === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) { + // moderator message + const isModerator = checkIsModerator(message); + if (isModerator) { + const contents = html`
+ You are now a + moderator. +
`; + return html`<${SystemMessage} contents=${contents} />`; + } + } else if ( + type === SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_FOLLOW || + SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_LIKE || + SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_REPOST + ) { + return html` <${SingleFederatedUser} message=${message} /> `; + } else { + console.log('Unknown message type:', type); + } +} diff --git a/webroot/js/components/chat/moderator-actions.js b/webroot/js/components/chat/moderator-actions.js new file mode 100644 index 0000000..d90939e --- /dev/null +++ b/webroot/js/components/chat/moderator-actions.js @@ -0,0 +1,298 @@ +import { h, Component, createRef } from '/js/web_modules/preact.js'; +import htm from '/js/web_modules/htm.js'; +import { textColorForHue } from '../../utils/user-colors.js'; +import { URL_BAN_USER, URL_HIDE_MESSAGE } from '../../utils/constants.js'; + +const html = htm.bind(h); + +const HIDE_MESSAGE_ICON = `/img/hide-message-grey.svg`; +const HIDE_MESSAGE_ICON_HOVER = '/img/hide-message.svg'; +const BAN_USER_ICON = '/img/ban-user-grey.svg'; +const BAN_USER_ICON_HOVER = '/img/ban-user.svg'; + +export default class ModeratorActions extends Component { + constructor(props) { + super(props); + this.state = { + isMenuOpen: false, + }; + this.handleOpenMenu = this.handleOpenMenu.bind(this); + this.handleCloseMenu = this.handleCloseMenu.bind(this); + } + + handleOpenMenu() { + this.setState({ + isMenuOpen: true, + }); + } + + handleCloseMenu() { + this.setState({ + isMenuOpen: false, + }); + } + + render() { + const { isMenuOpen } = this.state; + const { message, accessToken } = this.props; + const { id } = message; + const { user } = message; + + return html` +
+ + + ${isMenuOpen && + html`<${ModeratorMenu} + message=${message} + onDismiss=${this.handleCloseMenu} + accessToken=${accessToken} + id=${id} + userId=${user.id} + />`} +
+ `; + } +} + +class ModeratorMenu extends Component { + constructor(props) { + super(props); + this.menuNode = createRef(); + + this.state = { + displayMoreInfo: false, + }; + this.handleClickOutside = this.handleClickOutside.bind(this); + this.handleToggleMoreInfo = this.handleToggleMoreInfo.bind(this); + this.handleBanUser = this.handleBanUser.bind(this); + this.handleHideMessage = this.handleHideMessage.bind(this); + } + + componentDidMount() { + document.addEventListener('mousedown', this.handleClickOutside, false); + } + + componentWillUnmount() { + document.removeEventListener('mousedown', this.handleClickOutside, false); + } + + handleClickOutside = (e) => { + if ( + this.menuNode && + !this.menuNode.current.contains(e.target) && + this.props.onDismiss + ) { + this.props.onDismiss(); + } + }; + + handleToggleMoreInfo() { + this.setState({ + displayMoreInfo: !this.state.displayMoreInfo, + }); + } + + async handleHideMessage() { + if (!confirm('Are you sure you want to remove this message from chat?')) { + this.props.onDismiss(); + return; + } + + const { accessToken, id } = this.props; + const url = new URL(location.origin + URL_HIDE_MESSAGE); + url.searchParams.append('accessToken', accessToken); + const hideMessageUrl = url.toString(); + + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ idArray: [id] }), + }; + + try { + await fetch(hideMessageUrl, options); + } catch (e) { + console.error(e); + } + + this.props.onDismiss(); + } + + async handleBanUser() { + if (!confirm('Are you sure you want to remove this user from chat?')) { + this.props.onDismiss(); + return; + } + + const { accessToken, userId } = this.props; + const url = new URL(location.origin + URL_BAN_USER); + url.searchParams.append('accessToken', accessToken); + const hideMessageUrl = url.toString(); + + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ userId: userId }), + }; + + try { + await fetch(hideMessageUrl, options); + } catch (e) { + console.error(e); + } + + this.props.onDismiss(); + } + + render() { + const { message } = this.props; + const { displayMoreInfo } = this.state; + return html` + + `; + } +} + +// 3 dots button +function ModeratorMenuItem({ icon, hoverIcon, label, onClick }) { + return html` + + `; +} + +// more details panel that display message, prev usernames, actions +function ModeratorMoreInfoContainer({ + message, + handleHideMessage, + handleBanUser, +}) { + const { user, timestamp, body } = message; + const { displayName, createdAt, previousNames, displayColor } = user; + const isAuthorModerator = user.scopes && user.scopes.includes('MODERATOR'); + + const authorTextColor = { color: textColorForHue(displayColor) }; + const createDate = new Date(createdAt); + const sentDate = new Date(timestamp); + return html` +
+
+

+ Sent at ${sentDate.toLocaleTimeString()} +

+
+
+
+

Sent by:

+

+ ${displayName} +

+ +

+ First joined: ${createDate.toLocaleString()} +

+ + ${previousNames.length > 1 && + html` +

+ Previously known as: ${' '} + ${previousNames.join(', ')} +

+ `} +
+
+ <${handleHideMessage && ModeratorMenuItem} + icon=${HIDE_MESSAGE_ICON} + hoverIcon=${HIDE_MESSAGE_ICON_HOVER} + label="Hide message" + onClick="${handleHideMessage}" + /> + <${handleBanUser && ModeratorMenuItem} + icon=${BAN_USER_ICON} + hoverIcon=${BAN_USER_ICON_HOVER} + label="Ban user" + onClick="${handleBanUser}" + /> +
+
+ `; +} diff --git a/webroot/js/components/chat/username.js b/webroot/js/components/chat/username.js new file mode 100644 index 0000000..fb4be11 --- /dev/null +++ b/webroot/js/components/chat/username.js @@ -0,0 +1,158 @@ +import { h, Component, createRef } from '/js/web_modules/preact.js'; +import htm from '/js/web_modules/htm.js'; +const html = htm.bind(h); + +import { setLocalStorage } from '../../utils/helpers.js'; +import { + KEY_USERNAME, + KEY_CUSTOM_USERNAME_SET, +} from '../../utils/constants.js'; + +export default class UsernameForm extends Component { + constructor(props, context) { + super(props, context); + + this.state = { + displayForm: false, + isFocused: false, + }; + + this.textInput = createRef(); + + this.handleKeydown = this.handleKeydown.bind(this); + this.handleDisplayForm = this.handleDisplayForm.bind(this); + this.handleHideForm = this.handleHideForm.bind(this); + this.handleUpdateUsername = this.handleUpdateUsername.bind(this); + this.handleFocus = this.handleFocus.bind(this); + this.handleBlur = this.handleBlur.bind(this); + } + + handleDisplayForm() { + const { displayForm: curDisplay } = this.state; + this.setState({ + displayForm: !curDisplay, + }); + } + + handleHideForm() { + this.setState({ + displayForm: false, + }); + } + + handleKeydown(event) { + if (event.keyCode === 13) { + // enter + this.handleUpdateUsername(); + } else if (event.keyCode === 27) { + // esc + this.handleHideForm(); + } + } + + handleUpdateUsername() { + const { username: curName, onUsernameChange } = this.props; + let newName = this.textInput.current.value; + newName = newName.trim(); + if (newName !== '' && newName !== curName) { + setLocalStorage(KEY_USERNAME, newName); + // So we know that the user has set a custom name + setLocalStorage(KEY_CUSTOM_USERNAME_SET, true); + if (onUsernameChange) { + onUsernameChange(newName); + } + this.handleHideForm(); + } + } + + handleFocus() { + const { onFocus } = this.props; + if (onFocus) { + onFocus(); + } + } + + handleBlur() { + const { onBlur } = this.props; + if (onBlur) { + onBlur(); + } + } + + render(props, state) { + const { username, isModerator } = props; + const { displayForm } = state; + + const styles = { + info: { + display: displayForm ? 'none' : 'flex', + }, + form: { + display: displayForm ? 'flex' : 'none', + }, + }; + + const moderatorFlag = html` + + `; + const userIcon = html` + + `; + + return html` +
+
+ ${isModerator ? moderatorFlag : userIcon}${username} +
+ +
+ + + + +
+
+ `; + } +} diff --git a/webroot/js/components/external-action-modal.js b/webroot/js/components/external-action-modal.js new file mode 100644 index 0000000..111ebd8 --- /dev/null +++ b/webroot/js/components/external-action-modal.js @@ -0,0 +1,128 @@ +import { h, Component } from '/js/web_modules/preact.js'; +import htm from '/js/web_modules/htm.js'; +import MicroModal from '/js/web_modules/micromodal/dist/micromodal.min.js'; + +const html = htm.bind(h); + +export default class ExternalActionModal extends Component { + constructor(props) { + super(props); + this.state = { + iframeLoaded: false, + }; + + this.setIframeLoaded = this.setIframeLoaded.bind(this); + } + componentDidMount() { + // initalize and display Micromodal on mount + try { + MicroModal.init({ + awaitCloseAnimation: false, + awaitOpenAnimation: true, // if using css animations to open the modal. This allows it to wait for the animation to finish before focusing on an element inside the modal. + }); + MicroModal.show('external-actions-modal', { + onClose: this.props.onClose, + }); + } catch (e) { + console.error('modal error: ', e); + } + } + + setIframeLoaded() { + this.setState({ + iframeLoaded: true, + }); + } + + render() { + const { action, useIframe = true, customContent = null } = this.props; + const { url, title, description } = action; + const { iframeLoaded } = this.state; + const iframeStyle = iframeLoaded + ? null + : { backgroundImage: 'url(/img/loading.gif)' }; + + return html` +