diff --git a/README.md b/README.md index 73152a4..5250d34 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,9 @@ - [x] WebAudio - [x] Wasm AudioWorklet - [ ] WebGL -- [ ] WebRTC +- [x] WebRTC - [x] QRCode +- [ ] Maps - [x] Testing - [x] Coverage - [x] Unit @@ -78,11 +79,24 @@ - [x] Users - [x] Sessions - [x] Pages - - [x] App + - [x] Chat + - [x] Channels + - [x] Chat + - [x] Messages + - [x] Users - [x] About + - [x] App + - [x] AssemblyScript + - [x] Canvas - [x] Home - [x] OAuthRegister + - [x] QrCode + - [x] WebSockets + - [ ] UI Showcase - [x] Components + - [x] Header + - [x] Toast + - [x] Catch/show errors - [x] Login - [x] Logout - [x] OAuthLogin @@ -94,8 +108,6 @@ - [x] Input - [x] Label - [x] Link - - [x] Toast - - [x] Catch/show errors ## Setup diff --git a/api/chat/actions.ts b/api/chat/actions.ts index cfa26cd..616e7de 100644 --- a/api/chat/actions.ts +++ b/api/chat/actions.ts @@ -1,7 +1,8 @@ +// deno-lint-ignore-file require-await import { UserSession } from '~/api/auth/types.ts' import { bus } from "~/api/chat/bus.ts" import { broadcast, subs } from "~/api/chat/routes.ts" -import { ChatChannel, ChatMessage, ChatMessageType, UiChannel } from "~/api/chat/types.ts" +import { ChatChannel, ChatDirectMessage, ChatDirectMessageType, ChatMessage, ChatMessageType, UiChannel } from "~/api/chat/types.ts" import { createBus } from '~/api/core/create-bus.ts' import { Context } from '~/api/core/router.ts' import { getSession } from '~/api/core/sessions.ts' @@ -139,6 +140,23 @@ export async function sendMessageToChannel(ctx: Context, type: ChatMessageType, return message } +actions.post.sendMessageToUser = sendMessageToUser +export async function sendMessageToUser(ctx: Context, type: ChatDirectMessageType, targetNick: string, text: string = '') { + const { nick } = getSession(ctx) + if (nick === targetNick) return + + const msg: ChatDirectMessage = { + type, + nick, + text + } + + const sub = subs.get(targetNick) + if (sub) { + sub.send(msg) + } +} + actions.post.joinChannel = joinChannel export async function joinChannel(ctx: Context, channel: string) { const session = getSession(ctx) diff --git a/api/chat/types.ts b/api/chat/types.ts index be90582..b859473 100644 --- a/api/chat/types.ts +++ b/api/chat/types.ts @@ -25,3 +25,15 @@ export interface ChatMessage { nick: string text: string } + +export type ChatDirectMessageType = + | 'directMessage' + | 'webrtc:offer' + | 'webrtc:answer' + | 'webrtc:end' + +export interface ChatDirectMessage { + type: ChatDirectMessageType + nick: string + text: string +} diff --git a/api/ws/routes.ts b/api/ws/routes.ts index 5c7dc50..4a15d33 100644 --- a/api/ws/routes.ts +++ b/api/ws/routes.ts @@ -1,5 +1,5 @@ import { createBus } from '~/api/core/create-bus.ts' -import { Router } from '~/api/core/router.ts' +import type { Router } from '~/api/core/router.ts' import { getSession } from '~/api/core/sessions.ts' const clients = new Set() @@ -26,7 +26,7 @@ export function mount(app: Router) { ctx.log('[ws] connecting...', nick) - const { socket: ws, response } = Deno.upgradeWebSocket(ctx.request, { + const { response, socket: ws } = Deno.upgradeWebSocket(ctx.request, { idleTimeout: 0 }) diff --git a/src/comp/Login.tsx b/src/comp/Login.tsx index b117bdc..99b31b4 100644 --- a/src/comp/Login.tsx +++ b/src/comp/Login.tsx @@ -1,8 +1,7 @@ import { Sigui } from 'sigui' import { UserForgot, UserLogin } from '~/api/auth/types.ts' import * as actions from '~/src/rpc/auth.ts' -import { Link } from '~/src/ui/Link.tsx' -import { Fieldset, Input, Label } from '~/src/ui/index.ts' +import { Button, Fieldset, Input, Label, Link } from '~/src/ui/index.ts' import { parseForm } from '~/src/util/parse-form.ts' export function Login() { @@ -61,7 +60,7 @@ export function Login() {
info.mode = 'forgot'}>Forgot password - +
{() => info.error} @@ -89,7 +88,7 @@ export function Login() {
info.mode = 'login'}>Login using password - +
{() => info.error} diff --git a/src/comp/Logout.tsx b/src/comp/Logout.tsx index 6644fff..4c652c6 100644 --- a/src/comp/Logout.tsx +++ b/src/comp/Logout.tsx @@ -1,10 +1,11 @@ import { logout } from '~/src/rpc/auth.ts' import { state } from '~/src/state.ts' +import { Button } from '~/src/ui/index.ts' export function Logout({ then }: { then?: () => void }) { - return + }>Logout } diff --git a/src/comp/OAuthLogin.tsx b/src/comp/OAuthLogin.tsx index e4ed329..2df57ee 100644 --- a/src/comp/OAuthLogin.tsx +++ b/src/comp/OAuthLogin.tsx @@ -1,6 +1,7 @@ import { on } from 'utils' import { whoami } from '~/src/rpc/auth.ts' import { state } from '~/src/state.ts' +import { Button } from '~/src/ui/index.ts' export function OAuthLogin() { function oauthLogin(provider: string) { @@ -30,5 +31,7 @@ export function OAuthLogin() { }, { once: true }) } - return + return } diff --git a/src/comp/Register.tsx b/src/comp/Register.tsx index e742c38..2bbebd2 100644 --- a/src/comp/Register.tsx +++ b/src/comp/Register.tsx @@ -1,7 +1,7 @@ import { Sigui } from 'sigui' import { UserRegister } from '~/api/auth/types.ts' import * as actions from '~/src/rpc/auth.ts' -import { Fieldset, Input, Label } from '~/src/ui/index.ts' +import { Button, Fieldset, Input, Label } from '~/src/ui/index.ts' import { parseForm } from '~/src/util/parse-form.ts' export function Register() { @@ -50,7 +50,7 @@ export function Register() {
- +
{() => info.error} diff --git a/src/pages/App.tsx b/src/pages/App.tsx index 0ae87f7..cd456e7 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -1,4 +1,4 @@ -import { dispose, Sigui } from 'sigui' +import { Sigui } from 'sigui' import { dom } from 'utils' import { CachingRouter } from '~/lib/caching-router.ts' import { Header } from '~/src/comp/Header.tsx' @@ -13,6 +13,7 @@ import { Chat } from '~/src/pages/Chat/Chat.tsx' import { Home } from '~/src/pages/Home.tsx' import { OAuthRegister } from '~/src/pages/OAuthRegister.tsx' import { QrCode } from '~/src/pages/QrCode.tsx' +import { UiShowcase } from '~/src/pages/UiShowcase.tsx' import { WebSockets } from '~/src/pages/WebSockets.tsx' import { whoami } from '~/src/rpc/auth.ts' import { state } from '~/src/state.ts' @@ -31,6 +32,7 @@ export function App() { const router = CachingRouter({ '/': () => , + '/ui': () => , '/chat': () => , '!/ws': () => , '!/canvas': () => , diff --git a/src/pages/AssemblyScript.tsx b/src/pages/AssemblyScript.tsx index 23b4d50..754ee4b 100644 --- a/src/pages/AssemblyScript.tsx +++ b/src/pages/AssemblyScript.tsx @@ -2,6 +2,7 @@ import { Sigui } from 'sigui' import { Player } from '~/src/as/pkg/player.ts' import { PkgService } from '~/src/as/pkg/service.ts' import pkg from '~/src/as/pkg/wasm.ts' +import { Button } from '~/src/ui/index.ts' let audioContext: AudioContext @@ -34,7 +35,7 @@ export function AssemblyScript() {
Worker: {() => info.fromWorker}
- - + + } diff --git a/src/pages/Chat/Channels.tsx b/src/pages/Chat/Channels.tsx index d9c2790..14c3c18 100644 --- a/src/pages/Chat/Channels.tsx +++ b/src/pages/Chat/Channels.tsx @@ -7,6 +7,7 @@ import { icon } from '~/lib/icon.ts' import * as actions from '~/src/rpc/chat.ts' import { state } from '~/src/state.ts' import { byName, hasChannel } from './util.ts' +import { H3 } from '~/src/ui/Heading.tsx' export function Channels({ overlay = true }: { overlay?: boolean }) { using $ = Sigui() @@ -14,7 +15,7 @@ export function Channels({ overlay = true }: { overlay?: boolean }) { "w-[30%] max-w-56 flex flex-col gap-2 pt-1.5 pb-2.5 pr-4 mr-4 flex-shrink-0 border-r border-r-neutral-700", { 'absolute bg-neutral-900 h-[calc(100vh-4.5rem)]': overlay }, )}> -

+

Channels -

+
{() => state.channelsList.map(channel => { diff --git a/src/pages/Chat/Chat.tsx b/src/pages/Chat/Chat.tsx index c56368d..f9d26aa 100644 --- a/src/pages/Chat/Chat.tsx +++ b/src/pages/Chat/Chat.tsx @@ -1,20 +1,32 @@ -import { Sigui } from 'sigui' -import type { ChatMessage } from '~/api/chat/types.ts' +import { dispose, Sigui } from 'sigui' +import type { ChatDirectMessage, ChatMessage } from '~/api/chat/types.ts' import { Channels } from '~/src/pages/Chat/Channels.tsx' import { Messages } from '~/src/pages/Chat/Messages.tsx' import { Users } from '~/src/pages/Chat/Users.tsx' import { byName, byNick, hasChannel } from '~/src/pages/Chat/util.ts' +import { VideoCall } from '~/src/pages/Chat/VideoCall.tsx' import * as actions from '~/src/rpc/chat.ts' import { screen } from '~/src/screen.ts' import { state } from '~/src/state.ts' import { go } from '~/src/ui/Link.tsx' +export interface RemoteSdp { + type: 'webrtc:offer' | 'webrtc:answer' + nick: string + text: string +} + export function Chat() { using $ = Sigui() const info = $({ started: null as null | true, + showChannelsOverlay: false, + + videoCallType: null as null | 'offer' | 'answer', + videoCallTargetNick: null as null | string, + remoteSdp: null as null | RemoteSdp, }) actions.listChannels().then(channels => { @@ -31,7 +43,7 @@ export function Chat() { }) chat.onmessage = ({ data }) => { - const msg = JSON.parse(data) as ChatMessage + const msg = JSON.parse(data) as ChatMessage | ChatDirectMessage switch (msg.type) { case 'started': @@ -59,6 +71,29 @@ export function Chat() { if (channel.users.find(u => u.nick === msg.nick)) return channel.users = [...channel.users, { nick: msg.nick }].sort(byNick) } + break + } + + case 'directMessage': { + alert(msg.text) + break + } + + case 'webrtc:offer': { + info.remoteSdp = msg as RemoteSdp + info.videoCallType = 'answer' + info.videoCallTargetNick = msg.nick + break + } + + case 'webrtc:answer': { + info.remoteSdp = msg as RemoteSdp + break + } + + case 'webrtc:end': { + info.videoCallTargetNick = null + break } } } @@ -68,6 +103,14 @@ export function Chat() { } }) + $.fx(() => { + const { videoCallTargetNick } = $.of(info) + $() + return () => { + actions.sendMessageToUser('webrtc:end', videoCallTargetNick) + } + }) + $.fx(() => { const { started } = $.of(info) const { channelsList, currentChannelName } = state @@ -112,6 +155,19 @@ export function Chat() { :
} - {() => screen.md ? :
} + {() => screen.md ? { + info.videoCallType = 'offer' + info.videoCallTargetNick = nick + }} /> :
} + + {() => dispose() && info.videoCallType && info.videoCallTargetNick + ? + + : +
+ }
} diff --git a/src/pages/Chat/Messages.tsx b/src/pages/Chat/Messages.tsx index 168cb22..627038f 100644 --- a/src/pages/Chat/Messages.tsx +++ b/src/pages/Chat/Messages.tsx @@ -7,6 +7,7 @@ import { colorizeNick } from '~/src/pages/Chat/util.ts' import * as actions from '~/src/rpc/chat.ts' import { screen } from '~/src/screen.ts' import { state } from '~/src/state.ts' +import { H3 } from '~/src/ui/Heading.tsx' export function Messages({ showChannelsOverlay }: { showChannelsOverlay: Signal }) { using $ = Sigui() @@ -57,7 +58,7 @@ export function Messages({ showChannelsOverlay }: { showChannelsOverlay: Signal< 'w-full pt-1.5 pb-2.5 flex flex-col max-h-[calc(100vh-4rem)]', { 'w-[70%]': screen.md }, )} onclick={() => info.showChannelsOverlay = false}> -

+

{() => screen.sm ? @@ -77,7 +78,7 @@ export function Messages({ showChannelsOverlay }: { showChannelsOverlay: Signal< -

+
diff --git a/src/pages/Chat/Users.tsx b/src/pages/Chat/Users.tsx index f761be2..b2afeb7 100644 --- a/src/pages/Chat/Users.tsx +++ b/src/pages/Chat/Users.tsx @@ -1,14 +1,32 @@ +import { refs, Sigui } from 'sigui' import { colorizeNick } from '~/src/pages/Chat/util.ts' +import * as actions from '~/src/rpc/chat.ts' import { state } from '~/src/state.ts' +import { H3 } from '~/src/ui/Heading.tsx' +import { Link } from '~/src/ui/Link.tsx' + +export function Users({ onUserClick }: { + onUserClick(nick: string): void +}) { + if (!state.user) return
+ + const { nick } = state.user -export function Users() { return
-

Users

+

Users

{() => state.currentChannel?.users.map(user =>
- {user.nick} + {nick !== user.nick ? + onUserClick(user.nick)} + > + {user.nick} + + : {user.nick} + }
)}
diff --git a/src/pages/Chat/VideoCall.tsx b/src/pages/Chat/VideoCall.tsx new file mode 100644 index 0000000..4763565 --- /dev/null +++ b/src/pages/Chat/VideoCall.tsx @@ -0,0 +1,156 @@ +import { Mic, MicOff, PhoneOff, Video, VideoOff } from 'lucide' +import { Sigui, refs, type Signal } from 'sigui' +import { icon } from '~/lib/icon.ts' +import type { RemoteSdp } from '~/src/pages/Chat/Chat.tsx' +import * as actions from '~/src/rpc/chat.ts' + +const pcConfig = { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, + { urls: 'stun:stun2.l.google.com:19302' }, + ], +} + +function VideoButton(props: Record) { + return +} + +export function VideoCall({ type, targetNick, remoteSdp }: { + type: Signal, + targetNick: Signal, + remoteSdp: Signal +}) { + using $ = Sigui() + + const info = $({ + type, + targetNick, + remoteSdp, + + localVideoStream: null as null | MediaStream, + remoteVideoStream: null as null | MediaStream, + pc: null as null | RTCPeerConnection, + + cameraOn: true, + micOn: true, + }) + + const el =
+
+
+
+ + const localVideo = refs.localVideo as HTMLVideoElement + + async function startLocalVideo() { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + width: { exact: 100 }, + height: { exact: 50 }, + }, + audio: true, + }) + info.localVideoStream = stream + await localVideo.play() + } + + startLocalVideo() + + function createPc(stream: MediaStream) { + const pc = new RTCPeerConnection(pcConfig) + pc.addEventListener('track', (ev) => { + if (ev.track.kind === 'video') { + info.remoteVideoStream = ev.streams[0] + } + }) + for (const track of stream.getTracks()) { + pc.addTrack(track, stream) + } + return pc + } + + $.fx(() => { + const { type, localVideoStream: stream, targetNick } = $.of(info) + $() + if (type === 'offer') { + const pc = info.pc = createPc(stream) + pc.createOffer() + .then(offer => { + pc.setLocalDescription(offer) + actions.sendMessageToUser('webrtc:offer', targetNick, offer.sdp) + }) + } + }) + + $.fx(() => { + const { type, localVideoStream: stream, targetNick, remoteSdp } = $.of(info) + $() + if (type === 'answer') { + const pc = createPc(stream) + const theirOffer = new RTCSessionDescription({ + type: 'offer', + sdp: remoteSdp.text, + }) + pc.setRemoteDescription(theirOffer) + pc.createAnswer() + .then(answer => { + pc.setLocalDescription(answer) + pc.addEventListener('icecandidate', (ev) => { + if (ev.candidate !== null) return + actions.sendMessageToUser('webrtc:answer', targetNick, pc.localDescription!.sdp) + }) + }) + } + else if (type === 'offer') { + const theirAnswer = new RTCSessionDescription({ + type: 'answer', + sdp: remoteSdp.text, + }) + info.pc?.setRemoteDescription(theirAnswer) + } + }) + + $.fx(() => () => { + info.localVideoStream?.getTracks().forEach(track => track.stop()) + info.remoteVideoStream?.getTracks().forEach(track => track.stop()) + info.pc?.close() + info.remoteSdp = + info.localVideoStream = + info.remoteVideoStream = + info.pc = + null + }) + + return el +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 911a8f8..d1571fb 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -22,6 +22,7 @@ export function Home() { :
{state.user.isAdmin && Admin} + UI Showcase Chat WebSockets Canvas diff --git a/src/pages/UiShowcase.tsx b/src/pages/UiShowcase.tsx new file mode 100644 index 0000000..ea4700d --- /dev/null +++ b/src/pages/UiShowcase.tsx @@ -0,0 +1,43 @@ +import { Button, Fieldset, H1, H2, H3, Input, Label, Link } from '~/src/ui/index.ts' + +function UiGroup({ name, children }: { name: string, children?: any }) { + return
+

{name}

+ {children} +
+} + +export function UiShowcase() { + return ( +
+ +

UI Showcase

+ + +

Heading 1

+

Heading 2

+

Heading 3

+
+ + + + + + +
+ + +
+
+ + + + + + + About + + +
+ ) +} diff --git a/src/rpc/chat.ts b/src/rpc/chat.ts index 9659d97..530f0a3 100644 --- a/src/rpc/chat.ts +++ b/src/rpc/chat.ts @@ -7,3 +7,4 @@ export const deleteChannel = rpc('POST', 'deleteCh export const getChannel = rpc('GET', 'getChannel') export const joinChannel = rpc('POST', 'joinChannel') export const sendMessageToChannel = rpc('POST', 'sendMessageToChannel') +export const sendMessageToUser = rpc('POST', 'sendMessageToUser') diff --git a/src/ui/Button.tsx b/src/ui/Button.tsx new file mode 100644 index 0000000..e29a0be --- /dev/null +++ b/src/ui/Button.tsx @@ -0,0 +1,8 @@ +export function Button(props: Record) { + return +} diff --git a/src/ui/Heading.tsx b/src/ui/Heading.tsx new file mode 100644 index 0000000..15d996c --- /dev/null +++ b/src/ui/Heading.tsx @@ -0,0 +1,17 @@ +export function H1({ children }: { children?: any }) { + return

+ {children} +

+} + +export function H2({ children }: { children?: any }) { + return

+ {children} +

+} + +export function H3({ children }: { children?: any }) { + return

+ {children} +

+} diff --git a/src/ui/Input.tsx b/src/ui/Input.tsx index 2323d70..94ddcdb 100644 --- a/src/ui/Input.tsx +++ b/src/ui/Input.tsx @@ -1,6 +1,6 @@ export function Input(props: Record) { return } diff --git a/src/ui/Link.tsx b/src/ui/Link.tsx index 7a36cc3..fd013ad 100644 --- a/src/ui/Link.tsx +++ b/src/ui/Link.tsx @@ -16,13 +16,15 @@ export function go(href: string) { export function Link({ href = '#', onclick = go, + style, children }: { href?: string | (() => string), onclick?: (href: string) => unknown, + style?: any, children?: any }) { - return { + return { ev.preventDefault() onclick(typeof href === 'function' ? href() : href) }}>{children} diff --git a/src/ui/index.ts b/src/ui/index.ts index a7473d4..e0c92c8 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -1,4 +1,6 @@ +export * from './Button.tsx' export * from './Fieldset.tsx' +export * from './Heading.tsx' export * from './Input.tsx' export * from './Label.tsx' export * from './Link.tsx'